☰
现代 C++ 视角下教材第九章类与对象习题的改进写法
2026/10/15 9:49:27 网站建设 项目流程

翻开教材第九章《关于类和对象的进一步讨论》,很多人会有同一种感觉:前面的语法题照着书抄都能过,到了构造函数、析构函数、静态成员、友元函数这一章,忽然就没那么“听话”了。书上的例题能看懂,习题也能“跑出结果”,但总觉得哪里不对劲。这篇文章我把第九章里几道高频习题拿出来,先给教材风格的常规写法,再给一版我按工程习惯改过的写法。重点不是让你抄答案,而是解释为什么原答案可以正确运行,却不推荐你在真实项目里照搬。

顺带说一句,这本书的例题风格偏老,很多代码还带着十年前的习惯:文件头不写#include <iostream>而写#include <iostream.h>、全局using namespace std;、构造函数全部靠在函数体里赋值、统计对象个数要用到类外定义静态变量。这些写法不是错,但到了 C++11/C++17 时代,有更安全、更不容易出错的表达方式。我改答案的原则很简单:保持习题考察的知识点不变,把代码换成现代 C++ 推荐写法,并顺手补上教材没说透的细节。下面按知识模块拆开讲。

1. 第九章在讲什么:先搭一张知识地图

1.1 这一章真正的主角是“对象的生命周期”

前面章节教你怎么定义类、怎么创建对象,第九章则把聚光灯打到了对象“出生到消失”的全过程上。集中出现了四个核心概念:

  • 构造函数:对象创建时被自动调用的初始化函数,可以重载,可以带默认参数。
  • 析构函数:对象销毁前被自动调用的清理函数,没有参数、没有返回值、不能重载。
  • 静态成员:属于整个类而不属于某个具体对象的成员,所有对象共享同一份数据。
  • 友元函数/友元类:打破封装边界,允许外部函数或类访问私有成员。

除此之外还有两个很容易被忽略的小点:对象数组、对象指针与 this 指针。这几个概念单独拎出来都不算难,但组合在一起,就成了大多数习题的“陷阱区”。比如构造函数参数怎么写、对象数组初始化时调用哪个构造函数、静态成员变量到底在哪定义、友元函数和成员函数调私有成员的方式有何区别——题目里稍微绕一下,答案就会差之千里。

1.2 教材答案的风格问题出在哪里

既然是写“习题答案”,就不能回避一个问题:老教材的标准答案,普遍追求“短小”而不是“健壮”。它能通过编译、能输出正确结果,但不代表它是好的 C++ 代码。常见风格问题包括:

  • 所有成员变量都放在 public 下,封装形同虚设。
  • 构造函数大量使用a = x;而不是初始化列表。
  • 参数传递全部按值传递,频繁拷贝对象。
  • 能用char[]就不用std::string。
  • 静态成员变量必须在类外单独写一行定义,忘记写就链接报错。
  • 成员函数全不加const,导致 const 对象无法调用。

这些问题在单文件小习题里看不出来,但一旦你开始写真正的小项目,就会逐一暴露。所以我把教材答案改写时,刻意保持“考察点不变”,同时把封装性、传递方式、常量正确性这三个方面都拉回现代 C++ 的常规水准。

2. 为什么教材答案需要“改进”:三个改写原则

2.1 原则一:能封装就封装,别把所有成员都摊在 public 里

很多习题答案为了省事,把数据成员直接放 public。做题时无所谓,但到了多人协作的工程里,直接暴露成员变量意味着任何代码都能随意改动对象内部状态,出问题时你根本不知道是哪个函数动了它的值。我的改进版默认把数据成员放进 private,对外提供必要的读写接口。

更值得强调的是,接口要根据语义设计:有些成员允许外部读取但不允许随意修改,例如学生的学号;有些成员需要经过校验才能修改,例如分数不能超过 100。这正是“类”存在的意义——你不能只把 struct 换个名字叫 class。

2.2 原则二:初始化列表优先于构造函数体内赋值

教材里常见这种写法:

Student(int id, char name[]) { m_id = id; strcpy(m_name, name); }

改进后:

Student(int id, std::string name) : m_id(id), m_name(std::move(name)) {}

关键区别在于:前者是先创建成员变量,再在函数体里赋值,经历了“默认构造 + 赋值”两步;后者是直接用参数初始化成员变量,省掉一次多余操作。对于 string、vector 这类带资源管理的类型,初始化列表不只是性能问题,还可能关乎异常安全。

2.3 原则三:按 const 引用传参,能加 const 的成员函数都要加

老答案里经常出现“按值传对象”的写法:

void display(Student s) { s.show(); }

改成const Student& s至少有两点好处:一是避免拷贝整个对象,省去一次构造和析构;二是明确告诉阅读者和编译器“这个函数不会修改传入的对象”。同样,void show() const这种写法意味着成员函数内部不会修改任何数据成员,const 对象也能正常调用。这两个习惯,教材里几乎不提,但真实项目里处处都是。

下面这张表我建议贴在电脑前:

场景教材常见写法改进写法改动理由
文件头#include <iostream.h>#include <iostream>标准 C++ 头文件
命名空间using namespace std;按需std::前缀避免命名空间污染
构造初始化函数体赋值初始化列表省一次默认构造
参数传递按值传对象按 const 引用传避免拷贝
成员函数不加 const无修改则加 const支持 const 对象调用
对象计数类外定义静态变量C++17inline变量免维护链接符号

3. 高频习题的改进版解法:从“能运行”到“上得了工程”

3.1 静态成员统计对象个数:链接错误是怎么避开的

教材里有一道经典题:设计一个类,用静态成员统计当前对象的个数,创建时加一,析构时减一。常规答案长这样:

#include <iostream> using namespace std; class Student { public: Student() { count++; } ~Student() { count--; } static int count; }; int Student::count = 0; // 类外定义,不能省略 int main() { Student s1, s2; cout << Student::count << endl; // 期望输出 2 return 0; }

运行没问题,但我当年第一次写时怎么都编译不过,报的是undefined reference to Student::count。教材注释就一行“类外定义不可省略”,但它没解释为什么。原因很简单:静态成员变量属于类的“全局数据”,其存储空间必须在某个编译单元里分配一次。书上的写法是在源文件里补一行int Student::count = 0;,如果漏了这行,链接阶段就找不到符号。

改进版直接用 C++17 的内联变量,连这行都不用写:

#include <iostream> #include <string> class Student { private: std::string name_; static inline int count_ = 0; // C++17:无需类外再定义 public: explicit Student(const std::string& name) : name_(name) { ++count_; } Student(const Student& other) : name_(other.name_) { ++count_; // 拷贝构造也需要计数 } ~Student() { --count_; } static int count() { return count_; } }; int main() { Student s1("Alice"); Student s2("Bob"); Student s3 = s1; // 拷贝构造,count 变为 3 std::cout << Student::count() << '\n'; return 0; }

这里有两个容易踩的细节。第一,只给默认构造函数计数不够,还要考虑拷贝构造——Student s3 = s1;不写拷贝构造时,编译器会生成一个默认版本,它不会自动帮你递增计数器,所以计数会漏算。第二,赋值运算符不会创建新对象,不能递增计数,否则把一个对象赋给另一个对象时出现计数虚高。如果把这两条想清楚,静态成员那道题才算真掌握。

3.2 对象数组与指针:构造和析构顺序到底怎么排

教材另一道常见题是“创建一个对象数组,观察构造、析构顺序”。常规答案通常依赖默认构造函数,但实际题面上往往让你传入不同初始值。改进后的写法直接用大括号统一初始化:

#include <iostream> class Clock { private: int hour_; int minute_; public: Clock() : hour_(0), minute_(0) { std::cout << "默认构造\n"; } Clock(int hour, int minute) : hour_(hour), minute_(minute) { std::cout << "带参构造 " << hour << ":" << minute << '\n'; } ~Clock() { std::cout << "析构 " << hour_ << ":" << minute_ << '\n'; } void show() const { std::cout << hour_ << ":" << minute_ << '\n'; } }; int main() { Clock array[3] = { {8, 30}, {12, 0}, {18, 45} }; Clock* p = array; for (int i = 0; i < 3; i++) { (p + i)->show(); } return 0; }

运行后你会清楚地看到输出顺序:

  • 先 3 次带参构造;
  • 再 3 行时间显示;
  • 最后 3 次析构,顺序与构造相反。

对象数组的重点是“数组创建期间,每个元素都会依次调用构造函数;数组销毁时,按逆序调用析构函数”。如果你用的是默认构造函数,那三个元素调用的是同一个无参版本,看不出顺序问题。这道题的意义就是让你亲眼确认“构造由前到后、析构由后到前”这个结论。

实际工程里,固定大小数组极少直接用,更常见的是std::vector。同样这道题如果改成 vector,你既可以用emplace_back避免临时对象拷贝,也可以动态控制对象数量。教材不教 vector 是因为章节顺序靠前,你做完习题后一定要自己把数组版换成 vector 版做一遍,这种自我升级训练价值极高。

3.3 this 指针:从“返回 void”改成“返回引用”实现链式调用

this 指针的经典习题是:设计一个类,在成员函数里输出当前对象的地址。教材标准答案往往是这个意思:

class Counter { private: int value_ = 0; public: void add(int n) { value_ += n; } void show() const { std::cout << value_ << '\n'; } };

它能跑,但 this 指针的威力没有体现出来。我的改进版把add的返回值从void改为Counter&,在函数末尾return *this;,这样就能连续调用:

#include <iostream> class Counter { private: int value_ = 0; public: Counter& add(int n) { value_ += n; return *this; } Counter& reset() { value_ = 0; return *this; } int get() const { return value_; } }; int main() { Counter c; c.add(5).add(3).reset().add(2); std::cout << c.get() << '\n'; // 输出 2 return 0; }

这里返回的必须是Counter&,不能是Counter。如果返回对象本身,每次.add()调用都会拷贝一个新对象,连续调用看似成功,实际是在临时对象上操作,原对象根本没变,而且还得额外付出拷贝构造和析构的开销。树立这个意识,对你以后写链式 API、流式运算符重载都有帮助。

还有个容易被忽略的考点:this指针是Counter* const,它本身不能被修改,但可以通过它修改对象内容;在 const 成员函数里,this 的类型自动变成const Counter*,所以想返回Counter&的非常量引用就不行。这说明“要不要加 const”不是形式问题,而是真的会改变 this 指针的类型,改变你能否修改对象的权限。

3.4 友元函数:什么时候该用,什么时候别乱用

教材讲友元时,通常会出一个题目:定义一个复数类,用友元函数实现两个复数相加。教材标准答案也许是这样的:

#include <iostream> using namespace std; class Complex { public: double real, imag; Complex(double r, double i) { real = r; imag = i; } }; Complex add(Complex a, Complex b) { Complex t(a.real + b.real, a.imag + b.imag); return t; }

这里其实谈不上“友元”,因为数据全部公开,外部函数也能直接访问。既然考察友元,就应该让数据私有,让非成员函数通过友元关系访问。改进后:

#include <iostream> class Complex { private: double real_; double imag_; public: Complex(double real = 0, double imag = 0) : real_(real), imag_(imag) {} friend Complex operator+(const Complex& a, const Complex& b); friend std::ostream& operator<<(std::ostream& os, const Complex& c); }; Complex operator+(const Complex& a, const Complex& b) { return Complex(a.real_ + b.real_, a.imag_ + b.imag_); } std::ostream& operator<<(std::ostream& os, const Complex& c) { os << c.real_ << (c.imag_ >= 0 ? "+" : "") << c.imag_ << "i"; return os; } int main() { Complex x(1, 2), y(3, -1); Complex z = x + y; std::cout << z << '\n'; // 输出 4+1i return 0; }

改动后的优势有三处:数据私有,符合封装;运算符重载让复数相加更符合自然习惯;输出运算符可以通过友元直接读私有成员。要特别提醒的是,友元会打破封装,能用成员函数解决的问题,不要随便用友元。判断标准很简单:如果运算符需要“左侧对象和右侧对象都作为参数参与计算”,比如operator+,我们通常愿意用非成员函数加友元;如果只需要修改当前对象的状态,比如operator+=,就该优先实现为成员函数。

3.5 综合题:学生成绩管理的现代写法

教材里最后往往来一道综合题,类似“定义学生类,包含学号、姓名、若干门课成绩,统计平均分并输出”。常规答案会用固定数组存学生,参数一多,代码迅速膨胀。教材里的典型版本大约是:

class Student { public: int id; char name[20]; float score[3]; float average() { return (score[0]+score[1]+score[2])/3; } };

我改成使用std::string、std::vector和标准库排序的综合版本:

#include <algorithm> #include <iostream> #include <string> #include <vector> class Student { private: std::string name_; double score_; public: Student(std::string name, double score) : name_(std::move(name)), score_(score) {} const std::string& name() const { return name_; } double score() const { return score_; } friend bool operator<(const Student& lhs, const Student& rhs) { return lhs.score_ < rhs.score_; } }; int main() { std::vector<Student> students; students.emplace_back("Zhang", 88.5); students.emplace_back("Li", 92.0); students.emplace_back("Wang", 76.5); std::sort(students.begin(), students.end()); for (const auto& s : students) { std::cout << s.name() << ": " << s.score() << '\n'; } return 0; }

这里的改进点很密集:

  • 用std::string而不是char[]存名字,不需要思考字符串长度,更不会发生缓冲区溢出。
  • 用std::vector<Student>而不是固定数组,学生数量可以动态变化。
  • 用emplace_back直接在容器里构造对象,比push_back(Student(...))少一次拷贝。
  • 用std::sort加自定义operator<排序,不需要手写冒泡或者选择排序。
  • 用基于范围的 for 循环输出,不写数组下标。

有人会担心“教材还没教 vector 和 string,用它们不是超纲吗”。我的经验是:做完这道题,你完全可以顺手查一下 vector 和 string 的基本用法。它们属于 C++ 的标准库“基础设施”,早用早受益。书本不教不代表不应该学,习题的最终目标也不是过编译,而是建立正确的工程直觉。

4. 高发问题排查:这些坑我全踩过

4.1 静态成员“未定义引用”报错

现象:类里写了static int count;,main 里直接Student::count++,编译报undefined reference。

原因:静态成员变量只是声明,不是定义,必须有一个实体在某个源文件里分配内存。老写法需要int Student::count = 0;放在类外。C++17 起,在声明处加inline就能就地定义:static inline int count_ = 0;。

排查步骤:

  • 看是不是漏了类外定义。
  • 看是不是把定义写在头文件里导致多文件链接冲突。
  • 如果用了inline,确认编译器支持 C++17 及以上标准。

4.2 初始化列表的成员初始化顺序不是“列表顺序”

这是最阴险的一个坑。代码写的是:

class Demo { private: int a_; int b_; public: Demo(int b) : b_(b), a_(b_) { } };

你以为先用 b 初始化 b_、再用 b_ 初始化 a_。实际上成员初始化顺序由“类内声明顺序”决定,a_ 先声明,所以先执行a_(b_),此时 b_ 还没初始化,a_ 拿到的是一个未知值。课本上很少强调这个规则,但实际项目里线上事故没少见。

改进方式:声明顺序和初始化列表顺序保持一致,并且尽量不使用“用另一个成员去初始化这个成员”的写法。你可以在初始化列表里直接写参数:

Demo(int b) : a_(b), b_(b) {}

4.3 自己写了析构函数,却没写拷贝构造函数

教材经常让学生写析构函数来“观察对象销毁”。当你兴致勃勃地写了下面这种类,然后按值返回一个对象时,往往会出现运行期崩溃:

class StringWrapper { private: char* data_; public: StringWrapper(const char* s) { data_ = new char[100]; strcpy(data_, s); } ~StringWrapper() { delete[] data_; } };

原因:默认拷贝构造函数是“浅拷贝”,两个对象的data_指向同一块内存,第一个对象析构时释放掉,第二个对象析构时再次释放,形成 double free。解决办法是补充拷贝构造和拷贝赋值(或者直接禁用拷贝)。

现代 C++ 更推荐的做法是直接用std::string,它内部的拷贝、移动、资源释放全部正确处理,你根本不需要写析构函数。这也解释了为什么我的改进版里大量使用标准库容器和字符串——少写一个手动管理资源的类,就少一类 bug。

4.4 友元函数写在类里,就认为是成员函数

有同学把friend声明放在 public 区,然后以为它和成员函数一样,调用时写成obj.friendFunc(),编译报错。友元函数本质是“具有访问私有成员权限的普通外部函数”,它不是类的成员,没有 this 指针,调用时不需要加对象名。友元声明放在 public 还是 private 区域都不影响其访问权限,因为“是否友元”是由声明本身决定的,跟它写在哪个访问限定符下面无关。

判断方法:成员函数可以直接使用类内的类型别名和静态成员且不需要类名::;友元函数则必须把参数对象传进去,通过对象名访问私有成员。如果你在代码里看到friend关键字,那这个函数一定不是成员函数。

4.5 编译不过就先查这三处

遇到编译错误,我建议按下面顺序排查:

  1. 分号问题:class Student { ... };最后的;漏写,是出现诡异报错的最常见原因。
  2. 头文件与命名空间:是不是用了旧式头文件,或在使用cout前没写std::。
  3. 成员访问权限:在class里不写public,默认就是private,外部构造函数拿不到成员变量。

这三样检查完,大部分“为什么报错”基本都能解决。

5. 把习题吃透:课后该做的三次练习

5.1 第一次:按教材思路原封不动写一遍

不要一开始就追求“现代优雅”。第一遍请老老实实按教材的思维方式写,哪怕用using namespace std;、用char[]、把成员全放 public,也先让程序跑通。这一步是为了理解题目的核心考点,而不是展示你的编程品味。跑通之后,你心里会有一个基线:这段代码能用,但哪里别扭?

5.2 第二次:按现代 C++ 的习惯重写一遍

把第一次的程序推翻,按我前面说的三个原则重写:数据私有、初始化列表、按 const 引用传参、能用标准库就用标准库。第二步做完,你会发现代码量可能变少了,但信息密度更高了。建议对比两个版本的运行结果,确认行为一致,再用调试器逐步走一遍,观察构造和析构调用时机。

5.3 第三次:给代码加一点点工程要求

比如:给所有成员函数标const;在 main 里创建const对象,看看哪些函数还能调用;把对象数组改成vector,再试试emplace_back与push_back的区别;给学生类增加一个“总分最高的同学”查找逻辑。这种“加需求”的训练,是脱离教材答案、真正建立设计能力的必经之路。

我个人在教学和带新人的过程中发现:愿意把习题做三遍的人,和不满足于“能运行”的人,写出来的 C++ 完全是两个层次。第九章是分水岭——前面章节你学会的是语法,这一章你开始理解类的生命周期、资源管理和封装边界。跨过这道坎,后面学习运算符重载、继承、多态、模板,才会越学越轻松。

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

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

立即咨询