第十四章 重载运算与类型转换
14.1 基本概念
- 语法相关:
- 重载的运算符必须是某个类的成员或至少拥有一个类类型的运算对象
- 重载运算符函数的参数数量和该运算符作用的运算对象数量一样多,左侧运算对象传递给第一个参数,右侧传递给第二个,除了重载函数调用符
()
,其他重载运算符不能有默认实参,调用方式:operator+(data1, data2)
- 如果一个重载的运算符是成员函数,
this
指向左侧运算对象,因此定义成员运算符时的参数数量比运算符的运算对象少一个,调用方式:data1.operator+=(data2)
- 重载运算符的优先级和结合律跟对应的内置运算符保持一致。
- 使用:
- 一些运算符通常一起进行重载,比如重载了
==
也应该重载!=
,重载了<
也应该重载其他关系操作,重载了算数运算符或位运算符,也应该重载对应的复合赋值运算符 - 考虑定义为成员函数还是普通函数
- 赋值(
=
)、下标([]
)、调用(()
)和成员访问箭头(->
)运算符必须是成员。 - 递增、递减、解引用、复合赋值运算符一般是成员
- 具有对称性的运算符如算术、相等性、关系和位运算符等,通常是非成员函数。
- IO运算符应该声明为类的友元
- 赋值(
- 一些运算符通常一起进行重载,比如重载了
14.2 运算符重载
14.2.1 重载输出运算符<<
ostream& operator<< (ostream &os, const T &t);
- 第一个形参通常是一个非常量的
ostream
对象的引用,第二个形参是要打印类型的常量引用 - 输出运算符应该尽量减少格式化操作(比如不应该打印换行符)
14.2.2 重载输入运算符>>
istream& operator>> (istream& is, T &t);
- 第一个形参通常是运算符将要读取的流的引用,第二个形参是将要读取到的(非常量)对象的引用。
- 输入运算符必须处理输入可能失败的情况,而输出运算符不需要。
- 如果读取失败,输入运算符应该负责从错误中恢复,主要是将输入对象重置为合法状态,一般为未输入前的状态。
14.2.3 重载算数运算符+、-、*、/
- 一般设置为非成员函数,形参一般为常量引用,返回值不为引用(因为返回值一般是局部变量的拷贝)
- 一般都是先定义复合赋值运算符(成员函数),在基于此实现算数运算符(普通函数)
14.2.4 相等运算符==
- 如果定义了
operator==
,则这个类也应该定义operator!=
。 - 相等运算符和不等运算符的一个应该把工作委托给另一个
14.2.5 关系运算符<
- 如果两个对象是!=的,则一个对象应该
<
另一个对象
14.2.6 赋值运算符=
- 赋值运算符和复合赋值运算符应该返回左侧运算对象的引用。
14.2.7 下标运算符[]
- 一般会定义两个版本:
- 返回普通引用:
T& operator[]();
- 是类的常量成员,并返回常量引用:
const T& operator[] const;
- 返回普通引用:
14.2.8 递增和递减运算符++、--
- 应该同时定义前置版本和后置版本,而且通常为类的成员。
- 前置运算符应该返回递增或递减后对象的引用:
string& operator++();
- 后置运算符应该返回递增或递减前对象的值,而不是引用:
T operator++(int);
- 后置版本接受一个额外的、不被使用的
int
类型的形参,且无需命名,编译器提供一个值为0的实参。该形参唯一的作用就是区分前置和后置递增。 - 如果想通过函数调用的方式使用后置递增,需要为这个int形参传递一个值(比如0)
- 后置版本接受一个额外的、不被使用的
- 后置版本可以通过调用前置版本来实现。
- 前置运算符应该返回递增或递减后对象的引用:
14.2.9 成员访问运算符*、->
- 箭头运算符必须是类的成员,解引用运算符通常也是类的成员,且通常为const的成员函数,而且箭头运算符一般通过调用解引用运算符来实现
14.2.10 函数调用运算符()
14.8 函数调用运算符
- 如果类定义了调用运算符,则该类的对象称作【函数对象】。
- 函数对象可以被调用,同时因为函数对象可以存储状态(即数据成员),所以与普通函数相比更加灵活,通常作为泛型算法的实参
- C++中的【可调用对象】:函数、函数指针、lambda表达式、bind创建的对象、函数对象(或者说重载了调用运算符的类)
- 【可调用对象的类型】:lambda表达式有他自己唯一(未命名)的类类型,函数、函数指针的类型由返回值类型和实参类型决定
- 【调用形式】:指明了调用返回的类型和传递给调用的实参类型,比如
int(int,int)
- 不同类型的可调用对象可以共享同一种调用形式,但它们并不是同一类型。例子。
lambda
是函数对象
- 编译器将[[ch10-泛型算法#lambda表达式|lambda表达式]]转换成一个未命名类的未命名对象(即类中重载了函数调用运算符)
- 这个未命名类不包含默认构造函数、赋值运算符和默认析构函数,它是否包含默认的拷贝/移动构造函数,通常视捕获变量的类型而定
- lambda默认不能改变它捕获的变量,此时未命名类中的函数调用运算符是一个const成员函数;如果lambda被声明为可变的,则调用运算符就不再是const成员函数
- 如果进行引用捕获,编译器直接使用该引用而无须在产生的类中相应存储为数据成员(由程序确保该引用绑定的对象确实存在)
- 如果进行值捕获,产生的类必须为捕获的变量建立对应的数据成员,并创建构造函数,用捕获变量的值来初始化相应的数据成员
标准库定义的函数对象
标准库函数对象
算术 | 关系 | 逻辑 |
---|---|---|
plus<Type> | equal_to<Type> | logical_and<Type> |
minus<Type> | not_equal_to<Type> | logical_or<Type> |
multiplies<Type> | greater<Type> | logical_not<Type> |
divides<Type> | greater_equal<Type> | |
modulus<Type> | less<Type> | |
negate<Type> | less_equal<Type> |
- 一组表示算数运算符、关系运算符和逻辑运算符的模板类,每个类中重载了调用运算符来实现相应的命名操作
- 标准库函数对象经常用来替换算法中的默认运算符
- 如果想根据指针(或者说内存地址)进行排序,Type可以是指针类型,但是无法通过自定义的函数来进行内存地址的比较
可调用对象与function
- 调用形式相同的可调用对象,其类型不一定相同
function
封装了相同调用形式、但是不同类型的可调用对象1 2 3 4 5 6 7 8
int add(int i, int j) { return i + j; } auto mod = [](int i, int j) {return i % j;} function<int(int,int)> f1 = add; function<int(int,int)> f2 = mod; map<string, function<int(int,int)>> mp; mp.insert({"+", add}); mp.insert({"%", mod}); mp["+"](1,2); // 将不同可调用类型的可调用对象存储在一起
- 不能直接将重载函数的名字存入
function
类型的对象中,因为会产生二义性,消除二义性的方法是使用lambda或者函数指针而非函数名字1 2 3 4 5 6 7 8
int add(int i, int j) {return i + j} string add(string s, string t) {return s + t;} map<string, function<int(int, int)>> mp; mp.insert({"+", add}); // error: which add? int (*fp)(int, int) = add; mp.insert({"+", fp}); mp.insert({"+", [](int a, int b) {return add(a,b);}; // 另一种写法
14.9 重载、类型转换、运算符
类类型转换(或者称用户定义的类型转换):转换构造函数(从其他类型转换到类类型)+类型转换运算符(从类类型转换到其他类型)
类型转换运算符
- 一般类型:
operator type() const;
- 语法相关:
- 可以转换到任意类型(除了void),只要该类型能作为函数的返回类型
- 必须是类的成员函数,不能声明返回类型(但是函数返回一个对应类型的值),形参列表为空,一般为const成员函数
- 使用:
- 类型转换运算符不需要显式调用,在执行运算时会隐式的执行
- 尽量确保类型转换是有意义的,避免过度使用
- 尽管编译器一次只能执行一个【用户定义的类型转换】,但是隐式的【用户定义的类型转换】可以置于一个标准内置类型转换之前或之后
1 2 3 4 5 6 7 8 9
class smallInt{ public: smallInt(int i): val(i) {if(i<0 || i > 255) cout<<"bad value"<<endl;} operator int() const {return -val;} // 为了说明类型转换运算符的效果 private: size_t val; }; smallInt s = 2.1; // 先将double转换为int,再使用转换构造函数将int转换为smallInt cout<<s + 2.1<<endl; // 使用【类型转换运算符】将s隐式地转换为int,再转换为double相加
- 显式的类型转换运算符(C++11):
explicit operator type() const;
- 需要使用
static_cast<type>
进行显式的类型转换- 例外:当表达式被用作条件时,显式的类型转换将被隐式的执行
- 向
bool
的类型转换通常用在条件部分,因此operator bool
一般定义成explicit
的。
- 需要使用
避免有二义性的类型转换
- 必须确保在类类型和目标类型之间只存在唯一一种转换方式,否则很可能有二义性
- 两种情况会产生多重转换路径:
- 【用A的转换构造函数还是B的类型转换运算符】:A类定义了一个参数为B类的转换构造函数,B类定义了一个目标类型为A类的类型转换运算符,此时可以显式指定调用哪一种
- 无法使用强制类型转换来解决二义性问题,因为强制类型转换本身也面临二义性
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
struct B; struct A{ A() =default; A(const &B); // 转换构造函数 }; struct B{ operator A() const; // 类型转换运算符 }; A f(const A&); // 定义一个函数 B b; A a = f(b); // 二义性:使用B的【类型转换运算符】,还是使用A的【转换构造函数】 A a1 = f(b.operator A()); // 使用B的类型转换运算符 A a2 = f(A(b)); // 使用A的转换构造函数
- 【有多个类型转换运算符,用哪个】:类定义了多个类型转换规则,转换目标为内置类型,且转换级别一致
- 标准类型转换的级别决定编译器如何选择最佳匹配,转换级别一致就会出现二义性
1 2 3 4 5 6 7 8 9 10 11 12 13
struct A{ A(int); A(double); operator int() const; operator double() const; }; void f(long double); A a; f(a); // 二义性错误:a转换成int还是转换成double,再提升为long double short s = 1; A a2(s); // 不会产生二义性错误,因为short类型提升为int优先于short类型提升为double
- 【用A的转换构造函数还是B的类型转换运算符】:A类定义了一个参数为B类的转换构造函数,B类定义了一个目标类型为A类的类型转换运算符,此时可以显式指定调用哪一种
- 经验:尽量避免定义类型转换,且限制非显式构造函数
- 不要令两个类执行相同的类型转换
- 避免转换目标是内置算数类型的类型转换,特别是已经定义了一个转换成算数类型的类型转换
重载与函数匹配
- [[ch06-函数#6.6 函数匹配|函数匹配]]
- 重载函数与转换构造函数
- 当调用重载函数时,如果两个或多个类型转换都提供了同一种可行匹配,则这些类型转换一样好
1 2 3 4 5 6 7 8 9
struct C{ C(int); }; struct D{ D(int); }; void func(const C&); void func(const D&); // 重载函数 func(10); // 二义性错误:
- 重载函数与用户定义的类型转换
- 重载运算符与函数匹配
- 如果既定义了类型转换运算符(转换到内置类型),又将运算符进行重载,会遇到二义性问题
- 如果
a
是一种类类型,则表达式a sym b
可能是:a.operatorsym(b);
成员函数operatorsym(a,b);
普通函数
- 如果
1 2 3 4 5 6 7 8 9 10
class T{ friend T operator+ (const T&, const T&); public: T(int i); // 转换构造函数 operator int() const; // 类型转换运算符 private: int val; }; T t; int result = t + 1; // 二义性:可以将t转换成int进行内置加法,或者将1转换成类型T进行重载加法
- 如果既定义了类型转换运算符(转换到内置类型),又将运算符进行重载,会遇到二义性问题