强类型 typedef:防止混淆的类型安全
引言
笔者在某次代码审查中见过一段非常经典的 bug:一个函数的签名是 void set_rect(int width, int height),调用方写成了 set_rect(h, w)——参数顺序搞反了。编译器没有任何警告,因为 width 和 height 都是 int,类型完全匹配。但屏幕上的矩形就是歪的.这个bug不难解,但是就是感觉整个人被狠狠发可了一顿.
这种 bug 的根源在于:typedef 和 using 创建的只是类型别名,不是新类型。using Width = int; 和 using Height = int; 之后,Width 和 Height 仍然是同一个 int,编译器不会帮你区分它们。要真正创建编译器能够区分的类型,我们需要一种叫做"强类型 typedef"(也叫 opaque typedef、phantom type)的技术。
这一章我们从 typedef 的局限讲起,然后实现一个实用的强类型包装器,最后用它构建一个类型安全的单位系统。
第一步——理解 typedef / using 的局限
先看一段代码,感受一下普通别名到底有多"脆弱":
using UserId = int;
using OrderId = int;
UserId uid = 42;
OrderId oid = 100;
// 以下全部编译通过,没有任何警告
uid = oid; // OrderId 赋给 UserId?编译器觉得没问题
OrderId another = uid; // 反过来也行
void process_order(OrderId id);
process_order(uid); // 传了 UserId 进去?编译器不管
int total = uid + oid; // 两个"不同语义"的 ID 相加?随便加问题很清楚:using UserId = int 只是在给 int 起了个绰号。在编译器眼里,UserId 和 OrderId 和 int 完全是同一个东西。所有接受 int 的操作,UserId 和 OrderId 都能参与——哪怕语义上完全说不通。
这在大型代码库中是个巨大的隐患。函数参数列表越长、参数类型越是重复使用同一个底层类型,出错概率就越高。而且这类 bug 编译器抓不到,单元测试也未必能覆盖到,只能靠人眼在 code review 里发现——而人眼偏偏最不擅长发现这种"看起来都对"的问题。
第二步——Phantom Type 模式
解决方案的核心思想叫做 phantom type:用一个只有标记作用、不占实际空间的模板参数来区分不同的类型。
// 标签结构体,只用来区分类型,不需要实现任何东西
struct WidthTag {};
struct HeightTag {};
// 强类型包装器
template <typename Tag, typename Rep = int>
class StrongInt {
public:
constexpr explicit StrongInt(Rep value) : value_(value) {}
constexpr Rep get() const noexcept { return value_; }
private:
Rep value_;
};
using Width = StrongInt<WidthTag>;
using Height = StrongInt<HeightTag>;现在 Width 和 Height 是两个完全不同的类型。编译器会阻止你把一个赋给另一个:
Width w(100);
Height h(200);
// h = w; // 编译错误!不能把 Width 赋给 Height
// Width bad = h; // 编译错误!
void set_rect(Width w, Height h);
set_rect(h, w); // 编译错误!参数类型不匹配
set_rect(Width(100), Height(200)); // OKWidthTag 和 HeightTag 是空的类,不占用任何存储空间(因为 C++ 的空基类优化 EBO)。编译器在生成代码时,StrongInt<WidthTag> 和 StrongInt<HeightTag> 的运行时表现和裸 int 完全一样——零额外开销。
这个模式的精髓在于:用编译期的类型信息换取运行时的零开销。类型检查全部在编译期完成,运行时就是普通的整数操作。
第三步——构建实用的强类型包装器
上面那个 StrongInt 太简陋了。在实际项目中,我们通常需要支持一些运算操作。下面我们来构建一个更实用的版本,支持加减、比较、流输出等常见操作。
#include <cstdint>
#include <functional>
#include <iostream>
#include <type_traits>
/// @brief 强类型整数包装器
/// @tparam Tag 幽灵标签,用于区分不同类型
/// @tparam Rep 底层存储类型
template <typename Tag, typename Rep = int>
class StrongInt {
public:
using ValueType = Rep;
// 构造
constexpr explicit StrongInt(Rep value = Rep{}) : value_(value) {}
// 获取底层值
constexpr Rep get() const noexcept { return value_; }
// 自增/自减
constexpr StrongInt& operator++() noexcept { ++value_; return *this; }
constexpr StrongInt operator++(int) noexcept {
StrongInt tmp = *this;
++value_;
return tmp;
}
constexpr StrongInt& operator--() noexcept { --value_; return *this; }
constexpr StrongInt operator--(int) noexcept {
StrongInt tmp = *this;
--value_;
return tmp;
}
// 复合赋值(同类型)
constexpr StrongInt& operator+=(const StrongInt& other) noexcept {
value_ += other.value_;
return *this;
}
constexpr StrongInt& operator-=(const StrongInt& other) noexcept {
value_ -= other.value_;
return *this;
}
// 算术运算(同类型)
constexpr StrongInt operator+(const StrongInt& other) const noexcept {
return StrongInt(value_ + other.value_);
}
constexpr StrongInt operator-(const StrongInt& other) const noexcept {
return StrongInt(value_ - other.value_);
}
// 比较运算
constexpr bool operator==(const StrongInt& other) const noexcept {
return value_ == other.value_;
}
constexpr bool operator!=(const StrongInt& other) const noexcept {
return value_ != other.value_;
}
constexpr bool operator<(const StrongInt& other) const noexcept {
return value_ < other.value_;
}
constexpr bool operator<=(const StrongInt& other) const noexcept {
return value_ <= other.value_;
}
constexpr bool operator>(const StrongInt& other) const noexcept {
return value_ > other.value_;
}
constexpr bool operator>=(const StrongInt& other) const noexcept {
return value_ >= other.value_;
}
private:
Rep value_;
};
// 流输出(方便调试)
template <typename Tag, typename Rep>
std::ostream& operator<<(std::ostream& os, const StrongInt<Tag, Rep>& v)
{
os << v.get();
return os;
}这个 StrongInt 模板覆盖了日常使用中最常见的需求:构造、取值、加减、比较、流输出。而且所有运算都要求操作数是同一种 StrongInt 特化——你不可能把 Width 和 Height 相加,因为它们的 Tag 不同。
第四步——类型安全的单位系统
现在我们来用强类型包装器构建一个类型安全的物理单位系统。这是强类型 typedef 最经典的应用场景之一——通过类型系统防止不同物理量的值被混用。
// 标签定义
struct MetersTag {};
struct KilometersTag {};
struct CelsiusTag {};
struct FahrenheitTag {};
struct SecondsTag {};
struct MillisecondsTag {};
// 类型别名
using Meters = StrongInt<MetersTag, double>;
using Kilometers = StrongInt<KilometersTag, double>;
using Celsius = StrongInt<CelsiusTag, double>;
using Fahrenheit = StrongInt<FahrenheitTag, double>;
using Seconds = StrongInt<SecondsTag, double>;
using Milliseconds = StrongInt<MillisecondsTag, int64_t>;
// 单位转换函数
constexpr Kilometers to_kilometers(Meters m) noexcept
{
return Kilometers(m.get() / 1000.0);
}
constexpr Meters to_meters(Kilometers km) noexcept
{
return Meters(km.get() * 1000.0);
}
constexpr Milliseconds to_milliseconds(Seconds s) noexcept
{
return Milliseconds(static_cast<int64_t>(s.get() * 1000.0));
}使用起来:
Meters distance(5000.0);
Kilometers km = to_kilometers(distance);
// km = distance; // 编译错误!不能直接赋值
Seconds duration(2.5);
Milliseconds ms = to_milliseconds(duration);
// auto bad = distance + duration; // 编译错误!Meters 和 Seconds 不能相加这就是类型安全单位系统的威力:编译器在编译期就帮你拦截了所有"物理量不匹配"的错误。你不可能不小心把米和秒加在一起,也不可能把摄氏度当成华氏度来用。
当然,这个例子中的单位系统还是简化版的——真正的物理单位系统还需要处理无量纲数、复合单位(速度 = 距离 / 时间)等。但核心思路是一样的:用 phantom type 在编译期区分不同的物理量,运行时零开销。
第五步——避免参数混淆的实战案例
除了物理单位,强类型在避免参数混淆方面也非常有用。考虑一个常见的场景:业务系统中到处都是 ID 类型。
struct UserIdTag {};
struct OrderIdTag {};
struct ProductIdTag {};
using UserId = StrongInt<UserIdTag, uint64_t>;
using OrderId = StrongInt<OrderIdTag, uint64_t>;
using ProductId = StrongInt<ProductIdTag, uint64_t>;
class OrderService {
public:
OrderId create_order(UserId user, ProductId product, int quantity)
{
// 如果参数写反了,编译器会直接报错
return OrderId(next_id_++);
}
void cancel_order(OrderId id)
{
// 只接受 OrderId,不接受 UserId 或 ProductId
}
private:
uint64_t next_id_ = 1;
};OrderService service;
UserId user(42);
ProductId product(100);
OrderId order(1);
service.create_order(user, product, 3); // OK
// service.create_order(product, user, 3); // 编译错误!
// service.cancel_order(user); // 编译错误!UserId 不是 OrderId在大型项目中,数据库表的主键、外键、各种关联 ID 全都是 uint64_t。如果没有强类型区分,调用方很容易把 user_id 传到 order_id 的位置。笔者见过这种 bug 导致生产数据库执行了错误的删除操作——修复成本远比引入强类型高得多。
第六步——C++17 CTAD 简化使用
C++17 引入了类模板参数推导(Class Template Argument Deduction, CTAD),可以省去显式指定模板参数的麻烦。虽然我们的 StrongInt 需要两个模板参数(Tag 和 Rep),Tag 无法推导,但我们可以通过推导指引来简化构造:
// 对于 Rep 类型的推导指引
template <typename Tag>
StrongInt(Tag*) -> StrongInt<Tag, int>;
// 使用时只需要指定 Tag
struct ScoreTag {};
using Score = StrongInt<ScoreTag, int>;
Score s(100); // 直接构造,不需要写 <ScoreTag, int>不过说实话,在我们的使用模式中,强类型通常都是通过 using 别名来使用的,所以 CTAD 的实际作用不大。真正有用的是 C++17 的另一个特性——if constexpr 和 auto 推导让模板代码写起来更自然:
template <typename Tag, typename Rep>
constexpr auto make_strong(Rep value)
{
return StrongInt<Tag, Rep>(value);
}
// 使用
auto width = make_strong<WidthTag>(100);
// width 的类型是 StrongInt<WidthTag, int>,自动推导嵌入式实战——寄存器地址的类型安全
在嵌入式开发中,外设寄存器的地址通常用裸 uint32_t 表示。如果不同外设的寄存器地址不小心混在一起,后果可能是写入错误的寄存器导致硬件行为异常。强类型可以在这里发挥作用:
struct GpioRegTag {};
struct UartRegTag {};
struct SpiRegTag {};
using GpioRegAddr = StrongInt<GpioRegTag, uint32_t>;
using UartRegAddr = StrongInt<UartRegTag, uint32_t>;
using SpiRegAddr = StrongInt<SpiRegTag, uint32_t>;
void gpio_write(GpioRegAddr addr, uint32_t value);
void uart_write(UartRegAddr addr, uint32_t value);
// gpio_write(UartRegAddr(0x40001000), 42); // 编译错误!类型不匹配这种模式在大型嵌入式项目中非常有价值——当你的芯片有几十个外设、几百个寄存器地址时,类型安全的地址系统可以防止你写错寄存器。而且运行时零开销:StrongInt 的 get() 函数会被内联,生成的代码和直接用 uint32_t 完全一样。
已有库推荐
如果你不想自己维护一套强类型框架,社区里有几个成熟的开源库可以考虑。Jonathan Mueller 的 NamedType 是最知名的一个,它支持运算符继承、函数式操作、哈希、流输出等,功能非常全面。Boost 也有 Boost.StrongTypes(实验性质的 strong_typedef)。
不过笔者的建议是:如果你的需求只是"区分不同语义的同类型参数",手写一个简单的 StrongInt 模板就够了——代码不到一百行,完全可控,没有外部依赖。只有在需要更复杂的特性(如运算符继承、隐式转换策略定制)时,才需要引入第三方库。
小结
typedef 和 using 创建的只是类型别名,编译器不会帮你区分它们。Phantom type 模式通过一个不占空间的模板标签参数,让编译器在编译期就能区分"语义不同但底层类型相同"的值。强类型包装器的运行时开销为零——空标签类被 EBO 优化掉,所有函数都会被内联。
类型安全的单位系统和 ID 系统是强类型最典型的应用场景。前者防止不同物理量被混用,后者防止相同底层类型但语义不同的值被搞混。在嵌入式领域,强类型还可以用来区分不同外设的寄存器地址,防止误写入。
下一篇我们要讨论的 std::variant,虽然解决的问题不同(运行时多态 vs 编译期类型区分),但同样属于"用类型系统来防止错误"这个大主题。