C++ Primer 笔记 第十四章
C++ 允许用户通过运算符重载(Operator Overloading)为类对象定义运算,以及相关的类型转换。
重载的运算符实际上是有特殊名字的函数。该函数的名称由两部分组成:关键字 operator
与要重载的运算符符号。该函数拥有返回值,参数列表和函数体。
重载的运算符函数有几个特点:
this
。重载运算符有两种形式:是成员函数,或者参数列表中至少包含一个 class type 的参数 比如下方这样的写法就是错误的:
// error: cannot redefine the built-in operator for ints
int operator+(int, int);
+
,-
,*
,&
的一元 / 二元版本均可被重载由于重载的运算符是函数,因此该重载存在使用符号调用,或者直接调用的两种方式:
// equivalent calls to a nonmember operator function
data1 + data2; // normal expression
operator+(data1, data2); // equivalent function call
//member function version
data1 += data2; // expression-based ''call''
data1.operator+=(data2); // equivalent call to a member operator function
如果默认的运算符拥有以下的特性:
则这种运算符不应该被重载。比如 &&
和 ||
,重载该运算符会导致其失去 short-circuit evaluation 的特性;而对于 ,
,&
(取地址)等操作符,C++ 已经提供了这些运算符对类的操作,因此不需要进行重载。
在设计类的运算时,需要考虑两点:
比如以下常见的,保证一致性例子:
==
与 !=
<
。而重载 <
意味着应该重载其他所有的关系运算符。需要注意的是,这种一致性也包括返回值;也就是说:
bool
类型总的来说,重载运算符应该基于默认运算符的意义进行设计。我们可以延伸,映射该意义到类对象上,但不能违背已有的意义。正确的使用重载可以使程序变得更加直观,而滥用只会造成更多的歧义。
赋值运算符的设计应该与合成的版本类似:赋值之后,左右的值应该相等,且运算应该返回左边对象的引用。如果类中定义了算数运算或者位运算,则我们应该提供对应的复合赋值运算。
重载的运算符函数是否应该是成员函数取决于该运算符的特性:
=
,下标 []
,调用 ()
,成员访问 →
++
,自减 –
,解引用 *
值得提出的是,当重载 Symmetric operators 的时候,需要考虑类型转换的问题。这是因为 Symmetric operators 并不强制算子的位置:通常在 Symmetric operators 出现的运算中,左算子与右算子是可以互换位置的。
另外,如果某个运算符被重载为了成员函数,则其算子的位置就固定下来了。这里特指左算子,必须是该重载运算符所在类的对象:
string s = "world";
string t = s + "!"; // ok: we can add a const char* to a string
string u = "hi" + s; // would be an error if + were a member of string
输出运算符的重载对参数与返回值有如下的要求:
ostream
对象的引用:ostream
对象无法被拷贝ostream
对象的引用:这么设计是为了维护与默认输出运算符的一致性Sales_data 类的例子如下:
ostream &operator<<(ostream &os, const Sales_data &item)
{
os << item.isbn() << " " << item.units_sold << " "
<< item.revenue << " " << item.avg_price();
return os;
}
输出运算符通常不会对输出的内容进行格式化,尤其是换行的操作。该操作最好交由使用者来实现。
重载的输入输出运算符行必须是非成员函数。这是因为当重载运算符被定义为成员函数时,左边的算子必须是对应类的对象。如果不做调整,那么 istream
或 ostream
对象就会被放到右边:
Sales_data data;
data << cout; // if operator<< is a member of Sales_data
若要将 istream
或 ostream
对象放到运算符的左边,则重载运算符必须是 istream
或 ostream
类的成员函数。而 istream
或 ostream
类属于标准库的定义,我们不能为其添加成员函数。因此,输入输出重载运算符必须被设计为非成员函数。
重载的输入运算符对参数以及返回值的要求是:
istream
对象的 non-const 引用istream
对象的 non-const 引用,用于维护输出流的一致性与重载的输出运算符最大的区别是,重载的输入运算符需要检测输入的结果是否合法。检测从以下几个方面入手:
fail
flag 是否发生了变化以Sales_data 类为例,重载输入的实现如下:
istream &operator>>(istream &is, Sales_data &item)
{
double price; // no need to initialize; we'll read into price before we use it
is >> item.bookNo >> item.units_sold >> price;
if (is) // check that the inputs succeeded
item.revenue = item.units_sold * price;
else
item = Sales_data(); // input failed: give the object the default state
return is;
}
之所以要等到所有输入完成以后进行检测,是需要确保所有的输入值都是有效的;单独检查 fail
位是否正常,是因为 fail
位意味着输入出错但 stream 还在正常运行;如果是 badbit
或者 eofbit
这些由 stream 本身带来的问题,应该交由 stream 自身进行判定。当输入出错时,意味着当前类对象接收的内容很可能已经被破坏了;这时给与其一个正常的默认值可以保证其内部数据的完整性。
除此之外,很多时候输入运算符还需要进行额外的检测。比如上例中的 bookNo
,我们应该检测其输入格式是否合法。
算术运算符与关系运算符的设计要点如下:
const T&
类型,因为不会改变算子的状态
除此之外,使用复合运算符实现对应的算术运算符效率会更高:比如下例中,Sale_data 中的 +
运算重载实现:
// assumes that both objects refer to the same book
Sales_data
operator+(const Sales_data &lhs, const Sales_data &rhs)
{
Sales_data sum = lhs; // copy data members from lhs into sum
sum += rhs; // add rhs into sum
return sum;
}
重载等价运算符 ==
主要目的在于测试两个对象是否等价。通常,如果两个对象中所有对应的数据成员都相等,则该两个对象会被视作等价。比如 Sales_data 类的等价实现:
bool operator==(const Sales_data &lhs, const Sales_data &rhs)
{
return lhs.isbn() == rhs.isbn() &&
lhs.units_sold == rhs.units_sold &&
lhs.revenue == rhs.revenue;
}
bool operator!=(const Sales_data &lhs, const Sales_data &rhs)
{
return !(lhs == rhs);
}
等价运算的重载需要遵循以下的原则:
==
重载比新定义比较函数更好。这样做可以避免学习新的函数,也方便该类在某些算法和容器中的使用。a==b
,b==c
可以得知 a==c
。==
,该运算应该判断给定的对象中是否存在等价的数据。==
应该与 !=
成对出现,且后实现的操作应该基于先实现的操作实现;比如 ==
先实现,则 !=
可以用 ! (obj1==obj2)
的方式实现。
关系运算符在某些需要进行排序的容器和算法中很有用,特别是 <
。关系运算符的定义应该满足两个条件:
!=
意味着参与比较的对象有一个需要 <
另外一个第二点在评估是否需要定义关系运算符上尤为重要。以 Sales_data 类举例,Sales_data 类不应该实现关系运算符:
isbn
,revenue
, units_sold
三个成员来决定两个 Sales_data 类对象是否相等isbn
对 Sales_data 对象进行排序,当 isbn
相等,而 units_sold
或 revenue
不相等的时候:==
判定)<
求反得出此判定),得出两个对象相等的结论
由上可以看出,<
的定义得出的结果与 ==
并没有保持一致,因此这种情况下我们不应该定义关系运算符。
只有在单一的排序逻辑存在,并且该排序逻辑与 == 可以得出一致结果的时候,才可以进行关系运算符的重载。
除了之前提到过得赋值运算符的拷贝 / 移动形式,赋值运算符还允许将不同类型的数据赋予当前对象。一个重要的例子是 initializer_list:比如 vector 就可以使用初始化列表进行赋值:
vector<string> v;
v = {"a", "an", "the"};
这种用法下的赋值运算符重载需要遵循以下要求:
this
指代的对象不同,无法自我赋值下面是 StrVec 类中使用初始化列表赋值的赋值运算符实现(拷贝版本):
StrVec &StrVec::operator=(initializer_list<string> il)
{
// alloc_n_copy allocates space and copies elements from the given range
auto data = alloc_n_copy(il.begin(), il.end());
free(); // destroy the elements in this object and free the space
elements = data.first; // update data members to point to the new space
first_free = cap = data.second;
return *this;
}
书中推荐将复合赋值运算符与赋值运算符一起定义为成员函数,并返回左算子的引用。下面是 Sales_data 类中复合赋值运算符的实现:
// member binary operator: left-hand operand is bound to the implicit this pointer
// assumes that both objects refer to the same book
Sales_data& Sales_data::operator+=(const Sales_data &rhs)
{
units_sold += rhs.units_sold;
revenue += rhs.revenue;
return *this;
}
如果某个类是容器类,并且该容器支持通过位置获取元素,则我们应该为其重载下标运算符(Subscript operator)[]
。下标运算符的重载需要遵循以下规则:
下面是 StrVec 的下标操作符实现:
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]; }
// other members as in § 13.5 (p. 526)
private:
std::string *elements; // pointer to the first element in the array
};
使用的时候需要确保下标访问的元素不为空(通常这部分验证可以集成到下标操作符的重载中)。除此之外,常量版版本的重载无法进行赋值:
// assume svec is a StrVec
const StrVec cvec = svec; // copy elements from svec into cvec
// if svec has any elements, run the string empty function on the first one
if (svec.size() && svec[0].empty()) {
svec[0] = "zero"; // ok: subscript returns a reference to a string
cvec[0] = "Zip"; // error: subscripting cvec returns a reference to const
}
自增与自减运算符(++
和 –
)的重载大多数是为了迭代器类而实现的。迭代器通过对自身的自增 / 自减,实现了在元素序列中移动的目的。由于自增自减会改变迭代器状态,因此推荐将其声明为成员函数。
自增与自减的重载分为两个版本:前置版本 prefix 与 后置版本 postfix。两种版本有实现上的差异。
前置自增运算符的重载需要满足几个条件:
通常实现上,由于需要判断移位后的结果是否可以被解引用,因此需要检查当前迭代器所在位置。而根据移动方向的不同,自增自减的实现方法也不同。以 StrBlobPtr 为例:
// prefix: return a reference to the incremented/decremented object
StrBlobPtr& StrBlobPtr::operator++()
{
// if curr already points past the end of the container, can't increment it
check(curr, "increment past end of StrBlobPtr");
++curr; // advance the current state
return *this;
}
StrBlobPtr& StrBlobPtr::operator--()
{
// if curr is zero, decrementing it will yield an invalid subscript
--curr; // move the current state back one element
check(curr, "decrement past begin of StrBlobPtr");
return *this;
}
可以看到上面的实现中:
这么做的原因是基于自增 / 自减的结果带来的影响:
0
进行自减会得到一个非常大的正数。
Built-in type 后置自增 / 自减运算符与前置版本最大的一个区别是,后置版本返回的是没有自增之前的对象的状态(以值方式返回);而除此之外并没有其他的区别。这带来一个问题,由于返回值的不同不计入重载的区别,因此前置版本与后置版本的参数数量和类型都是相同的;因此同时定义两个版本会带来二义性。
为此,C++ 定义后置版本会额外的接收一个 int
类型的参数,用于区分前置版本与后置版本。当使用后置版本的时候,编译器默认会传递 0
作为 argument;而前置版本在被调用的时候则没有该 int
参数。需要注意的是,该参数唯一的作用是区分两个版本,因此不应该用于使用。
后置版本的实现可以基于前置版本:
class StrBlobPtr {
public:
// increment and decrement
StrBlobPtr operator++(int); // postfix operators
StrBlobPtr operator--(int);
// other members as before
};
// postfix: increment/decrement the object but return the unchanged value
StrBlobPtr StrBlobPtr::operator++(int)
{
// no check needed here; the call to prefix increment will do the check
StrBlobPtr ret = *this; // save the current value
++*this; // advance one element; prefix ++ checks the increment
return ret; // return the saved state
}
StrBlobPtr StrBlobPtr::operator--(int)
{
// no check needed here; the call to prefix decrement will do the check
StrBlobPtr ret = *this; // save the current value
--*this; // move backward one element; prefix -- checks the decrement
return ret; // return the saved state
}
可以看出来,后置版本的逻辑中,自增与返回值是分开的:
++*this
这行语句,调用了前置版本以及其附带的元素有效性检查
除此之外,参数中 int
不应该被用于其他地方,因此我们没有给与名字。
自增 / 自减也可以函数的方式调用:
StrBlobPtr p(a1); // p points to the vector inside a1
p.operator++(0); // call postfix operator++
p.operator++(); // call prefix operator++
在这里,参数 0
是作为区分调用版本的唯一标志,因此不能省略。带 0
参数的版本是后置版本。
成员访问运算符包括 *
解引用运算符与 →
箭头运算符,主要用于迭代器类与智能指针类中。比如 StrBlobPtr 可以重载这两个运算符,用于读取当前位置的元素:
class StrBlobPtr {
public:
std::string& operator*() const
{ auto p = check(curr, "dereference past end");
return (*p)[curr]; // (*p) is the vector to which this object points
}
std::string* operator->() const
{ // delegate the real work to the dereference operator
return & this->operator*();
}
// other members as before
};
该实现的几个注意事项:
p
p
解引用 + 下标的组合使用访问指定的元素由于解引用和箭头运算都是访问类型的函数,因此这两个函数都是 const 成员函数。特别需要注意解引用重载返回了普通引用。这是因为我们可以确定 StrBlobPtr 不会与 const StrBlob 绑定(否则是需要加 const 的,不然可以通过返回值修改对象)。
与其他的运算符重载不同,箭头运算符的重载不能重新诠释该运算符的意义。无论如何定义,该运算符始终意味着对类成员的访问。对箭头运算符的重载只能改变被获取的成员。
当使用箭头运算符的时候,箭头的左边必须是一个指向类对象的指针.。比如 point→mem
可以写作如下形式:
(*point).mem; // point is a built-in pointer type
point.operator()->mem; // pint is an object of class type
→
访问成员这两种不同的形式体现出重载箭头运算符拥有类似于递归的行为,即:
也就是说,可以将指针类对象视作中间过程。每使用一次该对象重载的箭头运算符,就会解引用指针对象,并访问当前类对象中的指针成员。得到的指针成员可以用于调用下一个类对象中的指针成员,或是最终的实体成员;而调用的过程也是通过箭头运算符的重载来实现的:
这也解释了为了什么箭头运算符重载的返回值必须是指针的问题。
当一个类重载了函数调用运算符(Function-Call Operator)时,该类的对象可以像函数一样使用。由于类对象可以定义数据成员实现状态的存储,使用起来也较普通函数更为灵活。
一个求绝对值的函数调用重载类的实现如下:
struct absInt {
int operator()(int val) const {
return val < 0 ? -val : val;
}
};
该实现中,我们通过重载函数调用运算,实现了求绝对值的功能。通过调用该类的对象,可以像函数一样得到某个值的绝对值:
nt i = -42;
absInt absObj; // object that has a function-call operator
int ui = absObj(i); // passes i to absObj.operator()
需要注意的是,函数调用的重载需要被定义为成员函数。该重载可以有多个版本。版本通过参数的数量与类型进行区分。由于函数调用的重载通过类对象实现了函数的功能,因此我们称该类对象为函数对象(Function Object)。
之前提到,函数对象类可以通过额外的数据成员来管理函数的状态。下面是一个打印带自定义分隔字符 string 的例子,其实现中使用了额外的数据成员存储自定义分隔字符:
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; // stream on which to write
char sep; // character to print after each output
};
重载的函数调用运算符则负责输出获取的 string,以及自定义的分隔字符。调用的时候,只需要使用 PrintString 类对象读取 string 进行打印即可:
PrintString printer; // uses the defaults; prints to cout
printer(s); // prints s followed by a space on cout
PrintString errors(cerr, '\n');
errors(s); // prints s followed by a newline on cerr
该类函数对象也可以用于泛型算法。比如将容器 vs
的所有元素以 cerr
加换行进行输出:
for_each(vs.begin(), vs.end(), PrintString(cerr, '\n'));
需要注意这里的类对象是使用构造函数创建的临时对象。每个临时对象会对应每个元素,并将其打印出来。这里需要与一般的函数对象调用进行区别。
函数对象的使用与 lambda 类似。实际上,lambda 在使用中被编译器转换为了一个无名类的无名对象。该对象也是通过函数调用运算符的重载来实现功能的。比如下面的 lambda 实际上与 ShorterString 类等价:
// sort words by size, but maintain alphabetical order for words of the same size
stable_sort(words.begin(), words.end(),
[](const string &a, const string &b)
{ return a.size() < b.size();});
//equivalent to the lambda above
class ShorterString {
public:
bool operator()(const string &s1, const string &s2) const
{ return s1.size() < s2.size(); }
};
需要注意的是,lambda 不会改变捕获的变量。这一点体现到函数调用运算符中则意味着该运算符的重载应该是 const 成员函数(mutable
lambda 则对应 non-const 成员函数)。在算法中使用该函数对象与之前的方式一致,第三个参数时一个临时的,新创建的函数对象:
stable_sort(words.begin(), words.end(), ShorterString());
lambda 分为引用捕获与值捕获。对应的函数对象的实现中,这两种捕获被进行了区分处理:
一个值捕获 lambda 转换为函数对象的例子:
// get an iterator to the first element whose size() is >= sz
auto wc = find_if(words.begin(), words.end(),
[sz](const string &a)
其对应函数对象的实现为:
class SizeComp {
SizeComp(size_t n): sz(n) { } // parameter for each captured variable
// call operator with the same return type, parameters, and body as the lambda
bool operator()(const string &s) const
{ return s.size() >= sz; }
private:
size_t sz; // a data member for each variable captured by value
};
这里,捕获列表中的变量以私有数据成员的形式存储到了函数对象 SizeComp 中。此外,函数对象还需要一个指定的构造函数提供接口对其进行赋值:
// get an iterator to the first element whose size() is >= sz
auto wc = find_if(words.begin(), words.end(), SizeComp(sz));
为什么不使用合成的默认构造函数?
标准库定义了一系列的函数对象,用于表示算术 / 关系 / 逻辑运算符。这些函数对象定义于 <functional>
头文件中:
这些函数对象的特点是:
一些使用例子:
plus<int> intAdd; // function object that can add two int values
negate<int> intNegate; // function object that can negate an int value
// uses intAdd::operator(int, int) to add 10 and 20
int sum = intAdd(10, 20); // equivalent to sum = 30
sum = intNegate(intAdd(10, 20)); // equivalent to sum = -30
// uses intNegate::operator(int) to generate -10 as the second parameter
// to intAdd::operator(int, int)
sum = intAdd(10, intNegate(10)); // sum = 0
标准库函数对象通常与算法搭配使用,用于复写算法中默认的运算符。比如 std::sort
默认的排序规则 <
可以用 greater
复写:
// passes a temporary function object that applies the < operator to two strings
sort(svec.begin(), svec.end(), greater<string>());
通过该复写,svec
中的 string 会按降序排列。
标准库函数对象有一个很重要的特性:这些对象代表的运算可以正确的应用到指针上。通常情况下,不同容器的指针进行运算的结果是未定义的;但通过使用标准库函数对象,我们仍然可以对这些指针进行运算。比如现在有一个 vector<string*>
,而我们希望按照指针的地址对 vector 中的元素进行排序:
vector<string *> nameTable;
// ok: library guarantees that less on pointer types is well defined
sort(nameTable.begin(), nameTable.end(), less<string*>());
而如果使用自定义的 lambda,则无法进行排序:
// error: the pointers in nameTable are unrelated, so < is undefined
sort(nameTable.begin(), nameTable.end(),
[](string *a, string *b) { return a < b; });
实际上,关系容器中的排序就是通过标准库函数对象 std::less<key_type>
实现的。因此,即便关系容器的 key 是指针类型,也可以进行排序。
C++ 中提供了如下几种可调用对象:
实现中,上述的可调用对象在具体的类型上可能有一些差异。但同时,这些可调用对象可能具有相同的 Call Signature,也就是:
比如下面的可调用对象,他们的 Call Signature 都是 int(int,int)
:
// ordinary function
int add(int i, int j) { return i + j; }
// lambda, which generates an unnamed function-object class
auto mod = [](int i, int j) { return i % j; };
// function-object class
struct divide {
int operator()(int denominator, int divisor) {
return denominator / divisor;
}
};
假设我们希望将上述的各种可调用对象组合起来做一个 int 的计算器。一个比较好的方法是通过建立函数表(Function Table),来管理这些可调用对象。使用 map 可以很容易的实现该表:
// maps an operator to a pointer to a function taking two ints and returning an int
map<string, int(*)(int,int)> binops;
上述的 map 中,key 的类型是 string,代表对应运算的符号;value 的类型是函数名(指针),代表了具体的可调用对象。当调用指定可调用对象时,只需要用下标访问对应的函数名既可。上面的例子中有一个问题:该 map 无法存储 lambda 类型的可调用对象:
binops.insert({"%", mod}); // error: mod is not a pointer to function
这是因为 int(*)(int,int)
这样的指针是具体的类型,并不适用于 lambda。lambda 返回的是一个无名的类类型,因此不能直接将 lambda 存储到该 map 中。function
类型来解决这个问题。该类型实现于 <functional>
头文件中,基本的操作如下:
<img src=“/_media/programming/cpp/cpp_primer/operations_on_function.svg” width=“600”>
</html>
与具体类型不同,标准function
类型是一种模板类型,按照 Call Signature 来创建类型。比如之前的 int(int,int)
可以声明为:
function<int(int, int)>
也就是说,所有的,有两个 int
参数 与 int
返回值的对象,都可以用上述的 function
类型表示。比如:
function<int(int, int)> f1 = add; // function pointer
function<int(int, int)> f2 = divide(); // object of a function-object class
function<int(int, int)> f3 = [](int i, int j) // lambda
{ return i * j; };
cout << f1(4,2) << endl; // prints 6
cout << f2(4,2) << endl; // prints 2
cout << f3(4,2) << endl; // prints 8
因此,将 function
类型用于 map 中,可以避免之前的问题:
// table of callable objects corresponding to each binary operator
// all the callables must take two ints and return an int
// an element can be a function pointer, function object, or lambda
map<string, function<int(int, int)>> binops = {
{"+", add}, // function pointer
{"-", std::minus<int>()}, // library function object
{"/", divide()}, // user-defined function object
{"*", [](int i, int j) { return i * j; }}, // unnamed lambda
{"%", mod} }; // named lambda object
由于 function
类型实际上是通过重载函数调用运算符实现的,因此调用的时候需要提供对应的参数:
binops["+"](10, 5); // calls add(10, 5)
binops["-"](10, 5); // uses the call operator of the minus<int> object
binops["/"](10, 5); // uses the call operator of the divide object
binops["*"](10, 5); // calls the lambda function object
binops["%"](10, 5); // calls the lambda function object
当程序中存在函数的重载时,不能将函数名作为 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} ); // error: which add?
这种情况下有两种办法:
function
类型的参数function
类型的参数
int (*fp)(int,int) = add; // pointer to the version of add that takes two ints
binops.insert( {"+", fp} ); // ok: fp points to the right version of add
// ok: use a lambda to disambiguate which version of add we want to use
binops.insert( {"+", [](int a, int b) {return add(a, b);} } );
C++ 允许用户通过重载类型转换运算符的方式自定义 class-type 与 其他类型的转换。这类转换被称为类类型转换(class-type conversions),也被称为用户自定义转换(user-defined converstions)。
类型转换运算符(conversion operator)负责将 class 类型的值转换为其他类型的值。该运算符有几个特点:
array
和 function
类型的类型转换void
,且需要满足表达式的条件其定义写法如下:
operator type() const;
其中 type
为需要转换的类型。
一个简单的类类型与 int
的类型转换定义如下:
class SmallInt {
public:
SmallInt(int i = 0): val(i)
{
if (i < 0 || i > 255)
throw std::out_of_range("Bad SmallInt value");
}
operator int() const { return val; }
private:
std::size_t val;
};
在该定义中,任意的算术类型可以通过构造函数转换为 SmallInt
类类型;而 SmallInt
类型可以通过重载的运算符转换为 int
类型:
SmallInt si;
si = 4; // implicitly converts 4 to SmallInt then calls SmallInt::operator=
si + 3; // implicitly converts si to int followed by integer addition
除此之外用户自定义的类型转换结果用于计算时也可能会发生隐式转换:
// the double argument is converted to int using the built-in conversion
SmallInt si = 3.14; // calls the SmallInt(int) constructor
// the SmallInt conversion operator converts si to int;
si + 3.14; // that int is converted to double using the built-in conversion
上述例子中,3.14
被隐式转换成了 int
,再通过自定义转换为了 SmallInt
。输出的时候,si
首先被转换为了 int
,之后与 3.14
相加的结果会隐式转换为 double
。
重载类型转换运算符有一个前提:class-type 与 conversion type 之间需要存在一一映射的关系(single mapping)。
也就是说,转换只能在没有歧义的对应关系中发生。比如类类型 date
与 int
。假设一个 date
的对象记做 1900-1-10
,那么可以找出很多种基于 int
的表达方式:比如记做 190011
,也比如说可以记做从某个时间点开始时经过的天数;此时的转换是存在歧义的。
当我们可以找出不止一种,但又是合理的表达方式的时候,类型转换运算符是不应该被重载的。这种情况下,我们应该定义不同的成员函数来提取不同的信息。
在类中定义类类型到 bool
类型的转换是比较常见的。但在早期的 C++ 版本中,这样的转换(隐式转换)通常会带来一些问题。比如下面的例子:
int i = 42;
cin << i; // this code would be legal if the conversion to bool were not explicit!
假设上面的例子中, istream
中定义了 istream
到 bool
的转换:
cin
并没有定义 «
输出运算符,因此这里 cin
会被转换为 bool
cin
被转化为 bool
,因此这里的 «
被诠释为移位运算符cin
被提升为 int
,并向左位移 42
个位置
为了避免这样的情况发生,C++11 提供了 explicit
关键字。该关键字的功能与应用到构造函数上一致,也是防止隐式转换的发生:
class SmallInt {
public:
// the compiler won't automatically apply this conversion
explicit operator int() const { return val; }
// other members as before
};
重载的类型转换运算符被 explicit
修饰后,只能通过显式的 cast 转换来进行类型转换;否则必须提供匹配的类型:
SmallInt si = 3; // ok: the SmallInt constructor is not explicit
si + 3; // error: implicit is conversion required, but operator int is explicit
static_cast<int>(si) + 3; // ok: explicitly request the conversion
需要注意的是,上述的限制有一个非常重要的例外。当 explicit
修饰的类型转换用于判断条件的时候,该类型转换以隐式转换的方式运作。判断条件特指如下的运算:
if / while /do while
的判断条件for
头部中的判断条件!
/ ||
/ &&
中的判断条件?:
中的判断条件
早期的 C++ 中,IO 类型通过定义到 void*
的类型转换来避免上述的问题。而 C++ 11 中, IO 类型则定义了到 bool
的 explicit
转换来取代该写法。因此,当我们使用 stream 对象作为判断条件时,都会使用到该转换。比如:
while (std::cin >> value)
此处的 std::cin
被转换为成了 bool
。该值为 true
意味着当前 std::cin
对象状态正常(good
)。
定义类类型到 bool 类型的转换通常是为了使用类类型作为判断条件。因此最好将该重载定义为 explicit 类型。
如果类中存在多个类型转换时,需要确保 class-type 与目标类型之间的转换是唯一的。如果对同一个类型的转换存在多种方式,很可能会导致潜在的二义性错误。有两个比较典型的例子:
下面的代码是一个交互转换导致二义性的例子:
// usually a bad idea to have mutual conversions between two class types
struct B;
struct A {
A() = default;
A(const B&); // converts a B to an A
// other members
};
struct B {
operator A() const; // also converts a B to an A
// other members
};
A f(const A&);
B b;
A a = f(b); // error ambiguous: f(B::operator A())
// or f(A::A(const B&))
该段代码中:
当调用 A a = f(b)
时,第一步是要将 B 类型的 参数 b
转换为 A 类型。而此时编译器无法确定是使用 类 A 中的转换方式还是使用 类 B 中的转换方式,因此导致了二义性。这种情况下,必须显式的指定转换的方式,才能进行正确的调用,比如:
A a1 = f(b.operator A()); // ok: use B's conversion operator
A a2 = f(A(b)); // ok: use A's constructor
转换方式不唯一导致二义性通常发生在类类型到算术类型的转换过程中。比如下面的例子:
struct A {
A(int = 0); // usually a bad idea to have two
A(double); // conversions from arithmetic types
operator int() const; // usually a bad idea to have two
operator double() const; // conversions to arithmetic types
// other members
};
void f2(long double);
A a;
f2(a); // error ambiguous: f(A::operator int())
// or f(A::operator double())
long lg;
A a2(lg); // error ambiguous: A::A(int) or A::A(double)
上例中,类 A 中定义了:
这样的设计会导致以下的调用出现二义性:
long double
类型的参数。而 A 中并没有定义直接转换为 long double
的转换。此时,我们既可以通过先转换为 int
再转换为 long double
,又可以通过先转换为 double
再转换为 long double
的方式完成上述的转换。因此,编译器无法决定选择哪种方式,二义性产生。long
类型构造 A 类型对象时,由于 A 中存在两种使用算术类型的构造方式,编译器同样无法做出决定。可以发现的是,在上述的类型转换过程中,编译器是通过最佳匹配的方式来选择合适的转换方式的。由于上述的转换都是处于算术类型转换的等级,因此编译器无法识别哪种方式更匹配。只要转换方式中存在不同的转换等级,那么二义性就不会发生。比如:
short s = 42;
// promoting short to int is better than converting short to double
A a3(s); // uses A::A(int)
这里的参数类型是 short
。 在转换的过程中,short
到 int
发生了整型提升的类型转换。这一类型转换的等级高于算术类型的转换,因此接收 int
的构造函数是最优选择。
当存在多个用户自定义的转换时,如果转换之前 / 之后存在标准类型的转换,则使用最佳匹配的策略来决定使用哪个转换。
正确设计自定义类型转换的几个准则:
总而言之,除了 bool
类型的显式转换以外,我们应该尽量避免自定义类型转换,并且尽可能的限制隐式转换在转换构造函数中的使用。
除了以上的两种情况以外,如果不同的类中同时定义了到某个目标类型的转换,那么这些转换会被视为具有同等的优先级。比如下面的例子:
struct C {
C(int);
// other members
};
struct D {
D(int);
// other members
};
void manip(const C&);
void manip(const D&);
manip(10); // error ambiguous: manip(C(10)) or manip(D(10))
在这个例子中,由于 C 和 D 都定义了转换到 int
的类型转换,因此在调用的时候,编译器无法确定使用哪一个转换来得到 int
参数。这种情况下只能通过显式的指定转换方式来构造对象:
manip(C(10)); // ok: calls manip(const C&)
如果存在需要对参数进行类型转换的构造函数,那么说明程序的设计存在不足。
需要注意的是,当同时存在两个或以上的最佳自定义类型转换方案时,整个转换过程中可能会出现的标准转换将不再作为选择转换方案的参考依据。具体来讲,只有当使用同一个自定义转换方案的时候,标准转换会被纳入考虑的范围(参考第二种二义性的情况)。比如下面的例子:
struct E {
E(double);
// other members
};
void manip2(const C&);
void manip2(const E&);
// error ambiguous: two different user-defined conversions could be used
manip2(10); // manip2(C(10) or manip2(E(double(10)))
[
这种情况下,虽然 E 默认使用 double
,而 C 使用的是 int
进行构造。由于 manip2
的重载需要使用不同的自定义类型转换,此处不考虑标准转换。因此,E 和 C 都可以将 10
最终转化为对应的类对象;而这将导致编译器无法确定哪一个 manip2
版本是最佳匹配的版本。
重载的运算符实际上是重载的函数。但与一般的重载函数的匹配规则不同,当重载运算符用于表达式中,比起一般使用 ()
调用函数的方式,其候选函数的范围会更大。
需要注意的是,即便运算符重载可以写成如下的函数形式:
a.operatorsym (b); // a has operatorsym as a member function
operatorsym(a, b); // operatorsym is an ordinary function
编译器也不能根据该形式的不同决定调用成员或者非成员函数。
当某个表达式中,如果参与运算的算子包含 class-type,且该 class-type 中重载了此运算,则:
在使用的过程中:
来看下面的例子:
class SmallInt {
friend
SmallInt operator+(const SmallInt&, const SmallInt&);
public:
SmallInt(int = 0); // conversion from int
operator int() const { return val; } // conversion to int
private:
std::size_t val;
};
我们对其进行如下的调用:
SmallInt s1, s2;
SmallInt s3 = s1 + s2; // uses overloaded operator+
int i = s3 + 0; // error: ambiguous
s3 = s1 + s2
的调用可以精确的匹配:
SmallInt operator+(const SmallInt&, const SmallInt&);
而 s3 + 0
的调用中:
+
同时涉及 class-type 与重载运算符,且左算子 s3
为 class-type,因此候选函数为所有的 operator+
(包括成员,非成员,built-in)+
没有重载 SmallInt
与 int
的版本,因此需要将其中一个算子进行转化SamllInt
与 int
可以相互转化,因此候选函数有两种:
//non-member version, int to SmallInt
SmallInt operator+(const SmallInt&, const SmallInt&);
////built-in version, SamllInt to int
int operator(int, int);
s3
到 int
类型,从而调用 Bulit-in 类型的运算符;或是通过转换 0
到 SmallInt
类型,从而调用非成员版本的运算符。由于这两者都是最佳候选,因此我们会得到二义性错误。如果类中同时存在 class-type 到算术类型的转换与运算符的重载,那么很可能会导致重载运算符与 built-in 运算符之间的二义性。