Skip to content

2.2 类与不变式 ​

在各类 C++ 基础教材中关于类的章节里,我们经常见到类似这样的示例:

cpp
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 保存成绩:

cpp
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 之间的分数」。所以不妨给「分数」本身定义新类型:

cpp
#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 表示需要通过操作维护不变式的类型。

因此学生仍然可以是一个聚合:

cpp
#include <string>

struct Student {
    int id{};
    std::string name;
    Score score;
};

Score 负责保证分数有效,Student 只是把学号、姓名和分数组合起来。没有必要为了统一风格把每个字段都藏在 getter 后面。

详谈类 ​

构造函数建立初始状态 ​

构造函数的任务是让对象从生命周期开始就处于有效状态。它没有返回类型,名称与类相同:

cpp
explicit Score(int value) : value_{value} {
    // 检查不变式
}

冒号后的部分叫成员初始化列表。程序会先按初始化列表,使用参数来初始化类的成员,然后再进入构造函数体。因此:

cpp
Score(int value) : value_{value} {}

并不等同于:

cpp
Score(int value) {
    value_ = value; // value_ 已先被初始化,然后才赋值
}

对于 const 成员、引用成员和没有默认构造函数的成员,必须使用成员初始化列表。

成员的真实初始化顺序由它们在类中的声明顺序决定,而不是初始化列表的书写顺序。

cpp
class Example {
  private:
    int first_;
    int second_;

  public:
    Example() : second_{first_}, first_{42} {}
};

这里仍然先初始化 first_,再初始化 second_。初始化列表最好保持与声明相同的顺序,避免误读和警告。

explicit 阻止意外转换 ​

如果只有一个参数的构造函数没有 explicit,它可能被用于隐式转换:

cpp
class Score {
  public:
    Score(int value); // 没有 explicit
};

void print_score(Score score);

print_score(75); // 编译通过,int 被隐式转换为 Score

这有时是刻意设计,但表示业务概念的类型通常不希望调用者无意间完成转换。加上 explicit 后,调用者必须明确构造:

cpp
print_score(Score{75});

const 成员函数承诺不修改对象 ​

成员函数参数列表后的 const 修饰当前对象:

cpp
int value() const noexcept { return value_; }

这表示该函数不会修改成员,因此它可以在 const Score 上调用:

cpp
void print_score(const Score &score) {
    std::cout << score.value() << '\n'; // 编译通过
}

如果忘记成员函数后的 const,即使函数体实际上没有修改数据,编译器也不会让它通过 const 引用调用。

cpp
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 引入的属性,用于提示编译器:函数返回值或类型的返回值不应被忽略。如果调用时直接丢弃了返回值,编译器会发出警告。

cpp
Score score{85};
std::cout << score.value() << "\n"; // 通过
score.value();
// ignoring return value of 'int Score::value() const', declared with
// attribute 'nodiscard'

它很适合用于无副作用、仅用于读取或计算并返回值的函数。

封装不是生成访问器 ​

如果为字段同时提供不受限制的 getter 和 setter:

cpp
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:

cpp
struct Vec2 {
    float x{0.0f};
    float y{0.0f};
};

封装应该提供更有意义的操作:

cpp
class BankAccount {
  public:
    void deposit(Money amount);
    bool withdraw(Money amount);
    [[nodiscard]] Money balance() const;

  private:
    Money balance_;
};

deposit 和 withdraw 能表达修改原因,并应该分别检查金额为正、余额充足等规则。

类也应当容易当作值使用 ​

一个表示分数、日期、坐标或金额的类,通常应像普通值一样自然:

cpp
Score first{80};
Score second = first;

if (first.passed()) {
    // ...
}

不要因为使用了类,就默认要求所有对象通过 new 创建,或让所有操作都依赖继承。能够直接创建、复制、传递和返回的具体类型,往往最容易组合。复制和移动的细节将在 下一节 详细讨论。

什么时候值得定义类 ​

下面情况通常值得创建一个类型:

  • 多个值必须共同满足某种约束;
  • 创建和修改时必须检查不变式;
  • 有一组操作天然属于这个概念;
  • 直接暴露底层表示会让调用者依赖实现细节;
  • 需要自动管理某种资源。

下面情况则常常只需要 struct 或普通函数:

  • 数据成员彼此独立,公开并不会制造无效状态;
  • 类型只是一次计算的返回结果;
  • 操作并不专属于某一个对象;
  • 加入 getter 和 setter 只会重复字段访问。

小结 ​

  • 类的核心价值是建立并维护不变式
  • struct 与 class 都能定义完整类型,没有高低之分
  • 构造函数应建立有效初始状态
  • 成员通过初始化列表初始化,顺序由声明顺序决定
  • 单参数构造函数通常应考虑 explicit
  • const 成员函数表达只读当前对象
  • 封装应提供有意义的操作,而不是机械地生成访问器