☰
Java Circle2D类设计:面向对象封装与几何计算实战解析
2026/10/21 18:04:36 网站建设 项目流程

如果你正在学 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 ≤ 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 这些细节讲清楚的人,少得多。这些细节恰恰才是工作里真正会用的东西。

最后分享一个我自己写这类几何类的小习惯:写完第一个版本后,不急着跑,先在纸上画三个圆——一个大圆、一个小圆、一个半重叠的圆,把它们的坐标和半径标出来,然后手算一遍预期结果,再让代码去跑。多花这十分钟,能省下后面两小时调试的工夫。这道题不难,但它是你打的第一个“类”的地基,把每一个为什么都想明白,比把它写完要值钱得多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询