文章目录
- 重载运算与类型转换
- 14.1基本概念
- 直接调用一个重载的运算符函数
- 某些运算符不应该被重载
- 使用与内置类型一致的含义
- 赋值和复合赋值运算符
- 选择作为成员或者非成员
- 14.2输入和输出运算符
- 14.2.1重载输出运算符<<
- 输出运算符尽量减少格式化操作
- 输入输出运算符必须是非成员函数
- 14.2.2重载输入运算符>>
- 标示错误
- 14.3算术和关系运算符
- 14.3.1相等运算符
- 14.3.2关系运算符
- 14.4赋值运算符
- 复合赋值运算符
- 14.5下标运算符
- 14.6递增和递减运算符
- 定义前置递增/递减运算符
- 区分前置和后置运算符
- 显式地调用后置运算符
- 14.7成员访问运算符
- 对箭头运算符返回值的限定
- 14.8函数调用运算符
- 含有状态的函数对象类
- 14.8.1Lambda是函数对象
- 表示lambda及相应捕获行为的类
- 14.8.2标准库定义的函数对象
- 在算法中使用标准库函数对象
- 14.8.3可调用对象与function
- 不同类型可能具有相同的调用形式
- 标准库function类型
- 重载的函数与function
- 14.9重载、类型转换与运算符
- 14.9.1类型转换运算符
- 定义含有类型转换运算符的类
- 类型转换运算符可能产生意外结果
- 显式的类型转换运算符
- 14.9.2避免有二义性的类型转换
- 实参匹配和相同的类型转换
- 二义性与转换目标为内置类型的多重类型转换
- 重载函数与转换构造函数
- 重载函数与用户定义的类型转换
- 14.9.3函数匹配与重载运算符
重载运算与类型转换
14.1基本概念
重载的运算符由关键字
operator
和其后要定义的运算符号共同组成,同时包含返回类型、参数列表以及函数体。其参数数量与该运算符作用的运算对象数量一样多。例如,一元运算符有一个参数,二元运算符有两个。除了重载的函数调用运算符operator()
之外,其他重载运算符不能包含有默认实参。
当一个重载的运算符是成员函数时,this
绑定到左侧运算对象。成员运算符函数的(显式)参数数量比运算对象的数量少一个。
对于一个重载的运算符来说,其优先级和结合律与对应的内置运算符保持一致。
直接调用一个重载的运算符函数
// 假设data1和data2是同一个类的不同实例
// 一个非成员运算符函数的等价调用
data1 + data2; // 普通的表达式
operator+(data1, data2); // 等价的函数调用
// 将this绑定到data1的地址,将data2作为实参传入了函数。
data1 += data2; // 基于调用的表达式
data1.operator+=(data2); // 对成员运算符函数的等价调用
某些运算符不应该被重载
某些运算符指定了运算对象求值的顺序,因为使用重载的运算符本质上是一次函数调用,所以这些关于运算对象求值顺序的规则无法应用到重载的运算符上。例如,
&&
、||
、‘,’(逗号运算符)。除此之外,&&
、||
的重载版本也无法保留内置运算符的短路求值属性,两个运算对象总是会被求值。
另一方面,c++已经定义了,
、&
用于类类型对象时的特殊含义,所以一般来说不应该被重载,否则其行为将异于常态,从而导致类的用户无法适应。
使用与内置类型一致的含义
如果某些操作在逻辑上与运算符相关,则它们适合于定义成重载的运算符:
- 如果类执行IO操作,则定义移位运算符使其与内置类型的IO保持一致。
- 如果类的某个操作是检查相等性,则定义
operator==
,此时,通常也应该有operator!=
。- 如果类包含一个内在的单序比较操作,则定义
operator<
,此时,通常也应该有其他关系操作。- 重载运算符的返回类型通常情况下应该与其内置版本的返回类型兼容:逻辑运算符和关系运算符应该返回
bool
,算术运算符应该返回一个类类型的值,赋值运算符和复合赋值运算符则应该返回左侧运算对象的一个引用。
赋值和复合赋值运算符
如果类含有算术运算符或者位运算符,则最好也提供对应的复合赋值运算符。
选择作为成员或者非成员
- 赋值(
=
)、下标([]
)、调用(()
)和成员访问箭头(->
)运算符必须是成员。- 复合赋值运算符一般来说应该是成员,但并非必须。
- 改变对象状态的运算符或者与给定类型密切相关的运算符,如递增、递减和解引用运算符,通常应该是成员。
- 具有对称性的运算符可能转换任意一端的运算对象,例如算术、相等性、关系和位运算符等,因此它们通常应该是普通的非成员函数。
当把运算符定义成成员函数时,它的左侧运算对象必须是运算符所属类的一个对象:
string s = "world";
// 正确:能把一个const char*加到一个string对象中。
string t = s + "!";
// 如果+是string的成员,则产生错误。其等价于:"hi".operator+(s),然而,由于"hi"的类型
// 是const char*,这是一种内置类型,根本就没有成员函数。因为string把+定义成了普通的非成员
// 函数,所以其等价于operator+("hi", s)。唯一的要求是至少有一个运算对象时类类型。
string u = "hi" + s;
14.2输入和输出运算符
14.2.1重载输出运算符<<
通常情况下,输出运算符的第一个形参是非常量
ostream
对象的引用。第二个形参一般来说是一个常量的引用,该常量是想要打印的类类型。
为了与其他输出运算符保持一致,operator<<
一般要返回它的ostream
形参。
ostream &operator<<(ostream &os, const Sales_data &item) {
os << item.isbn() << " " << item.units_sold << ""
<< item.revenue << " " << item.avg_price();
return os;
}
输出运算符尽量减少格式化操作
通常,输出运算符应该主要负责打印对象的内容而非控制格式,输出运算符不应该打印换行符。
输入输出运算符必须是非成员函数
Sales_data data;
data << cout; // 如果operator<<是Sales_data的成员
假设输入输出运算符是某个类的成员,则它们也必须是
istream
或ostream
的成员。然而,这两个类属于标准库,并且无法给标准库中的类添加任何成员。
另一方面,IO运算符通常需要读写类的非公有数据成员,所以IO运算符一般被声明为友元。
14.2.2重载输入运算符>>
通常情况下,输入运算符的第一个形参是运算符将要读取的流的引用,第二个形参是将要读入到的(非常量)对象的引用。该运算符通常会返回某个给定流的引用。
istream &operator>>(istream &is, Sales_data &item) {
double price; // 不需要初始化,因为会先将数据读入到price,之后才使用它。
is >> item.bookNo >> item.units_sold >> price;
if (is) { // 检查输入是否成功
item.revenue = item.units_sold * price;
} else {
item = Sales_data(); // 输入失败:对象被赋予默认的状态。
}
return is;
}
输入运算符必须处理输入可能失败的情况,而输出运算符不需要。
标示错误
一些输入运算符需要做更多数据验证的工作。例如,检查是否符合规范的格式。在这样的例子中,即使从技术上来看IO是成功的,输入运算符也应该设置流的条件状态以标示出失败信息。通常情况下,输入运算符只设置
failbit
。除此之外,设置eofbit
表示文件耗尽,而设置badbit
表示流被破坏。最好的方式是由IO标准库自己来标示这些错误。
14.3算术和关系运算符
通常情况下,把算术和关系运算符定义成非成员函数以允许对左侧或右侧的运算对象进行转换。因为这些运算符一般不需要改变运算对象的状态,所以形参都是常量的引用。
如果类定义了算术运算符,则它一般也会定义一个对应的复合赋值运算符。此时,最有效的方式是使用复合赋值来定义算术运算符:
Sales_data operator+(const Sales_data &lhs, const Sales_data &rhs) {
Sales_data sum = lhs;
sum += rhs;
return sum;
}
14.3.1相等运算符
如果某个类在逻辑上有相等性的含义,则该类应该定义
operator==
,这样做可以使得用户更容易使用标准库算法来处理这个类。
bool operator==(const Sales_data &lhs, const Sales_data &rhs) {
return lhs.isbn() == rhs.isbn()
&& lhs.unit_sold = rhs.units_sold
&& lhs.revenue = rhs.revenue;
}
bool operator!=(const Sales_data &lhs, const Sales_data &rhs) {
return !(lhs == rhs);
}
14.3.2关系运算符
定义了相等运算符的类也常常(但不总是)包含关系运算符。特别是,因为关联容器和一些算法要用到小于运算符,所以定义
operator<
会比较有用。
通常情况下,如果两个对象是!=
的,那么一个对象应该<
另外一个。
14.4赋值运算符
拷贝赋值和移动赋值运算符可以把类的一个对象赋值给该类的另一个对象。此外,类还可以定义其他赋值运算符以使用别的类型作为右侧运算对象。例如,
vector
的花括号列表赋值:
vector<string> v;
v = {"a", "an", "the"};
同样,也可以把这个运算符添加到
StrVec
类中(参考13.5节):
class StrVec {
public:
StrVec &operator=(std::initializer_list<std::string>);
// 其他成员与前面一致
};
// 无须检查自赋值,形参initializer_list<std::string>确保il与this所指的不是同一个对象。
StrVec &StrVec::operator=(initializer_list<std::string> il) {
// alloc_n_copy分配内存空间并从给定范围内拷贝元素
auto data = alloc_n_copy(il.begin(), il.end());
free(); // 销毁对象中的元素并释放内存空间
elements = data.first; // 更新数据成员使其指向新空间
first_free = cap = data.second;
return *this;
}
复合赋值运算符
复合赋值运算符不非得是类的成员,不过还是倾向于把包括复合赋值在内的所有赋值运算都定义在类的内部。
// 作为成员的二元运算符:左侧运算对象绑定到隐式的this指针。
Sales_data &Sales_data::operator+=(const Sales_data &rhs) {
units_sold += rhs.units_sold;
revenue += rhs.revenue;
return *this;
}
14.5下标运算符
表示容器的类通常可以通过元素在容器中的位置访问元素,这些类一般会定义下标运算符
operator[]
。
下标运算符必须是成员函数。
如果一个类包含下标运算符,则它通常会定义两个版本:一个返回普通引用,另一个是类的常量成员并且返回常量引用。
class StrVec {
public:
std::string &operator[](std::size_t n) {
return elements[n];
}
const std::string &operator[](std::size_t n) const {
return elements[n];
}
// 其他成员保持一致
private:
std::string *elements; // 指向数组首元素的指针
};
14.6递增和递减运算符
定义递增和递减运算符的类应该同时定义前置版本和后置版本。因为它们改变的正好是所操作对象的状态,所以这些运算符通常应该被定义成类的成员。
定义前置递增/递减运算符
为了与内置版本保持一致,前置运算符应该返回递增或递减后对象的引用。
// 参考12.1.6节
class StrBlobPtr {
public:
// 递增和递减运算符
StrBlobPtr &operator++(); // 前置运算符
StrBlobPtr &operator--();
// 其他成员保持一致
};
StrBlobPtr &StrBlobPtr::operator++() {
// 如果curr已经指向了容器的尾后位置,则无法递增它。
check(curr, "increment past end of StrBlobPtr");
++curr; // 将curr在当前状态下向前移动一个元素
return *this;
}
StrBlobPtr &StrBlobPtr::operator--() {
// 如果curr是0,则继续递减它将产生一个无效下标。
--curr;
check(curr, "decrement past begin of StrBlobPtr");
return *this;
}
区分前置和后置运算符
后置版本接受一个额外的(不被使用)
int
类型的形参。当使用后置运算符时,编译器为这个形参提供一个值为0的实参。
为了与内置版本保持一致,后置运算符应该返回对象的原值(递增或递减之前的值),返回的形式是一个值而非引用。一般调用各自的前置版本来完成实际的工作。
class StrBlobPtr {
public:
// 递增和递减运算符
StrBlobPtr operator++(int); // 后置运算符
StrBlobPtr operator--(int);
// 其他成员保持一致
};
// 后置版本:递增/递减对象的值但是返回原值。
StrBlobPtr StrBlobPtr::operator++(int) {
// 此处无须检查有效性,调用前置递增运算符时才需要检查。
StrBlobPtr ret = *this; // 记录当前的值
++*this; // 向前移动一个元素,前置++需要检查递增的有效性。
return ret; // 返回之前记录的状态
}
StrBlobPtr StrBlobPtr::operator--(int) {
// 此处无须检查有效性,调用前置递减运算符时才需要检查。
StrBlobPtr ret = *this; // 记录当前的值
--*this; // 向后移动一个元素,前置--需要检查递增的有效性。
return ret; // 返回之前记录的状态
}
显式地调用后置运算符
如果想通过函数调用的方式调用后置版本,则必须为它的整型参数传递一个值:
StrBlobPtr p(a1);
p.operator++(0); // 调用后置版本的operator++
p.operator++(); // 调用前置版本的operator++
14.7成员访问运算符
在迭代器类及智能指针类中常常用到解引用运算符(
*
)和箭头运算符(->
)。
class StrBlobStr {
public:
std::string &operator*() const {
auto p = check(curr, "dereference past end");
return (*p)[curr]; // (*p)是对象所指的vector
}
// 这里返回指针需要结合后面的知识来理解
std::string *operator->() const {
// 将实际工作委托给解引用运算符
return &this->operator*();
}
// 其他成员保持一致
};
对箭头运算符返回值的限定
箭头运算符永远不能丢掉成员访问这个最基本的含义。当重载箭头时,可以改变的是箭头从哪个对象当中获取成员,而箭头获取成员这一事实则永远不变。
对于形如point->mem
的表达式来说,point
必须是指向类对象的指针或者是一个重载了operator->
的类的对象。根据point
类型的不同,point->mem
分别等价于:
(*point).mem; // point是一个内置的指针类型
point.operator()->mem; // point是类的一个对象
除此之外,代码都将发生错误。
point->mem
的执行过程如下:
- 如果
point
是指针,则应用内置的箭头运算符,表达式等价于(*point).mem
。如果point
所指的类型没有名为mem
的成员,程序会发生错误。- 如果
point
是定义了operator->
的类的一个对象,则使用point.operator->()
的结果来获取mem
。其中,如果结果是一个指针,则执行第1步;如果该结果本身含有重载的operator->
,则重复调用当前步骤。最终,当这一过程结束时程序或者返回了所需的内容,或者返回一些表示程序错误的信息。
14.8函数调用运算符
如果类重载了函数调用运算符,则可以像使用函数一样使用该类的对象。因为这样的类同时也能存储状态,所以与普通函数相比更加灵活。
struct absInt {
int operator()(int val) const {
return val < 0 ? -val : val;
}
};
int i = -42;
absInt absObj; // 含有函数调用运算符的对象
int ui = absObj(i); // 将i传递给absObj.operator()
函数调用运算符必须是成员函数。一个类可以定义多个不同版本的调用运算符,相互之间应该在参数数量或类型上有所区别。
含有状态的函数对象类
class PrintString {
public:
PrintString(ostream &o = cout, char c = ' ') : os(o), sep(c) {}
void operator()(const string &s) const {
os << s << sep;
}
private:
ostream &os; // 用于写入的目的流
char sep; // 用于将不同输入隔开的字符
};
PrintString printer; // 使用默认值,打印到cout
printer(s); // 在cout中打印s,后面跟一个空格。
PrintString errors(cerr, '\n');
errors(s); // 在cerr中打印s,后面跟一个换行符。
函数对象常常作为泛型算法的实参:
for_each(vs.begin(), vs.end(), PrintString(cerr, '\n'));
14.8.1Lambda是函数对象
当编写了一个lambda后,编译器将该表达式翻译成一个未命名类的未命名对象。在lambda表达式产生的类中含有一个重载的函数调用运算符:
// 根据单词的长度对其进行排序,对于长度相同的单词按照字母表顺序排序。
stable_sort(words.begin(), words.end(),
[](const string &a, const string &b) { return a.size() < b.size(); });
// 其行为类似于这个类的一个未命名对象:
class ShorterString {
bool operator()(const string &s1, const string &s2) const {
return s1.size() < s2.size();
}
};
stable_sort(words.begin(), words.end(), ShorterString());
默认情况下lambda不能改变它捕获的变量。因此在默认情况下,由lambda产生的类当中的函数调用运算符是一个
const
成员函数。如果lambda被声明为可变的,则调用运算符就不是const
的了。
表示lambda及相应捕获行为的类
当一个lambda表达式通过引用捕获变量时,将由程序负责确保lambda执行时引用所引的对象确实存在。因此,编译器可以直接使用该引用而无须在lambda产生的类中将其存储为数据成员。
相反,通过值捕获的变量被拷贝到lambda中。因此,这种lambda产生的类必须为每个值捕获的变量建立对应的数据成员,同时创建构造函数,令其使用捕获的变量的值来初始化数据成员。
auto wc = find_if(words.begin(), words.end(),
[sz](const string &a) { return a.size() >= sz; });
// 产生的类形如:
class SizeComp {
public:
SizeComp(size_t n) : sz(n) {} // 该形参对应捕获的变量
// 该调用运算符的返回类型、形参和函数体都与lambda一致。
bool operator()(const string &s) const {
return s.size() >= sz;
}
private:
size_t sz; // 该数据成员对应通过值捕获的变量
};
Lambda表达式产生的类不含默认构造函数、赋值运算符及默认析构函数;它是否含有默认的拷贝/移动构造函数则通常要视捕获的数据成员类型而定。
14.8.2标准库定义的函数对象
标准库定义了一组表示算术运算符、关系运算符和逻辑运算符的类,每个类分别定义了一个执行命名操作的调用运算符。
plus<int> intAdd; // 可执行int加法的函数对象
negate<int> intNegate; // 可对int值取反的函数对象
// 使用intAdd::operator(int, int)求10和20的和
int sum = intAdd(10, 20);
sum = intNegate(intAdd(10, 20));
sum = intAdd(10, intNegate(10));
在算法中使用标准库函数对象
表示运算符的函数对象类常用来替换算法中的默认运算符。
// 传入一个临时的函数对象用于执行两个string对象的>比较运算
sort(svec.begin(), svec.end(), greater<string>());
需要特别注意的是,标准库规定其函数对象对于指针同样适用:
// 正常情况下,比较两个无关指针将产生未定义的行为。
vector<string*> nameTable; // 指针的vector
// 错误:nameTable中的指针彼此之间没有关系,所以<将产生未定义的行为。
sort(nameTable.begin(), nameTable.end(),
[](string *a, string *b) { return a < b; });
// 正确:标准库规定指针的less是定义良好的。
sort(nameTable.begin(), nameTable.end(), less<string*>());
关联容器使用
less<key_type>
对元素排序,因此可以定义一个指针的set
或者在map
中使用指针作为关键字而无须直接声明less
。
14.8.3可调用对象与function
C++中有几种可调用的对象:函数、函数指针、lambda表达式、
bind
创建的对象以及重载了函数调用运算符的类。
和其他对象一样,可调用的对象也有类型。然而,两个不同类型的可调用对象却可能共享同一种调用形式
不同类型可能具有相同的调用形式
// 普通函数
int add(int i, int j) { return i + j; }
// Lambda,其产生一个未命名的函数对象类。
auto mod = [](int i, int j) { return i % j; };
// 函数对象类
struct divide {
int operator()(int denominator, int divisor) {
return denominator / divisor;
}
};
// 尽管类型各不相同,但是共享同一种调用形式:
int(int, int)
// 可能希望使用这些可调用对象构建一个简单的桌面计算器。为了实现这一目的,
// 需要通过map定义一个函数表:
// 构建从运算符到函数指针的映射关系,其中函数接受两个int,返回一个int。
map<string, int(*)(int, int)> binops;
// 可以将add指针添加到binops中:
binops.insert({"+", add});
// 但是不能将mod或者divide存入binops,问题在于mod是个lambda表达式,而每个
// lambda有它自己的类类型,该类型与存储在binops中的值的类型不匹配。
标准库function类型
可以使用一个名为
function
的新的标准库类型解决上述问题,定义在functional
头文件中。
function
是一个模板,需要提供能够表示的对象的调用形式:
function<int(int, int)>
function<int(int, int)> f1 = add; // 函数指针
function<int(int, int)> f2 = divide(); // 函数对象类的对象
function<int(int, int)> f3 = [](int i, int j) { return i % j; }; // Lambda
cout << f1(4, 2) << endl; // 6
cout << f2(4, 2) << endl; // 2
cout << f3(4, 2) << endl; // 8
// 因此可以重新定义map:
map<string, function<int(int, int)>> binops = {
{"+", add},
{"-", std::minus<int>()},
{"/", divide()},
{"*", [](int i, int j) { return i * j; }},
{"%", mod}
};
// function类型重载了调用运算符,该运算符接受它自己的实参,然后
// 将其传递给存好的可调用对象:
binops["+"](10, 5);
binops["-"](10, 5);
binops["/"](10, 5);
binops["*"](10, 5);
binops["%"](10, 5);
重载的函数与function
不能(直接)将重载函数的名字存入
function
类型的对象中:
int add(int i, int j) { return i + j; }
Sales_data add(const Sales_data &, const Sales_data &);
map<string, function<int(int, int)>> binops;
binops.insert({"+", add}); // 错误:哪个add?
解决问题的一条途径是存储函数指针而非函数的名字:
int (*fp)(int, int) = add;
binops.insert({"+", fp});
同时,也能使用lambda来消除二义性:
binops.insert({"+", [](int a, int b) { return add(a, b); }});
14.9重载、类型转换与运算符
14.9.1类型转换运算符
类型转换运算符是类的一种特殊成员函数,它负责将一个类类型的值转换成其他类型。
// type表示某种类型
operator type() const;
类型转换运算符可以面向任意类型(除了
void
之外)进行定义,只要该类型能作为函数的返回类型。因此,不允许转换成数组或者函数类型,但允许转换成指针或者引用类型。
通常不应该改变待转换对象的内容,因此,类型转换运算符一般被定义成const
成员。
定义含有类型转换运算符的类
class SmallInt {
public:
// 定义了向类类型的转换
SmallInt(int i = 0) : val(i) {
if (i < 0 || i > 255) {
throw std::out_of_range("Bad");
}
}
// 定义从类类型向其他类型的转换
operator int() const {
return val;
}
private:
std::size_t val;
};
SmallInt si;
si = 4; // 首先将4隐式地转换成SmallInt,然后调用SmallInt::operator=。
si + 3; // 首先将si隐式地转换成int,然后执行整数的加法。
尽管编译器一次只能执行一个用户定义的类型转换,但是隐式的用户定义类型转换可以置于一个标准(内置)类型转换之前或之后,并与其一起使用。
// 内置类型转换将double实参转换成int
SmallInt si = 3.14; // 调用SmallInt(int)构造函数
// SmallInt的类型转换运算符将si转换成int
si + 3.14; // 内置类型转换将所得的int继续转换成double
明智地使用类型转换运算符。
类型转换运算符可能产生意外结果
在实践中,类很少提供类型转换运算符。在大多数情况下,如果类型转换自动发生,用户可能会感觉比较意外,而不是感觉受到了帮助。然而,定义向
bool
的类型转换还是比较普遍的现象。
在c++标准的早起版本中,如果类想定义一个向bool
的类型转换,则会遇到一个问题:因为bool
是一种算术类型,所以类类型的对象转换成bool
后就能被用在任何需要算术类型的上下文中。
int i = 42;
// istream本身并没有定义<<,所以应该产生错误。然而,该代码能使用istream的bool
// 类型转换运算符将cin转换成bool,而这个bool值接着会被提升成int并用作内置的左移
// 运算符的左侧运算对象。
cin << i;
显式的类型转换运算符
C++11引入了显式的类型转换运算符:
class SmallInt {
public:
// 编译器不会自动执行这一类型转换
explicit operator int() const {
return val;
}
// 其他成员保持一致
};
编译器(通常)也不会将一个显式的类型转换运算符用于隐式类型转换:
SmallInt si = 3; // 正确:SmallInt的构造函数不是显式的。
si + 3; // 错误:此处需要隐式的类型转换,但类的运算符是显式的。
static_cast<int>(si) + 3; // 正确:显式地请求类型转换。
但是也存在例外,即,如果表达式被用作条件,则编译器会将显式的类型转换自动应用于它。
if
、while
及do
语句的条件部分。for
语句头的条件表达式。!
、||
、&&
的运算对象。?:
的条件表达式。
14.9.2避免有二义性的类型转换
如果类中包含一个或多个类型转换,则必须确保在类类型和目标类型之间只存在唯一一种转换方式。否则,编写的代码将很可能具有二义性。
在两种情况下可能产生多重转换路径:
- 两个类提供相同的类型转换:例如,当A类定义了一个接受B类对象的转换构造函数,同时B类定义了一个转换目标是A类的类型转换运算符时,就说它们提供了相同的类型转换。
- 类定义了多个转换规则,而这些转换涉及的类型本身可以通过其他类型转换联系在一起。最典型的例子是算术运算符,对某个给定的类来说,最好只定义最多一个与算术类型有关的转换规则。
实参匹配和相同的类型转换
struct B;
struct A {
A() = default;
A(const B &);
// 其他数据成员
};
struct B {
operator A() const;
// 其他数据成员
};
A f(const A &);
B b;
// 二义性错误:含义是f(B::operator A())还是f(A::A(const B &))?
A a = f(b);
// 如果确实想执行上述调用,就不得不显式地调用类型转换运算符或者转换构造函数:
A a1 = f(b.operator A());
A a2 = f(A(b));
值得注意的是,无法使用强制类型转换来解决二义性问题,因为强制类型转换本身也面临二义性。
二义性与转换目标为内置类型的多重类型转换
如果类定义了一组类型转换,它们的转换源(或者转换目标)类型本身可以通过其他类型转换联系在一起,则同样会产生二义性的问题。
struct A {
A(int = 0); // 最好不要创建两个转换源都是算术类型的类型转换
A(double);
operator int() const; // 最好不要创建两个转换对象都是算术类型的类型转换
operator double() const;
// 其他成员
};
// 哪个类型转换都无法精确匹配long double
void f2(long double);
A a;
// 二义性错误:含义是f(A::operator int())还是f(A::operator double())?
f2(a);
long lg;
// 二义性错误:含义是A::A(int)还是A::A(double)?
A a2(lg);
当使用用户定义的类型转换时,如果转换过程包含标准类型转换,则标准类型转换的级别将决定编译器选择最佳匹配的过程:
short s = 42;
// 把short提升成int优于把short转换成double
A a3(s); // 使用A::A(int)
要想正确地设计类的重载运算符、转换构造函数及类型转换函数,必须加倍小心。尤其是当类同时定义了类型转换运算符及重载运算符时特别容易产生二义性。相关的经验规则:
- 不要令两个类执行相同的类型转换:如果
Foo
类有一个接受Bar
类对象的构造函数,则不要在Bar
类中再定义转换目标是Foo
类的类型转换运算符。- 避免转换目标是内置算术类型的类型转换。特别是当已经定义了一个转换成算术类型的类型转换时,接下来:
- 不要再定义接受算术类型的重载运算符。如果用户需要使用这样的运算符,则类型转换操作将转换你的类型的对象,然后使用内置的运算符。
- 不要定义转换到多种算术类型的类型转换。让标准类型转换完成向其他算术类型转换的工作。
因此,除了显式地向
bool
类型的转换之外,应该尽量避免定义类型转换函数并尽可能地限制那些"显然正确"的非显式构造函数。
重载函数与转换构造函数
当调用重载的函数时,从多个类型转换中进行选择将变得更加复杂。如果两个或多个类型转换都提供了同一种可行匹配,则这些类型转换一样好。
当几个重载函数的参数分属不同的类类型时,如果这些类恰好定义了同样的转换构造函数,则二义性问题将进一步提升:
struct C {
C(int);
// 其他成员
};
struct D {
D(int);
// 其他成员
};
void manip(const C &);
void manip(const D &);
// 二义性错误:含义是manip(C(10))还是manip(D(10))。
manip(10);
// 可以显式地构造正确的类型从而消除二义性:
manip(C(10));
如果在调用重载函数时需要使用构造函数或者强制类型转换来改变实参的类型,则这通常意味着程序的设计存在不足。
重载函数与用户定义的类型转换
当调用重载函数时,如果两个(或多个)用户定义的类型转换都提供了可行匹配,则认为这些类型转换一样好。在这个过程中,不会考虑任何可能出现的标准类型转换的级别。只有当重载函数能通过同一个类型转换函数得到匹配时,才会考虑其中出现的标准类型转换。
struct E {
E(double);
// 其他成员
};
void manip2(const C &);
void manip2(const E &);
// 二义性错误:两个不同的用户定义的类型转换都能用在此处。
manip2(10); // 含义是manip2(C(10))还是manip2(E(double(10)))
14.9.3函数匹配与重载运算符
重载的运算符也是重载的函数。因此,通用的函数匹配规则同样适用于判断在给定的表达式中到底应该使用内置运算符还是重载的运算符。不过当运算符函数出现在表达式中时,候选函数集的规模要比使用调用运算符调用函数时更大。
如果a
是一种类类型,则表达式a sym b
可能是:
a.operatorsym(b); // a有一个operatorsym成员函数
operatorsym(a, b); // operatorsym是一个普通函数
和普通函数调用不同,不能通过调用的形式来区分当前调用的是成员函数还是非成员函数。
当使用重载运算符作用于类类型的运算对象时,候选函数中包含该运算符的普通非成员版本和内置版本。除此之外,如果左侧运算对象是类类型,则定义在该类中的运算符的重载版本也包含在候选函数内。
当调用一个命名的函数时,具有该名字的成员函数和非成员函数不会彼此重载,这是因为用来调用命名函数的语法形式对于成员函数和非成员函数来说是不相同的。当通过类类型的对象(或者该对象的指针及引用)进行函数调用时,只考虑该类的成员函数。而当在表达式中使用重载的运算符时,无法判断正在使用的是成员函数还是非成员函数,因此二者都应该在考虑的范围内。
class SmallInt {
friend SmallInt operator+(const SmallInt &, const SmallInt &);
public:
SmallInt(int = 0); // 转换源为int的类型转换
operator int() const {
return val; // 转换目标为int的类型转换
}
private:
std::size_t val;
};
// 可以使用这个类将两个SmallInt对象相加,但如果试图执行混合模式的算术运算,
// 就将遇到二义性的问题:
SmallInt s1, s2;
SmallInt s3 = s1 + s2; // 使用重载的operator+
int i = s3 + 0; // 二义性错误