• 忘掉天地
  • 仿佛也想不起自己
bingliaolongBingliaolong  2026-08-08 15:18 Aet 隐藏边栏 |   抢沙发  3 
文章评分 1 次,平均分 5.0

SFINAEstd::enable_ifC++20 Concepts

SFINAE 是什么

  1. 模板参数替换失败不算错误
    1. Substitution Failure Is Not An Error

  1. 这里不是说整个程序永远不会报错,而是
    1. 这个候选模板的替换失败本身不立即报错,编译器可以继续寻找其他候选
    2. 如果所有候选都被排除,最终仍然会出现“没有匹配函数”的编译错误

编译器到底做了什么

  1. 可以把一次模板函数调用理解为

  1. 最关键的分界线是:
    1. 模板声明中的替换失败:可能触发 SFINAE
    2. 模板被选中后,函数体中的错误:通常是硬错误

SFINAE 只保护“直接上下文”

  1. 错误出现在声明中
    1. value.size() 位于返回类型中
    2. 对于没有 size() 的类型,模板可以被 SFINAE

  1. 错误只出现在函数体中

  1. 模板 bad<int> 的参数匹配优于省略号版本,所以模板先被选中

  1. 因此,阅读模板代码时必须分清

    1. 限制条件出现在函数声明里,还是隐藏在函数体里?
  2. SFINAE 的直接上下文通常包括

    1. 函数参数类型
    2. 函数返回类型
    3. 模板参数声明
    4. 这些位置中的 decltype、默认模板实参等表达式
  3. 通常不包括

    1. 函数体
    2. 为了替换而连带实例化的其他模板内部
    3. 某些默认成员函数或辅助类模板中的后续错误

std::enable_if原理

  1. std::enable_ifC++11 开始提供
    1. 可以把它近似理解为

  1. 于是
    1. 如果这种失败发生在模板替换的直接上下文中,模板就会被 SFINAE

  1. C++14 提供了简写

如何使用 enable_if

  1. 放在返回类型中
    1. 缺点是:
    2. 构造函数和析构函数没有返回类型,不能这样使用
    3. 返回类型中的约束不够醒目
    4. 复杂条件会让函数声明很难读

  1. 放在模板参数中
    1. 这是旧项目最常见的形式

  1. 放在普通函数参数中
    1. 这种形式在老代码里能看到,但通常不推荐,因为它污染了函数形参列表

enable_if 经典陷阱

  1. 下面的写法不能用于定义两个重载

  1. 原因是
    1. 默认模板实参不属于函数模板签名的一部分

修复完美转发构造函数

  1. 万能引用构造函数可能抢走复制构造函数
    1. 详细见上一编

  1. C++17 可以这样限制
    1. 两个约束分别表示:
    2. 一:参数本身不能是 Person,否则可能抢占复制或移动构造
    3. 二:std::string 必须真的能够由表达式类型 S&& 构造

std::void_t

  1. C++17 提供了
    1. 检测某个表达式是否存在
    2. 看起来什么都没做,但它可以把任意待检测表达式带入 SFINAE 上下文

  1. 检测类型是否有 size()

  1. 理解过程
    1. 主模板默认继承 false_type
    2. 如果 Tsize() 表达式合法,偏特化中的 void_t<...> 得到 void
    3. 偏特化匹配成功,继承 true_type
    4. 如果表达式非法,偏特化被 SFINAE 掉,回到主模板
  2. 不过它只证明表达式在语法和类型系统上合法,不证明
    1. size() 是常数时间
    2. 结果在运行期间正确
    3. 对象线程安全
    4. 返回值符合业务语义

C++20 Concepts

  1. C++20 把约束正式加入语言,不再需要通过“让某个 type 消失”来间接控制模板

  1. 它表示
    1. 对于 const T& value
    2. 表达式 value.size() 必须合法
    3. 结果必须可以转换成 std::size_t
  2. 使用方式有四种:
  3. 一:requires 子句

  1. 二:约束模板参数

  1. 三:缩写函数模板

  1. 四:尾部 requires

两个连续的 requires 是什么

  1. 大型项目里经常会看到

  1. 两个 requires 作用不同:
    1. 第一个是 requires-clause:给模板添加约束
    2. 第二个是 requires-expression:产生一个编译期 bool,检查表达式是否合法

Concepts 不等于 SFINAE

  1. 从使用效果看,两者都会让不满足条件的模板退出候选集合
  2. 但语言机制不同:
方面 SFINAE / enable_if C++20 Concepts
引入版本 SFINAE 早已有之;enable_if C++11 C++20
表达方式 制造无效类型或表达式 直接声明约束
错误信息 往往很长 通常更明确
重载排序 依赖传统模板偏序技巧 约束参与重载排序
可读性 条件复杂后较差 更接近接口声明
老项目兼容 C++11/14/17 可用 需要 C++20
  1. 严格来说
    1. Concepts 的约束不满足不是“SFINAE 失败”,而是候选函数不满足关联约束
    2. 只是从调用者视角看,它们效果相似

if constexpr不能替代约束

  1. C++17
    1. 这个模板对任何 T 都是候选函数。只是在模板被选中后,根据类型选择函数体中的分支

  1. 而下面的concepts
    1. 会在重载解析阶段排除非整数类型

  1. 因此
    1. Concepts/SFINAE:控制接口是否存在
    2. if constexpr:控制已经选中的模板怎样实现

网络代码中的 Concept

  1. Concept 明确了模板所依赖的静态接口
    1. Concept 是编译期类型约束,不是完整的业务契约
  2. 但它无法保证
    1. data() 返回的内存在发送期间一直有效
    2. size() 没有超过实际缓冲区
    3. size() 没有超过实际缓冲区
    4. command() 已转换成网络字节序

函数模板重载解析、模板偏序与约束排序

重载解析的完整流程

  1. 调用

  1. 编译器大致依次完成:
    1. 名字查找,收集名为 process 的候选
    2. 对函数模板进行模板参数推导
    3. 替换模板参数,SFINAE 掉无效模板
    4. 检查 C++20 约束是否满足
    5. 判断参数数量、参数转换等是否可行
    6. 比较隐式转换序列
    7. 如果转换一样好:
      普通函数通常优先于函数模板;
      函数模板之间进行模板偏序;
      C++20 还会比较约束谁更强
    8. 最佳候选确定后,实例化函数体

普通函数不一定优先于模板

  1. process(10)
    1. 转换质量相同,因此选择普通函数

  1. process(3.14)
    1. 模板转换更好,所以选择模板

  1. 因此规则不是“普通函数永远优先”,而是
    1. 转换质量相同时,普通函数优先
  2. process(&value)
    1. 这时模板偏序认为 T* 版本更加专用,所以选择指针版本

  1. process<>(10)
    1. 空模板实参列表表示
    2. 只考虑函数模板,因此普通函数 process(int) 被排除

隐式转换的基本等级

  1. 简化后的排序是
等级 示例
精确匹配 int -> intT& -> T&
提升 char -> intfloat -> double
标准转换 double -> int、派生类指针转基类指针
用户定义转换 转换构造函数、转换运算符
省略号 f(...)
  1. 前面的匹配通常优于后面的匹配
  2. 不过“精确匹配”内部还有:
    1. cv 限定转换
    2. 引用绑定
    3. 继承关系
    4. 初始化列表
    5. 模板偏序

模板参数推导时通常不做类型协调

  1. 编译器不会主动寻找共同类型,也不会在推导期间先把 int 转成 double
  2. 可以显式指定模板参数

  1. 或者将模板设计为两个参数

  1. 因此要区分
    1. 模板参数推导阶段:通常不依靠隐式转换解决推导冲突
    2. 推导完成以后:函数调用参数可以进行正常的隐式转换

什么是函数模板偏序

  1. 考虑

  1. 对于

  1. 两个模板都可以生成
    1. 转换质量完全相同,于是进行函数模板偏序

  1. 可以从“可接受类型的集合”理解:

    1. inspect(T) 能接受几乎所有可复制的类型
    2. inspect(T*) 只能接受指针类型
    3. 指针类型是所有类型中的一个子集
    4. 所以 T* 版本更加专用
  2. 更接近编译器的思考方法是:

    1. 用虚构类型 X 替换 T*,得到 X*
    2. 普通版本的 T 可以匹配 X*
    3. 用虚构类型 X 替换普通版本
    4. T* 不能匹配任意的 X
    5. 因而 T* 版本更加专用

  1. 注意:函数模板不能偏特化

参数包也参与模板偏序

  1. 调用
    1. 两个模板都能匹配,但没有参数包的版本更加专用

万能引用会干扰重载

  1. 考虑

  1. const 左值

  1. const 左值
    1. const T& 版本更合适

  1. 右值
    1. 选择 T&& 版本

  1. 这也是为什么万能引用可能抢占拷贝构造函数

  1. 不要同时随意提供 TT&&

C++20 Concepts如何参与排序

  1. 首先定义两个具有明确层次的 Concept

  1. 对于 int
    1. Integer<int> 成立
    2. SignedInteger<int> 也成立
    3. SignedInteger 明确包含 Integer 的约束
    4. 因此第二个模板更加受限
  2. 对于 unsigned int,只有 Integer 成立
  3. 这叫做约束的 subsumption,可以理解为:
    1. 如果满足约束 B 必然也满足约束 A,并且编译器能从约束结构中识别这种包含关系,那么 BA 更受限

编译器不会证明任意逻辑关系

  1. 下面的代码看起来第二个条件明显更严格

  1. 但对于 int,这可能产生二义性
    1. 原因是两处std::is_integral_v<T>来自不同的源代码位置,约束规范化后可能被视为不同的原子约束

多个互不相关的Concept可能二义

  1. 如果某个类型同时拥有 data()serialize()
    1. 两个候选都满足,而且约束之间没有包含关系,所以调用二义

  1. Concepts 不会自行判断
    1. 直接发送应该比序列化发送优先

网络代码中的重载设计

名字查找也会影响候选集合

  1. 经典泛型代码

  1. 这样候选集合可能同时包含:
    1. std::swap
    2. 通过 ADL 找到的、定义在 T 所属命名空间里的专用 swap

= delete 不会移除候选

  1. 编译器会选择精确匹配的

  1. 然后报错,因为它已被删除
    1. 它不会退而选择 convert(long)
技术 是否参与重载解析
SFINAE 失败 不参与
Concepts 约束不满足 不参与
= delete 参与,选中后报错
私有函数 参与,选中后做访问检查
函数体编译失败 已经选中,实例化时报错

函数返回类型通常不参与重载选择

  1. 不能仅通过返回类型重载

  1. 调用位置也不会帮助选择
    1. 编译器不会因为左边是 double 就选择返回 double 的版本

  1. 模板中通常通过显式模板参数表达目标类型

其他

std::declval

  1. 可以理解成
    1. 在不真正创建对象的情况下,假想自己拥有一个指定类型的表达式,用于编译期类型推导和合法性检查
  2. 为什么需要 std::declval

  1. 需要考虑引用折叠
写法 表达式类型
std::declval<T>() T&&
std::declval<T&>() T&
std::declval<const T&>() const T&
std::declval<T&&>() T&&
  1. 只能用于“不求值语境”
    1. std::declval 只能出现在不会真正执行表达式的语境中,最常见的是 decltype

阅读模板的翻译方法

  1. 只有 condition == true 时,这个候选才存在

  1. 检测 expression 对当前类型是否合法

  1. 这个函数的公开静态接口要求 T 满足 SomeConcept

  1. 然后再检查:
    1. 条件发生在声明中还是函数体中?
    2. 失败后是否还有其他重载?
    3. 多个约束重载中,哪个更加受限?
    4. 查的只是表达式合法性,还是也约束了返回类型?
    5. 万能引用参与时,约束检查的是 TT&&,还是 remove_cvref_t<T>

声明:本文为原创文章,版权归所有,欢迎分享本文,转载请保留出处!

bingliaolong
Bingliaolong 关注:0    粉丝:0
Everything will be better.

发表评论

表情 格式 链接 私密 签到
扫一扫二维码分享