前言
这篇博客用来总结overload resolution,这是一篇关于重载决议的演讲,very nice。
一些与overload易混淆的概念
override
- 子类继承父类,有可能你需要让父类某些函数为虚函数,让子类重写虚函数,形成多态。
- 但是你的子类,可能虚函数写错了,就无法形成多态。你可以在子类虚函数上加上override,编译器会帮助你检查该函数是否重写了。
- overload 是编译期多态,根据date type来区分调用哪个重载版本。
- override是运行期多态,根据object的动态类型区分调用哪种method。
运算符重载
- 运算符重载实际上不算是通常意义上的重载,它总是有一个operator加上一个要重载的运算符,这是它的名字。
- 运算符重载实现的机制也是重载决议。
为什么需要重载决议?
- 当多个函数对于某个调用都是可见的(visible),且它们具有相同的名字,不同的parameter-list(形参列表)时,重载就会发生。
- 函数重载可以避免名字过长。比如你有你个函数想用作int,char:
doThingsToInt();
doThingToChar();
doThing();
声明函数重载
- 函数重载适用范围很广,构造函数,成员函数,普通函数等等
- 当两个函数:
- 对于当前函数调用都是可见的,
- 拥有相同的名字,不同的参数列表,
- 这两个函数互为重载。
- 声明重载函数的顺序并不会改变调用的重载函数。你先声明的函数不一定优先级更高。
int dothing(int);
int dothing(int, double);
int main(){
dothing(1);
dothing(1, 3.14);
}
- 上面就是最普通的函数重载,它的调用也符合我们的预期。
什么是重载决议?
- 重载决议就是选择最匹配的重载函数的过程。
- 重载决议是面对函数重载时编译器必须做出的决定。
- 重载决议完全发生在编译期。
- 重载决议只会考虑实参的data type,和形参的type。该过程只关系类型的匹配程度,它不关心实际传过去的值!!只关心类型,只关心类型,只关心类型。
- 如果两个函数拥有同样的rank(等级),编译器就无法挑选出更匹配的那个,这就ambiguous。
模板函数也参与重载决议。。。因为模板函数和非模板函数也能形参函数重载, 这让重载决议有点复杂,
- 如果模板和非模板函数拥有相同的rank,那么非模板函数会被选择。(这也正常)
什么不是函数重载?
- 当两个函数只有返回值不同时,无法形成重载。返回值不是必须的,也就是说你可以不使用返回值,这样无法看出你想选择哪一个。(我们可以通过SFINAE实现一些“返回值版的函数重载”,interesting)。
- 当两个函数签名式(signature,函数名字+参数列表)相同,但是有不同的default value时,也不会形成重载。因为函数重载只看类型,不会看实际值。
- 当两个函数签名式相同,但是一个为static时,也不会形成函数重载。。。。
重载决议之过程
- 你可能认为重载决议very easy,不就是选择更匹配的吗?
- 也没那么容易。当你考虑到各种转换,像什么指针,引用的转换,左值引用,右值引用转换,模板实参推导,等等一系列的时候,情况变的糟糕了。。。(尤其是考虑转换)
- 而且重载决议可能会选择错误的匹配,这时候需要你去调试。弄清楚编译器是怎样执行的
函数重载 && 函数模板
- 确实,这两个东西很相似。
- 什么时候使用函数重载,什么时候使用函数模板呢?
当实现需要根据参数类型而变化时,使用函数重载。 当实现完全类似而与参数类型无关时,使用函数模板。 实际上,当我们谈论函数模板时,往往谈论的是函数重载而非函数模板。因为函数模板不支持偏特化。
before 重载决议
- 在重载决议发生之前,编译器会进行一种procedure叫做name lookup(所谓名称查找,这是固定术语)。
- name lookup会去找到关于你的调用可见的所有函数声明。
- name lookup 也许需要argumemt dependent lookup(所谓ADL,依据实参,编译器会自动去查看实参所在的命名空间,即使你没有显示说明。)
- 模板函数 也许需要 template argument deduction (模板实参推导,推导出函数模板的类型)。当编译器找到模板函数,它会去尝试推导出参数类型,然后放入overload set。
- 所有可见的函数声明的列表形成一个集合,叫做overload set。注意,这一步形成的overload set可能是非常大的,因为它只是简单的查找名称,查找全局,然后是各种命名空间,实参命名空间等等。
more details
- 第一步,编译器构造一个overload set,然后将其中所有的函数声明放入一个叫做candidates(候选人)的列表中。
- 第二步,编译器会将那些非法的函数声明从candidates列表中去掉,所谓“非法”在C++标准中被叫做“not viable”。
- 编译器一般会在两个方面判断一个函数是否为“not viable”:
- 根据你传入的参数个数。如果函数声明的参数个数比你调用的参数个数少,那么该函数声明not viable,将该函数声明从candidates列表移除。如果函数声明的参数个数多,且多余的参数没有default value,那么也是not viable。
- 判断参数类型。如果函数声明中的参数类型与实参的类型不匹配,即使考虑到隐式类型转换,那么就是not viable的。
void dothing(std::string);
void dothing(int);
void dothing(int, double);
int main(){
dothing(3);
dothing(3, 3.14);
return 0;
}
- 此时,在candidates列表中,所有的候选者都是可行的,但是我们要去寻找一个best match的。
- 第三步,找到最匹配的候选者。我们要通过一系列C++标准对candidates列表进行rank(排名)。
- 如果只有一个候选者获得最高的rank,或者candidates列表只剩下了一个候选者,那么排名结束,这就是我们想要的。如果有两个或者多个候选者排名相同,那么我们就要进入所谓的tie breaker(决胜局)。
type conversion(类型转换)
- 实际上,重载决议的最终阶段还是回到了类型转换上来。
- 类型转换,即将一种类型转换成另外一种类型。
- 我们有显示转换,static_cast,const_cast等等,我们还有隐式转换,如float到int。
rank(排名)
- 上面就是5个转换的排名。
- 第三个就是对非const到const 和非volatile到volatile的转换。
- 第四个是所谓的整形提升。
栗子2:
void dothing2(char){};
void dothing2(long){};
int main(){
dothing2(42);
return 0;
}
- 编译器认为这是ambiguous!!
- but,why?标准说,整形提升的排名高于普通的转换,为什么不是选择int到long的“整形提升”?
- 我们需要更仔细的查看标准,标准是说,整形提升是某一种长度比int小的intergral提升为int,你会发现整形提升往往是提升到int,而标准认为int到long不是整形提升,而是转换!!
- 所有的整形提升必须在标准中,如果标准没有提到,那么就是转换!!
其他两个排名
-
user defined conversion是转换成class类型or从class类型转换出去, -
这意味着,即使你使用std里面的各种class,编译器也会认为它们是用户自定义类型。 -
如果两个函数声明拥有同样的rank,那么它们需要进入tie breaker。而我们的tie breaker也有一些规则:
- 如果一个模板和一个非模板拥有同样的rank,那么选择非模板。
- 如果一个隐式转换需要的“steps”更少,那么我们选择这个。
- 对于C++20的concept新增一个tie breaker的规则:如果一个candidate的concept更严格,那么选择这一个。
- 注意,该3个规则不会影响rank,只有引入tie breaker的时候才会启用该三点规则。
- 如果tie breaker同样不能分出胜负,那么就会报错。
栗子4:
void dothing4(char val){};
template <class T>
void dothing4(T val){};
int main(){
dothing4(42);
return 0;
}
- 选择第二个版本。因为T会被实参推导规则推导成int,这是一个完美匹配。
- 所以我们应该尽量避免将函数模板和普通函数放在一个overload set中,因为模板往往形成最佳匹配。
- 注意,模板大于非模板只有在tie breaker中才会被使用。
解决ambiguous function call
- 增加或者删除一个重载函数
- 让构造函数成为implicit
- 为模板函数的参数添加约束(C++20有concept),这样通过SFINAE就会帮助我们排除某些函数模板。
- 将argument显示转换,而非使用隐式转换。
例如,static_cast<>,explicitly构造一个对象,使用string(“hello”)作为参数,而非传递一个string literal(string字面量)。
当最佳匹配不是你想要的
- 尝试利用规则制造出ambiguous,然后就能推导出最佳匹配位于哪一个rank,然后就可以进行更细致的推导。
- 尝试去理解编译器如何看待candidates
- 更改某些arguments的类型,
栗子6:
void dothing6_A(double, int, int){}
void dothing6_A(int, double, double){}
dothing6_A(4, 5, 6);
void dothing6_B(int, int, double){}
void dothing6_B(int, double, double){}
dothing6_B(4, 5, 6);
- dothing6_A的调用是ambiguous。你可能会选择one,因为第一个有两个int,可惜这不是编译器认为的。
- 编译器每次都会为每个参数进行rank。第一回合,为第一个参数排序,int是更好的排序,所以two不能是最好匹配。第二回合,one取胜。所以two不可能是最好匹配。到此为止,编译器结束重载决议,没有最好匹配。(这类似与某些游戏,没有平局,只有双输。)
- 而在dothing6_B中,第一回合,three和four的第一个参数都是int,它们是同样的rank。第二回合,three取胜,因为它的int更匹配。而第三回合,three和four又是同样的rank。综合以上,three在第二回合取胜,且其他回合没有更差,所以选择重载版本three。
- amazing。
栗子7:
void dothing7_A(int&){}
void dothing7_A(int){}
int x = 42;
dothing7_A(x);
void dothing7_B(int&){}
void dothing7_B(int){}
dothing7_B(42);
- one 和 four会被选择。
- 首先我们来看第一个调用,将int&绑定到int,是一个最佳匹配,不是一个转换,所以该调用时ambiguous。
- 第二个调用,选择four。因为42是一个常量,不能将非const左值引用绑定到常量上。因此four的调用不合法,只能选择three。
栗子8:
void dothing8_A(int&){}
void dothing8_A(int&&){}
int x = 42;
dothing8_A(x);
void dothing8_B(int&){}
void dothing8_B(int&&){}
dothing8_B(42);
- 在第一个调用中,选择one。因为将右值引用绑定到左值上。所以two是非法的调用,所以one胜出。
- 在第二个调用中,同样的,我们无法将非const左值引用绑定到常量上,所以four胜出。
栗子9:
void dothing9(int&){
cout << "int&" << endl;
}
void dothing9(...){
cout << "..." << endl;
}
struct MyStruct {
int data_ : 5;
};
MyStruct obj;
dothing9(obj.data_);
- 编译器会调用one。因为位域(bit field)在C++中不认为是一种type,位域不在type system中,所以当你传递一个位数为5的位域时,编译器会认为你传递了一个int类型,所以one是完美匹配。
- However,当你真正编译的时候,编译器会报错,因为你无法将int&绑定到位域上。
- 即使你加入一个const int&,依然会报错,因为const int&版本会在重载决议时被非const干掉。
|