【C++基础】第八十三课:[重载运算与类型转换]重载、类型转换与运算符

类型转换运算符,二义性的类型转换,函数匹配与重载运算符

Posted by x-jeff on September 20, 2023

【C++基础】系列博客为参考《C++ Primer中文版(第5版)》C++11标准)一书,自己所做的读书笔记。
本文为原创文章,未经本人允许,禁止转载。转载请注明出处。

1.重载、类型转换与运算符

隐式的类类型转换中我们看到由一个实参调用的非显式构造函数定义了一种隐式的类型转换,这种构造函数将实参类型的对象转换成类类型。我们同样能定义对于类类型的类型转换,通过定义类型转换运算符可以做到这一点。转换构造函数和类型转换运算符共同定义了类类型转换(class-type conversions),这样的转换有时也被称作用户定义的类型转换(user-defined conversions)。

2.类型转换运算符

类型转换运算符(conversion operator)是类的一种特殊成员函数,它负责将一个类类型的值转换成其他类型。类型转换函数的一般形式如下所示:

1
operator type() const;

其中type表示某种类型。类型转换运算符可以面向任意类型(除了void之外)进行定义,只要该类型能作为函数的返回类型。因此,我们不允许转换成数组或者函数类型,但允许转换成指针(包括数组指针及函数指针)或者引用类型。

类型转换运算符既没有显式的返回类型,也没有形参,而且必须定义成类的成员函数。类型转换运算符通常不应该改变待转换对象的内容,因此,类型转换运算符一般被定义成const成员。

一个类型转换函数必须是类的成员函数;它不能声明返回类型,形参列表也必须为空。类型转换函数通常应该是const。

2.1.定义含有类型转换运算符的类

举个例子,我们定义一个比较简单的类,令其表示0到255之间的一个整数:

1
2
3
4
5
6
7
8
9
10
11
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类既定义了向类类型的转换,也定义了从类类型向其他类型的转换。其中,构造函数将算术类型的值转换成SmallInt对象,而类型转换运算符将SmallInt对象转换成int:

1
2
3
SmallInt si;
si = 4; //首先将4隐式地转换成SmallInt,然后调用SmallInt::operator=
si + 3; //首先将si隐式地转换成int,然后执行整数的加法

尽管编译器一次只能执行一个用户定义的类型转换(参见:其他隐式类型转换),但是隐式的用户定义类型转换可以置于一个标准(内置)类型转换之前或之后(参见:算术转换),并与其一起使用。因此,我们可以将任何算术类型传递给SmallInt的构造函数。类似的,我们也能使用类型转换运算符将一个SmallInt对象转换成int,然后再将所得的int转换成任何其他算术类型:

1
2
3
4
//内置类型转换将double实参转换成int
SmallInt si = 3.14; //调用SmallInt(int)构造函数
//SmallInt的类型转换运算符将si转换成int
si + 3.14; //内置类型转换将所得的int继续转换成double

因为类型转换运算符是隐式执行的,所以无法给这些函数传递实参,当然也就不能在类型转换运算符的定义中使用任何形参。同时,尽管类型转换函数不负责指定返回类型,但实际上每个类型转换函数都会返回一个对应类型的值:

1
2
3
4
5
6
7
8
class SmallInt;
operator int(SmallInt&); //错误:不是成员函数
class SmallInt {
public:
	int operator int() const; //错误:指定了返回类型
	operator int(int = 0) const; //错误:参数列表不为空
	operator int*() const { return 42; } //错误:42不是一个指针
};

2.2.类型转换运算符可能产生意外结果

在实践中,类很少提供类型转换运算符。在大多数情况下,如果类型转换自动发生,用户可能会感觉比较意外,而不是感觉受到了帮助。然而这条经验法则存在一种例外情况:对于类来说,定义向bool的类型转换还是比较普遍的现象。

在C++标准的早期版本中,如果类想定义一个向bool的类型转换,则它常常遇到一个问题:因为bool是一种算术类型,所以类类型的对象转换成bool后就能被用在任何需要算术类型的上下文中。这样的类型转换可能引发意想不到的结果,特别是当istream含有向bool的类型转换时,下面的代码仍将编译通过:

1
2
int i = 42;
cin << i; //如果向bool的类型转换不是显式的,则该代码在编译器看来将是合法的!

这段程序试图将输出运算符作用于输入流。因为istream本身并没有定义«,所以本来代码应该产生错误。然而,该代码能使用istream的bool类型转换运算符将cin转换成bool,而这个bool值接着会被提升成int并用作内置的左移运算符的左侧运算对象。这样一来,提升后的bool值(1或0)最终会被左移42个位置。这一结果显然与我们的预期大相径庭。

2.3.显式的类型转换运算符

为了防止这样的异常情况发生,C++11新标准引入了显式的类型转换运算符(explicit conversion operator):

1
2
3
4
5
6
class SmallInt {
public:
	//编译器不会自动执行这一类型转换
	explicit operator int() const { return val; }
	//其他成员与之前的版本一致
};

显式的构造函数一样,编译器(通常)也不会将一个显式的类型转换运算符用于隐式类型转换:

1
2
3
SmallInt si = 3; //正确:SmallInt的构造函数不是显式的
si + 3; //错误:此处需要隐式的类型转换,但类的运算符是显式的
static_cast<int>(si) + 3; //正确:显式地请求类型转换

该规定存在一个例外,即如果表达式被用作条件,则编译器会将显式的类型转换自动应用于它。换句话说,当表达式出现在下列位置时,显式的类型转换将被隐式地执行:

  • if、while及do语句的条件部分。
  • for语句头的条件表达式。
  • 逻辑非运算符(!)、逻辑或运算符(||)、逻辑与运算符(&&)的运算对象。
  • 条件运算符(?:)的条件表达式。

2.4.转换为bool

在标准库的早期版本中,IO类型定义了向void*的转换规则,以求避免上面提到的问题。在C++11新标准下,IO标准库通过定义一个向bool的显式类型转换实现同样的目的。

无论我们什么时候在条件中使用流对象,都会使用为IO类型定义的operator bool。例如:

1
while (std::cin >> value)

while语句的条件执行输入运算符,它负责将数据读入到value并返回cin。为了对条件求值,cin被istream operator bool类型转换函数隐式地执行了转换。如果cin的条件状态是good,则该函数返回为真;否则该函数返回为假。

向bool的类型转换通常用在条件部分,因此operator bool一般定义成explicit的。

3.避免有二义性的类型转换

如果类中包含一个或多个类型转换,则必须确保在类类型和目标类型之间只存在唯一一种转换方式。否则的话,我们编写的代码将很可能会具有二义性。

有两种情况下可能产生多重转换路径。第一种情况是两个类提供相同的类型转换:例如,当A类定义了一个接受B类对象的转换构造函数,同时B类定义了一个转换目标是A类的类型转换运算符时,我们就说它们提供了相同的类型转换。

第二种情况是类定义了多个转换规则,而这些转换涉及的类型本身可以通过其他类型转换联系在一起。最典型的例子是算术运算符,对某个给定的类来说,最好只定义最多一个与算术类型有关的转换规则。

通常情况下,不要为类定义相同的类型转换,也不要在类中定义两个及两个以上转换源或转换目标是算术类型的转换。

3.1.实参匹配和相同的类型转换

在下面的例子中,我们定义了两种将B转换成A的方法:一种使用B的类型转换运算符、另一种使用A的以B为参数的构造函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
//最好不要在两个类之间构建相同的类型转换
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(const B&))?

因为同时存在两种由B获得A的方法,所以造成编译器无法判断应该运行哪个类型转换,也就是说,对f的调用存在二义性。该调用可以使用以B为参数的A的构造函数,也可以使用B当中把B转换成A的类型转换运算符。因为这两个函数效果相当、难分伯仲,所以该调用将产生错误。

如果我们确实想执行上述的调用,就不得不显式地调用类型转换运算符或者转换构造函数:

1
2
A a1 = f(b.operator A()); //正确:使用B的类型转换运算符
A a2 = f(A(b)); //正确:使用A的构造函数

值得注意的是,我们无法使用强制类型转换来解决二义性问题,因为强制类型转换本身也面临二义性。

3.2.二义性与转换目标为内置类型的多重类型转换

另外如果类定义了一组类型转换,它们的转换源(或者转换目标)类型本身可以通过其他类型转换联系在一起,则同样会产生二义性的问题。最简单也是最困扰我们的例子就是类当中定义了多个参数都是算术类型的构造函数,或者转换目标都是算术类型的类型转换运算符。

例如,在下面的类中包含两个转换构造函数,它们的参数是两种不同的算术类型;同时还包含两个类型转换运算符,它们的转换目标也恰好是两种不同的算术类型:

1
2
3
4
5
6
7
8
9
10
11
12
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的调用中,哪个类型转换都无法精确匹配long double。然而这两个类型转换都可以使用,只要后面再执行一次生成long double的标准类型转换即可。因此,在上面的两个类型转换中哪个都不比另一个更好,调用将产生二义性。

当我们试图用long初始化a2时也遇到了同样问题,哪个构造函数都无法精确匹配long类型。它们在使用构造函数前都要求先将实参进行类型转换:

  • 先执行long到double的标准类型转换,再执行A(double)。
  • 先执行long到int的标准类型转换,再执行A(int)。

编译器没办法区分这两种转换序列的好坏,因此该调用将产生二义性。

调用f2及初始化a2的过程之所以会产生二义性,根本原因是它们所需的标准类型转换级别一致。当我们使用用户定义的类型转换时,如果转换过程包含标准类型转换,则标准类型转换的级别将决定编译器选择最佳匹配的过程:

1
2
3
short s = 42;
//把short提升成int优于把short转换成double
A a3(s); //使用A::A(int)

3.3.重载函数与转换构造函数

当我们调用重载的函数时,从多个类型转换中进行选择将变得更加复杂。如果两个或多个类型转换都提供了同一种可行匹配,则这些类型转换一样好。

举个例子,当几个重载函数的参数分属不同的类类型时,如果这些类恰好定义了同样的转换构造函数,则二义性问题将进一步提升:

1
2
3
4
5
6
7
8
9
10
11
struct C {
	C(int);
	//其他成员
};
struct D {
	D(int);
	//其他成员
};
void manip(const C&);
void manip(const D&);
manip(10); //二义性错误:含义是manip(C(10))还是manip(D(10))

调用者可以显式地构造正确的类型从而消除二义性:

1
manip(C(10)); //正确:调用manip(const C&)

3.4.重载函数与用户定义的类型转换

当调用重载函数时,如果两个(或多个)用户定义的类型转换都提供了可行匹配,则我们认为这些类型转换一样好。在这个过程中,我们不会考虑任何可能出现的标准类型转换的级别。只有当重载函数能通过同一个类型转换函数得到匹配时,我们才会考虑其中出现的标准类型转换。

例如当我们调用manip时,即使其中一个类定义了需要对实参进行标准类型转换的构造函数,这次调用仍然会具有二义性:

1
2
3
4
5
6
7
8
struct E {
	E(double);
	//其他成员
};
void manip2(const C&);
void manip2(const E&);
//二义性错误:两个不同的用户定义的类型转换都能用在此处
manip2(10); //含义是manip2(C(10))还是manip2(E(double(10)))

因为调用重载函数所请求的用户定义的类型转换不止一个且彼此不同,所以该调用具有二义性。即使其中一个调用需要额外的标准类型转换而另一个调用能精确匹配,编译器也会将该调用标示为错误。

在调用重载函数时,如果需要额外的标准类型转换,则该转换的级别只有当所有可行函数都请求同一个用户定义的类型转换时才有用。如果所需的用户定义的类型转换不止一个,则该调用具有二义性。

4.函数匹配与重载运算符

重载的运算符也是重载的函数。因此,通用的函数匹配规则同样适用于判断在给定的表达式中到底应该使用内置运算符还是重载的运算符。不过当运算符函数出现在表达式中时,候选函数集的规模要比我们使用调用运算符调用函数时更大。如果a是一种类类型,则表达式a sym b可能是:

1
2
a.operatorsym(b); //a有一个operatorsym成员函数
operatorsym(a, b); //operatorsym是一个普通函数

和普通函数调用不同,我们不能通过调用的形式来区分当前调用的是成员函数还是非成员函数。

当我们使用重载运算符作用于类类型的运算对象时,候选函数中包含该运算符的普通非成员版本和内置版本。除此之外,如果左侧运算对象是类类型,则定义在该类中的运算符的重载版本也包含在候选函数内。

当我们调用一个命名的函数时,具有该名字的成员函数和非成员函数不会彼此重载,这是因为我们用来调用命名函数的语法形式对于成员函数和非成员函数来说是不相同的。当我们通过类类型的对象(或者该对象的指针及引用)进行函数调用时,只考虑该类的成员函数。而当我们在表达式中使用重载的运算符时,无法判断正在使用的是成员函数还是非成员函数,因此二者都应该在考虑的范围内。

表达式中运算符的候选函数集既应该包括成员函数,也应该包括非成员函数。

举个例子,我们为SmallInt类定义一个加法运算符:

1
2
3
4
5
6
7
8
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对象相加,但如果我们试图执行混合模式的算术运算,就将遇到二义性的问题:

1
2
3
SmallInt s1, s2;
SmallInt s3 = s1 + s2; //使用重载的operator+
int i = s3 + 0; //二义性错误

第一条加法语句接受两个SmallInt值并执行+运算符的重载版本。第二条加法语句具有二义性:因为我们可以把0转换成SmallInt,然后使用SmallInt的+;或者把s3转换成int,然后对于两个int执行内置的加法运算。

如果我们对同一个类既提供了转换目标是算术类型的类型转换,也提供了重载的运算符,则将会遇到重载运算符与内置运算符的二义性问题。