第十四章 操作重载与类型转换
基本概念
重载的运算符是具有特殊名字的函数:它们的名字由关键字operator和其后要定义的运算符号共同组成。和其他函数一样,重载的运算符也包含返回类型、参数列表以及函数体。
重载运算符函数的参数数量与该运算符作用的运算对象数量一样多。一元运算符有一个参数,二元运算符有两个。对于二元运算符来说,左侧运算对象传递给第一个参数,右侧运算对象传递给第二个参数。
如果一个运算符函数是成员函数,则它的第一个(左侧)运算对象绑定到隐式的this指针上,因此,成员运算符函数的参数数量比运算符的运算对象总数少一个。
p491列出了可以重载的运算符。
直接调用一个重载的运算符函数
我们能像调用普通函数一样直接调用运算符函数:
data1 + data2; // 普通的表达式
operator+(data1, data2); // 等价的函数调用
data1 += data2; // 基于“调用”的表达式
data1.operator+=(data2); // 对成员运算符函数的等价调用
某些运算符不应该被重载
-
逻辑与、逻辑或运算符,这些运算符指定了运算对象的求值顺序,又因为使用重载的函数运算符本质上是一次函数调用,那么求值顺序的规则无法得到应用。
-
取地址运算符,逗号运算符,C++语言已定义了其特殊含义,不应该被重载,否则其行为将异于常态。
p492讨论了如何选择重载运算符。
选择作为成员或者非成员
下面的准则有助于选择将运算符定义为成员函数还是普通的非成员函数:
-
赋值(=)、下标([])、调用(())、成员访问箭头(->),必须是成员。
-
复合赋值运算符一般来说应该是成员,但并非必须。
-
改变对象状态的运算符或者与给定类型密切相关的运算符,如递增、递减和解引用运算符,通常应该是成员。
-
具有对称性的运算符可能转换任意一端的运算对象,例如算术、相等性、关系和位运算等,通常应该是普通的非成员函数。
输入和输出运算符
IO标准库分别使用>>和<<执行输入和输出操作,IO库定义了用其读写内置类型的版本,而类则需要自定义适合其对象的新版本以支持IO操作。
重载输出运算符<<
通常情况下,输出运算符的第一个形参是一个非常量ostream对象的引用。是非常量是因为向流写入内容会改变其状态;是引用是因为无法复制一个ostream对象。
第二个形参一般来说是一个常量的引用,它是我们想打印的类类型。是引用是因为我们希望避免复制实参;是常量是因为打印对象不会改变对象的内容。
为了与其他输出运算符保持一致,operator<<一般要返回它的ostream形参。
ostream &operator<<(ostream &os, const Sales_data &item)
{
os << item.isbn() << " " << item.units_sold << " "
<< item.revenue << " " << item.avg_price();
return os;
}
Note
通常,输出运算符应该主要负责打印对象的内容而非控制格式,输出运算符不应该打印换行符。
重载输入运算符>>
通常情况下,输入运算符的第一个形参是运算符将要读取的流的引用,第二个形参是将要读入到的(非常量)对象的引用,返回某个给定流的引用。第二个形参之所以必须是个非常量是因为输入运算符本身的目的就是将输入读入到这个对象中。
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;
}
Note
输入运算符必须处理输入可能失败的情况,而输出运算符不需要。
算术和关系运算符
通常情况下,我们把算术运算符和关系运算符定义成非成员函数以允许对左侧或右侧的运算对象进行转换。因为这些运算符一般不需要改变运算对象的状态,所以形参都是常量的引用。
如果类定义了算术运算符,则它一般也会定义一个对应的复合赋值运算符。此时,最有效的方式是使用复合赋值来定义算术运算符:
Sales_data
operator+(const Sales_data &lhs, const Sales_data &rhs)
{
Sales_data sum = lhs; // 把lhs的数据成员拷贝给sum
sum += rhs; // 把rhs加到sum中
return sum;
}
相等运算符
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);
}
赋值运算符
之前介绍了拷贝赋值和移动赋值运算符,它们可以把类的一个对象赋值给类的另一个对象。此外,类还可以定义其他赋值运算符以使用别的类型作为右侧运算对象。
比如:
vector<string> v;
v = {"a", "b", "c"};
class StrVec {
public:
StrVec& operator=(std::initializer_list<std::string>)
{
// ...
}
};
复合赋值运算符
// 作为成员的二元运算符:左侧运算对象绑定到隐式的this指针
Sales_data& Sales_data::operator+=(const Sales_data &rhs)
{
units_sold += rhs.units_sold;
revenue += rhs.revenue;
return *this;
}
下标运算符
表示容器的类通常可以通过元素在容器中的位置访问元素,这些类一般会定义下标运算符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; // 指向数组首元素的指针
};
递增和递减运算符
在迭代器类中通常会实现递增运算符++和递减运算符--,这两种运算符使得类可以在元素的序列中前后移动。
对于内置类型来说,递增和递减运算符既有前置版本也有后置版本。同样,我们也应该为类定义两个版本的递增和递减运算符。
class StrBlobPtr {
public:
StrBlobPtr& operator++(); // 前置运算符
StrBlobPtr& operator--();
};
区分前置和后置运算符
后置版本接受一个额外的(不被使用的)int类型的形参,这个形参的唯一作用就是区分前置版本和后置版本的函数,而不是真的要在实现后置版本时参与运算。
class StrBlobPtr {
public:
StrBlobPtr operator++(int); // 后置运算符
StrBlobPtr operator--(int);
};
Note
为了与内置版本保持一致,后置运算符应该返回对象的原值,返回的形式是一个值而非引用。
成员访问运算符
在迭代器类及智能指针类中常常用到解引用运算符和箭头运算符。
class StrBlobPtr {
public:
std::string& operator*() const;
std::string* operator->() const
{
// 将实际工作委托给解引用运算符
return & this->operator*();
}
};
函数调用运算符
如果类重载了函数调用运算符,则我们可以像使用函数一样使用该类的对象。因为这样的类同时也能存储状态,所以与普通函数相比它们更灵活。
struct absInt {
int operator()(int val) const {
return val < 0 ? -val : val;
}
};
这个类只定义了一种操作:函数调用运算符,它负责接受一个int类型的实参,然后返回该实参的绝对值。
使用调用运算符的方式是令一个absInt对象作用于一个实参列表,这一过程看起来非常像调用函数的过程:
int i = -42;
absInt absObj;
int ui = absObj(i); // 将i传递给absObj.operator()
函数调用运算符必须是成员函数。一个类可以定义多个不同版本的调用运算符,相互之间应该在参数数量或类型上有所区别。
如果类定义了调用运算符,则该类的对象称作函数对象(function object)。因为可以调用这种对象,所以我们说这些对象的行为像函数一样。
lambda是函数对象
当我们编写了一个lambda后,编译器将该表达式翻译成一个未命名类的未命名对象。在lambda表达式产生的类中含有一个重载的函数调用运算符,默认情况下,它是一个const成员函数。
当一个lambda表达式通过引用捕获变量时,将由程序负责确保lambda执行时引用所引用的对象确实存在。因此,编译器可以直接使用该引用而无须在lambda产生的类中将其存储为数据成员。
通过值捕获的变量被拷贝到lambda中。因此,这种lambda产生的类必须为每个值捕获的变量建立对应的数据成员,同时创建构造函数,令其使用捕获的变量的值来初始化数据成员。
标准库定义的函数对象
标准库定义了一组表示算术运算符、关系运算符和逻辑运算符的类,每个类分别定义了一个执行命名操作的调用运算符。例如,plus类定义了一个函数调用运算符用于对一对运算对象执行+操作。
这些类都被定义成模板的形式,我们可以为其指定具体的应用类型,这里的类型即调用运算符的形参类型。例如,plus<string>
令string的加法运算符作用于string对象。
plus<int> intAdd; // 可执行int加法的函数对象
int sum = intAdd(10, 20); // 使用intAdd::operator(int, int)求10和20的和
p510列出了所有这些函数对象类,它们定义在functional头文件中。
在算法中使用标准库函数对象
表示运算符的函数对象类常用来替换算法中的默认运算符。比如,默认情况下排序算法使用operator<将序列按照升序排列。如果要执行降序排列的话,我们可以传入一个greater类型的对象。
// 传入一个临时的函数对象用于执行两个string对象的>比较运算
sort(svec.begin(), svec.end(), greater<string>());
可调用对象与function
C++语言中有几种可调用的对象:函数、函数指针、lambda表达式、bind创建的对象以及重载了函数调用运算符的类。
和其他对象一样,可调用对象也有类型。lambda有它自己唯一的未命名的类类型;函数及函数指针的类型由其返回值和实参类型决定。
然而,两个不同类型的可调用对象却可能共享同一种调用形式(call signature)。调用形式指明了调用返回的类型以及传递给调用的实参类型。一种调用形式对应一个函数类型,例如:
int(int, int)
function是一个模板,当创建一个具体的function类型时我们必须提供额外的信息,此额外信息是指该function类型能够表示的对象的调用形式:
function<int(int, int)>
这里声明的function类型,表示接受两个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;
cout << f2(4, 2) << endl;
cout << f3(4, 2) << endl;
重载、类型转换与运算符
在263页中我们看到由一个实参调用的非显示构造函数定义了一种隐式的类型转换,这种构造函数将实参类型的对象转换成类类型。我们同样能定义对于类类型的类型转换,通过定义类型转换运算符可以做到这一点。
转换构造函数和类型转换运算符共同定义了类类型转换(class-type conversions)。
类型转换运算符
类型转换运算符(conversion operator)是类的一种特殊成员函数,它负责将一个类类型的值转换成其他类型。其一般形式如下:
operator type() const;
其中type表示某种类型。类型转换运算符可以面向任意类型(除了void之外)进行定义,只要该类型能作为函数的返回类型。
类型转换运算符既没有显式的返回类型,也没有形参,而且必须定义成类的成员函数。类型转换运算符通常不应该改变待转换对象的内容,因此,一般被定义成const成员。
举个例子,我们定义一个比较简单的类,令其表示0到255之间的一个整数:
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对象,而类型转换运算符将SmallInt对象转换成int:
SmallInt si;
si = 4; // 4 -> SmallInt, 然后调用赋值运算符
si + 3; // si -> int,然后执行整数的加法
类型转换运算符可能产生意外结果
在实践中,类很少提供类型转换运算符。但有一种例外:对于类来说,定义向bool的类型转换还是比较普遍的现象,但这会遇到一个问题:因为bool是一种算术类型,所以类类型的对象转换成bool后就能被用在任何需要算术类型的上下文中(这不是期望的)。
显式的类型转换运算符
为了防止上述异常情况的发生,C++新标准引入了显式的类型转换运算符(explicit conversion operator):
class SmallInt {
public:
// 编译器不会自动执行这一类型转换
explicit operator int() const { return val; }
};
编译器不会将一个显式的类型转换运算符用于隐式类型转换:
SmallInt si = 3; // 正确:SmallInt的构造函数不是显式的
si + 3; // 错误:此处需要隐式的类型转换,但类的运算符是显式的
static_cast<int>(si) + 3; // 正确:显式地请求类型转换
但,如果表达式被用作条件,则编译器会将显示的类型转换自动应用于它。
Note
向bool的类型转换通常用在条件部分,因此operator bool一般定义成explicit的。
14.9.2节和14.9.3节讨论了避免二义性的类型转换和函数匹配遇到重载运算符时可选函数的问题,详细见书本p517。