运算符重载
关于operator
重载有什么好说的?
它允许你为类的用户提供直观的接口,并且使得模板能够与类和内置/固有类型同样良好地工作。
运算符重载允许 C/C++ 运算符在用户定义类型(类)上具有用户定义的含义。重载运算符是函数调用的语法糖。
class Fred {
public:
// ...
};
#if 0
// Without operator overloading:
Fred add(const Fred& x, const Fred& y);
Fred mul(const Fred& x, const Fred& y);
Fred f(const Fred& a, const Fred& b, const Fred& c)
{
return add(add(mul(a,b), mul(b,c)), mul(c,a)); // Yuk...
}
#else
// With operator overloading:
Fred operator+ (const Fred& x, const Fred& y);
Fred operator* (const Fred& x, const Fred& y);
Fred f(const Fred& a, const Fred& b, const Fred& c)
{
return a*b + b*c + c*a;
}
#endif
运算符重载有什么好处?
通过在类上重载标准运算符,你可以利用该类的用户的直觉。这让用户能够用问题领域的语言而不是机器语言进行编程。
最终目标是降低学习曲线和缺陷率。
运算符重载有哪些例子?
以下是运算符重载的众多示例中的几个:
myString + yourString
可能会连接两个std::string
对象。myDate++
可能会递增一个Date
对象。a * b
可能会将两个Number
对象相乘。a[i]
可能会访问一个Array
对象的元素。x = *p
可能会解引用一个指向磁盘记录的“智能指针”——它可能会寻找到p
“指向”的磁盘位置并将相应的记录返回到x
中。
但是运算符重载让我的类看起来很丑;它难道不应该让我的代码更清晰吗?
运算符重载是为了让类的“用户”生活更轻松,而不是为了让类的开发者生活更轻松!
考虑以下示例。
class Array {
public:
int& operator[] (unsigned i); // Some people don't like this syntax
// ...
};
inline
int& Array::operator[] (unsigned i) // Some people don't like this syntax
{
// ...
}
有些人不喜欢 operator
关键字或类体中与之相关的有些奇怪的语法。但是 operator
重载语法并不是为了让类的“开发者”生活更轻松。它是为了让类的“用户”生活更轻松。
int main()
{
Array a;
a[3] = 4; // User code should be obvious and easy to understand...
// ...
}
记住:在一个注重重用的世界里,通常会有许多人使用你的类,但只有一个人构建它(你自己);因此,你应该做那些有利于多数人而非少数人的事情。
哪些运算符可以/不可以被重载?
大多数都可以重载。唯一不能重载的 C 运算符是 .
和 ?:
(以及 sizeof
,它在技术上也是一个运算符)。C++ 添加了一些自己的运算符,其中大部分都可以重载,除了 ::
和 .*
。
以下是下标运算符的一个示例(它返回一个引用)。首先是“不”使用运算符重载:
class Array {
public:
int& elem(unsigned i) { if (i > 99) error(); return data[i]; }
private:
int data[100];
};
int main()
{
Array a;
a.elem(10) = 42;
a.elem(12) += a.elem(13);
// ...
}
现在,相同的逻辑“使用”运算符重载来表示:
class Array {
public:
int& operator[] (unsigned i) { if (i > 99) error(); return data[i]; }
private:
int data[100];
};
int main()
{
Array a;
a[10] = 42;
a[12] += a[13];
// ...
}
为什么我不能重载 .
(点)、::
、sizeof
等?
大多数运算符都可以由程序员重载。例外情况有:
. (dot) :: ?: sizeof
没有根本原因禁止重载 ?:
。到目前为止,委员会只是认为没有必要引入重载三元运算符的特例。请注意,重载 expr1?expr2:expr3
的函数无法保证只执行 expr2
和 expr3
中的一个。
sizeof
不能被重载,因为内置操作(例如隐式依赖于它的数组指针递增)会受到影响。考虑:
X a[10];
X* p = &a[3];
X* q = &a[3];
p++; // p points to a[4]
// thus the integer value of p must be
// sizeof(X) larger than the integer value of q
因此,程序员不能在不违反基本语言规则的情况下赋予 sizeof(X)
新的不同含义。
那 ::
呢?在 N::m
中,N
和 m
都不是带有值的表达式;N
和 m
是编译器已知的名称,::
执行的是(编译时)作用域解析而不是表达式求值。可以想象允许重载 x::y
,其中 x
是一个对象而不是命名空间或类,但这会——与初看起来相反——涉及引入新的语法(允许 expr::expr
)。这种复杂性会带来什么好处尚不清楚。
operator.
(点)原则上可以使用与 ->
相同的技术进行重载。然而,这样做可能会导致关于操作是针对重载 .
的对象还是由 .
引用的对象的问题。例如:
class Y {
public:
void f();
// ...
};
class X { // assume that you can overload .
Y* p;
Y& operator.() { return *p; }
void f();
// ...
};
void g(X& x)
{
x.f(); // X::f or Y::f or error?
}
这个问题可以通过多种方式解决。到目前为止,在标准化过程中,尚不清楚哪种方式是最好的。更多详情请参见 D&E。
我可以定义自己的运算符吗?
抱歉,不可以。这种可能性曾被考虑过几次,但每次都决定可能带来的问题大于可能带来的好处。
这不是一个语言技术问题。即使 Stroustrup 在1983年首次考虑它时,他也知道如何实现它。然而,经验表明,当我们超越最简单的例子时,人们对运算符使用的“显而易见”的含义似乎有着微妙的不同意见。一个经典的例子是 a**b**c
。假设 **
被定义为幂运算。那么 a**b**c
应该表示 (a**b)**c
还是 a**(b**c)
?专家们认为答案是显而易见的,他们的朋友也同意——然后发现他们对于哪个解析是显而易见的却没有达成一致。这种问题似乎很容易导致微妙的错误。
我可以重载 operator==
以便使用字符串比较来比较两个 char[]
吗?
不可以:任何重载的 operator
至少一个操作数必须是某种用户定义类型(大多数情况下这意味着一个 class
)。
但即使 C++ 允许你这样做(实际上不允许),你也不应该这样做,因为你最初就应该使用类似 std::string
的类而不是 char
数组,因为数组是邪恶的。
我可以创建一个用于幂运算的 operator**
吗?
不。
运算符的名称、优先级、结合性和元数由语言固定。C++ 中没有 operator**
,所以你不能为类类型创建它。
如果你有疑问,请考虑 x ** y
等同于 x * (*y)
(换句话说,编译器假设 y
是一个指针)。此外,运算符重载只是函数调用的语法糖。尽管这种特殊的语法糖可能非常甜,但它没有添加任何根本性的东西。我建议你重载 pow(base,exponent)
(双精度版本在
中)。
顺便说一句,operator^
可以用于幂运算,只是它的优先级和结合性不正确。
之前的常见问题解答告诉我哪些运算符我可以重载;但是哪些运算符我“应该”重载?
底线:不要让你的用户感到困惑。
记住运算符重载的目的是什么:降低使用你的类的代码的成本和缺陷率。如果你创建的运算符让你的用户感到困惑(因为它们很酷,因为它们让代码更快,因为你需要证明自己能做到;真正的原因不重要),那么你已经违反了最初使用运算符重载的全部原因。
重载运算符有哪些指导原则/"经验法则"?
以下是一些指导原则/经验法则(但在阅读此列表之前,请务必阅读前面的常见问题解答)
- 运用常识。如果你的重载运算符能让你的用户更轻松、更安全,那就去做;否则不要。这是最重要的指导原则。事实上,从非常真实的意义上说,这是唯一的指导原则;其余的都只是特例。
- 如果定义算术运算符,请保持通常的算术恒等式。例如,如果你的类定义了
x + y
和x - y
,那么x + y - y
应该返回一个行为上等同于x
的对象。术语“行为上等同”在下面的x == y
这一条中定义,但简单地说,它意味着这两个对象理想情况下应该表现出相同的状态。即使你决定不为你的类对象定义==
运算符,这也应该成立。 - 你只应该在算术运算符对用户具有逻辑意义时才提供它们。减去两个日期是有意义的,逻辑上返回这些日期之间的时间长度,因此你可能希望允许你的
Date
类的对象进行date1 - date2
(前提是你有一个合理的类/类型来表示两个Date
对象之间的时间长度)。然而,添加两个日期没有意义:将 1776 年 7 月 4 日添加到 1959 年 6 月 5 日意味着什么?同样,乘法或除法日期也没有意义,因此你不应该定义任何这些运算符。 - 你只应该在混合模式算术运算符对用户具有逻辑意义时才提供它们。例如,将一个时间段(例如 35 天)添加到日期(例如 1776 年 7 月 4 日)是有意义的,因此你可能定义
date + duration
返回一个Date
。同样,date - duration
也可以返回一个Date
。但duration - date
在概念层面没有意义(从 35 天中减去 1776 年 7 月 4 日意味着什么?),因此你不应该定义该运算符。 - 如果你提供构造性运算符,它们应该通过值返回结果。例如,
x + y
应该通过值返回其结果。如果它通过引用返回,你可能会遇到很多问题,无法弄清楚谁拥有被引用对象以及被引用对象何时被销毁。通过引用返回是否更高效并不重要;它“很可能是错误的”。有关此点的更多信息,请参阅下一条。 - 如果你提供构造性运算符,它们不应该改变它们的操作数。例如,
x + y
不应该改变x
。出于某种奇怪的原因,程序员通常将x + y
定义为逻辑上与x += y
相同,因为后者更快。但请记住,你的用户“期望”x + y
制作一个副本。事实上,他们选择+
运算符(而不是,比如,+=
运算符)正是因为他们“想要”一个副本。如果他们想要修改x
,他们会使用等同于x += y
的东西。不要为你的用户做语义决策;是“他们”的决定,而不是你的,他们是想要x + y
的语义还是x += y
的语义。如果你愿意,可以告诉他们哪个更快,但随后退一步,让他们做出最终决定——他们知道他们想要实现什么,而你不知道。 - 如果您提供构造性运算符,它们应该允许左操作数的提升(至少在类具有未标记为
explicit
关键字的单参数构造函数的情况下)。例如,如果您的Fraction
类支持从int
到Fraction
的提升(通过非explicit
构造函数Fraction::Fraction(int)
),并且如果您允许两个Fraction
对象进行x - y
运算,那么您也应该允许42 - y
。实际上,这仅仅意味着您的operator-()
不应该是Fraction
的成员函数。通常,您会将其设为友元,即使没有其他原因,也要强制它进入类的public:
部分,但即使它不是友元,也不应该是成员。 - 通常,当您将相同的运算符应用于固有类型时,如果操作数发生变化,则您的运算符才应该改变其操作数。
x == y
和x << y
不应该改变任何操作数;x *= y
和x <<= y
应该改变(但仅限于左操作数)。 - 如果你定义
x++
和++x
,请保持通常的恒等式。例如,x++
和++x
应该对x
具有相同的可观察效果,并且只在返回值上有所不同。++x
应该通过引用返回x
;x++
应该返回x
原始状态的副本(按值返回)或具有void
返回类型。你通常最好按值返回x
原始状态的副本,尤其是当你的类将在泛型算法中使用时。简单的方法是使用三行代码实现x++
:制作*this
的局部副本,调用++x
(即this->operator++()
),然后返回局部副本。对于x--
和--x
也有类似的注释。 - 如果您定义
++x
和x += 1
,请保持通常的恒等式。例如,这些表达式应该具有相同的可观察行为,包括相同的结果。除其他事项外,这意味着您的+=
运算符应该通过引用返回x
。对于--x
和x -= 1
也有类似的注释。 - 如果您为类似指针的对象定义
*p
和p[0]
,请保持通常的恒等式。例如,这两个表达式应该具有相同的结果,并且都不应该改变p
。 - 如果您为类似指针的对象定义
p[i]
和*(p+i)
,请保持通常的恒等式。例如,这两个表达式应该具有相同的结果,并且都不应该改变p
。对于p[-i]
和*(p-i)
也有类似的注释。 - 下标运算符通常成对出现;请参阅
const
-重载。 - 如果你定义
x == y
,那么x == y
应该为真,当且仅当这两个对象在行为上是等价的。在本条中,“行为上等价”意味着对x
执行的任何操作或操作序列的可观察行为将与应用于y
时相同。术语“操作”表示方法、友元、运算符或你可以对这些对象做的几乎所有其他事情(当然,除了取址运算符)。你并非总是能够实现这个目标,但你应该接近,并且你应该记录任何差异(除了取址运算符)。 - 如果您定义
x == y
和x = y
,请保持通常的恒等式。例如,赋值后,两个对象应该相等。即使您不定义x == y
,赋值后两个对象也应该在行为上等效(请参见上文对该短语的含义)。 - 如果你定义
x == y
和x != y
,你应该保持通常的恒等式。例如,这些表达式应该返回可转换为bool
的值,都不应该改变其操作数,并且x == y
应该与!(x != y)
具有相同的结果,反之亦然。 - 如果你定义了像
x <= y
和x < y
这样的不等式运算符,你应该保持通常的恒等式。例如,如果x < y
和y < z
都为真,那么x < z
也应该为真,等等。对于x >= y
和x > y
也有类似的注释。 - 如果你定义了像
x < y
和x >= y
这样的不等式运算符,你应该保持通常的恒等式。例如,x < y
的结果应该与!(x >= y)
相同。你并非总是能做到这一点,但你应该尽量接近并记录任何差异。对于x > y
和!(x <= y)
等也有类似的注释。 - 避免重载短路运算符:
x || y
或x && y
。这些重载版本不会短路——它们会评估两个操作数,即使左操作数“决定”了结果,这会迷惑用户。 - 避免重载逗号运算符:
x, y
。重载的逗号运算符不具有未重载时的相同排序属性,这会混淆用户。 - 不要重载对用户来说不直观的运算符。这被称为“最小惊讶原则”。例如,尽管 C++ 使用
std::cout << x
进行打印,尽管打印在技术上被称为插入,尽管插入听起来有点像将元素推入堆栈时发生的事情,但不要重载myStack << x
来将元素推入堆栈。在你非常疲惫或精神受损时,它可能有点意义,你的一些朋友可能觉得它“很酷”,但请直接说不。 - 运用常识。如果你没有在这里列出“你的”运算符,你可以自己想出来。只要记住运算符重载的最终目标:让你的用户生活更轻松,特别是让他们的代码编写成本更低,更清晰。
警告:列表不详尽。这意味着你可能会认为还有其他“缺失”的条目。我知道。
警告:列表包含指导方针,而不是死板的规则。这意味着几乎所有条目都有例外,并且大多数例外都没有明确说明。我知道。
警告:请不要给我发邮件讨论增补或例外。我花在这个特定答案上的时间已经太多了。
如何为 Matrix
类创建下标运算符?
使用 operator()
而不是 operator[]
。
当你有多个下标时,最清晰的方法是使用 operator()
而不是 operator[]
。原因是 operator[]
总是只接受一个参数,但 operator()
可以接受任意数量的参数(对于矩形矩阵,需要两个参数)。
例如
class Matrix {
public:
Matrix(unsigned rows, unsigned cols);
double& operator() (unsigned row, unsigned col); // Subscript operators often come in pairs
double operator() (unsigned row, unsigned col) const; // Subscript operators often come in pairs
// ...
~Matrix(); // Destructor
Matrix(const Matrix& m); // Copy constructor
Matrix& operator= (const Matrix& m); // Assignment operator
// ...
private:
unsigned rows_, cols_;
double* data_;
};
inline
Matrix::Matrix(unsigned rows, unsigned cols)
: rows_ (rows)
, cols_ (cols)
//, data_ ← initialized below after the if...throw statement
{
if (rows == 0 || cols == 0)
throw BadIndex("Matrix constructor has 0 size");
data_ = new double[rows * cols];
}
inline
Matrix::~Matrix()
{
delete[] data_;
}
inline
double& Matrix::operator() (unsigned row, unsigned col)
{
if (row >= rows_ || col >= cols_)
throw BadIndex("Matrix subscript out of bounds");
return data_[cols_*row + col];
}
inline
double Matrix::operator() (unsigned row, unsigned col) const
{
if (row >= rows_ || col >= cols_)
throw BadIndex("const Matrix subscript out of bounds");
return data_[cols_*row + col];
}
然后,你可以使用 m(i,j)
而不是 m[i][j]
来访问 Matrix
m
的元素。
int main()
{
Matrix m(10,10);
m(5,8) = 106.15;
std::cout << m(5,8);
// ...
}
请参阅下一个常见问题解答,了解有关使用 m(i,j)
而非 m[i][j]
的更多详细信息。
为什么我的 Matrix
类的接口不应该看起来像一个数组的数组?
这个 FAQ 的真正核心是:有些人构建了一个 Matrix 类,它的 operator[]
返回一个 Array
对象的引用(或者可能是一个原始数组,令人不寒而栗),而那个 Array
对象又有一个 operator[]
返回 Matrix 的一个元素(例如,一个 double
的引用)。因此,他们使用 m[i][j]
这样的语法而不是m(i,j)
这样的语法来访问矩阵的元素。
数组的数组解决方案显然有效,但它不如operator()
方法灵活。具体来说,使用 operator()
方法可以轻松进行性能调优,而在 [][]
方法中则更难,因此 [][]
方法更有可能导致性能不佳,至少在某些情况下是这样。
例如,实现 [][]
方法最简单的方式是将矩阵的物理布局作为密集矩阵,以行主序(或者列主序;我总是记不住)存储。相比之下,operator()
方法完全隐藏了矩阵的物理布局,这在某些情况下可以带来更好的性能。
这样说吧:operator()
方法永远不会比 [][]
方法差,有时甚至更好。
operator()
方法绝不会更差,因为它很容易使用operator()
方法实现密集、行主序的物理布局,因此当这种配置恰好是性能方面的最优布局时,operator()
方法与[][]
方法一样容易(也许operator()
方法稍微容易一点,但我不会为细枝末节争论)。operator()
方法有时更好,因为每当给定应用程序的最佳布局恰好不是密集、行主序时,使用operator()
方法实现通常比使用[][]
方法容易得多。
举个物理布局何时产生显著差异的例子,一个最近的项目恰好按列访问矩阵元素(也就是说,算法访问一列中的所有元素,然后是另一列中的元素,依此类推),如果物理布局是行主序,访问可能会“跨越缓存”。例如,如果行的长度恰好与处理器缓存大小差不多,则机器在几乎每次元素访问时都可能发生“缓存未命中”。在这个特定项目中,通过将逻辑布局(行、列)映射更改为物理布局(列、行),我们获得了 20% 的性能提升。
当然,数值方法中有很多这样的例子,稀疏矩阵是这个问题的另一个维度。由于使用 operator()
方法通常更容易实现稀疏矩阵或交换行/列顺序,因此 operator()
方法没有损失,反而可能有所收获——它没有缺点,但有潜在的优点。
我还是不明白。为什么我的 Matrix
类的接口不应该看起来像一个数组的数组?
原因与你封装数据结构和检查参数以确保其有效的原因相同。
少数人尽管存在限制,仍然使用 [][]
,他们争辩说 [][]
更好是因为它更快或者因为它使用了 C 语法。 “它更快”的论点的问题在于它不是——至少在两个世界知名的 C++ 编译器的最新版本上不是。 “使用 C 语法”的论点的问题在于 C++ 不是 C。此外,C 语法使得改变数据结构和检查参数值更加困难。
前两个常见问题解答的重点是,m(i,j)
为您提供了一种干净、简单的方式来检查所有参数并隐藏(因此,如果您愿意,可以更改)内部数据结构。世界上已经有太多暴露的数据结构和太多越界的参数,这些都花费了太多的金钱,造成了太多的延迟和太多的缺陷。
现在,每个人都知道“你”与众不同。你拥有预知未来的完美能力,并且你知道没有人会从改变矩阵的内部数据结构中获得任何好处。此外,你是一个“优秀”的程序员,不像那些偶尔传递错误参数的懒汉,所以你不需要担心参数检查这样的小问题。但是,即使你不需要担心维护成本(没有人需要改变你的代码),可能还有一两个其他程序员尚未完全完美。对他们来说,维护成本很高,缺陷真实存在,并且需求会变化。信不信由你,他们偶尔需要(最好坐下)改变他们的代码。
我承认我的舌头有些歪斜。但有一个重点。重点是封装和参数检查不是弱者的拐杖。使用能够简化封装和/或参数检查的技术是明智的。m(i,j)
语法就是其中一种技术。
尽管如此,如果你发现自己正在维护一个十亿行的应用程序,而原始团队使用了 m[i][j]
,或者即使你正在编写一个全新的应用程序,而你只是想使用 m[i][j]
,你仍然可以封装数据结构和/或检查所有参数。这甚至不难。然而,它确实需要一定程度的复杂性,无论你喜欢与否,普通 C++ 程序员都对此感到恐惧。幸运的是,你不是普通的,所以请继续阅读。
如果你只是想检查参数,只需确保外部的 operator[]
返回一个对象而不是一个原始数组,那么该对象的 operator[]
就可以以通常的方式检查其参数。请注意,这可能会减慢你的程序。特别是,如果这些内部类似数组的对象最终为它们的矩阵行分配自己的内存块,那么创建/销毁矩阵对象的性能开销会急剧增加。 “理论”成本仍然是 O(行 × 列),但在实践中,内存分配器(new
或 malloc
)的开销可能比其他任何东西都“大得多”,并且该开销可能会淹没其他成本。例如,在两个世界知名的 C++ 编译器上,每行单独分配内存的技术比为整个矩阵一次性分配内存的技术慢 10 倍。 10% 是一回事,10 倍是另一回事。
如果您想在不增加上述开销的情况下检查参数,并且/或者您想封装(并可能更改)矩阵的内部数据结构,请遵循以下步骤:
- 将
operator()(unsigned row, unsigned col)
添加到Matrix
类中。 - 创建嵌套类
Matrix::Row
。它应该有一个带参数(Matrix& matrix, unsigned row)
的构造函数,并且它应该将这两个值存储在其this
对象中。 - 更改
Matrix::operator[](unsigned row)
,使其返回一个Matrix::Row
类的对象,例如{ return Row(*this,row); }
。 - 类
Matrix::Row
然后定义自己的operator[](unsigned col)
,它转而调用,你猜对了,Matrix::operator()(unsigned row, unsigned col)
。如果Matrix::Row
的数据成员名为Matrix& matrix_
和unsigned row_
,那么Matrix::Row::operator[](unsigned col)
的代码将是{ return matrix_(row_, col); }
接下来,你将通过重复上述步骤来启用 const
重载。你将创建各种方法的 const
版本,并且你将创建一个新的嵌套类,可能命名为 Matrix::ConstRow
。不要忘记使用 const Matrix&
而不是 Matrix&
。
最后一步:找到那个没有阅读上一个常见问题解答的家伙,然后敲他的脑袋。
如果你有一个不错的编译器,并且你明智地使用内联,编译器应该会优化掉临时对象。换句话说,上面的 operator[]
方法有望不会比你一开始就直接调用 Matrix::operator()(unsigned row, unsigned col)
的情况更慢。当然,你可以让你的生活更简单,避免上面大部分工作,方法是一开始就直接调用 Matrix::operator()(unsigned row, unsigned col)
。所以你不如一开始就直接调用 Matrix::operator()(unsigned row, unsigned col)
。
我应该从外部(接口优先)还是从内部(数据优先)设计我的类?
从外部!
一个好的接口提供了以用户词汇表达的“简化”视图。在面向对象软件中,接口通常是单个类或紧密耦合的类组的公共方法的集合。
首先考虑对象在逻辑上代表什么,而不是你打算如何物理构建它。例如,假设你有一个 Stack
类,它将通过包含一个 LinkedList
来构建:
class Stack {
public:
// ...
private:
LinkedList list_;
};
Stack 应该有一个返回 LinkedList
的 get()
方法吗?或者一个接受 LinkedList
的 set()
方法?或者一个接受 LinkedList
的构造函数?显然答案是“否”,因为你应该从外到内设计你的接口。也就是说,Stack
对象的用户不关心 LinkedList
;他们关心压入和弹出。
现在再举一个更微妙的例子。假设 LinkedList
类是使用 Node
对象的链表构建的,其中每个 Node
对象都指向下一个 Node
:
class Node { /*...*/ };
class LinkedList {
public:
// ...
private:
Node* first_;
};
LinkedList
类应该有一个 get()
方法,让用户访问第一个 Node
吗?Node
对象应该有一个 get()
方法,让用户跟随该 Node
到链中的下一个 Node
吗?换句话说,LinkedList
从外部看起来应该是什么样子?LinkedList
真的是 Node
对象的链吗?或者这只是一个实现细节?如果它只是一个实现细节,LinkedList
将如何让用户一次访问 LinkedList
中的每个元素?
关键的洞察力是认识到 LinkedList
“不是”一个 Node
链。那可能是它“如何”构建的,但那不是它“是什么”。它是一个元素序列。因此,LinkedList
抽象也应该提供一个 LinkedListIterator
class
,并且该 LinkedListIterator
可能有一个 operator++
来移动到下一个元素,它可能有一个 get()
/set()
对来访问存储在 Node
中的“值”(Node
元素中的值完全由 LinkedList
用户负责,这就是为什么有一个 get()
/set()
对允许用户自由操纵该值)。
从用户的角度来看,我们可能希望我们的 LinkedList
class
支持类似于使用指针算术访问数组的操作:
void userCode(LinkedList& a)
{
for (LinkedListIterator p = a.begin(); p != a.end(); ++p)
std::cout << *p << '\n';
}
为了实现这个接口,LinkedList
将需要一个 begin()
方法和一个 end()
方法。这些方法返回一个 LinkedListIterator
对象。LinkedListIterator
将需要一个前进的方法 ++p
;一个访问当前元素的方法 *p
;以及一个比较运算符 p != a.end()
。
代码如下。需要注意的是,LinkedList
“没有”任何允许用户访问 Node
的方法。Node
是一种“完全”隐藏的实现技术。这使得 LinkedList
类更安全(用户不可能搞乱各种节点之间的不变量和链接),更易于使用(用户不需要额外费力地保持节点计数等于实际节点数,或任何其他基础设施),并且更灵活(通过更改一个 typedef
,用户可以将其代码从使用 LinkedList
更改为其他类似列表的类,并且大部分代码将干净地编译,并有望提高性能特性)。
#include <cassert> // Poor man's exception handling
class LinkedListIterator;
class LinkedList;
class Node {
// No public members; this is a "private class"
friend class LinkedListIterator; // A friend class
friend class LinkedList;
Node* next_;
int elem_;
};
class LinkedListIterator {
public:
bool operator== (LinkedListIterator i) const;
bool operator!= (LinkedListIterator i) const;
void operator++ (); // Go to the next element
int& operator* (); // Access the current element
private:
LinkedListIterator(Node* p);
Node* p_;
friend class LinkedList; // so LinkedList can construct a LinkedListIterator
};
class LinkedList {
public:
void append(int elem); // Adds elem after the end
void prepend(int elem); // Adds elem before the beginning
// ...
LinkedListIterator begin();
LinkedListIterator end();
// ...
private:
Node* first_;
};
以下是那些显然可以内联的方法(可能在同一个头文件中):
inline bool LinkedListIterator::operator== (LinkedListIterator i) const
{
return p_ == i.p_;
}
inline bool LinkedListIterator::operator!= (LinkedListIterator i) const
{
return p_ != i.p_;
}
inline void LinkedListIterator::operator++()
{
assert(p_ != NULL); // or if (p_==NULL) throw ...
p_ = p_->next_;
}
inline int& LinkedListIterator::operator*()
{
assert(p_ != NULL); // or if (p_==NULL) throw ...
return p_->elem_;
}
inline LinkedListIterator::LinkedListIterator(Node* p)
: p_(p)
{ }
inline LinkedListIterator LinkedList::begin()
{
return first_;
}
inline LinkedListIterator LinkedList::end()
{
return NULL;
}
结论:链表有两种不同类型的数据。存储在链表中的元素的值由链表的用户负责(并且“仅”由用户负责;链表本身不试图阻止用户将第三个元素更改为 5),以及链表的基础设施数据(next
指针等),其值由链表负责(并且“仅”由链表负责;例如,链表不允许用户更改(甚至查看!)各种 next
指针)。
因此,唯一的 get()
/set()
方法是获取和设置链表的“元素”,而不是链表的基础设施。由于链表隐藏了基础设施指针/等,它能够对该基础设施做出非常强有力的承诺(例如,如果它是双向链表,它可能保证每个前进指针都与来自下一个 Node
的后退指针匹配)。
因此,我们在这里看到了一个例子,其中类中“某些”数据的值是“用户”的责任(在这种情况下,类需要为该数据提供 get()
/set()
方法),但类想要控制的数据不一定有 get()
/set()
方法。
注意:这个例子的目的“不是”向你展示如何编写一个链表类。实际上,你“不应该”自己编写链表类,因为你应该使用编译器提供的“容器类”之一。理想情况下,你会使用标准容器类之一,例如 std::list
模板。
如何重载运算符 ++
和 --
的前缀和后缀形式?
通过一个虚拟参数。
由于前缀和后缀 ++
运算符可以有两种定义,C++ 语言为我们提供了两种不同的签名。两者都称为 operator++()
,但前缀版本不带参数,后缀版本带一个虚拟 int
。(虽然本讨论围绕 ++
运算符展开,但 --
运算符完全对称,适用于一个的所有规则和准则也适用于另一个。)
class Number {
public:
Number& operator++ (); // prefix ++
Number operator++ (int); // postfix ++
};
请注意不同的返回类型:前缀版本按引用返回,后缀版本按值返回。如果这对您来说不明显,那么在您看到定义(以及记住 y = x++
和 y = ++x
会将 y
设置为不同的东西)后,它应该会变得明显。
Number& Number::operator++ ()
{
// ...
return *this;
}
Number Number::operator++ (int)
{
Number ans = *this;
++(*this); // or just call operator++()
return ans;
}
后缀版本的另一种选择是不返回任何内容:
class Number {
public:
Number& operator++ ();
void operator++ (int);
};
Number& Number::operator++ ()
{
// ...
return *this;
}
void Number::operator++ (int)
{
++(*this); // or just call operator++()
}
但是,你“绝不能”让后缀版本通过引用返回 this
对象;你已被警告。
以下是这些运算符的使用方式:
Number x = /* ... */;
++x; // calls Number::operator++(), i.e., calls x.operator++()
x++; // calls Number::operator++(int), i.e., calls x.operator++(0)
假设返回类型不是“void”,你可以在更大的表达式中使用它们:
Number x = /* ... */;
Number y = ++x; // y will be the new value of x
Number z = x++; // z will be the old value of x
哪个效率更高:i++
还是 ++i
?
++i
有时比 i++
更快,并且永远不会比 i++
慢。
对于像 int
这样的固有类型,这无关紧要:++i
和 i++
的速度相同。对于像迭代器或前一个 FAQ 中的 Number
类这样的类类型,++i
很可能比 i++
快,因为后者可能会复制 this
对象。
i++
的开销,如果存在的话,可能不会造成任何实际影响,除非您的应用程序是 CPU 密集型的。例如,如果您的应用程序大部分时间都在等待某人点击鼠标、进行磁盘 I/O、网络 I/O 或数据库查询,那么浪费一些 CPU 周期不会损害您的性能。然而,输入 ++i
和 i++
一样容易,所以为什么不使用前者,除非您确实需要 i
的旧值。
所以,如果你正在编写 i++
作为一条语句而不是作为更大表达式的一部分,为什么不直接写 ++i
呢?你永远不会失去什么,有时还会获得一些。老派的 C 程序员习惯于写 i++
而不是 ++i
。例如,他们会说 for (i = 0;
i < 10;
i++) ...
。由于这里将 i++
用作语句,而不是更大表达式的一部分,那么你可能希望改用 ++i
。为了对称性,我个人提倡这种风格,即使它不能提高速度,例如,对于内置类型和后缀运算符返回 void
的类类型。
显然,当 i++
作为更大表达式的一部分出现时,情况就不同了:它之所以被使用,是因为它是唯一逻辑上正确的解决方案,而不是因为它是你在 C 编程时养成的旧习惯。