Skip to content

类型安全 ​

在除了避免内存泄露、数组越界等内存问题之外,现代 C++ 还通过了一系列保证类型安全的策略,用于在编译期暴露可能出现的错误。比如,一个变量只能处于若干种已经定义的状态、读取字符串的变量不应当用于字符串的所有权、函数接收连续的参数不应当依赖于裸指针和长度参数、枚举值不应当被随意混用等等。

从 C++ 11 到 C++ 20,标准库逐渐提供了一系列解决这些问题的工具。他们通过将类型约束写入声明,让编译器检查可能出现的问题。

精确类型的安全性 ​

在传统 C 风格中,状态的表示可能为:

cpp
int state = 1;

其中 1 的含义取决于编程者的约定。他可能是运行中、也可能是成功,也可能是个非法值。编译器只知道它是一个整数,因此将其设定为 999 当然也是合法的,尽管程序并没有对这样的状态进行处理。

再比如这样的函数接口声明:

cpp
void process(const int* data, std::size_t size);

如果 data 为空、size 大小和 data 长度不一致,那么可能就会具有未定义的异常。此外,这个声明也无法展示出函数是否会修改数据。

现代 C++ 可能会通过某些精确的类型来表达这些约束:

cpp
enum class State {
    idle,
    running,
    failed
};

void process(std::span<const int> data);

这样,非法状态、错误枚举类型、遗漏的数组长度等问题的检查工作就由编程者转移到了编译器。

枚举类型 ​

传统枚举会把枚举项暴露在外围作用域,甚至可以隐式转换为整数:

cpp
enum Color {
    red,
    green,
    blue
};

enum Status {
    success,
    failure
};

int value = red;  // 合法:red 隐式转换为 int

如果两个传统枚举中出现同名枚举项,还可能产生名称冲突。此外,传统枚举和整数之间的界限很弱。函数如果接收 int,各种本来不相关的枚举都可能被传进去。

基于这样的问题,C++ 11 提出了强类型枚举 enum class。这样,枚举项必须通过枚举类型访问,而不是暴露在公共域中。

cpp
enum class Color {
    red,
    green,
    blue
};

enum class Status {
    success,
    failure
};

// Call
Color color = Color::red;
Status status = Status::success;

如果确实需要整数值,那么就必须进行显式转换:

cpp
int value = static_cast<int>(Color::red);

这非常重要。因为它表达了程序员清楚地知道自己正在跨越类型边界。

枚举默认使用某种整数类型保存,但也可以显式指定。这在网络协议、文件格式和嵌入式开发中很常见,因为这些场景通常要求明确的数据宽度。

cpp
enum class PacketType : std::uint8_t {
    request  = 1,
    response = 2,
    error    = 3
};

enum class 适合表达一组不携带额外数据的命名选项,但如果不同状态需要携带不同数据,仅靠枚举就不够了。比如当运行状态需要表示任务名称、错误需要原因等,就需要 std::variant 出手了。

多值 Variant ​

std::variant 在 C++17 中引入,可以保存一组候选类型中的任意一种:

cpp
using Value = std::variant<int, double, std::string>;

Value value = 42;
value = 3.14;
value = std::string{"hello"};

在任意时刻,value 只保存其中一种类型。variant 不会同时保存三种类型,也不会进行类型转换,而是根据对应的实际对象决定类型。因此,variant 可以理解成一种带类型标签的联合体。比如:

cpp
struct Idle {};

struct Running {
    std::string task;
};

struct Failed {
    std::string reason;
};

using State = std::variant<Idle, Running, Failed>;

通过这种方式,就可以表达不同枚举类型的状态了。这和 Rust 中的 enum 非常相似:

rust
enum State {
    Idle,
    Running(String),
    Failed(String),
}

我们可以使用 std::holds_alternative 判断当前类型:

cpp
State state = Running{"backup"};

if (std::holds_alternative<Running>(state)) {
    // 判断当前保存的是 Running
}

也可以使用 std::get 取值:

cpp
const auto& running = std::get<Running>(state);

但如果当前保存的不是 Running,std::get 会抛出 std::bad_variant_access 异常。

如果不确定当前类型,可以使用 std::get_if:

cpp
if (const auto* running = std::get_if<Running>(&state)) {
    // running 是指针,指向 variant 内部保存的 Running
}

不过,当所有类型都需要处理时,不断使用 if 判断会越来越冗长。更合适的工具是 std::visit。

Visit 集中处理 Variant ​

std::visit 接收一个可调用对象,并根据 variant 当前保存的类型调用它:

cpp
std::variant<int, double> value = 3.14;

std::visit(
    [](const auto& item) {
        std::cout << item << '\n';
    },
    value
);

如果当前保存 int,lambda 参数就是 const int&;如果保存 double,参数就是 const double&。这种泛型 lambda 很方便,但它把所有类型放进了同一个处理逻辑。

如果不同类型需要不同操作,可以使用“重载 lambda”模式。如此就可以引出 overloaded 辅助类型。它把多个 lambda 的 operator() 组合到同一个对象中:

cpp
std::visit(overloaded{
    [](int value) {
        // 处理 int
    },
    [](double value) {
        // 处理 double
    }
}, variant_value);

当 variant 保存 int 时调用第一个 lambda,保存 double 时调用第二个。

这和 Rust 的模式匹配很相似:

rust
match value {
    Value::Integer(x) => { /* ... */ }
    Value::Float(x)   => { /* ... */ }
}

字符串的零拷贝视图 ​

假设一个函数只想读取用户名:

cpp
void print_name(const std::string& name);

它接收 std::string 很自然,但调用者如果手里是字符串字面量,则通常需要先构造一个临时 std::string。如果函数并不需要拥有字符串,也不需要修改字符串,那么创建新字符串属于不必要的工作。所以,C++17 提供了 std::string_view 用于此类场景:

cpp
void print_name(std::string_view name) {
    std::cout << name << '\n';
}

它可以统一观察多种字符串来源:

cpp
std::string name = "Alice";

print_name(name);
print_name("Bob");
print_name(std::string_view{"Charlie"});

string_view 可以粗略理解为字符地址和字符数量的结合。他不分配内存也不改变所有权,也不存在任何复制操作。他更像一个观察者。因此,在声明中直接传递 std::string_view 是合理的,而不是 string_view 的引用。

但是因为 string_view 只是一种视图,所以他观察的对象必须要拥有更长的生命周期。如果 string_view 指向的是失效的内存,则会产生悬垂引用的问题。

连续数据的零拷贝视图 ​

对于文章开头提到的数据指针和长度的问题,C++ 标准提供了对于这类连续数据的零拷贝视图。由于指针和长度是两个独立参数,编译器不知道它们是否匹配,所以 std::span 将这两者合为一个视图(就和 string_view 一样)。调用时只需要将数据传入即可。

cpp
void print_numbers(std::span<const int> numbers)
{
    for (int value : numbers) {
        std::cout << value << ' ';
    }

    std::cout << '\n';
}

// Call
int raw[] = {1, 2, 3};
    std::array<int, 3> array = {4, 5, 6};
    std::vector<int> vector = {7, 8, 9};

    print_numbers(raw);
    print_numbers(array);
    print_numbers(vector);

同样的,他依然存在生命周期问题。所以他不适合作为函数的返回值——他观察的对象在函数对象销毁后就已经销毁了。

Released under MIT License