1. 先拆需求:两个坐标类到底要干哪些活
说实话,这种题目我这些年见过太多次了,自己在项目里也写过无数个"点"类。别小看这个需求,它看起来就两句话——二维 MyPoint 类,有 x、y 坐标,有 get 函数,再写个计算两点距离的函数;然后再做一个三维的 ThreeDPoint 类——但真正落到代码上,你得做出一堆选择。坐标用什么类型存?要不要有默认构造函数?get 函数返回什么?距离函数写成成员函数好还是静态函数好?二维和三维之间是继承还是各写各的?这些问题在简历项目、课后作业、面试手写题里反复出现,考察的其实不是你记了多少语法,而是你有没有"类设计"的意识。
1.1 一句需求背后藏着多少设计点
需求正文虽然短,但拆开来看,至少涉及七个决策点:
- 坐标类型选 int、float 还是 double。
- 类需不需要默认构造函数,还是必须带 x、y 参数才能创建对象。
- get 函数是只读返回,还是要允许外部直接拿到内部引用。
- 距离计算是成员函数、静态函数还是全局函数。
- 三维点和二维点之间要不要建立继承关系。
- x、y 的访问控制是 public、private 还是 protected。
- 距离计算的结果精度怎么保证,返回值到底是 double 还是 float。
这些点单独拎出来都不难,但合在一起就能分出代码水平。有的同学会直接把 x、y 设成 public,main 函数里p.x = 3用得很爽,等以后对象一多、模块一复杂,这种裸奔式的写法会让人头痛欲裂。public 成员意味着任何一处代码都能改坐标值,你想排查"这个点什么时候被改掉的"都无从下手。private 配合 get 函数,才能保证只有你能控制 x、y 的读写方式。
1.2 用什么语言实现最合适
这个题目本身不绑定语言。C++、Java、Python、C# 都能做。我的个人建议是:如果你是为了学面向对象思想,用 C++ 最合适。C++ 逼着你把 public、private、const、传值传引用这些都写清楚,写不清楚就编译不过,天然帮你养成好习惯。Java 也不错,getter/setter 的语法约定几乎就来自 Java 社区。Python 写起来最短,但 Python 里没有强制的私有性,大家默认靠约定,初学者很容易写出"所有属性全裸奔"的类,反而学不到封装的精髓。
这篇内容我以 C++17 为主线来讲,不只是因为它是作业的常见语言,更因为它最能暴露设计问题。如果你用 Python 或 Java,看思路就好:构造、get 函数、距离函数这些核心组件完全一致,只是语法换个壳。
2. 二维 MyPoint 类:从设计到实现
2.1 成员变量、构造函数、get 函数
坐标类型直接选 double。两个理由:第一,真实场景里坐标几乎都是小数,地图经纬度、屏幕坐标、仿真位置,基本没有纯整数;第二,double 比 float 精度高,在算距离时平方项会累加误差,用 double 更稳。如果题目强行要求 int,那也行,但我建议构造函数里直接接收 double,距离计算时转成浮点,免得出现整数除法截断这种暗坑。
成员变量命名我习惯用x_和y_,尾随下划线。这样做的好处是能和构造函数形参x、y区分开。如果不加后缀,就得写this->x = x,每次都要带 this,啰嗦且容易漏。用_x前缀、m_x前缀其实都可以,关键是和项目规范保持一致,别今天x_明天_x。
构造函数我建议这样写:
MyPoint(double x = 0.0, double y = 0.0) : x_(x), y_(y) {}这一行代码同时解决了三件事。第一,允许MyPoint p;构造一个默认坐标(0,0)的对象;第二,允许MyPoint p(3,4);构造指定坐标;第三,给所有成员赋了初值,不会出现"成员变量未初始化"的半成品对象。默认构造能力特别重要,因为 C++ 里很多容器操作要求类型可默认构造,比如std::vector<MyPoint> v(100),它底层要创建 100 个默认对象,你没有默认构造,这段代码直接编译失败。
get 函数就是两个只读访问器:
double getX() const { return x_; } double getY() const { return y_; }const必须加。它的含义是"这个函数承诺不修改对象内部数据"。有了它,const MyPoint对象、const MyPoint&参数才能正常调用 get。实际工程里几乎天天遇到这种场景:拿到一个 const 引用,想读坐标,结果编译报错"不能调用非 const 成员函数",绝大多数就是这里少了 const。
返回值我用按值返回,而不是const double&。对 double 这种基础类型,按值返回和按引用返回在性能上没差别,但安全很多。如果写成double& getX(),调用方就能拿到内部成员引用,直接修改内部值,封装的墙就破了;如果写成const double& getX(),调用方保存这个引用时间过长,遇到对象析构就成了悬空引用。基础类型就老实按值返回。
我特意不写 set 函数。点是几何意义上的对象,一旦创建(3,4)这个点,它的坐标就应该固定。如果允许随手setX(100),调试的时候你根本不知道这个点什么时候被改了。这种设计叫作"不可变对象",在 Java 和 C++ 工程里都很常用。
2.2 距离计算:数学公式和函数签名
二维空间里两点(x1, y1)和(x2, y2)的欧几里得距离,就是勾股定理的坐标系版本:
d = sqrt((x1 - x2)^2 + (y1 - y2)^2)代码写成成员函数:
double distanceTo(const MyPoint& other) const { double dx = x_ - other.x_; double dy = y_ - other.y_; return std::sqrt(dx * dx + dy * dy); }参数用const MyPoint&,不是MyPoint other,也不是MyPoint* other。传值要拷贝整个对象,开销不必要;传指针语义模糊,还得解引用,且指针可能为空;传 const 引用最合适:不拷贝、只读、不修改。有一点很多初学者不知道:同一个类的成员函数可以访问另一个同类型对象的私有成员。所以我直接写了other.x_,不需要绕道other.getX()。C++ 的 private 限制的是"类外部",不是"同类其他对象"。
dx * dx我特意不写成std::pow(dx, 2)。pow 是通用幂函数,内部实现涉及复杂计算,对平方这种整数次幂来说是杀鸡用牛刀,既慢还可能引入额外舍入误差。直接相乘才是最快的写法。等以后你写图像处理、物理引擎,会发现这种细节对性能影响真不小。
函数末尾的const也不能漏。distanceTo不会修改本对象,加上 const 之后,const 对象也能调用它,否则又会碰到上面说的编译问题。
2.3 静态函数 vs 成员函数,到底选哪个
除了a.distanceTo(b)这种写法,你还会在不少代码里看到静态函数版本:
static double distance(const MyPoint& p1, const MyPoint& p2) { double dx = p1.x_ - p2.x_; double dy = p1.y_ - p2.y_; return std::sqrt(dx * dx + dy * dy); }或者全局函数版本:
double distance(const MyPoint& a, const MyPoint& b) { ... }三种写法各自有适用场景。成员函数a.distanceTo(b)读起来最自然,就像在问这个点"你离那个点有多远",适合作为这个业务类的核心API。静态/全局函数更像一个独立算法工具,适合在泛型编程、策略模式里使用,因为它不依赖具体对象,可以像普通函数指针一样传来传去。
我的建议:这道题选成员函数就行。需求原文里写的是"用于返回两个点之间的距离的函数",没有指明是静态,那按最符合直觉的成员函数来设计不会错。等以后你写更复杂的几何库,再考虑把距离算法抽象出来,提供多种距离度量方式。
3. 三维 ThreeDPoint 类:继承、组合还是独立写
3.1 三种设计方案的取舍
三维点无非是比二维多了个 z 坐标,难点在于两个类之间的关系怎么处理。这里有三条路。
方案一:独立实现。ThreeDPoint 自己写一套 x、y、z 成员和 get、distanceTo。优点是没有耦合,改二维不影响三维,改三维不影响二维;缺点是 x、y 相关的代码写了两遍,万一以后想在 getX 里加日志或校验,得记得改两处。
方案二:继承 MyPoint。ThreeDPoint 作为 MyPoint 的子类,复用 x、y、getX、getY,自己补 z、getZ,并重写距离计算。语义上说得通:三维点确实"是"一个二维点再额外扩展一维。优点是不重复代码,结构清晰;缺点是继承会带来隐藏、多态、构造顺序这些概念,如果不理解,反而容易写出 bug。
方案三:组合。让 ThreeDPoint 内部持有一个 MyPoint 对象和 z 成员。组合是复杂工程里更保守的复用方式,灵活、耦合低,但在这种简单题目里会显得别扭——想拿 x 坐标得写xy_.getX(),读起来绕。
我个人的倾向:学习阶段用继承,因为它能帮你把"is-a"关系、构造函数链、函数隐藏这些概念串起来;真实项目里,除非确实有强复用需求,否则我偏向独立实现,减少继承带来的隐式依赖。
3.2 ThreeDPoint 的完整实现
继承版本的完整代码:
class ThreeDPoint : public MyPoint { private: double z_; public: ThreeDPoint(double x = 0.0, double y = 0.0, double z = 0.0) : MyPoint(x, y), z_(z) {} double getZ() const { return z_; } double distanceTo(const ThreeDPoint& other) const { double dx = getX() - other.getX(); double dy = getY() - other.getY(); double dz = z_ - other.z_; return std::sqrt(dx * dx + dy * dy + dz * dz); } };构造函数初始化列表: MyPoint(x, y), z_(z)这个顺序有讲究。C++ 的规则是:先初始化基类,再初始化派生类成员,顺序由声明决定,不按你写的顺序来。你把z_(z)写在前面也不会先初始化它,有些编译器会为此报警告。理解这个顺序能避免不少奇怪的初始化问题。
由于 MyPoint 的x_、y_是 private,ThreeDPoint 的函数里不能直接访问x_,只能通过getX()。如果你真的想直接访问,可以把基类成员改成 protected,但我一般不推荐。protected 的封装性弱于 private,它的效果是"对所有子类公开",一旦子类数量增多、层级加深,字段名改动会波及一大批子类。能用 get 方法解决的事,就不值得去破坏封装。
注意这里的distanceTo(const ThreeDPoint&)和基类的distanceTo(const MyPoint&)是"同名不同参",这叫隐藏,不是真正的多态覆盖。这个写法完全够用,因为调用p3d.distanceTo(q3d)时编译器会找到三维版本,算出三维距离。真正的多态需要基类函数声明为virtual,然后通过基类指针或引用调用。在这个题目里不需要,我也不建议为了炫技去引入虚函数开销。
如果你不想用继承,独立实现版本同样简洁:
class ThreeDPoint { private: double x_, y_, z_; public: ThreeDPoint(double x = 0.0, double y = 0.0, double z = 0.0) : x_(x), y_(y), z_(z) {} double getX() const { return x_; } double getY() const { return y_; } double getZ() const { return z_; } double distanceTo(const ThreeDPoint& other) const { double dx = x_ - other.x_; double dy = y_ - other.y_; double dz = z_ - other.z_; return std::sqrt(dx * dx + dy * dy + dz * dz); } };这个版本一眼望去反而更清爽,没有继承带来的隐藏问题。这也再次说明:技术选型没有绝对标准,关键是你能说清楚自己为什么这么选。作业里我建议用继承版,因为它能展示你对类关系的理解;真实项目里我会倾向独立实现,直到确认真有公共逻辑需要复用。
3.3 三维距离公式的验证
三维欧几里得距离就是二维的扩展:
d = sqrt((x1 - x2)^2 + (y1 - y2)^2 + (z1 - z2)^2)理解方法很简单:把两个点想成长方体的两个对角顶点,三组坐标差分别是三条互相垂直的边长,空间距离就是体对角线。这个公式其实就是勾股定理在三维空间的推广。
我建议在写代码前先在纸上算三组数据:
(0,0,0)到(1,2,2),距离 = sqrt(1+4+4) = 3。(0,0,0)到(0,0,0),距离 = 0。(-1,-1,-1)到(1,1,1),距离 = sqrt(4+4+4) ≈ 3.4641。
然后拿程序输出和这三组数对比。这一步能高效区分"公式写错了"还是"代码写错了"。手算数据这件事容易被忽视,但却是调试最有效的手段之一。
4. 实操中一定要避开的几个坑
4.1 const 正确性和返回值选择的坑
第一个坑:get 函数不加 const。你写了double getX() { return x_; },表面没问题,但一旦在 const 对象或 const 引用上调用,编译直接报错。这个报错信息往往指向调用点而不是定义点,新手会一直盯着调用处看半天,其实根因在类定义。我的习惯是:所有只读函数先写const,再想别的。
第二个坑:返回值类型乱选。返回double&会暴露内部成员,让调用方直接改掉私有数据,封装失效;返回const double&虽然只读,但调用方如果保存了这个引用,对象析构后就悬空了。对 double 这种基础类型,按值返回效率不差、安全性最高,别想着省那一次拷贝。
4.2 浮点数比较别用 ==
距离函数返回的是 double,它来源于平方和再开根号,天然存在舍入误差。你写if (c.distanceTo(d) == 3.0),表面看公式没错,但运行时结果可能有亿点点偏差,判断就失败。正确做法是比较两个数的差的绝对值是否小于一个阈值:
bool isEqual = std::abs(real - expected) < 1e-9;这个经验适用于所有浮点运算。项目里因为浮点比较导致的偶发 bug 我见过太多,坐标判断、物理碰撞、价格计算,凡是涉及浮点的都中招。统一用"误差范围"判断,能省掉大量莫名其妙的排查时间。
4.3 命名、头文件、命名空间这些细节
头文件记得#include <cmath>,否则std::sqrt不一定能编译。有些编译器可能碰巧能过,但那是运气。我还会顺手#include <iostream>用于调试输出。
命名要贴合题目本身。类名MyPoint、ThreeDPoint,方法名getX、getY、getZ、distanceTo。代码首先是给人读的,然后才是给机器执行。你写p.distanceTo(q),任何接手的人一眼就懂;写成a.b(c),没人能快速理解。
如果这是项目的一部分,我还会把类放进命名空间,比如namespace geometry { ... }。命名空间几乎零成本,却能让你的点类不污染全局,也不会和别人写的同名类产生冲突。这种习惯越早养成越好。
4.4 不要把默认构造省略
你可能会想,坐标类必须传入 x、y 才能创建,不然算什么点。但 C++ 生态里许多容器和框架要求类型具备默认构造能力。std::vector<MyPoint> v(10)这种常见写法没有默认构造就直接编译失败。所以构造函数里给默认值不是偷懒,是工程需要。同理,ThreeDPoint 也要支持默认构造出(0,0,0)。初始化的坐标有明确含义:原点。这样对象任何时刻都有一个可预期的状态。
5. 测试程序怎么写才能证明类是对的
5.1 要覆盖哪些场景
很多同学测一组数据,输出"好像对"就结束了。实践经验告诉我,至少要覆盖六类场景:
- 两个不同点的距离是正数。
- 同一个点和自己的距离是 0。
- 对称性:
a.distanceTo(b)应该等于b.distanceTo(a)。 - 勾股数点对:
(0,0)到(3,4)精确等于 5。 - 负数坐标:比如
(-1,-1)到(2,3),手算验证。 - 默认构造:两个默认点距离为 0,坐标值确实是 0 而不是垃圾值。
这里我特别推荐"对称性"测试。很多人写距离函数时不注意方向,比如只算了other - this,因为平方项会吞掉符号,结果恰好没出错。但一旦以后改成非对称的量,这种错误就会立刻暴露。测试的意义就在于提前发现这种隐患。
5.2 一段能直接运行的主函数代码
我把两个类和测试代码合并到一起,你可以直接复制运行:
#include <cmath> #include <iostream> class MyPoint { private: double x_; double y_; public: MyPoint(double x = 0.0, double y = 0.0) : x_(x), y_(y) {} double getX() const { return x_; } double getY() const { return y_; } double distanceTo(const MyPoint& other) const { double dx = x_ - other.x_; double dy = y_ - other.y_; return std::sqrt(dx * dx + dy * dy); } }; class ThreeDPoint : public MyPoint { private: double z_; public: ThreeDPoint(double x = 0.0, double y = 0.0, double z = 0.0) : MyPoint(x, y), z_(z) {} double getZ() const { return z_; } double distanceTo(const ThreeDPoint& other) const { double dx = getX() - other.getX(); double dy = getY() - other.getY(); double dz = z_ - other.z_; return std::sqrt(dx * dx + dy * dy + dz * dz); } }; int main() { MyPoint a(0.0, 0.0); MyPoint b(3.0, 4.0); std::cout << "2D a-b: " << a.distanceTo(b) << "\n"; std::cout << "2D b-a: " << b.distanceTo(a) << "\n"; std::cout << "2D a-a: " << a.distanceTo(a) << "\n"; ThreeDPoint c(0.0, 0.0, 0.0); ThreeDPoint d(1.0, 2.0, 2.0); std::cout << "3D c-d: " << c.distanceTo(d) << "\n"; std::cout << "3D c-c: " << c.distanceTo(c) << "\n"; double expected3D = 3.0; double real3D = c.distanceTo(d); if (std::abs(real3D - expected3D) < 1e-9) std::cout << "3D test passed.\n"; else std::cout << "3D test failed.\n"; return 0; }在任意 C++17 环境下编译运行,预期输出:
2D a-b: 5 2D b-a: 5 2D a-a: 0 3D c-d: 3 3D c-c: 0 3D test passed.如果某个输出不对,按这个顺序排查:先看 dx、dy、dz 是否算对;再看是不是少了 z 轴平方项;最后看继承版本里是不是调到了基类的二维距离。这三步基本能解决绝大多数问题。
6. 再往前一步:这个题目还能怎么扩展
6.1 用模板类把维度参数化
二维、三维的距离公式本质上完全一样:对所有维度差值平方求和再开根号。既然算法一样,C++ 模板可以把维度变成参数,写一个类通吃所有维度:
#include <cmath> #include <cstddef> template <std::size_t Dim> class Point { private: double coords_[Dim] = {}; public: Point() = default; template <typename... Args> Point(Args... args) : coords_{static_cast<double>(args)...} {} double get(std::size_t index) const { return coords_[index]; } double distanceTo(const Point& other) const { double sum = 0.0; for (std::size_t i = 0; i < Dim; ++i) { double diff = coords_[i] - other.coords_[i]; sum += diff * diff; } return std::sqrt(sum); } };这样Point<2>是二维点,Point<3>是三维点,想算一百维距离也行。模板类的优势是算法只写一次,编译器替你生成不同维度的版本。缺点是编译报错信息对新手不友好,模板展开后的错误能看晕人。所以这道题我建议先掌握继承版和独立版,能做出来之后再考虑模板版当加分项,展示出来的效果会很亮眼。
6.2 运算符重载让操作更自然
坐标点做运算时,运算符重载能大幅提升可读性:
class MyPoint { public: MyPoint operator-(const MyPoint& other) const { return MyPoint(x_ - other.x_, y_ - other.y_); } };这样a - b会返回一个新的点,表示从 b 到 a 的坐标差向量。再配一个友元输出函数:
friend std::ostream& operator<<(std::ostream& os, const MyPoint& p) { os << "(" << p.x_ << ", " << p.y_ << ")"; return os; }然后就可以直接std::cout << p输出(3, 4),调试时不要太方便。重载运算符的原则是"语义要清晰"。给点重载+、-表示坐标偏移很自然;但如果你去重载*表达什么"点的面积",读者就会满头问号。重载是让别人更好读你的代码,不是炫技。
6.3 一道作业题和真实项目的距离
最后说点题外的个人体会。这个坐标类题目看起来小,但它背后藏着值对象设计、不可变状态、构造函数默认值、const 正确性、浮点比较、继承与组合权衡、最小测试集这一整套真实开发技能。为什么那么多公司喜欢在面试里考这类题?因为它能快速看出一个人是不是真的理解类设计,而不是只会写业务代码。
我写项目代码有个习惯:先写最简单的版本,跑通核心公式,打印中间变量确认结果;确认无误后,再考虑加 const、抽公共基类、上模板。很多人一上来就继承、模板、友元一把梭,函数写成三层嵌套,结果公式还没验证,找 bug 找到怀疑人生。基础没打好,谈架构就是空中楼阁。
另外,如果你拿到这道题,我强烈建议你顺手在多语言里各写一遍:Python 一行构造、Java 传统 getter、C++ 按引用传参。多对比几次,你会对"面向对象"这几个字有完全不同的领悟。坐标类看似基础,把它吃透了,后面学图形学、物理引擎、数据可视化,都会顺畅很多。