1. 从坐标点到矩形一个看似简单的类设计练习藏着多少坑C创建Point类表示坐标点点构线线构矩形——这个标题看起来像是课本上的一道课后习题但我跟你讲真动手写起来能踩的坑远比想象中多。很多人第一反应是这有什么难的三个类一嵌套不就完了结果写着写着发现点怎么存线怎么由点构成矩形又怎么由线构成拷贝构造要不要写const成员函数漏了怎么办运算符重载要不要加内存到底该用值语义还是指针语义我见过太多初学者在这个练习上翻车也见过工作几年的开发者回头写这类代码时依然犯迷糊。原因很简单这个题目表面考的是类与对象的语法实际上考的是组合关系建模、值语义与引用语义的取舍、const正确性、以及接口设计的边界感。这几个东西任何一个没想清楚代码就会变成一坨能跑但经不起推敲的东西。这篇内容我会带你从零把这个练习做扎实。不管你是刚学完C类的基础语法想找个练手项目还是已经工作但想回头把面向对象设计的基本功补一补都能从里面拿到可以直接抄作业的代码和思路。我会先讲清楚为什么这样设计再给完整实现最后把那些只有真正写过才知道的坑一个个挖出来。全程用从业者之间聊天的口吻不整那些虚的。关键词方面围绕这个标题核心涉及的是C类设计、Point类、组合关系、值语义、const正确性、拷贝构造、运算符重载、矩形建模。这些词我会在正文里自然带出来不堆砌。2. 为什么点构线、线构矩形这个组合关系值得认真对待2.1 组合不是简单的成员变量堆叠很多人写这个练习时第一版代码大概长这样Point类里放两个doubleLine类里放两个PointRectangle类里放两个Line。语法上没错能编译能跑。但问题在于这样写出来的东西组合关系是死的——你没法回答这条线的长度是多少这个矩形的面积是多少两个矩形是否相交这类问题因为你的类只是数据的容器没有任何行为。面向对象设计的核心不是把数据装进类里而是让数据和行为绑定在一起形成一个有内聚性的整体。Point应该知道怎么计算到另一个点的距离Line应该知道自己的长度Rectangle应该知道自己的面积和周长。这些行为放在对应的类里调用方就不需要自己去拆解内部结构算来算去接口自然就干净了。我个人的经验是判断一个类设计得好不好就看调用方需不需要访问它的内部成员来完成任务。如果需要说明这个类的职责划分有问题。在这个练习里如果调用方要算矩形面积还得自己去拿两条线的端点坐标那Rectangle这个类基本就白设计了。2.2 值语义还是引用语义这是第一个分水岭Point、Line、Rectangle这三个东西本质上都是值类型——它们描述的是几何对象本身不是某个资源的句柄。这意味着它们应该采用值语义拷贝一个Point得到的是一个独立的新Point改新的不影响旧的。这一点非常关键因为它直接决定了成员变量该怎么声明。如果你用指针来存成员比如Line里放Point* p1; Point* p2;那你就得自己管理内存写拷贝构造、拷贝赋值、析构函数还得处理浅拷贝导致的悬空指针问题。对于这种纯数据的小对象完全没必要。直接用值成员Point p1; Point p2;编译器自动生成的拷贝构造和赋值就是正确的深拷贝省心又安全。提示判断该用值语义还是引用语义一个简单的标准是——这个对象复制一份在语义上是否合理。坐标点复制一份当然合理所以用值语义。如果是文件句柄、网络连接这类东西复制就没意义了那才考虑引用语义。2.3 三个类的依赖方向决定了代码的可维护性Point、Line、Rectangle之间存在明确的依赖链Rectangle依赖LineLine依赖PointPoint不依赖任何东西。这个方向不能反。如果你让Point去引用Line那就形成了循环依赖编译都过不去逻辑上也说不通——一个点凭什么知道自己属于哪条线这个依赖方向带来的好处是Point是最稳定的基础类改动它影响面最大Rectangle是最上层的组合类改动它影响面最小。实际开发中我们应该尽量让改动发生在依赖链的上层底层类一旦定下来就少动。这也是为什么Point的接口要设计得特别谨慎——它被所有上层类依赖一个接口设计失误会传导到整个体系。3. Point类的接口设计哪些该有哪些坚决不加3.1 构造函数与默认值的取舍Point类最基础的实现就是两个坐标成员加一个构造函数。但这里有个细节值得琢磨要不要提供默认构造函数class Point { public: Point() : x_(0.0), y_(0.0) {} Point(double x, double y) : x_(x), y_(y) {} private: double x_; double y_; };我倾向于提供默认构造把点初始化到原点。原因是Line和Rectangle在构造时如果用户没给完整参数我们需要一个合法的空状态。如果Point没有默认构造Line的成员就没法默认初始化会带来一堆麻烦。当然如果你追求更严格的语义也可以不提供默认构造强制用户必须给坐标——但这会让上层类的实现变复杂。这是个权衡没有绝对对错。坐标类型用double而不是int是因为几何计算里经常出现小数坐标用int会丢精度。如果你确定场景里只有整数坐标用int也行但double更通用代价只是多占几个字节。3.2 访问器返回引用还是返回值坐标的getter和setter怎么写也有讲究。double x() const { return x_; } double y() const { return y_; } void setX(double x) { x_ x; } void setY(double y) { y_ y; }getter返回double值而不是const引用因为double是内置类型返回值拷贝成本极低返回引用反而多一层间接。setter直接改成员简单直接。这里要特别强调const正确性。所有不修改对象状态的成员函数都必须加const。这不是可选项是必须做的。为什么因为Line和Rectangle里会持有Point成员当Line的某个const成员函数需要读取Point坐标时如果Point的getter没有const就调不了。const正确性做不好上层类的const接口就全废了。我见过太多代码底层类的getter忘了加const导致上层所有const函数都得去掉const最后整个代码库的const正确性全面崩塌。这个坑一定要在写Point的时候就堵上。3.3 距离计算一个方法暴露的设计意图给Point加一个计算到另一点距离的方法是这个练习里第一个有实际意义的行为。double distanceTo(const Point other) const { double dx x_ - other.x_; double dy y_ - other.y_; return std::sqrt(dx * dx dy * dy); }注意参数用const引用避免拷贝函数本身加const因为它不修改自身状态。这个方法的实现里用到了std::sqrt需要包含cmath头文件。为什么这个方法值得加因为它体现了Point的职责——点应该知道怎么算距离。如果把这个计算放到Line里那Line就得访问两个Point的坐标破坏了封装。放在Point里Line只需要调用p1_.distanceTo(p2_)就能拿到长度干净利落。3.4 运算符重载加还是不加加哪些Point要不要重载运算符我的建议是加相等比较和输出流其他先不加。bool operator(const Point other) const { return std::abs(x_ - other.x_) 1e-9 std::abs(y_ - other.y_) 1e-9; } friend std::ostream operator(std::ostream os, const Point p) { os ( p.x_ , p.y_ ); return os; }相等比较用了一个极小值1e-9做容差而不是直接。这是浮点数比较的铁律——浮点运算有精度误差直接比较两个double是否相等几乎永远为false。这个细节很多人不知道写出来的相等判断永远返回false调试半天找不到原因。输出流重载成友元函数方便调试时直接std::cout point。这个不是必须的但加上之后调试体验好很多。至于加法、减法这些除非你的场景真的需要向量运算否则别加。接口要克制加得越多维护成本越高出错概率越大。这是我从实际项目里总结出来的教训——一个类暴露的接口越少它就越容易被正确使用。4. Line类从两个点到一个有向线段4.1 成员设计为什么是两个Point值成员Line类的成员就是两个Pointclass Line { public: Line() default; Line(const Point start, const Point end) : start_(start), end_(end) {} private: Point start_; Point end_; };用值成员而不是指针前面已经解释过原因。这里补充一点值成员让Line的拷贝构造、拷贝赋值、析构全部由编译器自动生成而且都是正确的。你不需要写任何一行内存管理代码这就是值语义的威力。有人可能会问如果两个点重合这条线还有意义吗从几何角度退化成一个点的线确实没意义。但要不要在构造函数里检查并抛异常我的建议是不检查。原因有二一是检查会带来运行时开销二是退化线在某些算法里是合法的中间状态。把校验的责任交给调用方或者提供一个isValid()方法让调用方自己判断比在构造函数里抛异常更灵活。4.2 长度计算复用Point的能力Line的长度计算直接复用Point的距离方法double length() const { return start_.distanceTo(end_); }这就是组合关系的好处——Line不需要知道距离怎么算它只需要知道Point能算距离这件事。如果哪天距离算法要改比如改成曼哈顿距离只需要改Point一个地方Line自动跟着变。这种变化传播的最小化是良好设计的标志。4.3 中点与方向按需扩展如果场景需要可以给Line加中点计算Point midpoint() const { return Point((start_.x() end_.x()) / 2.0, (start_.y() end_.y()) / 2.0); }注意这里用Point的公开接口x()和y()来构造新点而不是去访问start_的私有成员——虽然Line作为Point的使用者理论上可以通过友元访问但没必要。通过公开接口组合比通过友元穿透封装更健康。友元应该只在极少数确实需要访问私有数据的场景下使用比如运算符重载。方向向量也可以加返回一个Point表示从起点到终点的向量。但这里有个语义问题Point表示的是位置而向量表示的是位移两者数学上都是二元组但语义不同。严格来说应该单独定义一个Vector类。不过在这个练习的规模下复用Point表示向量是可以接受的简化只要在注释里说清楚就行。5. Rectangle类两条线如何定义一个矩形5.1 用哪两条线相邻边还是对角线这是设计Rectangle时第一个要拍板的问题。用两条相邻边比如底边和左边可以定义矩形用对角线也可以定义矩形。两种方案各有优劣。用相邻边的话Rectangle需要存两条共享一个端点的线。但这样会有一个隐患两条线共享的那个端点在内存里存了两份如果其中一份被修改矩形就破了——两条线不再共享端点形状就变了。用对角线的话Rectangle存两条对角线四个顶点由对角线端点推导。但这样计算顶点坐标会麻烦一些。我倾向于用相邻边因为更直观而且实际使用中修改矩形顶点的需求很少。如果确实需要保证一致性可以在Rectangle里不存Line而是直接存四个PointLine只在需要时临时构造。但这样就偏离了线构矩形的题目要求。折中方案是存两条相邻边但把修改接口设计得谨慎一些或者干脆不提供修改单条边的接口只提供整体平移、缩放这类操作。接口设计要防止用户做出破坏对象不变量的操作这是封装的核心价值。5.2 从两条边推导四个顶点假设Rectangle存的是底边bottom和左边left它们共享左下角顶点。那么四个顶点是左下角bottom的起点也是left的起点右下角bottom的终点左上角left的终点右上角需要计算——从右下角出发沿left的方向走left的长度class Rectangle { public: Rectangle(const Line bottom, const Line left) : bottom_(bottom), left_(left) {} double width() const { return bottom_.length(); } double height() const { return left_.length(); } double area() const { return width() * height(); } double perimeter() const { return 2.0 * (width() height()); } private: Line bottom_; Line left_; };这里有个隐含假设bottom和left是垂直的。如果不垂直这个矩形其实是平行四边形面积公式就不对了。要不要在构造时校验垂直同样我倾向于不校验但提供一个isRectangle()方法让调用方判断。校验垂直需要计算点积有开销而且浮点误差会让垂直的判断本身就不精确。5.3 面积与周长行为内聚的体现面积和周长的计算放在Rectangle里而不是让调用方自己算这就是行为内聚。调用方只需要rect.area()不需要知道矩形是怎么由线构成的。如果哪天Rectangle的内部表示从两条边改成四个点只要area()的语义不变调用方代码一行都不用改。这种接口与实现分离的能力是面向对象设计最大的价值之一。很多初学者写代码时只关注能不能跑不关注改了内部实现会不会影响调用方结果代码越写越僵化改一处崩一片。6. 完整实现与编译验证6.1 三个类的完整代码把前面的设计整合起来完整实现如下#include cmath #include iostream class Point { public: Point() : x_(0.0), y_(0.0) {} Point(double x, double y) : x_(x), y_(y) {} double x() const { return x_; } double y() const { return y_; } void setX(double x) { x_ x; } void setY(double y) { y_ y; } double distanceTo(const Point other) const { double dx x_ - other.x_; double dy y_ - other.y_; return std::sqrt(dx * dx dy * dy); } bool operator(const Point other) const { return std::abs(x_ - other.x_) 1e-9 std::abs(y_ - other.y_) 1e-9; } friend std::ostream operator(std::ostream os, const Point p) { os ( p.x_ , p.y_ ); return os; } private: double x_; double y_; }; class Line { public: Line() default; Line(const Point start, const Point end) : start_(start), end_(end) {} const Point start() const { return start_; } const Point end() const { return end_; } double length() const { return start_.distanceTo(end_); } Point midpoint() const { return Point((start_.x() end_.x()) / 2.0, (start_.y() end_.y()) / 2.0); } friend std::ostream operator(std::ostream os, const Line l) { os [ l.start_ - l.end_ ]; return os; } private: Point start_; Point end_; }; class Rectangle { public: Rectangle(const Line bottom, const Line left) : bottom_(bottom), left_(left) {} double width() const { return bottom_.length(); } double height() const { return left_.length(); } double area() const { return width() * height(); } double perimeter() const { return 2.0 * (width() height()); } const Line bottom() const { return bottom_; } const Line left() const { return left_; } friend std::ostream operator(std::ostream os, const Rectangle r) { os Rectangle{width r.width() , height r.height() , area r.area() }; return os; } private: Line bottom_; Line left_; };6.2 一个能跑通的测试用例int main() { Point p1(0.0, 0.0); Point p2(4.0, 0.0); Point p3(0.0, 3.0); Line bottom(p1, p2); Line left(p1, p3); Rectangle rect(bottom, left); std::cout Point p1: p1 \n; std::cout Line bottom: bottom \n; std::cout Line length: bottom.length() \n; std::cout Rectangle: rect \n; return 0; }预期输出底边长度4左边长度3矩形面积12周长14。这个用例覆盖了三个类的构造、行为调用和输出能跑通说明基本实现没问题。6.3 编译时容易遇到的报错第一次编译这份代码最常见的报错是std::sqrt找不到原因是忘了#include cmath。另一个常见报错是std::abs对double的重载找不到同样需要cmath。还有人会忘记iostream导致std::ostream未定义。如果用的是某些编译器std::abs对浮点的重载可能不在cmath里而在cstdlib里这时候用std::fabs更保险。我一般直接用std::fabs避免平台差异。7. 那些只有真正写过才知道的坑7.1 浮点数相等比较的陷阱前面提过Point的operator必须用容差比较。我见过有人写return x_ other.x_ y_ other.y_;然后测试时发现两个看起来一样的点比较结果却是false。原因是浮点运算的舍入误差——比如0.1 0.2不等于0.3而是0.30000000000000004。这个坑在几何计算里特别常见因为坐标经常经过加减乘除运算。容差取多少合适1e-9是个常用值适用于大多数场景。如果你的坐标范围很大比如以米为单位的地图坐标可能需要放宽到1e-6。容差的选择取决于你的精度需求没有万能值。7.2 const成员函数漏加导致的连锁反应这个坑我在前面强调过这里再具体说一个场景。假设Point的x()忘了加constdouble x() { return x_; } // 忘了const那么Line的length() const里调用start_.distanceTo(end_)时distanceTo是const的但它内部调用x()——而x()不是const在const上下文里调不了。编译器会报错你被迫把distanceTo的const去掉然后length()的const也得去掉最后Rectangle的所有const函数全废。一个const漏加污染整条依赖链。所以写Point的时候getter一定要加const这是底线。7.3 值成员的拷贝开销小对象无所谓大对象要警惕Point只有两个double拷贝成本极低值语义完全没问题。但如果你的类成员很大比如包含一个几百字节的数组值语义的拷贝开销就不可忽视了。这时候要考虑用引用语义或者移动语义。这个练习里三个类都很小值语义是最优解。但你要有这个意识值语义不是万能的它适用于小对象。判断标准是拷贝成本相对于间接访问成本哪个更低。小对象拷贝几个字节比解引用指针还快大对象拷贝就得不偿失了。7.4 矩形不变量被破坏的隐患前面提到Rectangle存两条相邻边隐含假设它们垂直且共享端点。如果有人拿到bottom的引用后修改了它的端点矩形就不成立了。怎么防方案一不暴露可修改的引用只暴露const引用。调用方拿到的是只读视图改不了。方案二不提供修改单条边的接口只提供整体操作平移、缩放。方案三在每次访问时校验不变量不满足就抛异常或返回错误码。我一般用方案一加方案二。封装的目的就是让用户无法做出破坏对象状态的操作如果封装了还能被破坏说明封装没做到位。7.5 头文件与实现分离的时机这个练习规模小全写在头文件里没问题。但如果类变复杂就应该把声明放头文件、实现放源文件。判断标准是如果修改实现会导致大量文件重新编译就该分离。小项目里全内联反而编译更快因为省去了链接开销。我见过有人在小练习里也严格分离头文件和源文件结果为了一个三行的函数建两个文件维护成本反而高。工程实践要讲性价比不要为了规范而规范。8. 从练习到实战这个模式还能怎么扩展8.1 加一个Shape基类做多态如果场景里有多种形状矩形、圆、三角形可以抽一个Shape基类定义虚函数area()和perimeter()让各形状继承实现。这样就能用基类指针统一处理不同形状实现多态。class Shape { public: virtual ~Shape() default; virtual double area() const 0; virtual double perimeter() const 0; };Rectangle继承Shape实现这两个纯虚函数。注意基类析构函数必须是虚的否则通过基类指针删除派生类对象时行为未定义。这个坑很经典面试也常考。8.2 用模板支持不同数值类型如果想让Point支持int、float、double多种坐标类型可以把Point做成模板template typename T class PointT { T x_, y_; // ... };但模板会带来代码膨胀和编译时间增加而且浮点容差比较在模板里不好统一处理。模板是双刃剑用之前想清楚是否真的需要。如果只是double和float两种用模板还行如果还要支持自定义数值类型就得考虑概念约束了。8.3 几何运算的进一步封装实际项目里几何对象往往需要相交判断、包含判断、距离计算等操作。这些操作可以做成自由函数也可以做成成员函数。我的经验是如果操作涉及两个对等的对象比如两矩形相交做成自由函数如果操作只涉及自身比如算面积做成成员函数。这样接口的归属更自然。相交判断涉及不少计算比如分离轴定理实现起来有一定复杂度。这个练习里不需要但你要知道这个方向可以继续深入。8.4 序列化与反序列化如果几何数据需要存盘或网络传输就得考虑序列化。简单的做法是提供to_string()和from_string()复杂的可以用JSON库。序列化格式一旦定下来就要保持兼容所以设计时要留扩展余地比如加版本号字段。这块内容展开又是一大篇这里点到为止。核心思想是类的设计要考虑它的生命周期——从创建、使用到持久化每个环节都有设计决策。9. 我在实际写这类代码时的几点体会写了这么多年C关于这种小类组合的练习我最大的体会是简单的东西写扎实比复杂的东西写花哨更有价值。Point、Line、Rectangle这三个类任何一个有经验的C开发者都能在半小时内写出来但写出来的质量差异巨大。差异就体现在const正确性、值语义选择、接口克制程度、浮点处理这些细节上。另一个体会是不要过早引入复杂性。我见过有人在这个练习里上来就用智能指针、用模板、用继承结果代码量翻了三倍可读性反而下降。正确的做法是先写出最简单能用的版本等需求真的来了再重构。YAGNI原则You Arent Gonna Need It在这里特别适用。最后一个实用建议写完这类代码后自己写几个边界测试。比如零长度的线、坐标是负数的点、面积为零的矩形。这些边界情况往往能暴露设计缺陷。我习惯在写完一个类后立刻写几个测试用例跑一遍比事后调试省事得多。如果你正在学C这个练习值得反复写几遍每次都会有新收获。第一遍关注语法第二遍关注设计第三遍关注边界和异常。三遍下来你对类设计的理解会上一个台阶。