C++转换构造函数和类型转换运算符共同定义了类类型志换(class-type conversions),这样的转换有时也被称作用户定义的类型转换(user-defined conversions)。
类型转换运算符
类型转换运算符(conversion operator)是类的一种特殊成员函数,它负责将一个类类型的值转换成其它类型,类型转换函数的一般形式如下所示:
operator type() const;
其中type表示某种类型,类型转换运算符可以面向任意类型(除了void之外)进行定义。只要该类型能作为函数的返回类型。因此,我们不允许转换成数组或者函数类型,但允许转换成指针(包括数组指针及函数指针)或者引用类型。
类型转换运算符既没有显式的返回类型,也没有形参,而且必须定义成类的成员函数。类型转换运算符通常不应该改变待转换对象的内容,因此,类型转换运算符一般被定义成const成员。
一个类型转换函数必须是类的成员函数;它不能声明返回类型,形参列表也必须为空。类型转换函数通常应该是const
定义含有类型转换运算符的类
举个例子,我们定义一个比较简单的类,令其表示0到255之间的一个整数:
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:
SmallInt si;
si = 4; // 首先将4隐式的SmallInt,然后调用SmallInt::operator=
si + 3; // 首先将si隐式转换成int,然后执行整数的加法
尽管编译器一次只能执行一个用户定义的类型转换,但是隐式的用户定义类型转换可以置于一个标准(内置)类型转换之前或之后,并与其一起使用。因此,我们可以将任何算术类型传递给SmallInt的构造函数。类似的,我们也能使用类型转换运算将一个SmallInt对象转成int,然后再将所得的int转换成任何其它算术类型:
// 内置类型转换将double实参转换成int
SmallInt si = 3.14; // 调用SmallInt(int)构造函数
// SmallInt的类型转换运算符将si转换成int
si + 3.14; // 内置类型转换将所得的int继续转换成double
因为类型转换运算符隐式执行的,所以无法给这些函数传递实参,当然也就不能在类型转换符的定义中使用任何形参。同时,尽管类型转换函数不负责指定返回类型,但实际上每个类型转换函数都会返回一个对应类型的值:
class SmallInt;
operator int(SmallInt&); // 错误:不是成员函数
class SmallInt {
public:
int operator int() const; // 错误:指定了返回类型
operator int(int = 0) const; // 错误:参数列表不为空
operator int*() const { return 42; } // 错误:42不是一个指针
private:
std::size_t val;
};
提示:避免过度使用类型转换函数
和使用重载运行符的经验一样,明智地使用类型转换运算符也能极大地简化类设计者的工作,同时使得使用类更加容易。然而,如果在类类型和转换类型之前不存在明显的映射关系,则这样的类型转换可能具有误导性。
例如,假设某个表示Date,我们也许会为它添加一个从Date到int的转换。然而,类型转换函数的返回值应该是什么?一种可能的解释是,函数返回一个十进制数,依次表示年、月、日,例如,July 30,1989可能转换为int值19890730,同时还存在另外一种合理的解释,即类型转换运算符返回的int表示的是从某个时间节点(比如January 1,1970)开始经过的天数。显然这两种理解都合情合理,毕竟从形式上看它们产生的效果都是越靠后的日期对应的整数值越大,而且两种转换都有实际的用处。
问题在于Date类型的对象和int类型的值之前不存在明确的一对一映射北,因此在此例中,不定义该类类型转换运算符会更好。作为替代的手段,类可以定义一个或多个普通的成员函数以从各种不同形式中提取所需的信息。
类型转换运算符可能产生意外结果
在实践中,类很少提供类型转换运算符。在大多数情况下,如果类型转换自动发生,用户可能会感觉比较意外,而不是感觉受到了帮助。然而这条经验法则存在一各占例外情况:对于类来说,定义向bool的类型转换还是比较普遍的现象。
在C++标准的早期版本中,如果类想定义一个向bool的类型转换,则它常常遇到一个问题:因为bool是一种算术类型,所以类类型的对象转换成bool后就能被用在任何需要算术类型的上下文中,这样的类型转换可能引发意想不到的结果,特别是当istream含有向bool的类型转换时,下面的代码仍将编译通过:
int i = 42;
cin << i; // 如果向bool的类型转换不是显式的,则该代码在编译器看来将是合法的
这段程序试图输出运算符作用于输入流。因为istream本身并没有定义<<,所以本来代码应该产生错误。然而,该代码使用istream的bool类型转换运算符将cin转换成bool,而这个bool值接着会被提升成int并用作内置的左移运算符的左侧运算对象。这样一来,提升后的bool值(1或0)最终会被左移42个位置,这一结果显然与我们的预期不同。
显式的类型转换运算符
为了防止这样的异常情况发生,C++11新标准引入了显式的类型转换运算符(explicit conversion operator):
class SmallInt {
public:
// 编译器不会自动执行这一类型转换
explicit operator int() const { return val; }
// 其它成员与之前的版本一致
};
和显式的构造函数一样,编译器(通常)也不会将一个显式的类型转换运算符用于隐式类型转换:
SmallInt si = 3; // 正确:SmallInt的构造函数不是显式的
si + 3; // 错误:此处需要隐式的类型转换,但类的运算符是显式的
static_cast<int>(si) + 3; // 正确:显式的请求类型转换
当类型转换运算符是显式的时,我们也能执行类型转换,不过必须通过显式的强制类型转换才可以。
该规定存在一个例外,即如果 表达式被用作条件,则编译器会将显式的类型转换自动应用于它。换句话说,当表达式出现下列位置时,显式的类型转换将隐式地执行:
- if、while及do语句的条件部分
- for语句头的条件表达式
- 逻辑非运算符(!)、逻辑或运算符(||)、逻辑运算符(&&)的运算
- 条件运算符(? :)的条件表达式。
转换为bool
在标准库的早期版本中,IO类型定义向void*的转换规则,以求避免上面提到的问题,在C++11新标准下,IO标准库通过定义一个向bool的显式类型转换实现同样的目的。
无论我们什么时候在条件中使用流对象,都会使用为IO类型定义的operator bool。例如:
while (std::cin >> value)
while语句的条件执行输入运算符,它负责将数据读入到value并返回cin。为了对条件求值,cin被istream operator bool类型转换函数隐式地执行了转换。如果cin的条件状态是good,则该函数返回真:否则该函数返回为假。
向bool的类型转换通常用在条件部分,因此operator bool一般定义成explicit的。
避免有二义性的类型转换
如果类中包含一个或多个类型转换,则必须确保在类类型和目标类型之间只存在唯一一种转换方式。否则的话,我们编写的代码将很可能会具有二义性。
在两种情况下可能产生多重转换路径。第一种情况两个类提供相同的类型转换:例如,当A类定义了个接受B类对象的转换构造函数,同时B类定义了一个转换目标是A类的类型转换运算符时,我们就说它们提供了相同的类型转换。
第二种情况是类定义了多个转换规则,而这些转换涉及的类型本身可以通过其它类型转换联系在一起。最典型的例子是算术运算符,对某个给定的类来说,最好只定义最多一个与算术类型有关的转换规则。
通常情况下,不要为类定义相同的类型转换,也不要在类中定义两个及两个以上转换源或转换目标是版本类型的转换。
实参匹配和相同的类型转换
在下面的例子中,我们定义了两种将B转成A的方法:一种使用B的类型转换运算符,另一种使用A的以B为参数的构造函数:
// 最好不要在两个类之间构建相同的类型转换
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的类型转换运算符。因为这两个函数效果相当、难分伯仲,所以该调用将产生错误。
如果我们确实想执行上述的调用,就不得不显式地调用类型转换运算符或者转换构造函数:
A al = 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的调用中,哪个类型转换都无法精确匹配long double。然而这两个类型转换都可以使用,只要后面再执行一次生成long double的标准类型转换即可。因此,在上面的两个类型转换中哪个都不比另一个更好,调用将产生二义性。
当我们试图用long初始化a2时也遇到了同样的问题,哪个构造函数都无法精确匹配long类型。它们在使用构造函数前都要求先将实参进行类型转换:
- 先执行long到double的标准类型转换,再执行A(double)
- 先执行long到int的标准类型转换,再执行A(int)
编译器没办法区分这两种转换序列的好坏,因此调用将产生二义性。
调用f2及初始化a2的过程之所以会产生二义性,根本原因是它们所需要的标准类型转换级别一致。当我们使用用户定义的类型转换时,如果转换过程包含标准类型转换,则标准类型转换的级别将决定编译器选择最佳匹配的过程:
short a = 33;
// 把short提升成int优于把short转换成double
A a3(a); // 使用A::A(int)
在此例中,把short提升成int的操作优于把short转换成double的操作,因此编译器将使用A::A(int)构造函数构造a3,其中实参是a(提升后)的值。
当我们使用两个用户定义的类型转换时,如果转换函数之前或之后存在标准类型转换,则标准类型转换将决定最佳匹配到底是哪个
提示:类型转换与运算符
要想正确地设计类的重载运算符、转换构造函数及类型转换函数,必须加倍小心。尤其是当类同时定义了类型转换运算符及重载运算符时特别容易产生二义性。以下的经验规则:
- 不要令两个类执行相同的类型转换:如果Foo类有一个接受Bar类对象的构造函数,则不要在Bar类中再定义转换目标是Foo类的类型转换运算符。
- 避免转换目标是内置算术类型的类型转换。特别是当你已经定义了一个转换成算术类型的类型转换时,接下来
不要再定义接受算术类型的重载运算符。如用户需要使用这样的运算符,则类型转换操作将转换你的类型对象,然后使用内置的运算符。
不要定义转换到多种算术类型的类型转换。让标准类型转换完成向其它算术类型转换的工作。
一言以蔽之:除了显式地向bool类型的转换之外,我们应该尽量避免定义类型转换函数并尽可能地限制那些“显然正确”的非显式构造函数。