资讯详情

C++第9章习题精讲:从构造函数到深拷贝的类设计改进

📅 2026/10/15 9:34:45 | 华诺云谱 👁 阅读
C++第9章习题精讲:从构造函数到深拷贝的类设计改进
最近一段时间陆续有正在学《C程序设计》第9章的朋友在后台问我关于类和对象的进一步讨论这一章的习题答案到底应该怎么写。我去翻了翻网上流传的答案说实话不少版本只能算“能编译”远远谈不上“能学到东西”。构造函数还在函数体里逐个赋值只读函数不加const回调参数还在用值传递统计成绩甚至习惯用全局变量。这一章恰恰是初学者从“认识类”过渡到“设计类”的分水岭答案里的这些细节才是真正的分水岭。所以我干脆把这一章里几道最有代表性的习题重新写了一遍不是照搬教材配套答案而是按我平时写代码的习惯做了不少改进。每道题都会先讲清楚原版常见的坑再给出改进后的完整代码然后逐条说明为什么这么改。适合正在啃第9章的同学直接抄作业也适合已经学过一遍、想回头看看自己当时哪里写得不对的人。1. 网上流传的答案差在哪以及本文的“改进”标准先说几句实话。教材本身是没有问题的但教材答案的写作风格和现在大家实际写 C 代码的习惯差距确实不小。很多老答案的问题是共性的我梳理了一下基本集中在五类能用但不好改。数据成员全部写在类私有区却习惯在构造函数里a x; b y; c z;而不是用初始化列表。功能是对的但一旦成员变量带了 const 或引用这种写法直接编译失败。只读函数不标 const。什么display()、area()、average()明明不会修改对象却不写const。这在单文件习题里看不出问题一旦你写多文件工程或者给别的函数传const Student就会遇到“无法调用非const成员函数”的编译错误。传参全靠复制。对象当参数时写void fun(Student s)每调用一次就构造一次副本既不高效也容易让人误解“传对象就是传引用”。类的接口设计不够“防呆”。比如三角形三条边给进去不检查能不能构成三角形成绩统计用全局变量 count 和 total既不安全也不便于扩展。内存管理随缘。只要涉及new就一定有对应delete但对象拷贝时是浅拷贝还是深拷贝很多人完全没有意识。所以这篇博文的“改进”标准不是炫技、不是引入一堆 C17/20 的新特性而是把第9章本来该学的知识点用得更扎实初始化列表、const 成员函数、引用传参、静态成员、友元的正确用法、运算符重载的返回类型、以及带动态内存的类必须处理的深拷贝问题。每一道题的改进点都对应一个考试或者面试里真正会被追问的地方。2. 三角形那道构造题从“能算出面积”到“写进工程没问题”第9章前面部分特别喜欢出求面积的题最常见的就是三角形类。题目要求通常是设计一个 Triangle 类私有成员是三条边长有一个求面积的成员函数可以在 main 里创建对象并输出面积。这题的经典之处在于它同时考察了构造函数、成员函数设计、数学公式实现和 const 语义。2.1 原版答案常见写法与问题网上最常见的版本长这样class Triangle { public: Triangle(float x, float y, float z) { a x; b y; c z; } float area() { float p (a b c) / 2; return sqrt(p * (p - a) * (p - b) * (p - c)); } private: float a, b, c; };先不说别的这个area()里出现了一个很经典的问题sqrt函数来自cmath很多老答案为了省事写#include math.h在竞赛环境下能过但在真正的工程里会被广泛建议使用 C 风格的cmath。更重要的是area()没有const它明明只是读取成员变量却向调用者暗示“这个函数可能会改对象”。一旦有人写好const Triangle t(3,4,5);然后调t.area()编译器直接报错。还有三条边全是float。海伦公式里有个p * (p - a) * (p - b) * (p - c)如果边长是带小数的数值float的精度在后续累计运算中容易产生误差。用double会更稳妥。2.2 改进后的完整代码#include iostream #include cmath using namespace std; class Triangle { private: double a, b, c; public: Triangle(double x 3, double y 4, double z 5) : a(x), b(y), c(z) {} bool isValid() const { return a 0 b 0 c 0 a b c a c b b c a; } double area() const { double p (a b c) / 2.0; return sqrt(p * (p - a) * (p - b) * (p - c)); } void display() const { if (!isValid()) { cout 三条边 a , b , c 无法构成三角形 endl; return; } cout 边长为 a , b , c 的三角形面积 area() endl; } }; int main() { Triangle t1; t1.display(); Triangle t2(3, 4, 6); t2.display(); Triangle t3(1, 2, 3); t3.display(); return 0; }2.3 改进点逐条说明第一构造函数用初始化列表。a(x), b(y), c(z)直接在对象构造阶段初始化成员而不是先调用默认构造函数构造出a,b,c再进入函数体赋值。对于double这种内置类型二者性能差异不明显但是这种写法对“成员是被 const 修饰的”、“成员是另一个没有默认构造函数的对象”等情况是唯一选择。第9章后面的对象组合、类成员里嵌套对象时会大量用到现在尽早养成习惯。第二把isValid()、area()、display()全部声明为const。这三个函数都不修改对象状态声明const之后它们就能被 const 对象调用。这算是一种“能力约束”你告诉编译器也告诉其他开发者调用它们不会产生副作用放心用。第三加了合法性校验。三角形的三条边必须为正数且任意两边之和大于第三边。原版答案一般不校验直接海伦公式算遇到1,2,3这种数据会出现p - a为负的情况sqrt对负数求根在 C 里是未定义行为可能在你的编译器上输出nan。加上判断后错误数据不会一路传播到外层这是工程里最基本的一条不要在入口处相信任何输入。第四使用double而非float并且海伦公式里的除法写成/ 2.0而不是/ 2。如果不小心写成(a b c) / 2并且三个数都是整型会发生整数除法截断结果差之毫厘谬以千里。这里虽然后续会自动类型转换但写成2.0可以明确告诉读者“这是一个实数运算”。2.4 运行结果与测试用例我实际编译运行输出如下边长为 3, 4, 5 的三角形面积 6 边长为 3, 4, 6 的三角形面积 5.33268 三条边 1, 2, 3 无法构成三角形第三个用例我是故意加的。设计类的成员函数时要主动考虑错误输入路径而不是默认所有人都传合法数据。这也是“部分有改进”里我觉得最值钱的一个改动。3. 学生成绩统计里的静态成员一句“未解析外部符号”教会的教训第9章里有一类非常经典的练习设计 Student 类保存学号、姓名、成绩统计一共创建了多少个学生对象、所有学生的总成绩是多少最后输出平均分。很多人的第一反应是用全局变量但这种做法破坏了类的封装任何函数都能随意修改 count 和 total不可控。正确思路是使用静态数据成员。3.1 常见实现全局变量 or 静态成员全局变量的写法大概是int count 0; double total 0; class Student { ... };构造函数里count; total s;。这样写程序能跑但 count 和 total 对所有类型都可见如果工程里还有其他类命名空间被污染。更关键的是它没用到第9章真正想让你掌握的静态成员机制。考试和面试都更愿意看到你问一句这两个变量属于类而不属于任何一个对象。3.2 改进版代码#include iostream #include string using namespace std; class Student { private: string name; double score; static int count; static double total; public: Student(const string n, double s) : name(n), score(s) { count; total s; } static double average() { return count 0 ? total / count : 0.0; } static int getCount() { return count; } void display() const { cout name 的成绩为 score endl; } }; int Student::count 0; double Student::total 0.0; int main() { Student s1(A同学, 82.5); Student s2(B同学, 90.0); Student s3(C同学, 76.0); s1.display(); s2.display(); s3.display(); cout 学生人数: Student::getCount() endl; cout 平均成绩: Student::average() endl; return 0; }3.3 静态成员的定义位置、初始化时机和调用方式这里有个新手几乎必踩的坑在类里面写static int count;只是声明不是定义。如果你忘记在类外写int Student::count 0;链接器会报“无法解析的外部符号”。这个错误第一次遇到时会让很多人大惑不解明明类里已经写了为什么还要重新写一遍原因是这样的静态数据成员不属于任何对象不能由构造函数初始化。每个对象构造时执行count但 count 的存储空间必须提前在程序的全局/静态存储区分配好。类定义只是告诉编译器存在这样一个变量真正分配内存的是类外的那句定义语句。这也是为什么我强烈建议把类外定义紧挨着类的代码写而不是散落到别的文件里。3.4 静态成员函数有哪些“看不见的规则”average()和getCount()声明为static它们没有 this 指针因此不能直接访问对象成员。也就是说在静态成员函数里写name或score会直接编译失败。这是第9章最容易考的知识点也是很多同学第一次理解“成员函数到底依附于什么”的关键。另外注意调用方式虽然静态成员函数可以用对象调用比如s1.average()但更规范、更清晰的写法是直接用类名调用Student::average()。这样一眼就能看出它和具体对象无关。main 里我没有对 count 做任何手动赋值每次构造 Student 对象时构造函数自动维护数据一致性天然得到保证。3.5 运行结果A同学 的成绩为 82.5 B同学 的成绩为 90 C同学 的成绩为 76 学生人数: 3 平均成绩: 82.8333如果把这个类拿去做扩展还可以加入排名、修改成绩等方法静态成员依旧是最稳妥的思路。这里我要特别提醒成绩字段使用double平均分计算时total / count的除法自然得到 double不要为了显示整数而把它强转成 int不然 83.9 会变成 83看起来没什么实际丢数据。4. 复数类重载运算符成员函数和友元函数怎么选运算符重载是第9章的“重头戏”。教材里经常用复数类 Complex 来做例子因为复数的加减乘除语义清晰适合展示operator、operator*这些重载写法。网上答案的问题通常在于参数写值传递、该返回引用的地方返回了对象、正负号输出处理得很丑。4.1 教材的例题启示教材在讲运算符重载时最核心的原则是重载后的运算符要符合直觉。复数加法不是修改某个对象而是产生一个新的复数对象。所以二元应该返回一个新对象但如果是它确实要修改第一个对象那就应该返回引用。很多初学者会把两者混为一谈全都返回对象。功能上可能没错但如果你连写c1 c2;编译器会生成临时对象多一次拷贝纯属浪费。4.2 改进版代码#include iostream using namespace std; class Complex { private: double real; double imag; public: Complex(double r 0, double i 0) : real(r), imag(i) {} Complex operator(const Complex c) const { return Complex(real c.real, imag c.imag); } Complex operator-(const Complex c) const { return Complex(real - c.real, imag - c.imag); } Complex operator(const Complex c) { real c.real; imag c.imag; return *this; } void display() const { cout real; if (imag 0) cout imag i; else cout - -imag i; cout endl; } }; int main() { Complex c1(3, 4); Complex c2(1, -2); Complex c3 c1 c2; c1 c2; cout c1 c2 ; c3.display(); cout c1 c2 后 c1 ; c1.display(); return 0; }4.3 参数为什么写 const 引用拷贝开销与临时对象写Complex operator(Complex c)是可以运行的但每次调用c1 c2时形参c会通过拷贝构造函数产生一个临时对象。复数的对象很小拷贝开销可能感受不到但这里更重要的不是开销而是语义。你并不需要副本你只需要读取数据。const Complex c表示“我不会修改你也不会把你复制一份”。这会让调用者非常放心。配合 const 成员函数operator是const的也就是说它保证不修改this指向的对象。c1 c2中this是c1c2是 const 引用。两个操作对象都被承诺“只读”非常干净。4.4 和返回类型的区别operator返回的是Complex一个全新对象。operator返回的是Complex并且return *this;。这里的this指针大家一定要会区分this是指向当前对象的指针*this才是当前对象本身。返回引用意味着不会产生副本后续链式调用如(c1 c2) c3也能正常工作。如果你的operator返回的是Complex值返回功能上仍然能赋值但会多一次拷贝。这类细节在笔试里经常被拿来考加号操作符返回对象复合赋值操作符返回引用。我觉得这不是为了刁难人而是因为它体现了运算符重载后面真正的工程思维尽量减少无谓的对象副本。4.5 输出格式的正负号处理我特意在display()里处理了虚部为负数的情况。如果不处理直接输出real imag i当 imag 是 -2 时输出会变成1-2i缺乏分隔符非常难读。更严重的问题是如果 imag 是 0有的答案还会输出30i观感不好。这里我只做了最基本的正负号处理你可以进一步扩展虚部为 0 时只输出实部实部为 0 时只输出虚部这些可以作为课后思考。5. 点类与友元函数距离计算该不该“破封装”友元函数也是第9章绕不开的话题。教材通常会这样设计题目定义 Point 类有私有数据成员 x 和 y写一个外部函数计算两个 Point 点的距离。问题来了外部函数无法访问私有 x 和 y怎么办两种主流方案一是给 Point 类加getX()、getY()二是用友元函数。这道题我认为友元更贴合题意。5.1 题目背景计算两点距离的公式是sqrt((x1-x2)^2 (y1-y2)^2)。如果 Point 类只提供两个 getter其实函数也能写但代码会变成p1.getX() - p2.getX()像连枷一样长。用友元函数可以直接写p1.x - p2.x语义更清晰也更体现教材引入友元的动机。5.2 改进版代码#include iostream #include cmath using namespace std; class Point { private: double x, y; public: Point(double px 0, double py 0) : x(px), y(py) {} friend double distance(const Point p1, const Point p2); }; double distance(const Point p1, const Point p2) { double dx p1.x - p2.x; double dy p1.y - p2.y; return sqrt(dx * dx dy * dy); } int main() { Point a(1, 2); Point b(4, 6); cout 两点之间距离 distance(a, b) endl; return 0; }5.3 为什么友元在这里比 getter 更合适有人说友元破坏了封装应该尽量避免。这话没错但要看场景。距离计算这个函数本质上是两个 Point 对象之间的“关系函数”而不是某个 Point 自己的行为。把它写成非成员函数反而比成员函数更自然distance(a, b)读起来像数学里的函数而不是a.distance(b)这种“点自己和自己比较”的错觉。同时这里的友元只是最小暴露。它只让distance这一个函数访问私有成员而不是向所有外部代码开放。那个“尽量不用友元”的说法针对的是把所有函数都设为友元的偷懒行为在合理场景下友元是 C 类封装机制的补充而不是破坏者。5.4 友元声明与类外定义的位置细节需要强调一个语法细节在类内声明friend double distance(const Point p1, const Point p2);之后类外定义该函数时不需要也不应该再加friend关键字。friend 只是在类内部授予访问权限的声明它不属于函数签名的一部分。有些初学者会在类外写friend double distance(...)编译报错后还以为函数和类没对上其实只是多了个不存在的关键字。另一个细节是函数访问私有成员的方式因为 friend 函数不是类的成员函数所以它不能通过this隐式访问数据成员。它必须通过参数里的对象访问比如p1.x。这也是为什么至少得到一个 Point 对象作为参数否则它根本没有摸到私有成员的机会。5.5 一个不容易察觉的陷阱友元与重载函数名冲突如果你以后在别的工程里也写了一个名叫distance的函数参数类型恰好也是Point编译器会在重载决议时发现多个匹配产生歧义。最直接的规避方式是把距离函数放进命名空间或者干脆把它设计成Point的静态成员函数。但就这道习题而言二参数版本的全局友元函数就是最贴切、最符合教材意图的写法。考试里问“友元函数能访问私有成员吗”答案是可以但条件是它必须通过对象来访问。6. 动态对象与深拷贝用 Vector 类把析构函数讲透第9章后半部分教材会讨论对象数组、对象指针和析构函数。如果多加一道“带动态内存的类”的习题就能把前面所有知识点串起来。我选了一个精简的 IntVector 类它内部用new[]分配一段连续的整型数组重载了下标运算符修改起来非常直观。6.1 为什么需要自定义拷贝构造和赋值运算符如果一个类里面只有几个简单成员比如 int、double编译器默认生成的拷贝构造函数是按字节复制的这叫浅拷贝。对普通成员没问题但如果类里有指针并且指针指向new出来的动态内存浅拷贝会带来灾难两个对象的指针指向同一块内存任何一个对象析构时执行delete[]另一个对象再析构就会对同一块内存执行二次释放程序直接崩溃。这在第9章习题里经常被忽略因为教材很少用动态内存类的习题去追问。但我觉得这才是“进一步讨论”该有的深度。6.2 改进版完整代码#include iostream using namespace std; class IntVector { private: int size; int* data; public: IntVector(int n) : size(n) { data new int[size](); } IntVector(const IntVector v) : size(v.size) { data new int[size]; for (int i 0; i size; i) { data[i] v.data[i]; } } IntVector operator(const IntVector v) { if (this v) { return *this; } delete[] data; size v.size; data new int[size]; for (int i 0; i size; i) { data[i] v.data[i]; } return *this; } ~IntVector() { delete[] data; } int operator[](int index) { return data[index]; } int getSize() const { return size; } }; int main() { IntVector v1(5); for (int i 0; i v1.getSize(); i) { v1[i] i * i; } IntVector v2(v1); v2[2] 99; cout v1 : ; for (int i 0; i v1.getSize(); i) { cout v1[i] ; } cout endl; cout v2 : ; for (int i 0; i v2.getSize(); i) { cout v2[i] ; } cout endl; return 0; }6.3 默认浅拷贝会怎样double free 现场如果没有自定义拷贝构造执行IntVector v2(v1);时v2.data 会直接复制 v1.data 的地址值。此时 v1 和 v2 指向同一块堆内存。修改v2[2] 99v1[2] 也会变成 99因为它们本来就是同一个数组。等 main 函数结束时v2 先调用析构delete[] data释放内存随后 v1 析构再次delete[] data同一块内存被释放两次。轻则运行时报错重则内存被系统回收后导致其他数据被破坏需要花很长时间排查。所以带指针的类必须实现三板斧深拷贝构造函数、重载赋值运算符、析构函数。这也就是常说的“Rule of Three”。这道题的改进就是让“对象数组/对象指针”的习题不再停留在语法层面而是真正处理内存安全问题。6.4 下标运算符返回引用的意义int operator[](int index)返回的是“数组元素”的引用。这样写才有v1[i] i * i;这样的左值用法。如果你返回int值返回赋值语句v1[i] ...实际上是在给一个临时对象赋值没有写回原数组编译就会报错。这个知识点和第4题里operator返回引用是同一个道理当你要修改目标对象时返回引用当你要产生新值时返回对象。6.5 自赋值检查等细节赋值运算符里我写了if (this v) return *this;这是处理自赋值比如v1 v1;。不写这个检查代码会先delete[] data把自己唯一的数据释放掉然后再把 v.size 和 v.data 拷给自己此时 data 已经是野指针读取的是被释放的内存属于未定义行为。虽然自赋值在自然代码里不常见但一旦出现在v1 v1或者一个容器内部排序交换时没有这个检查就会引发很难察觉的 Bug。这就是“看着多余实则保命”的几行代码。另外一个细节析构函数里我用的是delete[] data不是delete data。new 对应 deletenew[] 对应 delete[]。这两者写反了对内置类型数组可能没有明显症状但对带析构函数的对象数组会逐个出错甚至直接崩溃。栈区局部对象和堆区数组不要混用。7. 改完这几道题我顺手总结的编译踩坑清单最后这部分与其说是答案的一部分不如说是我把上面所有习题里最容易反复出现的编译错误集中到一起。每一条都是我实际见过别人踩坑或者自己曾经被坑过的点。7.1 静态成员只声明未定义最常见的是 Student 类这种 static 成员。类里写了static int count;却忘记在类外补int Student::count 0;。链接时报错 like“无法解析的外部符号”。记住类内是声明类外是定义。一个类可以定义在头文件里无数次编译但静态成员的定义在整个程序的全局作用域里只能出现一次否则会违反“单一定义规则”。7.2 const 成员函数里修改了成员像我写的area() const如果你在函数体里不小心写了a 1;编译器会报错。很多初学者对这个 const 的理解停留在“加了就报错不加就没事”。实际上const 成员函数是在告诉你这个函数承诺不改对象的任何非静态成员也不能调用其他非 const 成员函数。这个约束是双向的它既保护调用者也约束编写者。7.3 运算符重载返回类型导致的歧义Complex operator(const Complex c) const里的 const 若漏掉会带来一个隐患const Complex c1; c1 c2;编译不过。如果你写的函数参数不是 const 引用那么c1 c1这种“复数的平方距离”操作也会变得不自然。返回类型方面我在第4题已经说过了复合赋值运算符、-返回引用二元算术运算符返回新对象这个准则最好形成肌肉记忆。7.4 友元函数声明和定义不一致friend 函数在类内声明时参数列表必须和类外定义完全一致包括 const 引用修饰。如果你类内写friend double distance(const Point p1, const Point p2);类外却写double distance(Point p1, Point p2)这实际上声明了两个不同的函数类内 friend 的函数定义缺失类外定义的函数又没有访问权限。编译器不会给你一个清晰的错误只会莫名其妙地报“无法访问私有成员”。7.5 最后一个建议用调试输出验证边界如果你把这些代码都敲完建议自己在 main 里多写几个“极端数据”的测试用例然后用 cout 输出中间结果验证。比如三角形类的三边 1,2,3、学生类的零个学生、复数类的负虚部、IntVector 类的空数组等。很多时候我们写习题只测正常数据测不出问题等考试或实际项目里输入一歪隐藏 bug 才会浮出来。能写出防住边界条件的代码才算真正吃透了第9章的这些“进一步讨论”。我在实际写的过程中还发现与其急着把整章习题一次性全做完不如每天都手动敲一遍重点题慢慢形成对初始化列表、const 和引用传参的直觉比背十页答案管用得多。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑