C++你也行

C++你也行

本章介绍我在随书光盘中准备的供你阅读本书时使用的工具。你也许更喜欢使用你已经拥有的某种工具,也可能偏爱使用光盘中提供的IDE。不论作何选择,请浏览本章,并检查每一步骤是否可以正确运作(你也可以就此完成对自己所选工具的检查),这对你很有好处。如果你使用的是MinGW Developer Studio(我称之为MDS),可以通过将在屏幕上看见的画面和本章中的截屏图作比较,以检查你做得对不对。如果你使用的是光盘上的JGraspTM,那么可以通过检查光盘上的本章替代版本进行核对。如果你使用的是某些其他工具,仍应该浏览本章,以确保你知道如何使用这些工具编写并生成简单的程序。最重要的是,你必需检查选用的工具能否调用我提供的库。

在继续前进之前,请首先阅读光盘根目录下的Read_First.txt文件。它会告诉你光盘上有什么内容,以及到哪儿去寻找关于安装适合你的机器所使用操作系统(微软WindowsUNIX派生物(例如Linux)或Mac OS X)的软件的说明。在你完成该步骤或安装好你所选择的某些其他开发工具后,请接着从这里阅读。

也许你使用过某种拥有类似工具的语言,因此你对这种使用编译型语言的工具已经比较熟悉。如果是这样的话,请原谅我为了其他读者的利益而花时间去解释这些东西。

在本书中,我假定你正在使用一个集成开发环境(Integrated Development EnvironmentIDE),对程序员而言,它的作用如同木匠所使用的工作台。一些程序员习惯于使用命令行工具。如果你懂得如何编写makefile,这种方式很好,但如果你不会(或甚至对那些东西完全没有概念),则最好还是使用一个IDE,它可以提高编写、编译、连接以及调试代码期间的交互程度。

现在我准备从头讲解制作两个简单程序的过程。第一个程序是传统的“Hello World”,第二个程序用来检查是否一切都得到正确的设置,以便能够使用我提供的图形库。我有意将这些程序设计得很简单,目的是为了便于我们将精力放在从源代码和库创建出程序的过程上。随着讲解的逐渐深入,我会加入一些对你而言全新的信息如果你此前熟悉的语言完全不同于C++的话。

1.1   创建“Hello World”程序

第一步启动IDE。我总是让IDE图标呆在操作系统桌面上。如果从光盘上安装软件时你接受某一选项,就可以在桌面的某个地方找到图1-1所示的图标。

如果选择了别的选项,你将需要以不同的方式启动MDS。启动MDS之后,你应该在屏幕上看到图1-2所示窗体。

1-2   启动MDS后的界面

那些形形色色的面板(panes)的大小是可以调整的,随着学习的不断进展,你会明白它们各自的作用。倘若你熟悉IDE的使用,或许可以认识屏幕上的大多数东西。如果你点击下拉菜单,你会注意到大多数条目都是灰色的,并且大多数图标也是灰色的。将鼠标指针悬停在图标上就可以看到其工具提示(tool-tip),当然,前提是你没有禁用该特性。这些工具提示都很简短,不过我发现将默认热键(default hotkey)包含在提示中的确是个有意义的特性。

下一个步骤是进入Project菜单,选择New Project(若你偏爱使用热键,则可按下Ctrl+N)。

你将看见一个图1-3所示的数据输入窗口。

1-3   数据输入窗口

默认的项目类型是控制台应用程序(console application),它也是你阅读本书大部分篇幅所需创建的项目类型。你需要告诉IDE该项目的名字和项目文件的位置。只要从一开始就确定好位置,就不易出错。看看图1-3所示的文本框(text box),你会发现右侧有个灰色的小按钮(button)。点击此按钮,可导航至适当的子目录。如果你安装了MDS,该目录默认为C:/tutorial/chapter_1。现在转到上面的文本框,为你的项目取个名字。键入hello_world(注意MDS不能正确处理包含空格的文件名)并点击OK按钮。

接下来,你需要为源代码(source code,这一恰当的术语用于表示我们所编写的东西,编译器会将它转换成目标代码(object code))创建一个文件。从File菜单中选择New菜单项,你将看到图1-4所示对话框。

1-4   创建一个文件对话框

默认项就是我们想要的。你正在创建C/C++源文件并将它添加到项目中。而文件的位置与你存放项目本身的位置相同。你要做的全部事情只是为文件命名。将hellomain输入File name文本框。在打开FileView树(在左侧的面板中)之后,我的工作窗体的左上角如图1-5所示。

1-5   打开File View树后的工作窗体

在文件面板(其右侧深灰色边缘区域中有个表示代码行号的“1”)中输入以下代码:

确信在闭花括号后按下了Enter键,以便使文件以一个换行字符结束。

当输入代码时,MDS通过使用颜色尽量向你提供帮助。当输入一个不匹配的括号(圆括号、花括号或方括号)时,它首先会将其显示为红色。你可以使用Ctrl+B从一个括号跳转到与其匹配的另一个括号(如果存在的话)。MDS编辑器还支持代码折叠(即能够将代码隐藏起来,从而只显示一个头元素)。点击“int main”左边的减号试试看,你就会明白我的意思。在这个例子中该功能不是很有用,但是当你将注意力集中于某些其他代码片断、希望隐藏不相干的细节时,它就可以派上用场了。

在输入好代码后,从Build菜单下选择Compile菜单项。如果你正确地操作了每一步,你应该在底部面板中看到

倘若你忘记在源代码的结束花括号后按下Enter,你将会看到一条警告消息。之所以发出一条警告,是因为一个C++文件可以包含一些别的文件,而在文件的末尾少输入一个空行,就可能会导致编译器将下一行代码(即另一个文件中的第一行代码)附加到末尾的那一行代码后面,显然这极可能出问题。如果过去你习惯于使用一种解释型语言,例如Python,你可能会纳闷到目前为止究竟都得到了什么。是这样的,一个叫做编译器的工具把你的文本(源代码)转换成了一种连接器(linker)可以用来产生可执行程序的形式(目标代码)。我们很快就会考察源代码的其余部分,不过眼下我希望你生成一个可执行程序并运行它。你可以一步一步来,通过从Build菜单下选择Build菜单项,然后从同一菜单中选择Execute菜单项。或者,你也可以只选择Build and Execute菜单项。选择权尽在你手。你甚至可以直接选择Execute,这时MDS会询问你是否希望生成(build)可执行文件。

无论采用何种方法执行程序,最后你应该看到如图1-6所示的控制台窗口。

1-6   控制台窗口

MDS插入一条结束消息(在程序执行结束之后),这样,该窗口将保持打开状态直到你看好输出结果为止。“Terminated with return code 0”这一消息意味着程序圆满完成。

在继续学习之前,我希望你先尝试几件事情。首先,我希望你改变项目的设置。从Project菜单下选择Settings菜单项。现在选择Compile选项卡然后选中除最后一项(把所有警告都视作错误的做法杀伤力通常过大)以外的所有的警告框。其他选项则暂时维持原样。

可以针对调试版(debug version)和发行版(release version)分别设置选项。在程序开发过程中,我们通常愿意接受较低性能和体积较大的程序文件,以便在程序运行期间遭遇问题时换取更多的帮助(译注:因调试版中含各种调试信息)。然而,当我们准备将程序发布给他人时,我们就不再想要一个很大、很慢的程序了,因此制作一个发行版。通常发行版体积更小并可能更快一点儿,因为编译器会努力剥除所有不必要的细枝末节。

不必关注Project Settings对话框中的其他选项卡。我们只会用到CompileLink面板。

下一步,我希望你花些时间对源代码做一些实验(尝试添加几行额外的输出、省略分号,以及引入其他打字错误等),直至你对编译、生成和执行信心十足为止。由于你将要和这些工具一起度过漫长的日子,因此花一点时间熟悉它们是值得的。这也是我们从这样的一个无聊的小程序开始讲解的原因之一:它允许我们在使用手头工具编写更具有实际意义的程序之前,先专注于掌握工具的基本要素

1.2   代码的含义

让我们简要分析构成“Hello World”程序的六行代码。

第一行是注释。无论何时当编译器遇见一个“//”时,它将会忽略从那儿开始一直到该行结束的所有东西。注释是用来给人以及一些专门的代码分析工具看的,而并不是给编译器看的。本例中的注释几乎是多余的,包含它只是为了演示其用法而已。

第二行告诉编译器,接下来的代码可能使用C++标准库与经由控制台进出的流数据(streaming data)有关的部分里的名字。我们把尖括号内的内容称为header),它告诉编译器从它保存这种细节内容的地方获取有关信息。不同的编译器可用不同的方式获取信息。在实践中,头通常是位于编译器某一子目录中的一个文本文件。(倘若感兴趣,你可以在MinGWStudio/MinGW/include/c++/3.4.2中找到相应的iostream文件,但我认为目前这对你没什么意义。)

空行无实际意义,它的存在纯粹是为了将源代码引介性的部分同其余部分隔离开。下一行(int main(){)必须在每个程序中存在且只存在一次。事实上它决定了程序从哪里开始。还有其他一些main()的变种,允许在程序启动时通过参数接受一些数据,我们的例子不考虑它们。

第五行是程序的实质部分。std::cout是控制台输出对象的名字。换句话说,它是一个名字,我们用此名字来指定一个表示计算机上控制台的对象。控制台通常是监视器屏幕上的一个窗口。此名字中双冒号之前的部分表明,我们正在处理一个来自C++标准库的名字。

语言注解:C程序员可能将 << 认作左移位(left-shift)操作符。在输出对象或目标的上下文中,C++将此操作符作为流操作符重新利用,用于将数据插入到一个输出流中。

引号内的文本告诉编译器你希望将这些文本显示出来。我们将这些括起来的文本称为字符串字面量(string literal)。该语句的最后一个元素是分号。它表示语句终止,其作用类似于英文中的句号。省略它并编译代码试试,你将会看见由此导致的那一类错误消息。

该程序的最后一行是一个简单的结束花括号,与main所在那行代码末尾的开始花括号相匹配。一般而言,我们将一对开始到结束的花括号之间的代码称作一个语句块(block。在此上下文中,开始花括号到结束花括号之间的代码,是我们使用的这一版本的main的定义,它指定了程序在执行时会发生什么事情。我们把定义了一个函数的块称作函数体(function body。因此

是函数main()的主体。

1.3   第二个程序:空Playpen

我们时不时地会使用一个很特别的图形窗体,它是由我设计并由几个同事帮助实现的(等你使用它时,我想你会明白我为何给它起这么一个名字)。这是一个大小固定(512×512)的图形窗体,每个像素被限定为256种颜色之一。现代计算机通常都能够显示比它多得多的颜色,但我想要的除了使你了解简单的图形系统之外,还使这个程序具有极好的移植性。眼下,我们以其默认调色板(palette)来使用Playpen。(以后我们会探索选用256种不同的颜色。)

启动MDS并从Project菜单选择New菜单项。确保位置是正确的(你也许打算将“chaper_1”添加到它提供给你的路径之后,至少我在自己的机器上是这么做的)。现在输入“playpen”作为项目名称并按下Enter键。

接下来,使用File菜单或Ctrl+N,创建一份名为emptyplaypen的新C/C++源文件,并输入以下简短的程序:

这时候尝试编译源代码文件(Ctrl+F7)将会失败,并伴有四条或更多的错误消息。唯一有意义的是第一条,说找不到playpen.h。原因是我们必须告诉编译器到哪里去查找该头文件。(头(headers)是C++标准的组成部分,与头文件(header files)稍有不同,后者由第三方程序员提供,它们与第三方代码的关系很像头(header)与C++ 标准库的关系。)

Project菜单选择Settings菜单项,再选择Compile选项卡。在Compile面板的底部标注有“Additional include directories”的文本框内,输入“C:/tutorial/fgw_headers”(或者别的路径,如果你将CD内容安装到其他驱动器或目录中的话)。换言之,告诉编译器到Cturorial目录中的fgw_headers子目录中进行查找。在你设置头文件位置的同时,请顺便检查是否如第一个项目那样设置了相同的警告级别。

当你完成上述工作后,数据输入窗口应该如图1-7所示。

1-7   项目设置对话框

只要你的输入没有问题,emptyplaypen.cpp应该可以通过编译而不会发生错误或出现警告。现在尝试生成(build)它(F7),你将会看到连接期(link-time)错误消息,其中提到了你遗漏的东西。问题出在playpen.h曾对编译器许下过关于连接器可以找到什么东西的承诺,但我们并未告诉连接器究竟是哪个库文件提供了必需的预编译(目标)代码。连接器需要两个库文件:一个针对fgw库(我的库),一个针对gdi32库(跟操作系统相关的库,提供了一些被我的库所使用的基本图形设施)。

再次打开Project Settings对话框并选择Link选项卡。如果你正工作于微软Windows机器之上,则在Libraries文本框中输入“fgw;gdi32”

现在,你应该在屏幕上看到如图1-8所示画面。

1-8   输入fgw;gdi32后的窗口

返回项目并生成(build)它,一切应该能够正确运转了。

如果你正在使用的系统支持使用X Window System(例如LinuxMac OS X等)的图形,那么你需要将“fgw;X11;pthread”而不是“fgw;gdi32”插入到Libraries文本框中,后者用于微软Windows系统。你还需要像“C:/tutorial/fgw_headers;/usr/X11R6/lib”这样的路径,这两个路径分别告诉编译器我的库的头(headers)目录和X11R6库目录的位置。如果你不知道机器上的X11所支持文件的位置,你也许需要别人的帮助。

如果你使用的是一套不同的开发工具,你可能还需要知道库文件的名字而不仅仅是库的名字。MDS(连同GCC,即GNU Compiler CollectionMDS建立于它之上)使用这样的一种命名约定:库保存在以“lib”打头并以“.a”结尾的文件中。因此,库文件libfgw.alibgdi32.aWindows提供了两个库,而linfgw.alibX11.a以及libpthread.a则为使用X11作为图形支持的系统提供库。

最后,执行程序你将会看到图1-9所示结果。

你可以看到最顶层有一个标题为“Plyapen”的矩形白色窗体。在你学习本书期间我们将多次使用这个图形窗体。在它下面是一个标准控制台窗体(如果你没有改变计算机默认设置的话,它通常是黑色的)。在某些计算机上,你还可能在屏幕的左上角看到一片白色区域。不必为之烦心,那是因为某些版本的Windows在创建Playpen窗体时忘记了更新屏幕。只要你拖动一个窗体到那片区域上方,然后再移走,Windows就可以正确地更新屏幕。

1-9   执行程序后的窗口

利用任务栏选择控制台窗口(也可以点击屏幕上该窗体可见的某个部分,以将其提到顶层),然后按照它显示的指令进行操作。

1.4   代码的含义

注释之后的第一行,是一种不同形式的 #include。引号的使用告诉编译器这是一个头文件(header file)(与系统或语言的头(header)相对),且编译器应该到指示该文件的地方去寻找它。默认情况下,编译器会在当前文件所处的目录中查找。由于希望它在另一个目录中寻找头文件,因此我们修改了Project Settings,在包含路径(include path)中添加fgw_headers。使用形式不当的#include指令是个常见的错误。请注意,究竟该使用尖括号(针对头和系统头文件)还是双引号(针对用户和第三方头文件),这一点很重要。

接下来的三行代码同我们第一个程序中的一样,具有同样的重要性。fgw::playpen blank; 这一行代码告诉编译器,你希望创建一个Playpen对象,并且你将该对象命名为blank。如果你熟悉声明(declaration)和类型(type)的概念,那么,你应该知道fgw::playpen是一个类型,而整个语句则是blank的声明。我将在下一章讨论有关声明(declarations)的细节知识。

我相信std::cout << "Please press the 'ENTER' key"; 的含义对你来说是显而易见的。该程序的最后一行语句std::cin.get(); 对一些读者来说可能有点奇怪。是这样的,std::cin是标准C++控制台输入对象(std::cout的输入对应物)。我们大多用它获取从键盘输入的信息。该行语句的其余部分告诉编译器你希望从键盘获取单个字符。只有当收到输入完毕的信号时(通常由程序的用户按下Enter键),才从键盘提取数据。

我们不得不采用某些方法使程序保持运行状态,直到不再使用Playpen为止,这么说是因为窗体会在程序结束之时自动关闭。尝试移除std::cin语句(或在该行语句前面插入“//”从而将其注释掉),然后运行修改过的程序。Playpen在屏幕上一闪即逝,而std::cin.get()则使程序保持等待状态,直到用户通过按下Enter键提供一些输入为止。

1.5   其他尝试

你已经尝试的两个程序没有做任何一点点有趣的事情,如果你希望尝试一些更令人兴奋的东西,你可以阅读《You Can Do It![Glassborow 2004] 的第一章,它将为你提供更多的信息,使你了解可以在Playpen窗体中做哪些事情。

1.6   总结

MinGW Developer Studio为用MinGW开发工具套件进行软件开发提供了形形色色的工具。

当开始一个新项目时,你可能需要改变Project Settings中的一些设置。特别是,你可能需要告诉编译器去哪里查找某些头文件,你还可能需要告诉连接器关于你的程序所使用的任何专门的库。对其他IDE来说,其机制可能不尽相同,但本质是类似的。任何IDE都需要知道到哪儿寻找头文件以及会用到哪些库。此外,库的搜索顺序通常也很重要。换句话说,库列表(library list)的顺序是很重要的。库的名字在命令行中以前缀-l(负号+字母l)进行指定。库习惯上存储在以库的名字加lib前缀为名字的文件中。

每一个C++程序都包含一个叫做main() 的函数,它被用作程序的执行入口点。

C++包括一对对象std::coutstd::cin,它们代表控制台输出设备(默认为监视器上的一个窗口)和控制台输入设备(默认为键盘)。要想使用这两个对象,需要将iostream头(header)包含进程序中。

我们可以通过使用 << 将数据发送(或转移)至std::cout,并且可以通过使用get() 函数从std::cin中抽取单个字符。

循环(重复执行一些代码若干次,通常伴随有某个变量值的变化)和决策(decision-making)是两个最基础的编程机制。在前面的章节中已经用过它们,本章将对其作更细致的考察,并看一看它们的替代品。我还将向你介绍C++用于处理(文本)字符串和处理连续的相同类型对象数组的首选机制。这些机制不同于C中使用的机制,而且也或多或少地不同于许多其他流行语言所使用的机制。

一些有着纯函数式语言编程经历的读者也许对C++的循环机制感到陌生。他们可能习惯于使用递归处理许多要求重复的地方。那些来自其他过程式语言(如Visual BasicPascal)的人,需要用心注意C++为重复和决策使用的语法。虽然该语法可能与他们过去所使用的类似,但在细节上存在很大的差异。

3.1   一些库类型

3.1.1   std::string—一个库类型

除了上一章中考察的基础类型之外,C++还通过标准库提供了大量的类型。我们把这些类型称作用户自定义类型(user-defined types,最终我将向你展示如何设计并实现你自己的类型。使用基础类型和使用标准库类型(用户自定义类型的主要类别之一)之间的重要区别在于,后者要求我们在源代码中使用它之前,必须先包含适当的头(header)。

std::string是这些类型之一,是C++用以处理字符串(由字符组成的连续序列,用于表示某些文本)的方式。库(我将用来表示“C++标准库)为std::string提供了大量的功能。最重要的方面之一是,std::string类型的对象可以调整其使用的存储空间的大小,以满足当前所存储的char序列的文本的要求。存储空间的增长(或缩减)可以适应文本字符串的大小。

语言注解:和C使用的char数组相比,使用std::string的代码更为安全,因为它不易导致缓冲区溢出漏洞。这一特性将程序员从原始的C机制所要求的繁重工作中解放出来。对于来自其他编程背景的人来说,也许还存在别的惊奇。例如,和某些其他语言中建立的字符串机制不同,C++字符串是可变的,也就是说,可以改变其内容(除非将其声明为const)。

眼下我们暂且将自己限制于std::string对象的四个属性:

1. 它们可以通过形形色色的方式创建,其中之一是根据一个字符串字面量来创建。因此可以编写

用值“Hello World”为变量greeting创建一个std::string对象。

2. 可以将一个字符串赋给另一个字符串:

这导致message现在含有其自己的“Hello World”副本。

3. 可以从输入流(例如std::cin)中提取字符串,并存储于一个由适当的变量所指定的对象中:

这将导致绑定到message的对象包含有从控制台提取的下一个字符序列,以第一个非空白字符开始,并在下一个空白字符的实例之前立即停止。它将结束空白字符(记住,包括换行字符)留在std::cin的输入缓存中。稍后我们将看看如何提取含有空白字符的文本。

4. 可以将字符串发送到一个输出流,例如std::cout

这将导致“Hello World”被显示在控制台上(然后输出转移到新的一行)。

3.1.2   std::vector<>—半类型

C++拥有一套用于从现有类型创建新类型的有趣且强悍的机制:类模板(class templates)。编写有用的模板需要大量的经验、知识和技巧。使用由专家设计的类模板,可以让我们的生活倍加轻松,无忧无虑。std::vector<>是一个类模板例子。它提供了一种创建对象的序列(或数组)的方式。std::vector<>对象的重要特性之一是它能自我调整以适应当前所包含对象的数目。它还提供了其他有用的功能。任何时候,如果用户判定存在某些其他东西可能会普遍地有用,他们可以使用C++提供的机制来扩展那些功能。

我将std::vector称为半类型(half type),是因为在成为一个完整的C++类型之前,它还需要某些其他东西。为了从模板创建(实例化)类型,我们必须提供其他信息,就std::vector<>来说,我们要提供它将要包含的对象的类型。

  

一个选择得当的程序比很多页的解释效果更好,这里为你准备了一个小程序,输入并试一试:

我打算将此程序的代码详解推迟到我讨论完C++中的循环和决策问题之后。你也许觉得这个程序还有改进的余地,比方说,程序假定了输入的正确性。请警惕这类假定,并养成处理它们的习惯。我故意在代码中留下这些问题,因为我希望你检查代码,而不是仅仅听从我的话。以上代码连同早先的程序一起,为下文提供了一些讨论背景。

3.2   决策

C++为做出决策(making decisions)提供了三种主要机制:if-elseswitch以及一个通常被称为条件操作符或三元操作符的特殊操作符(因为它是C++中唯一接受三个操作数的操作符)。

3.2.1   if-else语句

不仅所有主要的计算机语言都拥有这样的结构,而且几乎普遍地使用相同的单词。然而,请仔细阅读,因为C++和你当前使用的语言之间可能存在差异。

语言注解:Python程序员尤其需要小心,因为C++不使用缩排作为语法元素。那些习惯了Pascal等语言的人需要注意,C++限制ifelse只能控制单条语句,当然,这个语句通常是一个复合语句。复合语句是通过把一条或多条简单语句,放到由一对匹配的花括号定义的语句块内创建而成的。

C++if-else语句的形式是:

布尔表达式(Boolean-expression)可以是任何东西,只要其求得的值可隐式地转换为一个bool值即可,包括所有基础类型和所有指针类型。任何基础类型的零值都被视为false,所有其他值均被视为true。对于指针来说,除了一个称为null指针(不指向任何东西的指针)的特殊值被视为false外,其余所有值都被视为true(当我们探讨指针的细节时再详加叙述)。

动作(action)必须为单个语句。然而,在C++中,任何包围在一对花括号内的语句组都算单个复合语句。简单语句以一个;(分号)结束,而复合语句则以结束花括号结束。当心,不要在复合语句(有时称为语句块)的结束花括号后加上分号,因为那将构成一条空的简单语句,它的存在也许会有意义。举个例子,

在外层由两条语句组成:一条复合语句,其本身由三条语句构成,后面跟着一条空的简单语句。

if-elseelse子句是可选的(迄今为止我们尚未在任何程序中使用它)。不过,它必须是受if控制的语句的下一句。比方说,

很好,就像

在上面的代码中,我将受if控制的单个语句写成一个复合语句。下面的if-else语句同样OK

然而,下面这个就有问题了(看看你能否发现关键的区别):

在这种情况下,编译器也许能够诊断出问题(在复合语句结束处有个多余的分号),但若情况更复杂,if里面又嵌套有if,编译器可能就做不到了。它将会误解你的意图,不会认为else部分与紧靠前面的if有关系。

  

编写一个简单的程序,请求用户输入一个整数值,如果所给的值是0就输出简单的消息“zero”,否则输出“not zero”

3.2.2   switch语句

if-else语句用于进行存在两种可能性的决策(two-way decisions),但有时我们希望进行多种可能性的决策(multi-way decision)。我的意思是有时在逻辑上存在不止两种选择。C++为这种情况提供了switch语句。以下是switch语句的形式:

在使用C++switch语句时,由许多重要的细节需要注意。

控制表达式求得的必须是某种整型值,或者是可隐式转换为整型的值。所有基础类型都符合这个要求。然而指针不符合此要求,因为不存在从指针类型到整型的自动转换。

case值必须是编译器所知道的固定值。通常来说,这意味着它们应该是某一种基础整型的字面量。稍后我们会发现还存在一些别的选项。

最后的default子句(实际上,如果想标新立异,你可以把它放在选择列表中的任何位置)是可选的,如果缺少它,编译器将你的代码视为默认动作啥也不做。

程序将从相应的case开始执行代码并继续,直到它遇见return语句、break语句,或者switch语句的结束花括号为止。明白这一点很重要,因为具有类似结构的其他语言将下一个case视为当前这个case的结束标志。

语言注解:Pascal是一个为多可能性选择结构使用了不同设计语言的例子。C++特性(跟C家族的其他语言共享,如Java)允许一个case一路执行到底,也就是执行随后的一些case代码,直到某样东西显式地结束连续的执行动作为止。这是导致程序出现bug的原因之一,当使用switch语句的时候你需要小心谨慎,避免简单地将其视作你已经在其他语言中用过的机制在C++中的等价物。

这里有一个简短的代码片断,示范了switch语句的用法:

  

编写一个简短的程序,使用以上代码片断,并检查遗漏一个break语句的后果。别忘了向你的程序中添加输入验证。

3.2.3   switch

下面是使用了switch语句的另外一个代码片断:

注意C++case机制是如何允许一块代码处理数种情况(cases)的。某些语言以不同的方式处理这种情况,C++则使用了C机制(以便更多的C代码可以作为C++来编译)。

     

利用以上代码编写一个程序。然后改进它,以处理大写字母。我们需要做更多的工作,以便从其他可能的输入中挑选出辅音。如果考虑重音字母的情况,那么需要做更多的工作。如果你想进一步体验,<cctype>头提供了大量的来自C标准库的函数,允许你对char进行分类。这些函数包括std::isdigit()std::ispunct()std::isalpha()。如果字符分别表示数字、标点符号或字母,则这些函数返回true。欲知更多的细节,以及依赖于现场(locale)的替代品的细节(考虑到各国的字符集),请查阅一份优秀的库参考,例如Nicolai Josuttis的《The C++ Standard Library[Josuttis 1999]

3.2.4   条件操作符

C++提供了一种操作符,允许你在两个候选表达式中选择一个来求值,具体选择哪一个则依赖于控制表达式的布尔值。因为它是操作符,所以使用时将会产生一个结果值。正是此值的存在,得以将条件操作符与if-else结构区分开来。下面是条件操作符的形式:

Boolean-control-expression ? result-expression-one : result-expression-two

条件操作符是多种由两个彼此分开的符号构成的C++操作符之一。在这种情况下,两个符号分别是? :。首先求得控制表达式的值,以决定应该对两个结果表达式中的哪一个进行求值。如果控制表达式值为true,则整个表达式的值就是第一个结果表达式的求值结果。如果控制表达式的值为false,则整个表达式的值就是第二个结果表达式的值。如果求值存在副作用,则只有对控制表达式进行求值的副作用,以及对被选中的结果表达式进行求值的副作用,才会发生。

语言注解:C程序员应当特别小心,因为C++扩展了这个操作符的潜在用途,所以,例如:

可以编译并工作(它将ij中较小的那个设置为0)。更有争议的是,以下代码在C++中可以编译:

换句话说,两个备选项都提供相同类型(在本例中大概是int)的对象,位于圆括号中的条件表达式可以出现在赋值的左侧。对这个设施的使用通常是一个糟糕的主意。

这里有一小段代码,示范了条件操作符的一个简单用途(假设count表示橘子的个数):

它为处理单个对象提供了一个恰当的版本,甚至还考虑了英文将零个对象视为复数的怪癖(注意操作符优先规则使得count == 1count != 1周围的圆括号成为多余。我之所以使用圆括号,纯粹是为了便于你阅读代码)。

  

回到numbers程序(第42页),并且:

1. 将通过return 0包含了一个出口的if语句改为if-else语句,以处理两种备选情况。

2. 修改输出语句,以处理用户在以-999结束程序之前只输入了一个值的情况。

3.3   循环

C++拥有三种主要的结构来处理循环。它还有一种方式来滚动循环,然而,优秀的程序员一般都避开那个选项。

语言注解:某些语言,特别是函数式语言,使用递归作为其主要的重复工具。在C++中也可以使用这个机制,但结果代码通常不如使用C++本身的循环机制所编写的代码好。当把递归作为循环机制使用时,C++编译器往往不能生成优秀的目标代码。将递归用于循环还会让其他C++程序员难以读懂你的代码,因为你的代码编写方式对他们来说可能很陌生。

3.3.1   do-while循环

我们已经以一种高度特殊化的形式使用过do-while循环,可以重复执行某些动作,直到遇到一个导致跳出循环的内部条件为止。do-while循环的一般形式是:

用伪代码表示,do-while循环具有这样的效果:

它总是至少执行一次动作(action)部分。然后程序检查是否应该回去重复动作部分。程序会保持重复动作部分,直到发生下面两件事情之一为止:while子句中的布尔表达式的值为false,或者内部动作之一强行跳出。第一个选项很常见,尽管迄今为止书中我们尚未使用这种形式。例如,以下代码片断显示数字09以及它们的平方:

  

编写一个小程序,加入以上代码片断。编译、连接并执行,检查输出是否如预期的一样。现在将++i改为i++,编译并执行结果。不同的结果令你吃惊吗?你理解这个区别吗?我们有时需要注意究竟该使用前自增还是后自增。

3.3.2   while循环

while循环在开始处执行条件测试,它大概比do-while还要常用。主要的区别在于do-while循环总是至少执行一次,而while循环立即进行测试并且只有测试求得值为true时,才会执行动作(action)部分。在C++中,while循环的形式是:

用伪代码表示,while循环有这样的效果(可拿它与do-while循环的伪代码对比):

目前为止我可以在本书中每一个使用了do-while(true)循环的地方直截了当地使用while(true)循环。事实上,或许对依赖内部跳出的循环使用这种方式更好,因为它警告代码阅读者留意一个内部的break语句或return语句。我选择别的方式的原因,部分因为是那么做使我得以在这里拿while循环举例子。两种选择之间没有太多的区别,但把稍微好一点的放在第二位给出,也许有助于你记住C++常常给你选择的机会。

我们可以将上文的代码片断写成:

然而,结果不完全一样。

抵制诱惑,切勿这么写:

如果你编写的代码,在同一条语句中既递增了一个变量,又第二次使用那个变量,那么就可能会发生潜在的非常危险的事情,注意我说的潜在的一词。这样的语句拥有未定义行为(undefined behavior(我将在第6章中明确地介绍它)。这意味着C++标准没有对这样的代码作任何要求。它可以做你所期望的事情,它也可以做某些不同的事情,甚至是一些灾难性的事情。我曾经写过一个程序,因为未定义行为而改编(reprogrammmed)了一块昂贵的显卡的BIOS。使得未定义行为难以理解的原因是,程序可以做你所期待的事情,但那不是硬性要求。

随着我们的进展,我将指出其他潜在的未定义行为。然而,你应该注意这并不是C++的专利:所有编程语言都有未定义行为的情况,只不过C++在这方面比某些语言更容易出问题。

经验丰富的C++程序员恪守一个简单的方针:不在带有其他变量的表达式中使用自增和自减操作符。这仅仅是一个方针,因此在有的地方老练的程序员可能会忽视它。然而,你要有一个充分的理由才能这么做。绝对的准则是:永远不要在同一个完整的表达式中使用一个正在增加或减少的变量的第二个实例。目前,你可以将完整的表达式想像为诸如决策或循环中的控制表达式之类的东西,或者是任何以分号结尾的表达式。

  

修改之前的程序,用while循环代替do-while循环。检验结果并决定如何修改程序,以便你可以获得和从前一样的结果。

3.3.3   for循环

C++中最常用的第三个循环结构是for循环。一旦你找到了感觉,便可轻松驾驭它,但一开始可能会有点生疏。for循环的形式是:

用伪代码表示,for循环具有这样的效果:

让我来分步讲解这些部分。

初始化

初始化部分只执行一次。它陈述了第一次进入循环前必须要完成的事情。它可以为空(即什么也不做),可以在循环开始前为许多变量设定初始值,还可以用于定义某个类型的一个或多个变量。C++中最常见的是最后一项,定义一个将要控制该循环的变量。

控制表达式

控制表达式总是在动作语句的每次执行之前被执行,以决定是否应该再执行一次动作语句(如果是第一次的话,则决定是否应该执行它一次)。一旦此表达式求得值为false,循环就立即终止。这个部分可以为空,但如果是这样,循环将不得不使用一个内部跳出——正如我们在dowhile(true)循环中所做的那样。换句话说,省去控制表达式等价于在那里写上true

终止

终止部分是可选的动作,它的执行位于每一次通过(pass through)循环的末尾,且在对下一次控制表达式的测试之前。注意,它发生在每一次通过的结束处,所以第一次通过之前它不会被执行。

递归

我在本章中数次提到递归。某些程序员可能还不熟悉这个概念,其他人则可能迷惑不解:它怎么会是循环的替补方案呢。这里有一个简短的程序,举例说明了递归及其循环用法。

这段代码工作起来令人非常满意,并且某些编译器也许能高效地编译它,但因为在C++中一般不这样使用递归,所以我们不指望C++编译器能够高效地处理这段代码。就编译器而言,它需要10print_square的参数的实例,而我们正常的C++版的程序只使用单一控制变量。从函数式编程的观点来看,递归形式更为清晰,因为它不使用任何变量,只有纯粹的值。然而,C++编译器并没有专为处理纯值和递归而进行调节。

如果你仍然感到迷惑,术语递归指的是函数调用它自身的情况,如print_square在上述例子中所做的一样。就像循环一样,递归函数需要某种方式停下来。与循环不同的是,当失去控制时,它可以迅速耗尽你的计算机上所有可利用的资源。

动作

动作或受控制的语句,可以是单个语句,但更常见的是复合语句(包含在花括号内的一个语句块)。它提供每一次通过循环期间将会执行的动作。这个语句可以是一个null语句(空的),在这种情况下,执行的唯一动作将由控制表达式和终止表达式提供。例如,下列代码是有效的(尽管一般不提倡其为优良的代码):

整合

这里是早先那个代码片断的等价物,但使用for循环代替了while循环:

在初始化子句中定义for循环的控制变量是C++中的标准惯用法。像本例中这样用于计数的控制表达式通常使用!=(不等于)操作符来表达。如果你来自一门例如C的语言,以前使用不同的惯用法(如小于操作符),则可能会对此感到奇怪,但是在C++中不等于的比较工作甚至可以在控制变量为用户自定义类型(不支持严格的排序)的情况下工作(最终我们将广泛使用可以展现此性质的类型迭代器)。这个C++的惯用法在使用整型变量时也能很好地工作,正如在这里一样。

当我们处理独立式的自增操作符时,在C++中通常也使用前自增。对于整型对象,使用前或后自增尚无多大区别,但稍后我们将使用其他类型的迭代器(用于控制重复和标识集合成员的对象),对于那些类型的对象,前自增效率更高。因此,我们采用一种在所有情况下都工作良好的风格。我们把这种自动的选择称作惯用法(idiom。知道了一门语言的惯用法,无论是编写你自己的代码,抑或阅读其他程序员的代码,都会更轻松一些。

语言注解:惯用法的问题之一是,新手常常试图重复以前所用的语言的惯用法。有时这样行得通,有时看上去可以工作,其实隐藏着陷阱,还有时在C++中运作不良(例如使用递归进行循环)或根本就无法工作(例如使用Python形式的for循环,这是个强大的东西,但不是C++当前所能支持的)。多数C惯用法在C++中可以工作,但它们需要在C++环境中加以复查,因为它们并非完全是C++中的首选用法。

3.3.4   breakcontinuegoto

在一个构造中,存在数种偏离正常代码流程的方式。我将return的细节留到第5章,当讨论函数时再去研究。你可能也注意到了从正常流程抛出一个异常退出。我也将那个细节留给稍后的某一章讨论。

我已经广泛使用了break,但没有深入介绍太多关于它所做事情的细节。break致使从所有上述的循环结构(forwhiledo-while)中立即跳出,以及强行从一个swtich语句(和if语句,但通常你应当避免在那儿使用它)中退出。紧跟在所讨论的构造之后的那条语句,将是在break语句之后执行的一句。

良好的经验是,避免使用break来退出循环,尽管我们已经见过一个特例——使用它来退出一个无限循环。我们常常可以修改源代码,使之不再需要break。结果代码常常更为简单,即使乍看上去不是那么明显。如果你发现自己为避免提早退出循环而编写了更复杂的代码,那么你大概走错了方向。往往当程序员努力遵守禁用break的编码方针时,他们依然在使用一种会卷入提早退出的思想模式中。尝试设计你的代码,以使得提早从循环退出不再必不可少。等你做到为了所有正当的理由而减少break的使用这一点,你的代码将会变得更简练、更优雅。

有时我们觉得需要放弃循环的当前这次的通过(pass),并立即转到下一次。C++为此提供了一个特殊的机制:关键字continue。下面是一小段示范了其用法的代码(这只是一个用法示范,并非优良的编码实践):

在本例中,我们本可以毫不费力地消除continue而替换成下面的代码:

然而,我可以辩称第一种形式更为清晰,因为它使得特例情形一目了然,而第二种形式却掩盖了它。在这个简单的例子中,我认为没有太多的东西值得深究,是否使用continue应当取决于基于哪一种方式可以让代码变得更清晰。

我要好好谈谈goto。在超过十年的C++编程中,我从未发现它有用处。我没有多强的哲学理由去反对goto,我只是感到,任何使用goto的代码都可以用一种没有goto但同时又更简单的方式重新编写。它之所以继续存在于C++中,不过是从过去普遍使用长函数的时期沿袭下来的。现如今,我们趋向于编写许多简短的函数,并依赖优秀的编译器减少实际函数调用的次数。一般说来,goto在现代C++编程中没有用。你有必要知道它的存在,只是因为你可能在他人的代码中遇到它,但你自己不需要使用它。

语言注解:对于习惯了BASIC这样的语言的程序员来说,问题之一是他们习惯于使用goto。他们习惯的某些惯用法要使用goto,所以他们就将其用法引入C++中。使用goto最大的问题是经常导致不够清晰的代码。这样的代码难以维护且容易滋生bug

理论上,你可以使用gotoif(用法参见前面的伪代码)来滚动(roll)循环。实践中,没有C++程序员会那么做。

代码详解

Number-Sorting程序

这里再次给出那个程序的重要代码,以免你来回折腾于源代码(第41页)和我的讲解之间。我已经突出显示了将要解释的代码行。

9行将numbers定义为一个对象的名字,该对象封装了一个ints的连续序列。std::vector的使用指定了它将是一个连续序列。<int>则指定了它是一个ints的序列。由于我在定义中没有提供额外的信息,因此numbers将作为一个空序列亮相。C++std::vector序列具有这样的增长策略:允许视需求以一种提供最优综合性能的方式扩展。它们是C++程序员用来封装相同类型对象的首选容器。

12行在紧靠第一次使用变量next处之前定义了它。严格说来,由于我们是在代码块内定义next,所以代码会在每一次语句块重复执行时再次创建它。然而,任何高品质的编译器都会避免添加额外的代码。我没有初始化next,因为我要从下一行的std::cin中获取一个值。这是可以合理地省略基础类型变量初始化的少数情形之一。尽管如此,许多程序员坚持认为即使在这种情况下也要初始化,因为他们担心以后有人可能会将变量的定义点与它首次被写入的点分离开,从而敞开大门让其他人添加的代码在此变量的值被写入之前就使用它。

在我做了假定说明的上下文中(译注:前面有一行输入提示“Next whole number

(-999 to stop):”),如果你仔细考虑第13行,便会注意到代码假定用户总是输入一个有效的整数,因为甚至连输入一个带小数点的数字这样微不足道的错误,也会把程序送入无限循环。至今我们仍没有准备好如何处理这个问题,我们所做的只是向std::cerr发送一条错误消息,然后通过抛出一个异常来终止程序。

  

如果你还没有这么做,请着手改进程序,使其在使用std::cin后立即进行测试,如果失败,就抛出一个异常。

代码详解

14行测试输入是否结束。如果用户输入了指定的值,程序便越过后面的代码,从第17行开始执行。否则,它就执行第15行。这一行使用了由std::vector提供的一种机制,用于在它所持有序列的末尾添加一个新对象。如果不存在足够的空间存放这个新对象,push_back()将触发可提供更大空间的内部行为,如果有必要,还会将现有值复制到那个空间内,以使序列中的对象保持连续。换句话说,std::vector自动维护它的内容,使其保持为连续的序列。

1720行处理了无数字输入的可能性,使用了由std::vector提供的报告序列中当前对象个数的机制。numbers.size()用于求取numbers序列中对象的个数。第19return 0用以提早离开main(也就是程序)。我们可以把剩余的正常处理代码放入一个由else控制的语句块,以避免对提前退出的需要。

  

修改程序以消除return 0,并且使余下的正常处理成为一个受else控制的语句块。在你做到了这一点后,考察两个版本,决定哪一个对你而言更清楚。我倾向于先前的return,但某些编码方针禁止提前使用return。尝试提出一个替代设计,避免使正常处理成为if-elseelse部分。

代码详解

21行使用了现代C++较强大的特性之一:将算法应用于序列。#include <algorithm>提供了对库中有点儿命名不当的算法部分的访问。std::sort是库提供的大约80种算法之一,其默认版本需要两个实参。第一个实参指定序列从哪儿开始,第二个实参指定如何确定序列已经结束。默认情况下,std::sort()通过使用针对存储在序列中的对象的<(小于)操作符,将序列元素按自然升序进行排列。

numbers.begin()使用std::vector的机制,提供标识了序列起点的值(称为迭代器(iterator))。numbers.end()提供表示序列结束的迭代器(我们会另找时间讨论迭代器的细节,眼下只要知道它们通常标识对象的位置就可以了)。注意,end()标识的并不是最后一个对象:它是个特殊的迭代器,用于决定序列有没有结束(换言之,与大多数迭代器不同,它并不标识某个对象的位置)。乍看上去这种表示序列结束的方式看起来有点奇怪,但它具有许多优点,随着你渐渐习惯它,你可能会感到奇怪:你竟然曾经期望序列的结尾是最后一个元素,而不是最后一个元素之后的一位。

现在如果再看一眼第21行,你就会发现它导致依照升序重新排列了名为numbers的序列。

2426行将存储在numbers中的序列作为以逗号分隔的列表输出。for循环的控制表达式保持运转,直到它数完numbers的所有元素为止。注意,连同许多其他语言一起,C++从零开始计数,所以当计数达到容器中元素的个数(由numbers.size()给出)时,就完成任务了。

25行使用了std::vector允许通过使用下标或索引访问元素的特性。这意味着我们可以像使用数组一样使用std::vector对象,但它比数组多了许多功能。究竟对此感到多么惊讶,取决于你之前的编程经验。

3.4   关于魔数

程序员常常将数值字面量称作魔数(magic numbers。有时从使用数值字面量(literal number)的上下文来看,它的意义是显而易见的,例如以下代码片断中用于将小时转换为分钟的系数:

我看不出在此上下文中使用字面量60有何不妥。但在有些情况下,即便上下文使得用途清晰,我可能依然倾向于使用具名值。例如:

你可能认出这个字面量是数学常量p的一个近似值,但你能确定我输入了正确的数字吗(其实我没有)?另外,如果p出现在程序中,它很可能会不止出现一次。

可读性和正确性只是把字面量换成名字的两个原因。在C++中,我们通过为字面量定义一个以const限定的名字来实现这一点。以上代码现在变为:

瞬间,任何人,即使具备的领域常识再少,也可以明白最后一行代码。使用具名常量意味着我们只需要仔细地输入一次数值,在那之后,我们就可以编写出易读的代码。万一我输错了值,或希望提供更多的有效数字,只有一处需要改动的地方。

数值本质上反复无常之处,就是魔数造成更多麻烦的地方。在number-sorting程序中,选择-999标记输入的结束,是完全随心所欲的。它之所以意味着输入的终止,是因为我决定让它具有这种含义。一个观看代码的陌生人有理由询问-999有什么特殊的含义。答案无它,只是因为它是一个魔数,其性质恰好是我决定赋予的。我们应当永远用具名值替代这种数值字面量。在C++中,我们通过为一个适当类型的const对象提供一个合适的名字而做到这一点。在number-sorting程序中,我可能会提供:

现在我将第11行替换为:

将第14行替换为:

我希望你觉得结果更清晰且易于维护。你可以通过修改end_input的定义简单地将终止值修改为别的什么。另外,源代码将需要更少的注释,因为具名值传达了你的意图。

从今以后,带着一定程度的怀疑看待字面量,问问你自己在上下文中使用它们是否明智,它们有的味道吗?你将从减少了许多倍的维护时间中,找回为一点点额外的录入而付出的代价。

fgw::playpen对象所使用的颜色的默认调色板中,为颜色编码的位(bits)使用的名字fgw::red1等,就是消除魔数的一个例子。在本例中,当我们离开默认调色板,名字便失去作用(事实上,它们肯定变得有误导性)。具名值并不能解决所有的问题。我们必须谨慎地选择名字。

练习

1. 编写一个程序,询问用户将要输入多少个单词,然后将这些单词收集到一个std::vector<std::string>里面。输入完毕后,按字母顺序排列单词并将它们输出为一列。

2. 编写一个程序,从键盘收集单词直到用户输入“END”为止。然后它应该对单词进行排序并输出为一列。通过使用一个适当命名的常量对象,来设法避免使用魔值“END”

3. 编写一个程序,此程序在Playpen窗口中显示一列20个黑色像素。仔细检查以确保它的一列有20个像素,不是19也不是21。我建议你不要使用默认的Playpen比例,而是把比例设置得大一些。你也许可以想出一种办法能让你计算出所绘制的像素。

4. 编写一个程序,提示用户输入两个整数,然后在Playpen窗口中显示一个十字架,它的高度是第一个数字,宽度是第二个数字。修改这个程序,以便用户可以在除了黑色的其他颜色中选择一个颜色。比方说,你可以问询用户红色的量(07),绿色的量(07)以及蓝色的量(07)。你知道的C++以及默认Playpen调色板的相关知识足以完成这个任务,但如果你还不是一个能熟练地以某种其他语言编程的程序员,这对你来说这可能有些难度。

5. 编写一个程序,用256种颜色的瓷砖覆盖Playpen256种颜色中每一种颜色都在默认调色板中。如果你使用的比例是3216行每行16个逻辑像素将精确地覆盖Playpen。我给你的唯一的提示是考虑使用嵌套的for循环。你也许不得不多实验几遍。(别忘了你可以改变原点,如果这对你有帮助的话)

6. 编写一个程序,画出Playpen的对角线。

7. 值集合的中位数是指当它们按照数字顺序排列好之后处于中间的那个数。如果因为在集合中有偶数个值而导致中间有两个值的话,中位数就是中间那一对值的算术平均数。编写一个程序收集输入的值,然后输出它们的中位数。

8. 考试成绩以这样的规律进行分级:前20%的考生得“A”,接下来的20%“B”等,最后20%的人得“E”。编写一个程序,收集20位考生的分数,然后输出等级分界。注意,你无须知道可能的最高分。为得到完美的解决方案,你需要解决多位考生处于一个等级分界上的问题。

延伸练习

这些练习为有经验的程序员提供了更大的挑战。如果你感到以上练习很费力,那么暂时丢下这些练习。稍后你可以随时再回过头来做它们。像往常一样,答案被限制于仅仅使用迄今为止我介绍过的C++知识。

9. 三角形数(triangular numbers)是那些由前n个整数的和产生的数。前四个是:1

3= 1 + 2),6= 1 + 2 + 3)和10= 1 + 2 + 3 + 4)。编写一个程序,在Playpen中将三角形数的像素显示为三角形。它应该以你所选定的颜色显示一个单独的像素开始,一直等到用户按下Enter键。在那时,它应该添加一行两个像素,使现有的像素在新行的正上方。这个过程重复进行,直到屏幕显示了第十个三角形数为止。

10. 编写一个程序,将从1100的整数分成两组,使两组数字的平方根之和相等,保留六位有效数字。小心:你正在处理浮点数,因此需要谨慎处理相等的概念。你可以使用库函数std::sqrt。你需要包含<cmath>头。

11. 编写一个程序,接受若干单词作为输入,并输出它们的平均(算术平均)长度以及元音字母对辅音字母的总体比率。你可以利用这一事实:std::string类型的对象与std::vector类型的对象一样,也可以通过使用size()来报告它们的大小(字符的个数)。

12. 编写一个程序,此程序接受成对的单词,然后依据它们是否由相同的字母组成,选择输出“anagram”“not anagram”。可以使用和存储std::vector对象完全相同的方式来存储std::string对象。你还可以使用==比较两个字符串是否相等。那应该会有很大的帮助。

13. 编写一个程序,它接受值位于-100100范围内的整数,并以文本输出它们的名字。因此,如果输入27,输出应该是“twenty-seven”。如果输入-39,输出应该是“minus thirty-nine”“negative thirty-nine”。如果你学有余力,可尝试将程序转换为处理某种其他自然语言,例如法语。甚至尝试更困难的:编写一个程序,让用户输入文本,而输出是数字。

3.5   参考

3.5.1   决策

C++为进行决策提供了三种主要机制:

1. if-else,适合于本质上有两种可能性的决策。else是可选项,但如果它出现了,则必须是受if控制的语句的下一句。受控语句可以是单条语句,也可以是一个复合语句(花括号内的一个语句块)。

2. switch,允许受一个整数值控制的有多种可能性的选择。选择由关键字case标识,后面跟着一个常量整数值和一个冒号。一个选择分支的执行由下一个breakreturn语句或switch语句的结束花括号终止。switch语句可以包括一个由关键字default标识的包罗万象的选项。

3. 条件操作符,依赖一个控制表达式,以选择对两个表达式中的哪一个进行求值。

形式为:control-expression ? expression-for-true : expression-for-false

机制1和机制3之间的区别在于,前者拥有控制语句,而后者则拥有控制表达式。表达式求解之后提供的值可能会被用作同一语句中更大的表达式的组成部分。而控制语句则是完整的,它没有值。下列代码片断(打算作为函数的出口的一部分使用)示范了二者在使用中的差异:

第一个例子使用由if-else结构选择的两个return语句,第二个例子返回一个由条件表达式选择的值。

3.5.2   循环、重复和迭代

这是三种具有非常相似含义的术语。C++为循环提供了三种主要的机制:

1. while(control-expression) action。只要控制表达式求得的值为true(或非零),就重复执行动作(action)语句(通常是包围在括号中的复合语句)。在每次重复(包括第一次)之前求解控制表达式的值。如果它的值为false(或零),则不执行动作语句,处理工作从下一条语句继续。注意,这意味着动作(action)部分的语句有时不会被执行,甚至一次也不执行。

2. do{action} while(control-expression); 在动作语句块的每次执行后,执行测试以决定是否再次执行循环。当动作必须总是要执行至少一次的时候,使用它。在其他方面,它与while循环类似。

3. for(initialization; test; termination) action。这种形式的循环大致等价于这么写:

从来都不是非得使用for语句不可(我们总是可以用while语句替代for语句)。然而,所使用的两种结构允许提供能帮助其他程序员理解我们意图的惯用法。通常当重复的次数由某个值决定时(例如当我们想要遍历许多种情况的时候),使用for语句是合乎习惯的。当我们期望重复的终点由某个其他条件(例如到达正在处理的文件的末尾)产生时,使用while循环。

使用do-while最常见的情况是,必须以某个选项获取并处理某种形式的数据,以根据当前循环的结果来获得更多的数据。

C++提供多种方式结束一个循环过程。关键字continue允许程序中止当前重复并直接转到下一次重复的测试。对for循环来说,continue立即执行终止(termination)语句,接着测试是否进行另一次重复。

break语句用于完全退出循环。换句话说,break强制执行紧接在当前循环结构后面的语句。

存在其他语句例如return,可导致循环提前终止,那只是它们被设计的行为的结果,而不是被设计用于离开循环的行为的结果,或者简单地说,它们并非专为循环场合设计。

3.5.3   C++标准库类型

这个类型提供文本类型行为。它还提供针对char值的序列的适当行为。它包含我们所期待的针对文本的功能,例如支持添加文本和比较文本的相等性。作为序列类型,它提供了C++序列的功能。序列对象知道自己的长度或大小,知道自己从哪里开始、到哪里结束,它们通常可以被索引和排序。我们将在后面的章节中学习std::string的更多功能。

std::vector是一个被C++称为类模板(class template)的实例。它提供了我们期待的大小可动态调整的数组所具有的功能。由于序列的功能很大程度上独立于序列中对象的类型,因此我们需要一种方式来指定那个功能,以使其独立于存储在序列中对象的类型之外。类模板的主要用途之一是提供这样的泛型功能。我们可以在使用时插入一系列广泛的类型。只有较少的几种类型不能与std::vector友好相处。一个类型可以实例化std::vector的基本标准是,该类型的对象必须可以被复制和赋值。换句话说,必须能够按值传递该类型的对象,且必须能够将一个该类型的值赋给一个相同类型的对象。在你学习C++现阶段,这些约束可能没有太多意义,它们的意义将会在你学完本书的教程部分时显现出来。

这是简短却重要的一章,涵盖了一组对于正确地使用C++编写健壮且可靠的程序起重要作用的相关主题。本章没有参考部分,因为整章同时扮演了参考和教程的角色。然而,本章以一套方针(guidelines)结束,帮助你避开序列点(sequence points)和求值顺序(order of evaluation)可能造成的大多数陷阱。这些方针对于实践目的而言足够了,如果你希望透彻地理解它们,或希望有时忽略它们,那么本章的其余部分将为你提供必备的基础知识。那些编写C++代码供他人使用的的程序员需要熟悉这些内容。本章没有习题,因为这些主题本身不适于练习。实验项目只是为了帮助你检查那些可以检查的方面,而非不可预知的方面。

6.1 行为的类型

C++将源代码的预期行为划分成四类:完全定义的(fully defined)、由实现定义的(implementation-defined)、未定义的(undefined)以及未指定的(unspecified)。

6.1.1 完全定义的行为

这是由C++标准完全指定的行为;如果一款编译器不能编译具有完整定义的行为的源代码,使其依照C++标准的规定做事情,那么该编译器必然含有臭虫(buggy)。比方说,C++标准要求以下源代码可以通过编译,并得到一个输出为2的程序:

注意,我们需要#include <istream>,因为标准并没有指定<iostream>要为输入和输出对象提供完全的行为(尽管许多专家认为标准应当这么做)。标准仅仅要求<iostream>声明std命名空间中的名字coutcerrclogcinwcoutwcerrwclogwcin。大多数实现都在<iostream>中包含了<istream><ostream>。尽管标准允许如此,但未作要求。

我之所以挑选上面的例子,是因为如果用C++编译器来编译,则等价的C程序

拥有完全同样的要求,但如果用一个旧款C编译器来编译,要求则有所不同。第一个问题是,C++指定了main()函数缺少返回语句时的行为(相当于在该函数末尾加上return 0;)。但C没有指定,因此在旧的Creturn语句的缺失被视为一个错误。C的最新版本(常称为C99,以区别于仍广为使用的、于1989/90年标准化、于1994年修订的版本)具有与C++相同的行为:离开main的末尾等价于return 0;

第二个问题是,在C中字符字面量的类型是int而不是charint的大小是由实现定义的,在多数系统上是24个字节。然而,以上代码的以下变体,不论使用CC++编译器,都必须输出2

这是因为CC++标准都将sizeof char定义为1char占用两种语言中可直接访问的最小内存。

6.1.2 由实现定义的行为

这是一种可以随着不同的实现而变化的行为,但实现者需要在文档中说明细节。前面见过这方面的一个例子:int对象使用的内存量与char对象使用的内存量的比率是由实现定义的。换句话说,

所产生的结果是由实现定义的。

由实现定义的行为的另一个例子是,char的行为是和带符号的小整数(即signed char)一样,还是和不带符号的小整数(unsigned char)一样。许多编译器允许程序员决定char具有哪种行为。

实验

用手头的编译器和IDE尝试如下代码,探索你正在使用的配置提供哪种形式的char

我没有为异常处理而费心,因为这是个极小的一次性程序。究竟如何以-1初始化c,有赖于char被视为带符号的还是无符号的整型。如果char是带符号的类型,那么-1就被当作-1 存储(系统究竟以何种方式表示负一,依赖于带符号整数是用补码、反码还是原码表示)。如果char被视为无符号的类型,则-1将位于char的最大有效值附近。注意,对于char的任何无符号表示法而言,最大的可能值毫无疑问大于200

6.1.3 未指定的行为

它涵盖了这样一种情况:编译器可以自由选择任意一种合理的动作,实现工具也不需要在文档中说明编译器将选择哪一种动作。这很常见,因为所有可能的选择通常将拥有完全相同的结果。然而,这常常可能会导致构建出这样的代码:其行为随着编译器做出不同的选择而发生变化。

未指定的行为的一个例子是C++(与像Java这样的某些别的语言不同)不指定子表达式(subexpressions)(更大的表达式的组成部分)的求值顺序。考察以下源代码:

C++标准要求按某种顺序调用add1_to_global()add2_to_global()(即不能并行地将两个调用发送给两个不同的CPU处理),但它没有指定先求解哪个表达式。在这种情况下,结果将依赖于编译器做出的选择。如果它按从左到右的顺序调用两个函数,则结果为4,但如果它以相反的顺序调用它们,则结果为5。无论编译器做何选择,最终存储在global中的值都将是3

因为C++标准未指定子表达式(本例中是两个函数调用)的求值顺序,所以45均是上述程序的正确输出。实际上,你正在使用的编译器(假定你我所用为同一版本)将生成一个输出为5的程序。这表明,编译器创建的代码先调用add2_to_global()而后调用add1_to_global()。(译注:译者使用CD提供的MDS所得结果为4

如果你还不知道全局变量可能引起严重的问题,那么这个例子应当向你示范了优秀的程序员避开它们的原因之一。

尽管C++在表达式求值期间操作符的应用顺序提供了规则,但它仅为子表达式的求值顺序规定了极少的要求:对子表达式的求解,发生在操作符需要该子表达式的值之前。许多程序员忽视了这条规则的完整含意。比方说,使用上述函数,

也具有未指定的行为。输出可以是“13”“23”,这依赖于按什么顺序调用这两个函数。此外,子表达式(在本例中是函数调用)的求值顺序可能会随着使用地点的不同而发生变化。

警告!

对子表达式的求值顺序做任何假定都是不安全的。运行测试代码也不会告诉你任何更多的信息,因为你知道的只是这些子表达式在该测试代码中的求值顺序而已。如果顺序很重要,你必须设法强制顺序,比方说将求值放到分开的语句中。例如,

必定导致输出“13”,因为不存在重排整个语句的执行顺序的自由。

6.1.4 未定义的行为

这是个潜伏在太多太多的代码中的大问题,而编写这种代码的人却以为知道自己在做什么。任何时候当你在做C++标准没有提供要求的事情时,你就已经身处未定义行为的地带了。我经常看见程序员为自己申辩,理由是他们测试过代码而且程序如预期的样子运行。那正是未定义行为邪恶的方面。它之所以能够隐藏好多年,是因为没有什么东西去激发问题的出现。未定义行为会危害真实系统的典型例子数不胜数。比方说,我曾经改编过一块十分昂贵的显卡上的EPROM,实际上就是用了下面这样的程序:

我剥去了所有其他代码,并以这样的方式给函数命名,是为了说服即使最马虎的读者也永远不要使用它。C程序员可以读懂这段代码做了什么,它在C++中也做了同样的事。简而言之,它分配足够的局部存储区来存储下一个“No”,然后继续试图在相同的地方写“yes”。它为“No”分配的空间存不下“yes”,所以最后一个字符会溢出。在许多系统上,那个溢出可能不会带来实际的伤害。在我当时使用的系统上,它将数据写到函数返回地址的顶部。结果,函数没有返回给调用者,而是返回到别的什么地方。不幸的是,它返回到的地方恰巧就是搞破坏的可执行代码。当然,现代操作系统往往能侦测这样的野蛮行为,并简单地结束程序。但是:

警告!

程序员绝不能依赖于操作系统介入以保护自己的程序和其他程序,从而避免具有未定义行为的代码所导致的后果。包含未定义行为的程序随时都可能导致事故的发生。

6.2 序列点

序列点(sequence points)是C++程序中的稳定点(islands of stability),在那里可以确定某些动作已经完成,且其他动作尚未开始。所有计算机语言都隐式地拥有这样的点,但在C++ 中(与在C中一样)它们由标准显式地提供。良好的编程习惯要求要么严格坚持一套方针,要么透彻理解在序列点之间发生的事情。

大多数程序都由求解表达式构成。表达式的求解会得出一个值。另外,许多表达式还具有副作用。较为显著的副作用包括诸如打开和关闭文件、从文件中提取数据、将数据插入文件中,以及其他形式的输入和输出等。

一个较为阴险的副作用是改变程序自身的状态。我指的是对程序的内存写入某些东西。最明显的例子是使用赋值来存储结果。许多程序员对此感到吃惊:这种存储结果的过程远非好事。那些拥有函数式语言(例如Haskell)编程经验的人甚至可能学过:赋值是一种危险且高度可疑的操作。

这样的C++语句由两个独立的元素构成。首先是求解表达式i = j * k。我们几乎总是丢弃结果,虽然如此,存在一个可以在某些环境中使用的结果。举个例子,

以上代码将返回表达式result = lhs * rhs的值。一般而言,我们通常对如下事实更感兴趣:求解赋值表达式会导致将一个值存储在由赋值操作符之左操作数指定的对象中。函数foo()完成了两件事情。它返回一个值,但也有副作用,即将计算lhs * rhs得到的值存储在由result指定的对象中。注意它与下面这个函数有何不同:

函数bar()没有副作用;它计算出并且返回lhs * rhs的结果。以计算机科学的术语表达,bar()是一个纯函数(pure function),因为对它的调用在外部对象(例如打印机)或程序的内部资源(例如内存)上,都没有永久的作用。

伴随副作用的一个问题是确定它们将在何时发生。在大多数机器上,将结果存入内存是一个相对较慢的过程。另外,一些硬件在内存写入之后、程序再次访问该块内存之前要求一段稳定时间(stabilization time)。C++使用的解决方案(继承自C)指定了称为序列点(sequence point)的东西,如果有必要,程序必须等待内存稳定。这是序列点概念的动机之一。作为程序员,我们更关心它在实践中的意义。

在两个序列点之间,我们随时可以自由地读取任何表示对象的内存,前提是我们没有改写那片内存。然而,如果在序列点之间改写了一片内存,则必须只能改写一次。此外,我们只能将对那片内存的读取,作为决定程序将要向那片内存写入某些东西的过程的一部分。违反这些规则中任意之一,都将导致未定义行为。

大多数程序员对规则的第一部分感到愉快:对于序列点之间的一片内存,只能写入一次。很多人不理解第二条限制。那条限制确保了这一点:如果在序列点之间内存既被读取又被写入,那么读取将在写入开始之前完成。在C++未指定子表达式求值顺序的上下文中,这是确保安全的唯一准则。

现在你应该明白为何知道源代码中的序列点在哪里很重要了。下面是一个完整的清单:

完全表达式(full expression):在完全表达式求值的结尾存在一个序列点。完全表达式的值不被直接作为求解某个其他表达式的一部分使用。举个例子,在上面的函数bar()中,lhs * rhs是一个完全表达式。然而,在函数foo()中,lhs * rhs不是一个完全表达式,因为在计算对result的赋值中使用了它的结果。

注意,一条语句可以包含不止一个完全表达式。例如,语句

包含两个完全表达式:a < bi++

函数调用(function call):两个序列点保护一个函数调用。在所有实参赋值之后立即有一个序列点,从而函数体可以在所有初始化形参的副作用已经完成的假定之上继续执行。第二个序列点位于返回点,它确保任何提供返回值的副作用在调用该函数的代码恢复执行前已经完成。

很少有程序员编写的代码存在返回序列点的问题,不过入口的序列点有时会被误解。比方说,

可能直到你更仔细地检查对bar()的调用之前,这段代码看上去都很健康。为了调用bar(),必须计算两个表达式ii++。它们不是完全表达式,因为结果将被作为实参用于初始化bar()的形参。这意味着在ii++的计算之间不存在序列点。然而,对第一个实参的求解要求读取存储于由i指定的对象中的值,但我们无法确定什么东西将被写入i(考虑求解第二个实参期间递增i 的副作用),换句话说,我们破坏了关于在序列点之间读写同一个对象的规则。这就意味着我们身处未定义行为的境地,任何事情都有可能发生。在实践中,这个问题通常表现为两种求值顺序导致第一个实参具有不同的值。这种行为不应该麻痹你的警惕心,因为这并不是未指定的行为,而是未定义的行为。请弄清楚这两种行为的区别,早晚有一天会派上用场。

逗号操作符(comma operator):在某些上下文中,逗号(,)仅仅是分隔一串项目(items)的标点。在其他上下文中,则是C++序列操作符(sequence operator)。知道它到底在发挥什么作用,很大程度上是经验问题。不幸的是,这对你的程序可能有深远的影响。当逗号是序列操作符时,它会向代码中注入一个序列点,意味着逗号左边的表达式是完全求值的(fully evaluated),在触及右边的表达式之前所有副作用均已完成。

更糟糕的是,如果逗号操作符的至少一个操作数是用户自定义类型,则C++允许程序员重新定义它。在那些环境中,它不再是序列操作符,左右操作数(表达式)可以按任意顺序求解。因此,或许最好假定逗号不是序列操作符,除非你确切地知道它确实是。

条件操作符(conditional operator):在条件操作符的左操作数的求值,和其他两个操作数中被选中的那一个的求值之间,存在一个序列点。因此

就第一条语句来说,这段代码没问题。我无法想像怎么可能写出这样的语句,但其中不存在未定义行为。由于在? 处的序列点的保护,第一次对value的读取不会受到稍后对同一存储区的写入的影响。

|| && 操作符:对于这些操作符的内建版本而言,左操作数(表达式)的求值后面都存在一个序列点。这意味着左操作数是完全求解的,所有结果的副作用在计算右操作数之前已经完成。注意,只在有必要确定其结果的时候才会计算右操作数。这意味着求解右操作数的任何副作用依赖于左操作数的值。

多序列点

一个表达式常常包含多个序列点,程序员必须注意不要假定序列点强制求值的顺序。序列(逗号)操作符、条件操作符,以及|| && 操作符可以强制操作数的求值顺序,但这只是就事论事而已。如果你编写

expr2之前完全求解(包含副作用)expr1”,和expr4之前完全求解expr3”的任何求值序列,都在规则允许之内。比方说,不存在必须在expr4之前求解expr1的要求。

6.3 求值顺序

没有更多好说的。在序列点之间,可以按照与所含操作符的结合性(associativity)和优先级(precedence)一致的任意顺序求解子表达式。最重要的是,圆括号不改变求值顺序,只改变优先级。比方说,操作符的规则要求在如下代码中

除法必须在乘法之前完成,它们俩又必须在给d赋值之前完成。然而,expr1expr2expr3,以及由d指定的对象的地址可以按任意顺序求解。添加圆括号并不能改变后一个规则。因此,企图通过以下方式强制乘法的执行必须在除法之前发生,纯属徒劳之举。

6.4 方针

注意,下列方针为你提供了一套关于求值顺序和序列点的安全编程实践。忽视一个方针也没关系,只要你愿意花时间检查自己确实没有向代码中引入未定义行为。你还应该检查任何未指定的行为不会导致错误的结果。另外,你应该对代码所依赖的任何由实现定义的行为进行说明。比方说,如果你的程序依赖于使用32位表示法的int,你应当清楚地以文档说明。

规则1:必须以完全表达式的方式使用自增、自减操作符。不在一个完全表达式中使用多于一个的赋值或复合赋值操作符。如果遵循这个规则,你将不会违反限制在序列点之间读写同一块存储区的规则。

规则2:避免编写修改全局对象的函数。

这条规则存在例外,比如使用std::cout,它就是个全局对象(在命名空间std中),但即使在这里,你仍需小心。举个例子,考虑:

尝试以上代码。将

换成

不要试图解释结果,因为在前两种情况中,我们得到的都是未指定的行为。对于编译器来说,存在其他可用的求值顺序。在第三种情况中,编译器必须首先调用hello()然后调用world(),但发生了非常离奇的事情。程序首先执行逗号左边的每样东西,然后执行逗号右边的每样东西。后者只是看上去可以工作,实际上不行。原因在于,表达式world() << '/n'得到的值是:world() 返回值左移“'/n'”位后得到的值。

尝试将'/n'改为"/n"。在前两种情况中,程序生成相同的结果,但在第三种情况中你会得到一个编译期错误,因为"/n"是字符串字面量,它不能转换为一个整数值。作为一种替代方式,尝试将world()的返回值改为double。再一次,前两个例子如之前一样编译并执行(尽管那是因为小数指示符被抑制,因为输出的double其实是个整数)。然而,在第三种情况中你会再次得到一个编译期错误,因为左移操作符不能应用于浮点类型。

规则3:不要将同一个对象按引用传递给两个函数,除非存在一个介入的序列点,或者两个函数都通过const引用接受实参。

记住,传递引用(而非const引用)允许函数改变被引用的对象。如果两个函数调用中,至少有一个可以改变对象,那么序列点之间未指定的求值顺序意味着,代码可能存在不只一种结果。举个例子,考虑:

如果程序先调用bar(),则输出为“01”,但如果先调用foo(),则输出为“00”。在函数调用和返回中的序列点,仅仅移除未定义行为的潜在可能性,它们不影响执行输出语句所需的各个表达式的求值顺序。

规则4:不要调用以表达式为实参的函数,除非你确定实参的求值顺序无关紧要。

记住,用于在函数调用中分隔实参的逗号,仅仅是标点而不是序列操作符。

 

本书是一本优秀的C++教材,内容包括:基础类型、操作符和简单变量,循环和决策,命名空间和C++标准库,用C++编写函数,为、序列点和求值顺序,泛型函数,用户自定义类型、指针、智能指针、迭代器和动态实例,动态对象创建和多态对象,流、文件和持久性,异常,重载操作符和转换操作符,容器、迭代器和算法等。作者重点介绍类、模板、操作符重载、异常、命名空间等从事现代C++编程不可或缺的语言特性,以及容器、算法、迭代器等重要的标准库组件。本书通过例子代码和“代码详解”,将C++的精华展示给读者。.<br>本书可供完全不同的读者群体使用。无论你是否有编程基础,都可以从本书中受益。<br>学习C++的过程可能很枯燥。为了使学习过程生动有趣,Francis Glassborow以其独树一帜的激励风格讲解编程任务和工具,使你得以迅速开始编程。他的教学方式鼓舞人心,并提供了亲手打造的例子和项目。图形用户界面(GUI)扩展可为你提供即时反馈,你可从创建GUI程序中获得更多的乐趣。每一章末尾的参考素材则为你提供进一步的帮助。..<br>本书带有一款完全可移植的开源编译器,以及针对Windows和Linux的IDE。书中广泛地使用了作者的图形库,为你带来丰富的辅助素材,以便迅速有效地处理编程任务。<br>如果你已经通过自学掌握了编程基础,或在工作中学会了基本的编程技能,那么本书非读不可!它将把你的编程水平推向更高的层次。在学习过程中你一定会获得许多乐趣!...
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值