2.2 类与不变式
在各类 C++ 基础教材中关于类的章节里,我们经常见到类似这样的示例:
class Person {
public:
std::string name;
int age;
void say_hello() const {
std::cout << "Hello, my name is " << name << " and I am " << age
<< " years old." << std::endl;
};
};
Person person;
person.name = "Alice";
person.age = 30;
person.say_hello();这样的代码使用了类语法,却没有形成有意义的抽象。如果使用仅包含 name 和 age 的 struct,再另外定义 say_hello(const Person& person),岂不是差不多的效果?在这里,name 和 age 基本还是把一堆变量凑在一起然后起了个名字而已,也不保证其中的值有效。
我们这里引入不变式(invariant)的概念:只要一个对象处于可正常使用的状态,无论对象如何被修改(通过方法调用或属性变更
建立与维护不变式,是类的核心价值。
从一个无效分数开始
当我们用 int 类型属性 score 保存成绩:
struct Student {
int id{};
std::string name;
int score{};
};但 score 可以被写成 -20 或 500。如果每个使用它的函数都自行检查范围,迟早会有一处遗漏。
当然,我们可以定义 class Student,把 score 变成私有属性,然后定义 getter 和 setter 来限制外部对 score 的访问。但是这样做也有缺陷:一旦离开 Student,限制就消失了。变量、函数参数可能都需要独立于 Student 但有范围的分数。
换种想法,考虑语义,作为「分数」本身的性质,为什么需要在 Student 上限制它、改变 Student 的语义呢?其实并不是 Student 的问题,而是 int score 作为「一个任意整数」不能准确表达「0 到 100 之间的分数
#include <stdexcept>
class Score {
public:
explicit Score(int value) : value_{value} {
if (value < 0 || value > 100) {
throw std::out_of_range{"Score must be between 0 and 100"};
}
}
[[nodiscard]] int value() const noexcept { return value_; }
[[nodiscard]] bool passed(int passing_score = 60) const noexcept {
return value_ >= passing_score;
}
private:
int value_;
};
const Score foo{85};
const Score bar{120};
// 运行时报错:Score must be between 0 and 100现在,任何成功构造出的 Score 都满足范围要求。后续函数接收 Score 时,不必重新验证它是不是合法分数。在这里,分数在区间 就是 Score 类所维护的不变式,构造函数保卫了「分数」的语义。
这里暂时用异常报告构造失败。也可以使用返回
optional或expected的工厂函数。这里的
noexcept表示函数承诺不会让异常逃出。读取一个整数和进行比较不会失败,因此可以给出这个保证。不要为了装饰接口而到处添加noexcept;一旦写出,它就是调用者可以依赖的契约。怎样选择错误处理方式将在第三篇展开;维护不变式这一目标不变。
我们将稍后讨论这段代码涉及到的一些语法点。
struct 与 class 没有高低之分
C 中的 struct 不能有成员函数、不能有访问修饰符、不能继承,但在 C++ 中,struct 也有了这些能力。两者最直接的差异只是默认访问权限和默认继承权限:
struct成员默认为public;class成员默认为private。
struct 同样能有构造函数、成员函数、模板和虚函数。选择名称通常是在表达设计意图:
- 用
struct表示成员可以独立理解和修改的简单数据聚合; - 用
class表示需要通过操作维护不变式的类型。
因此学生仍然可以是一个聚合:
#include <string>
struct Student {
int id{};
std::string name;
Score score;
};Score 负责保证分数有效,Student 只是把学号、姓名和分数组合起来。没有必要为了统一风格把每个字段都藏在 getter 后面。
详谈类
构造函数建立初始状态
构造函数的任务是让对象从生命周期开始就处于有效状态。它没有返回类型,名称与类相同:
explicit Score(int value) : value_{value} {
// 检查不变式
}冒号后的部分叫成员初始化列表。程序会先按初始化列表,使用参数来初始化类的成员,然后再进入构造函数体。因此:
Score(int value) : value_{value} {}并不等同于:
Score(int value) {
value_ = value; // value_ 已先被初始化,然后才赋值
}对于 const 成员、引用成员和没有默认构造函数的成员,必须使用成员初始化列表。
成员的真实初始化顺序由它们在类中的声明顺序决定,而不是初始化列表的书写顺序。
class Example {
private:
int first_;
int second_;
public:
Example() : second_{first_}, first_{42} {}
};这里仍然先初始化 first_,再初始化 second_。初始化列表最好保持与声明相同的顺序,避免误读和警告。
explicit 阻止意外转换
如果只有一个参数的构造函数没有 explicit,它可能被用于隐式转换:
class Score {
public:
Score(int value); // 没有 explicit
};
void print_score(Score score);
print_score(75); // 编译通过,int 被隐式转换为 Score这有时是刻意设计,但表示业务概念的类型通常不希望调用者无意间完成转换。加上 explicit 后,调用者必须明确构造:
print_score(Score{75});const 成员函数承诺不修改对象
成员函数参数列表后的 const 修饰当前对象:
int value() const noexcept { return value_; }这表示该函数不会修改成员,因此它可以在 const Score 上调用:
void print_score(const Score &score) {
std::cout << score.value() << '\n'; // 编译通过
}如果忘记成员函数后的 const,即使函数体实际上没有修改数据,编译器也不会让它通过 const 引用调用。
class Foo {
public:
void bar() const {};
void baz(){};
};
Foo non_const_foo{};
const Foo const_foo{};
non_const_foo.bar(); // 通过
const_foo.bar(); // 通过
non_const_foo.baz(); // 通过
const_foo.baz();
// passing 'const Foo' as 'this' argument discards qualifiers[[nodiscard]] 不允许丢弃返回值
[[nodiscard]] 是 C++17 引入的属性,用于提示编译器:函数返回值或类型的返回值不应被忽略。如果调用时直接丢弃了返回值,编译器会发出警告。
Score score{85};
std::cout << score.value() << "\n"; // 通过
score.value();
// ignoring return value of 'int Score::value() const', declared with
// attribute 'nodiscard'它很适合用于无副作用、仅用于读取或计算并返回值的函数。
封装不是生成访问器
如果为字段同时提供不受限制的 getter 和 setter:
class Vec2 {
public:
float get_x() const noexcept { return x; }
float get_y() const noexcept { return y; }
void set_x(float x) noexcept { this->x = x; }
void set_y(float y) noexcept { this->y = y; }
private:
float x{0.0f};
float y{0.0f};
};那么这样的访问器通常没有为类型增加什么语义。它们只改变了访问语法,却没有限制无效状态,也没有提供更有意义的操作。除非需要稳定 ABI、为以后增加校验留出接口,或者存在其他明确需求,否则还不如使用最基础的 struct:
struct Vec2 {
float x{0.0f};
float y{0.0f};
};封装应该提供更有意义的操作:
class BankAccount {
public:
void deposit(Money amount);
bool withdraw(Money amount);
[[nodiscard]] Money balance() const;
private:
Money balance_;
};deposit 和 withdraw 能表达修改原因,并应该分别检查金额为正、余额充足等规则。
类也应当容易当作值使用
一个表示分数、日期、坐标或金额的类,通常应像普通值一样自然:
Score first{80};
Score second = first;
if (first.passed()) {
// ...
}不要因为使用了类,就默认要求所有对象通过 new 创建,或让所有操作都依赖继承。能够直接创建、复制、传递和返回的具体类型,往往最容易组合。复制和移动的细节将在 下一节 详细讨论。
什么时候值得定义类
下面情况通常值得创建一个类型:
- 多个值必须共同满足某种约束;
- 创建和修改时必须检查不变式;
- 有一组操作天然属于这个概念;
- 直接暴露底层表示会让调用者依赖实现细节;
- 需要自动管理某种资源。
下面情况则常常只需要 struct 或普通函数:
- 数据成员彼此独立,公开并不会制造无效状态;
- 类型只是一次计算的返回结果;
- 操作并不专属于某一个对象;
- 加入 getter 和 setter 只会重复字段访问。
小结
- 类的核心价值是建立并维护不变式
struct与class都能定义完整类型,没有高低之分- 构造函数应建立有效初始状态
- 成员通过初始化列表初始化,顺序由声明顺序决定
- 单参数构造函数通常应考虑
explicit const成员函数表达只读当前对象- 封装应提供有意义的操作,而不是机械地生成访问器