类型安全是编程语言设计中的核心概念。一个类型安全的语言能够防止将一种类型的数据当作另一种不兼容的类型使用。C++的类型系统继承了C语言的许多弱点——数值类型之间的隐式转换、指针类型之间的强制转换、以及typedef只是类型别名而非新类型的事实。这些特性虽然带来了灵活性,但也在大型项目中引入了难以追踪的错误。近年来,C++社区对增强类型安全的呼声越来越高,一系列技术和实践应运而生。
typedef的局限性是问题的起点。typedef int UserId;和typedef int ProductId;创建了两个别名,但它们在类型系统中是完全等价的。一个接受UserId的函数可以被传入ProductId,编译器不会发出任何警告。这种类型别名只是为程序员提供文档,对编译器没有约束力。在大型系统中,这种混淆可能导致灾难性后果——例如,将用户ID当作产品ID去查询数据库。
参考:https://ltglu.cn/category/sleep-disorders.html
枚举类是C++11在类型安全方向上的重要改进。传统的enum将其值暴露在外部作用域中,且可以隐式转换为整数。enum class则要求显式作用域解析(Color::Red),且不允许隐式转换为整数。枚举类提供了一种创建新类型的方式,但其应用范围有限——枚举只能表示一组离散的值,不能携带额外的数据或行为。
强类型typedef的需求催生了多种解决方案。最简单的是包装类型:定义一个只包含单个成员的结构体,例如struct UserId { int value; };。这种类型的对象不能与整数或其他包装类型混淆,因为编译器将它们视为不同的类型。但包装类型的开销是显著的:你需要为每个操作编写转发函数(构造函数、赋值、比较、算术等),这产生了大量的样板代码。
Boost.Units库展示了强类型的威力。它通过模板元编程在编译期检查物理单位的一致性。将米和秒相加会导致编译错误,将米除以秒得到的速度类型不同于米或秒。这种检查完全在编译期完成,运行时零开销。但Boost.Units的实现极其复杂,编译时间显著增加,且错误信息难以理解。
C++11的std::chrono是将强类型应用于时间单位的成功案例。std::chrono::seconds和std::chrono::milliseconds是不同的类型,不能隐式转换。但库提供了duration_cast进行显式转换,以及算术运算符的合理定义。std::chrono证明了强类型可以在不牺牲便利性的情况下提高安全性。
参考:https://ltglu.cn/category/sleep-methods.html
Fluent C++的NamedType库提供了一种优雅的强类型typedef实现。通过继承一个模板基类,你可以创建带有自定义行为的新类型。例如,using UserId = NamedType;创建了一个名为UserId的新类型,与int和其他NamedType区分。你可以选择性地添加可加性、可减性、可比较性等属性。这种设计保持了零开销(在优化下),同时提供了清晰的语法。
Rust的newtype模式对C++产生了影响。Rust中struct UserId(i32);创建了一个新类型,可以通过.0访问内部值。Rust的derive属性可以自动实现常见的trait(如Debug、Clone、Copy)。C++目前没有等价的自动实现机制,但可以借助宏或代码生成器来减少样板代码。
透明typedef是标准委员会正在考虑的提案。其目标是引入一个新的关键字(如typedef_class或newtype),创建一个与底层类型布局相同但类型不同的新类型。这样的新类型在运行时没有开销(与原始类型完全相同的表示),但编译器将其视为不同的类型,禁止隐式转换。同时,开发者可以选择性地“继承”底层类型的操作(算术、比较、位运算等),避免手工编写转发函数。
参考:https://ltglu.cn/category/sleep-science.html
类型安全的另一个维度是非空指针。T可以指向一个T对象,也可以是空指针。C++没有内建的非空指针类型。gsl::not_null<T>(来自指南支持库)提供了一个包装器,它在构造时检查非空,并在解引用时返回T&。这消除了空指针检查的需要,但增加了运行时开销(至少是一次检查)。更理想的是编译期保证的非空类型,但这需要所有权系统的支持,超出了C++当前的能力。
受约束的整数类型是另一个方向。不是所有的整数都有效——例如,年龄应该在0到150之间,月份在1到12之间。传统的做法是在运行时检查,抛出异常或返回错误。强类型系统可以在类型中编码这些约束:Age类型保证其值在有效范围内,构造时验证。一旦构造成功,后续使用不需要重复检查。这种模式被称为“智能构造函数”或“契约式设计”。
C++26的合约(Contracts)与强类型有协同作用。合约允许在函数边界指定前置条件和后置条件,例如[[pre: age > 0 && age < 150]]。这提供了运行时检查,但不像强类型那样在类型层面编码不变性。理想情况下,强类型和合约应该结合使用:用强类型编码静态可验证的不变式,用合约编码动态可验证的条件。
在实际工程中推广强类型面临文化和实践的双重阻力。文化上,C++开发者习惯了灵活性,认为强类型“太啰嗦”、“限制太多”。实践上,现有代码库大量依赖隐式转换和类型别名,引入强类型需要大量的重构。但经验表明,在接口边界(尤其是库的公共API)使用强类型,能够显著减少因类型混淆导致的bug。即使只在关键位置使用强类型——如ID类型、物理单位、货币类型——也值得付出样板代码的代价。
参考:https://ltglu.cn