C++模板进阶指南:SFINAE

空明流转(https://zhuanlan.zhihu.com/p/21314708)

SFINAE可以说是C++模板进阶的门槛之一,如果选择一个论题来测试对C++模板机制的熟悉程度,那么在我这里,首选就应当是SFINAE机制。

我们不用纠结这个词的发音,它来自于 Substitution failure is not an error 的首字母缩写。这一句之乎者也般难懂的话,由之乎者 —— 啊,不,Substitution,Failure和Error三个词构成。

我们从最简单的词“Error”开始理解。Error就是一般意义上的编译错误。一旦出现编译错误,大家都知道,编译器就会中止编译,并且停止接下来的代码生成和链接等后续活动。

其次,我们再说“Failure”。很多时候光看字面意思,很多人会把 Failure 和 Error 等同起来。但是实际上Failure很多场合下只是一个中性词。比如我们看下面这个虚构的例子就知道这两者的区别了。

假设我们有一个语法分析器,其中某一个规则需要匹配一个token,它可以是标识符,字面量或者是字符串,那么我们会有下面的代码:

switch(token)
{
case IDENTIFIER:
// do something
break;
case LITERAL_NUMBER:
// do something
break;
case LITERAL_STRING:
// do something
break;
default:
throw WrongToken(token);
}

假如我们当前的token是LITERAL_STRING的时候,那么第一步,它在匹配IDENTIFIER时,我们可以认为它failure了。

但是如果这个Token既不是标识符,也不是数字字面量,也不是字符串字面量的时候,并且,我们的语法规则认为这一条件是无论如何都不可接受的,这时我们就认为它是一个error。

比如大家所熟知的函数重载,也是如此。比如说下面这个例子:

struct A {};
struct B: public A {};
struct C {}; void foo(A const&) {}
void foo(B const&) {} void callFoo() {
foo( A() );
foo( B() );
foo( C() );
}

那么 foo( A() ) 虽然匹配 foo(B const&) 会失败,但是它起码能匹配 foo(A const&),所以它是正确的; foo( B() ) 能同时匹配两个函数原型,但是B&要更好一些,因此它选择了B。而foo( C() ); 因为两个函数都匹配失败(Failure)了,所以它找不到相应的原型,这时才会爆出一个编译器错误(Error)。

所以到这里我们就明白了,在很多情况下,Failure is not an error,因为编译器在遇到Failure的时候,往往还需要尝试其他的可能性。

好,现在我们把最后一个词,Substitution,加入到我们的字典中。现在这句话的意思就是说,我们要把 Failure is not an error 的概念,推广到Substitution阶段。

所谓substitution,就是将函数模板中的形参,替换成实参的过程。C++标准中对这一概念的解释比较拗口,它分别指出了以下几点:

什么时候函数模板会发生实参 替代(Substitute) 形参的行为

什么样的行为被称作 Substitution

什么样的行为不可以被称作 Substitution Failure —— 他们叫SFINAE error。

我们在此不再详述,有兴趣的同学可以参照 SFINAE - cppreference.com ,这是标准的一个精炼版本。如果只总结最常见的情况,那就是假设我们有这么个函数签名:

template <
typename T0,
// 一大坨其他模板参数
typename U = /* 和前面T有关的一大坨 */
>
RType /* 和模板参数有关的一大坨 */
functionName (
PType0 /* PType0 是和模板参数有关的一大坨 */,
PType1 /* PType1 是和模板参数有关的一大坨 */,
// ... 其他参数
)
{
// 实现,和模板参数有关的一大坨
}

那么,所有函数签名上的“和模板参数有关的一大坨”,基本都是Substitution时要处理的东西(当然也有一些例外)。一个更具体的例子来解释上面的“一大坨”:

template <
typename T,
typenname U = typename vector<T>::iterator //
>
typename vector<T>::value_type //
foo(
T*, //
T&, //
typename T::internal_type, //
typename add_reference<T>::type, //
int // 这里都不需要 substitution
)
{
// 整个实现部分,都没有 substitution。这个很关键。
}

嗯,粗糙的介绍完SFINAE之后,我们先来看一个最常见的例子看看它是什么个行为:

struct X {
typedef int type;
}; struct Y {
typedef int type2;
}; template <typename T> void foo(typename T::type); // Foo0
template <typename T> void foo(typename T::type2); // Foo1
template <typename T> void foo(T); // Foo2 void callFoo() {
foo<X>(); // Foo0: Succeed, Foo1: Failed, Foo2: Failed
foo<Y>(); // Foo0: Failed, Foo1: Succeed, Foo2: Failed
foo<int>(); // Foo0: Failed, Foo1: Failed, Foo2: Succeed
}

在这个例子中,当我们指定 foo<Y> 的时候,substitution就开始工作了,而且会同时工作在三个不同的foo签名上。如果我们仅仅因为Y没有type,就在匹配Foo0时宣布出错,那显然是武断的,因为我们起码能保证,也希望将这个函数匹配到Foo1上。

实际上,std/boost库中的enable_if也是借用了这个原理。

我们来看enable_if的一个应用:假设我们有两个不同类型的counter,一种counter是普通的整数类型,另外一种counter是一个复杂对象,它有一个成员叫做increase。现在,我们想把这两种类型的counter封装一个统一的调用:inc_counter。那么,我们直觉会简单粗暴的写出下面的代码:

struct ICounter {
virtual void increase() = ;
virtual ~ICounter() {}
}; struct Counter: public ICounter {
void increase() override {
// Implements
}
}; template <typename T>
void inc_counter(T& counterObj) {
counterObj.increase();
} template <typename T>
void inc_counter(T& intTypeCounter){
++intTypeCounter;
} void doSomething() {
Counter cntObj;
uint32_t cntUI32; // blah blah blah
inc_counter(cntObj);
inc_counter(cntUI32);
}

我们非常希望它可以如我们所愿的work —— 因为其实我们是知道对于任何一个调用,两个inc_counter只有一个是正常工作的。“有且唯一”,我们理应当期望编译器能够挑出那个唯一来。

可惜编译器做不到这一点。首先,它就告诉我们,这两个签名其实是一模一样的,我们遇到了redefinition。

template <typename T> void inc_counter(T& counterObj);
template <typename T> void inc_counter(T& intTypeCounter);

所以我们要借助于enable_if这个T对于不同的实例做个限定:

template <typename T> void inc_counter(
T& counterObj,
typename std::enable_if<
is_base_of<T, ICounter>::value
>::type* = nullptr ); template <typename T> void inc_counter(
T& counterInt,
typename std::enable_if<
std::is_integral<T>::value
>::type* = nullptr );

关于这个 enable_if 是怎么工作的,语法为什么这么丑,我来解释一下:

首先,substitution只有在推断函数类型的时候,才会起作用。推断函数类型需要参数的类型,所以,typename std::enable_if<std::is_integral<T>::value>::type 这么一长串代码,就是为了让enable_if参与到函数类型中;

其次,is_integral<T>::value返回一个布尔类型的编译期常数,告诉我们它是或者不是一个integral,enable_if<C>的作用就是,如果这个C值为True,那么type就会被推断成一个void或者是别的什么类型,让整个函数匹配后的类型变成 void inc_counter<int>(int & counterInt, void* dummy = nullptr); 如果这个值为False,那么enable_if<false>这个特化形式中,压根就没有这个::type,于是substitution就失败了 —— 所以这个函数原型根本就不会被产生出来。

所以我们能保证,无论对于int还是counter类型的实例,我们都只有一个函数原型是通过了substitution —— 这样就保证了它的“有且唯一”,编译器也不会因为你某个替换失败而无视成功的那个实例。

这个例子说到了这里,熟悉C++的你,一定会站出来说我们只要把第一个签名改成如下的形式:

void inc_counter(ICounter& counterObj);

就能完美解决这个问题了,根本不需要这么复杂的编译器机制。

嗯,你说的没错,在这里这个特性一点都没用。

这也提醒我们,当你觉得需要写enable_if的时候,首先要考虑到以下可能性:

重载(对模板函数)

偏特化(对模板类而言)

虚函数

但是问题到了这里并没有结束。因为,increase毕竟是个虚函数。假如counter需要调用的地方实在是太多了,这个时候我们会非常期望 increase 不再是个虚函数以提高性能。此时我们会调整继承层级:

struct ICounter {};
struct Counter: public ICounter {
void increase() {
// impl
}
};

那么原有的void inc_counter(ICounter& counterObj) 就无法再执行下去了。这个时候你可能会考虑一些变通的办法:

template <typename T>
void inc_counter(ICounter& c) {}; template <typename T>
void inc_counter(T& c) { ++c; }; void doSomething() {
Counter cntObj;
uint32_t cntUI32; // blah blah blah
inc_counter(cntObj); //
inc_counter(static_cast<ICounter&>(cntObj)); //
inc_counter(cntUI32); //
}

对于1,因为cntObj到ICounter是需要类型转换的,所以比 void inc_counter(T&) [T = Counter]要更差一些。然后它会直接实例化后者,结果实现变成了++cntObj,BOOM!

那么我们做2试试看?嗯,工作的很好。但是等等,我们的初衷是什么来着?不就是让inc_counter对不同的计数器类型透明吗?这不是又一夜回到解放前了?

所以这个时候,就能看到 enable_if 是如何通过 SFINAE 发挥威力的了。

那么为什么我们还要ICounter作为基类呢? 这是个好问题。在本例中,我们用它来区分一个counter是不是继承自ICounter。最终目的,是希望知道counter有没有increase这个函数。

所以ICounter只是相当于一个标签。而于情于理这个标签都是个累赘。但是在C++11之前,我们并没有办法去写类似于:

template <typename T> void foo(T& c, decltype(c.increase())* = nullptr);

这样的函数签名,因为假如T是int,那么 c.increase() 这个函数调用并不属于Type Failure,它是一个Expression Failure,会导致编译器出错。所以我们才退而求其次,用一个类似于标签的形式来提供足够的信息。

到了C++11,它正式提供了 Expression SFINAE,这时我们就能抛开ICounter这个无用的Tag,直接写出我们要写的东西:

struct Counter {
void increase() {
// Implements
}
}; template <typename T>
void inc_counter(T& intTypeCounter, std::decay_t<decltype(++intTypeCounter)>* = nullptr) {
++intTypeCounter;
} template <typename T>
void inc_counter(T& counterObj, std::decay_t<decltype(counterObj.increase())>* = nullptr) {
counterObj.increase();
} void doSomething() {
Counter cntObj;
uint32_t cntUI32; // blah blah blah
inc_counter(cntObj);
inc_counter(cntUI32);
}

此外,还有一种情况只能使用 SFINAE,而无法使用包括继承、重载在内的任何方法,这就是Universal Reference。比如,

// 这里的a是个通用引用,可以准确的处理左右值引用的问题。
template <typename ArgT> void foo(ArgT&& a);

假如我们要限定ArgT只能是 float 的衍生类型,那么写成下面这个样子是不对的,它实际上只能接受 float 的右值引用。

void foo(float&& a);

此时的唯一选择,就是使用通用引用,并增加enable_if限定类型,如下面这样:

template <typename ArgT>
void foo(
ArgT&& a,
typename std::enabled_if<
is_same< std::decay_t<ArgT>, float>::value
>::type* = nullptr
);

从上面这些例子可以看到,SFINAE最主要的作用,是保证编译器在泛型函数、偏特化、及一般重载函数中遴选函数原型的候选列表时不被打断。除此之外,它还有一个很重要的元编程作用就是实现部分的编译期自省和反射。

虽然它写起来并不直观,但是对于既没有编译器自省、也没有Concept的C++1y来说,已经是最好的选择了。

稍后会更新到Github中

https://zhuanlan.zhihu.com/p/21314708

https://en.cppreference.com/w/cpp/language/sfinae

https://en.cppreference.com/w/cpp/types/enable_if

C++模板进阶指南:SFINAE的更多相关文章

  1. Weex入门与进阶指南

    Weex入门与进阶指南 标签: WeexiOSNative 2016-07-08 18:22 59586人阅读 评论(8) 收藏 举报 本文章已收录于:  iOS知识库  分类: iOS(87)  职 ...

  2. freeMarker(五)——模板开发指南补充知识

    学习笔记,选自freeMarker中文文档,译自 Email: ddekany at users.sourceforge.net 模板开发指南补充知识 1. 自定义指令 自定义指令可以使用 macro ...

  3. FreeMarker模板开发指南知识点梳理

    freemarker是什么? 有什么用? 怎么用? (问得好,这些都是我想知道的问题) freemarker是什么? FreeMarker 是一款 模板引擎: 即一种基于模板和要改变的数据, 并用来生 ...

  4. HTML5游戏开发进阶指南(亚马逊5星畅销书,教你用HTML5和JavaScript构建游戏!)

    HTML5游戏开发进阶指南(亚马逊星畅销书,教你用HTML5和JavaScript构建游戏!) [印]香卡(Shankar,A.R.)著 谢光磊译 ISBN 978-7-121-21226-0 201 ...

  5. 【读书笔记】读《高性能网站建设指南》及《高性能网站建设进阶指南:Web开发者性能优化最佳实践》

    这两本书就一块儿搞了,大多数已经理解,简单做个标记.主要对自己不太了解的地方,做一些记录.   一.读<高性能网站建设指南> 0> 黄金性能法则:只有10%~20%的最终用户响应时间 ...

  6. 《Velocity 模板使用指南》中文版[转]

    转自:http://blog.csdn.net/javafound/archive/2007/05/14/1607931.aspx <Velocity 模板使用指南>中文版 源文见 htt ...

  7. HTML5游戏开发进阶指南

    <HTML5游戏开发进阶指南> 基本信息 作者: (印)香卡(Shankar,A.R.)    译者: 谢光磊 出版社:电子工业出版社 ISBN:9787121212260 上架时间:20 ...

  8. iOS进阶指南试读之UI篇

    iOS进阶指南试读之UI篇 UI篇 UI是一个iOS开发工程师的基本功.怎么说?UI本质上就是你调用苹果提供给你的API来完成设计师的设计.所以,想提升UI的功力也很简单,没事就看看UIKit里的各个 ...

  9. SQL 横转竖 、竖专横 (转载) 使用Dapper.Contrib 开发.net core程序,兼容多种数据库 C# 读取PDF多级书签 Json.net日期格式化设置 ASPNET 下载共享文件 ASPNET 文件批量下载 递归,循环,尾递归 利用IDisposable接口构建包含非托管资源对象 《.NET 进阶指南》读书笔记2------定义不可改变类型

    SQL 横转竖 .竖专横 (转载)   普通行列转换 问题:假设有张学生成绩表(tb)如下: 姓名 课程 分数 张三 语文 74 张三 数学 83 张三 物理 93 李四 语文 74 李四 数学 84 ...

随机推荐

  1. 原生js 判断变量是一个数组

    const arr = [] // 1. 最简单 ES5+ Array.isArray(arr) // 2. 兼容性好的方法,也很准确 Object.prototype.toString.call(a ...

  2. Java程序员面试题收集(6)

    <!————————————————————————————基础题122道,代码题19道————————————————————————————> JAVA相关基础知识1.面向对象的特征有 ...

  3. 用JetBrains PyCharm 2017.2创建运行Django程序

    在JetBrains PyCharm 2017.2里选择 文件(F) 新项目 点击  三角形  运行 修改Urls.py """S14Djngo URL Configur ...

  4. vue-cli+webpack搭建简单的vue项目框架

    0.先去官网下载安装nodeJS 1.在cmd中输入命令 node -version    若出现node版本号 则安装成功 2.通过命令:cd 文件夹名     进入某具体文件夹后进行如下命令操作: ...

  5. java知识点---文件分隔符

    本篇讲述java编程中,怎样解决跨平台时,因不同系统中分隔符不同导致的文件或路径找不到的问题 首先来看两个例子: 一.linux系统和windows系统中的文件路径: Linux系统: Windows ...

  6. 沙雕去死吧! 劳资终于弄好了toimcat9 apt和ssl配置

    搜一下吧,要不就是不用apr模式,也不用nio模式,直接用bio模式跑的,麻烦问一下,您们都是傻屌吗? APR模式必须要用!!!!! 然后全他妈的用jks证书,然后再提取公钥*.cer,然后再转换为* ...

  7. 使用session实现一次性验证码

    在登录页面和各种页面,会看到有验证码输入,这样做的目的是为了防止密码猜测工具破解密码,保护了用户密码安全,验证码只能使用一次,这样就给密码猜测工具带来了很大的困难,基本上阻断了密码猜测工具的使用. 可 ...

  8. 2018-8-10-如何使用-C#-爬虫获得专栏博客更新排行

    title author date CreateTime categories 如何使用 C# 爬虫获得专栏博客更新排行 lindexi 2018-08-10 19:16:51 +0800 2018- ...

  9. Redhat/Fedora 或类似系统, 配置网络的工具介绍

    在Redhat早期的版本中, 有linuxconf .redhat-config-network .netconfig 等工具: 在Redhat/Fedora 最新的版本有 system-config ...

  10. Leetcode91.Decode Ways解码方法

    一条包含字母 A-Z 的消息通过以下方式进行了编码: 'A' -> 1 'B' -> 2 ... 'Z' -> 26 给定一个只包含数字的非空字符串,请计算解码方法的总数. 示例 1 ...