
上个月写一个通用配置导出模块要给结构体、容器、字符串、数值统一序列化成 JSON。这个模块第一步就是拿到一个变量后先判断它是什么类型。我一开始想得很简单C判断类型嘛typeid一把梭。结果同事在 code review 里贴了几行批注大意是这个方案对普通变量是准的但遇到多态对象就不一定了而且很多判断在编译期就能做你拖到运行期等于白丢一块性能。从那以后我才系统地捋了一遍 C 里的类型判断到底有几种做法各自适合什么场景。这篇文章就把我梳理的结果和踩坑的地方写下来。按判断发生在哪个阶段来分先讲运行期typeid、dynamic_cast再讲编译期type_traits、decltype、if constexpr、concepts中间放三个真实场景最后列一份踩坑清单。无论你是刚转 C、正在写泛型库还是在维护一个到处是多态对象的业务系统应该都能对上一两个实际问题。1. 先把类型判断这四件事拆开说清楚1.1 判断发生的时机决定了你该选哪个工具C 里的类型判断之所以让新手迷糊是因为它不是一个动作而是两件发生在不同时间、由不同机制完成的事编译期判断编译器在实例化模板、做重载决议时根据静态类型信息选择不同的代码路径。这个判断结果是硬编码在生成的机器码里的运行时不消耗任何额外时间。运行期判断程序真正执行到某个分支时通过虚函数表或类型信息记录来识别对象的动态类型。只有具备多态性的对象才能走到这一步。绝大多数刚接触的教程会让你先学 typeid但我现在真心觉得这是教学顺序的失误。C 的类型系统本来就以静态为主运行期多态只是补充。如果一上来就把 typeid 当成判断类型的万能钥匙后面写模板时就会不自觉地往运行期拖泛型代码的性能和可读性都会出问题。1.2 一张表看清C类型判断的全部工具我把日常会用到的手段列成一张表后面每个章节再展开讲细节工具判断时机依赖条件引入标准典型用途typeid运行期/编译期多态类型需开启RTTIC98类型字符串标识、调试日志dynamic_cast运行期多态类型、开启RTTIC98基类指针安全转派生类指针decltype编译期无特殊依赖C11推导表达式类型配合auto使用type_traits编译期无特殊依赖C98/11判断is_same、is_base_of等类型特征if constexpr编译期C17C17按类型条件编译不同的模板分支concepts编译期C20C20声明类型约束面向接口编程表里最关键的一条分界线是凡是不依赖 RTTI 的都在编译期可完成能优先用就优先用。RTTI 不仅运行时有开销而且很多嵌入式环境、游戏引擎为了控制二进制体积会直接关掉它。你写的工具库一旦被这些项目引用typeid 和 dynamic_cast 会直接变成编译错误到时候再回头改模板代码成本翻倍。2. 运行期类型判断typeid 和 dynamic_cast 的正确用法2.1 typeid 看起来简单但它有一个容易被忽视的分叉typeid 最容易理解它返回一个 type_info 对象可以用比较也可以用.name()拿到类型名。我用一个简单的几何图形例子来说明#include iostream #include typeinfo class Shape { public: virtual ~Shape() default; }; class Circle : public Shape {}; class Rectangle : public Shape {}; void identify(Shape s) { std::cout s 的类型是: typeid(s).name() std::endl; } int main() { Circle c; Rectangle r; identify(c); // 输出 Circle 的类型名 identify(r); // 输出 Rectangle 的类型名 }注意identify(Shape s)的参数是引用typeid(s)返回的是参数的动态类型所以能正确识别出 Circle 和 Rectangle。但如果把参数改成Shape s按值传递对象会被切片成 Shapetypeid 就只能认出 Shape所有派生类都会被打回原形。还有一类对象需要特别当心非多态类型。如果类的定义里没有任何虚函数typeid 走的就不是运行期 RTTI而是基于静态类型的编译期判断。这会导致下面这种诡异现象class Point {}; void check(Point p) { // Point 没有虚函数这里永远输出 Point std::cout typeid(p).name() std::endl; }看起来是在运行期判断实际拿到的却是编译期就确定的结果。很多人在业务代码里写出这种逻辑后派生类对象传进来判断结果却始终是基类排查问题时又因为 typeid 在大多时候正常而忽略了该类没有虚函数这个前提。2.2 dynamic_cast 才是真正做运行期类型检查的主角如果要在基类指针或引用上安全地转回派生类dynamic_cast是标准答案。它会在转换前检查对象的动态类型失败时返回 nullptr如果是引用转换失败则抛出std::bad_cast异常。#include iostream #include typeinfo Shape* getShape(int kind) { if (kind 1) return new Circle(); return new Rectangle(); } void handleShape(Shape* s) { // 先判断是不是 Circle再安全转型 Circle* c dynamic_castCircle*(s); if (c ! nullptr) { std::cout 这是一个圆半径逻辑处理... std::endl; } }dynamic_cast和static_cast的区别要拎清楚static_cast完全不检查是我不管你是啥我说你是你就是dynamic_cast会走 RTTI 查一下是我先确认你是不是是才转给你。处理外部传入的接口时绝对不要用static_cast做向下转型一旦类型判断失误后面的崩溃和内存破坏都很难追。2.3 关于RTTI的开关和性能RTTI 是一个可以关闭的特性。GCC/Clang 用-fno-rttiMSVC 用/GR-。关闭后typeid 和 dynamic_cast 相关代码直接编译不过。如果项目里真的因为体积或性能关闭了 RTTI运行期类型判断就只剩一条路自己维护类型标识常见做法是在基类里加一个返回整数或字符串的虚函数。这也是很多游戏引擎的做法他们宁愿用虚函数表手动分派也不想为整个项目背负 RTTI 的成本。性能上typeid 和 dynamic_cast 都不是免费的。dynamic_cast 在某些实现里需要沿着继承链比较类型多层继承和菱形继承时会明显变慢。所以我在项目里的原则是只在处理真正的多态对象、且必须做运行时才知道具体类型的分发时用它们能让编译期解决的问题绝不拖到运行期。3. 编译期类型判断type_traits、decltype 和 if constexpr 的主场3.1 type_traits把类型判断做成常量表达式C11 起type_traits头文件提供了一批用于类型判断的工具核心输出是一个值为 true/false 的编译期常量。最常用的是std::is_same、std::is_base_of、std::is_integral、std::is_pointer、std::is_class等。#include type_traits #include iostream static_assert(std::is_sameint, int::value, int 和 int 当然相同); static_assert(std::is_base_ofShape, Circle::value, Circle 继承自 Shape); // C17 开始可以用 _v 后缀 static_assert(std::is_integralint); static_assert(!std::is_integralfloat);static_assert是编译期断言的绝配写泛型代码时只要模板参数的类型不满足预期编译时就报错比运行期弹日志提示早了十万八千里。我习惯在每个公开模板函数入口放一组static_assert相当于把类型判断写成文档任何调用方违反约定都过不了编译。type_traits 的实现原理也不难理解底层全是模板特化。is_sameT, U主模板定义为 false然后特化为is_sameT, T为 true。它和运行期 RTTI 没有任何关系所以关掉 RTTI 也完全不影响。3.2 decltype 和 auto先推导出类型再判断decltype负责在编译期推导一个表达式或变量的类型auto则是声明变量时让编译器自动推导。两者配合可以写出先拿到类型再做判断的泛型逻辑template typename T auto getValueTypeName() { using RawType std::decay_tT; // 去掉引用和 const if constexpr (std::is_same_vRawType, int) { return int; } else if constexpr (std::is_same_vRawType, std::string) { return string; } }一个必须记住的坑decltype(v)推导出的结果保留表达式的引用和 cv 限定符而auto会去掉顶层 const 和引用。比如int x 42; int ref x; using T decltype(ref); // T 是 int auto r ref; // r 是 int不是引用做类型判断时我强烈建议通过std::decay_tT或std::remove_cvref_tTC20先把类型洗干净统一去掉 const、volatile 和引用再交给 is_same 判断。否则一个const std::string就能让is_sameT, std::string返回 false排查半天才意识到修饰符没剥掉。3.3 if constexpr让代码在编译期长成两棵不同的树C17 引入的if constexpr是泛型编程的一次大升级。它可以这样理解编译器根据常量条件直接把不满足的分支代码丢弃不参与实例化。这意味着你可以在一个模板函数里光明正大地写不同类型的处理逻辑完全不同的代码而不用担心报错。举一个最典型的场景通用打印函数。#include iostream #include string #include vector #include type_traits template typename T void printValue(const T value) { if constexpr (std::is_integral_vT) { std::cout 整数: value std::endl; } else if constexpr (std::is_floating_point_vT) { std::cout 浮点: value std::endl; } else if constexpr (std::is_same_vstd::decay_tT, std::string) { std::cout 字符串: value std::endl; } else if constexpr (std::is_same_vstd::decay_tT, const char*) { std::cout C字符串: value std::endl; } else { std::cout 未知类型 std::endl; } }如果是 C14这个函数要用 std::enable_if 拆成三个重载才能实现代码量直接翻三倍。而在 if constexpr 里编译器看到std::is_integral_vint为 true会把整棵 else if 子树全部丢弃不生成任何额外代码。3.4 回头看 SFINAE 为什么会被 if constexpr 替代SFINAESubstitution Failure Is Not An Error是 C 模板时代判断类型的经典武器。它利用模板替换失败时不报错、而是把该特化从候选集合里删除的特性配合 std::enable_if 实现条件性重载。打个比方SFINAE 就像在网上买东西时客服先问一句你有没有这个券没有就让你排队等下一个客服if constexpr 则是下单时系统自动判断按你的情况生成一张专属购物车。前者能实现同样的效果但代码极其绕需要写额外的模板参数、只能用类型层面的表达式、报错信息晦涩难懂。除非你的项目还锁在 C14否则新代码完全不需要再写 std::enable_if。我从 C17 起已经把所有场景的 enable_if 全部改写成 if constexpr type_traits可读性提升显著。4. C20 concepts把类型判断变成接口约束4.1 concept 的本质也是编译期判断C20 concepts 提供了一种更直白的类型判断表达用concept声明一组约束把它当成类型层面的接口来使用。本质依然是编译期判断只是把判断逻辑起了一个名字允许复用。#include concepts #include type_traits template typename T concept Number std::is_arithmetic_vT; template Number T T add(T a, T b) { return a b; } int main() { auto r add(1, 2); // 编译通过 // auto s add(std::string(a), std::string(b)); // 编译失败std::string 不是 Number }模板参数template Number T的写法比template typename T, std::enable_if_tstd::is_arithmetic_vT, int 0直观太多。函数签名本身就说明了这个函数只接受数字类型调用方的 IDE 也能直接给出约束提示。4.2 用 requires 表达式做能力检测concepts 比 is_same 更进一步的地方在于它可以判断一个类型是否具备某种能力而不仅仅判断是不是某个类型。比如我想判断一个类型是否有toString成员函数用 requires 表达式template typename T concept HasToString requires(const T obj) { { obj.toString() } - std::convertible_tostd::string; };这行代码的意思很直白要求 T 类型的对象 obj 能调用toString()且返回值能转换成 std::string。这种能力检测在泛型代码里非常管用你不需要维护一张我支持的所有类型清单只要类型具备相应能力自动进入对应分支。4.3 一个具体的能力分派示例把 concepts 和 if constexpr 结合能写出非常优雅的分派逻辑。比如我有两套序列化策略一类类型自带 serialize 方法另一类类型是 flatbuffer 结构体。可以这样声明template typename T concept HasSerialize requires(const T obj) { { obj.serialize() } - std::same_asstd::vectorchar; }; template typename T std::vectorchar doSerialize(const T obj) { if constexpr (HasSerializeT) { return obj.serialize(); } else { // 走通用字段遍历逻辑或者直接 static_assert 拒绝 static_assert(HasSerializeT, 该类型没有可用的序列化方式); } }编译器在实例化 doSerialize 时会先判断 T 是否满足 HasSerialize然后只编译对应的分支。如果都不满足static_assert 给出的报错信息比任何运行期异常都清晰。5. 实战三个高频场景里的类型判断打法5.1 给任意对象写通用 toString 或 print我前面已经展示了 printValue 的基本版这里讲一个更完整的通用 toString。目标是数值转字符串、字符串原样返回、容器遍历拼接、自定义类型调用它的 toString 方法。#include string #include sstream #include vector #include map #include concepts template typename T concept HasToStringMethod requires(const T obj) { { obj.toString() } - std::convertible_tostd::string; }; std::string toString(const std::string s) { return s; } template typename T requires std::is_arithmetic_vT std::string toString(T value) { return std::to_string(value); } template typename T requires HasToStringMethodT std::string toString(const T obj) { return obj.toString(); } template typename T requires requires(const T container) { { std::begin(container) }; { std::end(container) }; } std::string toString(const T container) { std::ostringstream oss; oss [; bool first true; for (const auto item : container) { if (!first) oss , ; oss toString(item); // 递归调用 first false; } oss ]; return oss.str(); }这里用重载 requires 子句完成了一个四路类型判断。你可能会问为什么不写成一个模板函数加一堆 if constexpr因为 println 这类的需求重载决议做得比我手动判断更稳而且递归调用 toString(item) 时会自动选择当前类型的正确重载。这也算类型判断的另一种形态让编译器通过重载决议替你判断。5.2 数据库绑定参数时的类型自动分派我参与过一个对接 TDengine 的项目用 C 绑定写入数据库时taos_stmt_prepare之后要为每个参数选择不同的绑定函数。TDengine 的 C 接口要求不同类型调用taos_bind_bind_param时填入不同类型的 value 结构比如整数用 int64、浮点用 double、字符串用 const char*。如果手动写分支参数一多状态很重。我直接用 type_traits 在编译期帮忙选择#include type_traits template typename T void bindValue(TAOS_STMT* stmt, int index, const T value) { if constexpr (std::is_integral_vT) { // 绑定整数类型数据按 int64_t 传给接口 int64_t v static_castint64_t(value); taos_bind_param(stmt, index, v); } else if constexpr (std::is_floating_point_vT) { double v static_castdouble(value); taos_bind_param(stmt, index, v); } else if constexpr (std::is_same_vstd::decay_tT, std::string) { const char* cstr value.c_str(); taos_bind_param(stmt, index, cstr); } else { static_assert(!sizeof(T), 不支持的绑定类型); } }这个函数最大的价值是调用方传入一个新类型时如果没被覆盖到编译直接失败而不是等程序运行到写入数据库时才抛诡异错误。类型判断在这里变成了 API 的安全网。5.3 std::any 和 std::variant 的类型恢复判断C17 引入了两个可以装不同类型的容器它们本身就是类型判断的另一种玩法。std::any可以保存任意类型取出时必须用any_castT做类型判断。如果类型不匹配指针版本返回 nullptr值版本抛std::bad_any_cast。#include any #include string #include iostream void processAny(const std::any val) { if (val.type() typeid(int)) { int v std::any_castint(val); std::cout 整数: v std::endl; } else if (val.type() typeid(std::string)) { auto s std::any_caststd::string(val); std::cout 字符串: s std::endl; } }std::variant则是编译期的有限类型联合它提供的holds_alternativeT和std::visit都比 any 更安全、更高效。如果有编译期确定的类型集合优先用 variant别用 any。#include variant #include string #include iostream using Value std::variantint, double, std::string; void printValue(const Value v) { std::visit([](const auto val) { if constexpr (std::is_same_vstd::decay_tdecltype(val), std::string) { std::cout 字符串: val std::endl; } else { std::cout 数值: val std::endl; } }, v); }std::visit会自动根据 variant 当前实际保存的类型调用 lambda 的对应实例化版本配合 if constexpr 做类型判断是 C17 里最舒服的组合。5.4 基类指针集合的运行时识别处理事件系统或插件列表时经常遇到一个 vector里面装的是某个基类的 unique_ptr但实际对象是各种派生类。这时候 dynamic_cast 是标准打法#include vector #include memory class Event { public: virtual ~Event() default; }; class MouseEvent : public Event {}; class KeyEvent : public Event {}; void dispatchEvents(const std::vectorstd::unique_ptrEvent events) { for (const auto evt : events) { if (auto* mouse dynamic_castMouseEvent*(evt.get())) { handleMouse(mouse); } else if (auto* key dynamic_castKeyEvent*(evt.get())) { handleKey(key); } } }如果这个 dispatch 会持续新增事件类型我更推荐改成函数表或 virtual function 的方式避免每加一个类型就动一次判断代码。dynamic_cast 适合少量、稳定、可穷举的派生关系。6. 类型判断踩坑清单这些错误我都在代码里见过6.1 typeid 用到非多态类型上结果过于正确如 2.1 所说在没有虚函数的类型上typeid 走的是静态类型。但代码看起来和正常的 typeid 用法没有任何区别非常容易踩。我的经验是只要类型没有虚函数就不要用它做什么动态识别。如果确实要区分多个无多态的类型考虑改用模板特化或 variant。6.2 dynamic_cast 的两种失败方式要分清指针版 dynamic_cast 失败返回 nullptr你得先判空再使用引用版失败直接抛std::bad_cast。很多人写引用版不包 try-catch一个空引用转换直接让程序崩溃。我建议在公共服务里统一用指针版Rectangle* r dynamic_castRectangle*(s); if (r nullptr) { // 类型不符记录日志或返回错误 return; }6.3 判断时忘了剥掉 const、引用和指针is_same 是字面匹配std::is_same_vT, int在 T 为const int时结果是 false。这是新手踩得最多的坑。解决办法是判断前统一做类型清洗using CleanT std::remove_cvref_tT; // C20 using CleanT std::decay_tT; // C11 兼容6.4 decltype(auto) 的引用陷阱decltype(auto)作为函数返回值时会保留表达式的引用属性。如果你本意是返回一个值结果表达式恰好是左值引用函数的返回类型就变成引用极大安全隐患template typename T decltype(auto) getValue(T obj) { return obj.member; // 返回类型是引用调用方以为拿到值实际拿到了引用 }类型判断的上下文中使用 decltype 时一定要清楚它和 auto 的区别判断前先想一想这个表达式是左值还是右值、带不带引用。6.5 if constexpr 分支里的常见理解错误if constexpr 和普通 if 长得几乎一样但它的条件是编译期常量。如果条件里依赖的模板参数没有被实例化编译器会选择不编译对应分支这没问题但如果分支代码里出现了语法错误比如某个类型没有某个成员那么只有该分支被选中时才会报错。反过来说如果分支没有被选中里面有语法错误也不会被发现。这让坑变得隐蔽你以为代码写对了实际是在别的 T 上编译通过了。解决思路是在 if constexpr 的 else 分支里写上 static_assert依赖模板参数确保没有漏网之鱼。7. 个人体感与选型建议7.1 我的判断优先级经过这么多项目我自己形成了四条简单的选型规则编译期能解决坚决不用运行期模板函数内部一律用 type_traits if constexpr必须处理多态对象的动态类型dynamic_cast 优先于手写 typeid 手动强转typeid 只留作调试日志和类型名展示不做核心业务判断公开接口的类型约束用 concepts 写比 static_assert 更可读能被 IDE 提示。7.2 如何在老项目里逐步引入如果你的项目还是 C14先聚焦一件事用 type_traits 替代所有手写 typeid 判断。这一项不需要改编译标准收益却最直接。等编译标准升到 C17再把 std::enable_if 逐一替换成 if constexpr会发现大量模板代码从绕来绕去变成从上到下一根直线。升到 C20 后用 concepts 把模板的约束声明在函数签名里以后任何人调用你的模板函数约束在 IDE 里就看得见。7.3 一个小技巧用 static_assert 留下类型判断文档我在写公共模板时习惯在最顶部放一组 static_assert。比如一个函数只接受整数类型template typename T void processInteger(T value) { static_assert(std::is_integral_vT, processInteger 只接受整数类型); // 正常逻辑 }这比注释可靠一百倍。注释会过时static_assert 不会——它让类型判断不仅是一个过程还成为 API 的一部分。我现在的习惯是公开模板函数的入口放约束声明内部配合 if constexpr 区分分支。typeid 只出现在调试日志里dynamic_cast 只在处理真实多态对象时才出现。这套组合从 C17 开始用得很稳如果你的项目还锁在 C14那就先忍受一下 std::enable_if 的丑但判断思路是一样的编译期能做的绝不留到运行期。