第18章 运算符重载(Operator Overloading)
目录
18.2.1 二元和一元运算符(Binary and Unary Operators)
18.2.2 运算符的预定义含义 (Predefined Meanings for Operators)
18.2.3 运算符和用户定义类型(Operators and User-Defined Types)
18.2.5 命名空间中的运算符(Operators in Namespaces)
18.3 一种复数类型(A Complex Number Type)
18.3.1 成员和非成员运算符(Member and Nonmember Operators)
18.3.2 混合模式算术(Mixed-Mode Arithmetic)
18.3.5 读写函数(Accessor Functions)
18.4.2 explicit型转换运算符(explicit Conversion Operators)
18.1 引言
每个技术领域——以及大多数非技术领域——都开发了传统的速记符号,以方便涉及常用概念的演示和讨论。比如,因为认识已久,
x+y∗z
对我们而言比
用z 乘以 y 并将结果加到x
更清晰。
很难高估常用操作的简洁符号的重要性。
与大多数编程语言一样,C++ 支持其内置类型的一组运算符。然而,大多数通常使用运算符的概念都不是 C++ 中的内置类型,因此它们必须表示为用户定义的类型。例如,如果您需要 C++ 中的复杂算术、矩阵代数、逻辑信号或字符串,则可以使用类来表示这些概念。为这样的类定义运算符有时允许程序员提供比仅使用基本函数符号更传统和更方便的符号来操作对象。考虑:
class complex { // 非常简化的复数
double re, im;
public:
complex(double r, double i) :re{r}, im{i} { }
complex operator+(complex);
complex operator∗(complex);
};
这定义了复数概念的简单实现。复数由一对由运算符 + 和 * 操作的双精度浮点数表示。程序员定义complex::operator+()和complex::operator*()来分别提供 + 和 * 的含义。例如,如果 b 和 c 是复数类型,则 b+c 表示 b.operator+(c)。我们现在可以近似complex表达式的传统解释:
void f()
{
complex a = complex{1,3.1};
complex b {1.2, 2};
complex c {b};
a = b+c;
b = b+c∗a;
c = a∗b+complex(1,2);
}
通常的算术优先级规则成立,因此第二个语句意味着 b=b+(c*a),而不是 b=(b+c)*a。
请注意,C++ 语法的编写使得 {} 表示法只能用于初始值设定项和赋值的右侧:
void g(complex a, complex b)
{
a = {1,2}; //OK: 赋值的右边
a += {1,2}; // OK: 赋值的右边
b = a+{1,2}; // 语法错误
b = a + complex{1,2}; // OK
g(a,{1,2}); // OK: 函数参数被视为是初始化器
{a,b} = {b,a}; // 语法错误
}
似乎没有根本原因不在更多地方使用 {},但编写允许在表达式中的任何位置使用 {} 的语法存在技术问题(例如,您如何知道一个分号后面的 { 是表达式或块的开始?)并且提供良好的错误消息导致表达式中 {} 的使用更加有限。
运算符重载的许多最明显的用途都是针对数值类型。然而,用户定义运算符的用途并不限于数值类型。例如,通用抽象接口的设计常常会导致使用->、[]、()等运算符。
18.2 运算符函数
可以声明定义以下运算符 (§10.3) 含义的函数:
+ − ∗ / % ˆ &
| ˜ ! = < > +=
−= ∗= /= %= ˆ= &= |=
<< >> >>= <<= == != <=
>= && || ++ −− −>∗ ,
−> [] () new new[] delete delete[]
用户不能定义以下运算符:
:: —— 作用域解析符 (§6.3.4, §16.2.12)
. ——成员选择符(§8.2)
.∗ ——通过指向成员的指针实现的成员选择符(§20.6)
它们采用名称而不是值作为第二个操作数,并提供引用成员的主要方法。允许它们重载会导致微妙的情况 [Stroustrup,1994]。名称型 “运算符”不能重载,因为它们报告有关其操作数的基本事实:
sizeof ——计算对象所占内存的大小(§6.2.8)
alignof ——对象对齐运算符 (§6.2.9)
typeid ——一个对象的type_info 运算符 (§22.5)
最后,三元条件表达式运算符不能重载(没有特别根本的原因):
?: ——条件评估运算符(§9.4.1)
此外,用户定义的文字量(第 19.2.6 节)是通过使用operator“”表示法来定义的。这是一种语法诡计,因为没有名为“”的运算符。类似地,operator T() 定义一种到类型 T 的转换(第 18.4 节)。
无法定义新的运算符标记(tokens),但当这组运算符不足够时,您可以使用函数调用表示法。例如,使用 pow(),而不是 ***。这些限制看似严厉,但更灵活的规则很容易导致歧义。例如,定义一个运算符 *** 来表示求幂可能看起来是一个显而易见且简单的任务,但请再想一想。 *** 应该绑定到左侧(如 Fortran 中)还是右侧(如 Algol 中)?表达式 a**p 应该解释为 a*(*p) 还是 (a)**(p)?所有此类技术问题都有解决方案。然而,最不确定的是应用微妙的技术规则是否会带来更具可读性和可维护性的代码。如果有疑问,请使用命名函数。
运算符函数的名称是关键字operator,后接运算符本身,例如operator<<。运算符函数被声明并且可以像任何其他函数一样调用。运算符的使用只是显式调用运算符函数的简写。例如:
void f(complex a, complex b)
{
complex c = a + b; // 简写
complex d = a.operator+(b); // 显式调用
}
考虑到前面的complex 定义,这两个初始化器是同义的。
18.2.1 二元和一元运算符(Binary and Unary Operators)
二元运算符可以由采用一个参数的非静态成员函数或采用两个参数的非成员函数来定义。对于任何二元运算符@,aa@bb都可以解释为aa.operator@(bb) 或operator@(aa,bb)。如果两者都定义了,则重载解析(第 12.3 节)将确定使用哪种解释(如果有)。例如:
class X {
public:
void operator+(int);
X(int);
};
void operator+(X,X);
void operator+(X,double);
void f(X a)
{
a+1; // a.operator+(1)
1+a; // ::operator+(X(1),a)
a+1.0; // ::operator+(a,1.0)
}
一元运算符,无论是前缀还是后缀,都可以由不带参数的非静态成员函数或带一个参数的非成员函数来定义。对于任何前缀一元运算符@,@aa 可以解释为aa.operator@() 或operator@(aa)。如果两者都定义了,则重载解析(第 12.3 节)将确定使用哪种解释(如果有)。对于任何后缀一元运算符@,aa@ 可以解释为aa.operator@(int) 或operator@(aa,int)。第 19.2.4 节对此进行了进一步解释。如果两者都定义了,则重载解析(第 12.3 节)将确定使用哪种解释(如果有)。只能为语法 (§iso.A) 中为其定义的语法声明运算符。例如,用户无法定义一元 % 或三元 +。考虑:
class X {
public: // members (with implicit this pointer):
X∗ operator&(); // 前缀一元运算符 & (……的地址)
X operator&(X); // 二元运算符& (与)
X operator++(int); // 后缀递增(见 §19.2.4)
X operator&(X,X); // 错: 三元
X operator/(); // 错: 一元 /
};
// 非成员函数 :
X operator−(X); //前缀一元减
X operator−(X,X); //二元减
X operator−−(X&,int); // 后缀递减
X operator−(); //错: 无操作数
X operator−(X,X,X); // 错 : 三元
X operator%(X); // 错 : 一元 %
运算符 [] 在 §19.2.1 中描述,运算符 () 在 §19.2.2 中描述,运算符 −> 在 §19.2.3 中描述,运算符 ++ 和 −− 在 §19.2.4 中描述,分配和释放运算符在 §11.2.4 和§19.2.5 中描述。
运算符operator= (§18.2.2)、operator[] (§19.2.1)、operator() (§19.2.2) 和operator−>(§19.2.3) 必须是非静态成员函数。
&&、|| 和 ,(逗号)的默认含义涉及排序:第一个操作数在第二个操作数之前求值(对于 && 和 ||,第二个操作数并不总是求值)。这个特殊规则不适用于用户定义版本的 &&、|| 和 ,(逗号);相反,这些运算符的处理方式与其他二元运算符完全相同。
18.2.2 运算符的预定义含义 (Predefined Meanings for Operators)
某些内置运算符的含义被定义为等效于相同参数上的其他运算符的某种组合。例如,如果a是一个int,++a意味着a+=1,这又意味着a=a+1。这种关系不适用于用户定义的运算符,除非用户定义了它们。例如,编译器不会从 Z::operator+() 的定义生成 Z::operator+=()和 Z::operator=() 的定义。
运算符 =(赋值)、&(取地址)和 ,(排序;第 10.3.2 节)在应用于类对象时具有预定义的含义。这些预定义的含义可以被消除(使用“delete”;§17.6.4):
class X {
public:
// ...
void operator=(const X&) = delete;
void operator&() = delete;
void operator,(const X&) = delete;
// ...
};
void f(X a, X b)
{
a = b; // 错: 无 operator=()
&a; // 错: 无 operator&()
a,b; // 错: 无 operator,()
}
或者,可以通过适当的定义赋予它们新的含义。
18.2.3 运算符和用户定义类型(Operators and User-Defined Types)
运算符函数必须是成员或至少采用一个用户定义类型的参数(重新定义 new 和 delete 运算符的函数不需要)。该规则确保用户不能更改表达式的含义,除非表达式包含用户定义类型的对象。特别是,不可能定义专门对指针进行操作的运算符函数。这确保了 C++ 可扩展但不可变(类对象的运算符 =、& 和 , 除外)。
打算接受内置类型(第 6.2.1 节)作为其第一个操作数的运算符函数不能是成员函数。例如,考虑将复数变量 aa 加到整数 2:aa + 2 通过适当声明的成员函数可以解释为 aa.operator+(2),但 2 + aa 不能,因为不存在类 int ,对其定义的 + 表示 2.operator+(aa)。即使有,也需要两个不同的成员函数来处理 2+aa 和 aa+2。因为编译器不知道用户定义的 + 的含义,所以它不能假设该运算符是可交换的,从而将 2+aa 解释为 aa+2。使用一个或多个非成员函数可以轻松处理此示例(第 18.3.2 条,第 19.4 条)。
枚举是用户定义的类型,因此我们可以为它们定义运算符。例如:
enum Day { sun, mon, tue, wed, thu, fri, sat };
Day& operator++(Day& d)
{
return d = (sat==d) ? sun : static_cast<Day>(d+1);
}
检查每个表达式是否有歧义。如果用户定义的运算符提供了可能的解释,则根据第 12.3 节中的重载解析规则检查表达式。
18.2.4 传递对象(Passing Objects)
当我们定义一个运算符时,我们通常希望提供一个约定的表示法,例如a=b+c。因此,我们对如何将参数传递给运算符函数的选择有限以及它如何返回它的值。例如,我们不能要求指针参数并期望程序员使用取址运算符或返回指针并期望用户解引用它:*a=&b+&c 是不可接受的。
对于参数,我们有两个主要选择(§12.2):
• 按值传递
• 按引用传递
对于小对象,例如一到四个字,按值调用通常是一种可行的替代方案,并且通常可以提供最佳性能。然而,参数传递和使用的性能取决于机器体系结构、编译器接口约定(应用程序二进制接口;ABI)以及访问参数的次数(访问按值传递的参数几乎总是比访问按引用传递的参数更快)。例如,假设一个 Point 表示为一对整数:
void Point::operator+=(Point delta); // 按值传递
较大的对象,我们通过引用传递。例如,因为矩阵(Matrix)(一个简单的双精度矩阵;§17.5.1)很可能比几个单词大,所以我们使用引用传递:
Matrix operator+(const Matrix&, const Matrix&); // 按const 引用传递
特别是,我们使用 const 引用来传递不打算由被调用函数修改的大对象(第 12.2.1 节)。
通常,运算符返回结果。返回对新创建的对象的指针或引用通常是一个非常糟糕的主意:使用指针会带来符号问题,而引用自由存储上的对象(无论是通过指针还是通过引用)会导致内存管理问题。相反,按值返回对象。对于大型对象,例如矩阵,定义移动操作以使此类值的传输高效(§3.3.2,§17.5.2)。例如:
Matrix operator+(const Matrix& a, const Matrix& b) // 按值返回
{
Matrix res {a};
return res+=b;
}
请注意,返回其参数对象之一的运算符可以(而且通常确实)返回一个引用。例如,我们可以这样定义 Matrix 的运算符 +=:
Matrix& Matrix::operator+=(const Matrix& a) // 按引用返回
{
if (dim[0]!=a.dim[0] || dim[1]!=a.dim[1])
throw std::exception("bad Matrix += argument");
double∗ p = elem;
double∗ q = a.elem;
double∗ end = p+dim[0]∗dim[1];
while(p!=end)
∗p++ += ∗q++
return ∗this;
}
这对于作为成员实现的运算符函数尤其常见。
如果函数只是将对象传递给另一个函数,则应使用右值引用参数(§17.4.3,§23.5.2.1,§28.6.3)。
18.2.5 命名空间中的运算符(Operators in Namespaces)
运算符要么是类的成员,要么是在某个命名空间(可能是全局命名空间)中定义的。考虑标准库中字符串 I/O 的简化版本:
namespace std { // simplified std
class string {
// ...
};
class ostream {
// ...
ostream& operator<<(const char∗); //输出 C 风格字符串
};
extern ostream cout;
ostream& operator<<(ostream&, const string&); // 输出std::string
} // namespace std
int main()
{
const char∗ p = "Hello";
std::string s = "world";
std::cout << p << ", " << s << "!\n";
}
自然地,这会写出Hello, world!。但为什么?请注意,我并没有通过编写以下内容来访问 std 中的所有内容:
using namespace std;
相反,我使用了 string 和 cout 的 std:: 前缀。换句话说,我的行为是最好的,没有污染全局命名空间或以其他方式引入不必要的依赖关系。
C 风格字符串的输出运算符是 std::ostream 的成员,因此定义
std::cout << p
指的是
std::cout.operator<<(p)
然而,std::ostream没有成员函数来输出std::string,所以
std::cout << s
指的是
operator<<(std::cout,s)
命名空间中定义的运算符可以根据其操作数类型求得,就像函数可以根据其参数类型求得一样(第 14.2.4 节)。特别是,cout 位于命名空间 std 中,因此在寻找 << 的合适定义时会考虑 std。这样,编译器就会找到并使用:
std::operator<<(std::ostream&, const std::string&)
考虑一个二元运算符@。如果 x 是 X 类型并且 y 是 Y 类型,则 x@y 的解析如下:
• 如果X 是一个类,则查找operator@ 作为X 的成员或X 基的成员;和
• 在x@y 周围的上下文中查找operator@ 的声明;和
• 如果X 定义在命名空间N 中,则在N 中查找operator@ 的声明;和
• 如果Y 是在命名空间M 中定义的,则在M 中查找operator@ 的声明。
可以找到多个运算符@的声明,并使用重载解析规则(第 12.3 节)来查找最佳匹配(如果有)。仅当运算符具有至少一个用户定义类型的操作数时,才应用此查找机制。因此,将考虑用户定义的转换(第 18.3.2 节,第 18.4 节)。请注意,类型别名只是一个同义词,而不是单独的用户定义类型(第 6.5 节)。
一元运算符的解析方式类似。
请注意,在运算符查找中,成员不会优先于非成员。这与命名函数的查找不同(第 14.2.4 节)。不隐藏运算符可确保内置运算符永远不会无法访问,并且用户可以在不修改现有类声明的情况下为运算符提供新含义。例如:
X operator!(X);
struct Z {
Z operator!(); //不隐藏 ::operator!()
X f(X x) { /* ... */ return !x; } // 调用::operator!(X)
int f(int x) { /* ... */ return !x; } // 对int调用内置 !
};
特别是,标准 iostream 库定义 << 成员函数来输出内置类型,并且用户可以定义 << 来输出用户定义的类型,而无需修改 ostream 类(第 38.4.2 节)。
18.3 一种复数类型(A Complex Number Type)
第 18.1 节中介绍的复数的实现过于严格,无法取悦任何人。例如,我们希望这能起作用:
void f()
{
complex a {1,2};
complex b {3};
complex c {a+2.3};
complex d {2+b};
b = c∗2∗c;
}
此外,我们希望提供一些额外的运算符,例如用于比较的 == 和用于输出的 <<,以及一组合适的数学函数,例如 sin() 和 sqrt()。
类complex是一种具体类型,因此其设计遵循§16.3 的指导原则。此外,complex算术的用户非常依赖运算符,以至于complex的定义发挥了运算符重载的大部分基本规则。
本节中开发的complex类型使用 double 作为其标量,大致相当于标准库的 complex<double> (§40.4)。
18.3.1 成员和非成员运算符(Member and Nonmember Operators)
我更喜欢尽量减少直接操作对象表示的函数数量。这可以通过在类本身中仅定义本质上修改其第一个参数值的运算符(例如 +=)来实现。然后,在类外部定义仅根据其参数的值生成新值的运算符(例如 +),并在其实现中使用基本运算符:
class complex {
double re, im;
public:
complex& operator+=(complex a); //需访问表示
// ...
};
complex operator+(complex a, complex b)
{
return a += b; //通过 += 访问表示
}
此运算符 operator+() 的参数按值传递,因此 a+b 不会修改其操作数。
已经这些声明,我们可以写出:
void f(complex x, complex y, complex z)
{
complex r1 {x+y+z}; // r1 = operator+(operator+(x,y),z)
complex r2 {x}; // r2 = x
r2 += y; // r2.operator+=(y)
r2 += z; // r2.operator+=(z)
}
除了可能的效率差异外,r1 和 r2 的计算是等效的。
复合赋值运算符(例如 += 和 *=)往往比“简单”对应的 + 和 * 更容易定义。这起初让大多数人感到惊讶,但这是因为 + 运算涉及三个对象(两个操作数和结果),而 += 运算只涉及两个对象。在后一种情况下,通过消除对临时变量的需要来提高运行时效率。例如:
inline complex& complex::operator+=(complex a)
{
re += a.re;
im += a.im;
return ∗this;
}
这不需要临时变量来保存加法的结果,并且对于编译器来说很容易完美内联。
一个好的优化器也会为普通 + 运算符的使用生成接近最佳的代码。然而,我们并不总是有一个好的优化器,而且并非所有类型都像complex这样简单,因此第 19.4 节讨论了定义直接访问类表示的运算符的方法。
18.3.2 混合模式算术(Mixed-Mode Arithmetic)
为了处理 2+z(其中 z 是复数),我们需要定义运算符 + 来接受不同类型的操作数。在 Fortran 术语中,我们需要混合模式算术。我们可以简单地通过添加适当版本的运算符来实现这一点:
class complex {
double re, im;
public:
complex& operator+=(complex a)
{
re += a.re;
im += a.im;
return ∗this;
}
complex& operator+=(double a)
{
re += a;
return ∗this;
}
// ...
};
可以在complex外部定义operator+()的三种变体:
complex operator+(complex a, complex b)
{
return a += b; // 调用 complex::operator+=(complex)
}
complex operator+(complex a, double b)
{
return {a.real()+b,a.imag()};
}
complex operator+(double a, complex b)
{
return {a+b.real(),b.imag()};
}
访问函数 real() 和 imag() 在第 18.3.6 节中定义。
给定这些 + 声明,我们可以写出:
void f(complex x, complex y)
{
auto r1 = x+y; // 调用operator+(complex,complex)
auto r2 = x+2; // 调用 operator+(complex,double)
auto r3 = 2+x; // 调用 operator+(double,complex)
auto r4 = 2+3; // 内置整数加
}
为了完整性,我添加了整数加法。
18.3.3 转换(Conversions)
为了处理带有标量的complex变量的赋值和初始化,我们需要将标量(整数或浮点数)转换为complex。例如:
complex b {3}; // 应当指的是 b.re=3, b.im=0
void comp(complex x)
{
x = 4; // 应当指的是 x.re=4, x.im=0
// ...
}
我们可以通过提供一个带有单个参数的构造函数来实现这一点。采用单个参数的构造函数指定从其参数类型到构造函数类型的转换。例如:
class complex {
double re, im;
public:
complex(double r) :re{r}, im{0} { } // 从double 构造complex
// ...
};
构造函数指定实线在复平面中的传统嵌入。
构造函数是创建已知类型的值的处方。当期待一个类型值且当这样一个值可以通过构造函数从提供作为初始化器或赋值的值创建时,我们使用构造函数。因此,不需要显式调用需要单个参数的构造函数。例如:
complex b {3};
指的是
complex b {3,0};
仅当用户定义的转换唯一时,才会隐式应用该转换(第 12.3 节)。如果您不希望隐式使用构造函数,请将其声明为显式(第 16.2.6 节)。
当然,我们仍然需要带有两个double值的构造函数,并且将complex初始化为 {0,0} 的默认构造函数也很有用:
class complex {
double re, im;
public:
complex() : re{0}, im{0} { }
complex(double r) : re{r}, im{0} { }
complex(double r, double i) : re{r}, im{i} { }
// ...
};
使用默认参数,我们可以缩写:
class complex {
double re, im;
public:
complex(double r =0, double i =0) : re{r}, im{i} { }
// ...
};
默认情况下,复制复数值被定义为复制实部和虚部(§16.2.2)。例如:
void f()
{
complex z;
complex x {1,2};
complex y {x}; // y 也有值{1,2}
z = x; // z 也有值 {1,2}
}
18.3.3.1 操作数的转换(Conversions of Operands)
我们为四个标准算术运算符中的每一个定义了三个版本:
complex operator+(complex,complex);
complex operator+(complex,double);
complex operator+(double ,complex);
// ...
这可能会变得乏味,而乏味的事情很容易变得容易出错。如果我们对每个函数的每个参数的类型有三种选择怎么办?我们需要每个单参数函数的三个版本,每个双参数函数的 9 个版本,每个三参数函数的 27 个版本,等等。这些变体通常非常相似。事实上,几乎所有变体都涉及将参数简单转换为通用类型,然后采用标准算法。
为每个参数组合提供不同版本的函数的替代方法是依赖转换。例如,我们的complex类提供了一个将double转换为complex的构造函数。因此,我们可以简单地为complex声明仅一种版本的相等运算符:
bool operator==(complex,complex);
void f(complex x, complex y)
{
x==y; // means operator==(x,y)
x==3; // means operator==(x,complex(3))
3==y; // means operator==(complex(3),y)
}
可能有一些理由倾向于定义单独的函数。例如,在某些情况下,转换可能会产生开销,而在其他情况下,可以对特定参数类型使用更简单的算法。在此类问题并不重要的情况下,依靠转换并仅提供函数的最通用变体(可能还加上一些关键变体)包含混合模式算术可能产生的变体组合爆增。
如果函数或运算符存在多个变体,编译器必须根据参数类型和可用的(标准和用户定义)转换选择“正确”的变体。除非存在最佳匹配,否则表达式是不明确的并且是错误的(参见第 12.3 节)。
通过在表达式中显式或隐式使用构造函数构造的对象是自动的,并且将在第一时间被销毁(请参见第 10.3.4 节)。
不会将隐式用户定义转换应用于一个 • (或 ->)的左侧。即使 • 是隐式的也是这样。例如:
void g(complex z)
{
3+z; //OK: complex(3)+z
3.operator+=(z); // 错: 3 不是一个类对象
3+=z; //错: 3 不是一个类对象
}
因此,您可以通过使该运算符成为成员来近似理解该运算符需要左值作为其左侧操作数。 然而,这只是一个近似值,因为可以通过修改操作访问临时值,例如operator+=():
complex x {4,5}
complex z {sqr t(x)+={1,2}}; // 类似 tmp=sqr t(x), tmp+={1,2}
如果我们不想要隐式转换,我们可以使用显式来抑制它们(§16.2.6,§18.4.2)。
18.3.4 文字量(Literals)
我们有内置类型的文字量(译注:具体类型的文字表示形式)。例如,1.2 和 12e3 是 double 类型的文字量。对于complex,我们可以通过声明构造函数为 constexpr(第 10.4 节)来高度接近这一点。例如:
class complex {
public:
constexpr complex(double r =0, double i =0) : re{r}, im{i} { }
// ...
}
鉴于此,可以在编译时从其组成部分构造complex,就像从内置类型构造文字量一样。例如:
complex z1 {1.2,12e3};
constexpr complex z2 {1.2,12e3}; // 由编译时初始化保证
当构造函数简单且内联时,尤其是当它们是 constexpr 时,将带有文字量参数的构造函数调用视为文字量是相当合理的。
可以进一步引入用户定义的文字量(第 19.2.6 节)来支持我们的complex类型。特别是,我们可以将 i 定义为表示“虚数”的后缀。
constexpr complex<double> operator "" i(long double d) //虚数文字量
{
return {0,d}; // complex 是一个文字量类型
}
(译注:上述运算符函数的参数必须为long double,如改为double会报错:文本运算符的浮点参数类型("double")无效,应为 long double)
这将允许我们写成这样:
complex z1 {1.2+12e3i};
complex f(double d)
{
auto x {2.3i};
return x+sqrt(d+12e3i)+12e3i;
}
与 constexpr 构造函数相比,这个用户定义的文字量给我们带来了一个优势:我们可以在表达式中间使用用户定义的文字量,其中 {} 表示法只能在由类型名称限定时使用。上面的例子大致相当于:
complex z1 {1.2,12e3};
complex f(double d)
{
complex x {0,2.3};
return x+sqrt(complex{d,12e3})+complex{0,12e3};
}
我怀疑文字量风格的选择取决于你的审美观和你工作领域的惯例。标准库complex使用 constexpr 构造函数而不是用户定义的文字量。
18.3.5 读写函数(Accessor Functions)
到目前为止,我们仅通过构造函数和算术运算符提供了类complex。这对于实际使用来说还不够。特别是,我们经常需要能够检查和更改实部和虚部的值:
class complex {
double re, im;
public:
constexpr double real() const { return re; }
constexpr double imag() const { return im; }
void real(double r) { re = r; }
void imag(double i) { im = i; }
// ...
};
我认为为班级的所有成员提供单独的访问权限不是一个好主意;一般来说,事实并非如此。对于许多类型来说,单独访问(有时称为获取和设置功能)会引发灾难。如果我们不小心,个人访问可能会损害不变量,并且通常会使表示的更改变得复杂。例如,考虑以下机会:
为 §16.3 中的 Date 的每个成员或(甚至更多)为 §19.3 中的 String 提供 getter 和 setter 的滥用。然而,对于complex,real()和imag()在语义上是重要的:如果一些算法可以独立设置实部和虚部,那么它们的编写是最干净的。例如,给定 real() 和 imag(),我们可以将简单、常见且有用的操作(例如==)简化为非成员函数(而不影响性能):
inline bool operator==(complex a, complex b)
{
return a.real()==b.real() && a.imag()==b.imag();
}
18.3.6 辅助函数(Helper Functions)
如果我们把所有的零散的东西放在一起,complex类就变成了:
class complex {
double re, im;
public:
constexpr complex(double r =0, double i =0) : re(r), im(i) { }
constexpr double real() const { return re; }
constexpr double imag() const { return im; }
void real(double r) { re = r; }
void imag(double i) { im = i; }
complex& operator+=(complex);
complex& operator+=(double);
// -=, *=, and /=
};
此外,我们还必须提供一些辅助函数:
complex operator+(complex,complex);
complex operator+(complex,double);
complex operator+(double ,complex);
// 二元 -, *, 和 /
complex operator−(complex); // 一元减
complex operator+(complex); // 一元加
bool operator==(complex,complex);
bool operator!=(complex,complex);
istream& operator>>(istream&,complex&); // 输入
ostream& operator<<(ostream&,complex); // 输出
请注意,成员 real() 和 imag() 对于定义至关重要。以下大多数辅助函数的定义同样依赖于 real() 和 imag()。
我们可以提供函数让用户根据极坐标来思考:
complex polar(double rho, double theta);
complex conj(complex);
double abs(complex);
double arg(complex);
double norm(complex);
double real(complex); // 为记法方便
double imag(complex); // 为记法方便
最后,我们必须提供一组适当的标准数学函数:
complex acos(complex);
complex asin(complex);
complex atan(complex);
// ...
从用户的角度来看,这里提供的复杂类型几乎与标准库中的 <complex> 中的complex类型相同(第 5.6.2 节,第 40.4 节)。
18.4 类型转换(Type Conversion)
类型转换可通过下述方式完成:
• 采用单个参数的构造函数(第 16.2.5 节)
• 转换运算符(§18.4.1)
在任一情况下,转换可以是:
• explicit(显式);也就是说,转换仅在直接初始化(第 16.2.6 节)中执行,即作为不使用 = 的初始化器。
• 隐式的;也就是说,它将应用于任何可以明确使用的地方(第 18.4.3 节),例如作为函数参数。
18.4.1 转换运算符(Type Conversion)
使用采用单个参数的构造函数来指定类型转换很方便,但会产生不良影响。另外,构造函数不能指定
[1] 从用户定义类型到内置类型的隐式转换(因为内置类型类型不是类),或者
[2] 从新类到先前定义的类的转换(不修改旧类的声明)。
这些问题可以通过为源类型定义转换运算符来解决。成员函数 X::operator T()(其中 T 是类型名称)定义了一个从X到T的转换。例如,我们可以定义一个可以与算术运算自由混合的6位非负整数类 Tiny。如果Tiny的操作上溢或下溢,则会抛出 Bad_range 异常:
class Tiny {
char v;
void assign(int i) { if (i&˜077) throw Bad_rang e(); v=i; }
public:
class Bad_range { };
Tiny(int i) { assign(i); }
Tiny& operator=(int i) { assign(i); return ∗this; }
operator int() const { return v; } // 到 int 函数的转换
};
每当 Tiny 由 int 初始化以及每当将 int 值赋值给 v 时,都会检查范围。当我们复制Tiny时不需要范围检查,因此使用默认的复制构造函数和赋值就可以了。
为了能够对 Tiny 变量进行通常的整数运算,我们定义了从 Tiny 到 int 的隐式转换,Tiny::operator int()。请注意,要转换的类型是运算符名称的一部分,不能重复作为转换函数的返回值:
Tiny::operator int() const { return v; } // right
int Tiny::operator int() const { return v; } // error
在这方面,转换运算符也类似于构造函数。
每当需要 int 的地方出现 Tiny 时,就会使用适当的 int。例如:
int main()
{
Tiny c1 = 2;
Tiny c2 = 62;
Tiny c3 = c2−c1; // c3 = 60
Tiny c4 = c3; // 无范围检查(不必)
int i = c1+c2; // i = 64
c1 = c1+c2; // 范围错误: c1 不能是 64
i = c3−64; //i = -4
c2 = c3−64; // 范围错误: c2 不能为 -4
c3 = c4; // 无范围检查 (不必)
}
当读取(由转换运算符实现)很简单时,转换函数对于处理数据结构似乎特别有用,而赋值和初始化则明显不那么简单。
istream 和 ostream 类型依赖于转换函数来启用以下语句:
while (cin>>x)
cout<<x;
输入操作 cin>>x 返回一个 istream&。该值隐式转换为指示 cin 状态的值。然后可以暂时测试该值(参见第 38.4.4 节)。然而,以这样一种在转换过程中丢失信息的方式定义从一种类型到另一种类型的隐式转换通常并不是一个好主意。
一般来说,明智的做法是避免引入转换运算符。当过度使用时,它们会导致歧义。编译器可以捕获此类歧义,但解决它们可能会很麻烦。最好的想法可能是最初通过命名函数进行转换,例如 X::make_int()。如果这样的函数变得足够流行,以至于显式使用变得不优雅,则可以用转换运算符 X::operator int() 来替换它。
如果同时定义了用户定义的转换和用户定义的运算符,则可能会在用户定义的运算符和内置运算符之间出现歧义。例如:
int operator+(Tiny,Tiny);
void f(Tiny t, int i)
{
t+i; //错, 歧义: “operator+(t,Tiny(i))”或“int(t)+i”?
}
因此,对于已知类型,通常最好依赖用户定义的转换或用户定义的运算符,但不能同时依赖两者。
18.4.2 explicit型转换运算符(explicit Conversion Operators)
转换运算符往往被定义为可以在任何地方使用。但是,声明一个explicit型转换运算符并使其仅适用于直接初始化是可行的(第 16.2.6 节),其中将使用等效的explicit型构造函数。例如,标准库 unique_ptr (§5.2.1, §34.3.1) 具有到 bool 的显式转换:
template <typename T, typename D = default_delete<T>>
class unique_ptr {
public:
// ...
explicit operator bool()const noexcept; // *this 存储一个指针吗(非 nullptr)?
// ...
};
显式声明此explicit型转换运算符的原因是避免在令人惊讶的上下文中使用它。考虑:
void use(unique_ptr<Record> p, unique_ptr<int> q)
{
if (!p) // OK: we want this use
throw Inv alid_uninque_ptr{};
bool b = p; // 错; 可疑用法
int x = p+q; // 错; 我们绝对不想要这个
}
如果 unique_ptr 没有显式转换为 bool,则最后两个定义将会编译。b 的值将变为 true,x 的值将变为 1 或 2(取决于 q 是否有效)。
18.4.3 歧义性(Ambiguities)
如果存在一个赋值运算符 X::operator=(Z) 使得 V 可赋值给 Z 或者存在一个从V 到 Z 的唯一转换,则将 V 类型的值赋值给 X 类的对象是有效的。
在某些情况下,可以通过重复使用构造函数或转换运算符来构造所需类型的值。这必须通过显式转换来处理;只有一层用户定义的隐式转换是有效的。在某些情况下,可以通过多种方式构造所需类型的值;此类情况是无效的。例如:
class X { /* ... */ X(int); X(const char∗); };
class Y { /* ... */ Y(int); };
class Z { /* ... */ Z(X); };
X f(X);
Y f(Y);
Z g(Z);
void k1()
{
f(1); //错: 歧义f(X(1))还是 f(Y(1))?
f(X{1}); //OK
f(Y{1}); //OK
g("Mack"); //错: 需要2个用户定义类型转换; g(Z{X{"Mack"}}) 不用试
g(X{"Doc"}); // OK: g(Z{X{"Doc"}})
g(Z{"Suzy"}); // OK: g(Z{X{"Suzy"}})
}
仅当没有用户定义的转换无法解析调用时(即仅使用内置转换),才会考虑用户定义的转换。例如:
class XX { /* ... */ XX(int); };
void h(double);
void h(XX);
void k2()
{
h(1); // h(double{1}) or h(XX{1})? h(double{1})!
}
调用 h(1) 意味着 h(double(1)),因为该替代方案仅使用标准转换而不是用户定义的转换(第 12.3 节)。
转换规则既不是最容易实现的,也不是记录起来最简单的,更不是可以设计为通用的。然而,它们要安全得多,而且所产生的解决方案通常比其他方案更令人惊讶。手动解决歧义比查找由意外转换引起的错误要容易得多。
坚持严格的自下而上分析意味着在重载解析中不使用返回类型。例如:
class Quad {
public:
Quad(double);
// ...
};
Quad operator+(Quad,Quad);
void f(double a1, double a2)
{
Quad r1 = a1+a2; // 双精度浮点数相加
Quad r2 = Quad{a1}+a2; // 强制四元运算
}
之所以选择这种设计的原因,一方面是因为严格的自下而上分析更容易理解,另一方面是因为不考虑编译器决定程序员可能希望对加采用哪一个精度的工作。
一旦确定了初始化或赋值双方的类型,就使用这两种类型来解析初始化或赋值。例如:
class Real {
public:
operator double();
operator int();
// ...
};
void g(Real a)
{
double d = a; // d = a.double();
int i = a; // i = a.int();
d = a; // d = a.double();
i = a; // i = a.int();
}
在这些情况下,类型分析仍然是自下而上的,即在任何时候都仅考虑单个运算符及其参数类型。
18.5 建议(Advice)
[1] 定义运算符主要是为了模仿常规用法; §18.1。
[2] 如果默认值不适合某个类型,则重新定义或禁止复制; §18.2.2。
[3] 对于大操作数,使用const引用参数类型; §18.2.4。
[4] 对于较大的结果,使用移动构造函数; §18.2.4。
[5] 对于需要访问表示的操作,优先使用成员函数而不是非成员函数;§18.3.1。
[6] 对于不需要访问表示的操作,优先选择非成员函数而不是成员;§18.3.2。
[7] 使用命名空间将辅助函数与“它们的”类关联起来; §18.2.5。
[8] 对对称运算符使用非成员函数; §18.3.2。
[9] 使用成员函数来表达需要左值作为其左操作数的运算符;§18.3.3.1。
[10] 使用用户定义的文字量来模仿传统的表示法; §18.3.4。
[11] 仅当类的基本语义要求的时候,才为数据成员提供“set() 和 get() 函数”;§18.3.5。
[12] 谨慎引入隐式转换; §18.4。
[13] 避免破坏值(“窄化”)的转换; §18.4.1。
[14] 不要将相同的转换同时定义为构造函数和转换运算符(译注:仅需一种);§18.4.3。
内容来源:
<<The C++ Programming Language >> 第4版,作者 Bjarne Stroustrup