类型安全
在除了避免内存泄露、数组越界等内存问题之外,现代 C++ 还通过了一系列保证类型安全的策略,用于在编译期暴露可能出现的错误。比如,一个变量只能处于若干种已经定义的状态、读取字符串的变量不应当用于字符串的所有权、函数接收连续的参数不应当依赖于裸指针和长度参数、枚举值不应当被随意混用等等。
从 C++ 11 到 C++ 20,标准库逐渐提供了一系列解决这些问题的工具。他们通过将类型约束写入声明,让编译器检查可能出现的问题。
精确类型的安全性
在传统 C 风格中,状态的表示可能为:
int state = 1;其中 1 的含义取决于编程者的约定。他可能是运行中、也可能是成功,也可能是个非法值。编译器只知道它是一个整数,因此将其设定为 999 当然也是合法的,尽管程序并没有对这样的状态进行处理。
再比如这样的函数接口声明:
void process(const int* data, std::size_t size);如果 data 为空、size 大小和 data 长度不一致,那么可能就会具有未定义的异常。此外,这个声明也无法展示出函数是否会修改数据。
现代 C++ 可能会通过某些精确的类型来表达这些约束:
enum class State {
idle,
running,
failed
};
void process(std::span<const int> data);这样,非法状态、错误枚举类型、遗漏的数组长度等问题的检查工作就由编程者转移到了编译器。
枚举类型
传统枚举会把枚举项暴露在外围作用域,甚至可以隐式转换为整数:
enum Color {
red,
green,
blue
};
enum Status {
success,
failure
};
int value = red; // 合法:red 隐式转换为 int如果两个传统枚举中出现同名枚举项,还可能产生名称冲突。此外,传统枚举和整数之间的界限很弱。函数如果接收 int,各种本来不相关的枚举都可能被传进去。
基于这样的问题,C++ 11 提出了强类型枚举 enum class。这样,枚举项必须通过枚举类型访问,而不是暴露在公共域中。
enum class Color {
red,
green,
blue
};
enum class Status {
success,
failure
};
// Call
Color color = Color::red;
Status status = Status::success;如果确实需要整数值,那么就必须进行显式转换:
int value = static_cast<int>(Color::red);这非常重要。因为它表达了程序员清楚地知道自己正在跨越类型边界。
枚举默认使用某种整数类型保存,但也可以显式指定。这在网络协议、文件格式和嵌入式开发中很常见,因为这些场景通常要求明确的数据宽度。
enum class PacketType : std::uint8_t {
request = 1,
response = 2,
error = 3
};enum class 适合表达一组不携带额外数据的命名选项,但如果不同状态需要携带不同数据,仅靠枚举就不够了。比如当运行状态需要表示任务名称、错误需要原因等,就需要 std::variant 出手了。
多值 Variant
std::variant 在 C++17 中引入,可以保存一组候选类型中的任意一种:
using Value = std::variant<int, double, std::string>;
Value value = 42;
value = 3.14;
value = std::string{"hello"};在任意时刻,value 只保存其中一种类型。variant 不会同时保存三种类型,也不会进行类型转换,而是根据对应的实际对象决定类型。因此,variant 可以理解成一种带类型标签的联合体。比如:
struct Idle {};
struct Running {
std::string task;
};
struct Failed {
std::string reason;
};
using State = std::variant<Idle, Running, Failed>;通过这种方式,就可以表达不同枚举类型的状态了。这和 Rust 中的 enum 非常相似:
enum State {
Idle,
Running(String),
Failed(String),
}我们可以使用 std::holds_alternative 判断当前类型:
State state = Running{"backup"};
if (std::holds_alternative<Running>(state)) {
// 判断当前保存的是 Running
}也可以使用 std::get 取值:
const auto& running = std::get<Running>(state);但如果当前保存的不是 Running,std::get 会抛出 std::bad_variant_access 异常。
如果不确定当前类型,可以使用 std::get_if:
if (const auto* running = std::get_if<Running>(&state)) {
// running 是指针,指向 variant 内部保存的 Running
}不过,当所有类型都需要处理时,不断使用 if 判断会越来越冗长。更合适的工具是 std::visit。
Visit 集中处理 Variant
std::visit 接收一个可调用对象,并根据 variant 当前保存的类型调用它:
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() 组合到同一个对象中:
std::visit(overloaded{
[](int value) {
// 处理 int
},
[](double value) {
// 处理 double
}
}, variant_value);当 variant 保存 int 时调用第一个 lambda,保存 double 时调用第二个。
这和 Rust 的模式匹配很相似:
match value {
Value::Integer(x) => { /* ... */ }
Value::Float(x) => { /* ... */ }
}字符串的零拷贝视图
假设一个函数只想读取用户名:
void print_name(const std::string& name);它接收 std::string 很自然,但调用者如果手里是字符串字面量,则通常需要先构造一个临时 std::string。如果函数并不需要拥有字符串,也不需要修改字符串,那么创建新字符串属于不必要的工作。所以,C++17 提供了 std::string_view 用于此类场景:
void print_name(std::string_view name) {
std::cout << name << '\n';
}它可以统一观察多种字符串来源:
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 一样)。调用时只需要将数据传入即可。
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);同样的,他依然存在生命周期问题。所以他不适合作为函数的返回值——他观察的对象在函数对象销毁后就已经销毁了。