文章目录
第十四章 操作重载与类型转换
- 当运算符作用于类类型的运算对象时,可以通过运算符重载重新定义该运算符的含义。
14.1 基本概念
- 重载的运算符是具有特殊名字的函数:它们的名字由关键字operator和其后要定义的运算符号共同组成。
- 当一个重栽的运算符是成员函数时,this绑定到左侧运算对象,成员运算符函数的(显式)参数数量比运算对象的数量少一个。
- 对于一个运算符函数来说,它或者是类的成员,或者至少含有一个类类型的参数。
//错误:不能为int重定义内置的运算符
int operator+(int, int);
- 直接调用一个重载的运算符函数。
- 这两次调用是等价的,它们都调用了非成员函数operator+。
//一个非成员运算符函数的等价调用
data1 + data2; //普通的表达式
operator+(data1, data2); //等价的函数调用
- 可以调用其他成员函数一样显式地调用成员运算符函数。
data1 += data2; //基于“调用”的表达式
data1.operator+=(data2); //对成员运算符函数的等价调用
- 通常情况下,不应该重载逗号、取地址、逻辑与和逻辑或运算符,无法保留求值顺序和/或短路求值属性。
- 当开始设计一个类时,首先应该考虑的是这个类将提供哪些操作。在确定类需要哪些操作之后,才能思考到底应该把每个类操作设成普通函数还是重载的运算符。
- 如果类执行IO操作,则定义移位运算符使其与内置类型的IO保持一致。
- 如果类的某个操作是检查相等性,则定义operator==,如果类有了operator==,意味着它通常也应该有operator!=。
- 如果类包含一个内在的单序比较操作,则定义operator<,如果类有了operator<, 则它也应该含有其他关系操作。
- 重载运算符的返回类型通常情况下应该与其内置版本的返回类型兼容:逻辑运算符和关系运算符应该返回bool,算术运算符应该返回一个类类型的值,赋值运算符和复合赋值运算符则应该返回左侧运算对象的一个引用。
- 下面的准则有助于在将运算符定义为成员函数还是普通的非成员函数做出抉择:
- 赋值(==)、下标([])、调用(())和成员访问箭头(->)运算符必须是成员。
- 复合赋值运算符一般来说应该是成员,但并非必须,这一点与赋值运算符略有不同。
- 改变对象状态的运算符或者与给定类型密切相关的运算符,如递增、递减和解引用运算符,通常应该是成员。
- 具有对称性的运算符可能转换任意一端的运算对象,例如算术、相等性、关系和位运算符等,因此它们通常应该是普通的非成员函数。
string s = "world";
string s = "world";
string t = s + "!"; //正确:我们能把一个const char*加到一个string对象中
string u = "hi" + s; //如果+是string的成员,则产生错误
- 如果operator+是string类的成员,则上面的第一个加法等价于s.operator+(“!”)
- “hi”+s等价于"hi".operator+(s),显然"hi"的类型是const char*, 这是一种内置类型,根本就没有成员函数。
14.2 输入和输出运算符
- IO标准库分别使用>>和<<执行输入和输出操作。对于这两个运算符来说,IO库定义了用其读写内置类型的版本,而类则需要自定义适合其对象的新版本以支持IO操作。
- 输入输出运算符必须是非成员函数。
14.2.1 重载输出运算符<<
- 通常情况下,输出运算符的第一个形参是个非常量ostream对象的引用,因为向流写入内容会改变其状态。
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 算术和关系运算符
- 通常情况下,把算术和关系运算符定义成非成员函数以允许对左侧或右侧的运算对象进行转换。
- 如果类同时定义了算术运算符和相关复合赋值运算符,则通常情况下应该使用复合赋值来实现算术运算符。
14.3.1 相等运算符
- C++中的类通过定义相等运算符来检验两个对象是否相等,通常比较对象的每一个数据成员,只有当所有对应的成员都相等时才认为两个对象相等。
- 如果某个类在逻辑上有相等性的含义,则该类应该定义operator==,这样做可以使得用户更容易使用标准库算法来处理这个类。
14.3.2 关系运算符
- 定义了相等运算符的类也常常(但不总是)包含关系运算符。
- 如果存在唯一一种逻辑可靠的<定义,则应该考虑为这个类定义<运算符。
14.4 赋值运算符
- 元素列表作为参数赋值。
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;
}
- 不论形参的类型是什么,赋值运算符都必须定义为成员函数。
- 复合赋值运算符不非得是类的成员,不过还是倾向于把包括复合赋值在内的所有赋值运算都定义在类的内部。
- 为了与内置类型的复合赋值保持一致,类中的复合赋值运算符也要返回其左侧运算对象的引用。
//作为成员的二元运算符:左侧运算对象绑定到隐式的this指针
//假定两个对象表示的是同一本书
Sales_data &Sales_data::operator+=(const Sales_data &rhs)
{
units_sold += rhs.units_sold;
revenue += rhs.revenue;
return *this;
}
14.5 下标运算符
- 下标运算符必须是成员函数。
- 如果一个类包含下标运算符,则它通常会定义两个版本:一个返回普通引用,另一个是类的常量成员并且返回常量引用。
14.6 递增和递减运算符
- 定义递增和递减运算符的类应该同时定义前置版本和后置版本,这些运算符通常应该被定义成类的成员。
- 为了与内置版本保持一致,前置运算符应该返回递增或递减后对象的引用。
- 要想同时定义前置和后置运算符,必须首先解决一个问题,即普通的重载形式无法区分这两种情况,前置和后置版本使用的是同一个符号。
- 为了解决这个问题,后置版本接受一个额外的(不被使用)int类型的形参,不会用到int形参,所以无须为其命名
- 为了与内置版本保持一致,后置运算符应该返回对象的原值(递增或递减之前的值),返回的形式是一个值而非引用。
14.7 成员访问运算符
- 在迭代器类及智能指针类中常常用到解引用运算符
*
,->
。 - 箭头运算符必须是类的成员,解引用运算符通常也是类的成员。
- 重载的箭头运算符必须返回类的指针或者自定义了箭头运算符的某个类的对象。
14.8 函数调用运算符
- 函数调用运算符必须是成员函数。
- 一个类可以定义多个不同版本的调用运算符,相互之间应该在参数数量或类型上有所区别。
- 如果类定义了调用运算符,则该类的对象称作函数对象。
- 函数对象常常作为泛型算法的实参。
14.8.1 lambda是函数对象
- 可以用函数对象类替代lambda表达式。
- lambda表达式产生的类不含默认构造函数、赋值运算符及默认析构函数,它是否含有默认的拷贝、移动构造函数则通常要视捕获的数据成员类型而定。
14.8.2 标准库定义的函数对象
- 标准库定义了一组表示算术运算符、关系运算符和逻辑运算符的类,每个类分别定义了一个执行命名操作的调用运算符。
plus<int> intAdd; //可执行int加法的函数对
negata<int> intNegata; //可对int值取反的函数对象
- 在算法中使用标准库函数对象,表示运算符的函数对象类常用来替换算法中的默认运算符。
- 在默认情况下排序算法使用operate<将序列按照升序排列。
- 如果要执行降序排列的话,我们可以传入一个greater类型的对象。
sort(svec.begin(), svec.end(), greater<string>());
- 标准库规定其函数对象对于指针同样适用。
14.8.3 可调用对象与function
- C++语言中有几种可调用的对象:函数、函数指针、lambda表达式以及重载了函数调用运算符的类。
- 和其他对象一样,可调用的对象也有类型,例如,每个lambda有它自己唯一的(未命名)类类型;函数及函数指针的类型则由其返回值类型和实参类型决定。
- 下面共享一种调用形式: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;
}
}
- 如果我们需要为可调用对象构建一个简单的桌面计算器,为了实现这一目的,需要定义一个函数表用于存储指向这些可调用对象的指针。
// 构建从运算符到函数指针的映射关系,其中函数接受两个int、返回一个int
map<string, int(*)(int, int)> binops;
// add是一个指向正确类型函数的指针
binops.insert({"+", add}); // "+", add}是一个pair+
binops.insert({"%", mod}); // 错误:mod不是一个函数指针
-
每个lambda有它自己的类类型,该类型与存储在binops中的值的类型不匹配。
-
标准库function类型。
-
function是一个模板,和我们使用过的其他模板一样,当创建一个具体的function类型时我们必须提供额外的信息。
function<int(int, int)>
function<int(int, int)> fl = 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
- 重新定义map。
map<string, function<int(int, int)>> binops =
{
{ "+", add }, //函数指针
{ "-", std::minus<int>(), } //标准库函数对象
{ "/", divide()}, //用户定义的函数对象
{ "*", [](int i, int j) { return i * j; }} //未命名的lambda
{ "%", mod}; //命名的lambda对象
};
- 不能直接将重载函数的名字存入function类型的对象中。
- 存储函数指针来消除二义性。
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);} });
14.9 重载、类型转换与运算符
- 转换构造函数和类型转换运算符共同定义了类类型转换,这样的转换有时也被称作用户定义的类型转换。
14.9.1 类型转换运算符
- 类型转换运算符是类的一种特殊成员函数,它负责将一个类类型的值转换成其他类型。
- 其中type表示某种类型。类型转换运算符可以面向任意类型(除了void之外)进行定义。
operator type() const;
- 定义含有类型转换运算符的类。
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 si;
si = 4; //首先将4隐式地转换成SmallInt,然后调用SmallInt::operator=
si + 3; //首先将si隐式地转换成int,然后执行整数的加法
- 因为类型转换运算符是隐式执行的,所以无法给这些函数传递实参,当然也就不能在类型转换运算符的定义中使用任何形参。
class Smallint;
operator int(Smallint&); //错误:不是成员函数
class Smallint {
public:
int operator int() const; //错误:指定了返回类型
operator int(int = 0) const //错误:参数列表不为空
operator int*() const { return 42; } //错误:42不是一个指针
- 避免过度使用类型转换函数。
- 类型转换运算符可能产生意外结果,在实践中,类很少提供类型转换运算符。
- 特别是当istream含有向bool的类型转换时,下面的代码仍将编译通过:
- 该代码能使用istream的bool类型转换运算符将cin转换成bool,而这个bool值接着会被提升成int并用作内置的左移运算符的左侧运算对象。这样一来,提升后的bool值(1或0)最终会被左移42个位置。
int i= 42;
cin << i; //如果向bool的类型转换不是显式的,则该代码在编译器看来将是合法的!
- C++11新标准引入了显式的类型转换运算符。
class Smallint
{
public:
//编译器不会自动执行这一类型转换
explicit operator int() canst { return val; }
}
- 和显式的构造函数一样,编译器通常不会将一个显式的类型转换运算符用于隐式类型转换:
Smallint si = 3; //正确:Smallint的构造函数不是显式的
si + 3; //错误:此处需要隐式的类型转换,但类的运算符是显式的
static_cast<int>(si) + 3; //正确:显式地请求类型转换
- 当类型转换运算符是显式的时,我们也能执行类型转换,不过必须通过显式的强制类型转换才可以。
- 当表达式出现在下列位置时,显式的类型转换将被隐式地执行:
- if、while及do语句的条件部分
- for语句头的条件表达式
- !逻辑非、逻辑或!!、逻辑与&&
- 条件运算符(? :)的条件表达式。
- 在标准库的早期版本中,IO类型定义了向void*的转换规则,以求避免上面提到的问题。
- 在C++11新标准下,IO标准库通过定义一个向bool的显式类型转换实现同样的目的。
- 无论我们什么时候在条件中使用流对象,都会使用为IO类型定义的operator bool。
while (std::cin >> value)
- while语句的条件执行输入运算符,它负责将数据读入到value并返回cin。
- 为了对条件求值,cin被istream operator bool类型转换函数隐式地执行了转换。
- 如果cin的条件状态是good,则该函数返回为真,否则该函数返回为假。
14.9.2 避免有二义性的类型转换
- 如果类中包含一个或多个类型转换,则必须确保在类类型和目标类型之间只存在唯一转换方式,否则的话,我们编写的代码将很可能会具有二义性。
- 通常情况下,不要为类定义相同的类型转换,也不要在类中定义两个及两个以上转换原或转换目标是算术类型的转换。
//最好不要在两个类之间构建相同的类型转换
struct B;
struct A
{
A() = default;
A(const B&); //把一个B转换成A
//其他数据成员
};
struct B
{
operator A() const; //也是把一个B转换成A
//其他数据成员
}
A f(const A&); //声明一个函数
B b;
A a = f(b); //二义性错误:含义是 f(B::operator A()) 还是 还是f(A::A(constB&)) ?
A a1 = f(b.operator A()); //正确:使用B的类型转换运算符
A a2 = f(A(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)?
- 调用f2及初始化a2的过程之所以会产生二义性,根本原因是它们所需的标准类型转换级别一致。
- 当我们使用用户定义的类型转换时,如果转换过程包含标准类型转换,并且标准类型转换的级别可确定编译器选择的最佳匹配,那么二义性也不会存在。
short s = 42;
A a3(s); //把short提升成int优于把short转换成double 使用A::A(int)
- 当几个重载函数的参数分属不同的类类型时,如果这些类恰好定义了同样的转换构造函数,则二义性问题将进一步提升:
struct C
{
C(int);
//其他成员
};
struct D
{
D(int);
//其他成员
};
void manip(const C&);
void manip(const D&);
mainp(10); //二义性错误:含义是manip(C(10))还是manip(D(10))
- 如果在调用重载函数时我们需要使用构造函数或者强制类型转换未改变实参的类型,则这通常意味着程序的设计存在不足。
struct E
{
E(double);
//其他成员
};
void manip2(const C&);
void manip2(const E&);
//二义性错误:两个不同的用户定义的类型转换都能用在此处
manip2(10); //含义是manip2(C(l0))还是manip2(E(double(10)))
- 在调用重载函数时,如果需要额外的标准类型转换,则该转换的级别只有当所有可行函数都请求同一个用户定义的类型转换时才有用。如果所需的用户定义的类型转换不止一个,则该调用具有二义性。
14.9.3 函数匹配与重载运算符
- 重载的运算符也是重载的函数。
- 如果a是一种类类型,则表达式a sym b可能是:
a.operatorsym(b); //a有一个operatorsym成员函数
operatorsym(a, b); //operatorsym是一个普通函数
- 表达式中运算符的候选函数集既应该包括成员函数,也应该包括非成员函数。
- 如果我们对同一个类既提供了转换目标是算术类型的类型转换,也提供了重载的运算符,则将会遇到重载运算符与内置运算符的二义性问题。
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; //二义性错误
- 第二条加法语句具有二义性:因为可以把0转换成SmallInt, 然后使用SmallInt的+,或者把s3转换成int, 然后对于两个int执行内置的加法运算。