C++ 的重载决议与 SFINAE

§ 参考资料 共 3 条

实参依赖查找

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

实参依赖查找(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 左值引用形参,或左值实参对应右值引用形参,那么函数不可行

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

  1. 至少存在一个 F1 的实参,它的隐式转换优于 F2 的该实参的对应的隐式转换
  2. 从 F1 的返回类型到要初始化的类型的标准转换序列优于从 F2 的返回类型到该类型的标准转换序列
  3. 在需要对返回值进行引用绑定的情况下,F1 的返回类型是与正在初始化的引用相同种类的引用(左值或右值),而 F2 的返回类型不是
  4. F1 是非模板的函数而 F2 是模板特化
  5. F1 与 F2 都是模板特化,且按照模板特化的偏序规则,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,这个问题编号表示:当多个默认模板参数有依赖关系时,替换顺序是从前往后依次进行,前一个失败则后面不替换。所以该例子实际上不会报错