# 类

Source: https://codewiki.com/zh/cpp/classes/

> - **what**: 类是用户定义类型，把对象的状态、可执行的操作和访问边界写在一个定义中。
> - **trap**: 构造函数体开始执行时，成员早已按声明顺序初始化；把初始化列表写成另一种顺序不会改变事实。
> - **fix**: 让构造函数建立不变量，用 `private` 保存受约束的状态，并优先让标准库成员提供正确的复制、移动与析构行为。

## 是什么，为什么存在

C++ 的类（class）是用户定义类型。类定义列出数据成员、成员函数、嵌套类型和访问规则；对象（object）则是该类型在运行时的一个实例。每个对象各有自己的非静态数据成员，并通过同一组成员函数提供的接口工作。

类把一个概念的表示和合法操作放在一起。例如，恒温器对象可以保存当前温度，同时只允许调用者设置设备支持的范围。这样，调用者不必记住每次写入前都重复验证，类型本身负责维护规则。

对象在所有可观察操作之间都应满足的规则称为类不变量（class invariant）。构造函数先建立它，公开成员函数在修改状态后继续保持它。`private` 不能自动保证正确性，但它能减少绕过这些检查的入口。

你会在值对象、配置、资源句柄、容器和服务接口中遇到类。类并不等于继承层次，也不要求使用虚函数。很多好用的 C++ 类只包含几个值类型成员和一小组直接操作。

## 工作原理

类定义引入一个类型名，并在花括号中声明成员。以对象表达式调用非静态成员函数时，函数会操作那个对象；在函数体中，`this` 指向当前对象。通常直接写 `width_`，等价于通过当前对象访问 `this->width_`。

访问说明符（access specifier）控制名称可从哪里使用。`public` 成员构成调用者可见的接口，`private` 成员只允许该类的成员与友元访问，`protected` 还向派生类开放。访问控制在编译期检查，它不改变对象是否占有某段内存。

`class` 中没有显式说明的成员默认是 `private`。同样的定义若使用 `struct`，默认则是 `public`。除此之外，两者都能拥有构造函数、成员函数、模板、基类和访问说明符。

构造函数负责创建可用对象。它没有返回类型，而且其名称由类类型决定。创建对象时，编译器根据实参选择可用构造函数，然后先初始化基类和成员，最后才进入构造函数体。

成员初始化器列表（member initializer list）写在构造函数参数列表与函数体之间。它是初始化成员的地方，不是简写的赋值语句。引用成员、`const` 成员，以及没有默认构造函数的成员都必须在进入函数体前得到合适的初始化。

非静态数据成员按照类定义中的声明顺序初始化，而不是按照初始化器列表的书写顺序。让初始化器列表采用同一顺序，可以让依赖关系一眼可见，也能避免读到尚未初始化的成员。析构时，这些成员按相反顺序销毁。

成员函数末尾的 `const` 限定当前对象。例如，`double area() const` 可以在 `const Rectangle` 上调用，并且不能通过普通成员访问修改对象的数据。只读取状态的观察函数通常都应带有这个限定。

一次普通对象创建经历以下步骤：

1. 编译器根据对象定义和实参选择构造函数。
2. 虚基类与直接基类先按语言规定的顺序初始化。
3. 非静态数据成员按声明顺序初始化。
4. 构造函数体执行，处理需要多个已初始化成员参与的检查或工作。
5. 对象在作用域内通过公开接口使用。
6. 生命周期结束时，析构函数体先执行，成员和基类随后按构造的逆序销毁。

这套顺序让成员对象自行管理生命周期。把 `std::string`、`std::vector` 或智能指针作为成员时，外层对象通常无需手写释放代码。编译器生成的操作会组合这些成员已有的行为。

## 示例

下面四个程序逐步加入访问控制、验证、复制行为和生命周期跟踪。它们都以 C++23 模式用本地 `g++` 编译并执行，输出来自实际运行。

### 一个最小的值类型

`Rectangle` 把表示放在 `private` 区域，只公开计算面积和缩放操作。调用者可以改变尺寸，但不能直接让其中一个成员绕过类接口单独变化。

<!-- quick -->

```cpp
// file: rectangle.cpp
#include <iostream>

class Rectangle {
public:
    Rectangle(double width, double height)
        : width_(width), height_(height) {}

    [[nodiscard]] double area() const {
        return width_ * height_;
    }

    void scale(double factor) {
        width_ *= factor;
        height_ *= factor;
    }

private:
    double width_;
    double height_;
};

int main() {
    Rectangle label{4.0, 2.5};
    std::cout << "area: " << label.area() << '\n';
    label.scale(0.5);
    std::cout << "scaled area: " << label.area() << '\n';
}
```

```text
area: 10
scaled area: 2.5
```

<!-- /quick -->

花括号中的 `4.0` 与 `2.5` 选择双参数构造函数。两个成员在构造函数体执行前由初始化器列表写入。`area()` 带有 `const`，因为计算面积不需要改变对象。

这个小例子尚未拒绝负尺寸。如果领域规则要求尺寸为正，构造函数和 `scale()` 都必须验证输入。仅仅把数据设为 `private` 不会凭空建立这条规则。

### 在构造时建立不变量

`Thermostat` 只接受 `5` 到 `30` 摄氏度。默认构造函数委托给接收温度的构造函数，因此两个创建入口共享同一份验证逻辑。

```cpp
// file: thermostat.cpp
#include <iostream>
#include <stdexcept>

class Thermostat {
public:
    Thermostat() : Thermostat(20) {}

    explicit Thermostat(int celsius) : celsius_(celsius) {
        validate(celsius_);
    }

    void set_temperature(int celsius) {
        validate(celsius);
        celsius_ = celsius;
    }

    [[nodiscard]] int temperature() const { return celsius_; }

private:
    static void validate(int celsius) {
        if (celsius < 5 || celsius > 30) {
            throw std::out_of_range{"temperature must be 5..30"};
        }
    }

    int celsius_;
};

int main() {
    Thermostat office;
    Thermostat lab{18};
    office.set_temperature(22);
    std::cout << "office: " << office.temperature() << '\n';
    std::cout << "lab: " << lab.temperature() << '\n';

    try {
        lab.set_temperature(2);
    } catch (const std::out_of_range& error) {
        std::cout << "rejected: " << error.what() << '\n';
    }
}
```

```text
office: 22
lab: 18
rejected: temperature must be 5..30
```

单参数构造函数使用 `explicit`，所以整数不会在函数调用中悄悄转换成 `Thermostat`。直接初始化 `Thermostat lab{18}` 仍然有效。`explicit` 禁止的是隐式转换，不是正常的显式构造。

`validate()` 不依赖具体对象，因此声明为 `static` 私有成员。创建和修改都通过它检查同一范围。非法修改先抛出异常，原来的温度不会改变。

### 让成员组合出值语义

`ReadingLog` 使用 `std::string` 和 `std::vector` 保存所有状态。类没有直接管理裸资源，因此不声明析构、复制或移动操作。

```cpp
// file: reading_log.cpp
#include <iostream>
#include <string>
#include <utility>
#include <vector>

class ReadingLog {
public:
    explicit ReadingLog(std::string owner)
        : owner_(std::move(owner)) {}

    void add(std::string title) {
        titles_.push_back(std::move(title));
    }

    [[nodiscard]] const std::string& owner() const { return owner_; }
    [[nodiscard]] std::size_t size() const { return titles_.size(); }

private:
    std::string owner_;
    std::vector<std::string> titles_;
};

int main() {
    ReadingLog original{"Mina"};
    original.add("The Left Hand of Darkness");

    ReadingLog copy = original;
    copy.add("Kindred");

    std::cout << original.owner() << ": " << original.size() << '\n';
    std::cout << copy.owner() << ": " << copy.size() << '\n';
}
```

```text
Mina: 1
Mina: 2
```

编译器生成的复制构造函数逐个复制成员。`std::vector` 的复制拥有独立元素存储，所以向 `copy` 添加书名不会改变 `original`。这种由成员组合行为的设计通常称为零法则。

`owner()` 返回 `const` 引用，避免每次观察时复制字符串，同时阻止调用者通过该接口修改名称。返回引用也带来生命周期约束：引用只在对应 `ReadingLog` 对象及其 `owner_` 成员仍然存活时有效。

### 观察成员的构造与析构顺序

`Report` 由两个 `Trace` 成员组成。输出直接展示声明顺序如何决定构造顺序，以及析构如何反向进行。

```cpp
// file: construction_order.cpp
#include <iostream>
#include <string>
#include <utility>

class Trace {
public:
    explicit Trace(std::string name) : name_(std::move(name)) {
        std::cout << "construct " << name_ << '\n';
    }

    ~Trace() {
        std::cout << "destroy " << name_ << '\n';
    }

private:
    std::string name_;
};

class Report {
public:
    Report() : header_{"header"}, body_{"body"} {
        std::cout << "report ready\n";
    }

    ~Report() {
        std::cout << "report done\n";
    }

private:
    Trace header_;
    Trace body_;
};

int main() {
    Report report;
    std::cout << "using report\n";
}
```

```text
construct header
construct body
report ready
using report
report done
destroy body
destroy header
```

`header_` 在 `body_` 之前声明，所以它先构造。离开 `main()` 时，`Report` 的析构函数体先运行，随后 `body_` 与 `header_` 依次销毁。成员对象的析构函数不需要由 `Report` 手工调用。

这个跟踪类型为了教学而输出生命周期事件。生产类通常不在析构函数中写日志，因为输出可能抛错、争用锁或发生在日志系统已经关闭之后；真正应依赖的是语言规定的顺序。

## 陷阱

### 公开表示破坏不变量

> **陷阱:** 把所有数据成员设为 `public`，或为每个成员机械生成 setter，会允许调用者组合出类本来不接受的状态。一个范围类型若分别暴露 `set_min()` 和 `set_max()`，中间步骤就可能出现 `min > max`。

**修复：** 按领域操作设计接口，例如一次设置整个范围，并在提交状态前检查参数。没有需要保护的关系时，公开数据的简单 `struct` 反而更诚实，不必强行写一圈 getter 和 setter。

### 初始化器列表不能重排成员

> **陷阱:** 初始化器列表的书写顺序不会改变成员的真实初始化顺序。若先声明的成员用到后声明成员的值，它读到的对象还没有完成初始化；读取未初始化的标量可能产生未定义行为。

**修复：** 先声明被依赖的成员，再声明依赖它的成员，并让初始化器列表保持同样顺序。启用编译器警告，`-Wall -Wextra` 通常能指出列表顺序与声明顺序不一致。

### 在构造函数体内赋值

> **陷阱:** 构造函数体里的 `member = value` 是赋值。进入函数体前，`member` 已经默认初始化，或者代码已经因无法初始化引用、`const` 成员或无默认构造函数的成员而不能通过编译。

**修复：** 用成员初始化器列表建立成员的初始值。函数体留给必须等多个成员都初始化后才能执行的验证或副作用，并尽量在产生外部副作用前完成可能失败的检查。

### 意外的隐式转换

> **陷阱:** 可用一个实参调用的构造函数可能成为转换构造函数。生成代码常漏掉 `explicit`，于是本来要求强类型对象的调用点也接受整数或字符串，重载选择随之变得难以判断。

**修复：** 除非转换本身就是接口设计的一部分，否则把这类构造函数声明为 `explicit`。在调用点使用花括号直接构造，明确表示要创建该类型。

### 裸拥有指针被逐成员复制

> **陷阱:** 编译器生成的复制会复制每个成员的值。若裸指针表示独占所有权，两个对象会得到同一个地址，之后可能重复释放；只补一个析构函数并不能修好复制语义。

**修复：** 优先用 `std::vector`、`std::string` 或智能指针表达所有权，并遵循零法则。确实需要自定义资源所有者时，要整体设计复制、移动与析构，相关细节见 `cpp/raii`、`cpp/smart-pointers` 和 `cpp/move-semantics`。

### 观察函数缺少 `const`

> **陷阱:** 一个只读成员函数若没有末尾的 `const`，就不能通过 `const` 对象或 `const` 引用调用。生成代码经常在 getter 中漏掉它，迫使调用者去掉本应保留的限定。

**修复：** 对不修改逻辑状态的观察函数加 `const`，并用 `const` 引用编译调用测试。缓存等实现细节若确实需要在观察函数中变化，要谨慎使用 `mutable`，不能借它绕过公开的不变量。

<!-- deep -->

## 编译器生成的特殊成员函数

C++ 把默认构造函数、复制构造函数、移动构造函数、复制赋值运算符、移动赋值运算符和析构函数统称为特殊成员函数（special member function）。在满足各自条件时，编译器会隐式声明其中一些函数，并在需要时生成定义。所谓「编译器会帮你生成所有东西」并不准确，因为任意一个用户声明都可能改变其余函数的生成条件。

只要类声明了任意构造函数，编译器就不会再隐式声明默认构造函数。需要无参创建时，可以显式写 `Type() = default`。不允许某种操作时则写 `= delete`，这样意图会出现在接口中，误用也会在编译时报错。

用户声明的析构函数会阻止移动构造函数和移动赋值运算符的隐式声明，即使析构函数写成 `= default`。声明移动构造或移动赋值中的任意一个，又会让隐式复制操作被定义为删除。正因这些规则会相互影响，拥有资源的类不能靠试错补函数。

零法则避开了大部分联动：让每个成员自己正确管理生命周期，外层类不声明任何特殊成员函数。默认生成的复制会逐成员复制，默认生成的移动会逐成员移动，析构则按逆序销毁成员。最终语义取决于成员类型，所以选择成员就是在选择外层类的复制与所有权契约。

下面的声明表达常见设计意图：

| 设计意图 | 典型声明 | 结果 |
|---|---|---|
| 普通值类型 | 不声明特殊成员函数 | 成员决定复制、移动与析构 |
| 只可移动的所有者 | 删除复制，默认或实现移动 | 复制在编译期失败 |
| 固定标识的对象 | 删除复制与移动 | 对象不能通过这些操作转移 |
| 自定义复制值类型 | 成组检查复制、移动与析构 | 每项都必须保持同一不变量 |

`= default` 不是「什么也不做」。它请求语言定义的成员式行为，并让函数的可用性、异常说明和删除状态继续由成员与基类决定。`= delete` 也不只用于复制操作，任何不应参加调用的函数都可以删除。

## 初始化与析构顺序

完整对象的构造顺序先处理虚基类，再处理直接基类，然后按声明顺序处理非静态数据成员，最后执行构造函数体。普通的无继承类只需关注成员声明顺序。析构过程反向进行，所以构造较晚的成员会更早销毁。

类内成员初始化器可以为成员提供默认初值，例如 `int retries_ = 3`。某个构造函数若在自己的初始化器列表中指定 `retries_`，该构造函数的值会覆盖类内默认值。这样可以把多个构造函数共享的安全初值放在成员声明旁边。

委托构造函数把初始化责任交给同一类的另一个构造函数。目标构造函数完成后，委托构造函数的函数体才执行；委托初始化器不能同时再列出其他成员。它适合让多个入口共享一条建立不变量的路径，就像前面的 `Thermostat()`。

如果成员构造期间抛出异常，已经完成构造的成员和基类会按逆序销毁。外层对象尚未完成构造，因此不会执行它自己的析构函数。这是把资源放进 RAII 成员的重要原因：已经创建成功的部分能自动清理，不需要构造函数手工回滚每一步。

析构函数应释放对象拥有的资源，但标准库成员已经会析构自己。手工调用成员的析构函数，或手工释放它已经管理的资源，会造成重复销毁。外层类通常只在直接拥有非 RAII 资源或需要额外生命周期动作时才写析构函数。

## `class` 与 `struct`

在 C++ 中，`class` 与 `struct` 都定义类类型。语言层面的主要区别只有默认访问：`class` 的成员和基类默认是 `private`，`struct` 的成员和基类默认是 `public`。显式写出访问说明符后，两者能表达相同的成员和继承结构。

惯例上，`struct` 常表示成员可直接读取的简单记录，`class` 常表示通过接口保护不变量的类型。这只是读者预期，不是编译器规则。带有构造函数的 `struct` 合法，只有数据成员的 `class` 也合法。

聚合类型（aggregate）由另一组语言规则决定，不能只看关键字。把 `class` 改成 `struct` 不一定让类型成为聚合，也不一定允许指定初始化。判断具体类型时，应根据 C++23 的聚合条件检查其构造函数、基类、虚函数和访问控制。

选择关键字时先看接口意图。如果调用者应直接组合和修改所有字段，`struct` 能清楚表达开放记录；如果每次状态变化都必须维护关系，使用带小型公开接口的 `class` 更合适。无论选择哪一个，所有权和生命周期仍须单独设计。

<!-- /deep -->

[检查点: cpp/classes](https://codewiki.com/zh/cpp/classes/#checkpoint)

## 延伸阅读

- [C++ 工作草案：类](https://eel.is/c++draft/class)
- [C++ 工作草案：构造函数](https://eel.is/c++draft/class.ctor)
- [C++ 工作草案：成员访问控制](https://eel.is/c++draft/class.access)
- [cppreference：类](https://en.cppreference.com/w/cpp/language/classes.html)
