C++ 类把状态与操作组织成用户定义类型;这里讲清访问控制、构造与不变量,以及编译器生成特殊成员函数时的边界。

难度 入门 时长 标准深度约 12分钟
版本 C++23
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::stringstd::vector 或智能指针作为成员时,外层对象通常无需手写释放代码。编译器生成的操作会组合这些成员已有的行为。

示例

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

一个最小的值类型

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

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';
}
area: 10
scaled area: 2.5

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

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

在构造时建立不变量

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

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';
    }
}
office: 22
lab: 18
rejected: temperature must be 5..30

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

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

让成员组合出值语义

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

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';
}
Mina: 1
Mina: 2

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

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

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

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

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";
}
construct header
construct body
report ready
using report
report done
destroy body
destroy header

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

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

陷阱

公开表示破坏不变量

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

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

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

在构造函数体内赋值

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

意外的隐式转换

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

裸拥有指针被逐成员复制

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

观察函数缺少 const

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

深入 编译器生成的特殊成员函数

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

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

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

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

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

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

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

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

初始化与析构顺序

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

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

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

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

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

classstruct

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

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

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

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

延伸阅读

检查点

4个问题 · 1 道输出预测题 · 1 道找错题

复制为 Markdown 面试题库 在 GitHub 上编辑 报告错误 讲清楚了吗?