单元九 · 零基础路线图-第 7–9 周·类与 RAII

专栏《Zero to C++》· 单元 09

阅读时间约 13 分钟。这三周自己定义类,并第一次写出资源不会泄漏的程序。


这三周的目标

前六周用的都是别人写好的东西:std::stringstd::coutstd::getline。这三周开始写自己的类型。

单元 02 已经展示过类长什么样,那时它是别人写好的代码,你只是读。现在要自己定义、自己想清楚「这个类允许出现哪些状态」。

产物是 inspect.cpp 的第三个版本。它把散在 main 里的计数逻辑收进一个 WordCounter,再用一个 TallyFile 把结果写进文件------后者是这个专栏里第一个资源不会泄漏的程序

类:把状态和行为装在一起

第 8 周结束时,main 里是这样的:

cpp 复制代码
std::string text;
std::size_t words = 0;
// ...读入若干行,每读一行就更新 text 和 words...

textwords 是分离的两个变量。它们之间有约束关系------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

wordstext 对不上了,而程序全速跑完,没有任何提示。这不是 C 的缺陷------C 的结构体本来就不承诺任何不变式,它只是一个数据打包工具。责任在使用者身上,由使用者保证每次修改都配对。

单元 01 讲的那三种习惯里,这一条是根源:C 里数据是开放的,约束靠约定;C++ 里数据可以关起来,约束靠类型。 private: 那一行就是分界线。

析构函数与 RAII

TallyFile 里有这么一个成员:

cpp 复制代码
std::ofstream out_;

它管着一个打开的文件。这个文件什么时候关?不用你管。TallyFile 的对象销毁时,out_ 作为它的成员被自动销毁,std::ofstream 的析构函数负责关闭文件。

三条路径都会触发这个销毁:

  1. main 正常执行到 return 0
  2. 中途 return 1 提前退出。
  3. 某处抛出异常。

三种情况下文件都被正确关闭,你一行都不用写。 这就是 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 周的验收

  • 能给自己的类写出一条明确的不变式,并说明它在哪一行被维持
  • 知道 privatepublic 的分界该怎么划
  • 能写出构造函数和初始化列表
  • 能说出初始化列表按什么顺序执行,以及顺序写错的后果
  • 能说出成员函数后面的 const 是什么意思
  • 能解释 RAII 在 TallyFile 里具体是怎样让文件关上的
  • 能说出 = delete 那一对声明防的是什么

下一篇是路线图的最后一段:用 STL 的容器和算法把这个工具重写一遍,去掉手写的循环,并第一次用上 lambda。


附录 · 术语表

本单元出现的词。不要求记住,读的时候遇到不懂的回来查一眼。

类的语法

类(class) --- 把数据和对数据的操作打包在一起的自定义类型。

对象(object) --- 类的一个实例。WordCounter counter; 创建了一个对象。

成员变量(member variable) --- 写在类里面的数据。text_words_ 都是。

成员函数(member function) --- 写在类里面的函数。addcharswords 都是。

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()) --- 一直读行,读到空行为止。

相关推荐
无忧.芙桃1 小时前
嵌入式(一):嵌入式历史与计算机基础:从 ENIAC 到 MCU 四大技术方向
c语言·单片机·嵌入式硬件·计算机
布莱克6051 小时前
C++ 七大排序算法详解:选择、冒泡、插入、计数、堆、快速与归并排序
数据结构·c++·算法·排序算法
一只旭宝1 小时前
Python 与 C/C++ 内存模型对比总结
c语言·c++·python
心态还需努力呀2 小时前
把 Flameshot 编译进鸿蒙 PC:一次 Qt C++ 截图工具的源码级移植实战
c++·qt·harmonyos
嵌入式er2 小时前
经纬恒润(成都)嵌入式一面 + 二面
单片机·嵌入式硬件
shehuiyuelaiyuehao2 小时前
算法44,模拟算法,数青蛙
算法·哈希算法·散列表
白色的北极熊2 小时前
c语言 输出菱形字符串
c语言·开发语言
hanlin032 小时前
刷题笔记:力扣第287题-寻找重复数
笔记·算法·leetcode
kevin_kang2 小时前
第02章 对话时间线与延迟分析
算法