强类型 typedef:让编译器帮你拦截参数传反的 bug
这个仓库已经开源!现代化 C++(C++11/14/17/20)从基础到进阶的系统教程都在这里,力争做一条完备的现代 C++ 学习路径!欢迎各位大佬前来参观,喜欢的话点个⭐!
Github 一键直达: git clone https://github.com/Awesome-Embedded-Learning-Studio/Tutorial_AwesomeModernCPP
看看超酷的新网站:https://awesome-embedded-learning-studio.github.io/Tutorial_AwesomeModernCPP/
引言
笔者在某次代码审查中见过一段非常经典的 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 的局限
先看一段代码,感受一下普通别名到底有多"脆弱":
usingUserId=int;usingOrderId=int;UserId uid=42;OrderId oid=100;// 以下全部编译通过,没有任何警告uid=oid;// OrderId 赋给 UserId?编译器觉得没问题OrderId another=uid;// 反过来也行voidprocess_order(OrderId id);process_order(uid);// 传了 UserId 进去?编译器不管inttotal=uid+oid;// 两个"不同语义"的 ID 相加?随便加问题很清楚:using UserId = int只是在给int起了个绰号。在编译器眼里,UserId和OrderId和int完全是同一个东西。所有接受int的操作,UserId和OrderId都能参与——哪怕语义上完全说不通。
这在大型代码库中是个巨大的隐患。函数参数列表越长、参数类型越是重复使用同一个底层类型,出错概率就越高。而且这类 bug 编译器抓不到,单元测试也未必能覆盖到,只能靠人眼在 code review 里发现——而人眼偏偏最不擅长发现这种"看起来都对"的问题。
第二步——Phantom Type 模式
解决方案的核心思想叫做 phantom type:用一个只有标记作用、不占实际空间的模板参数来区分不同的类型。
// 标签结构体,只用来区分类型,不需要实现任何东西structWidthTag{};structHeightTag{};// 强类型包装器template<typenameTag,typenameRep=int>classStrongInt{public:constexprexplicitStrongInt(Rep value):value_(value){}constexprRepget()constnoexcept{returnvalue_;}private:Rep value_;};usingWidth=StrongInt<WidthTag>;usingHeight=StrongInt<HeightTag>;现在Width和Height是两个完全不同的类型。编译器会阻止你把一个赋给另一个:
Widthw(100);Heighth(200);// h = w; // 编译错误!不能把 Width 赋给 Height// Width bad = h; // 编译错误!voidset_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<typenameTag,typenameRep=int>classStrongInt{public:usingValueType=Rep;// 构造constexprexplicitStrongInt(Rep value=Rep{}):value_(value){}// 获取底层值constexprRepget()constnoexcept{returnvalue_;}// 自增/自减constexprStrongInt&operator++()noexcept{++value_;return*this;}constexprStrongIntoperator++(int)noexcept{StrongInt tmp=*this;++value_;returntmp;}constexprStrongInt&operator--()noexcept{--value_;return*this;}constexprStrongIntoperator--(int)noexcept{StrongInt tmp=*this;--value_;returntmp;}// 复合赋值(同类型)constexprStrongInt&operator+=(constStrongInt&other)noexcept{value_+=other.value_;return*this;}constexprStrongInt&operator-=(constStrongInt&other)noexcept{value_-=other.value_;return*this;}// 算术运算(同类型)constexprStrongIntoperator+(constStrongInt&other)constnoexcept{returnStrongInt(value_+other.value_);}constexprStrongIntoperator-(constStrongInt&other)constnoexcept{returnStrongInt(value_-other.value_);}// 比较运算constexprbooloperator==(constStrongInt&other)constnoexcept{returnvalue_==other.value_;}constexprbooloperator!=(constStrongInt&other)constnoexcept{returnvalue_!=other.value_;}constexprbooloperator<(constStrongInt&other)constnoexcept{returnvalue_<other.value_;}constexprbooloperator<=(constStrongInt&other)constnoexcept{returnvalue_<=other.value_;}constexprbooloperator>(constStrongInt&other)constnoexcept{returnvalue_>other.value_;}constexprbooloperator>=(constStrongInt&other)constnoexcept{returnvalue_>=other.value_;}private:Rep value_;};// 流输出(方便调试)template<typenameTag,typenameRep>std::ostream&operator<<(std::ostream&os,constStrongInt<Tag,Rep>&v){os<<v.get();returnos;}这个StrongInt模板覆盖了日常使用中最常见的需求:构造、取值、加减、比较、流输出。而且所有运算都要求操作数是同一种 StrongInt 特化——你不可能把Width和Height相加,因为它们的Tag不同。
第四步——类型安全的单位系统
现在我们来用强类型包装器构建一个类型安全的物理单位系统。这是强类型 typedef 最经典的应用场景之一——通过类型系统防止不同物理量的值被混用。
// 标签定义structMetersTag{};structKilometersTag{};structCelsiusTag{};structFahrenheitTag{};structSecondsTag{};structMillisecondsTag{};// 类型别名usingMeters=StrongInt<MetersTag,double>;usingKilometers=StrongInt<KilometersTag,double>;usingCelsius=StrongInt<CelsiusTag,double>;usingFahrenheit=StrongInt<FahrenheitTag,double>;usingSeconds=StrongInt<SecondsTag,double>;usingMilliseconds=StrongInt<MillisecondsTag,int64_t>;// 单位转换函数constexprKilometersto_kilometers(Meters m)noexcept{returnKilometers(m.get()/1000.0);}constexprMetersto_meters(Kilometers km)noexcept{returnMeters(km.get()*1000.0);}constexprMillisecondsto_milliseconds(Seconds s)noexcept{returnMilliseconds(static_cast<int64_t>(s.get()*1000.0));}使用起来:
Metersdistance(5000.0);Kilometers km=to_kilometers(distance);// km = distance; // 编译错误!不能直接赋值Secondsduration(2.5);Milliseconds ms=to_milliseconds(duration);// auto bad = distance + duration; // 编译错误!Meters 和 Seconds 不能相加这就是类型安全单位系统的威力:编译器在编译期就帮你拦截了所有"物理量不匹配"的错误。你不可能不小心把米和秒加在一起,也不可能把摄氏度当成华氏度来用。
当然,这个例子中的单位系统还是简化版的——真正的物理单位系统还需要处理无量纲数、复合单位(速度 = 距离 / 时间)等。但核心思路是一样的:用 phantom type 在编译期区分不同的物理量,运行时零开销。
第五步——避免参数混淆的实战案例
除了物理单位,强类型在避免参数混淆方面也非常有用。考虑一个常见的场景:业务系统中到处都是 ID 类型。
structUserIdTag{};structOrderIdTag{};structProductIdTag{};usingUserId=StrongInt<UserIdTag,uint64_t>;usingOrderId=StrongInt<OrderIdTag,uint64_t>;usingProductId=StrongInt<ProductIdTag,uint64_t>;classOrderService{public:OrderIdcreate_order(UserId user,ProductId product,intquantity){// 如果参数写反了,编译器会直接报错returnOrderId(next_id_++);}voidcancel_order(OrderId id){// 只接受 OrderId,不接受 UserId 或 ProductId}private:uint64_tnext_id_=1;};OrderService service;UserIduser(42);ProductIdproduct(100);OrderIdorder(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<typenameTag>StrongInt(Tag*)->StrongInt<Tag,int>;// 使用时只需要指定 TagstructScoreTag{};usingScore=StrongInt<ScoreTag,int>;Scores(100);// 直接构造,不需要写 <ScoreTag, int>不过说实话,在我们的使用模式中,强类型通常都是通过using别名来使用的,所以 CTAD 的实际作用不大。真正有用的是 C++17 的另一个特性——if constexpr和auto推导让模板代码写起来更自然:
template<typenameTag,typenameRep>constexprautomake_strong(Rep value){returnStrongInt<Tag,Rep>(value);}// 使用autowidth=make_strong<WidthTag>(100);// width 的类型是 StrongInt<WidthTag, int>,自动推导嵌入式实战——寄存器地址的类型安全
在嵌入式开发中,外设寄存器的地址通常用裸uint32_t表示。如果不同外设的寄存器地址不小心混在一起,后果可能是写入错误的寄存器导致硬件行为异常。强类型可以在这里发挥作用:
structGpioRegTag{};structUartRegTag{};structSpiRegTag{};usingGpioRegAddr=StrongInt<GpioRegTag,uint32_t>;usingUartRegAddr=StrongInt<UartRegTag,uint32_t>;usingSpiRegAddr=StrongInt<SpiRegTag,uint32_t>;voidgpio_write(GpioRegAddr addr,uint32_tvalue);voiduart_write(UartRegAddr addr,uint32_tvalue);// 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 编译期类型区分),但同样属于"用类型系统来防止错误"这个大主题。
参考资源
- foonathan.net: Emulating strong/opaque typedefs in C++
- Fluent C++: Strong types by struct
- NamedType (GitHub)
- C++ Core Guidelines: Type safety