如果你正在学 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 + r2 | false |
| 外切 | d == r1 + r2 | 视具体定义 |
| 相交(部分重叠) | |r1 - r2| < d < r1 + r2 | true |
| 内切 | d == |r1 - r2| | 视具体定义 |
| 包含 / 内含 | d ≤ |r1 - r2|(等价于 d + r2 ≤ r1) | false |
判断是否重叠,标准写法是同时满足两个不等式:
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,结果就可能和预期不一样。
应对方式有两种。第一种,在这个练习的范围内,坐标和半径通常都是干净的数,误差不会引起实际问题,直接比较就行。第二种,如果做真实几何计算,引入一个很小的误差容忍值 epsilon:
boolean 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 会互相为 true,overlaps 应该是 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 这些细节讲清楚的人,少得多。这些细节恰恰才是工作里真正会用的东西。
最后分享一个我自己写这类几何类的小习惯:写完第一个版本后,不急着跑,先在纸上画三个圆——一个大圆、一个小圆、一个半重叠的圆,把它们的坐标和半径标出来,然后手算一遍预期结果,再让代码去跑。多花这十分钟,能省下后面两小时调试的工夫。这道题不难,但它是你打的第一个“类”的地基,把每一个为什么都想明白,比把它写完要值钱得多。