C++ 模板简介(一)—— SFINAE

2023-11-19

SFINAE, 类型检查, Concepts

​ SFINAE 机制是组成 C++ 模板机制及类型安全的相当重要的基础。全称是 Substitution failure is not an error。大概的意思就是只要找到了可用的原型(比如函数模板、类模板等)就不会编译错误。SFINAE 可以被用来进行模式匹配。在尝试本篇代码时请打开 C++17。

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

导入

​ 为什么我们需要类型安全?除了能够保证用户调用我们编写的函数时传错参数之外,我们还可以避免这个情况:

struct A {};
vector<A> v;
sort(v.begin(), v.end());

你可以看到一大坨一大坨的信息(真的很多,你试着编译一下就知道有多少(我可以告诉你就因为没有为 A 添加小于号运算符,产生了 200 行的编译错误信息)。

如果我们采用了这篇文章中的机制,我们可以将编译错误信息限制到 30 行以内(友好多了)。

不过如果你打开了 C++20,那么编译错误信息就会相当好看,然后本篇博客就被废掉了

SFINAE

纯模板参数

我们看下面的一个例子:

template <typename T>
struct A;

template <>
struct A<int>
{
    typedef int value_type;
};

template <class T, class U = typename A<T>::value_type>
void func(T);

如果我们调用 func(int),那么上面的代码就可以编译,但是调用func(double)时,就会报错:

test.cpp: In function ‘int main()’:
test.cpp:18:13: error: no matching function for call to ‘func(double)’
     func(0.0);
             ^
test.cpp:11:6: note: candidate: template<class T, class U> void func(T)
 void func(T t)
      ^
test.cpp:11:6: note:   template argument deduction/substitution failed:
test.cpp:10:20: error: invalid use of incomplete type ‘struct A<double>’
 template <class T, class U = typename A<T>::value_type>
                    ^
test.cpp:2:8: note: declaration of ‘struct A<double>’
 struct A;
        ^

意思是我们在调用了 func(double) 时,func的完整的类型其实是func<double, typename A<double>::value_type>,也就是说 func 的类型依赖于 A<double>::value_type,但是我们知道我们只定义了 A<int>::value_type,而并未对其他的模板参数特化,也就是说 A<double> 其实是一个不完整的类型,显然我们不可以调用不完整的类型,因此编译失败。

函数参数(模板相关)

我们再来看另一种例子:

struct A { typedef int typeA; };
struct B { typedef int typeB; };
struct C { typedef int typeC; };
template <typename T> void func(typename T::typeA) { cout << 1; }
template <typename T> void func(typename T::typeB) { cout << 2; }
template <typename T> void func(T) { cout << 3; }

int main()
{
    func<A>(1); // 输出 1,匹配到了第一个 func(只要找到一个匹配的即可)
    func<B>(2); // 输出 2,由于第一个 func 不能匹配,看第二个 func,匹配到了
    func<C>(3); // 编译失败,因为 C 既没有 typeA,也没有 typeB,两个 func 都不能匹配,编译失败
    func<int>(4); // 输出 3
}

看到上面的例子中 func 能匹配到相应的函数,这是因为匹配条件是唯一不冲突的(因此定义的顺序是没有关系的,因为不会产生歧义),我们再来看:

template <typename T> void func(typename T::typeA) { cout << 1; }
template <typename T> void func(typename T::typeB) { cout << 2; }
template <typename T> void func(int) { cout << 3; }

如果我们的func函数是这么定义的,那么可以让func<C>(3)编译通过。但是func<A>(1)func<B>(2)都将会编译失败,因为这两个函数调用既可以匹配前两条,又可以匹配第三条。所以会产生歧义从而编译失败。

其他

下面是 C++ Reference 上提到的一个例子:

template <int I> void div(char(*)[I % 2 == 0 ? 1 : -1] = 0) {
    // this overload is selected when I is even
}

template <int I> void div(char(*)[I % 2 == 1 ? 1 : -1] = 0) {
    // this overload is selected when I is odd
}

这个例子很有趣,div 函数分成了两份,一份只在 I为偶数的情况下调用,一份只在I为奇数的情况下调用。首先这两个函数利用了函数参数类型的不一致从而避免了调用的歧义,其次再利用两个钟必有一个参数需要维度为负数的数组会编译失败的性质,根据 SFINAE 原则,选取那个不会编译失败的函数进行调用。从而区分开来了两个函数。多说一下:

​ 所以你可能会想我们为什么要费这么大劲这么写两个 div 来区分 I 的奇偶性?而不是用 if 判断?这就涉及零开销的问题了,因为 I 的奇偶性我们在编译期就可以知道,那么判断 I 的时间如果能在编译时完成,如果再到运行时每次判断一下,就会造成运行时的额外开销。(很多 C++ 程序编译的时候都有跑编译的服务器集群跑的)

​ 我们可以抽取一下使得这段代码更加易于阅读(需要启用 C++11):

template <int I> void div(typename std::enable_if<(I % 2 == 0)>::type * = 0) {
    
}
template <int I> void div(typename std::enable_if<(I % 2 == 1)>::type * = 0) {
    
}

​ 我们在参数中使用了 enable_if 这个结构体代替了声明一个数组。enable_if 的模板参数为真时 type 才存在,否则不存在(就像之前的 A::value_type 是否存在一样)。然后参数中我们定义了 type 的指针,省略了这个参数的参数名,并且为其添加了默认值 0 使得我们不需要为其传值。至此你应该也能理解之前我们声明 char 数组时后面的 =0 是什么意思。我们之后详细介绍 enable_if 的内容。

​ 我们可以使用模板的偏特化来模仿上面的例子(函数不支持模板的偏特化,所以只能用结构体内的静态函数代替)

template <int I, bool = I % 2>
struct div;

template <int I, true>
struct div
{
    static void work() {
        
    }
};

template <int I, false>
struct div
{
    static void work() {
        
    }
};

应用

限定参数是特定的类型

我们花了很多篇幅介绍了 SFINAE 是什么,那么它能做什么?我们了解一下 <type_traits> 模板库中的函数。

假如我们现在有如下需求:

template <typename T> T div(T t) { return t / 2; }

我们希望这个函数的参数是整型(bool, int, long, unsigned 等),而不希望是浮点型或者其他类型的变量传入(否则就不是向下取整的除以 2)。要怎么做呢?一种简单的想法是利用函数重载:

int div(int i) { return i / 2; }
long div(long i) { return i / 2; }
float div(float i) { return floor(i / 2); }
double div(double i) { return floor(i / 2); }

可是,如果要覆盖所有的基本类型,无疑要为每一个基本类型都写一遍重载才能实现完整的类型覆盖,有没有更简单的方法呢?

template <typename T>
typename std::enable_if<std::is_integral<T>::value, T>::type div(T t) {
    return t / 2;
}

template <typename T>
typename std::enable_if<std::is_floating_point<T>::value, T>::type div(T t) {
    return std::floor(t / 2);
}

// 对于既不是整型,又不是浮点数的类型,就会因为匹配不到两个函数从而编译失败

首先我们先介绍一个帮助模板 is_integral<T>,其模板参数为整型时,is_integral<T>::value 为真,否则为假。由于我们要利用 SFINAE 来实现类型检查,所以我们要在函数的某个地方插入一些代码使得模板参数 T 不为整型时这个函数将会编译失败从而让编译器不选择这个函数。纵观函数,我们发现返回值相当适合用来判断,我们之前介绍了 enable_if 的用法,其原理就是当模板参数中的布尔值为假时其 type 不存在从而导致编译失败进而阻止编译器采用该函数。那如果布尔值为真呢?type 就是 T。也就是说我们绕了一圈,最后回到了 T。最后我们只要将其模板参数中的布尔值令为 is_integral_v<T> ,就可以在 T 为整型的情况下 type 存在且为 T 从而不改变该函数的真实返回类型。

enable_if

我们之前不断地提到了 enable_if 这个模板,怎么实现的呢?相信你通过之前的描述能够自己想出来怎么实现的,这里给出一种普通的实现方式:

template <bool Cond, class T = void> struct enable_if {};
template <class T> struct enable_if<true, T> { typedef T type; };

那么如何利用这个 enable_if 就要发挥你的想象啦

判断是否存在某个函数

你可能想在 C++ 中使用类似接口的东西,比如这样:

struct counter_base { virtual void count() = 0; };
struct counter : public counter_base {
    virtual void count() override {
        // do something
    }
};

然后你就可以这么干:

void count(counter_base &i) { i.count(); }

这样如果我们调用了 foo(counter()),那么 i.count() 将会调用 counter::count。同时我们可以确保传进来的变量 i 确实有 count() 这个函数。

但是!如果 foo 这个函数调用的地方实在是太多了,多到居然虚函数居然会影响程序性能,以至于你被迫不这么干的时候,你要怎么做呢?大概就是:

template <typename T> void count(T &i) { i.count(); }

这样完全可以,我们确保了 T 确实有 count,否则会编译失败。

但是如果我们哪天添加了一个需求:允许 count<int>(var),然后使 var++来表示一次计数,你现在的程序就失效了。那么我们要怎么办呢?解决提出问题的人

那么我们就需要使用 SFINAE 了,考虑如何判断一个函数是否存在。我们只能通过调用对象实例的 count 函数才能知道是否存在,那么这个并不能使用类型的 SFINAE 检查,因为我们目前还没有一个工具可以以布尔值的形式得到一个函数是否存在,也就是说 enable_if 还无法使用。考虑 decltype 关键字,我们知道 decltype 关键字能得到一个表达式的类型,同时在表达式不合法时编译失败。那么我们可以考虑通过表达式检查模板类型 T 是否有 count 函数。比如一个函数的参数类型或返回类型中使用 decltype(i.count()),就可以出现这个函数编译失败的情况。那么接下来我们如何判断函数是否编译失败?答案就是 SFINAE。参考之前 div 参数的写法,我们就可以得到如下的程序:

#include <bits/stdc++.h>
using namespace std;

struct counter {
    void count() {
        std::cout << "count";
    }
};

template <typename T>
struct has_count {
    
    template <typename K>
    static std::true_type test(decltype(std::declval<K>().count()) *);

    template <typename K>
    static std::false_type test(...); // 使用 ... 就可以区分开两个函数而不会产生歧义

    using type = decltype(test<T>(nullptr)); // 通过获得函数的返回类型来判断使用了哪个函数
};

template <typename T>
enable_if_t<has_count<T>::type::value> foo(T &t) {
    t.count();
}

template <typename T>
enable_if_t<is_integral_v<T>> foo(T &i) {
    std::cout << "int";
}

int main() {
    counter c; foo(c);
    int i; foo(i);
}

test 利用了 SFINAE,declval<K>() 表示拿到一个编译期的 K 的实例,这样我们就可以调用 count 函数,由于我们调用 count 函数是在编译期(decltype 的计算是在编译期,所以括号内的值是编译期计算的)调用的,所以可能产生一个 SFINAE 的编译错误,如果 K 没有 count 函数,那么编译期就会选中第二个 test 函数。那么我们怎么知道编译器选择了哪一个函数呢?我们可以通过函数返回值得到。首先第二个 test 函数的返回类型就是 false_type,而第一个 test 函数的返回类型就是 true_type。这样我们通过 decltype(test<T>(blablabla)) 就可以得到 true_type 或者 false_type 从而区分开两个函数。

注意 test 函数必须要有模板 K 才能启用 SFINAE,如果 declval<K> 写成 declval<T> 是不行的,因为依赖了 test 本身以外的模板参数。

判断是否存在运算符

我们最开始提到了 sort 函数默认情况下将调用 less 比较器进而调用比较对象的小于运算符,如果小于运算符不存在将会造成大量的编译错误信息。那么我们如何实现判断运算符是否存在?或者判断比较器是否可用?和函数判断一样的:

template <typename A, typename B, typename OperT>
struct has_operator {

    template <typename X, typename Y, typename Oper>
    static std::true_type test(decltype(std::declval<Oper>()(std::declval<X>(), std::declval<Y>())) *);

    template <typename X, typename Y, typename Oper>
    static std::false_type test(...);

    using type = decltype(test<A, B, OperT>(nullptr));
    static constexpr bool value = type::value;
};

static_assert(has_operator<int, int, std::less<>>::value, "failed");

我们知道了如何判断是否存在某种运算符,那么就可以做很多事情了:判断一个模板参数类型是不是 callable 的,或者判断 T 是不是迭代器(支持 ++ 等)。

上面的例子还能被修改成检查运算符范围类型的。你可以想想怎么做。

判断是否是基类

std::is_base_of<Base, Derived> 可以判断 Derived 是不是 Base 的子类。如何实现呢?和判断是否有函数、运算符一样,我们使用两个函数来表示。我们可以利用的性质是:DerivedBase 的子类,所以 Derived* 可以传进 Base* 参数的函数中,那么事情就变得简单了:

template <typename Base, typename Derived>
struct is_base_of {

    template <typename X>
    static std::true_type test(Base *);

    template <typename X>
    static std::false_type test(...);

    using type = decltype(test<Base>(std::declval<Derived*>()));
    static constexpr bool value = type::value;
};

Concepts (C++20)

Concepts 真正地将我们从以上晦涩难懂拐弯抹角的代码(而且编译器也很累啊)中解救出来,我们看一些例子:

template <typename T>
concept bool EqualityComparable = requires(T a, T b) {
    { a == b } -> bool
};

void f(EqualityComparable);

template <typename T>
void f(T) requires EqualityComparable<T>;

上面几行代码就表示 f 需要一个具有相等运算符,而且运算符返回类型为 boolHasCount 就可以检查 T 是否有 count 函数。

下面是一些其他的例子:

template <int T> concept Even = T % 2 == 0;
template <int T> concept Odd = T % 2 == 1;
template <Even I> void div();
template <Odd I> void div();


template <typename T>
concept bool HasCount = requires(T a) {
    { a.count() } -> void
};

if constexpr (C++17)

我们之前介绍了 div 函数:

template <typename T>
typename std::enable_if<std::is_integral<T>::value, T>::type div(T t) {
    return t / 2;
}

template <typename T>
typename std::enable_if<std::is_floating_point<T>::value, T>::type div(T t) {
    return std::floor(t / 2);
}

这样可以实现区分整数除 2 和浮点数除 2。可是 enable_if_t 不管怎么看都不直观,所以 C++17 为我们带来了 if constexpr

template <typename T>
T div(T t) {
    if constexpr (std::is_integral_v<T>)
        return t / 2;
    else if constexpr (std::is_floating_point_v<T>)
        return std::floor(t / 2);
}

如果前两个 if 都没有匹配会直接编译失败。所以是不是代码变得简单了很多呢?

为什么我们需要 if constexpr?因为这种情况下由编译器直接计算条件表达式的值从而 if 语句将被直接替换成条件满足的语句块从而减小运行开销,同时也解决了一些编译的问题:

template <int N, int... Ns>
int sum()
{
    if (sizeof...(Ns) == 0)
        return N;
    else
        return N + sum<Ns...>();
}

这是一个计算模板参数中的数字的和的函数,比如 sum<1, 2, 3>() == 6,但是你会发现上面的函数编译失败了,原因是如果 Ns 参数包为空时,sum<Ns...> 就相当于 sum<>,而我们并没有 sum<> 这个函数,从而因为找不到函数而编译失败。实际上因为我们并不能定义 sum<> 这种模板参数为空的函数,所以并不能通过函数重载的方式实现 sum 函数,所以我们要绕一圈:

template <int... Ns>
int sum()
{
    return [](const std::array<int, sizeof...(Ns)>& a)
    {
        return std::accumulate(a.begin(), a.end(), 0);
    }({Ns...});
}

通过将 Ns 扩展成一个数组从而计算这个数组的和。

但是如果使用 if constexpr 就不一样了:

template <int N, int... Ns>
int sum()
{
    if constexpr (sizeof...(Ns) == 0)
        return N;
    else
        return N + sum<Ns...>();
}

由于编译器能在编译器计算布尔表达式的值,因此当我们调用 sum<N> 时就并不会继续调用 sum<>,因为 sizeof...(Ns) == 0,所以编译器不会尝试调用 sum<>() 从而避免了上述的问题。

事实上 C++17 还有更简单的实现方法:

template <typename... Ns>
auto sum(Ns... ns) {
    return (ns + ...);
}
本文内容由网友自发贡献,版权归原作者所有,本站不承担相应法律责任。如您发现有涉嫌抄袭侵权的内容,请联系:hwhale#tublm.com(使用前将#替换为@)

C++ 模板简介(一)—— SFINAE 的相关文章

随机推荐

  • iOS实训笔记—调用系统相机与网络请求

    iOS开发实训第三周周报 总结 本周开始进行项目的开发 目前小组计划共同完成前端开发 我负责的部分为个人页面 其中涉及到加载个人信息时 需要从相册或相机获取图片 作为头像上传 并进行网络请求 获取资源 因此本周周报总结这部分的内容 学习知识
  • NeRF学习笔记(含公式、图解和过程)

    NeRF学习笔记 关注公众号 不定期分享NeRF相关文献 引言 NeRF Representing Scenes as Neural Radiance Fields for View Synthesis作为2020年ECCV的一篇论文 在用
  • 51单片机的智能饮水机控制系统【proteus仿真+程序+原理图】

    1 主要功能 该系统由AT89C51单片机 LCD1602模块 DS18B20温度传感器模块 DS1302时间模块 继电器驱动模块 电位器模块构成 适用于智能饮水机 智能水杯等相似项目 可实现功能 版本一 1 LCD1602实时显示时间 水
  • 在CentOS上安装桌面环境(例如GNOME)

    可以按照以下步骤在 CentOS 上安装桌面环境 例如 GNOME 确保您的 CentOS 系统已连接到互联网 并拥有 root 或具有 sudo 权限的用户 打开终端 并使用 yum 包管理器更新系统 sudo yum update 安装
  • MSP430嵌入式接口编程(惯性测量单元温湿度双音多频磁力计LCD显示等)

    Energia IDE编程MSP430 GPIO 串口通讯 定时中断 添加库 嵌入式器件接口编程 加速度计 include
  • 全 民 进 入 互 联 网

    2015年 3C行业的变化有目共睹 互联网 的概念全面深入人心 贯穿于企业经营和百姓的日常生活中 通讯行业提速降费 诸多国产精品手机现身 电商行业更加规范 移动端超越PC端成为主流渠道 家电行业诞生多个新技术 智能家电格局正在改写 让我们一
  • C++实现FFT频谱分析

    Update 9 10 2022 鸽了太久 增补了一些新的表述和简单推导 以及FFT在算法竞赛中的应用部分 帖子里的代码已经分别在2021全国大学生电子设计竞赛 洛谷OJ和课程设计中实战过 可靠性有保障 Origin 10 23 2021
  • web前端技术笔记(九)JavaScript介绍、变量、操作元素属性

    JavaScript JavaScript介绍 变量 变量类型 变量 函数 属性 函数参数命名规范 获取元素方法一 操作元素属性 通过 操作属性 通过 操作属性 innerHTML JavaScript介绍 JavaScript是运行在浏览
  • Ant-Maven-Gradle

    make Makefile学习 peterYong 博客园 ant ant 工具 milkty 博客园 maven 学习Maven这一篇就够了 轻松的小希的博客 CSDN博客 学Maven 这篇万余字的教程 真的够用了 江南一点雨 博客园
  • CSS 样式书写规范,css样式书写规范

    在工作当中css样式是非常重要的 但是咋样书写css样式更重要 一 css书写规范 1 定位属性 position display float left top right bottom overflow clear z index 2 自
  • 千与千寻 中日歌词与罗马音译(最准确啦)

    千与千寻 国语和日语版 Cover 木村 弓 作曲 木村 弓 作词 觉 和歌子 张 就此告别吧水上的列车就快到站 粥 呼 胸 奥 yo n de i ru mu ne no do ko ka o ku de 张 开往未来的路上没有人会再回返
  • MySQL 触发器入门 (转载)

    博客迁移 时空蚂蚁http cui zhbor com MySQL 5 1包含对触发器的支持 触发器是一种与表操作有关的数据库对象 当触发器所在表上出现指定事件时 将调用该对象 即表的操作事件触发表上的触发器的执行 创建触发器 在MySQL
  • Android 本地更新APK(无需添加运行时权限)

    很多APP都会有自动更新APP然后本地安装的功能 之前一直是用AsnycTask来做的 最近发现AsyncTask被标记为过时 那么就换一种方式来写吧 我自己是做在Dialog里面 使用okhttp进行文件下载 配合自定义View的进度条进
  • python大规模数据处理技巧之一:数据常用操作

    面对读取上G的数据 python不能像做简单代码验证那样随意 必须考虑到相应的代码的实现形式将对效率的影响 如下所示 对pandas对象的行计数实现方式不同 运行的效率差别非常大 虽然时间看起来都微不足道 但一旦运行次数达到百万级别时 其运
  • 线程的创建及性能

    目录 1 多线程 VS 单线程性能 2 线程3中创建方式 2 1 创建方式一 继承Thread 1种写法 2 2 创建方式二 实现Runnable及变种 4种写法 2 3 创建方式三 带返回值的Callable 2种写法 线程休眠演示打印电
  • momentJS 时间差计算

    momentJS时间差计算 最近在使用JavaScript计算时间差的时候 发现很多问题需要处理 在查看momentJS之后 发现非常容易 console log moment format YYYY MM DD HH mm ss 当前时间
  • 完全二叉树与满二叉树

    去笔试了很多次 每次都有有关于二叉树的题目 而且其中最多的是关于完全二叉树 然而完全二叉树在哥心中的形态一直很模糊 究其原因是我把完全二叉树和满二叉树搞混了 其实满二叉树是完全二叉树的特例 因为满二叉树已经满了 而完全并不代表满 所以形态你
  • 提升应用性能的关键步骤——UniApp性能优化策略与技巧详解

    前言 作者主页 雪碧有白泡泡 个人网站 雪碧的个人网站 推荐专栏 java一站式服务 前端炫酷代码分享 uniapp 从构建到提升 从0到英雄 vue成神之路 解决算法 一个专栏就够了 架构咱们从0说 数据流通的精妙之道 文章目录 前言 代
  • JAVA,异常

    异常概念 通常大家认为异常就是错误 但这个错误有很多种 1 语法错误 2 JVM虚拟机错误 3 平台错误 4 程序运行错误 平台或者资源或者逻辑 数值等错误 常见的异常 1 java lang NullPointerException 空指
  • C++ 模板简介(一)—— SFINAE

    SFINAE 类型检查 Concepts SFINAE 机制是组成 C 模板机制及类型安全的相当重要的基础 全称是 Substitution failure is not an error 大概的意思就是只要找到了可用的原型 比如函数模板