Java Circle2D类设计:面向对象封装与几何计算实战解析 如果你正在学 Java 的面向对象大概率绕不开这个题定义 Circle2D 类。它不是什么高深算法题通常出现在教材讲完类、对象、封装之后的第一批综合练习里表面上是让你写一个圆——字段有圆心坐标 x、y 和半径 radius方法算面积、算周长、判断点和圆的位置关系。但真正把它写明白你会发现这个题把字段设计、构造器重载、只读封装、方法重载、浮点精度、数学函数选型这些零碎知识点全串在了一起。这个类具体能做什么呢用一句话概括把二维几何里的“圆”变成程序里的一个一等公民对象。你可以 new 一个圆出来问它的面积、周长问某个点是否落在它里面问另一个圆是被它包住还是和它相交。放在实际场景里游戏碰撞检测、地图上的圆形范围查询、CAD 软件的图形拾取底层都会用到这套东西。所以别看这是个“课后题”它背后的思想是能直接迁移到真实项目里的。这篇文章适合两类人。第一类是刚学完 Java 基础、第一次接触自定义类的初学者可以照着把代码写通顺带弄明白每个方法为什么这么写第二类是写过一遍但没细想过边界条件和性能取舍的人可以在里面补充一些教材不太会写的细节。我会把几何公式、工程取舍、常见坑和完整测试都过一遍最后再聊几句从这道题延伸出去的设计思考。1. 先搞清楚这个题到底在考什么很多初学者拿到题目就直接去敲代码结果写完能跑就算过关其实错过了这道题真正的价值。先花两分钟把需求拆一遍你会发现它考的不是数学而是面向对象的基本功。1.1 需求拆解一个圆需要哪些数据和行为一道标准的 Circle2D 定义题需求是这样的数据圆心的横坐标 x、纵坐标 y、半径 radius。构造一个无参构造器生成默认圆圆心 (0,0)半径 1一个带参构造器接收 x、y、radius 三个参数。读取为每个字段提供 getX、getY、getRadius 方法。计算getArea() 返回面积getPerimeter() 返回周长。判断contains(double x, double y) 判断一个点是否在圆内contains(Circle2D circle) 判断另一个圆是否完全被自己包含overlaps(Circle2D circle) 判断两个圆是否有重叠。注意一个容易忽略的细节题目只给了 get 方法没有给 set 方法。也就是说圆一旦创建出来圆心和半径就不能改了。这不是设计上的疏忽而是一个有意的选择。不可变对象在并发编程、缓存共享场景里非常有价值因为对象不会被意外修改你可以在多个地方安全地共享同一个引用。String 类就是这么设计的Circle2D 天然适合走这条路。1.2 为什么坐标和半径必须用 double这是我见过最常见的翻车点。有些同学觉得坐标用 int 就行反正整数坐标看起来更清爽。但仔细想圆的面积是 πr²周长是 2πr不管半径是不是整数计算结果都几乎必然是小数。用 int 去接这些值直接就截断成整数了精度丢得莫名其妙。更直接的原因是 contains(double x, double y) 这个方法。如果坐标是 int你想判断点 (0.5, 0.5) 是否在圆内根本连参数都传不进去。几何世界里坐标是连续量输入可能是任意实数double 是 Java 里表达连续实数的最基本选择。当然 double 也不是绝对精确后面我还会专门讲它带来的坑但在这个场景里double 就是合适的类型。2. 字段与构造器把封装这件事想明白这一节看起来简单其实就是两道题的核心考点字段要怎么声明对象要怎么初始化。这里面有一连串“为什么”值得掰开讲。2.1 私有字段加只读访问器这个设计是故意的所有字段都声明为 private这是封装的第一步。封装的核心目的是让类自己掌握数据的合法性。比如一个圆半径小于 0 是没有意义的。如果 radius 是 public 字段别的代码随手就能给 circle.radius 赋个 -5对象就进入了一个非法状态而且你根本拦不住。改成 private 之后外部代码只能通过构造器和 get 方法接触数据。类自己就有了掌控权构造的时候把关半径不合法就拒绝创建读取的时候只读外部想改也改不了。配合上没有 set 方法这个类就成了事实上的不可变类——创建之后内部状态不会再变。这里有个小知识点值得提如果在未来的需求里你得允许用户移动圆或者缩放圆比起直接加 set 方法更好的做法是提供类似 moveTo(double newX, double newY) 和 resize(double factor) 这样的行为方法。为什么因为行为方法可以校验参数、维持不变量而普通的 setter 只是把赋值操作裸露在外面。这个思路在真实项目里非常常见。2.2 构造器重载与 this() 委托无参构造器最省事的写法是这样的public Circle2D() { this(0, 0, 1); }注意这里没有直接写 x 0; y 0; radius 1;而是把 this(0, 0, 1) 放在第一行调用带参构造器。这么做的好处是字段初始化的逻辑只写一遍。以后如果默认圆的参数要改比如默认半径改成 2只需要改带参构造器那一处无参构造器自动跟着变。关于 this() 有个硬性语法规则它必须是构造器里的第一条语句。这是因为 Java 要求先完成对父类的构造、完成当前对象基本状态的建立之后才能执行其他逻辑。如果你在 this() 前面写了别的语句编译器会直接报错。顺带一提this() 和 this.xxx 是两回事前者是构造器之间的调用后者是字段访问初学者经常搞混。2.3 要不要在构造器里校验半径教材给的参考实现通常不做校验直接 this.x x; this.y y; this.radius radius;。但放在工程视角看这是不够的。半径是圆的根本属性它不能是负数甚至严格一点说 0 也需要语义上的确认——一个半径为 0 的圆还能叫圆吗在带参构造器里加一道防线public Circle2D(double x, double y, double radius) { if (radius 0) { throw new IllegalArgumentException(半径不能为负数: radius); } this.x x; this.y y; this.radius radius; }这样构造器就承担了“建立合法对象”的职责。有人会问校验扔在这里合适吗合适。对象构造是数据进入类的第一道门在第一道门拦住非法数据后面所有方法都不用再重复判断 radius 0 了代码反而更简洁。这就是“快速失败”原则的体现。3. 四个方法背后的数学原理与工程取舍构建方法的时候很多人只顾着把公式写上去没想过公式为什么这么写、有没有更优的等价写法。这一节我把每个方法背后的数学条件和工程细节拆开说。3.1 面积与周长Math.PI 和一次乘法就够了面积是 πr²周长是 2πr直接用 Math.PI 这个常量就行。Math.PI 是 Java 里最接近数学常数 π 的 double 值精度大约可以精确表示到小数点后第 16 位左右完全够用。有个细节值得说计算半径的平方我建议写 radius * radius而不是 Math.pow(radius, 2)。Math.pow 是通用幂函数内部要走对数和指数运算比一次乘法慢得多而且对于整数次幂浮点结果偶尔还会有一点舍入差异。我见过真实项目里有人在一个被调用上百万次的几何方法里写 Math.pow(radius, 2)后来改成乘法之后性能立刻就上来了。单次调用看不出差别放循环里差别就明显了。public double getArea() { return Math.PI * radius * radius; } public double getPerimeter() { return 2 * Math.PI * radius; }方法名用 getPerimeter 还是 getCircumference两个都对计算逻辑一样。但教材里通常用 getPerimeter因为把它和“周长”这个概念对应起来更直观。3.2 判断点在圆内距离公式的两派写法数学原理很简单点 P 到圆心 C 的距离 d如果 d 小于等于半径点就在圆内或者圆边界上。第一派是直译数学公式public boolean contains(double x, double y) { double distance Math.sqrt(Math.pow(this.x - x, 2) Math.pow(this.y - y, 2)); return distance radius; }好处是读代码的人能一眼对回数学公式适合初学者理解。坏处是 Math.pow 和 Math.sqrt 都不便宜。对于这个作业题完全没问题但如果这段代码出现在游戏引擎里每帧要做几万次这种判断性能就不够看了。第二派叫平方距离比较public boolean contains(double px, double py) { double dx x - px; double dy y - py; return dx * dx dy * dy radius * radius; }为什么能这么写因为 dx² dy² 和 radius² 都是非负数比较两个非负数的平方大小和比较它们开方后的大小是严格等价的。所以可以省掉一次 Math.sqrt。这在游戏开发的碰撞检测里是最基础的优化手段术语叫 squared distance check。还有一个边界问题用 还是用 按通常的几何惯例点落在圆边上应该也算在圆内所以用 。这个细节在单元测试里很容易被揪出来。3.3 判断圆是否包含另一个圆一条简单但容易写反的不等式判断圆 A 是否完全包含圆 B数学条件是这样的两个圆心的距离 d加上 B 的半径仍然小于等于 A 的半径。public boolean contains(Circle2D circle) { double d distance(x, y, circle.x, circle.y); return d circle.radius radius; }直觉解释从 A 的圆心出发走到 B 的圆心再走一段 B 的半径就到达了 B 圆距离 A 最远的那个点。只要这个“最远点”还在 A 内部那么 B 整圈都在 A 内部。这条不等式容易写反成 radius d circle.radius那就变成“B 包含 A”了。写代码前先在纸上画两个圆标出圆心连线再对照不等式基本就不会错。还要注意这个方法是不对称的A.contains(B) 和 B.contains(A) 绝大多数时候结果不同这一点在做测试时要特意覆盖。3.4 判断圆重叠两个条件缺一不可两个圆的位置关系一共有五种我给你整理成一张表位置关系数学条件overlaps 应返回相离d r1 r2false外切d r1 r2视具体定义相交部分重叠|r1 - r2| d r1 r2true内切d |r1 - r2|视具体定义包含 / 内含d ≤ |r1 - r2|等价于 d r2 ≤ r1false判断是否重叠标准写法是同时满足两个不等式public boolean overlaps(Circle2D circle) { double d distance(x, y, circle.x, circle.y); return d radius circle.radius d Math.abs(radius - circle.radius); }第一个条件 d r1 r2 保证两个圆不是相离或外切。第二个条件 d |r1 - r2| 保证两个圆不是包含或内切。这里最经典的错误是只写第一个条件比如return d radius circle.radius;这么写大圆 (0,0,10) 和小圆 (1,0,1) 会被误判成“重叠”。因为 d 等于 1而 1 1 2确实小于等于 10。但这两个圆的真实关系是包含它们压根没有公共内部区域。所以第二个条件不是可选项是必需品。关于相切要不要算重叠数学上“重叠”通常是指两个圆有公共的内部区域而相切只有公共点不算重叠。所以上面用严格不等号 和 。但说实话这道题在不同题目来源里给的语义不完全一样有的教材用 把相切也算进去。写的时候最好确认清楚题的表述或者按严格的数学定义来再写注释说明你的选择。真实工程里更要按产品需求定比如碰撞检测里“碰上了”通常就是包含相切的 d r1 r2这和数学定义的“重叠”并不一样。4. 完整代码与测试验证有了前面的设计决策代码就可以完整落下来了。我先把经过校验、注释清晰的完整实现贴出来然后给你一套可以直接复现的测试方案。4.1 可以跑起来的实现public class Circle2D { private double x; private double y; private double radius; public Circle2D() { this(0, 0, 1); } public Circle2D(double x, double y, double radius) { if (radius 0) { throw new IllegalArgumentException(半径不能为负数: radius); } this.x x; this.y y; this.radius radius; } public double getX() { return x; } public double getY() { return y; } public double getRadius() { return radius; } public double getArea() { return Math.PI * radius * radius; } public double getPerimeter() { return 2 * Math.PI * radius; } public boolean contains(double px, double py) { double dx x - px; double dy y - py; return dx * dx dy * dy radius * radius; } public boolean contains(Circle2D circle) { double d distance(x, y, circle.x, circle.y); return d circle.radius radius; } public boolean overlaps(Circle2D circle) { double d distance(x, y, circle.x, circle.y); return d radius circle.radius d Math.abs(radius - circle.radius); } private static double distance(double x1, double y1, double x2, double y2) { return Math.hypot(x1 - x2, y1 - y2); } Override public String toString() { return Circle2D[center( x , y ), radius radius ]; } }我用了 Math.hypot 而不是 Math.sqrt(dxdx dydy)原因是 hypot 内部实现会做缩放处理避免中间步骤出现溢出或下溢。举个极端例子两个坐标值都是 1e308 量级时直接算 dx*dx 会溢出成 Infinity而 Math.hypot 能正确返回约 1.414e308 的结果。代价是 hypot 比 sqrt 慢一些但在这个练习里完全值得也帮你养成写数值计算时考虑极端情况的习惯。4.2 测试用例与预期结果写类不写测试等于没写。我按功能点设计了这么一组用例你可以在自己的代码里全部验证一遍用例输入预期结果默认构造new Circle2D()圆心 (0,0)半径 1面积new Circle2D(0, 0, 2)12.566370614359172周长new Circle2D(0, 0, 2)12.566370614359172点在内部默认圆.contains(0, 0)true点在边界默认圆.contains(1, 0)true点在外部默认圆.contains(1.1, 0)false包含小圆(0,0,5).contains(new Circle2D(1, 1, 1))true相交重叠(0,0,2).overlaps(new Circle2D(3, 0, 2))true相离(0,0,2).overlaps(new Circle2D(5, 0, 2))false包含时不重叠(0,0,10).overlaps(new Circle2D(1, 0, 1))false外切不重叠(0,0,2).overlaps(new Circle2D(4, 0, 2))false第二行和第三行结果一样是巧合因为半径 2 的圆的面积和周长数值恰好都是 4π这个数字看着对称容易引起混淆顺手提一下免得你以为代码写错了。4.3 冒烟测试 main 方法如果你想快速验证直接写个 main 方法逐行打印结果public class TestCircle2D { public static void main(String[] args) { Circle2D c1 new Circle2D(); System.out.println(默认圆心: c1.getX() , c1.getY()); System.out.println(默认半径: c1.getRadius()); System.out.println(面积: c1.getArea()); System.out.println(周长: c1.getPerimeter()); System.out.println(包含(0,0): c1.contains(0, 0)); System.out.println(包含(1,0): c1.contains(1, 0)); System.out.println(包含(1.1,0): c1.contains(1.1, 0)); Circle2D big new Circle2D(0, 0, 5); Circle2D small new Circle2D(1, 1, 1); System.out.println(大圆包含小圆: big.contains(small)); Circle2D a new Circle2D(0, 0, 2); Circle2D b new Circle2D(3, 0, 2); System.out.println(相交重叠: a.overlaps(b)); Circle2D c new Circle2D(5, 0, 2); System.out.println(相离: a.overlaps(c)); Circle2D outer new Circle2D(0, 0, 10); Circle2D inner new Circle2D(1, 0, 1); System.out.println(包含但不重叠: outer.overlaps(inner)); } }跑出来的结果应该是true、true、false、true、true、false、false这样几何判断那三个方法的正确性就有了初步的证明。5. 常见问题与避坑手册这个题看着简单但踩坑的人从来不少。我按经验把高频问题分成了三类浮点精度、代码错误、测试盲区。每一条都是我实际见过别人犯过的错。5.1 浮点精度为什么几何判断要留个心眼double 是二进制浮点数无法精确表示所有十进制小数。最经典的例子是 0.1 0.2算出来不是 0.3而是 0.30000000000000004。这不是 Java 的问题是所有二进制浮点数的通病IEEE 754 标准就是这个机制。这个误差对 Circle2D 的几何方法有什么影响最典型的场景是相切判断。理论上两个圆外切时d 应该正好等于 r1 r2。但由于每一步运算都有微小的舍入误差算出来的 d 可能是 3.9999999999999996而不是 4。如果你用精确的相等判断或者用 d r1 r2结果就可能和预期不一样。应对方式有两种。第一种在这个练习的范围内坐标和半径通常都是干净的数误差不会引起实际问题直接比较就行。第二种如果做真实几何计算引入一个很小的误差容忍值 epsilonboolean isTangent Math.abs(d - (radius circle.radius)) 1e-9;把“严格相等”放宽成“足够接近”。我个人建议做开发时提前想好自己领域的精度需求而不是等到线上出现幽灵 bug 再来排查那会儿排查成本就高了。5.2 代码层面的五个高频错误我整理了一张速查表基本覆盖了初学者在写这道题时的典型问题错误类型错误写法示例正确做法形参遮蔽x x;用 this.x x 显式区分字段和参数边界判断反了return distance radius边界点算内部用 重叠少写条件return d r1 r2同时判断 d |r1 - r2|半径未校验直接赋负值构造器里抛 IllegalArgumentException用 int 存坐标private int x, y用 double 保留连续精度形参遮蔽那条值得多说一句构造器写成 x x; 时编译器不会报错但实际效果是给参数自己赋值字段 x 仍然是默认值 0。在带参构造器里创建出来的对象圆心不带参数的值这个问题在调试时很难一眼看出来因为代码完全不报错。所以看到构造器里没有 this. 前缀的赋值先停下来检查一遍。5.3 测试盲区边界、相切、同心和零半径很多人测试时就挑几个常规值跑一下点选在圆正中间圆选一个包含一个然后就没有然后了。等到作业被扣分或者面试被追问才发现以下盲区全没覆盖点正好在圆边界上比如默认圆判断 (1,0)这里最考验用 还是 。两个圆相切不管是外切还是内切都处于“差一点就是重叠”的分界线上。同心圆d 0。包含判断要正常重叠判断要正常返回 false。半径相等的两个圆重叠|r1 - r2| 0只要 d 0 就满足第二个条件。半径为 0 的圆数学上是一个点代码上要能处理别因为 radius 为 0 就出现除零或者奇怪的行为。再补充一个容易被忽略的场景两个完全相等的圆。如果创建一个 (0,0,2) 再创建一个 (0,0,2)contains 会互相为 trueoverlaps 应该是 false因为它其实是“包含”而不是部分重叠。这个用例能帮你确认第二个条件确实在起作用。6. 从作业到实战Circle2D 还能往哪走写完了也别急着把这个类丢进回收站。它的价值不在于通过考试而在于它是一个非常好的跳板往任何方向延伸都是知识量满满的旅程。6.1 圆与圆的碰撞检测圆是最廉价的碰撞体之一游戏里经常用它做粗略碰撞检测。角色身上挂一个圆子弹身上挂一个圆每帧判断两个圆是否相交相交就触发伤害。为什么用圆因为判断两圆是否相交只需要算一次圆心距离比多边形相交快得多。这也是物理引擎里 broad phase第一轮广筛常用的手段先把明显不可能碰撞的物体快速剔除再对剩下少量候选做精确检测。实战里直接套用前面讲的平方距离优化判断 d² r1 r2 的平方省掉 sqrt。几千个物体的场景每帧都要做省下的都是真金白银。6.2 设计上再打磨equals、Comparable、接口抽象代码满足题目要求之后还可以试着往更工程化的方向打磨equals 和 hashCode判断两个圆是否相等比较三个字段是否分别相等。哈希时要对 double 字段调用 Double.hashCode避免出现不可预测的行为。Comparable让 Circle2D 实现 Comparable按半径排序或者按面积排序。这样圆就能放进 TreeSet 或者用 Collections.sort 直接排序。toString我上面已经加了一个打印对象时能看到圆心和半径调试体验完全不同。再往上一层是最有价值的抽象思考如果系统里不只有圆还有矩形、三角形怎么办你会很自然地想到抽一个 Shape 接口里面声明 getArea、getPerimeter、contains然后让 Circle2D、Rectangle2D 各自实现。这就是面向对象里的多态Circle2D 就是你理解多态最好的一课。如果你用的是现代 Java比如 16 以上还可以用 record 来定义数据载体public record Circle2D(double x, double y, double radius) {}编译器自动帮你生成构造器、equals、hashCode、toString。但这个题目的本意是让你亲手把这些部分组装一遍所以先手写一段等理解透了再用 record 偷懒学习顺序别反了。6.3 面试官喜欢追问的几个点这道题的低配版在面试里也经常出现形式可能变成“手写一个圆类”或者“两个圆怎么判断相交”。面试官通常会顺着代码追问这几个点为什么设计成不可变你回答线程安全、不会出现非法状态比直接说“题目没要求 setter”更有说服力。半径传负数怎么办你说构造器里抛 IllegalArgument这就是工程素养。判断点是否在圆里Math.sqrt 和平方比较哪个更好能说出平方比较省掉开方、适合高频调用的基本就能让面试官眼前一亮。圆和圆的相交判断只比 d r1 r2 够吗你答不够包含状态下这个条件也成立这个细节能显著区分你是在背代码还是真理解了。我自己带过不少人写这个题目见过形形色色的实现。坦白讲能把方法都写出来的人挺多的但能把 contains 的边界、overlaps 的双条件、为什么不用 setter 这些细节讲清楚的人少得多。这些细节恰恰才是工作里真正会用的东西。最后分享一个我自己写这类几何类的小习惯写完第一个版本后不急着跑先在纸上画三个圆——一个大圆、一个小圆、一个半重叠的圆把它们的坐标和半径标出来然后手算一遍预期结果再让代码去跑。多花这十分钟能省下后面两小时调试的工夫。这道题不难但它是你打的第一个“类”的地基把每一个为什么都想明白比把它写完要值钱得多。