C++ 的重载决议与 SFINAE

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

https://zh.cppreference.com/w/cpp/language/overload_resolution

https://zh.cppreference.com/w/cpp/language/adl

实参依赖查找

下面的重载决议中提到了实参依赖查找,看一下

实参依赖查找(Argument-Dependent Lookup,简称 ADL),也叫 Koenig 查找,是一种特殊的名字查找规则,用于在调用函数模板或非成员函数时,自动在实参所属的命名空间中查找对应的函数

当你调用一个函数时,如果这个函数没有用完整限定名(例如 ns::foo()),那么编译器会到实参的命名空间中查找同名函数

#include <iostream>

namespace myns {
    struct MyType {};

    void print(MyType) {
        std::cout << "myns::print called\n";
    }
} // namespace myns

int main() {
    myns::MyType obj;
    print(obj);  // 这里没有 using myns::print,也没写 myns::print(),但编译器能找到它
}

它可以用在操作符重载中:

namespace math {
    struct Vector {};

    Vector operator+(const Vector&, const Vector&) {
        return {};
    }
}

math::Vector a, b;
auto c = a + b;  // 依靠 ADL 找到 operator+

常与模板函数搭配使用,例如:

namespace myns {
    struct Widget {};

    void swap(Widget&, Widget&) {
        std::cout << "custom swap\n";
    }
}

template<typename T>
void doSomething(T& a, T& b) {
    using std::swap;
    swap(a, b);  // 借助 ADL 找到 myns::swap
}

注意:

  • ADL 只影响无限定的名字查找
  • 如果无限定的名字查找找到的候选集合中含有:类成员的声明、块作用域的函数声明、任何非函数或函数模板的声明时,无法使用 ADL

重载决议

重载决议通过以下几个步骤进行:

  • 建立候选函数集合
  • 从候选函数集合中去除函数,只保留可行函数
  • 分析可行函数集合,以确定唯一的最佳可行函数

如果函数无法被重载决议选择,那么不能使用它

候选函数

在重载决议开始前,将名字查找模板实参推导所选择的函数组成候选函数 的集合

  • 调用具名函数
  • 调用类对象(可调用对象)
  • 调用重载运算符
  • 使用构造函数进行直接初始化或默认初始化时,候选函数是所有的构造函数
  • 进行拷贝初始化时,候选函数是所有的转换构造函数等

还有一些其他的东西

可行函数

给定以上述方式构造的候选函数集,重载决议的下一步骤是检验各个实参与形参,并将集合缩减为可行函数 的集合

候选函数必须满足下列条件:

  • 实参数量相等的可行
  • 实参数量少的,如果有省略号形参,那么可行
  • 实参数量多的,如果剩下的有默认实参,那么可行
  • 如果有关联的约束(C++ 20),那么必须满足它
  • 对于每个实参,必须至少存在一个隐式转换序列将它转换到对应的形参(用户定义的转换只能隐式应用一次)
  • 如果任何形参具有引用类型,那么这一步负责引用绑定:如果右值实参对应非 `const1 左值引用形参,或左值实参对应右值引用形参,那么函数不可行

如果有两个函数 F1F2 可行,那么如果满足下面的条件,就称 F1 优于 F2(从前往后依次判断):

  1. 至少存在一个 F1 的实参,它的隐式转换优于 F2 的该实参的对应的隐式转换
  2. F1 的返回类型到要初始化的类型的标准转换序列优于从 F2 的返回类型到该类型的标准转换序列
  3. 在需要对返回值进行引用绑定的情况下,F1 的返回类型是与正在初始化的引用相同种类的引用(左值或右值),而 F2 的返回类型不是
  4. F1 是非模板的函数而 F2 是模板特化
  5. F1F2 都是模板特化,且按照模板特化的偏序规则F1 更特殊
  6. F2 是重写的候选而 F1 不是(也就是说重写的成员函数劣于普通的成员函数)
  7. F1 是从用户定义推导指引所生成的而 F2 不是
  8. F1复制推导候选而 F2 不是
  9. F1 是从非模板构造函数生成而 F2 是从构造函数模板生成(构造函数也有正常优于模板的说法)

总结:

  1. 参数列表的隐式转换分级、返回值的隐式转换
  2. 非模板优于模板,模板特化越特殊越好
  3. 非重写成员函数优于重写成员函数
  4. 对构造函数来说,先看参数,再看推导指引,然后非模板优于模板

隐式转换序列的分级

每种标准转换序列的类型都被赋予三个等级之一:

  • 准确匹配:不要求转换、左值到右值转换、限定性转换、函数指针转换、(C++ 17 起)类类型到相同类的用户定义转换
  • 提升:整数提升、浮点数提升
  • 转换:整数转换、浮点数转换、浮点数整数转换、指针转换、成员指针转换、布尔转换、派生类到它的基类的用户定义转换

下面的不看了,像法律条文一样

SFINAE

SFINAE 就是 Substitution Failure Is Not An Error 的首字母缩写,意思是“替换失败不是错误”

在函数模板的重载决议中,当模板形参在替换成显式指定的类型或推导出的类型失败时,从重载集中丢弃这个特化,而非导致编译失败

在选择候选函数之前,会对函数模板形参进行两次替换:

  • 显式指定的模板实参
  • 推导出的实参和从默认项获得的实参

替换会发生在:函数形参类型、返回值类型、模板形参声明(尖括号里面的东西)等

下面类型的错误是 SFINAE 错误:

  • 同时展开多个不同长度的参数包

  • 试图构建 void、引用或者函数的数组或者异常长度(长度小于等于 0)的数组:

    template<int I>
    void div(char(*)[I % 2 == 0] = 0) {
        // 当 I 是偶数时选择这个重载,当 I 是奇数时,数组长度为 0
    }
    
    template<int I>
    void div(char(*)[I % 2 == 1] = 0) {
        // 当 I 是奇数时选择这个重载
    }
    
  • 试图在作用域解析运算符 :: 左侧使用类和枚举以外的类型:

    template<class T>
    int f(typename T::B*);
    
    template<class T>
    int f(T);
    
    int i = f<int>(0); // 使用第二个重载
    
  • 试图使用类型的成员,其中类型不包含指定成员或者在要求类型、模板、非类型的地方,指定成员不是类型、模板、非类型

  • 试图创建指向引用的指针

  • 试图创建到 void 的引用

  • 试图创建指向 T 的成员的指针,其中 T 不是类类型

  • 试图将非法类型给予非类型模板形参

一个特殊例子:

template<typename A>
struct B { using type = typename A::type; };
 
template<
    typename T,
    typename U = typename T::type,    // 如果 T 没有成员 type 那么就是 SFINAE 失败
    typename V = typename B<T>::type> // 如果 T 没有成员 type 那么就是硬错误
                                      // (经由 CWG 1227 保证不出现,
                                      // 因为到 U 的默认模板实参中的替换会首先失败)
void foo (int);
 
template<typename T>
typename T::type h(typename B<T>::type);
 
template<typename T>
auto h(typename B<T>::type) -> typename T::type; // 重声明
 
template<typename T>
void h(...) {}
 
using R = decltype(h<int>(0));     // 非良构,不要求诊断

因为 B<T> 里面确实声明了 type,而在替换阶段并没有进行实例化,所以在替换阶段并不知道这个 type 是否正常

在真正实例化了这个模板函数时,B<T> 就被实例化,这时才发现没有 T::type,所以是一个硬错误

而这里说到了 CWG 1227,这个问题编号表示:当多个默认模板参数有依赖关系时,替换顺序是从前往后依次进行,前一个失败则后面不替换。所以该例子实际上不会报错