资讯动态

C++第八章类和对象习题解析:从能跑到好用的进阶指南

发布时间:2026/10/15 10:16:50 来源:尧图企业网站定制
1. 为什么“类和对象”这一章值得单独拿出来死磕如果你正在学C或者正在带学生学C大概率绕不开一本经典教材——封面上印着“C程序设计”几个大字的那本。而第八章“类和对象”几乎是所有人从“写C语言风格代码”跨入“面向对象思维”的分水岭。我见过太多人前面七章学得顺风顺水循环、数组、函数、指针都能写一到第八章就开始发懵为什么要把数据藏起来构造函数到底什么时候被调用拷贝构造为什么会导致程序崩溃析构函数不写行不行这些问题教材上都有答案但教材上的习题答案往往只给一个“能跑通”的版本离“写得好”还有相当距离。我前后带过几轮学生做这一章的习题自己也把课后题从头到尾刷过三遍每一遍都会发现之前忽略的细节。这篇文章就是把这些年积累的习题解析、改进思路和踩坑经验整理出来适合正在啃这一章的学生、需要备课的讲师以及想重新夯实C面向对象基础的开发者。文章会覆盖第八章习题中最核心的几类题型类的定义与封装、构造函数与析构函数、拷贝构造函数、静态成员、友元函数与友元类、运算符重载入门、对象数组与对象指针。每一类我都会先讲清楚“这道题到底在考什么”再给出参考实现然后重点说明“为什么这样写更好”以及“常见错误在哪里”。所有代码都基于标准C不依赖任何特定编译器扩展你可以直接复制到本地编译运行。2. 类的定义与封装从“能跑”到“好用”的差距2.1 习题常见形态与考察意图第八章最常见的习题类型就是定义一个类包含若干私有数据成员和公有成员函数实现数据的输入、输出和简单计算。比如经典的“时间类”“日期类”“学生类”“矩形类”。教材上的参考答案通常长这样class Time { private: int hour, minute, second; public: void setTime(int h, int m, int s) { hour h; minute m; second s; } void showTime() { cout hour : minute : second endl; } };这个写法没错能通过编译也能输出正确结果。但它有几个明显的问题第一setTime没有做任何合法性检查你传25:70:99它也照单全收第二数据成员没有初始化如果用户忘了调用setTime就直接showTime输出的是垃圾值第三showTime把输出格式写死了想换个格式就得改类本身。2.2 改进方案加入校验、默认值与灵活输出我的改进思路是把“类”当成一个微型系统来设计而不是一个数据袋子。具体来说做三件事。第一给数据成员提供默认值。在C11之后最简洁的方式是使用类内成员初始化class Time { private: int hour 0; int minute 0; int second 0; public: // ... };这样即使不调用任何设置函数对象也处于一个合法状态。如果你用的是老标准就在构造函数里初始化。第二在设置函数中加入范围校验。时间的小时是0到23分和秒是0到59。校验失败时怎么办有两种策略一是拒绝修改并返回false二是钳制到合法范围。我倾向于返回bool让调用者知道设置是否成功bool setTime(int h, int m, int s) { if (h 0 || h 23 || m 0 || m 59 || s 0 || s 59) return false; hour h; minute m; second s; return true; }第三把输出格式的控制权交给调用者。可以提供一个返回格式化字符串的函数或者接受一个格式参数string toString() const { char buf[16]; snprintf(buf, sizeof(buf), %02d:%02d:%02d, hour, minute, second); return string(buf); }注意这里的const修饰它表示这个函数不会修改对象的状态。加上const之后常量对象也能调用这个函数这是一个经常被忽略但非常重要的细节。2.3 封装的核心原则与常见误区封装不是简单地把成员变量标成private就完事了。真正的封装意味着类的使用者不需要知道内部是怎么实现的只需要知道能做什么。举个例子如果我把时间内部存储为“从午夜开始的秒数”对外仍然提供setTime(h, m, s)和getHour()等接口那么使用者完全感受不到区别。这就是封装的价值——内部实现可以随时替换只要接口不变使用者代码就不需要改动。常见误区有三个。第一个误区是把所有成员都设为public觉得这样方便。这在小型练习中问题不大但一旦代码规模上去任何一处修改都可能影响全局。第二个误区是提供了get和set函数就认为自己封装了但如果get返回的是内部数据的引用使用者仍然可以直接修改int getHour() { return hour; } // 危险外部可以修改私有成员正确的做法是返回const引用或值。第三个误区是忽略了const正确性。一个不修改对象状态的函数就应该标记为const否则常量对象无法调用它而且在多线程环境下也会带来隐患。提示判断一个函数是否应该加const只需要问自己一个问题——这个函数会改变对象的逻辑状态吗如果不会就加const。3. 构造函数与析构函数对象生命周期的关键节点3.1 构造函数的重载与委托构造第八章的习题中构造函数相关的题目占比很高。常见的要求是定义一个类提供多个构造函数分别支持默认初始化、带参初始化和拷贝初始化。教材上的典型答案会写三个独立的构造函数每个都重复一遍成员初始化逻辑。这种写法虽然正确但违反了DRY原则Dont Repeat Yourself。C11引入了委托构造函数可以让一个构造函数调用同一个类中的另一个构造函数class Student { private: string name; int age; double score; public: Student() : Student(未知, 0, 0.0) {} // 委托构造 Student(string n, int a, double s) : name(n), age(a), score(s) {} };这样默认构造函数只需要写一行所有的初始化逻辑集中在带参构造函数中。如果以后要增加新的成员变量只需要修改一处。另一个常见问题是构造函数的参数名与成员变量名冲突。教材上通常建议用不同的名字比如n、a、s但这样可读性差。更好的做法是使用成员初始化列表并在参数名前加下划线或使用this指针Student(string name, int age, double score) : name(name), age(age), score(score) {} // 初始化列表中的name是成员括号里的name是参数这种写法在C社区中非常常见编译器能够正确区分。3.2 析构函数什么时候必须写什么时候可以不写析构函数是第八章的另一个重点。教材上的习题通常会要求“在析构函数中输出一条信息以便观察对象的销毁顺序”。这类题目本身不难但它引出一个关键问题什么时候必须自己写析构函数答案很简单当类管理了动态分配的资源时。如果你的类中有指针成员并且这个指针指向的是new出来的内存那么必须写析构函数来delete它。否则就会造成内存泄漏。如果类中没有动态资源那么不写析构函数完全没问题编译器会自动生成一个什么都不做的析构函数。我见过一个典型的习题定义一个字符串类内部用char*存储字符串。很多学生的答案是这样的class MyString { private: char* str; public: MyString(const char* s) { str new char[strlen(s) 1]; strcpy(str, s); } ~MyString() { delete[] str; } };这个答案在析构函数部分是正确的但它遗漏了拷贝构造函数和拷贝赋值运算符。一旦发生对象拷贝两个对象会指向同一块内存析构时就会发生“双重释放”程序直接崩溃。这就是著名的“三法则”如果你需要自定义析构函数、拷贝构造函数或拷贝赋值运算符中的任何一个那么通常三个都需要。3.3 对象数组与构造析构顺序的观察技巧教材上有一类习题要求定义对象数组并观察构造和析构的顺序。比如Student arr[3] {Student(A, 18, 90), Student(B, 19, 85), Student(C, 20, 88)};这里涉及临时对象的构造、拷贝构造和析构。很多学生看到输出结果中有额外的构造和析构调用感到困惑。实际上Student(A, 18, 90)会先构造一个临时对象然后用它拷贝构造数组元素最后临时对象被析构。如果编译器支持拷贝省略C17起是强制要求这些额外的调用会被省略。如果你想清楚地观察构造和析构的顺序我建议在构造函数和析构函数中加入输出语句并加上对象地址Student(string n, int a, double s) : name(n), age(a), score(s) { cout 构造: this name endl; } ~Student() { cout 析构: this name endl; }通过地址可以区分不同的对象通过名字可以知道是哪个对象被销毁。实测下来这种方法比单纯输出“构造了一个对象”有用得多。注意在析构函数中输出信息时不要使用可能已经失效的成员。比如如果成员是动态分配的字符串在delete之后再输出它的内容就是未定义行为。4. 拷贝构造函数最容易被忽视的“隐形杀手”4.1 为什么编译器生成的拷贝构造函数不够用拷贝构造函数是第八章习题中出错率最高的知识点。编译器会自动生成一个拷贝构造函数它的行为是“逐成员拷贝”。对于基本类型成员int、double等这没问题对于对象成员它会调用该对象的拷贝构造函数但对于指针成员它只拷贝指针的值也就是让两个指针指向同一块内存。这就是所谓的“浅拷贝”。浅拷贝带来的问题在析构时爆发两个对象都认为自己拥有那块内存都去delete第二次delete就是未定义行为通常导致程序崩溃。教材上的习题经常用“字符串类”或“数组类”来考察这一点。4.2 深拷贝的实现要点与自赋值检查深拷贝的核心思想是不拷贝指针而是拷贝指针指向的内容。对于字符串类实现如下MyString(const MyString other) { str new char[strlen(other.str) 1]; strcpy(str, other.str); }拷贝赋值运算符则需要额外处理自赋值的情况MyString operator(const MyString other) { if (this other) return *this; // 自赋值检查 delete[] str; // 释放原有内存 str new char[strlen(other.str) 1]; strcpy(str, other.str); return *this; }自赋值检查是必须的。如果没有它delete[] str会释放掉自己的内存然后other.str就成了悬空指针后面的strlen和strcpy都会出错。还有一个更优雅的实现方式叫做“拷贝并交换”MyString operator(MyString other) { // 注意参数是按值传递 swap(str, other.str); return *this; }这个版本利用了拷贝构造函数创建临时对象然后交换内部指针。临时对象在函数结束时析构自动释放原有内存。它天然支持自赋值代码也更简洁。缺点是会多一次拷贝但在现代C中移动语义可以消除这个开销。4.3 移动语义C11之后的更优解如果你的编译器支持C11及以上标准那么对于管理动态资源的类还应该提供移动构造函数和移动赋值运算符。移动语义的核心是“窃取”资源而不是复制MyString(MyString other) noexcept : str(other.str) { other.str nullptr; }注意noexcept标记它告诉编译器这个操作不会抛出异常这对于标准库容器在扩容时选择移动而非拷贝至关重要。移动之后源对象的指针被置空它的析构函数就不会释放已经被“偷走”的资源。在实际习题中如果题目没有明确要求移动语义你可以不写但了解这个概念对于理解现代C的资源管理非常有帮助。5. 静态成员、友元与运算符重载入门5.1 静态数据成员与静态成员函数的配合使用静态成员是第八章的一个小难点。教材上的典型习题是统计某个类一共创建了多少个对象。答案是在类中定义一个静态数据成员在构造函数中递增在析构函数中递减class Counter { private: static int count; public: Counter() { count; } ~Counter() { --count; } static int getCount() { return count; } }; int Counter::count 0; // 静态成员必须在类外定义这里有几个容易出错的地方。第一静态数据成员必须在类外定义并初始化否则链接时会报“未定义引用”。第二静态成员函数不能访问非静态成员因为它没有this指针。第三静态成员函数可以通过类名直接调用也可以通过对象调用但推荐用类名语义更清晰。5.2 友元函数与友元类的适用场景友元是C中一个争议较大的特性。它破坏了封装但在某些场景下确实能简化代码。教材上的习题通常要求“用友元函数计算两个对象的和”或“用友元类访问另一个类的私有成员”。我的建议是能不用友元就不用。大多数情况下提供公有的get函数或者把功能实现为成员函数是更好的选择。友元应该作为最后的手段而不是首选方案。如果确实需要用也要尽量缩小友元的范围——能用友元函数就不用友元类能只友元一个函数就不友元整个类。5.3 运算符重载的基本规则与常见陷阱运算符重载是第八章的选学内容但很多习题会涉及。最基本的规则是不能改变运算符的优先级和结合性不能创造新的运算符不能重载::、.、.*、?:等少数几个运算符。以运算符为例通常实现为友元函数或全局函数这样左右操作数都可以隐式转换Complex operator(const Complex a, const Complex b) { return Complex(a.real b.real, a.imag b.imag); }如果实现为成员函数那么左操作数必须是Complex类型不能是int等可以转换为Complex的类型。这是一个实际编码中容易忽略的细节。另一个陷阱是运算符的前置和后置版本。前置版本返回引用后置版本返回值并且后置版本需要一个int参数来区分Complex operator(); // 前置 Complex operator(int); // 后置后置版本中的int参数没有任何实际用途只是一个占位符调用时不需要传递。6. 常见编译错误与运行时问题速查在实际做习题的过程中遇到编译错误和运行时崩溃是家常便饭。我把这些年学生最常遇到的问题整理成一张速查表方便你快速定位。错误现象可能原因排查方法undefined reference to XXX::count静态数据成员未在类外定义在.cpp文件中添加int XXX::count 0;程序在析构时崩溃浅拷贝导致双重释放检查是否实现了拷贝构造函数和拷贝赋值运算符error: passing const XXX as this argument discards qualifiers常量对象调用了非const成员函数给不修改对象状态的成员函数加上const构造函数中的成员变量是垃圾值未在初始化列表中初始化也未使用类内初始化使用初始化列表或C11类内初始化operator中自赋值导致崩溃未做自赋值检查在赋值运算符开头添加if (this other) return *this;对象数组初始化失败类没有默认构造函数提供默认构造函数或在初始化列表中显式构造每个元素友元函数无法访问私有成员友元声明位置或写法错误检查友元声明是否在类内部是否使用了friend关键字除了表格中的问题还有一个非常隐蔽的坑在构造函数中调用虚函数。虽然第八章通常不涉及虚函数但如果你提前了解了继承可能会遇到这个问题。在构造函数中调用虚函数实际调用的是当前类版本的函数而不是派生类重写的版本。因为派生类部分还没有构造完成。这个知识点在后续章节会详细讲解但在这里提一句避免你踩坑。提示遇到编译错误时先看错误信息的第一行它通常指向真正的问题所在。后面的错误往往是连锁反应不要被吓到。7. 从习题到工程这些练习到底有什么用很多人做课后习题时会有一种感觉这些题目太简单了跟实际项目差距很大。确实教材上的习题为了教学目的往往把问题简化了。但如果你仔细思考会发现这些简化背后对应着真实的工程问题。比如“时间类”的合法性校验对应的是实际项目中输入验证的需求。“字符串类”的深拷贝对应的是资源管理的问题——在实际项目中资源可能是内存、文件句柄、网络连接、数据库连接等管理原则是一样的。“静态计数器”对应的是对象生命周期统计这在性能分析和调试中非常有用。我在实际项目中写过一个日志类内部管理一个文件指针。这个类的设计思路和第八章习题中的“字符串类”几乎一模一样构造函数打开文件析构函数关闭文件拷贝构造函数和拷贝赋值运算符需要特别处理否则两个日志对象会操作同一个文件指针导致写入混乱。如果你在第八章就把这些基础打牢后面遇到类似问题时就能迅速定位。另一个实际应用是“对象数组”的管理。在游戏开发中子弹、敌人、粒子等对象通常以数组或容器的方式管理。对象的构造和析构顺序、拷贝行为、内存布局都会影响性能。第八章的习题虽然简单但它训练的是你对对象生命周期的敏感度这种敏感度在性能优化时非常关键。8. 我个人的刷题方法与改进习惯最后分享一些我自己的做法。我刷第八章习题时不会只满足于“答案对了”。每做完一道题我会问自己三个问题第一这个类有没有可能处于非法状态如果有可能怎么防止第二这个类被拷贝时会发生什么有没有潜在的问题第三如果我要给这个类添加一个新功能现有的接口设计是否支持这三个问题对应的是不变式维护、资源管理和可扩展性它们是面向对象设计的核心。教材上的习题不会直接考这些但如果你能在做题时有意识地思考它们收获会远超题目本身。另外我强烈建议你把每道题的代码都实际编译运行一遍不要只在纸上写。很多问题只有编译器才能发现比如const正确性、静态成员定义、初始化列表顺序等。我见过太多学生觉得自己“看懂了”一上机就报错。编程是一门实践技能看会了不等于写会了。如果你用的是较新的编译器建议开启所有警告-Wall -Wextra -Wpedantic。编译器会帮你发现很多潜在问题比如未初始化的成员、有符号无符号比较、未使用的变量等。这些警告在初学阶段可能显得烦人但它们能帮你养成好习惯。还有一个习惯是每写完一个类就写一个简单的测试函数覆盖各种边界情况。比如时间类测试0:0:0、23:59:59、24:0:0、-1:0:0等输入。这个过程会让你对类的行为有更清晰的认识也能提前发现逻辑错误。代码写多了你会发现第八章的这些习题虽然基础但它们是整个C面向对象体系的基石。构造函数、析构函数、拷贝控制、静态成员、友元、运算符重载这些概念在后续的继承、多态、模板、STL中反复出现。把这一章吃透后面的路会顺很多。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑