想象一个课程报名系统。每门课有课程代码、容量和已报名学号。刚开始时,我们可能只想定义三个变量,然后在需要的地方直接修改。
问题很快就会出现:容量能否为 0?同一个学号能否登记两次?满员后谁来拒绝新报名?如果任何代码都能直接改容量和名单,这些规则就会散落在整个程序里。
类可以把表示状态的数据和维护状态的操作放在一个类型中。本文会把一包公开数据逐步演化成可直接编译的 CourseRoster,并说清它为何始终保持有效。
最直接的版本可能是一个 struct:
#include <cstddef>
#include <string>
#include <vector>
struct CourseRoster {
std::string course_code;
std::size_t capacity;
std::vector<std::string> student_ids;
};它能表示数据,却没有表达哪些状态合法。下面的代码能通过编译:
CourseRoster roster{"", 0, {"S-001", "S-001"}};
roster.capacity = 1;
roster.student_ids.push_back("S-002");此时课程代码为空,容量是 1,名单却有 3 个条目,还有重复学号。格式上它仍是 CourseRoster,业务上已经无法使用。
设计用户自定义类型时,先回答两个问题:
对花名册来说,“登记学号”是有意义的操作,“给容量随意减 100”却不是。我们希望外部代码表达意图:
roster.enroll("S-002");这比在每个调用点重复容量、格式和去重检查更可靠。
类不是为了模仿每一个现实名词。当一组数据有稳定规则和明确操作时,定义类型才能让代码更准确。如果数据只是自由取值的简单记录,保持为 struct 往往更清楚。
不变量是一个可用对象在稳定状态下必须满足的条件。CourseRoster 先定义四条规则:
构造函数要创建第一个有效状态,每个修改操作在返回时仍要保持这些条件。
public 表达用法,private 隐藏表示下面是第一个可运行版本:
#include <algorithm>
#include <cstddef>
#include <iostream>
#include <stdexcept>
#include <string>
#include <utility>
#include <vector>
class CourseRoster {
public:
CourseRoster(std::string code, std::size_t capacity)
: code_{std::move(code)}, capacity_{capacity} {
输出为:
true
false
true
false
size=2外部代码无法直接向 student_ids_ 追加元素,只能通过 enroll() 请求修改。该函数先检查空学号、满员和重复,成功修改后仍保持不变量。
private 不会自动让数据正确。它只把直接修改表示的代码限制在类内。如果 enroll() 本身没有检查容量,类仍然会制造无效状态。
class 和 struct 都可以有数据成员、成员函数和构造函数。关键差别是 class 成员默认为 private,struct 默认为 public。习惯上,简单数据聚合常用 struct,需要保护不变量的类型常用 class。
protected 主要给派生类提供访问,不是“对外只读”。这个花名册没有继承需求,数据保持 private。

enroll、contains、full 和 size 进入 CourseRoster;私有状态由课程代码非空、容量大于零、人数不超容量及学号非空且唯一四条不变量共同守护。下面的实验室提供两种访问方式。先在“公开字段”模式中直接改动容量和名单,观察无效状态如何出现;再切换到受控接口,用构造和成员函数重新建立不变量。末尾的接口分类练习用来检查哪些操作应该是查询、修改或根本不应公开。
this 与只读承诺同一份 full() 代码可以用于多个对象:
CourseRoster morning{"CPP-AM", 30};
CourseRoster evening{"CPP-PM", 60};
bool a = morning.full();
bool b = evening.full();第一次调用检查 morning,第二次检查 evening。成员函数通过隐含的当前对象区分它们。this 是指向该对象的指针:
bool CourseRoster::full() const {
return this->student_ids_.size() >= this->capacity_;
}this-> 在这里可以省略。只有当参数与成员同名,或需要引用整个当前对象时,它才常被显式写出。this 是指针,*this 才是当前对象。返回 *this 可以支持连续调用,但只应在这种用法符合类的语义时使用。
const 成员区分观察与修改参数列表后的 const 承诺不通过当前对象修改普通数据成员:
class CourseRoster {
public:
std::size_t size() const;
bool full() const;
bool contains(const std::string& id) const;
bool enroll(std::string id);
};
void print_summary(const CourseRoster
非 const 对象也能调用 const 成员。因此,查询函数正确标记 const 会让它适用于更多语境。

this 指向当前调用对象;const 查询成员适用于普通与只读对象,修改成员只能用于普通对象。设计接口时,先把操作分成“观察状态”和“修改状态”。前者应尽量是 const 成员,后者要明确失败方式并保持不变量。
如果初始化依赖普通 init() 函数,调用者可能忘记它,留下“对象已存在,规则尚未建立”的窗口。构造函数则是对象创建的一部分:
class CourseRoster {
public:
explicit CourseRoster(std::string code, std::size_t capacity = 30);
private:
std::string code_;
std::size_t capacity_;
std::vector<std::string> student_ids_;
};
CourseRoster::CourseRoster(std::string code, std
code_ 和 capacity_ 在成员初始值列表中直接初始化。student_ids_ 未出现在列表中,它调用 std::vector 的默认构造,得到空容器。
如果在构造函数体中才写 code_ = std::move(code);,code_ 在进入函数体前已经默认构造,该语句是赋值,不是它的第一次初始化。const 成员、引用成员和无默认构造的成员更必须在初始值列表中初始化。
成员按类定义中的声明顺序构造,与初始值列表中的排列无关:
class Snapshot {
public:
explicit Snapshot(std::size_t count)
: labels_(count), cached_size_{labels_.size()} {}
private:
std::vector<std::string> labels_; // 先声明,先构造
std::size_t cached_size_; // 可以使用 labels_
};初始值列表也应按声明顺序排列,让代码外观与真实顺序一致。开启 -Wall -Wextra 后,编译器通常会对可疑的逆序列表给出警告。

Session 生命周期从成员声明顺序开始:成员先依次构造,再进入构造函数体;销毁时先执行析构函数体,随后成员按声明逆序退出。初始值列表的书写顺序不会改变真实顺序。CourseRoster cpp{"CPP-101"}; 省略容量时使用 30。这是构造参数的默认值,不等于无参默认构造;CourseRoster unknown; 仍不合法,因为没有课程代码的花名册不是有效对象。
由于该构造函数能以一个实参调用,explicit 可以阻止字符串被意外转成整个花名册:
void archive(CourseRoster roster);
std::string code{"CPP-101"};
// archive(code); // 错误:禁止隐式转换
archive(CourseRoster{code}); // 正确:明确构造随着功能增加,把所有函数体都塞进类定义会掩盖接口。保留声明后,使用者可以快速看到对象如何创建、哪些操作只读、哪些操作会修改状态:
class CourseRoster {
public:
explicit CourseRoster(std::string code, std::size_t capacity = 30);
const std::string& code() const noexcept;
std::size_t capacity() const noexcept;
std::size_t size() const
实现放在类定义之后,多文件项目中通常放在 .cpp 文件。CourseRoster:: 说明函数属于哪个类:
std::size_t CourseRoster::size() const noexcept {
return student_ids_.size();
}
bool CourseRoster::remove(std::string_view id) {
const auto it = std::find_if(student_ids_.begin(), student_ids_.end(),
[id](const std::
“还有多少空位”只需公开查询,无需成员或友元权限:
std::size_t available_seats(const CourseRoster& roster) noexcept {
return roster.capacity() - roster.size();
}这可以保持成员接口较小,也缩小能直接破坏私有表示的函数集。
输出运算符的左侧是 std::ostream,因此适合是非成员函数。这里只授权该函数读取表示:
std::ostream& operator<<(std::ostream& out, const CourseRoster& roster) {
out << roster.code_ << " (" << roster.student_ids_.size()
<< '/' << roster.capacity_ << ')';
for (const auto& id : roster.student_ids_) out << "\n - " <<
operator<< 仍是非成员函数。friend 只授予被指定函数访问权,不会自动双向、传递或扩散。如果输出所需信息已能通过简洁的公开查询获取,就可以取消友元。
不要为了少写几个查询函数,就把整个工具类或大量函数声明为友元。每增加一个友元,都扩大了依赖私有表示的范围。
局部自动对象在执行到定义时构造,离开作用域时析构。构造时先按声明顺序构造成员,再进入外层构造函数体;销毁时先执行外层析构函数体,再逆序销毁成员。
#include <iostream>
#include <string>
#include <utility>
class Trace {
public:
explicit Trace(std::string name) : name_{std::move(name)} {
std::cout << "构造 " << name_ << '\n';
}
~Trace() { std::cout <<
输出为:
进入作用域
构造 花名册
构造 缓存
进入 Session 构造函数体
使用对象
进入 Session 析构函数体
析构 缓存
析构 花名册
离开作用域roster_ 先声明,所以先构造;销毁时顺序反过来,cache_ 先析构。这种反顺序让后构造、可能依赖早期成员的对象先退出。
下面的追踪器把这条规则变成可以逐步执行的时间线。你可以故意交换成员声明与初始值列表的排列,比较两者对真实构造顺序的影响;另一组控件会同步显示普通对象和 const 对象的 this 指向与成员调用是否合法。
析构函数名由 ~ 加类名组成。一个类如果直接拥有必须归还的资源,构造与析构应形成配对:构造时取得资源并建立有效状态,析构时归还仍被拥有的资源。资源可以是内存、文件、锁或套接字。
这种把资源生命期绑定到对象生命期的做法,让正常返回和异常退出都经过同一套自动清理。
CourseRoster 的 std::string 和 std::vector 已会管理自己的资源。对象销毁时,生成的析构函数会自动调用这些成员的析构函数,无需再写空的 ~CourseRoster() {}。
不要显式直接调用普通对象的析构函数。那会提前结束内部部件的生命期,而作用域结束时还可能再次析构。让作用域和拥有型成员负责清理。
不同花名册各有一份课程代码、容量和学号列表。但“单课最大容量是 300”属于整个类型,不需要每个对象再存一份。C++17 可以在类内定义这个静态常量:
class CourseRoster {
public:
static constexpr std::size_t max_capacity() noexcept {
return hard_limit_;
}
static bool valid_student_id(std::string_view id);
private:
inline static constexpr std::size_t hard_limit_{300};
std::string code_;
std::size_t capacity_;
静态成员仍受访问控制。外部可调用 CourseRoster::max_capacity(),却不能直接访问 hard_limit_。
this学号格式检查不依赖某个花名册对象:
bool CourseRoster::valid_student_id(std::string_view id) {
if (id.size() < 3 || id.size() > 16) return false;
return std::all_of(id.begin(), id.end(), [](unsigned char ch) {
return std::isalnum(ch)
输出是 true 和 false。静态成员函数不针对具体对象,因而没有 this,也不能在没有显式对象的情况下直接读取 capacity_ 或 student_ids_。
静态常量容易理解,静态可变数据则被所有对象共享,修改它会影响全局行为,还可能需要处理并发访问。不要只因为“所有对象都能看到”就把大量可变状态放进静态成员。
CourseRoster morning{"CPP-AM", 30};
morning.enroll("S-001");
CourseRoster copy = morning; // 复制构造:copy 在此诞生
CourseRoster evening{"CPP-PM", 60};
evening = morning; // 复制赋值:evening 早已存在复制构造为新对象建立初始值;复制赋值用源对象的值替换已存在目标对象的旧值。
默认复制依次复制每个成员。CourseRoster 的 std::string、std::size_t 和 std::vector<std::string> 都有合适的值语义,因此修改副本不会改动原对象。
如果类直接使用指针拥有资源,逐成员复制只会复制指针保存的地址:
class RawBuffer {
std::size_t size_;
char* data_; // 若表示独占所有权,默认复制通常不对
};复制后两个对象可能指向同一块存储,销毁时就可能重复释放。默认复制是否正确,由每个成员的复制语义决定。深拷贝与移动操作需要单独处理,此处不展开实现。
如果一个概念本就不应复制,可在编译期禁止:
ActiveSession(const ActiveSession&) = delete;
ActiveSession& operator=(const ActiveSession&) = delete;Rule of Zero 鼓励使用会自己管理资源的成员组合业务类,不手写析构、复制或移动操作。CourseRoster 依赖 std::string 和 std::vector 的资源管理与值语义,自己只维护业务规则。

std::string 与 std::vector 组合的 CourseRoster 默认复制为彼此独立的值,原始拥有指针则需要额外审查所有权语义。#include <algorithm>
#include <cctype>
#include <cstddef>
#include <iostream>
#include <stdexcept>
#include <string>
#include <string_view>
#include <utility>
#include <vector>
class CourseRoster {
public:
explicit CourseRoster(std::string code, std::size_t capacity = 30);
const
输出为:
S-001 -> true
S-002 -> true
S-001 -> false
! -> false
原对象
CPP-101 (2/3)
- S-001
- S-002
副本
CPP-101 (2/3)
- S-002
- S-003这个版本没有手写析构、复制构造或复制赋值。每个成员都有正确的生命期和复制语义,生成操作就是合适实现。
完成小类时,最后检查:概念是否清楚,不变量是否明确,构造是否立即建立有效状态,公开接口是否小而完整,查询是否标记 const,友元是否真必要,生成析构和复制是否符合每个成员的语义。