侯捷 C++ 面向对象上篇:从头文件到构造函数,写好第一个类
学过变量、函数和控制流之后,为什么还需要认真学“怎样写一个类”?侯捷开场提出的目标,是把一小段程序也写成接口清楚、初始化可靠的类型。单个类的规则站稳以后,才讨论几个类怎样合作。
来源档案:讲者为侯捷;原片标题页题为《C++面向對象程序設計》,画面带 GeekBand 标识。本次采用 B 站账号 DetachmentSy 于 2025 年上传的《侯捷 - C++面向对象高级开发(上)》,回放编号 BV1r6h5zgE2i,当前共 13 个分 P。上传者与原讲者分别署名,原录制年份及原课程是否另有删剪尚未核实。
本篇覆盖 P1《1.C++编程简介》、P2《2.头文件与类的声明》、P3《3.构造函数》。核对日期:2026-09-12;时间坐标仅适用于本上传版本。链接失效时,可用“侯捷 C++面向對象程序設計 GeekBand”及分 P 题名检索。
本文为 AI 辅助初加工的学习笔记:先通读上述三段完整音轨转录,再结合关键课件核对术语和代码。正文重新组织课堂推导;标为“整理者补充”的判断与实验不作为老师原话。
本门课程怎么读
本篇先解决三个相连的问题:类怎样划定访问边界,头文件怎样把接口交给别人,对象怎样在创建时取得合适的初值。后续学习应沿着课堂的 Complex 与 String 案例推进:先理解单个值如何参与运算,再理解对象拥有外部资源后,复制和销毁为什么需要重新设计,最后进入类之间的关系。
先修要求并不高,但需要扎实:知道变量、类型、作用域、循环、条件分支与函数,能编译、链接、运行一个小程序。P1 00:34–03:11 明确了这组前提,最好熟悉 C。来自 Java 或 C# 的读者也可以进入,尤其需要留意 C++ 的对象生存期与资源责任,不能只凭相似的关键词迁移理解。
P1 把学习分为单个类的设计与多个类的关系,并分别用 object-based、object-oriented 帮助定位课程阶段。这是课堂的组织方式;学习时真正要追踪的是:一个类型承诺什么,谁能改动它,使用者需要知道多少内部细节。
课程也把语言与标准库分开讨论。会写 class,还不等于能熟练使用容器、算法与资源封装。P1 的书目从《C++ Primer》《The C++ Programming Language》,到《Effective C++》《The C++ Standard Library》《STL 源码剖析》,展示了语法、设计经验、库使用、实现阅读逐步深入的路线。它们是当年的课堂书目,视频里的“最新版”不应理解为今天的版次结论。本文不把导览课件中的 C++03/TR1 并列写法当作二者等同的依据,也不把 STL 与整个标准库画等号。
先看数据在哪里,再决定类负责什么
P2 开头从“函数处理数据”转到“把数据与允许的操作组织在同一个类型中”。复数有实部、虚部,外部想做的是创建复数、读取数值、相加或取得共轭。类的接口因此围绕这些动作设计。
课堂随后画出两种对象。四个 Complex 各自有一份实部和虚部;这不意味着每个对象都要再存一份成员函数机器码。String 的图则不同:对象中有指针,字符内容放在另外分配的内存里。复制两个数值,通常已经复制了复数的值;复制一个地址,却未必复制了那个地址指向的内容。
这就是课程先讲“没有指针成员的类”,再讲“有指针成员的类”的原因。整理者补充:进一步应判断成员是否拥有资源。 借用指针并不负责 delete;反过来,std::string 成员虽然不是裸指针,也有资源,但其类型已经承担管理责任。这里先记住要追问责任,不把“看到指针就手写析构函数”当作规则。
P3 的访问级别把这条边界落实到语法。实部、虚部放进 private,读取函数放进 public;外部通过 z.real() 取得实部,不能直接访问私有的 z.re。内部辅助函数也可以是私有的,并非所有函数都必须公开。
这个变化的价值不只是把字段藏起来。使用者依赖“能读取实部”这个承诺,类的作者就能集中维护表示与操作的一致性。若以后加入约束或改变内部表示,不必让每个调用者自行适配字段操作。当然,一个单纯的数据记录也可以有意采用公开字段;这里讨论的是希望由类型维护边界的设计。
课堂用 C 的全局数据说明混杂访问的风险,需要保留示意边界:C 也能通过结构体、函数接口和文件内部链接组织模块,并没有“数据必须全部全局”的语言规定。
头文件是交付给使用者的一部分
P2 没有立即钻入运算符实现,而是先摆出头文件与测试程序。测试程序包含类型接口,再创建对象并观察结果。这一步提醒我们:写类既要考虑内部实现,也要考虑别人怎样开始使用它。
课堂输出演示最终采用现代的 <iostream> 形式。自写头文件常用 #include "complex.hpp",标准库头常用 #include <iostream>;引号与尖括号涉及查找方式及惯例,不是文件作者身份的类型检查。包含头文件提供所需声明,程序的编译和链接仍然是不同环节。
真正容易在小程序里漏掉的是重复包含。假设两个头文件都需要 Complex,测试程序又同时包含它们,同一个类定义就可能进入同一翻译单元两次。P2 12:11–16:03 的“防卫式声明”用一个宏记住是否已经处理过正文。下面是改用非保留宏名的结构示意,类的具体内容暂省略:
#ifndef HOUJIE_NOTES_COMPLEX_HPP
#define HOUJIE_NOTES_COMPLEX_HPP
// class Complex { ... };
#endif
第一次遇到该文件,宏尚未定义,于是处理正文并定义宏;再次遇到时跳过正文。课件采用双下划线形式的名字,原创代码不应照搬,因为含双下划线的标识符保留给实现使用。参见标准草案的保留标识符规则。
整理者补充:include guard 只解决当前翻译单元中的重复包含。 它不会自动补齐头文件依赖,也不会让所有函数定义都能在多个 .cpp 中随意重复。一个可独立包含的头文件,还应自己包含它实际需要的声明;调用者不应靠猜测某个特殊包含顺序才能编译。
这与 P3 的 inline 讨论可以接起来。普通头文件中,在类体内定义的成员函数隐式为 inline;类外定义可以显式写 inline。老师在 P3 03:09–04:03 已经强调:是否真的把函数体展开到调用点,由编译器决定。因此不能把课堂理解为“写了 inline 就必然更快”。
现代规则还赋予 inline 与多翻译单元定义有关的作用:在满足单一定义规则等条件时,可以在不同翻译单元中定义同一个 inline 函数。它的语言语义与机器码是否展开是两件事。本文限定在传统头文件使用方式,不把类内隐式 inline 的说法无条件推广到 named modules。参见标准草案 dcl.inline。
从两个数值到一个可创建的对象
P3 接下来让使用者创建复数。需要实部和虚部时传两个参数;不指定时,希望得到零。构造函数把这些创建方式集中到一个入口,名称与类名相同,不写返回类型,也不写 void。
课堂核心写法可缩成下面的类内片段,其他成员省略:
Complex(double r = 0, double i = 0)
: re(r), im(i) {}
Complex a(2, 1) 指定两个分量;Complex b 使用默认实参;Complex c(2) 只省略虚部。默认实参不是构造函数专属特性,普通函数也可以使用。它减少了重复的创建入口,但设计者必须知道哪些调用形状会因此变得可行。
这里最值得停下来的是冒号后的成员初始化列表。若把它删去,改在函数体里写 re = r; im = i;,对于本例的两个 double,最终数值可以一样。区别是前者在成员初始化阶段给值,后者进入函数体后才赋值。
整理者补充:这个区别首先是语义,其次才是可能的效率。 把成员换成一个类对象,函数体赋值意味着它此前已经先被初始化;把成员换成没有默认成员初始值的 const int,更不能靠进入函数体后赋值来补救。成员初始化按类中的声明顺序进行,最后才执行构造函数体,不由列表书写顺序重新排定。参见成员与基类初始化规则。
因此不能从这个两数值例子推导“列表写法在所有编译器上必定少若干条指令”。对简单数值,优化后的代码可能相同;养成初始化列表的习惯,是让创建过程直接表达对象应有的状态,并在成员变复杂以后仍保持正确。
课堂说构造函数随创建对象而调用,不像普通成员函数那样对已有对象直接调用。这不妨碍写 Complex(2, 1) 这样的构造表达式;它是在创建一个结果对象,不是在给已有对象调用一次“重新构造”的普通方法。析构及外部资源责任会在 String 案例中继续展开。
默认实参与重载一起出现时,要从调用处检查
P3 先用 real() 与 real(double) 示范重载:前者查询,后者修改,名称相同,但参数列表不同。修改函数不能沿用查询函数的 const 限定;const 成员函数的完整解释留待后续参数与接口设计。
课件还展示编译器处理后的符号名称,帮助理解同名函数如何在实现中被区分。整理时应区分两个层面:重载决议是语言规则,符号修饰是实现和 ABI 的事情,具体字串不是标准承诺。
随后出现一个更有用的反例:已有带全部默认实参的构造函数,再加一个无参构造函数。这两个入口都能接收“没有实参”的请求,编译器无法选出唯一最佳候选。问题不在于参数表面上写了几个,而在于调用时有几个候选同样可行。默认实参可以让参数较多的函数成为可行候选,参见标准草案 over.match.viable。
课堂口头上把这种情况概括为两个构造不能同时存在。更精确地说:两个声明可以共存,有实参的调用也可能完全正常;无参调用发生歧义。下面是整理者独立编写的 C++20 最小实验,不是完整复数实现:
#include <cassert>
struct Probe {
int chosen;
Probe() : chosen(0) {}
Probe(double, double = 0) : chosen(1) {}
};
struct Ambiguous {
Ambiguous() = default;
Ambiguous(double = 0, double = 0) {}
};
int main() {
Probe a;
Probe b(2);
assert(a.chosen == 0 && b.chosen == 1);
[[maybe_unused]] Ambiguous valid(2);
#ifdef TRY_AMBIGUOUS
Ambiguous invalid;
#endif
}
用 clang++ -std=c++20 -Wall -Wextra -Wpedantic -Werror constructor-ambiguity.cpp 编译并执行,断言通过;再加 -DTRY_AMBIGUOUS -fsyntax-only,编译器在 Ambiguous invalid 处报告构造调用歧义,并列出两个候选。已在 Apple clang 21 实测。这个实验同时验证了“声明能共存”与“某种调用失败”,避免把两个层面混为一谈。
Probe 的修复思路是让带参数构造至少要求一个实参;另一种方案是只保留带全部默认实参的构造。选择哪种取决于接口要表达什么,不需要为了展示重载而额外增加入口。
P2 结尾引入类模板也出于同样的设计意识:如果 Complex 只因 double、float、int 不同就复制三份实现,后续修改容易失步。template<typename T> 让数值类型暂时成为参数,使用时再指定。课程随即回到非模板案例,以免多个问题同时展开。模板减少这种重复,并不保证任意类型都满足复数操作的要求;先把一种明确类型的接口设计好,才能判断哪些部分值得泛化。
重点回看
| 分 P 与时间 | 关键词 | 回看要解决的问题 |
|---|---|---|
| P1 · 03:15–06:13 | 我们的目标、Complex、String | 为什么先设计单个类,再研究类关系 |
| P1 · 09:43–15:40 | 语言与标准库、Bibliography | 区分语法、库使用与实现阅读的学习任务 |
| P2 · 03:14–07:09 | 实部虚部、指向字符区的指针 | 对象内的数据与外部资源有什么差别 |
| P2 · 12:11–16:03 | Header、防卫式声明 | 第一次和第二次包含分别处理什么 |
| P2 · 18:44–22:46 | class template、typename T | 重复三份类的需求如何导向模板 |
| P3 · 01:35–09:53 | inline、access level | 区分是否展开与是否公开;比较字段访问和查询函数 |
| P3 · 10:01–20:17 | constructor、initialization list | 默认实参、成员初始化与函数体赋值如何衔接 |
| P3 · 20:29–27:14 | real 重载、两个构造候选 | 从调用表达式解释无参构造歧义 |
思考与实验
- 在最小实验里,把
Probe(double, double = 0)的第一个参数也改成默认值。先预测Probe a、Probe b(2)、Probe c(2, 3)哪些能编译,再逐个启用验证。检查诊断是否发生在声明处还是调用处。 - 建立一个含
const int value;的小类,不给默认成员初始值。分别尝试构造函数体中的value = n;与初始化列表中的value(n)。用编译结果解释:为什么这次差别已不只是效率? - 建一个头文件,里面有简单类定义,在同一
.cpp中包含两次。比较加 guard 前后的编译结果;再把一个普通非 inline 函数定义放进头文件,让两个.cpp都包含它并链接。第二步若出现重复符号,为什么第一步的 guard 没有解决?不要把某次链接器未报错当作 ODR 合法性的证明。
读完这里,可以接着对照站内《C++ 设计模式:从变化出发》。那篇以增加图形种类观察修改如何传播;本篇从一个类的字段与构造入口观察使用者依赖了什么。两者都可以用同一个检验来学习:改变一个需求,看看哪些调用代码被迫跟着修改,再判断接口是否承担了合适的责任。