文章目录
第 14 章 重载运算与类型转换
14.1 基本概念
除了重载的函数调用运算符 operator() 之外,其他重载运算符不能含有默认实参。
可以重载的运算符:
(1)选择作为成员或者非成员
下面的准则有助于我们在将运算符定义为成员函数还是普通的非成员函数做出抉择:
- 赋值(
=
)、下标([]
)、调用(()
)和成员访问箭头(->
)运算符必须是成员。 - 复合赋值运算符一般来说应该是成员,但并非必须。
- 改变对象状态的运算符或者与给定类型密切相关的运算符,如递增、递减和解引用
运算符,通常应该是成员。 - 具有对称性的运算符可能转换任意一端的运算对象,例如算术、相等性、关系和位
运算符等,因此它们通常应该是普通的非成员函数。
14.2 输入和输出运算符
14.2.1 重载输出运算符 <<
ostream &operator<<(ostream &os, const Sales_data &item)
{
os << item.isbn() << " " << item.units_sold << " "
<< item.revenue << " " << item.avg_price();
return os;
}
输出运算符尽量减少格式化操作,并且必须是成员函数。
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;
}
输入运算符必须处理输入可能失败的情况,而输出运算符不需要。
14.3 算术和关系运算符
// 假设两个对象指向同一本书
Sales_data operator+(const Sales_data &lhs, const Sales_data &rhs)
{
Sales_data sum = lhs; // 把 lhs 的数据成员拷贝给 sum
sum += rhs; // 将 rhs 加到 sum 中
return sum;
}
14.3.1 相等运算符
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);
}
14.3.2 关系运算符(*)
14.4 赋值运算符
// 作为成员的二元运算符;左侧运算对象绑定到隐式的 this 指针
// 假定两个对象表示的是同一本书
Sales_data& Sales_data::operator+=(const Sales_data &rhs)
{
units_sold += rhs.units_sold;
revenue += rhs.revenue;
return *this;
}
14.5 下标运算符
如果一个类包含下标运算符,则它通常会定义两个版本:一个返回普通引用,另一个是类的常量成员并且返回常量引用。
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]; }
// 其他成员与 13.5 一致
private:
std::string *elements; // 指向数组首元素的指针
};
14.6 递增和递减运算符
class StrBlobPtr
{
public:
// 递增和递减运算符
StrBlobPtr& operator++(); // 前置运算符
StrBlobPtr& operator--();
StrBlobPtr& operator++(int); // 后置运算符
StrBlobPtr& operator--(int);
// 其他成员和之前的版本一致
};
// 前置版本:返回递增 / 递减对象的引用
StrBlobPtr& StrBlobPtr::operator++()
{
// 如果 curr 已经指向了容器的尾后位置,则无法递增它
check(curr, "increment past end of StrBlobPtr");
++curr; // 将 curr 在当前状态下向前移动一个元素
return *this;
}
StrBlobPtr& StrBlobPtr::operator--()
{
// 如果 curr 是0,则继续递减它将产生一个无效下标
--curr; // 将 curr 在当前状态下向后移动一个元素
check(curr, "decrement past begin of StrBlobPtr");
return *this;
}
// 后置版本∶ 递增 / 递减对象的值但是返回原值
StrBlobPtr StrBlobPtr::operator++(int)
{
// 此处无须检查有效性,调用前置递增运算时才需要检查
StrBlobPtr ret = *this; // 记录当前的值
++*this; // 向前移动一个元素,前置++需要检查递增的有效性
return ret; // 返回之前记录的状态
}
StrBlobPtr StrBlobPtr::operator--(int)
{
// 此处无须检查有效性,调用前置递减运算时才需要检查
StrBlobPtr ret = *this; // 记录当前的值
--*this; // 向后移动一个元素,前置--需要检查递减的有效性
return ret; // 返回之前记录的状态
}
14.7 成员访问运算符
class StrBlobPtr
{
public:
std::string& operator*() const
{
auto p = check(curr, "dereference past end");
return (*p)[curr]; // (*p) 是对象所指的 vector
}
std::string* operator->() const
{ // 将实际工作委托给解引用运算符
return & this->operator*();
}
// 其他成员与之前的版本一致
}
14.8 函数调用运算符
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()
即使 absObj 只是一个对象而非函数,我们也能"调用"该对象。调用对象实际上是在运行重载的调用运算符。
如果类定义了调用运算符,则该类的对象称作函数对象。因为可以调用这种对象,所以我们说这些对象的"行为像函数一样"。
14.8.1 lambda 是函数对象
// 根据单词的长度对其进行排序,对于长度相同的单词按照字母表顺序排序
stable_sort(words.begin(), words.end(),
[](const string &a, const string &b) { return a.size() < b.size();} );
其行为类似于下面这个类的一个未命名对象
class ShorterString
{
public:
bool operator()(const string &s1, const string &s2) const
{
return s1.size ()< s2.size ();
}
};
用这个类替代 lambda 表达式后,我们可以重写并重新调用 stable_sort:
stable_sort(words.begin(), words.end(), ShorterString());
14.8.2 标准库定义的函数对象
标准库定义了一组表示算术运算符、关系运算符和逻辑运算符的类,每个类分别定义了一个执行命名操作的调用运算符。例如,
- plus 类定义了一个函数调用运算符用于对一对运算对象执行 + 的操作
- modulus 类定义了一个调用运算符执行二元的号操作
- equal_to 类执行 ==
这些类都被定义成模板的形式,我们可以为其指定具体的应用类型,这里的类型即调用运算符的形参类型。例如,
plus<int> intAdd; // 可执行 int 加法的函数对
negate<int> intNegate; // 可对 int 值取反的函数对象
// 使用 intAdd::operator(int,int) 求 10 和 20 的和
int sum = intAdd(10, 20); // 等价于 sum = 30
sum = intNegate(intAdd(10, 20)); // 等价于 sum = -30
// 使用 intNegate::operator(int) 生成 -10
// 然后将 -10 作为 intAdd::operator(int,int) 的第二个参数
sum = intAdd(10, intNegate(10)); // sum = 0
下表所列的类型定义在头文件 < functional > 中:
14.8.3 可调用对象与 function
C++ 语言中有几种可调用的对象:
- 函数
- 函数指针
- lambda 表达式(
- bind 创建的对象(
- 重载了函数调用运算符的类。
和其他对象一样,可调用的对象也有类型。例如,
- 每个 lambda 有它自己唯一的(未命名)类类型
- 函数及函数指针的类型则由其返回值类型和实参类型决定,等等。
然而,两个不同类型的可调用对象却可能共享同一种调用形式。调用形式指明了调用返回的类型以及传递给调用的实参类型。一种调用形式对应一个函数类型,例如:
int(int, int)
是一个函数类型,它接受两个 int、返回一个 int。不同类型可能具有相同的调用形式
对于几个可调用对象共享同一种调用形式的情况,有时我们会希望把它们看成具有相同的类型。例如,考虑下列不同类型的可调用对象:
//普通函数
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;
}
};
上面这些可调用对象分别对其参数执行了不同的算术运算,尽管它们的类型各不相同,但是共享同一种调用形式。
我们希望使用这些可调用对象构建一个简单的桌面计算器。为了实现这一目的,需要定义一个函数表用于存储指向这些可调用对象的"指针"。当程序需要执行某个特定的操作时,从表中查找该调用的函数。在 C++ 语言中,函数表很容易通过 map 来实现。
// 构建从运算符到函数指针的映射关系,其中函数接受两个int、返回一个int
map<string, int(*)(int, int)> binops;
我们可以按照下面的形式将 add 的指针添加到 binops 中:
// 正确∶ add 是一个指向正确类型函数的指针
binops.insert({"+",add}); // {"+",add} 是一个pair
但是我们不能将 mod 或者 divide 存入 binops。问题在于 mod 是个 lambda 表达式,而每个 lambda 有它自己的类类型,该类型与存储在 binops 中的值的类型不匹配。
(1)标准库 function 类型
我们可以使用一个名为 function 的新的标准库类型解决上述问题,function 定义在 < functional >头文件中。
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; // 打印6
cout << f2(4, 2) << endl; // 打印2
cout << f3(4, 2) << endl; // 打印8
使用这个 function 类型我们可以重新定义 map:
// 列举了可调用对象与二元运算符对应关系的表格
// 所有可调用对象都必须接受两个 int、返回一个 int
// 其中的元素可以是函数指针、函数对象或者 lambda
map<string, function<int(int, int)>> binops;
map<string,function<int(int,int)>> binops = {
{ "+", add }, // 函数指针
{ "-", std::minus<int>() }, // 标准库函数对象
{ "/", divide() }, // 用户定义的函数对象
{ "*", [](int i,int j) { return i * j;} }, // 未命名的 lambda
{ "%", mod } // 命名了的 lambda 对象
};
binops["+"](10,5); // 调用 add(10,5)
binops["-"](10,5); // 使用 minus<int> 对象的调用运算符
binops["/"](10,5); // 使用 divide 对象的调用运算符
binops["*"](10,5); // 调用 lambda 函数对象
binops["%"](10,5); // 调用 lambda 函数对象
(2)重载函数与 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; // 指针所指的 add 是接受两个 int 的版本
binops.insert({ "+", fp }); // 正确:fp 指向一个正确的 add 版本
同样,我们也能使用 lambda 来消除二义性:
// 正确∶ 使用 lambda 来指定我们希望使用的 add 版本
binops.insert({ "+", [](int a, int b) { return add(a, b); } });
lambda 内部的函数调用传入了两个 int,因此该调用只能匹配接受两个 int 的 add 版本,而这也正是执行 lambda 时真正调用的函数。
14.9 重载、类型转换与运算符
14.9.1 类型转换运算符
一个类型转换函数必须是类的成员函数;它不能声明返回类型,形参列表也必须为空。类型转换函数通常应该是 const。
operator type() const;
(1)显式的类型转换运算符
class SmallInt
{
public:
// 编译器不会自动执行这一类型转换
explicit operator int() const { return val; }
// 其他成员与之前的版本一致
};
当类型转换运算符是显式的时,我们也能执行类型转换,不过必须通过显式的强制类型转换才可以。
该规定存在一个例外,即如果表达式被用作条件,则编译器会将显式的类型转换自动应用于它。换句话说,当表达式出现在下列位置时,显式的类型转换将被隐式地执行:
- if、while 及 do 语句的条件部分
- for 语句头的条件表达式
- 逻辑非运算符(
!
)、逻辑或运算符(||
)、逻辑与运算符(&&
)的运算对象 - 条件运算符(
? :
)的条件表达式。
14.9.2 避免有二义性的类型转换
如果类中包含一个或多个类型转换,则必须确保在类类型和目标类型之间只存在唯一一种转换方式。否则的话,我们编写的代码将很可能会具有二义性:
-
两个类提供相同的类型转换。
当 A 类定义了一个接受 B 类对象的转换构造函数,同时 B 类定义了一个转换目标是 A 类的类型转换运算符时,我们就说它们提供了相同的类型转换。
-
类定义了多个转换规则,而这些转换涉及的类型本身可以通过其他类型转换联系在一起。
对某个给定的类来说,最好只定义最多一个与算术类型有关的转换规则。
一言以蔽之:除了显式地向 bool 类型的转换之外,我们应该尽量避免定义类型转换函数并尽可能地限制那些"显然正确"的非显式构造函数。