
写模板函数时最常见的困境是一个逻辑但对不同类型的处理方式不一样——指针要解引用非指针直接用容器要遍历标量直接打印。C11 时代这事只能靠 SFINAE 把逻辑拆到多个重载里或者写std::enable_if的绕口签名。C17 的if constexpr把这件事拉回函数体内写法跟普通if几乎一样但没被选中的分支根本不会被实例化。这篇把这句话拆开讲透包括它做不到的地方。1. 引子指针解引用非指针原样返回需求是写一个unwrap()传指针就返回它指向的值传别的就原样返回。直觉上写个普通if就完了// if_constexpr_vs_if.cpp片段故意编不过—— 普通 if 没有「丢弃分支」这回事#includetype_traitstemplateclassTautounwrap_bad(T v){if(std::is_pointer_vT){return*v;// 反例不要这么写T int 时这行照样会被实例化}else{returnv;}}// 真实报错gcc-13.2.0// error: invalid type argument of unary * (have int)//// 关键std::is_pointer_vint 明明在编译期就是 false为什么还会报错// 因为普通 if 是「运行时分支」——条件表达式在编译期就算出来两个分支// 依然要全部通过类型检查、全部被实例化。int 上做 *v 非法于是编译失败。// 顺带还有第二个坑两个分支返回类型不同T 与 T 的解引用结果// auto 推导也过不去。这就引出了if constexpr要解决的第一个问题。官方文档if statement含 constexpr if 一节— cppreference2. 本质区别没被选中的分支不实例化把上面的if改成if constexpr同样的逻辑立刻编得过、跑得起来// if_constexpr_ptr.cpp — 编译: g -stdc17 -Wall -O2 if_constexpr_ptr.cpp -o icp#includecstdio#includetype_traits// 对指针解引用对非指针原样返回 —— 两种路径的表达式类型完全不同普通 if 做不到templateclassTautounwrap(T v){ifconstexpr(std::is_pointer_vT){return*v;// 只在 T 是指针时被实例化}else{returnv;// 只在 T 不是指针时被实例化}}intmain(){intn42;int*pn;std::printf(unwrap(p) %d\n,unwrap(p));std::printf(unwrap(n) %d\n,unwrap(n));std::printf(unwrap(\hi\) 首字符 %c\n,unwrap(hi));}unwrap(p) 42 unwrap(n) 42 unwrap(hi) 首字符 h两者的差别可以精确地概括成两条第一条是条件必须是编译期常量表达式if constexpr的条件必须能当场算出boolstd::is_pointer_vT是常量模板参数合法运行时的if (x 0)拿去当if constexpr的条件直接编译失败。第二条、也是真正关键的那条是被丢弃的分支不参与实例化if constexpr 的实例化流程模板内部 模板定义 实例化 unwrapint(42) 实例化 unwrapint*(p) ────────────────────────── ───────────────────────── ───────────────────────── if constexpr (is_pointer_vT) └─ return *v; ✗ 条件为假 → 整条语句 ✓ 条件为真 → 实例化本分支 被「丢弃」不进实例化 *v 合法返回 int else └─ return v; ✓ 实例化本分支 ✗ 被丢弃不进实例化 v 是 int合法 return v 的结果是 int* 结果一次实例化只「看见」自己那一半代码 —— 这就是不用 SFINAE 也能按类型分叉unwrapint那次实例化里return *v;这行压根没被编译器检查所以int上做解引用这个错误从来不会发生。这正是if constexpr能取代大量 SFINAE 的原因SFINAE 是把不匹配的实现从重载集里剔除if constexpr是把不匹配的实现从实例化里剔除——目的相同但前者要改函数签名后者只改函数体。3. 边界丢弃的分支语法必须正确「不实例化」不等于「不存在」。被丢弃的分支仍然要被解析所以语法错误照样报。cppreference 上给了一个很直接的例子在非模板函数里被丢弃的分支会被完整检查——// if_constexpr_syntax.cpp片段故意编不过voidf(){ifconstexpr(false){inti0;int*pi;// 反例不要这么写即使条件是 false这行也照样报错}}// 原因这里不在模板里没有「实例化」这一步可谈// 所有语句都被完整做类型检查。if constexpr 不是 #if 预处理指令的替代品。而在模板内部情况正好相反只要条件在实例化之后不再是值依赖的value-dependent被丢弃的子语句不做实例化——也就是语法必须正确、语义可以无效。下面这段代码里else分支对一个int调用了根本不存在的成员函数frobnicate()但它能正常编译运行因为我们只拿整型去实例化它// if_constexpr_discard.cpp — 编译: g -stdc17 -Wall -O2 if_constexpr_discard.cpp -o icd#includecstdio#includetype_traitstemplateclassTvoidshow(T v){ifconstexpr(std::is_integral_vT){std::printf(整型: %lld\n,static_castlonglong(v));}else{// T int 时整个 else 分支被丢弃这一行不会被实例化// 所以「int 没有 frobnicate() 成员」这件事编译器不会去查。// 反过来一旦用 double 去实例化 show这里立刻报错。v.frobnicate();}}intmain(){show(7);show(static_castshort(9));// show(3.14); // ❌ 放开这行double 非整型 → else 分支被实例化 → 编译失败}整型: 7 整型: 9注意v.frobnicate()里v是依赖名dependent name模板定义阶段本来就不检查真正决定它会不会报错的是「这个分支有没有被实例化」。这条规则的实际价值写类型分派时可以放心地在各个分支里用只对特定类型成立的成员和运算符例如只对容器用.size()、只对指针用*。但反过来拼错一个括号、少写一个逗号无论分支丢不丢弃都会报错——因为那是解析阶段的事。4.return与auto返回值推导if constexpr里放return是常见用法但和auto返回值推导配合时有个必须记住的点同一份实例化里所有可能执行到的return语句返回类型必须一致。被丢弃分支里的return不参与推导因为它根本没被实例化。// if_constexpr_join.cpp — 编译: g -stdc17 -Wall -O2 if_constexpr_join.cpp -o icj#includecstdio#includestring#includetype_traits// 数值转字符串其它类型直接返回std::string 或能转成它的东西templateclassTstd::stringstringify(constTv){ifconstexpr(std::is_arithmetic_vT){returnstd::to_string(v);// 只在算术类型上实例化}else{returnv;// 只在非算术类型上实例化}}// 递归模板的基准情形直接写在函数体内不用再开一个重载templateclassFirst,class...Reststd::stringjoin(constFirstfirst,constRest...rest){ifconstexpr(sizeof...(rest)0){returnstringify(first);// 基准情形}else{returnstringify(first), join(rest...);// 递归步}}intmain(){std::printf(%s\n,join(1,2.5,three,std::string(four)).c_str());std::printf(%s\n,join(42).c_str());}1, 2.500000, three, four 42join(42)只给一个实参时Rest是空包sizeof...(rest) 0为真走基准情形——基准情形和递归步现在住在同一个函数里比 C11 时代「再写一个重载」清爽得多。stringify那边则体现了第 2 节的规则T int时走std::to_stringT std::string或char[6]时走return v后者靠std::string的隐式转换两条路径的返回类型都是std::stringauto推导没有冲突。顺带一个观察join(1, 2.5, ...)把2.5打印成了2.500000因为std::to_string(double)固定 6 位小数。这不是if constexpr的问题是to_string的既定行为——要控制精度得换std::ostringstream或 C20 的std::format。官方文档std::to_string — cppreference5. 典型用途类型分派if constexpr最实际的用途是在一个函数里按类型分派到不同实现而且可以串成链if constexpr (A) { … } else if constexpr (B) { … } else { … }。下面的完整示例用它在四种类型之间做分派算术类型直接打印、std::vector走遍历、std::string带前缀、其余兜底。// if_constexpr_full.cpp — 编译: g -stdc17 -Wall -O2 if_constexpr_full.cpp -o icfull#includecstdio#includestring#includetype_traits#includevector// 判断 T 是不是 std::vector偏特化版任何分配器都认templateclassTstructis_vector:std::false_type{};templateclassT,classAllocstructis_vectorstd::vectorT,Alloc:std::true_type{};templateclassTvoiddump(constTv){ifconstexpr(std::is_arithmetic_vT){std::printf(%s\n,std::to_string(v).c_str());}elseifconstexpr(is_vectorT::value){std::printf(vector(size%zu): ,v.size());for(constautox:v)std::printf(%s ,std::to_string(x).c_str());std::printf(\n);}elseifconstexpr(std::is_same_vT,std::string){std::printf(string: %s\n,v.c_str());}else{std::printf(其它类型\n);}}intmain(){dump(3.14);// 算术类型dump(std::string(hello));// std::stringdump(std::vectorint{1,2,3});// 容器dump(7);// 整型也是算术类型}3.140000 string: hello vector(size3): 1 2 3 7链条的运作方式和普通if / else if一样但每一次实例化只会走通一条路径dumpdouble实例化时后面三个分支全部被丢弃v.size()这类只对容器成立的调用压根不会被检查dumpstd::vectorint实例化时则轮到第一、三、四分支被丢弃。所以std::to_string(x)写在容器分支里是安全的——只要不拿「元素不是算术类型的容器」去实例化它。整个程序无裸new/delete无using namespace std;is_vector用enum-free 的std::true_type/std::false_type表达符合 Core Guidelines 的「用类型系统表达意图」。官方文档C Core Guidelines — T.1: 用模板直接表达意图6. 两张表和 SFINAE、Concepts 怎么选先看三者在「按类型分叉」这件事上的位置差异维度if constexprC17std::enable_if/ SFINAEC11ConceptsC20代码位置函数体内部函数签名 / 模板参数列表模板参数列表条件形态任意编译期bool常量表达式靠替换失败表达的bool具名概念可组合不匹配时该分支不实例化该候选被静默剔除不参与重载解析能否用于非模板能但两个分支都被完整检查不能不能报错可读性好直接指向出错那行差一长串替换细节最好直接说不满足哪个概念适合场景同一函数内按类型走分支、递归基准情形维护 C11/14 代码、控制重载集新项目的约束首选再具体一点同一个unwrap()三种写法长这样写法代码评价SFINAEtemplate class T std::enable_if_tstd::is_pointer_vT, T unwrap(T v)加一个!的反向重载签名臃肿两个重载要同步维护if constexpr一个函数体内if constexpr (std::is_pointer_vT)逻辑集中读起来就是普通的 if / elseConceptstemplate class T requires std::is_pointer_vT风格约束报错最清晰但需要 C20 编译器务实结论新代码里「同一函数内按类型分叉」一律用if constexpr要控制重载集决定某个候选参不参与重载解析时 SFINAE 或 Concepts 才是对的工具因为if constexpr只能在函数已经选定之后起作用——它没法让整个函数从候选里消失。这条边界很重要别指望用if constexpr去解决重载歧义。官方文档Constraints and concepts — cppreference7. 延伸阅读if statementconstexpr if 一节 — cppreference —— 丢弃分支的准确定义含「非模板里被完整检查」的例子细节以这页为准std::enable_if — cppreference —— 被if constexpr取代的老写法读老代码时必查Constraints and concepts — cppreference —— C20 的约束体系约束重载集的首选std::is_invocable — cppreference —— 常和if constexpr配对使用的可调用性查询C Core Guidelines: Templates — isocpp.org —— 模板设计的总体指导思想8. 一句话总结if constexpr与普通if的本质区别有两条条件必须是编译期常量以及未被选中的分支不参与实例化——后者正是它能取代大量 SFINAE 的原因SFINAE 把不匹配的实现从重载集里剔除if constexpr把它从实例化里剔除。被丢弃的分支仍然必须语法正确解析阶段照查只是语义不必有效不做类型检查所以可以放心地在分支里用只对特定类型成立的成员。用它写递归模板的基准情形、写「按类型走不同实现」的类型分派都很顺手但要控制重载集——让某个函数整个从候选里消失——仍然得靠enable_if或 Concepts。