现代 C++ 视角下教材第九章类与对象习题的改进写法

发布时间:2026/10/15 9:49:30

现代 C++ 视角下教材第九章类与对象习题的改进写法 翻开教材第九章《关于类和对象的进一步讨论》很多人会有同一种感觉前面的语法题照着书抄都能过到了构造函数、析构函数、静态成员、友元函数这一章忽然就没那么“听话”了。书上的例题能看懂习题也能“跑出结果”但总觉得哪里不对劲。这篇文章我把第九章里几道高频习题拿出来先给教材风格的常规写法再给一版我按工程习惯改过的写法。重点不是让你抄答案而是解释为什么原答案可以正确运行却不推荐你在真实项目里照搬。顺带说一句这本书的例题风格偏老很多代码还带着十年前的习惯文件头不写#include iostream而写#include iostream.h、全局using namespace std;、构造函数全部靠在函数体里赋值、统计对象个数要用到类外定义静态变量。这些写法不是错但到了 C11/C17 时代有更安全、更不容易出错的表达方式。我改答案的原则很简单保持习题考察的知识点不变把代码换成现代 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 对象调用对象计数类外定义静态变量C17inline变量免维护链接符号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;如果漏了这行链接阶段就找不到符号。改进版直接用 C17 的内联变量连这行都不用写#include iostream #include string class Student { private: std::string name_; static inline int count_ 0; // C17无需类外再定义 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; // 输出 41i 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::vectorStudent 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::vectorStudent而不是固定数组学生数量可以动态变化。用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;放在类外。C17 起在声明处加inline就能就地定义static inline int count_ 0;。排查步骤看是不是漏了类外定义。看是不是把定义写在头文件里导致多文件链接冲突。如果用了inline确认编译器支持 C17 及以上标准。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 编译不过就先查这三处遇到编译错误我建议按下面顺序排查分号问题class Student { ... };最后的;漏写是出现诡异报错的最常见原因。头文件与命名空间是不是用了旧式头文件或在使用cout前没写std::。成员访问权限在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 完全是两个层次。第九章是分水岭——前面章节你学会的是语法这一章你开始理解类的生命周期、资源管理和封装边界。跨过这道坎后面学习运算符重载、继承、多态、模板才会越学越轻松。
延伸阅读

更多相关文章

2026/10/15 9:49:30

多目标进化算法实战:从NSGA-II原理到参数调优与避坑指南

1. 从单目标到多目标:为什么我们需要进化算法如果你做过任何形式的参数调优,大概率遇到过这样的场景:手头有一个明确的指标,比如让某个响应时间尽可能短,或者让某个吞吐量尽可能高。这种只有一个优化目标的问题&#x…

2026/10/15 9:44:28

数据分析笔试复盘:统计概率、SQL与业务分析解题框架

1. 这场笔试到底在考什么先说说我为什么会对这个题目有印象。2021届秋招那会儿,数据分析岗的竞争已经进入白热化阶段,某头部互联网公司的数据分析笔试更是被圈内人称为“筛人机器”——不是因为它考得有多偏,而是因为它能在短短90分钟内&…

2026/10/15 9:44:28

管了10年车间,我发现:真正有用的管理,就靠这5张表!

管了10年车间,我越来越觉得,车间管理最怕的不是问题多,而是每天忙着处理问题,却始终不知道问题到底出在哪里。生产计划没完成,设备故障反复发生,质量异常不断,物料损耗也降不下来。每天开会、填…

2026/10/15 11:05:26

分布式A2A:企业级智能体协同的架构生存门槛

1. 为什么“分布式 A2A”不是技术选型,而是企业 Agent 架构的生存门槛我第一次在客户现场听到“我们想用 Agent 解决客服自动响应”时,心里是松了口气的——这事儿不难,调个 LLM API,套个 RAG 模块,再加个规则兜底&…

2026/10/15 11:05:26

YOLOv3数据增强与迭代器设计实战:从策略到性能优化

1. 为什么数据增强在YOLOv3训练里不是"可选项" 很多人第一次跑YOLOv3训练脚本时,最容易忽略的就是数据增强这一块。大家习惯性地把数据集整理成标准格式,写好DataLoader,然后直接开训,结果跑了几十个epoch发现验证集mAP…

2026/10/15 11:05:26

深度学习在智能汽车中的核心应用与工程落地实践

这两年只要聊到智能汽车,AI和深度学习这两个词基本跑不掉。我前阵子帮朋友调一个车载摄像头检测模型,白天跑完数据晚上就要出结果,中间还穿插着跟车机软件团队对接口,真正体会到:深度学习在汽车上的应用,早…

2026/10/15 11:05:26

VLA在线强化学习实战:RL Token如何解决采样效率与灾难性遗忘

视觉-语言-动作模型(VLA)这两年在机器人操作领域的热度有目共睹,但真正把它推到线上强化学习(Online RL)里跑通的人并不多。原因很直接:VLA 参数量大、推理慢、动作空间连续且高维,直接拿来做 a…

2026/10/15 11:05:26

DeepSeek Harness安全围栏:AI Agent沙箱隔离四大支柱

1. 什么是 DeepSeek Harness 的“安全围栏”?它真能给 AI Agent 套上紧箍咒吗? AI Agent 不再是实验室里的玩具,而是开始接管真实业务流程:自动审批报销单、实时分析交易日志、调用内部 API 处理客户工单、甚至在生产环境里生成并…

2026/10/15 11:00:23

K8s 1.14集群在Ubuntu 16.04上的搭建实战

最近在给某个老项目做技术栈还原,需要在Ubuntu 16.04的一批旧服务器上把Kubernetes 1.14.0集群重新搭起来。网上能找到的教程要么年份太新,命令对不上旧版本;要么只贴命令不讲原因,遇到报错完全不知道从哪里下手。我前后花了两周时…

2026/10/14 11:31:03

Python调用Gemini Structured Outputs实现工单路由门禁

客服工单最怕的不是模型“答错一句话”,而是它给出一段看起来合理的说明,程序却从中猜错优先级。通俗做法是:要求模型只交 JSON(JavaScript Object Notation,轻量数据格式),再让代码验证它。Gem…

2026/10/14 17:41:07

Spring Boot超市进销存系统毕设实战:从需求拆解到答辩通关

最近带的一个学生项目组里,有A同学跑来问我:选什么毕设题目最稳妥,既能让评审老师觉得工作量够,又不会在答辩时被问到语无伦次。我第一反应就是推荐基于Spring Boot的超市仓库管理系统——也就是超市进销存系统。这个题目乍一看平…

2026/10/14 2:32:55

Flutter StatefulWidget 生命周期核心解析

很多刚开始接触 Flutter 的朋友,在看完一堆“Hello World”和基础组件之后,大概率都会撞上同一堵墙:StatefulWidget 里那堆 initState、build、dispose 方法,到底什么时候被调用?为什么顺序是那样?在里面到…

2026/10/15 0:02:22

防关联浏览器哪个好?三款产品实测差异解析

新手做多店铺,最纠结的就是防关联浏览器选哪个。飞跨、网易、站斧这三款被问得最多,先给结论:要一体化省心、长期便宜,选站斧;要网易招牌和稳,选网易;预算紧做东南亚 Lazada,选飞跨。…

2026/10/15 0:02:22

影刀RPA深度实战:大模型Fine-tuning定制RPA专用Prompt与指令生成

影刀RPA深度实战:大模型Fine-tuning定制RPA专用Prompt与指令生成 一、技术背景 大模型(LLM)在通用场景下表现强大,但当我们将 GPT-4 或 Qwen 直接用于生成影刀RPA操作指令和 XPath 定位时,准确率通常只有 40%-60%。原因…

还想了解更多?直接咨询顾问

免费诊断 + 免费方案 + 透明报价。

全国咨询热线400-8866-253
免费获取方案
☎咨询二维码 ☎ ↑