专栏《Zero to C++》· 单元 09
阅读时间约 13 分钟。这三周自己定义类,并第一次写出资源不会泄漏的程序。
这三周的目标
前六周用的都是别人写好的东西:std::string、std::cout、std::getline。这三周开始写自己的类型。
单元 02 已经展示过类长什么样,那时它是别人写好的代码,你只是读。现在要自己定义、自己想清楚「这个类允许出现哪些状态」。
产物是 inspect.cpp 的第三个版本。它把散在 main 里的计数逻辑收进一个 WordCounter,再用一个 TallyFile 把结果写进文件------后者是这个专栏里第一个资源不会泄漏的程序。
类:把状态和行为装在一起
第 8 周结束时,main 里是这样的:
cpp
std::string text;
std::size_t words = 0;
// ...读入若干行,每读一行就更新 text 和 words...
text 和 words 是分离的两个变量。它们之间有约束关系------words 必须等于 text 里的词数------但这条约束只存在于你的记忆里。某个地方改了 text 忘了改 words,程序不会报错,只会算错。
类要做的就是把它们绑到一起,并且让违反约束这件事在编译期就不可能:
cpp
class WordCounter {
public:
// 不变式:words_ 始终等于 text_ 里的总词数
void add(const std::string& line) {
words_ += count_words(line);
text_ += line;
text_ += '\n';
}
std::size_t chars() const { return text_.size(); }
std::size_t words() const { return words_; }
private:
std::string text_;
std::size_t words_ = 0;
};
words_ += count_words(line); 和 text_ += line; 这两行永远一起出现,因为它们只写在一个地方------add 里。外部代码想改内容,只能通过 add,于是 words_ 必然跟着更新。
这就是不变式 :对象在整个生命周期里始终成立的条件。这里的条件是「words_ 等于 text_ 的词数」。类的价值,在于把这条条件从「靠人记得」变成「结构上不可能违反」。
构造函数:给对象一个合法的起点
上面的 WordCounter 没有显式写构造函数,因为它的成员都有合理的默认值(空字符串和 0)。需要参数时就要自己写:
cpp
class TallyFile {
public:
explicit TallyFile(const std::string& path) : out_(path, std::ios::app) {}
// ...
private:
std::ofstream out_;
};
冒号后面那句 out_(path, std::ios::app) 叫初始化列表 。成员在这里被初始化------写进函数体的 out_ = ... 是赋值,不是初始化。
初始化列表有个容易踩的坑:它的执行顺序由成员的声明顺序决定,不由你写在冒号后面的顺序决定。
这个规则听起来只是措辞问题,但一个成员要用另一个成员来算初值时,它就会出错:
cpp
class Bad {
public:
Bad(int n) : b_(n), a_(b_ * 2) {} // 写的是 b_ 在前
int a() const { return a_; }
int b() const { return b_; }
private:
int a_; // 声明顺序:a_ 在前
int b_;
};
按写的顺序读,b_ 应该是 5、a_ 应该是 10。实际不成立------a_ 声明在前,所以先初始化 a_,而那时 b_ 还没有值。实测输出:
a_ = 0 b_ = 5
a_ 读到的是尚未初始化的 b_,它的值不保证是多少,这次的 0 只是碰巧。开 -Wall 会同时给出两条警告,一条说初始化顺序和写的顺序不一致,一条说用了未初始化的成员:
warning: 'Bad::b_' will be initialized after [-Wreorder]
warning: member 'Bad::b_' is used uninitialized [-Wuninitialized]
避开它的办法很简单:初始化列表的顺序和声明的顺序写成一致。养成这个习惯之后,这条规则就不需要记住了。
explicit 的作用是禁止编译器拿这个构造函数做隐式转换。少了它,TallyFile f = "a.txt"; 这种写法会被悄悄接受;加上之后编译器会拒绝。
第三种 const:成员函数后面的那个
单元 07 讲过 const 用在变量上,单元 08 讲过它用在参数上。类里还有第三种位置------写在成员函数的参数列表后面:
cpp
class WordCounter {
public:
void add(const std::string& text) { /* ... */ }
std::size_t chars() const { return chars_; } // 注意这个 const
private:
std::size_t chars_ = 0;
};
它修饰的是调用这个函数的那个对象 ,意思是「这个函数不会改动对象」。chars_ 在 chars() const 里只能读,不能写。
不加这个 const 会怎样?看这段:
cpp
const WordCounter c;
c.add("hello"); // 编译不过
实测 g++ 的报错:
error: passing 'const WordCounter' as 'this' argument discards qualifiers
报错的措辞绕,含义直白:c 是只读的,而 add 没有承诺不改它,所以编译器不让调。反过来,chars() const 就能调------它承诺了。
于是得到一个判据,同时管两件事:能加 const 的成员函数就加上。 加了之后,只读对象能用的函数就多了(不给别人添麻烦),编译器也会替你盯着------哪天你在 const 函数里改了成员,它立刻报错。
WordCounter 里的 chars() 和 words()、后面单元 10 里的 top(),都是这个写法。
一个 C 的对照
把 WordCounter 按 C 的写法重写,问题就现形了:
c
typedef struct {
char text[256];
size_t words; /* 公开字段,谁都能改 */
} Counter;
int main(void) {
Counter c;
c.text[0] = '\0';
strcat(c.text, "I love C++");
c.words = 3; /* 手工维护,靠人记得 */
c.words = 99; /* 随手一改,不变式就没了 */
printf("text = %s words = %zu\n", c.text, c.words);
return 0;
}
实测输出:
text = I love C++ words = 99
words 和 text 对不上了,而程序全速跑完,没有任何提示。这不是 C 的缺陷------C 的结构体本来就不承诺任何不变式,它只是一个数据打包工具。责任在使用者身上,由使用者保证每次修改都配对。
单元 01 讲的那三种习惯里,这一条是根源:C 里数据是开放的,约束靠约定;C++ 里数据可以关起来,约束靠类型。 private: 那一行就是分界线。
析构函数与 RAII
TallyFile 里有这么一个成员:
cpp
std::ofstream out_;
它管着一个打开的文件。这个文件什么时候关?不用你管。TallyFile 的对象销毁时,out_ 作为它的成员被自动销毁,std::ofstream 的析构函数负责关闭文件。
三条路径都会触发这个销毁:
main正常执行到return 0。- 中途
return 1提前退出。 - 某处抛出异常。
三种情况下文件都被正确关闭,你一行都不用写。 这就是 RAII:把资源的生命周期绑到对象上。
单元 01 里那个 C 的 fopen / fclose 版本,第二条和第三条路径上都要手写 fclose。漏一条就是一次泄漏。
一份被删掉的代码
TallyFile 里有一对看起来莫名其妙的声明:
cpp
TallyFile(const TallyFile&) = delete;
TallyFile& operator=(const TallyFile&) = delete;
它们做的事是禁止拷贝 。如果不写,TallyFile a("x.txt"); TallyFile b = a; 会编译通过,然后两个对象析构时都去关同一个文件------第二次关的时候,那个文件已经不属于它们了。
= delete 是 C++11 的写法,意思是「这个函数我明确不要,谁调用谁编译失败」。它对 RAII 类来说是默认动作:管着独占资源的对象,通常不该被拷贝。
做出工具:inspect.cpp 第三版
cpp
// inspect.cpp ------ 文本小助手 v3
// 编译:g++ -std=c++20 -Wall -Wextra inspect.cpp -o inspect
#include <cstddef>
#include <fstream>
#include <iostream>
#include <string>
std::size_t count_words(const std::string& line) {
std::size_t n = 0;
bool in_word = false;
for (char c : line) {
if (c == ' ') {
in_word = false;
} else if (!in_word) {
in_word = true;
++n;
}
}
return n;
}
class WordCounter {
public:
// 不变式:words_ 始终等于 text_ 里的总词数
void add(const std::string& line) {
words_ += count_words(line);
text_ += line;
text_ += '\n';
}
std::size_t chars() const { return text_.size(); }
std::size_t words() const { return words_; }
private:
std::string text_;
std::size_t words_ = 0;
};
class TallyFile {
public:
explicit TallyFile(const std::string& path) : out_(path, std::ios::app) {}
bool ok() const { return static_cast<bool>(out_); }
void write(const std::string& s) { out_ << s; }
TallyFile(const TallyFile&) = delete;
TallyFile& operator=(const TallyFile&) = delete;
private:
std::ofstream out_; // 析构时自动关闭
};
int main() {
WordCounter counter;
std::cout << "输入文本,空行结束:\n";
std::string line;
while (std::getline(std::cin, line) && !line.empty()) {
counter.add(line);
}
std::cout << "字符数:" << counter.chars() << '\n';
std::cout << "词数:" << counter.words() << '\n';
TallyFile log("tally.txt");
if (!log.ok()) {
std::cout << "打不开 tally.txt\n";
return 1;
}
log.write("chars=" + std::to_string(counter.chars())
+ " words=" + std::to_string(counter.words()) + "\n");
std::cout << "结果已写入 tally.txt\n";
return 0;
}
输入两行 I love C++ 和 C++ loves me,再按一次回车结束,实测输出:
输入文本,空行结束:
字符数:24
词数:6
结果已写入 tally.txt
tally.txt 的内容:
chars=24 words=6
注意 main 里从头到尾没有一行 close。文件在 TallyFile 对象离开作用域时被关闭。
第 7--9 周的验收
- 能给自己的类写出一条明确的不变式,并说明它在哪一行被维持
- 知道
private和public的分界该怎么划 - 能写出构造函数和初始化列表
- 能说出初始化列表按什么顺序执行,以及顺序写错的后果
- 能说出成员函数后面的
const是什么意思 - 能解释 RAII 在
TallyFile里具体是怎样让文件关上的 - 能说出
= delete那一对声明防的是什么
下一篇是路线图的最后一段:用 STL 的容器和算法把这个工具重写一遍,去掉手写的循环,并第一次用上 lambda。
附录 · 术语表
本单元出现的词。不要求记住,读的时候遇到不懂的回来查一眼。
类的语法
类(class) --- 把数据和对数据的操作打包在一起的自定义类型。
对象(object) --- 类的一个实例。WordCounter counter; 创建了一个对象。
成员变量(member variable) --- 写在类里面的数据。text_ 和 words_ 都是。
成员函数(member function) --- 写在类里面的函数。add、chars、words 都是。
private --- 只有类自己的代码能访问这些成员。外部代码碰不到。
public --- 谁都能访问。
构造函数(constructor) --- 对象创建时自动调用,用来建立初始状态。名字和类名相同。
初始化列表 --- 构造函数冒号后面那一段,形如 out_(path, std::ios::app)。成员在这里被初始化,执行顺序按成员的声明顺序。
const 成员函数 --- 写在参数列表后面的 const,含义是「这个函数不会改动对象」。只读对象只能调用这类函数。
析构函数(destructor) --- 对象销毁时自动调用,写作 ~类名()。
explicit --- 禁止这个构造函数被用于隐式转换。
= delete --- 明确表示「这个函数不要」,调用它会导致编译失败。
const 成员函数 --- 函数名后面写 const,表示它不修改这个对象。
本单元的核心概念
不变式(invariant) --- 对象在生命周期里始终成立的条件。WordCounter 的不变式是「words_ 等于 text_ 的词数」。
封装 --- 用 private 挡住外部直接访问数据,只留出受控的接口。
RAII --- 把资源的获取放进构造函数、释放放进析构函数,让资源生命周期跟着对象走。
资源(resource) --- 程序向系统借来的东西:内存、文件、锁、连接。借了就得还。
泄漏(leak) --- 借了资源没有还。文件句柄泄漏用久了会让程序打不开新文件。
本单元的代码里出现的
std::ofstream --- 输出文件流。构造时打开,析构时自动关闭。
std::ios::app --- 以「追加」方式打开文件,新内容写在末尾。
static_cast<bool>(out_) --- 判断这个文件流是不是处于可用状态。
std::to_string(x) --- 把数字转成字符串,方便拼接。
while (std::getline(std::cin, line) && !line.empty()) --- 一直读行,读到空行为止。