C++ 学习笔记之(14) - 重载运算与类型转换
在C++ 学习笔记之(4)-表达式、运算符与类型转换中记录了C++语言中定义的大量运算符和内置类型的自动转换规则,并且当运算符作用于类类型时,可以通过运算符重载重新定义该运算符的含义。同时,也能自定义类类型之间的转换规则,即和内置类型的转换一样,类类型转换隐式的将一种类型的无锡爱那个转换成另一种类型对象
基本概念
- 重载运算符函数的名字由关键字
operator
和其后的运算符号共同组成,也包括返回类型、参数列表和函数体 - 重载运算符函数的参数数量与该运算符作用的运算对象数量一样多
- 除了重载的函数调用运算符
operator()
外,其他重载运算符不能含有默认实参 - 当一个重载的运算符是成员函数时,
this
绑定到左侧运算对象,显示参数数量比运算对象数量少一个 - 当运算符作用于内置类型的运算对象时,无法重载
调用重载运算符函数
// 一个非成员运算符函数的等价调用 data1 + data2; // 普通表达式 operator+(data1, data2); // 等价的函数调用
&&
、||
和,
,这三种运算符重载后求值顺序规则无法保留,且前两个运算符重载版本中短路求值属性失效重载运算符尽量使用与内置类型一致的含义
重载运算符作为成员函数还是非成员函数的准则
- 成员函数
- 必须:赋值
=
、下标[]
、调用()
和成员访问箭头->
运算符 - 一般:复合赋值运算符
+=, -=
、递增递减++, --
和解引用*
运算符 - 普通非成员函数
- 具有对称性的运算符可能转换任意一端的运算对象,比如算数、相等性、关系和位运算符等
输入和输出运算符
IO
标准库分别使用>>
和<<
执行输入和输出操作。IO
库定义了用其读写内置类型的版本,而类类型的版本需要自定义。
重载输出运算符<<
- 通常,输出运算符的第一个形参为一个非常量
ostream
对象的引用,因为ostream
对象不可拷贝,并会改变。第二个形参为一个常量的引用,只打印不改变。返回ostream
形参 - 输出运算符尽量减少格式化操作,输出运算符应该主要打印对象的内容而非控制格式
- 输入输出运算符必须是非成员函数,且被声明为类的友元
ostream *operator<<(ostream &os, const Sales_data &item)
{
os << item.isbn() <<" " << item.units_sold;
return os;
}
重载输入运算符>>
- 通常,输入运算符的第一个形参是输入流的引用,第二个为读入到的对象的非常量引用。返回某个给定流的引用。
- 输入运算符必须处理输入可能失败的情况,而输出运算度不需要
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;
}
算数和关系运算符
通常,算数和关系运算符被定义成非成员函数以允许对左侧和右侧的运算对象进行转换,且这些运算符一般不需要改变运算对象,故形参都为常量引用
- 若类定义了算术运算符,一般也会定义对应的复合赋值运算符,且一般用复合赋值运算符实现算数运算符
==
与!=
运算符一般同时存在,且应该把工作委托给另一个,即一个负责实际比较,另一个调用该运算符
赋值运算符
除了拷贝赋值和移动赋值运算符,类还可以定义其他赋值运算符,以使用其他类型作为右侧运算对象
StrVec &StrVec::operator=(initializer_list<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; }
复合赋值运算符一般为成员函数,且返回其左侧运算对象的引用
下标运算符
- 下标运算符
operator[]
必须是成员函数 - 若类包含下标运算符,则通常定义两个版本:一个返回普通引用,另一个为类的常量成员并返回常量引用
递增和递减运算符
- 定义递增和递减运算符的类应该同时定义前置版本和后置版本,且应被定义成类的成员
- 前置运算符应该返回递增或递减后对象的引用
- 后置版本接受一个额外的(不被使用)
int
类型的形参,以区别前置版本 - 后置运算符应该返回对象的原值(递增或递减之前的值),返回的是值而非引用
class A{
public:
A& operator++(); // 前置运算符
A operator++(int); // 后置运算符
}
// 显示调用
A pa;
pa.operator++(0); // 调用后置版本的 operator++
pa.operator++(); // 调用前置版本的 operator++
成员访问运算符
- 箭头运算符必须是类的成员,解引用运算符通常也是类的成员
- 重载的箭头运算符必须返回类的指针或自定义了箭头运算符的某个类的对象
函数调用运算符
- 函数调用运算符必须是成员函数。一个类可以定义多个不同版本的调用运算符,相互之间应该在参数数量或类型上有所区别。
- 函数对象:若类定义了函数调用运算符,则该类的对象被称为函数对象,即调用对象类似于调用函数
struct absInt{
int operator()(int val) const{ // 返回参数绝对值
return val < 0 ? -val : val;
}
}
int i = -42;
absInt absObj; // 含有函数调用运算符的对象
int ui = absObj(i); // 将 i 传递给 absObj.operator()
lambda
是函数对象
lambda
表达式被编译器翻译成一个未命名类的未命名对象lambda
表达式产生的类不含默认构造函数、赋值运算符以及默认析构函数;是否含有默认的拷贝/移动构造函数则通常要视捕获的数据成员类型而定
// 获得第一个指向满足条件元素的迭代器,该元素满足 size() is >= sz
auto wc = find_if(words.begin(), words.end(), [sz](const string &a) { return a.size() >= sz; });
// 该 lambda 表达式产生的类形如
class SizeComp{
SizeComp(size_t n): sz(n) {} // 该形参对应捕获的变量
// 该调用运算符的返回类型、形参和函数体都与 lambda 一致
bool operator()(const string &s) const { return s.size() >= sz; }
private:
size_t sz; // 该数据成员对应通过值捕获的变量
}
auto wc = find_if(words.begin(), words.end(), SizeComp(sz)); // 等价于上述使用 lambda语句
标准库定义的函数对象
标准库定义了一组表示算术运算符、关系运算符和逻辑运算符的类,每个类分别定义了一个执行命名操作的调用运算符
plus<int> intAdd; // 可执行 int 加法的函数对
int sum = intAdd(10, 20); // 等价于 sum = 10 + 20
// 比较指针的内存地址来 sort 指针的 vector
vector<string *> nameTable; // 指针的 vector
// 错误:nameTable 中的指针彼此之间没有关系,所以 < 将产生未定义的行为
sort(nameTable.begin(), nameTable.end(), [](string *a, string *b) { return a < b; });
sort(nameTable.begin(), nameTable.end(), less<string*>()); // 正确:标准库规定指针的 less 定义良好
可调用对象与function
C++语言中有集中可调用的对象:函数、函数指针、lambda
表达式、bind
创建的对象以及重载了函数调用运算符的类。
- 可调用对象也有类型,比如
lambda
有自己唯一的(未命名)类类型;函数及函数指针的类型则有返回值类型和实参决定等。 - 不同类型的调用对象可能共享同一种 调用形式(call signature)。
- 可用
function
标准库模板表示不同类型却共享调用形式的可调用对象
// 调用形式 int(int, int), 考虑不同类型的调用对象
int add(int i, int j) { return i + j; } // 普通函数
auto mod = [](int i, int j) { return i % j; }; // lambda, 其产生一个未命名的函数对象类
struct divide{
int operator()(int denominator, int divisor) { return deniminator / divisor; }
};
// 可通过 function 模板表示某种调用形式的可调用对象
function<int(int, int)> f1 = add; // 函数指针
function<int(int, int)> f2 = mod; // lambda
function<int(int, int)> f3 = divide(); // 函数对象类的对象
cout << f1(4, 2) << endl << f2(4, 2) << endl << f3(4, 2) << endl; // 6, 0, 2
重载、类型转换与运算符
在C++ 学习笔记之(7)-类中可看到由实参调用的非显示构造函数能够通过隐式类型转换将实参类型的对象转换成类类型。对于类类型的转换,可以通过定义转换构造函数和类型转换运算符共同定义
类型转换运算符
类的一种特殊成员函数,可将一个类类型转换为其他类型
- 转换的类型要作为函数的返回类型,故不允许转换为数组或者函数类型,但允许转换为指针(包括数组指针以及函数指针)或者引用类型
- 类型转换运算符没有显示的返回类型,也没有形参,且必须定义成类的成员函数,一般为
const
- 虽然编译器只允许一步类类型转换,但类类型转换可以和标准(内置)类型转换一起使用
class SmallInt{
public:
// 构造函数将算数类型的值转换为 SmallInt 对象
SmallInt(int i = 0): val(i)
{ if(i < 0 || i > 255) throw std::out_of_range("Bad SmallInt Value")}
// 类型转换运算符将 SmallInt 对象转换为 int
operator int() const { return val; }
private:
std::size_t val;
}
SmallInt si;
// 算数类型转换为类类型
si = 4; // 首先将 4 隐式转换成 SmallInt, 然后调用 SmallInt::operator=
// 类类型转换为算数类型
si + 3; // 首先将 si 隐式转换成 int, 然后执行整数的加法
// 类类型转换和标准内置类型转换共同使用
SmallInt si2 = 3.14; // 内置类型转换将 double 实参转换成 int, 然后调用SmallInt(int)构造函数
C++11定义的 显示的类型转换运算符表示必须通过显示的强制类型转换调用
class SmallInt{ public: explicit operator int() const { return val; } // 编译器不会自动执行这一类型转换 // ... 其他与之前一致 }; SmallInt si = 3; // 正确:SmallInt 的构造函数不是显示的 si + 3; // 错误:此刻需要隐式的类类型向算数类型转换,但类的运算符是显示的 static_int<int>(si) + 3; // 正确:显示地请求类型转换
若表达式被用作条件,则编译器会自动应用显示类型转换,即下列情况会隐式执行显示的类型转换
if
、while
及do
语句的条件部分for
语句头的条件表达式- 逻辑非运算符
!
、逻辑或运算符||
、逻辑与运算符&&
的运算对象 - 条件运算符
(? :)
的条件表达式
避免有二义性的类型转换
若类中包含一个或多个类型转换,则必须确保在类类型和目标类型之间只存在唯一一种转换方式,否则,可能会有二义性
不要为类提供相同的类型转换,比如
A
类定义了接受B
类型对象的转换构造函数,同时B
类定义了转换目标是A
类的类型转换运算符不要定义多个转换规则,比如在类中最多只定义一个与算数类型有关的转换规则
struct A{ A(int = 0); // 最好不要创建两个转换预案都是算数类型的类型转换 A(double); operator int() const; // 最好不要创建两个转换对象都是算数类型的类型转换 operator double() const; }; void f2(long double); A a; f2(a); // 二义性错误:含义是f(A::operator int()) 还是 f(A::operator double()) ? long lg; A a2(lg); // 二义性错误:含义是 A::A(int)还是 A::A(double)? // 上边两种都无法精确匹配,故产生二义性 short s = 42; A s3(s); // 正确:使用 A::A(int) 把 short 提升成 int 优于把 short 转换成 double
在使用两个用户定义的类型转换时,若转换函数之前或之后存在标准类型转换,则标准类型转换将决定最佳匹配是i哪一个
函数匹配与重载运算符
表达式中运算符的候选函数集既包括成员函数,也应该包括非成员函数
若一个类既提供了转换目标是算数类型的类型转换,也提供了重载运算符,则将遇到重载运算符与内置运算符的二义性问题
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 s1, s2; SmallInt s3 = s1 + s2; // 正确:使用重载的 operator+ int i = s3 + 0; // 错误:二义性错误
结语
一个重载的运算符必须是类的成员或者至少拥有一个类类型的运算对象。重载运算符的运算对象数量、结合律、优先级与对应的用于内置类型的运算符完全一致
若类重载了函数调用运算符operator()
,则该类的对象被称作 函数对象, 且lambda
表达式是一种简便的定义函数对象类的方式
在类中可以定义转换源或转换目标是该类型本身的类型转换,这样的转换是自动执行。只接受单独一个实参的非显示构造函数定义了从实参类型到类类型的类型转换;而非显示的类型转换运算符则定义了从类类型到其他类型的转换