重载运算与类型转换
一个电话彷佛否定了一直以来所做的一切,不知所措的打完最后一个字......
重载运算与类型转换
基本概念(Basic Concepts)
重载的运算符是具有特殊名字的函数,它们的名字由关键字operator
和其后要定义的运算符号组成。
重载运算符函数的参数数量和该运算符作用的运算对象数量一样多。对于二元运算符来说,左侧运算对象传递给第一个参数,右侧运算对象传递给第二个参数。除了重载的函数调用运算符operator()
之外,其他重载运算符不能含有默认实参。
如果一个运算符函数是类的成员函数,则它的第一个运算对象会绑定到隐式的this
指针上。因此成员运算符函数的显式参数数量比运算对象的数量少一个。
当运算符作用于内置类型的运算对象时,无法改变该运算符的含义。
只能重载大多数已有的运算符,无权声明新的运算符号。
可以被重载的运算符:
1
2
3
4
5
6
7+ - * / % ^
& | ~ ! , =
< > <= >= ++ --
<< >> == != && ||
+= -= /= %= ^= &=
|= *= <<= >>= [] ()
-> ->* new new [] delete delete []不能被重载的运算符:
1
:: .* . ?:
重载运算符的优先级和结合律与对应的内置运算符一致。
可以像调用普通函数一样直接调用运算符函数。
1 | // equivalent calls to a nonmember operator function |
通常情况下,不应该重载逗号,
、取地址&
、逻辑与&&
和逻辑或||
运算符。
建议只有当操作的含义对于用户来说清晰明了时才使用重载运算符,重载运算符的返回类型也应该与其内置版本的返回类型兼容。
如果类中含有算术运算符或位运算符,则最好也提供对应的复合赋值运算符。
把运算符定义为成员函数时,它的左侧运算对象必须是运算符所属类型的对象。
1 | string s = "world"; |
string s = "world";
:
- 定义了一个字符串
s
,并初始化为"world"
。string t = s + "!";
:
- 将字符串
s
和一个常量字符串"!"
进行拼接。- 这是一个合法的操作,因为C++允许将一个常量字符串(
const char*
)与一个std::string
进行拼接,C++编译器会自动将常量字符串转换为std::string
对象,然后执行拼接操作。string u = "hi" + s;
:
- 这行代码尝试将一个常量字符串
"hi"
与字符串s
进行拼接。- 这样的操作在C++中是错误的,如果
+
是std::string
类的成员函数,那么这将会是一种错误的用法。因为在C++中,std::string
的+
操作符重载函数要求左操作数是std::string
类型。- 但实际上,
+
不是std::string
类的成员函数,而是由C++标准库提供的全局函数。C++中对字符串进行拼接时,可以将常量字符串和std::string
对象放在一起,编译器会自动将常量字符串转换为std::string
对象,然后执行拼接操作。
如何选择将运算符定义为成员函数还是普通函数:
- 赋值
=
、下标[]
、调用()
和成员访问箭头->
运算符必须是成员函数。 - 复合赋值运算符一般是成员函数,但并非必须。
- 改变对象状态或者与给定类型密切相关的运算符,如递增、递减、解引用运算符,通常是成员函数。
- 具有对称性的运算符可能转换任意一端的运算对象,如算术、相等性、关系和位运算符,通常是普通函数。
输入和输出运算符(Input and Output Operators)
重载输出运算符<<
(Overloading
the Output Operator <<
)
通常情况下,输出运算符的第一个形参是ostream
类型的普通引用,第二个形参是要打印类型的常量引用,返回值是它的ostream
形参。
1 | ostream &operator<<(ostream &os, const Sales_data &item) |
输出运算符应该尽量减少格式化操作。
输入输出运算符必须是非成员函数。而由于IO操作通常需要读写类的非公有数据,所以输入输出运算符一般被声明为友元。(一般是设置成为友元)
重载输入运算符>>
(Overloading
the Input Operator >>
)
通常情况下,输入运算符的第一个形参是要读取的流的普通引用,第二个形参是要读入的目的对象的普通引用,返回值是它的第一个形参。
1 | istream &operator>>(istream &is, Sales_data &item) |
输入运算符必须处理输入失败的情况,而输出运算符不需要。
以下情况可能导致读取操作失败:
- 读取了错误类型的数据。
- 读取操作到达文件末尾。
- 遇到输入流的其他错误。
当读取操作发生错误时,输入操作符应该负责从错误状态中恢复。
如果输入的数据不符合规定的格式,即使从技术上看IO操作是成功的,输入运算符也应该设置流的条件状态以标示出失败信息。通常情况下,输入运算符只设置failbit
状态。eofbit
、badbit
等错误最好由IO标准库自己标示。
算术和关系运算符(Arithmetic and Relational Operators)
通常情况下,算术和关系运算符应该定义为非成员函数,以便两侧的运算对象进行转换。其次,由于这些运算符一般不会改变运算对象的状态,所以形参都是常量引用。
算术运算符通常会计算它的两个运算对象并得到一个新值,这个值通常存储在一个局部变量内,操作完成后返回该局部变量的副本作为结果(返回类型建议设置为原对象的const
类型)。
1 | // assumes that both objects refer to the same book |
如果类定义了算术运算符,则通常也会定义对应的复合赋值运算符,此时最有效的方式是使用复合赋值来实现算术运算符。
相等运算符(Equality Operators)
相等运算符设计准则:
如果类在逻辑上有相等性的含义,则应该定义
operator==
而非一个普通的命名函数。这样做便于使用标准库容器和算法,也更容易记忆。通常情况下,
operator==
应该具有传递性。如果类定义了
operator==
,则也应该定义operator!=
。operator==
和operator!=
中的一个应该把具体工作委托给另一个。1
2
3
4
5
6
7
8
9
10
11bool 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);
}
关系运算符(Relational Operators)
定义了相等运算符的类通常也会定义关系运算符。因为关联容器和一些算法要用到小于运算符,所以定义operator<
会比较实用。
关系运算符设计准则:
- 定义顺序关系,令其与关联容器中对关键字的要求保持一致。
- 如果类定义了
operator==
,则关系运算符的定义应该与operator==
保持一致。特别是,如果两个对象是不相等的,那么其中一个对象应该小于另一个对象。 - 只有存在唯一一种逻辑可靠的小于关系时,才应该考虑为类定义
operator<
。
赋值运算符(Assignment Operators)
赋值运算符必须定义为成员函数,复合赋值运算符通常也是如此。这两类运算符都应该返回其左侧运算对象的引用。
1 | StrVec &StrVec::operator=(initializer_list<string> il) |
下标运算符(Subscript Operator)
下标运算符必须定义为成员函数。
类通常会定义两个版本的下标运算符:一个返回普通引用,另一个是类的常量成员并返回常量引用。
1 | class StrVec |
递增和递减运算符(Increment and Decrement Operators)
定义递增和递减运算符的类应该同时定义前置和后置版本,这些运算符通常定义为成员函数。
为了与内置操作保持一致,前置递增或递减运算符应该返回运算后对象的引用。
1 | // prefix: return a reference to the incremented/decremented object |
后置递增或递减运算符接受一个额外的(不被使用)int
类型形参,该形参的唯一作用就是区分运算符的前置和后置版本。
1 | class StrBlobPtr |
为了与内置操作保持一致,后置递增或递减运算符应该返回运算前对象的原值(返回类型建议设置为原对象的const
类型)。
1 | StrBlobPtr StrBlobPtr::operator++(int) |
如果想通过函数调用的方式使用后置递增或递减运算符,则必须为它的整型参数传递一个值。
1 | StrBlobPtr p(a1); // p points to the vector inside a1 |
成员访问运算符(Member Access Operators)
箭头运算符必须定义为成员函数,解引用运算符通常也是如此。
重载的箭头运算符必须返回类的指针或者自定义了箭头运算符的类的对象。
1 | class StrBlobPtr |
对于形如point->mem
的表达式来说,point
必须是指向类对象的指针或者是一个重载了operator->
的类的对象。point
类型不同,point->mem
的含义也不同。
- 如果
point
是指针,则调用内置箭头运算符,表达式等价于(*point).mem
。 - 如果
point
是重载了operator->
的类的对象,则使用point.operator->()
的结果来获取mem
,表达式等价于(point.operator->())->mem
。其中,如果该结果是一个指针,则执行内置操作,否则重复调用当前操作。
函数调用运算符(Function-Call Operator)
函数调用运算符必须定义为成员函数。一个类可以定义多个不同版本的调用运算符,相互之间必须在参数数量或类型上有所区别。
1 | class PrintString |
如果类定义了调用运算符,则该类的对象被称作函数对象(function object),函数对象常常作为泛型算法的实参。
lambda是函数对象(Lambdas Are Function Objects)
编写一个lambda
后,编译器会将该表达式转换成一个未命名类的未命名对象,类中含有一个重载的函数调用运算符。
1 | // sort words by size, but maintain alphabetical order for words of the same size |
lambda
默认不能改变它捕获的变量。因此在默认情况下,由lambda
产生的类中的函数调用运算符是一个const
成员函数。如果lambda
被声明为可变的,则调用运算符就不再是const
函数了。
lambda
通过引用捕获变量时,由程序负责确保lambda
执行时该引用所绑定的对象确实存在。因此编译器可以直接使用该引用而无须在lambda
产生的类中将其存储为数据成员。相反,通过值捕获的变量被拷贝到lambda
中,此时lambda
产生的类必须为每个值捕获的变量建立对应的数据成员,并创建构造函数,用捕获变量的值来初始化数据成员。
1 | // get an iterator to the first element whose size() is >= sz |
lambda
产生的类不包含默认构造函数、赋值运算符和默认析构函数,它是否包含默认拷贝/移动构造函数则通常要视捕获的变量类型而定。
标准库定义的函数对象(Library-Defined Function Objects)
标准库在头文件functional
中定义了一组表示算术运算符、关系运算符和逻辑运算符的类,每个类分别定义了一个执行命名操作的调用运算符。这些类都被定义为模板的形式,可以为其指定具体的应用类型(即调用运算符的形参类型)。
算数运算:
1
2plus<T> divides<T> minus<T>
modulus<T> multiplies<T> negate<T>关系运算:
1
2
3equal_to<T> not_equal_to<T>
greater<T> greater_equal<T>
less<T> less_equal<T>逻辑运算:
1
logical_and<T> logical_or<T> logical_not<T>
关系运算符的函数对象类通常被用来替换算法中的默认运算符,这些类对于指针同样适用。
1 | vector<string *> nameTable; // vector of pointers |
可调用对象与function
(Callable
Objects and function
)
调用形式指明了调用返回的类型以及传递给调用的实参类型。不同的可调用对象可能具有相同的调用形式。
标准库function
类型是一个模板,定义在头文件functional
中,用来表示对象的调用形式。
操作 | 含义 |
---|---|
function<T> f |
f 是可以存储调用形式为T 的可调用对象的空function |
function<T> f(nullptr) |
显式构造一个空function |
function<T> f(obj) |
f 是可调用对象obj 的拷贝 |
f |
当f 含有可调用对象时为true |
f(args) |
使用参数args 调用f 中的对象 |
创建一个具体的function
类型时必须提供其所表示的对象的调用形式。
1 | // ordinary function |
不能直接将重载函数的名字存入function
类型的对象中,这样做会产生二义性错误。消除二义性的方法是使用lambda
或者存储函数指针而非函数名字。
C++11新标准库中的function
类与旧版本中的unary_function
和binary_function
没有关系,后两个类已经被bind
函数代替。
重载、类型转换与运算符(Overloading,Conversions,and Operators)
转换构造函数和类型转换运算符共同定义了类类型转换(class-type conversion)。
类型转换运算符(Conversion Operators)
类型转换运算符是类的一种特殊成员函数,负责将一个类类型的值转换成其他类型。它不能声明返回类型,形参列表也必须为空,一般形式如下:
1 | operator type() const; |
类型转换运算符可以面向除了void
以外的任意类型(该类型要能作为函数的返回类型)进行定义。
1 | class SmallInt |
这段代码定义了一个名为
SmallInt
的类,该类表示一个范围在 0 到 255 之间的小整数。它有一个私有成员val
用来存储这个小整数的值。构造函数
SmallInt(int i = 0)
接受一个整数参数i
,并将其赋值给val
。在构造对象时,如果传入的整数值i
不在 0 到 255 的范围内,则抛出一个std::out_of_range
异常,提示值超出了可接受的范围。
operator int() const
是一个类型转换操作符,它将SmallInt
类型对象转换为int
类型。这使得SmallInt
对象可以像普通的整数一样进行操作和使用,因为它可以自动转换为int
类型。总的来说,这个类的作用是提供了一种限制了取值范围的整数类型,确保其取值在 0 到 255 之间。
隐式的用户定义类型转换可以置于一个标准(内置)类型转换之前或之后,并与其一起使用。
1 | // the double argument is converted to int using the built-in conversion |
SmallInt si = 3.14;
: 这一行创建了一个SmallInt
类型的对象si
,并用双精度浮点数3.14
初始化它。因为SmallInt
类有一个接受整数参数的构造函数,所以这里会发生类型转换,将3.14
转换为整数。由于3.14
被截断成3
,然后传递给构造函数,因此会调用SmallInt(int)
构造函数来创建si
对象。
si + 3.14;
: 这一行将si
与3.14
相加。在这个表达式中,si
是一个SmallInt
类型的对象,但3.14
是一个双精度浮点数。根据 C++ 中的内置类型转换规则,si
需要先转换为int
类型,然后与3
相加。所以si
会调用其类型转换操作符operator int()
将其转换为整数。然后,得到的整数3
会与3.14
相加。最终结果是一个双精度浮点数,因为整数3
会自动转换为3.0
,然后与3.14
相加。
应该避免过度使用类型转换函数。如果在类类型和转换类型之间不存在明显的映射关系,则这样的类型转换可能具有误导性。
C++11引入了显示的类型转换运算符(explicit conversion operator)。和显式构造函数一样,编译器通常不会将显式类型转换运算符用于隐式类型转换。
1 | class SmallInt |
如果表达式被用作条件,则编译器会隐式地执行显式类型转换。
if
、while
、do-while
语句的条件部分。for
语句头的条件表达式。- 条件运算符
? :
的条件表达式。 - 逻辑非运算符
!
、逻辑或运算符||
、逻辑与运算符&&
的运算对象。
类类型向bool
的类型转换通常用在条件部分,因此operator bool
一般被定义为显式的。
避免有二义性的类型转换(Avoiding Ambiguous Conversions)
在两种情况下可能产生多重转换路径:
A
类定义了一个接受B
类对象的转换构造函数,同时B
类定义了一个转换目标是A
类的类型转换运算符。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19// usually a bad idea to have mutual conversions between two class types
struct B;
struct A
{
A() = default;
A(const B&); // converts a B to an A
// other members
};
struct B
{
operator A() const; // also converts a B to an A
// other members
};
A f(const A&);
B b;
A a = f(b); // error ambiguous: f(B::operator A())
// or f(A::A(const B&))类定义了多个类型转换规则,而这些转换涉及的类型本身可以通过其他类型转换联系在一起。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15struct A
{
A(int = 0); // usually a bad idea to have two
A(double); // conversions from arithmetic types
operator int() const; // usually a bad idea to have two
operator double() const; // conversions to arithmetic types
// other members
};
void f2(long double);
A a;
f2(a); // error ambiguous: f(A::operator int())
// or f(A::operator double())
long lg;
A a2(lg); // error ambiguous: A::A(int) or A::A(double)
这段代码定义了一个名为
A
的结构体,其中包含了两个构造函数和两个类型转换操作符。这会导致一些潜在的问题和二义性。
A(int = 0);
和A(double);
: 这是两个构造函数,分别接受一个整数和一个双精度浮点数作为参数。这样的设计有时候会引起问题,因为编译器可能会在进行类型匹配时出现歧义。
operator int() const;
和operator double() const;
: 这是两个类型转换操作符,分别将A
类型对象转换为整数和双精度浮点数。同样地,这样的设计也可能导致使用时的歧义,因为编译器无法确定应该选择哪个转换操作符。在主代码块中:
f2(a);
: 这一行代码尝试将a
转换为long double
类型,并传递给函数f2
。但是存在问题,因为A
类型有两个可能的转换:operator int()
和operator double()
。因此,编译器无法确定应该调用哪个转换操作符,因此会报告错误,指出这里存在二义性。
A a2(lg);
: 这一行代码尝试用long
类型变量lg
初始化A
类型对象a2
。但同样存在问题,因为long
类型既可以通过A(int)
构造函数来初始化,也可以通过A(double)
构造函数来初始化。编译器无法确定应该调用哪个构造函数,因此会报告错误,指出这里存在二义性。
可以通过显式调用类型转换运算符或转换构造函数解决二义性问题,但不能使用强制类型转换,因为强制类型转换本身也存在二义性。
1 | A a1 = f(b.operator A()); // ok: use B's conversion operator |
通常情况下,不要为类定义相同的类型转换,也不要在类中定义两个及两个以上转换源或转换目标都是算术类型的转换。
使用两个用户定义的类型转换时,如果转换前后存在标准类型转换,则由标准类型转换决定最佳匹配。
如果在调用重载函数时需要使用构造函数或者强制类型转换来改变实参的类型,通常意味着程序设计存在不足。
调用重载函数时,如果需要额外的标准类型转换,则该转换只有在所有可行函数都请求同一个用户定义类型转换时才有用。如果所需的用户定义类型转换不止一个,即使其中一个调用能精确匹配而另一个调用需要额外的标准类型转换,也会产生二义性错误。
1 | struct C |
函数匹配与重载运算符(Function Matching and Overloaded Operators)
表达式中运算符的候选函数集既包括成员函数,也包括非成员函数。
1 | class SmallInt |
SmallInt(int = 0);
: 这是一个构造函数,用于将整数转换为SmallInt
类型。
operator int() const { return val; }
: 这是一个类型转换操作符,允许SmallInt
类型对象隐式转换为整数。
friend SmallInt operator+(const SmallInt&, const SmallInt&);
: 这是声明了一个友元函数operator+
,允许两个SmallInt
类型对象进行加法运算。在主代码块中:
SmallInt s1, s2;
: 声明了两个SmallInt
类型的对象s1
和s2
。
SmallInt s3 = s1 + s2;
: 这行代码调用了重载的加法运算符+
,对s1
和s2
进行加法操作,并将结果赋值给s3
。因为友元函数operator+
允许SmallInt
类型对象进行加法运算,所以这行代码是合法的。
int i = s3 + 0;
: 这行代码试图将s3
转换为整数,然后再与整数0
相加,但此处存在歧义。因为在SmallInt
类中定义了类型转换操作符,使得SmallInt
类型对象可以隐式转换为整数,同时又重载了operator+
让两个SmallInt
类型对象可以相加,所以编译器无法确定应该调用哪个操作符。因此,编译器报告错误,指出这里存在二义性。
如果类既定义了转换目标是算术类型的类型转换,也定义了重载的运算符,则会遇到重载运算符与内置运算符的二义性问题。