- 内容提要
- 前言
- 第 1 章 预备知识
- 第 2 章 开始学习 C++
- 第 3 章 处理数据
- 第 4 章 复合类型
- 第 5 章 循环和关系表达式
- 第 6 章 分支语句和逻辑运算符
- 第 7 章 函数——C++的编程模块
- 第 8 章 函数探幽
- 第 9 章 内存模型和名称空间
- 第 10 章 对象和类
- 第 11 章 使用类
- 第 12 章 类和动态内存分配
- 第 13 章 类继承
- 第 14 章 C++中的代码重用
- 第 15 章 友元、异常和其他
- 第 16 章 string 类和标准模板库
- 第 17 章 输入、输出和文件
- 第 18 章 探讨 C++新标准
- 附录 A 计数系统
- 附录 B C++保留字
- 附录 C ASCII 字符集
- 附录 D 运算符优先级
- 附录 E 其他运算符
- 附录 F 模板类 string
- 附录 G 标准模板库方法和函数
- 附录 H 精选读物和网上资源
- 附录 I 转换为 ISO 标准 C++
- 附录 J 复习题答案
11.5 再谈重载:一个矢量类
下面介绍另一种使用了运算符重载和友元的类设计——一个表示矢量的类。这个类还说明了类设计的其他方面,例如,在同一个对象中包含两种描述同一样东西的不同方式等。即使并不关心矢量,也可以在其他情况下使用这里介绍的很多新技术。矢量(vector),是工程和物理中使用的一个术语,它是一个有大小和方向的量。例如,推东西时,推的效果将取决于推力的大小和推的方向。从某个方向推可能会省力,而从相反的方向推则要费很大的劲。为完整地描述汽车的运动情况,应指出其运动速度(大小)和运动方向;如果逆行,则向高速公路的巡警辩解没有超速、超载是徒劳的(免疫学家和计算机专家使用术语矢量的方式不同,请不要考虑这一点,至少在第 16 章介绍计算机科学版本——vector 模板类之前应如此)。下面的旁注介绍了更多有关矢量的知识,但对于下面的 C++示例来说,并不必完全理解这些知识。
矢量
假设工蜂发现了一个非凡的花蜜储藏处,它匆忙返回蜂巢,告知其他蜜蜂,该花蜜储藏处离蜂巢 120 码。“这种信息是不完整的”,其他蜜蜂感到很茫然——“还必须告知方向!”,该工蜂答道:“太阳方向偏北 30 度”。知道了距离(大小)和方向,其他的蜜蜂能很快找到蜜源。蜜蜂懂得矢量。
许多数量都有大小和方向。例如,推的效果取决于力气的大小和方向。在计算机屏幕上移动对象时也涉及到距离和方向。可以使用矢量来描述这类问题。例如,可以用矢量来描述如何在屏幕上移动(放置)对象,即用箭头从起始位置画到终止位置,来对它作形象化处理。矢量的长度是其大小——描述了移动的距离;箭头的指向描述了方向(参见图 11.1)。表示这种位置变化的矢量称为位移矢量(displacement vector)。
现在,假设您是 Lhanappa——伟大的毛象猎手。猎狗报告毛象群位于西北 14.1 公里处。但由于当时刮的是东南风,您不想从东南方向接近毛象群,因此先向西走了 10 公里,再向北走了 10 公里,最终从南面接近毛象群。您知道这两个位移矢量与指向西北的 14.1 公里的矢量的方向相同。伟大的毛象猎手 Lhanappa 也知道如何将两个矢量相加。
将两个矢量相加有一种简单的几何解释。首先,画一个矢量,然后从第一个矢量的尾部开始画第二个矢量。最后从第一个矢量的开始处向第二个矢量的结尾处画一个矢量。第三个矢量表示前两个矢量的和(参见图 11.2)。注意,两个矢量之和的长度可能小于它们的长度之和。
显然,应为矢量重载运算符。首先,无法用一个数来表示矢量,因此应创建一个类来表示矢量。其次,矢量与普通数学运算(如加法、减法)有相似之处。这种相似表明,应重载运算符,使之能用于矢量。
出于简化的目的,本节将实现一个二维矢量(如屏幕位移),而不是三维矢量(如表示直升机或体操运动员的运动情况)。描述二维矢量只需两个数,但可以选择到底使用哪两个数:
图 11.1 使用矢量描述位移
图 11.2 将两个矢量相加
- 可以用大小(长度)和方向(角度)描述矢量;
- 可以用分量 x 和 y 表示矢量。
两个分量分别是水平矢量(x 分量)和垂直矢量(y 分量),将其相加可以得到最终的矢量。例如,可以这样描述点的运动:向右移动 30 个单位,再向上移动 40 个单位(参见图 11.3)。这将把该点沿与水平方向呈 53.1 度的方向移动 50 个单位,因此,水平分量为 30 个单位、垂直分量为 40 个单位的矢量,与长度为 50 个单位、方向为 53.1 度的矢量相同。位移矢量指的是从何处开始、到何处结束,而不是经过的路线。这种表示基本上和第 7 章在直角坐标与极坐标之间转换的程序中介绍的相同。
图 11.3 矢量的 x 和 y 分量
有时一种表示形式更方便,而有时另一种更方便,因此类描述中将包含这两种表示形式(参见本章后面的旁注“多种表示方式和类”)。另外,设计这个类时,将使得用户修改了矢量的一种表示后,对象将自动更新另一种表示。使对象有这种智能,是 C++类的另一个优点。程序清单 11.13 列出了这个类的声明。为复习名称空间,该清单将类声明放在 VECTOR 名称空间中。另外,该程序使用枚举创建了两个常量(RECT 和 POL),用于标识两种表示法(枚举在第 10 章介绍过,因此这里直接使用它)。
程序清单 11.13 vector.h
注意,程序清单 11.13 中 4 个报告分量值的函数是在类声明中定义的,因此将自动成为内联函数。这些函数非常短,因此适于声明为内联函数。因为它们都不会修改对象数据,所以声明时使用了 const 限定符。第 10 章介绍过,这种句法用于声明那些不会对其显式访问的对象进行修改的函数。
程序清单 11.14 列出了程序清单 11.13 中声明的方法和友元函数的定义,该清单利用了名称空间的开放性,将方法定义添加到 VECTOR 名称空间中。请注意,构造函数和 reset( ) 函数都设置了矢量的直角坐标和极坐标表示,因此需要这些值时,可直接使用而无需进行计算。另外,正如第 4 章和第 7 章指出的,C++的内置数学函数在使用角度时以弧度为单位,所以函数在度和弧度之间进行转换。该 Vector 类实现对用户隐藏了极坐标和直角坐标之间的转换以及弧度和度之间的转换等内容。用户只需知道:类在使用角度时以度为单位,可以使用两种等价的形式来表示矢量。
程序清单 1.14 vector.cpp
也可以以另一种方式来设计这个类。例如,在对象中存储直角坐标而不是极坐标,并使用方法 magval( ) 和 angval( ) 来计算极坐标。对于很好进行坐标转换的应用来说,这将是一种效率更高的设计。另外,方法 reset( ) 并非必不可少的。假设 shove 是一个 Vector 对象,而您编写了如下代码:
可以使用构造函数来得到相同的结果:
然而,方法 set( ) 直接修改 shove 的内容,而使用构造函数将增加额外的步骤:创建一个临时对象,然后将其赋给 shove。
这些设计决策遵守了 OOP 传统,即将类接口的重点放在其本质上(抽象模型),而隐藏细节。这样,当用户使用 Vector 类时,只需考虑矢量的通用特性,例如,矢量可以表示位移,可以将两个矢量相加等。使用分量还是大小和方向来表示矢量已无关紧要,因为程序员可以设置矢量的值,并选择最方便的格式来显示它们。
下面更详细地介绍 Vector 类的一些特性。
11.5.1 使用状态成员
Vector 类储存了矢量的直角坐标和极坐标。它使用名为 mode 的成员来控制使用构造函数、reset( ) 方法和重载的 operator<<( ) 函数使用哪种形式,其中枚举 RECT 表示直角坐标模式(默认值)、POL 表示极坐标模式。这样的成员被称为状态成员(state member),因为这种成员描述的是对象所处的状态。要知道具体含义,请看构造函数的代码:
如果第三个参数是 RECT 或省略了(原型将默认值设置为 RECT),则将输入解释为直角坐标;如果为 POL,则将输入解释为极坐标:
标识符 POL 的作用域为类,因此类定义可使用未限定的名称。但全限定名为 VECTOR::Vector::POL,因为 POL 是在 Vector 类中定义的,而 Vector 是在名称空间 VECTOR 中定义的。注意,如果用户提供的是 x 值和 y 值,则构造函数将使用私有方法 set_mag( ) 和 set_ang( ) 来设置距离和角度值;如果提供的是距离和角度值,则构造函数将使用 set_x( ) 和 set_y( ) 方法来设置 x 值和 y 值。另外,如果用户指定的不是 RECT 或 POL,则构造函数将显示一条警告消息,并将状态设置为 RECT。
看起来好像难以将 RECT 和 POL 外的其他值传递给构造函数,因为第三个参数的类型为 VECTOR::Vector::Mode。像下面这样的调用无法通过编译,因为诸如 2 等整数不能隐式地转换为枚举类型:
然而,机智而好奇的用户可尝试下面这样的代码,看看结果如何:
就这里而言,编译器将发出警告。
接下来,operator<<( ) 函数也使用模式来确定如何显示值:
由于 operator<<() 是一个友元函数,而不在类作用域内,因此必须使用 Vector::RECT,而不能使用 RECT。但这个友元函数在名称空间 VECTOR 中,因此无需使用全限定名 VECTOR:: Vector::RECT。
设置模式的各种方法只接受 RECT 和 POL 为合法值,因此该函数中的 else 永远不会执行。但进行检查还是一个不错的主意,它有助于捕获难以发现的编程错误。
多种表示方式和类
可以用不同但等价的方式表示的量很常见。例如,可以按每加仑汽油消耗汽车能行驶的英里数来计算油耗(美国),也可以按每 100 公里消耗多少公升汽油来计算(欧洲)。可以用字符串表示数字,也可以用数值方式表示,可以使用 IQ 或 kiloturkey 的方法表示智商。类非常适于在一个对象中表示实体的不同方面。首先在一个对象中存储多种表示方式;然后,编写这样的类函数,以便给一种表示方式赋值时,将自动给其他表示方式赋值。例如,Vector 类的 set_by_polar( ) 方法将 mag 和 ang 成员设置为函数参数的值,并同时设置成员 x 和 y。也可存储一种表示方式,并使用方法来提供其他表示方式。通过在内部处理转换,类允许从本质(而不是表示方式)上来看待一个量。
11.5.2 为 Vector 类重载算术运算符
在使用 x、y 坐标时,将两个矢量相加将非常简单,只要将两个 x 分量相加,得到最终的 x 分量,将两个 y 分量相加,得到最终的 y 分量即可。根据这种描述,可能使用下面的代码:
如果对象只存储 x 和 y 分量,则这很好。遗憾的是,上述代码无法设置极坐标值。可以通过添加另外一些代码来解决这种问题:
然而,使用构造函数来完成这种工作,将更简单、更可靠:
上述代码将新的 x 分量和 y 分量传递给 Vector 构造函数,而后者将使用这些值来创建无名的新对象,并返回该对象的副本。这确保了新的 Vector 对象是根据构造函数制定的标准规则创建的。
提示:
如果方法通过计算得到一个新的类对象,则应考虑是否可以使用类构造函数来完成这种工作。这样做不仅可以避免麻烦,而且可以确保新的对象是按照正确的方式创建的。
1.乘法
将矢量与一个数相乘,将使该矢量加长或缩短(取决于这个数)。因此,将矢量乘以 3 得到的矢量的长度为原来的三倍,而方向不变。要在 Vector 类中实现矢量的这种行为很容易。对于极坐标,只要将长度进行伸缩,并保持角度不变即可;对于直角坐标,只需将 x 和 y 分量进行伸缩即可。也就是说,如果矢量的分量为 5 和 12,则将其乘以 3 后,分量将分别是 15 和 36。这正是重载的乘法运算符要完成的工作:
和重载加法一样,上述代码允许构造函数使用新的 x 和 y 分量来创建正确的 Vector 对象。上述函数用于处理 Vector 值和 double 值相乘。可以像 Time 示例那样,使用一个内联友元函数来处理 double 与 Vector 相乘:
2.对已重载的运算符进行重载
在 C++中,−运算符已经有两种含义。首先,使用两个操作数,它是减法运算符。减法运算符是一个二元运算符,因为它有两个操作数。其次,使用一个操作数时(如−x),它是负号运算符。这种形式被称为一元运算符,即只有一个操作数。对于矢量来说,这两种操作(减法和符号反转)都是有意义的,因此 Vector 类有这两种操作。
要从矢量 A 中减去矢量 B,只要将分量相减即可,因此重载减法与重载加法相似:
操作数的顺序非常重要。下面的语句:
将被转换为下面的成员函数调用:
这意味着将从隐式矢量参数减去以显式参数传递的矢量,所以应使用 x − b.x,而不是 b.x − x。
接下来,来看一元负号运算符,它只使用一个操作数。将这个运算符用于数字(如−x)时,将改变它的符号。因此,将这个运算符用于矢量时,将反转矢量的每个分量的符号。更准确地说,函数应返回一个与原来的矢量相反的矢量(对于极坐标,长度不变,但方向相反)。下面是重载负号的原型和定义:
现在,operator-( ) 有两种不同的定义。这是可行的,因为它们的特征标不同。可以定义−运算符的一元和二元版本,因为 C++提供了该运算符的一元和二元版本。对于只有二元形式的运算符(如除法运算符),只能将其重载为二元运算符。
注意:
因为运算符重载是通过函数来实现的,所以只要运算符函数的特征标不同,使用的运算符数量与相应的内置 C++运算符相同,就可以多次重载同一个运算符。
11.5.3 对实现的说明
前几节介绍的实现在 Vector 对象中存储了矢量的直角坐标和极坐标,但公有接口并不依赖于这一事实。所有接口都只要求能够显示这两种表示,并可以返回各个值。内部实现方式可以完全不同。正如前面指出的,对象可以只存储 x 和 y 分量,而返回矢量长度的 magval( ) 方法可以根据 x 和 y 的值来计算出长度,而不是查找对象中存储的这个值。这种方法改变了实现,但用户接口不变。将接口与实现分离是 OOP 的目标之一,这样允许对实现进行调整,而无需修改使用这个类的程序中的代码。
这两种实现各有利弊。存储数据意味着对象将占据更多的内存,每次 Vector 对象被修改时,都需要更新直角坐标和极坐标表示;但查找数据的速度比较快。如果应用程序经常需要访问矢量的这两种表示,则这个例子采用的实现比较合适;如果只是偶尔需要使用极坐标,则另一种实现更好。可以在一个程序中使用一种实现,而在另一个程序中使用另一种实现,但它们的用户接口相同。
11.5.4 使用 Vector 类来模拟随机漫步
程序清单 11.15 是一个小程序,它使用了修订后的 Vector 类。该程序模拟了著名的醉鬼走路问题(Drunkard Walk problem)。实际上,醉鬼被认为是一个有许多健康问题的人,而不是大家娱乐消遣的谈资,因此这个问题通常被称为随机漫步问题。其意思是,将一个人领到街灯柱下。这个人开始走动,但每一步的方向都是随机的(与前一步不同)。这个问题的一种表述是,这个人走到离灯柱 50 英尺处需要多少步。从矢量的角度看,这相当于不断将方向随机的矢量相加,直到长度超过 50 英尺。
程序清单 11.15 允许用户选择行走距离和步长。该程序用一个变量来表示位置(一个矢量),并报告到达指定距离处(用两种格式表示)所需的步数。可以看到,行走者前进得相当慢。虽然走了 1000 步,每步的距离为 2 英尺,但离起点可能只有 50 英尺。这个程序将行走者所走的净距离(这里为 50 英尺)除以步数,来指出这种行走方式的低效性。随机改变方向使得该平均值远远小于步长。为了随机选择方向,该程序使用了标准库函数 rand( )、srand( ) 和 time( )(参见程序说明)。请务必将程序清单 11.14 和程序清单 11.15 一起进行编译。
程序清单 11.15 randwalk.cpp
该程序使用 using 声明导入了 Vector,因此该程序可使用 Vector::POL,而不必使用 VECTOR:: Vector::POL。
下面是程序清单 11.13~程序清单 11.15 组成的程序的运行情况:
这种处理的随机性使得每次运行结果都不同,即使初始条件相同。然而,平均而言,步长减半,步数将为原来的 4 倍。概率理论表明,平均而言,步数(N)、步长(s),净距离 D 之间的关系如下:
这只是平均情况,但每次试验结果可能相差很大。例如,进行 1000 次试验(走 50 英尺,步长为 2 英尺)时,平均步数为 636(与理论值 625 非常接近),但实际步数位于 91~3951。同样,进行 1000 次试验(走 50 英尺,步长为 1 英尺)时,平均步数为 2557(与理论值 2500 非常接近),但实际步数位于 345~10882。因此,如果发现自己在随机漫步时,请保持自信,迈大步走。虽然在蜿蜒前进的过程中仍旧无法控制前进的方向,但至少会走得远一点。
程序说明
首先需要指出的是,在程序清单 11.15 中使用 VECTOR 名称空间非常方便。下面的 using 声明使 Vector 类的名称可用:
因为所有的 Vector 类方法的作用域都为整个类,所以导入类名后,无需提供其他 using 声明,就可以使用 Vector 的方法。
接下来谈谈随机数。标准 ANSI C 库(C++也有)中有一个 rand( ) 函数,它返回一个从 0 到某个值(取决于实现)之间的随机整数。该程序使用求模操作数来获得一个 0~359 的角度值。rand( ) 函数将一种算法用于一个初始种子值来获得随机数,该随机值将用作下一次函数调用的种子)依此类推。这些数实际上是伪随机数,因为 10 次连续的调用通常将生成 10 个同样的随机数(具体值取决于实现)。然而,srand( ) 函数允许覆盖默认的种子值,重新启动另一个随机数序列。该程序使用 time(0)的返回值来设置种子。time(0)函数返回当前时间,通常为从某一个日期开始的秒数(更广义地,time( ) 接受 time_t 变量的地址,将时间放到该变量中,并返回它。将 0 用作地址参数,可以省略 time_t 变量声明)。因此,下面的语句在每次运行程序时,都将设置不同的种子,使随机输出看上去更为随机:
头文件 cstdlib(以前为 stdlib.h)包含了 srand( ) 和 rand( ) 的原型,而 ctime(以前是 time.h)包含了 time( ) 的原型。C++11 使用头文件 radom 中的函数提供了更强大的随机数支持。
该程序使用 result 矢量记录行走者的前进情况。内循环每轮将 step 矢量设置为新的方向,并将它与当前的 result 矢量相加。当 result 的长度超过指定的距离后,该循环结束。
程序通过设置矢量的模式,用直角坐标和极坐标显示最终的位置。
下面这条语句将 result 设置为 RECT 模式,而不管 result 和 step 的初始模式是什么:
这样做的原因如下。首先,加法运算符函数创建并返回一个新矢量,该矢量存储了这两个参数的和。该函数使用默认构造函数以 RECT 模式创建矢量。因此,被赋给 result 的矢量的模式为 RECT。默认情况下,赋值时将分别给每个成员变量赋值,因此将 RECT 赋给了 result.mode。如果偏爱其他方式,例如,result 保留原来的模式,可以通过为类定义赋值运算符来覆盖默认的赋值方式。第 12 章将介绍这样的示例。
顺便说一句,在将一系列位置存储到文件中很容易。首先包含头文件 fstream,声明一个 ofstream 对象,将其同一个文件关联起来:
然后,在计算结果的循环中加入类似于下面的代码:
这将调用友元函数 operator<<(fout, result),导致引用参数 os 指向 fout,从而将输出写入到文件中。您还可以使用 fout 将其他信息写入到文件中,如当前由 cout 显示的总结信息。
如果你对这篇内容有疑问,欢迎到本站社区发帖提问 参与讨论,获取更多帮助,或者扫码二维码加入 Web 技术交流群。
绑定邮箱获取回复消息
由于您还没有绑定你的真实邮箱,如果其他用户或者作者回复了您的评论,将不能在第一时间通知您!
发布评论