强类型 typedef:让编译器帮你拦截参数传反的 bug这个仓库已经开源现代化 CC11/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 的局限先看一段代码感受一下普通别名到底有多脆弱usingUserIdint;usingOrderIdint;UserId uid42;OrderId oid100;// 以下全部编译通过没有任何警告uidoid;// OrderId 赋给 UserId编译器觉得没问题OrderId anotheruid;// 反过来也行voidprocess_order(OrderId id);process_order(uid);// 传了 UserId 进去编译器不管inttotaluidoid;// 两个不同语义的 ID 相加随便加问题很清楚using UserId int只是在给int起了个绰号。在编译器眼里UserId和OrderId和int完全是同一个东西。所有接受int的操作UserId和OrderId都能参与——哪怕语义上完全说不通。这在大型代码库中是个巨大的隐患。函数参数列表越长、参数类型越是重复使用同一个底层类型出错概率就越高。而且这类 bug 编译器抓不到单元测试也未必能覆盖到只能靠人眼在 code review 里发现——而人眼偏偏最不擅长发现这种看起来都对的问题。第二步——Phantom Type 模式解决方案的核心思想叫做 phantom type用一个只有标记作用、不占实际空间的模板参数来区分不同的类型。// 标签结构体只用来区分类型不需要实现任何东西structWidthTag{};structHeightTag{};// 强类型包装器templatetypenameTag,typenameRepintclassStrongInt{public:constexprexplicitStrongInt(Rep value):value_(value){}constexprRepget()constnoexcept{returnvalue_;}private:Rep value_;};usingWidthStrongIntWidthTag;usingHeightStrongIntHeightTag;现在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。编译器在生成代码时StrongIntWidthTag和StrongIntHeightTag的运行时表现和裸int完全一样——零额外开销。这个模式的精髓在于用编译期的类型信息换取运行时的零开销。类型检查全部在编译期完成运行时就是普通的整数操作。第三步——构建实用的强类型包装器上面那个StrongInt太简陋了。在实际项目中我们通常需要支持一些运算操作。下面我们来构建一个更实用的版本支持加减、比较、流输出等常见操作。#includecstdint#includefunctional#includeiostream#includetype_traits/// brief 强类型整数包装器/// tparam Tag 幽灵标签用于区分不同类型/// tparam Rep 底层存储类型templatetypenameTag,typenameRepintclassStrongInt{public:usingValueTypeRep;// 构造constexprexplicitStrongInt(Rep valueRep{}):value_(value){}// 获取底层值constexprRepget()constnoexcept{returnvalue_;}// 自增/自减constexprStrongIntoperator()noexcept{value_;return*this;}constexprStrongIntoperator(int)noexcept{StrongInt tmp*this;value_;returntmp;}constexprStrongIntoperator--()noexcept{--value_;return*this;}constexprStrongIntoperator--(int)noexcept{StrongInt tmp*this;--value_;returntmp;}// 复合赋值同类型constexprStrongIntoperator(constStrongIntother)noexcept{value_other.value_;return*this;}constexprStrongIntoperator-(constStrongIntother)noexcept{value_-other.value_;return*this;}// 算术运算同类型constexprStrongIntoperator(constStrongIntother)constnoexcept{returnStrongInt(value_other.value_);}constexprStrongIntoperator-(constStrongIntother)constnoexcept{returnStrongInt(value_-other.value_);}// 比较运算constexprbooloperator(constStrongIntother)constnoexcept{returnvalue_other.value_;}constexprbooloperator!(constStrongIntother)constnoexcept{returnvalue_!other.value_;}constexprbooloperator(constStrongIntother)constnoexcept{returnvalue_other.value_;}constexprbooloperator(constStrongIntother)constnoexcept{returnvalue_other.value_;}constexprbooloperator(constStrongIntother)constnoexcept{returnvalue_other.value_;}constexprbooloperator(constStrongIntother)constnoexcept{returnvalue_other.value_;}private:Rep value_;};// 流输出方便调试templatetypenameTag,typenameRepstd::ostreamoperator(std::ostreamos,constStrongIntTag,Repv){osv.get();returnos;}这个StrongInt模板覆盖了日常使用中最常见的需求构造、取值、加减、比较、流输出。而且所有运算都要求操作数是同一种 StrongInt 特化——你不可能把Width和Height相加因为它们的Tag不同。第四步——类型安全的单位系统现在我们来用强类型包装器构建一个类型安全的物理单位系统。这是强类型 typedef 最经典的应用场景之一——通过类型系统防止不同物理量的值被混用。// 标签定义structMetersTag{};structKilometersTag{};structCelsiusTag{};structFahrenheitTag{};structSecondsTag{};structMillisecondsTag{};// 类型别名usingMetersStrongIntMetersTag,double;usingKilometersStrongIntKilometersTag,double;usingCelsiusStrongIntCelsiusTag,double;usingFahrenheitStrongIntFahrenheitTag,double;usingSecondsStrongIntSecondsTag,double;usingMillisecondsStrongIntMillisecondsTag,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_castint64_t(s.get()*1000.0));}使用起来Metersdistance(5000.0);Kilometers kmto_kilometers(distance);// km distance; // 编译错误不能直接赋值Secondsduration(2.5);Milliseconds msto_milliseconds(duration);// auto bad distance duration; // 编译错误Meters 和 Seconds 不能相加这就是类型安全单位系统的威力编译器在编译期就帮你拦截了所有物理量不匹配的错误。你不可能不小心把米和秒加在一起也不可能把摄氏度当成华氏度来用。当然这个例子中的单位系统还是简化版的——真正的物理单位系统还需要处理无量纲数、复合单位速度 距离 / 时间等。但核心思路是一样的用 phantom type 在编译期区分不同的物理量运行时零开销。第五步——避免参数混淆的实战案例除了物理单位强类型在避免参数混淆方面也非常有用。考虑一个常见的场景业务系统中到处都是 ID 类型。structUserIdTag{};structOrderIdTag{};structProductIdTag{};usingUserIdStrongIntUserIdTag,uint64_t;usingOrderIdStrongIntOrderIdTag,uint64_t;usingProductIdStrongIntProductIdTag,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 导致生产数据库执行了错误的删除操作——修复成本远比引入强类型高得多。第六步——C17 CTAD 简化使用C17 引入了类模板参数推导Class Template Argument Deduction, CTAD可以省去显式指定模板参数的麻烦。虽然我们的StrongInt需要两个模板参数Tag和RepTag无法推导但我们可以通过推导指引来简化构造// 对于 Rep 类型的推导指引templatetypenameTagStrongInt(Tag*)-StrongIntTag,int;// 使用时只需要指定 TagstructScoreTag{};usingScoreStrongIntScoreTag,int;Scores(100);// 直接构造不需要写 ScoreTag, int不过说实话在我们的使用模式中强类型通常都是通过using别名来使用的所以 CTAD 的实际作用不大。真正有用的是 C17 的另一个特性——if constexpr和auto推导让模板代码写起来更自然templatetypenameTag,typenameRepconstexprautomake_strong(Rep value){returnStrongIntTag,Rep(value);}// 使用autowidthmake_strongWidthTag(100);// width 的类型是 StrongIntWidthTag, int自动推导嵌入式实战——寄存器地址的类型安全在嵌入式开发中外设寄存器的地址通常用裸uint32_t表示。如果不同外设的寄存器地址不小心混在一起后果可能是写入错误的寄存器导致硬件行为异常。强类型可以在这里发挥作用structGpioRegTag{};structUartRegTag{};structSpiRegTag{};usingGpioRegAddrStrongIntGpioRegTag,uint32_t;usingUartRegAddrStrongIntUartRegTag,uint32_t;usingSpiRegAddrStrongIntSpiRegTag,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 CFluent C: Strong types by structNamedType (GitHub)C Core Guidelines: Type safety
强类型 typedef:让编译器帮你拦截参数传反的 bug
发布时间:2026/7/2 20:45:10
强类型 typedef:让编译器帮你拦截参数传反的 bug这个仓库已经开源现代化 CC11/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 的局限先看一段代码感受一下普通别名到底有多脆弱usingUserIdint;usingOrderIdint;UserId uid42;OrderId oid100;// 以下全部编译通过没有任何警告uidoid;// OrderId 赋给 UserId编译器觉得没问题OrderId anotheruid;// 反过来也行voidprocess_order(OrderId id);process_order(uid);// 传了 UserId 进去编译器不管inttotaluidoid;// 两个不同语义的 ID 相加随便加问题很清楚using UserId int只是在给int起了个绰号。在编译器眼里UserId和OrderId和int完全是同一个东西。所有接受int的操作UserId和OrderId都能参与——哪怕语义上完全说不通。这在大型代码库中是个巨大的隐患。函数参数列表越长、参数类型越是重复使用同一个底层类型出错概率就越高。而且这类 bug 编译器抓不到单元测试也未必能覆盖到只能靠人眼在 code review 里发现——而人眼偏偏最不擅长发现这种看起来都对的问题。第二步——Phantom Type 模式解决方案的核心思想叫做 phantom type用一个只有标记作用、不占实际空间的模板参数来区分不同的类型。// 标签结构体只用来区分类型不需要实现任何东西structWidthTag{};structHeightTag{};// 强类型包装器templatetypenameTag,typenameRepintclassStrongInt{public:constexprexplicitStrongInt(Rep value):value_(value){}constexprRepget()constnoexcept{returnvalue_;}private:Rep value_;};usingWidthStrongIntWidthTag;usingHeightStrongIntHeightTag;现在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。编译器在生成代码时StrongIntWidthTag和StrongIntHeightTag的运行时表现和裸int完全一样——零额外开销。这个模式的精髓在于用编译期的类型信息换取运行时的零开销。类型检查全部在编译期完成运行时就是普通的整数操作。第三步——构建实用的强类型包装器上面那个StrongInt太简陋了。在实际项目中我们通常需要支持一些运算操作。下面我们来构建一个更实用的版本支持加减、比较、流输出等常见操作。#includecstdint#includefunctional#includeiostream#includetype_traits/// brief 强类型整数包装器/// tparam Tag 幽灵标签用于区分不同类型/// tparam Rep 底层存储类型templatetypenameTag,typenameRepintclassStrongInt{public:usingValueTypeRep;// 构造constexprexplicitStrongInt(Rep valueRep{}):value_(value){}// 获取底层值constexprRepget()constnoexcept{returnvalue_;}// 自增/自减constexprStrongIntoperator()noexcept{value_;return*this;}constexprStrongIntoperator(int)noexcept{StrongInt tmp*this;value_;returntmp;}constexprStrongIntoperator--()noexcept{--value_;return*this;}constexprStrongIntoperator--(int)noexcept{StrongInt tmp*this;--value_;returntmp;}// 复合赋值同类型constexprStrongIntoperator(constStrongIntother)noexcept{value_other.value_;return*this;}constexprStrongIntoperator-(constStrongIntother)noexcept{value_-other.value_;return*this;}// 算术运算同类型constexprStrongIntoperator(constStrongIntother)constnoexcept{returnStrongInt(value_other.value_);}constexprStrongIntoperator-(constStrongIntother)constnoexcept{returnStrongInt(value_-other.value_);}// 比较运算constexprbooloperator(constStrongIntother)constnoexcept{returnvalue_other.value_;}constexprbooloperator!(constStrongIntother)constnoexcept{returnvalue_!other.value_;}constexprbooloperator(constStrongIntother)constnoexcept{returnvalue_other.value_;}constexprbooloperator(constStrongIntother)constnoexcept{returnvalue_other.value_;}constexprbooloperator(constStrongIntother)constnoexcept{returnvalue_other.value_;}constexprbooloperator(constStrongIntother)constnoexcept{returnvalue_other.value_;}private:Rep value_;};// 流输出方便调试templatetypenameTag,typenameRepstd::ostreamoperator(std::ostreamos,constStrongIntTag,Repv){osv.get();returnos;}这个StrongInt模板覆盖了日常使用中最常见的需求构造、取值、加减、比较、流输出。而且所有运算都要求操作数是同一种 StrongInt 特化——你不可能把Width和Height相加因为它们的Tag不同。第四步——类型安全的单位系统现在我们来用强类型包装器构建一个类型安全的物理单位系统。这是强类型 typedef 最经典的应用场景之一——通过类型系统防止不同物理量的值被混用。// 标签定义structMetersTag{};structKilometersTag{};structCelsiusTag{};structFahrenheitTag{};structSecondsTag{};structMillisecondsTag{};// 类型别名usingMetersStrongIntMetersTag,double;usingKilometersStrongIntKilometersTag,double;usingCelsiusStrongIntCelsiusTag,double;usingFahrenheitStrongIntFahrenheitTag,double;usingSecondsStrongIntSecondsTag,double;usingMillisecondsStrongIntMillisecondsTag,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_castint64_t(s.get()*1000.0));}使用起来Metersdistance(5000.0);Kilometers kmto_kilometers(distance);// km distance; // 编译错误不能直接赋值Secondsduration(2.5);Milliseconds msto_milliseconds(duration);// auto bad distance duration; // 编译错误Meters 和 Seconds 不能相加这就是类型安全单位系统的威力编译器在编译期就帮你拦截了所有物理量不匹配的错误。你不可能不小心把米和秒加在一起也不可能把摄氏度当成华氏度来用。当然这个例子中的单位系统还是简化版的——真正的物理单位系统还需要处理无量纲数、复合单位速度 距离 / 时间等。但核心思路是一样的用 phantom type 在编译期区分不同的物理量运行时零开销。第五步——避免参数混淆的实战案例除了物理单位强类型在避免参数混淆方面也非常有用。考虑一个常见的场景业务系统中到处都是 ID 类型。structUserIdTag{};structOrderIdTag{};structProductIdTag{};usingUserIdStrongIntUserIdTag,uint64_t;usingOrderIdStrongIntOrderIdTag,uint64_t;usingProductIdStrongIntProductIdTag,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 导致生产数据库执行了错误的删除操作——修复成本远比引入强类型高得多。第六步——C17 CTAD 简化使用C17 引入了类模板参数推导Class Template Argument Deduction, CTAD可以省去显式指定模板参数的麻烦。虽然我们的StrongInt需要两个模板参数Tag和RepTag无法推导但我们可以通过推导指引来简化构造// 对于 Rep 类型的推导指引templatetypenameTagStrongInt(Tag*)-StrongIntTag,int;// 使用时只需要指定 TagstructScoreTag{};usingScoreStrongIntScoreTag,int;Scores(100);// 直接构造不需要写 ScoreTag, int不过说实话在我们的使用模式中强类型通常都是通过using别名来使用的所以 CTAD 的实际作用不大。真正有用的是 C17 的另一个特性——if constexpr和auto推导让模板代码写起来更自然templatetypenameTag,typenameRepconstexprautomake_strong(Rep value){returnStrongIntTag,Rep(value);}// 使用autowidthmake_strongWidthTag(100);// width 的类型是 StrongIntWidthTag, int自动推导嵌入式实战——寄存器地址的类型安全在嵌入式开发中外设寄存器的地址通常用裸uint32_t表示。如果不同外设的寄存器地址不小心混在一起后果可能是写入错误的寄存器导致硬件行为异常。强类型可以在这里发挥作用structGpioRegTag{};structUartRegTag{};structSpiRegTag{};usingGpioRegAddrStrongIntGpioRegTag,uint32_t;usingUartRegAddrStrongIntUartRegTag,uint32_t;usingSpiRegAddrStrongIntSpiRegTag,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 CFluent C: Strong types by structNamedType (GitHub)C Core Guidelines: Type safety