C 到 C++ 迁移手册(一)
零、介绍
像任何人类语言一样,C++ 提供了一种表达概念的方式。如果成功的话,随着问题变得越来越大和越来越复杂,这种表达方式比其他方式要容易和灵活得多。
但是,你不能只把 C++ 看做一个特性的集合;有些功能孤立地看毫无意义。如果你考虑的是设计,而不是简单的编码,那么你只能使用各部分的总和。为了这样理解 C++,你必须理解 C 语言的问题——以及一般的编程问题。这本书讨论了编程问题,为什么它们是问题,以及 C++ 解决这些问题的方法。因此,我在每章中解释的一系列特性是基于我所看到的用这种语言解决特定类型问题的方式。通过这种方式,我希望一次一点地让你从理解 C 语言到 C++ 思维成为你的母语。
自始至终,我将采取这样的态度:你想在你的头脑中建立一个模型,让你能够理解语言,直到裸露的金属;如果你遇到一个谜题,你可以把它输入到你的模型中并推断出答案。我将尝试向您传达使我开始“从 C 到 C++”的见解。
先决条件
我已经决定假设别人已经教过你 C 语言,并且你对它至少有一个阅读水平。我主要关注的是简化我觉得困难的东西:C++ 语言。虽然我已经增加了一章 C 语言的快速介绍,但我仍然假设你已经有了某种编程经验。此外,就像你通过在小说中看到新单词的上下文来直观地了解它们一样,你也可以从本书其余部分中使用 C 的上下文中了解很多关于 C 的信息。
学习 C++
我进入 C++ 的起点和我期望本书的许多读者所处的位置完全一样:作为一名程序员,对编程抱着非常严肃、具体的态度。我后来发现,我甚至不是一个非常好的 C 程序员,隐藏了我对结构、malloc()和free()、setjmp()和longjmp()以及其他“复杂”概念的无知,当话题在谈话中出现时,我羞愧地跑开了,而不是去寻求新的知识。
目标
我有几个指导我写这本书的目标。下面的列表描述了它们。
- 一次呈现一个简单的步骤,这样读者在继续阅读之前可以很容易地消化每个概念。
- 使用尽可能简单和简短的例子。这经常阻止我处理“真实世界”的问题,但是我发现,当初学者能够理解一个例子的每一个细节时,他们通常会更高兴,而不是被它所解决的问题的范围所打动。
- 仔细排列特征的展示顺序,这样你就不会看到你没有接触过的东西。当然,这并不总是可能的;在这些情况下,将给出简短的介绍性描述。
- 给你我认为对你理解这门语言很重要的东西,而不是我所知道的一切。
- 保持每个部分相对集中。
- 为读者提供一个坚实的基础,这样他们就可以很好地理解这些问题,进而阅读更难的课程和书籍。
- 我尽量不使用任何特定供应商的 C++ 版本,因为在学习语言时,我认为特定实现的细节没有语言本身重要。
章
C++ 是一种在现有语法基础上构建新的不同特性的语言。(正因为如此,它被称为混合面向对象编程语言。)这本书的设计初衷只有一个:简化学习 C++ 的过程。以下是本书所包含章节的简要描述。
- **对象介绍:**当项目变得太大、太复杂而不容易维护时,“软件危机”就诞生了,程序员说:“我们无法完成项目,即使能,也太贵了!”这引发了本章中讨论的许多反应,以及面向对象编程(OOP)的思想和它如何试图解决软件危机。
- **制作和使用对象:**本章解释了使用编译器和库构建程序的过程。它介绍了书中的第一个 C++ 程序,并展示了程序是如何构造和编译的。
- **C++ 中的 C:**这一章是对 C++ 中使用的 C 语言特性的详细概述,以及一些只有 c++ 才有的基本特性。它还介绍了在软件开发领域很常见的 make 实用程序。
- **数据抽象:**c++ 中的大多数特性都围绕着创建新数据类型的能力。这不仅提供了优秀的代码组织,而且为更强大的 OOP 能力奠定了基础。
- **隐藏实现:**你可以决定你的结构中的一些数据和函数对于新类型的用户来说是不可用的,方法是将它们私有。
- **初始化和清理:**未初始化的变量是最常见的 C 错误之一。C++ 中的构造器允许你保证新数据类型的变量总是被正确初始化。如果你的对象也需要某种清理,你可以保证这种清理总是发生在 C++ 析构函数上。
- 函数重载和默认参数: C++ 旨在帮助你构建大型复杂的项目。在这样做的时候,你可能会引入多个使用相同函数名的库,你也可以选择在一个库中使用不同含义的相同名称。C++ 通过函数重载简化了这一过程,只要参数列表不同,就可以重用相同的函数名。默认参数通过自动为某些参数提供默认值,允许您以不同的方式调用同一个函数。
- **常量:**本章涵盖了
const和volatile关键字,它们在 C++ 中有额外的含义,尤其是在类内部。 - **内联函数:**预处理器宏消除了函数调用开销,但是预处理器也消除了有价值的 C++ 类型检查。内联函数为您提供了预处理器宏的所有优点,以及真实函数调用的所有优点。
- 名称控制:创建名称是编程中的一项基本活动,当一个项目变大时,名称的数量可能会多得令人难以招架。C++ 允许您在名称的创建、可见性、存储位置和链接方面进行大量控制。本章展示了在 C++ 中如何使用两种技术来控制名字,静态关键字和名字空间特性。
- 引用和复制构造器: C++ 指针的工作方式类似于 C 指针,还有更强的 C++ 类型检查功能。您还将遇到复制构造器,它控制对象通过值传入和传出函数的方式。
- 在这一章中,你将学到操作符重载只是一种不同类型的函数调用,你将学习如何编写自己的函数调用,处理有时令人困惑的参数、返回类型的使用,以及决定是否让操作符成为成员或朋友。
- **动态对象创建:**一个空中交通管制系统需要管理多少架飞机?一个 CAD 系统需要多少个形状?在一般的编程问题中,你无法知道你正在运行的程序所需要的对象的数量、生存期、或者类型;在这里,您将了解 C++ 的 new 和 delete 如何通过在堆上安全地创建对象来优雅地解决这个问题。
- **继承和组合:**数据抽象允许你从零开始创建新的类型,但是有了组合和继承,你可以从现有的类型创建新的类型。通过组合,您可以使用其他类型作为片段来组装新的类型,而通过继承,您可以创建现有类型的更具体版本。
- **多态和虚函数:**通过小而简单的例子,你将看到如何创建一个具有继承性的类型家族,并通过它们的公共基类来操作这个家族中的对象。关键字
virtual允许你一般地对待这个家族中的所有对象。 - **模板简介:**继承和组合允许你重用目标代码,但这并不能解决你所有的重用需求。模板通过为编译器提供一种替换类或函数体中的类型名的方法,允许您重用源代码。
- **异常处理:**错误处理一直是编程中的难题。异常处理是 C++ 中的一个主要特性,它允许您在发生严重错误时将对象“抛出”函数,从而解决了这个问题。
- **字符串深度:**最常见的编程活动是文本处理。C++
string类将程序员从内存管理问题中解放出来,同时提供强大的文本处理能力。 - iostreams: 最初的 C++ 库之一——提供基本 I/O 功能的库——叫做
iostreams。它旨在用一个更容易使用、更灵活和可扩展的 I/O 库来取代 C 的stdio.h。 - **运行时类型识别:**运行时类型识别(RTTI)在你只有一个指向基本类型的指针或引用时,找到一个对象的确切类型。
- **多重继承:**这乍听起来很简单:一个新类是从多个现有类继承而来的。然而,您可能会以模糊性和基类对象的多个副本而告终。虚拟基类解决了这个问题。
那么,祝你“从 C 到 C++”一切顺利!
-阿鲁内斯·戈亚尔
2013 年 5 月 14 日,新德里
一、对象介绍
计算机革命起源于一台机器。因此,我们编程语言的起源看起来就像那台机器。
但是计算机与其说是机器,不如说是思维放大工具(“可以说是思维的自行车”)和一种不同的表达媒介。因此,这些工具看起来越来越不像机器,更像我们大脑的一部分,也像其他表达媒介,如写作、绘画、雕塑、动画和电影制作。面向对象编程是将计算机用作表达媒介的趋势的一部分。
本章将向你介绍面向对象编程的基本概念,包括 OOP 开发方法的概述。这一章和这本书都假设你有使用过程编程语言的经验,尽管不一定是 c。
这一章是背景和补充材料。许多人在没有首先了解全局的情况下,对涉足面向对象编程感到不舒服。因此,这里引入了许多概念来给你一个 OOP 的坚实的概述。然而,许多其他人直到他们首先看到了一些机制之后,才明白大图的概念;如果没有一些代码,这些人可能会陷入困境,迷失方向。如果你是后一种人,并且渴望了解这门语言的细节,可以跳过这一章;此时跳过它不会妨碍您编写程序或学习语言。然而,你最终会想回到这里来充实你的知识,这样你就能理解为什么对象是重要的,以及如何用它们来设计。
抽象的进展
所有编程语言都提供抽象。可以说,你能够解决的问题的复杂性与抽象的种类和质量直接相关。(“类”是指你在抽象什么。)汇编语言是底层机器的一个小抽象。随后出现的许多所谓的命令式语言都是汇编语言的抽象。这些语言比汇编语言有了很大的改进,但是它们的主要抽象仍然要求你根据计算机的结构而不是你试图解决的问题的结构来思考。程序员必须建立机器模型(在解决方案空间中,这是你对问题建模的地方,比如一台计算机)和实际正在解决的问题模型(在问题空间,这是问题存在的地方)之间的关联。执行这种映射所需要的努力,以及它对于编程语言来说是外在的这一事实,产生了难以编写且维护昂贵的程序,并且作为副作用,创造了整个编程方法行业。
对机器建模的替代方法是对你试图解决的问题建模。PROLOG 将所有问题都转化为一连串的决策。已经为基于约束的编程和专门通过操纵图形符号的编程创建了语言。这些方法中的每一种都是解决特定问题的好方法,但是当你走出这个领域时,它们就变得笨拙了。
面向对象的方法更进一步,它为程序员提供了在问题空间中表示元素的工具。这种表示足够通用,程序员不会受限于任何特定类型的问题。我们将问题空间中的元素及其在解决方案空间中的表示称为对象。(当然,您还需要其他没有问题空间类似物的对象。)这个想法是允许程序通过添加新类型的对象来适应问题的术语,所以当你阅读描述解决方案的代码时,你也在阅读表达问题的单词。这是一个比我们以前拥有的更加灵活和强大的语言抽象。因此,OOP 允许你根据问题来描述问题,而不是根据解决方案运行的计算机。
不过,电脑还是有连接的。每个物体看起来都有点像一台小电脑;它有一个状态,你可以要求它执行一些操作。然而,这对于现实世界中的物体来说似乎不是一个坏的类比;都有特点和行为。
一些语言设计者认为面向对象编程本身不足以轻松解决所有编程问题,因此提倡将各种方法组合成多种编程语言。
有五个特征代表了面向对象编程的纯方法。
- 一切都是物体。把一个物体想象成一个花式变量;它存储数据,但是您可以向该对象“发出请求”,要求它对自身执行操作。理论上,你可以在你试图解决的问题中采用任何概念成分(狗、建筑、服务等)。 ) 并在你的程序中把它表现为一个对象。
- 程序是一堆对象,它们通过发送消息告诉彼此该做什么。要向一个对象发出请求,你需要向该对象“发送一条消息”。更具体地说,您可以将消息视为调用属于特定对象的函数的请求。
- 每个对象都有自己的记忆,由 ot 她的对象组成。换句话说,通过制作包含现有对象的包来创建一种新的对象。因此,您可以在程序中构建复杂性,同时将它隐藏在对象的简单性后面。
- 每个对象都有一个类型。按照这种说法,每个对象都是一个类的实例,其中“类”与“类型”同义一个类最重要的区别特征是你可以发送给它的消息。
- 特定类型的所有对象可以接收相同的消息。因为类型为
circle的对象也是类型为shape的对象,所以保证circle接受shape消息。这意味着你可以编写与shapes对话的代码,并自动处理任何符合形状描述的东西。这个可替代性是 OOP 中最强大的概念之一。
一个对象有一个接口
所有对象都是独一无二的,也是具有共同特征和行为的一类对象的一部分,这一思想被直接用于第一种面向对象语言 Simula-67,其基本关键字class为程序引入了一种新类型。
顾名思义,Simula 是为开发模拟而创建的,例如经典的银行出纳员问题。在这里,你有一堆出纳员、客户、账户、交易和货币单位——许多对象。在程序执行过程中,除了状态不同之外都相同的对象被分组到对象的类中,这就是关键字class的来源。创建抽象数据类型(类)是面向对象编程中的一个基本概念。抽象数据类型的工作方式几乎与内置类型完全一样:你可以创建一种类型的变量(在面向对象的说法中称为对象或实例)并操纵这些变量(称为发送消息或请求;你发送一条消息,对象就知道如何处理它)。每个类的成员(元素)都有一些共性:每个账户都有一个余额,每个柜员都可以接受存款,等等。同时,每个成员都有自己的状态,每个账户都有不同的余额,每个柜员都有名字。因此,柜员、客户、账户、交易等。都可以用计算机程序中的唯一实体来表示。这个实体就是对象,每个对象都属于一个定义其特征和行为的特定类。
因此,尽管我们在面向对象编程中真正做的是创建新的数据类型,但实际上所有面向对象编程语言都使用class关键字。当你看到“类型”这个词时,想想“类”,反之亦然。
由于类描述了一组具有相同特征(数据元素)和行为(功能)的对象,所以类实际上是一种数据类型,因为例如浮点数也具有一组特征和行为。区别在于程序员定义一个类来适应一个问题,而不是被迫使用一个现有的数据类型,该数据类型被设计来表示机器中的一个存储单元。您可以通过添加特定于您需求的新数据类型来扩展编程语言。编程系统欢迎新的类,并像对待内置类型一样给予它们所有的关心和类型检查。
面向对象的方法不限于构建模拟。不管你是否同意任何程序都是你正在设计的系统的模拟,OOP 技术的使用可以很容易地将大量的问题简化为一个简单的解决方案。
一旦建立了一个类,您就可以创建该类的任意多个对象,然后操纵这些对象,就好像它们是您试图解决的问题中存在的元素一样。事实上,面向对象编程的挑战之一是在问题空间的元素和解决方案空间的对象之间创建一对一的映射。
但是你如何让一个对象为你做有用的工作呢?必须有一种方法向对象发出请求,以便它做一些事情,比如完成一个事务,在屏幕上画一些东西,或者打开一个开关。每个对象只能满足特定的请求。你对一个对象的请求是由它的接口定义的,类型决定了接口。一个简单的例子可能是在图 1-1 中一个灯泡的表示,代码可能是
Light lt;
lt.on();
图 1-1 。灯泡的类型和接口
这个接口建立了你可以对一个特定的对象发出什么样的请求。但是,必须有代码来满足这个请求。这和隐藏的数据一起,组成了实现。从过程化编程的角度来看,这并不复杂。一个类型有一个与每个可能的请求相关联的函数,当你向一个对象发出一个特定的请求时,这个函数就会被调用。这个过程通常可以概括为:你向一个对象发送一条消息(发出一个请求,然后这个对象计算出如何处理这条消息(它执行代码)。
这里,类型/类的名字是Light,这个特定的Light对象的名字是lt,你可以对一个Light对象发出的请求是打开它、关闭它、使它变亮或变暗。通过为对象声明一个名字(lt)来创建一个Light对象。要向对象发送消息,您需要声明对象的名称,并用句点(点)将其连接到消息请求。从预定义类的用户的角度来看,这就是用对象编程的全部内容。
图 1-1 遵循统一建模语言(UML) 的格式。每个类都用一个盒子来表示,盒子的顶部是类型名,你想描述的任何数据成员在盒子的中间部分,而成员函数(属于这个对象的函数,它接收你发送给那个对象的任何消息)在盒子的底部。通常,UML 设计图中只显示类名和公共成员函数,因此中间部分没有显示。如果您只对类名感兴趣,那么底部也不需要显示。
隐藏的实现
将竞技场分成类创建者(创建新数据类型的人)和客户端程序员(在应用程序中使用数据类型的类消费者)是有帮助的。客户端程序员的目标是收集一个装满类的工具箱,用于快速应用程序开发。类创建者的目标是构建一个只向客户端程序员公开必要内容的类,而隐藏其他所有内容。为什么呢?因为如果它是隐藏的,客户端程序员就不能使用它,这意味着类创建者可以随意更改隐藏的部分,而不用担心对其他任何人的影响。隐藏部分通常表示对象的脆弱内部,很容易被粗心或不知情的客户端程序员破坏,因此隐藏实现可以减少程序错误。实现隐藏的概念怎么强调都不为过。
在任何关系中,重要的是要有各方都尊重的界限。当您创建一个库时,您与客户程序员建立了一种关系,客户程序员也是一名程序员,但他正在使用您的库组装一个应用程序,可能是为了构建一个更大的库。
如果每个人都可以使用一个类的所有成员,那么客户端程序员就可以对这个类做任何事情,并且没有办法强制执行规则。尽管您可能真的希望客户端程序员不要直接操作您的类的一些成员,但是没有访问控制就没有办法阻止它。一切对世界都是赤裸裸的。
因此,访问控制的第一个原因是让客户端程序员不要接触他们不应该接触的部分——数据类型的内部机制所必需的部分,但不是用户解决特定问题所需的界面的一部分。这实际上是对用户的一种服务,因为他们可以很容易地看到对他们来说什么是重要的,什么是可以忽略的。
访问控制的第二个原因是允许库设计者改变类的内部工作方式,而不用担心它会如何影响客户程序员。例如,您可能以简单的方式实现了一个特定的类以简化开发,然后发现您需要重写它以使它运行得更快。如果接口和实现被清晰的分离和保护,你可以很容易的完成,并且只需要用户重新链接。
C++ 使用三个显式关键字来设置类的边界:public、private和protected。它们的用法和含义非常简单。这些访问说明符决定了谁可以使用后面的定义。public表示每个人都可以使用以下定义。另一方面,private关键字意味着除了你——该类型的创建者——之外,没有人可以访问该类型的成员函数中的那些定义。private是你和客户程序员之间的一堵砖墙。如果有人试图访问一个private成员,他们会得到一个编译时错误。protected的行为就像private一样,除了继承类可以访问protected成员,但不能访问private成员。继承即将推出。
重用实现
一旦一个类被创建和测试,它应该(理想地)代表一个有用的代码单元。事实证明,这种可重用性并不像许多人希望的那样容易实现;产生一个好的设计需要经验和洞察力。但是一旦你有了这样的设计,它就乞求被重用。代码重用是面向对象编程语言提供的最大优势之一。
重用一个类最简单的方法是直接使用该类的一个对象,但是你也可以将该类的一个对象放在一个新的类中。我们称之为“创建成员对象”您的新类可以由任意数量和类型的其他对象组成,您可以根据需要以任意组合来实现新类所需的功能。因为您正在从现有的类中组合一个新的类,所以这个概念被称为组合(或者更一般地说,聚合)。构图,如图 1-2 中的所示,通常被称为“has-a”关系,就像“一辆汽车有一个引擎”
图 1-2 。显示组合(“有-有”关系)
(这个 UML 图用实心菱形表示组成,表示有一辆汽车。我将典型地使用一种更简单的形式:只有一条线,,没有菱形,来表示一个关联。)
作曲有很大的灵活性。新类的成员对象通常是私有的,这使得使用该类的客户端程序员无法访问它们。这允许您在不干扰现有客户端代码的情况下更改这些成员。您还可以在运行时更改成员对象,以动态地更改程序的行为。接下来描述的继承没有这种灵活性,因为编译器必须对用继承创建的类进行编译时限制。
因为继承在面向对象编程中非常重要,所以它经常被高度强调,新的程序员可以理解继承应该在任何地方使用。这可能导致笨拙和过于复杂的设计。相反,在创建新类时,您应该首先考虑组合,因为它更简单、更灵活。如果你采用这种方法,你的设计会更整洁。一旦你有了一些经验,当你需要继承的时候就相当明显了。
继承:重用接口
就其本身而言,对象的概念是一个方便的工具。它允许你通过概念将数据和功能打包在一起,这样你就可以代表一个合适的问题空间想法,而不是被迫使用底层机器的习惯用法。这些概念通过使用关键字class被表达为编程语言中的基本单元。
然而,令人遗憾的是,费尽周折创建一个类,然后又被迫创建一个可能具有类似功能的全新的类。如果您可以获取现有的类,克隆它,然后对克隆进行添加和修改,那就更好了。这实际上是你通过继承得到的,除了如果原始类(称为基或超或父类)被改变,修改后的“克隆”(称为派生的或继承的或子或子类)也会反映这些改变。
(图 1-3 中 UML 图中的箭头从派生类指向基类。正如您将看到的,可以有多个派生类。)
图 1-3 。显示继承(从超类派生子类)
类型不仅仅描述一组对象的约束;和其他类型也有关系。两种类型可以有共同的特征和行为,但是一种类型可能比另一种类型包含更多的特征,并且还可能处理更多的消息(或者以不同的方式处理它们)。继承使用基类型和派生类型的概念来表达类型之间的这种相似性。基类型包含从它派生的类型之间共享的所有特征和行为。您创建一个基本类型来表示您对系统中一些对象的核心想法。从基本类型中,您可以派生出其他类型来表达实现这个核心的不同方式。
例如,垃圾回收机器将垃圾分类。基础类型是trash,每一片垃圾都有重量、价值等等,可以撕碎、融化、分解。由此衍生出更多特定类型的垃圾,它们可能具有额外的特征(瓶子有颜色)或行为(铝罐可能被压碎,钢罐有磁性)。此外,有些行为可能是不同的(纸张的价值取决于其类型和条件)。使用继承,您可以构建一个类型层次结构,用它的类型来表达您试图解决的问题。
第二个例子如图 1-4 中的所示,是经典的Shape例子,可能用于计算机辅助设计系统或游戏模拟。基础类型是Shape,每个形状都有大小、颜色、位置等等。每个形状都可以被绘制、擦除、移动、着色等。由此衍生出特定类型的形状(继承 ): Circle、Square、Triangle等等,每一种都可能有额外的特征和行为。例如,某些形状可以翻转。有些行为可能会有所不同,例如当您想要计算形状的面积时。类型层次结构体现了形状之间的相似性和差异。
图 1-4 。显示形状的类型层次结构
用与问题相同的术语来描述解决方案是非常有益的,因为从问题的描述到解决方案的描述不需要很多中间模型。对于对象,类型层次结构是主要模型,因此您可以直接从现实世界中的系统描述转到代码中的系统描述。事实上,人们对于面向对象设计的一个困难是,从开始到结束都太简单了。一个被训练去寻找复杂解决方案的头脑通常在一开始会被这种简单性难倒。
当从现有类型继承时,会创建一个新类型。这个新类型不仅包含现有类型的所有成员(尽管private的成员被隐藏起来,不可访问),更重要的是它复制了基类的接口。也就是说,可以发送给基类对象的所有消息也可以发送给派生类对象。因为你可以通过发送给它的消息知道一个类的类型,这意味着派生类和基类是相同的类型。在前面的例子中,Circle是一个Shape。这种通过继承实现的类型等价是理解面向对象编程意义的基本途径之一。
因为基类和派生类都有相同的接口,所以必须有一些实现来配合该接口。也就是说,当一个对象接收到一个特定的消息时,必须有一些代码要执行。如果你只是简单地继承一个类,不做任何其他事情,基类接口的方法会直接进入派生类。这意味着派生类的对象不仅具有相同的类型,它们还具有相同的行为,这并不特别有趣。
有两种方法可以区分新的派生类和原始基类。第一种非常简单:只需向派生类中添加全新的函数。这些新函数不是基类接口的一部分。这意味着基类没有做你想要的那么多,所以你添加了更多的函数。这种简单原始的继承用法有时是解决问题的完美方案。但是,您应该仔细寻找您的基类可能也需要这些附加函数的可能性。这个发现和迭代你的设计的过程,如图 1-5 ,所示,在面向对象编程中经常发生。
图 1-5 。展示 OOP 中的迭代
虽然继承有时可能意味着您要向接口添加新的功能,但这不一定是真的。区分新类的第二个也是更重要的方法是改变现有基类函数的行为。这被称为超越该功能,如图图 1-6 所示。
图 1-6 。显示 OOP 中函数的覆盖
要覆盖一个函数,只需在派生类中为该函数创建一个新的定义。你在说,“我在这里使用相同的接口函数,但是我希望它为我的新类型做一些不同的事情。”
Is-a 与 is-like-a 关系
关于继承可能会有一些争论:继承应该只覆盖基类函数()而不添加不在基类中的新成员函数*)吗?这意味着派生类型与基类是完全相同的类型,因为它有完全相同的接口。因此,您完全可以用派生类的对象替换基类的对象。这可以被认为是纯替代,它通常被称为替代原则。从某种意义上说,这是对待继承的理想方式。在这种情况下,我们经常将基类和派生类之间的关系称为 is-a 关系,因为我们可以说“一个圆是一个形状。”对继承的一个测试是确定你是否能陈述关于类的 is-a 关系,并让它有意义。*
有时,您必须向派生类型添加新的接口元素,从而扩展接口并创建新的类型。新类型仍然可以替换基本类型,但是这种替换并不完美,因为不能从基本类型访问新函数。这可以描述为一个是——像——一个的关系;新类型有旧类型的接口,但也包含其他功能,所以你不能说它完全一样。例如,考虑一台空调(如图 1-7 中的所示)。假设你的房子安装了所有的制冷控制器;也就是说,它有一个允许您控制冷却的界面。想象一下,空调坏了,你换成热泵,既能制热又能制冷。热泵就像一台空调,但它能做更多。因为你的房子的控制系统被设计成仅仅控制冷却,它被限制为与新物体的冷却部分通信。新对象的接口被扩展了,现有的系统除了原来的接口什么都不知道。
图 1-7 。冷却系统与温度控制系统
当然,一旦你看到这个设计,很明显基类Cooling System不够通用,应该被重命名为Temperature Control System,这样它也可以包括加热——在这一点上,替代原则将起作用。然而,图 1-7 中的图表是设计和现实世界中可能发生的例子。
当你看到替代原则时,很容易觉得这种方法(纯替代)是做事的唯一方式,事实上,如果你的设计以这种方式工作,那么很好。但是你会发现,有时候同样清楚的是,你必须向一个派生类的接口添加新的函数。通过检查,这两种情况都应该相当明显。
具有多态的可互换对象
在处理类型层次结构时,您通常不希望将对象视为其特定类型,而是将其视为其基类型。这允许您编写不依赖于特定类型的代码。在 shape 示例中,函数操纵通用形状,而不管它们是Circle s、Square s、Triangle s 等等。所有形状都可以被绘制、擦除和移动,所以这些函数只是向一个shape对象发送一条消息;他们不担心对象如何处理信息。
这种代码不受添加新类型的影响,添加新类型是扩展面向对象程序以处理新情况的最常见方式。例如,您可以派生出一个名为Pentagon的Shape的新子类型,而无需修改只处理一般形状的函数。这种通过派生新的子类型来轻松扩展程序的能力非常重要,因为它极大地改进了设计,同时降低了软件维护的成本。
然而,试图将派生类型的对象作为它们的通用基本类型来对待会有一个问题(Circles作为Shapes、Bicycles作为Vehicles、Cormorants作为Birds等等)。).如果一个函数要告诉一个通用的形状来画它自己,或者告诉一个通用的车辆来驾驶,或者告诉一只通用的鸟来移动,编译器在编译时不能精确地知道哪段代码将被执行。这才是重点!当消息被发送时,程序员不希望知道将执行哪段代码;draw函数同样适用于Circle、Square或Triangle,对象将根据其具体类型执行适当的代码。如果您不必知道将执行哪段代码,那么当您添加新的子类型时,它执行的代码可以不同,而无需更改函数调用。
因此,编译器无法准确知道执行了哪段代码,它做了什么?例如,在图 1-8 中,BirdController对象只与通用Bird对象一起工作,并不知道它们的确切类型。从BirdController的角度来看,这很方便,因为它不必编写特殊的代码来确定它正在使用的Bird的确切类型,或者Bird的行为。那么,当调用move()而忽略特定类型的Bird时,正确的行为将会发生(a Goose跑、飞或游,a Penguin跑或游)是如何发生的呢?
图 1-8 。非 OOP 中的早期绑定与 OOP 中的晚期绑定
答案是面向对象编程中的主要转折:编译器不能进行传统意义上的函数调用。非面向对象编译器生成的函数调用导致了所谓的早期绑定,这个术语你可能以前没有听说过,因为你从来没有以其他方式思考过。它意味着编译器生成一个对特定函数名的调用,链接器将这个调用解析为要执行的代码的绝对地址。在 OOP 中,程序直到运行时才能确定代码的地址,所以当一个消息被发送到一个通用对象时,一些其他的方案是必要的。
为了解决这个问题,面向对象语言使用了后期绑定的概念。当你向一个对象发送消息时,被调用的代码直到运行时才被确定。编译器确实确保函数存在,并对参数和返回值执行类型检查(这种情况不成立的语言被称为弱类型),但它不知道要执行的确切代码。
为了执行后期绑定,C++ 编译器会插入一段特殊的代码来代替绝对调用。这段代码使用存储在对象中的信息计算函数体的地址(这个过程在第十五章中有更详细的介绍)。因此,每个对象可以根据该特殊代码位的内容表现不同。当你向一个对象发送一条消息时,这个对象实际上知道如何处理这条消息。
您使用关键字virtual声明您希望函数具有后期绑定属性的灵活性。你不需要理解virtual的机制来使用它,但是没有它你就不能用 C++ 进行面向对象的编程。在 C++ 中,你必须记住添加virtual关键字,因为默认情况下,成员函数是而不是动态绑定的。虚函数允许你表达同一个家族中类的行为差异。这些差异导致了多态行为。
考虑一下Shape的例子。这一系列的类(都基于相同的统一接口)在本章前面已经画出了图表。为了演示多态,您希望编写一段代码,这段代码忽略类型的具体细节,只与基类对话。该代码从特定于类型的信息中解耦了,因此更容易编写和理解。而且,如果一个新的类型——比如一个Hexagon——通过继承被添加,你写的代码对于新类型的Shape就像对于现有类型一样有效。因此,程序是可扩展的。
如果你用 C++ 写一个函数(你将很快学会如何做):
void doStuff(Shape& s) {
s.erase();
// ...
s.draw();
}
这个函数对任何一个Shape说话,所以它独立于它正在绘制和擦除的对象的具体类型(&的意思是“获取传递给doStuff()的对象的地址”,但是现在理解它的细节并不重要)。如果在程序的其他部分使用doStuff()函数
Circle c;
Triangle t;
Line l;
doStuff(c);
doStuff(t);
doStuff(l);
不管对象的确切类型是什么,对doStuff( )的调用都会自动正常工作。
这实际上是一个相当惊人的把戏。想想这条线
doStuff(c);
这里发生的是一个Circle被传递给一个期待一个Shape的函数。既然一个Circle是一个Shape,那么就可以由doStuff()来当作一个。也就是说,doStuff()能发给 a Shape的任何消息,a Circle都能接受。所以这是一件完全安全和符合逻辑的事情。
我们称这个处理派生类型的过程为基础类型向上转换。名称 cast 用于铸造模具的意思,而 up 来自继承图的典型排列方式,基类在顶部,派生类向下展开。因此,强制转换为基类型是在继承图中向上移动;上抛如图 1-9 中的所示。
图 1-9 。向上移动继承图,也称为“向上转换”
一个面向对象的程序在某个地方包含了一些向上转换,因为这是你不知道你正在使用的确切类型的方法。请看doStuff()中的代码:
s.erase();
// ...
s.draw();
注意,它没有说“如果你是一个Circle,做这个,如果你是一个Square,做那个,等等。”如果你写那种代码,检查一个Shape实际上可能是所有可能的类型,它是混乱的,你需要在每次添加一个新的Shape时修改它。在这里,你只要说“你是一个形状。我知道你自己可以erase()和draw(),所以去做吧,并且正确处理好细节。”
令人印象深刻的是,不知何故,正确的事情发生了。为Circle调用draw()会导致与为Square或Line调用draw()时不同的代码被执行,但是当draw()消息被发送到匿名Shape时,正确的行为会基于Shape的实际类型发生。这是惊人的,因为正如前面提到的,当 C++ 编译器为doStuff()编译代码时,它不能确切地知道它正在处理什么类型。所以通常,你会期望它最终为Shape调用erase()和draw()的版本,而不是为特定的Circle、Square或Line调用。然而,由于多态,正确的事情发生了。编译器和运行时系统处理细节;你需要知道的只是它会发生,更重要的是如何利用它进行设计。如果一个成员函数是virtual,那么当你给一个对象发送一个消息时,这个对象会做正确的事情,即使涉及到向上转换。
创建和销毁对象
从技术上讲,OOP 的领域是抽象数据类型、继承和多态,但是其他问题至少同样重要。本节概述了这些问题。
尤其重要的是对象的创建和销毁方式。对象的数据在哪里,如何控制该对象的生命周期?不同的编程语言使用不同的哲学。C++ 认为控制效率是最重要的问题,所以它给程序员一个选择。为了获得最大的运行速度,可以在编写程序时通过将对象放在堆栈上或静态存储中来确定存储和生存期。堆栈是内存中的一个区域,微处理器在程序执行期间直接使用它来存储数据。栈上的变量有时被称为自动或作用域变量。静态存储区只是在程序开始运行之前分配的一块固定的内存。使用堆栈或静态存储区域优先考虑存储分配和释放的速度,这在某些情况下是有价值的。然而,您牺牲了灵活性,因为在编写程序时,您必须知道对象的确切数量、生存期和类型。如果你试图解决一个更一般的问题,比如计算机辅助设计、仓库管理或空中交通管制,这就太有限制性了。
第二种方法是在称为堆的内存池中动态创建对象。在这种方法中,直到运行时你才知道你需要多少对象,它们的寿命是多少,或者它们的确切类型是什么。那些决定是在程序运行时的瞬间做出的。如果需要一个新对象,只需在需要时使用new关键字在堆中创建它。当您完成存储时,您必须使用delete关键字释放它。
因为存储是在运行时动态管理的,所以在堆上分配存储所需的时间要比在堆栈上创建存储所需的时间长得多。(在堆栈上创建存储通常是一条向下移动堆栈指针的微处理器指令,另一条向上移动堆栈指针的微处理器指令)。动态方法作出了通常合乎逻辑的假设,即对象往往是复杂的,因此寻找存储和释放存储的额外开销不会对对象的创建产生重要影响。此外,更大的灵活性对于解决一般的编程问题是必不可少的。
然而,还有另一个问题,那就是对象的生命周期。如果在堆栈上或静态存储中创建一个对象,编译器会确定该对象持续的时间,并可以自动销毁它。但是,如果在堆上创建它,编译器就不知道它的生存期。在 C++ 中,程序员必须以编程方式确定何时销毁对象,然后使用delete关键字执行销毁。作为替代,环境可以提供一个叫做垃圾收集器的特性,当一个对象不再被使用时,它会自动发现并销毁它。当然,使用垃圾收集器编写程序要方便得多,但是它要求所有的应用程序必须能够容忍垃圾收集器的存在和垃圾收集的开销。这不符合 C++ 语言的设计要求,所以没有包括在内,尽管 C++ 有第三方垃圾收集器。
异常处理:处理错误
自从编程语言诞生以来,错误处理一直是最困难的问题之一。因为设计一个好的错误处理方案非常困难,所以许多语言干脆忽略了这个问题,把这个问题留给了库设计者,他们想出了在许多情况下都可以工作但很容易被绕过的中间措施,通常只需忽略它们。大多数错误处理方案的一个主要问题是,它们依赖程序员在遵循约定的约定时保持警惕,而该语言并不强制这样做。如果程序员不警惕(这在他们匆忙时经常发生),这些方案很容易被忘记。
异常处理将错误处理直接连接到编程语言中,有时甚至连接到操作系统中。异常是从错误位置“抛出”的对象,并且可以被适当的异常处理程序捕获,该异常处理程序用于处理特定类型的错误。就好像异常处理是一条不同的并行执行路径,当出现问题时可以采用。因为它使用一个单独的执行路径,所以不需要干扰你正常执行的代码。这使得代码更容易编写,因为您不必经常检查错误。此外,抛出的异常不同于函数返回的错误值,也不同于函数为指示错误条件而设置的标志——这些都可以忽略。异常不能被忽略,所以它肯定会在某个时候被处理。最后,异常提供了一种从糟糕的情况中可靠恢复的方法。除了退出程序之外,你通常还能把事情做好,恢复程序的执行,这就产生了更加健壮的系统。
值得注意的是,异常处理不是面向对象的特性,尽管在面向对象语言中,异常通常用一个对象来表示。异常处理在面向对象语言之前就存在了。(你可能会注意到第十七章详细介绍了异常处理)。
分析和设计
面向对象的范例是一种新的、不同的编程思维方式,许多人一开始不知道如何处理 OOP 项目。一旦你知道一切都应该是一个对象,并且当你学会更多地以面向对象的方式思考时,你就可以开始创建“好的”设计,利用 OOP 提供的所有好处。
一个方法(通常被称为方法论)是一组用于分解编程问题复杂性的过程和启发。自从面向对象编程出现以来,许多 OOP 方法已经被公式化了。这一节将让你对使用一种方法时你试图完成的事情有一个感觉。
尤其是在 OOP 中,方法学是一个需要很多实验的领域,所以在你考虑采用一个方法之前,理解这个方法试图解决什么问题是很重要的。对于 C++ 来说尤其如此,在 c++ 中,编程语言旨在降低表达程序所涉及的复杂性(与 C 相比)。事实上,这可能会减少对越来越复杂的方法的需求。相反,在 C++ 中,相对于使用简单的方法和过程化语言所能处理的问题,更简单的方法可能就足够了。
同样重要的是要意识到“方法论”这个术语经常太过宏大,承诺太多。无论你现在做什么,当你设计和编写一个程序的时候,都是一个方法。它可能是你自己的方法,你可能没有意识到这样做,但它是你在创作时经历的一个过程。如果这是一个有效的过程,它可能只需要一个小的调整就可以和 C++ 一起工作。如果你对你的生产力和你的程序结果不满意,你可能想考虑采用一个正式的方法,或者从许多正式的方法中选择一些。
当你经历发展过程时,最重要的问题是:不要迷失。这很容易做到。大多数分析和设计方法都是为了解决最大的问题。请记住,大多数项目都不属于这一类别,所以您通常可以使用方法推荐的相对较小的子集来进行成功的分析和设计。但是某种过程,不管多么有限,通常会比简单地开始编码更好地引导你前进。
也很容易卡住,陷入分析瘫痪,感觉自己无法前进,因为没有把现阶段的每一个小细节都钉死。请记住,无论您做了多少分析,都有一些关于系统的事情直到设计时才会显露出来,更多的事情直到您编写代码时才会显露出来,甚至直到程序启动并运行时才会显露出来。因此,非常快速地完成分析和设计,并对所提议的系统进行测试是至关重要的。
这一点值得强调。由于我们在过程化语言方面的历史,值得称赞的是,一个团队希望在转向设计和实现之前仔细地进行并理解每一分钟的细节。当然,在创建 DBMS 时,彻底了解客户的需求是有好处的。但是数据库管理系统是一类非常适定和容易理解的问题;在许多这样的程序中,数据库结构是要解决的问题。本章讨论的编程问题是一种不确定性问题,其中的解决方案不是简单地重新形成一个众所周知的解决方案,而是涉及一个或多个不确定因素——对于这些因素,以前没有很好的解决方案,因此有必要进行研究。*在进入设计和实现阶段之前试图彻底分析一个不确定的问题会导致分析的瘫痪,因为在分析阶段你没有足够的信息来解决这类问题。*解决这样的问题需要整个周期的迭代,这需要冒险行为(这是有意义的,因为你正在尝试做一些新的事情,潜在的回报更高)。看起来“匆忙”进入初步实现增加了风险,但这反而可以降低不确定项目中的风险,因为您可以尽早发现解决问题的特定方法是否可行。产品开发就是风险管理。
人们经常建议你“造一个扔掉”使用 OOP,你可能仍然会扔掉它的部分,但是因为代码被封装到类中,在第一次迭代中,你将不可避免地产生一些有用的类设计,并开发一些关于系统设计的有价值的想法,这些想法不需要被扔掉。因此,对一个问题的第一次快速处理不仅为下一次分析、设计和实现迭代产生了关键信息,还为该迭代创建了代码基础。
也就是说,如果你正在寻找一个包含大量细节和建议许多步骤和文档的方法,仍然很难知道什么时候停止。记住你想要发现什么。
- 有哪些对象?(你如何将你的项目划分成它的组成部分?)
- 它们的接口是什么?(您需要能够向每个对象发送什么消息?)
如果你除了对象和它们的接口什么都没有,那么你可以写一个程序。出于各种原因,你可能需要比这更多的描述和文档,但你不能少了。
这个过程可以分为五个阶段,第 0 阶段只是使用某种结构的最初承诺。
阶段 0:制定计划
你必须首先决定在你的过程中有哪些步骤。这听起来很简单(事实上,所有这些听起来都很简单),然而人们通常不会在开始编码之前做出这个决定。如果你的计划是“让我们跳进来开始编码”,很好。至少同意这是计划。
注意有时当你有一个很好理解的问题时,这是合适的。
您也可以在这个阶段决定一些额外的过程结构是必要的,但不是全部。可以理解的是,一些程序员喜欢在休假模式下工作,在这种模式下,开发他们的工作;的过程没有强加任何结构,换句话说*,“该完成的时候就完成了。”这可能在一段时间内很有吸引力,但是沿途有几个里程碑有助于围绕这些里程碑集中和激励你的努力,而不是停留在单一的目标**完成项目*。此外,它将项目分成更小的部分,使它看起来不那么具有威胁性(加上里程碑提供了更多庆祝的机会)。
使命宣言
你建立的任何系统,不管有多复杂,都有一个基本的目的——它所在的行业,它满足的基本需求。如果你能越过用户界面、硬件或系统特定的细节、编码算法和效率问题,你最终会发现它的本质,简单而直接。就像好莱坞电影里所谓的高概念一样,可以用一两句话来形容。这种纯粹的描述是起点。
高概念相当重要,因为它为你的项目定下了基调;这是一份使命宣言。你不一定第一次就做对了(你可能会在项目的后期才变得完全清晰),但要不断尝试,直到感觉正确为止。例如,在一个空中交通管制系统中,你可能从一个高度的概念开始,这个概念集中在你正在构建的系统上:“塔台程序跟踪飞机。”但是考虑一下当你把系统缩小到一个非常小的机场时会发生什么;也许只有一个人类控制器,或者根本没有。一个更有用的模型不会关注你正在创建的解决方案,而是描述问题:“飞机到达,卸载,维修和重装,然后离开。”
阶段 1 :我们在做什么?
在上一代程序设计(称为过程化设计)中,这被称为“创建需求分析和系统规格说明”这些当然是容易迷路的地方;名字令人生畏的文件本身就可能成为大项目。然而,本意是好的。
需求分析包括列出一系列指导方针,用于了解工作何时完成以及客户何时满意。系统规范是对程序将做什么(而不是如何做)来满足需求的描述。需求分析实际上是你和客户之间的一个契约(即使客户在你的公司工作,或者是一些其他的对象或系统)。系统规范是对问题的顶级探索,在某种意义上是对是否可以完成以及需要多长时间的发现。因为这两者都需要人们达成共识(而且因为它们通常会随着时间的推移而改变),所以通常最好是让它们尽可能的简洁——最好是列表和基本图表——以节省时间。您可能有其他约束条件,要求您将它们扩展成更大的文档,但是通过保持初始文档小而简洁,它可以在领导者的几次小组头脑风暴中创建,领导者可以动态地创建描述。这不仅会征求每个人的意见,还会培养团队中每个人最初的认同和同意。也许最重要的是,它能以极大的热情启动一个项目。
在这个阶段,有必要把注意力集中在你要完成的核心任务上:确定系统应该做什么。最有价值的工具是所谓的用例的集合。用例识别系统中的关键特性,这些特性将揭示你将要使用的一些基本类。这些基本上是对一些问题的描述性回答,比如:
- “谁会用这个系统?”
- "那些演员能用这个系统做什么?"
- "这个演员是如何用这个系统做到的?"
- “如果其他人这么做,或者如果同一个演员有不同的目标,这怎么可能行得通?”(揭示变化)
- "用系统做这些可能会发生什么问题?"(揭示例外情况)
例如,如果你正在设计一个自动柜员机,系统功能的一个特定方面的用例能够描述自动柜员机在每一种可能的情况下做什么。这些情况中的每一种都被称为场景,一个用例可以被认为是场景的集合。你可以把一个场景想象成一个问题,这个问题以“如果...?"例如,如果客户在 24 小时内刚存入一张支票,但没有支票,账户中没有足够的钱来提供所需的取款,自动柜员机会怎么做?
图 1-10 中的用例图是为了防止你过早地陷入系统实现的细节中。
图 1-10 。自动柜员机(ATM)用例图
每个 stick person 代表一个 actor ,它通常是一个人或某种其他类型的自由代理。(这些甚至可以是其他计算机系统,就像自动取款机一样。)方框代表你系统的边界。省略号代表用例,这些用例描述了系统可以执行的有价值的工作。参与者和用例之间的线代表了交互。
系统实际上是如何实现的并不重要,只要对用户来说是这样的。
一个用例不需要非常复杂,即使底层系统很复杂。它仅用于向用户展示系统。例如,图 1-11 显示了一个简单的用例图。
图 1-11 。展示了底层复杂系统的简单用例图
用例通过确定用户可能与系统的所有交互来产生需求规格。你试图为你的系统发现一套完整的用例,一旦你完成了,你就有了系统应该做什么的核心。关注用例的好处在于,它们总是把你带回到本质上,并且防止你偏离到对完成工作不重要的问题上。也就是说,如果你有一套完整的用例,你就可以描述你的系统并进入下一个阶段。第一次尝试时,你可能无法完全理解,但没关系。一切都会及时显露出来,如果此时你要求一个完美的系统规范,你会被卡住。
如果你被卡住了,你可以使用一个粗略的近似工具来启动这个阶段:用几个段落描述这个系统,然后寻找名词和动词。名词可以暗示参与者、用例的上下文(例如,“大厅”),或者用例中操作的工件。动词可以暗示参与者和用例之间的交互,并指定用例中的步骤。您还会发现名词和动词在设计阶段产生对象和消息(注意用例描述子系统之间的交互,因此名词和动词技术只能用作头脑风暴工具,因为它不产生用例)。
用例与参与者之间的边界可以指出用户界面的存在,但它并没有定义这样的用户界面。现在你已经对你正在构建的东西有了一个大概的了解,所以你可能会知道这需要多长时间。这里有很多因素在起作用。如果你估计一个很长的时间表,公司可能会决定不建立它(从而将他们的资源用在更合理的事情上——这是一件好事情)。或者经理可能已经决定了项目需要多长时间,并试图影响你的估计。但是最好从一开始就有一个诚实的时间表,并尽早处理艰难的决定。已经有很多尝试想出准确的调度技术(像预测股票市场的技术),但可能最好的方法是依靠你的经验和直觉。凭直觉判断到底需要多长时间,然后加倍,再加 10%。你的直觉可能是正确的;你可以在这段时间内找到工作。“加倍”将把它变成体面的东西,10%将处理最后的抛光和细节。无论你想如何解释它,不管当你透露这样一个时间表时会发生什么样的抱怨和操纵,它似乎就是那样工作的。
第二阶段:我们将如何建造它?
在这一阶段,你必须想出一个设计来描述这些类看起来像什么,以及它们将如何交互。确定类别和交互的一个优秀技术是类别责任协作 (CRC) 卡片。这个工具的部分价值在于它的技术含量很低:你从一套 3×5 的空白卡片开始,然后在上面写字。每张卡片代表一个班级,在卡片上写下以下内容:
- 类的名称。重要的是这个名字抓住了类的本质,这样一看就明白了。
- 班级的“职责”——应该做什么。这通常可以通过陈述成员函数的名称来总结(因为在一个好的设计中,这些名称应该是描述性的),但这并不排除其他注释。如果你需要播种这个过程,从一个懒惰的程序员的角度来看这个问题。你希望有什么东西神奇地出现来解决你的问题?
- 类的“协作”:它还与其他什么类交互?“互动”是一个有意宽泛的术语;它可能意味着聚合,或者仅仅是某个其他对象的存在,它将为该类的某个对象执行服务。合作也应该考虑这门课的观众。例如,如果你创建了一个类
Firecracker,谁来观察它,一个Chemist还是一个Spectator?前者会想知道什么化学物质进入建筑,后者会对爆炸时释放的颜色和形状做出反应。
你可能会觉得卡片应该更大,因为你想在上面得到所有的信息,但它们是故意小的,不仅是为了保持你的班级小,也是为了防止你过早进入太多的细节。如果你不能在一张小卡片上写下你需要知道的关于一个类的所有内容,那么这个类就太复杂了(要么你写得太详细,要么你应该创建多个类)。理想的班级应该是一眼就能看懂的。CRC 卡的想法是帮助你完成设计的第一步,这样你就可以了解整体情况,然后优化你的设计。
CRC 卡的一大好处就是通信。最好是在没有电脑的情况下,在一个小组中实时完成。每个人负责几个班级(最初没有名字或其他信息)。您通过一次解决一个场景来运行实时模拟,决定将哪些消息发送到不同的对象以满足每个场景。当你经历这个过程时,你会发现你需要的类以及它们的职责和协作,当你这样做时,你会填写卡片。当你完成了所有的用例,你应该对你的设计有一个相当完整的第一次切割。
在我开始使用 CRC 卡之前,我在提出初始设计时最成功的咨询经历是站在一个以前没有构建过 OOP 项目的团队面前,在白板上绘制对象。我们讨论了对象之间应该如何通信,并删除了其中的一些对象,用其他对象替换它们。实际上,CRC 卡是在白板上管理的。团队(知道项目应该做什么)实际上创建了设计;他们“拥有”设计,而不是别人给他们设计。所有需要做的就是通过问正确的问题来指导这个过程,尝试这些假设,并从团队获得反馈来修改这些假设。这个过程的真正美妙之处在于,团队学会了如何进行面向对象的设计,而不是通过回顾抽象的例子,而是通过致力于他们当时最感兴趣的一个设计:他们自己的设计。
一旦你有了一套 CRC 卡,你可能想用 UML 为你的设计创建一个更正式的描述。你不需要使用 UML,但是它会很有帮助,特别是如果你想在墙上贴一个图表让大家思考,这是一个好主意。UML 的另一种选择是对象及其接口的文本描述,或者是代码本身,这取决于您的编程语言。
UML 还为描述系统的动态模型提供了额外的图表符号。这在系统或子系统的状态转换占主导地位,需要它们自己的图的情况下(例如在控制系统中)很有帮助。您可能还需要描述数据占主导地位的系统或子系统(如数据库)的数据结构。
当你描述完对象及其接口后,你就知道第二阶段已经完成了。好吧,大部分都是——通常会有一些漏网之鱼,直到第三阶段才被发现。不过没关系。你所关心的是你最终会发现你所有的物品。在过程的早期发现它们是很好的,但是 OOP 提供了足够的结构,所以如果你后来发现它们也不是那么糟糕。事实上,在整个程序开发过程中,一个对象的设计往往发生在五个阶段。
对象设计的五个阶段
一个对象的设计寿命并不局限于你写程序的时候。相反,一个对象的设计出现在一系列的阶段。拥有这种观点是有帮助的,因为你会立刻停止期待完美;相反,你意识到对一个物体做什么和它应该是什么样子的理解是随着时间而发生的。这个观点也适用于各种类型程序的设计;一个特定类型的程序的模式是通过一次又一次地与那个问题进行斗争而出现的。对象也有它们的模式,这些模式是通过理解、使用和重用形成的。
- 物体发现。 *这个阶段发生在程序的初始分析期间。*通过寻找外部因素和边界、系统中元素的重复以及最小的概念单元,可以发现对象。如果你已经有了一套类库,有些对象是显而易见的。暗示基类和继承的类之间的共同性可能马上出现,或者在设计过程的后期出现。
- 对象组装。 当你在构建一个对象时,你会发现需要一些在发现过程中没有出现的新成员。对象的内部需求可能需要其他类来支持它。
- 系统建设。 再一次,对一个对象的更多需求可能出现在这个后期。当你学习时,你进化你的对象。与系统中的其他对象进行通信和互连的需求可能会改变您的类的需求,或者需要新的类。例如,您可能会发现需要包含很少或没有状态信息的辅助类,如链表,它们只是帮助其他类运行。
- 系统扩展。 当您向系统添加新功能时,您可能会发现您以前的设计不支持简单的系统扩展。有了这些新信息,您就可以重新构建系统的各个部分,可能会添加新的类或类层次结构。
- 对象重用。 这是一个班级真正的压力测试。如果有人试图在一个全新的环境中重用它,他们可能会发现一些缺点。随着你改变一个类来适应更多的新程序,这个类的一般原理会变得更加清晰,直到你有了一个真正可重用的类型。然而,不要期望系统设计中的大多数对象是可重用的;您的大部分对象是系统特定的,这是完全可以接受的。可重用类型往往不太常见,为了可重用,它们必须解决更一般的问题。
对象开发指南
当考虑开发你的类时,这些阶段提供了一些指导方针。
- 让一个具体的问题生成一个类,然后让这个类在其他问题的解决过程中成长成熟。
- **记住,发现你需要的类(和它们的接口)**是系统设计的主要部分。如果你已经有了这些类,这将是一个简单的项目。
- 不要一开始就强迫自己什么都知道;边走边学。这无论如何都会发生。
- 开始编程;让一些东西工作起来,这样你就可以证明或否定你的设计。不要担心你最终会得到过程式的意大利面条式代码——类将问题分开,有助于控制混乱和熵。坏课不会破坏好课。
- 永远保持简单。具有明显效用的小而干净的对象比大而复杂的接口要好。当决策点出现时,考虑选择并选择最简单的一个,因为简单的类几乎总是最好的。从小而简单开始,当你更好地理解它时,你可以扩展类接口,但是随着时间的推移,很难从类中移除元素。
阶段 3:构建核心
这是从粗略设计到可测试的编译和执行代码体的最初转换,尤其是证明或否定你的架构。这不是一个一次性的过程,而是迭代构建系统的一系列步骤的开始,正如您将在阶段 4 中看到的。
*你的目标是找到需要实现的系统架构的核心,以便生成一个可运行的系统,*不管这个系统在最初阶段是多么不完整。您正在创建一个框架,您可以在此基础上进行进一步的迭代。您还执行许多系统集成和测试中的第一个,并向涉众反馈他们的系统将会是什么样子以及进展如何。理想情况下,你也暴露了一些关键的风险。您可能还会发现可以对您的原始体系结构进行的更改和改进——如果不实现该系统,您将不会学到这些东西。
构建系统的一部分是你从需求分析和系统规范(无论以什么形式存在)的测试中得到的现实检查。确保您的测试验证了需求和用例。当系统的核心稳定后,您就可以继续前进并添加更多的功能了。
阶段 4:迭代用例
一旦核心框架开始运行,您添加的每个特性集本身就是一个小项目。您在迭代期间添加一个特性集,这是一个相当短的开发周期。
一次迭代有多大?理想情况下,每次迭代持续一到三周(这可以根据实现语言而变化)。在这一阶段结束时,您将拥有一个集成的、经过测试的系统,比以前拥有更多的功能。但是特别有趣的是迭代的基础:单一用例。每一个用例都是一个相关功能的包,你可以在一次迭代中一次性构建到系统中,*。*这不仅让你对用例的范围有了更好的了解,也让你对用例的概念有了更多的确认,因为这个概念在分析和设计之后并没有被抛弃,相反,它是整个软件构建过程中的一个基本开发单元。
当您实现目标功能或者外部截止日期到来,并且客户对当前版本满意时,您停止迭代。(记住,软件是订阅业务。)因为流程是迭代的,所以你有很多机会去出货一个产品,而不是一个单一的端点;开源项目只在迭代、高反馈的环境中工作,这正是它们成功的原因。
由于许多原因,迭代开发过程是有价值的。您可以在早期发现并解决关键风险,客户有充分的机会改变他们的想法,程序员的满意度更高,项目可以更精确地进行。但是一个额外的重要好处是对涉众的反馈,他们可以通过产品的当前状态准确地看到所有东西在哪里。这可能会减少或消除对令人麻木的状态会议的需求,并增加风险承担者的信心和支持。
第五阶段:进化
这是开发周期中传统上被称为维护的点,这是一个包罗万象的术语,可以指从“让它按照最初真正应该的方式工作”到“添加客户忘记提到的功能”再到更传统的“修复出现的错误”和“根据需要添加新功能”的一切如此多的误解被应用到术语“维护”上,以至于它有了一点欺骗的性质,部分是因为它表明你实际上已经建立了一个原始的程序,你需要做的只是更换部件,给它上油,并防止它生锈。也许有一个更好的术语来描述正在发生的事情。
让我们用术语 进化 。换句话说,你不会第一次就做对,所以给自己学习和回头做出改变的自由。随着你学习和更深入地理解这个问题,你可能需要做很多改变。无论是从短期还是长期来看,如果你进化到正确的程度,你所产生的优雅将会得到回报。演进是你的程序从优秀走向卓越的地方,在那里你在第一遍中没有真正理解的问题变得清晰。这也是您的类可以从单一项目用途发展为可重用资源的地方。
“做对”的含义不仅仅是程序根据需求和用例工作。这还意味着代码的内部结构对您来说是有意义的,并且感觉它很好地组合在一起,没有笨拙的语法、过大的对象或笨拙的代码暴露位。此外,您必须有某种感觉,程序结构将在它的生命周期中不可避免地经历变化,并且这些变化可以容易和干净地进行。这不是一个小壮举。你不仅要明白你在构建什么,还要明白程序将如何发展。幸运的是,面向对象的编程语言特别擅长支持这种持续的修改——由对象创建的边界有助于防止结构崩溃。它们还允许您进行更改——那些在过程化程序中看起来很激烈的更改——而不会在整个代码中引起地震。事实上,对进化的支持可能是 OOP 最重要的好处。
*随着进化,你创造出至少接近你认为你正在建造的东西,*然后你踢踢轮胎,把它和你的需求比较,看看它在哪里有不足。然后你可以回头通过重新设计和重新实现程序中不能正常工作的部分来修复它。在你找到正确的解决方案之前,你可能真的需要解决这个问题,或者问题的一个方面,好几次。
当你建立了一个系统,看到它符合你的需求,然后发现它实际上不是你想要的,进化也会发生。当你看到系统运行时,你会发现你真的想解决一个不同的问题。如果你认为这种进化将会发生,那么你应该尽可能快地构建你的第一个版本,这样你就可以发现它是否确实是你想要的。
也许要记住的最重要的事情是,默认情况下——根据定义,真的——如果你修改了一个类,那么它的超类和子类将仍然起作用。您不必害怕修改(,尤其是如果您有一组内置的单元测试来验证您的修改的正确性)。修改不一定会破坏程序,任何结果的改变都将局限于你所改变的类的子类和/或特定的合作者。
计划有回报
没有大量精心绘制的平面图,你是不会建造房子的。如果你建造一个甲板或狗屋,你的计划不会如此详细,但你可能仍然会从某种草图开始,以指导你前进。
软件开发已经走向了极端。很长一段时间,人们在开发中没有太多的结构,但后来大型项目开始失败。作为回应,我们最终得到了具有令人生畏的大量结构和细节的方法,主要用于那些大型项目。这些方法学使用起来太可怕了——看起来你会把所有的时间都花在编写文档上,而没有时间编程。
但是,通过遵循一个计划(最好是一个简单而简短的计划)并在编码之前提出设计结构,你会发现事情比你一头扎进去开始黑客攻击要容易得多,而且你也会意识到很大的满足感。根据我的经验,想出一个优雅的解决方案在完全不同的层面上是非常令人满意的;感觉更接近艺术而不是科技。而优雅总是有回报的;不是轻浮的追求。它不仅让您的程序更容易构建和调试,而且也更容易理解和维护,这就是财务价值所在。
极限编程
在所有的分析和设计技术中,极限编程 ( XP )的概念是最激进的,也是最令人愉快的。XP 既是一种关于编程工作的哲学,也是一套做编程工作的指导方针。这些指导方针中的一些反映在最近的其他方法中,但是两个最重要和不同的贡献是先写测试和成对编程。
首先编写测试
传统上,测试被放在项目的最后一部分,在你“做好一切工作,但只是为了确保万无一失”之后它的优先级很低,专门从事这方面工作的人没有得到很多地位,甚至经常被隔离在地下室里,远离“真正的程序员”测试团队也以同样的方式回应,甚至穿着黑色的衣服,每当他们打破了什么东西就高兴地咯咯笑。
XP 通过赋予测试与代码同等(甚至更高)的优先权,彻底革新了测试的概念。事实上,你在编写被测试的代码之前编写测试*,测试将永远伴随着代码。每次您进行项目集成时,测试都必须成功执行(这通常是——有时一天不止一次)。*
首先编写测试有两个极其重要的作用。 首先,它强制定义了一个类的接口。XP 测试策略比这更进一步——它确切地指定了对于该类的消费者来说该类必须是什么样子,以及该类必须如何行为。你可以写所有的散文或者创建所有你想要的图表来描述一个类应该如何表现以及它看起来像什么,但是没有什么比一组测试更真实的了。前者是一个愿望清单,但是测试是一个由编译器和运行程序强制执行的契约。很难想象有比测试更具体的描述了。
在创建测试时,你被迫完全思考出类,并且经常会发现在 UML 图、CRC 卡、用例*、*等的思考实验中可能遗漏的所需功能。
编写测试的第二个重要影响来自于每次构建软件时运行测试。该活动为您提供了由编译器执行的另一半测试。如果你从这个角度来看编程语言的发展,你会发现技术的真正进步实际上是围绕着测试的。汇编语言只检查语法,但是 C 语言施加了一些语义限制,这些限制防止你犯某些类型的错误。OOP 语言强加了更多的语义限制,如果你仔细想想,这实际上是测试的形式。“这种数据类型使用正确吗?该函数是否被正确调用?”是由编译器或运行时系统执行的测试种类。
我们已经看到了将这些测试嵌入语言的结果:人们已经能够用更少的时间和精力编写更复杂的系统,并让它们工作。但是语言设计所提供的内置测试只能到此为止。在某些时候,你必须介入并添加剩余的测试,产生一个完整的套件(与编译器和运行时系统合作)来验证你的整个程序。而且,就像有一个编译器在你身后监视一样,难道你不想让这些测试从一开始就帮助你吗?这就是为什么您首先编写它们,并在每次构建系统时自动运行它们。你的测试成为语言提供的安全网的延伸。
使用越来越强大的编程语言会鼓励并允许你尝试更大胆的实验,因为这种语言会让你避免浪费时间去追踪 bug。XP 测试方案对你的整个项目做同样的事情。因为您知道您的测试将总是捕捉您引入的任何问题(并且当您想到它们时,您会定期添加任何新的测试),所以您可以在需要时进行大的更改,而不用担心您会将整个项目完全打乱。这是难以置信的强大。
结对编程
结对编程违背了我们从一开始就被灌输的个人主义。程序员也被认为是个性的典范。然而,XP 本身也在与传统思维作斗争,它说每个工作站应该有两个人来编写代码。这应该在有一组工作站的区域进行,没有设施设计人员喜欢的障碍。
结对编程的价值在于,一个人实际上在做编码,而另一个人在思考。思考者将大局记在心里,不仅仅是手头问题的图景,而是 XP 的指导方针。例如,如果两个人一起工作,其中一个不太可能说“我不想先写测试”。如果编码员卡住了,他们可以交换位置。如果他们两个都被卡住了,他们的想法可能会被工作区里能提供帮助的其他人听到。两人一组工作让事情顺利进行。或许更重要的是,它让编程变得更加社会化和有趣。
C++ 为什么成功
C++ 如此成功的部分原因是,它的目标不仅仅是将 C 变成面向对象的语言(尽管它是以这种方式开始的),还解决了开发人员今天面临的许多其他问题,尤其是那些在 C 语言上有大量投资的人。传统上,OOP 语言受到这样一种态度的影响,即你应该放弃你所知道的一切,从零开始,使用一套新的概念和新的语法,认为从长远来看,最好丢掉过程化语言带来的所有旧包袱。从长远来看,这可能是真的。但从短期来看,许多包袱是有价值的。最有价值的元素可能不是现有的代码库(如果有足够的工具,可以翻译),而是现有的思维库。如果你是一个正常工作的 C 程序员,为了采用一种新的语言,你必须放弃你所知道的关于 C 的一切,你会立刻在几个月内变得效率低下,直到你的头脑适应新的范例。然而,如果您能够利用现有的 C 语言知识并对其进行扩展,那么在进入面向对象编程的世界时,您可以继续使用您已经知道的知识进行生产。由于每个人都有他或她自己的编程心理模型,这一步已经够乱的了,因为没有从头开始一个新的语言模型的额外费用。所以 C++ 成功的原因,简而言之,是经济上的:转移到面向对象程序设计仍然需要成本,但 C++ 的成本可能会更低。
C++ 的目标是提高生产力。这种生产力来自许多方面,但这种语言旨在尽可能多地帮助您,同时尽可能少地妨碍您使用任意规则或任何要求您使用特定功能集的要求。C++ 的设计是为了实用;C++ 语言设计决策的基础是为程序员提供最大的好处(至少从 C 的角度来看)。
更好的 C
即使您继续编写 C 代码,您也能立即获得成功,因为 C++ 已经弥补了 C 语言中的许多漏洞,并提供了更好的类型检查和编译时分析。你被迫声明函数,这样编译器可以检查它们的使用。对于值替换和宏来说,对预处理器的需求实际上已经消除了,这消除了一系列难以发现的错误。C++ 有一个叫做引用的特性,它允许更方便地处理函数参数和返回值的地址。名字的处理通过一个叫做函数重载的特性得到了改进,它允许你对不同的函数使用相同的名字。名为 namespaces 的特性也改进了对名字的控制。还有许多更小的功能可以提高 c 的安全性
你已经在学习曲线上了
学习一门新语言的问题在于效率。没有一家公司能够承受因为学习一门新语言而突然失去一名高效的软件工程师。C++ 是对 C 的扩展,而不是全新的语法和编程模型。它允许您继续创建有用的代码,随着您学习和理解它们,逐渐应用这些特性。这可能是 C++ 成功的最重要的原因之一。
此外,所有现有的 C 代码在 C++ 中仍然是可行的,但是因为 C++ 编译器更挑剔,所以在用 C++ 重新编译代码时,您经常会发现隐藏的 C 错误。
效率
有时候,用执行速度换取程序员的生产力是合适的。例如,一个财务模型可能只在短时间内有用,因此快速创建模型比快速执行模型更重要。然而,大多数应用程序都需要一定程度的效率,所以 C++ 总是偏向于更高的效率。因为 C 程序员倾向于非常注重效率,这也是一种确保他们不会认为语言太胖太慢的方法。C++ 中的许多特性旨在允许您在生成的代码不够高效时进行性能调优。
您不仅拥有与 C 中相同的低级控制能力(以及在 C++ 程序中直接编写汇编语言的能力),而且轶事证据表明,面向对象的 C++ 程序的编程速度往往与用 C 编写的程序相差不到 10%,甚至更接近。面向对象程序的设计实际上可能比 C 语言程序更有效。
系统更容易表达和理解
为适应问题而设计的课程往往能更好地表达问题。这意味着当你写代码时,你是在用问题空间的术语描述你的解决方案,而不是用计算机的术语,计算机是解决方案空间。您处理更高级的概念,并且可以用一行代码做更多的事情。
易于表达的另一个好处是维护,这在程序的生命周期中占据了很大一部分成本。如果一个程序更容易理解,那么它就更容易维护。这也可以降低创建和维护文档的成本。
最大限度地利用图书馆
创建程序最快的方法是使用已经写好的代码:一个库。*c++ 的一个主要目标是使库的使用更容易。*这是通过将库转换成新的数据类型(类)来实现的,因此引入库意味着向语言中添加新的类型。因为 C++ 编译器负责处理库的使用方式——保证正确的初始化和清理,并确保正确调用函数——所以您可以专注于希望库做什么,而不是必须如何做。
因为名字可以通过 C++ 命名空间隔离到程序的各个部分,所以您可以使用任意多的库,而不会遇到 C 中的名字冲突。
模板的源代码重用
有一类重要的类型需要修改源代码才能有效地重用它们。C++ 中的模板特性自动执行源代码修改,使其成为重用库代码的特别强大的工具。使用模板设计的类型将毫不费力地与许多其他类型一起工作。模板特别好,因为它们对客户程序员隐藏了这种代码重用的复杂性。
错误处理
C 中的错误处理是一个臭名昭著的问题,也是一个经常被忽视的问题;手指交叉通常涉及。如果你正在构建一个大型复杂的程序,没有什么比把一个错误埋在某个地方而不知道它来自哪里更糟糕的了。 C++ 异常处理(本章介绍,后面的第十七章已经提到过了)是一种保证错误被注意到并且结果发生的方式。
大型编程
许多传统语言对程序的大小和复杂性有内在的限制。例如,BASIC 可以很好地为某些类型的问题提供快速的解决方案,但是如果程序超过几页或者超出了该语言的正常问题范围,就像试图在越来越粘稠的液体中游泳一样。c 也有这些限制。例如,当一个程序超过 50,000 行代码时,名字冲突就开始成为一个问题——实际上,你用完了函数和变量名。另一个特别糟糕的问题是 C 语言中的小漏洞——隐藏在大型程序中的错误很难被发现。
没有明确的界限告诉你什么时候你的语言让你失望了,即使有,你也会忽略它。你不会说,“我的 BASIC 程序变得太大了;我只好用 C 语言重写了!”相反,你试图硬塞几行代码来增加一个新特性。因此,额外的成本会悄悄逼近你。
C++ 的设计是为了帮助进行大范围的编程——也就是说,消除小程序和大程序之间的复杂界限。当您编写 Hello,World 类型的实用程序时,您当然不需要使用 OOP、模板、名称空间和异常处理,但是当您需要它们时,这些特性就在那里。编译器积极地为大大小小的程序找出产生错误的错误。
过渡战略
如果你购买 OOP,你的下一个问题可能是,“我如何让我的经理/同事/部门/同事开始使用对象?”想想你——一个独立的程序员——将如何着手学习使用一门新的语言和一种新的编程范式。你以前做过。首先是教育和榜样;接下来是一个试验项目,让你在不做任何令人困惑的事情的情况下对基础有所了解。然后,一个真正有用的项目出现了。在你的第一个项目中,你通过阅读、向专家提问和与朋友交换提示来继续你的教育。这是许多有经验的程序员建议的从 C 转换到 C++ 的方法。更换整个公司当然会引入一定的团队动力,但在每一步都要记住一个人会怎么做。
指导方针
这里有一些向 OOP 和 C++ 过渡时要考虑的指导方针。
培养
第一步是某种形式的教育。记住公司在纯 C 代码上的投资,在六到九个月的时间里,当每个人都在困惑多重继承是如何工作的时候,尽量不要把一切都搞乱。选择一个小团体进行灌输,最好是由好奇的人组成的团体,他们可以很好地合作,并且在学习 C++ 时可以作为他们自己的支持网络。
有时建议的另一种方法是同时对所有公司级别进行教育,包括战略经理的概述课程以及项目建设者的设计和编程课程。这对于那些正在从根本上改变做事方式的小公司,或者大公司的部门来说尤其有利。然而,因为成本更高,一些人可能选择从项目级培训开始,做一个试点项目(可能有外部导师),让项目团队成为公司其他人的老师。
低风险项目
首先尝试一个低风险的项目,并允许出现错误。一旦你获得了一些经验,你就可以从第一个团队的成员那里播种其他项目,或者使用团队成员作为 OOP 技术支持人员。第一个项目可能第一次就不能正常工作,所以它对公司来说不应该是关键任务。它应该是简单的、独立的、有指导意义的;这意味着它应该包括创建对公司其他程序员学习 C++ 有意义的类。
成功的典范
从头开始之前,找出好的面向对象设计的例子。很有可能有人已经解决了你的问题,如果他们还没有完全解决,你可以应用你所学到的抽象来修改现有的设计以满足你的需求。
使用现有类库
转向 OOP 的主要经济动机是类库形式的现有代码的简单使用,特别是标准的 C++ 库。当您除了main( )之外什么都不需要写,从现成的库中创建和使用对象时,最短的应用程序开发周期就会产生。然而,一些新程序员不理解这一点,不知道现有的类库,或者通过对语言的迷恋,渴望编写可能已经存在的类。如果您在转换过程的早期努力寻找并重用其他人的代码,那么您在 OOP 和 C++ 方面的成功将会得到优化。
不要用 C++ 重写现有代码
虽然用 C++ 编译器编译你的 C 代码通常会通过发现旧代码中的问题而产生(有时是巨大的)好处,但是利用现有的功能代码并用 C++ 重写它通常不是最好的方式。(如果一定要把它变成对象,可以把 C 代码“包装”在 C++ 类里。)好处越来越多,尤其是当代码被重用时。但是,除非是一个新项目,否则在最初的几个项目中,你可能看不到你所希望的生产率的显著提高。当把一个项目从概念变成现实时,C++ 和 OOP 表现得最好。
管理障碍
如果你是一名经理,你的工作是为你的团队获取资源,克服团队成功的障碍,并努力提供最有成效和最愉快的环境,这样你的团队最有可能实现那些总是要求你的奇迹。迁移到 C++ 属于这三种类型,如果它不需要你付出任何代价,那就太好了。尽管对于一个 C 程序员团队来说,迁移到 C++ 可能比面向对象编程更便宜(取决于您的约束条件),但*它不是免费的,*在试图在您的公司内部推销迁移到 C++ 并着手进行迁移之前,您应该知道一些障碍。
启动成本
迁移到 C++ 的成本不仅仅是购买 C++ 编译器(最好的编译器之一 GNU C++ 编译器是免费的)。如果你投资于培训(也可能是指导你的第一个项目),如果你发现并购买能解决你的问题的类库,而不是试图自己构建这些类库,你的中长期成本将会最小化。这些是硬货币成本,必须纳入现实的提案中。此外,在学习一门新语言和可能的新编程环境时,生产力的损失也是隐性成本。培训和指导当然可以减少这些,但是团队成员必须克服自己的困难去理解新技术。在这个过程中,他们会犯更多的错误(这是一个特点,因为承认错误是学习的最快途径)并且效率更低。即使这样,有了一些类型的编程问题、正确的类和正确的开发环境,你学习 C++ 的时候也有可能比继续学习 c++ 更有效率(即使考虑到你犯的错误更多,每天写的代码更少)
性能问题
一个常见的问题是,“面向对象程序设计不会自动让我的程序变得更大更慢吗?”答案是,“看情况。”大多数传统的 OOP 语言在设计时都考虑了实验和快速原型,而不是精益和平均操作。因此,它们实际上保证了尺寸的显著增加和速度的降低。然而,C++ 是为生产编程而设计的。当您关注快速原型时,您可以在忽略效率问题的同时尽可能快地组装组件。如果你使用任何第三方库,这些库通常已经被他们的供应商优化过了;在任何情况下,当您处于快速开发模式时,这都不是问题。当你有一个你喜欢的系统,如果它够小够快,那么你就完成了。如果没有,您可以开始用一个分析工具进行调优,首先寻找可以用内置 C++ 特性的简单应用程序实现的加速。如果这没有帮助,您可以在底层实现中寻找可以进行的修改,这样就不需要更改使用特定类的代码。只有在没有其他办法解决问题的情况下,你才需要改变设计。性能在这部分设计中如此重要,这表明它必须是主要设计标准的一部分。您可以通过快速开发尽早发现这一点。
几乎普遍地,从 C(或其他一些过程化语言)迁移到 C++(或其他一些面向对象的语言)的程序员都有编程效率大大提高的亲身经历,这是你能找到的最有说服力的论据。
常见设计错误
当你的团队开始使用 OOP 和 C++ 时,程序员通常会经历一系列常见的设计错误。之所以经常出现这种情况,是因为在早期项目的设计和实施过程中,专家的反馈太少,因为公司内部没有培养出专家,可能会有挽留顾问的阻力。人们很容易觉得自己在周期中过早地理解了 OOP,并偏离了正确的方向。对于语言老手来说显而易见的事情对于新手来说可能是一个内部争论的话题。通过使用经验丰富的外部专家进行培训和指导,可以避免这种创伤。
另一方面,容易犯这些设计错误的事实指向了 C++ 的主要缺点:它与 C 的向后兼容性(当然这也是它的主要优势)。为了完成能够编译 C 代码的壮举,该语言必须做出一些妥协,这导致了许多“黑暗角落”这些都是事实,并且组合了语言学习曲线的一部分。
审查会议
- 本章试图让你对面向对象编程和 C++ 的广泛问题有一个感觉,包括为什么 OOP 是不同的,特别是为什么 C++ 是不同的,OOP 方法的概念,以及最后当你把你自己的公司转移到 OOP 和 C++ 时你会遇到的各种问题。
- OOP 和 C++ 可能并不适合所有人。评估您自己的需求并决定 C++ 是否能最好地满足这些需求,或者如果您使用另一个编程系统(包括您目前正在使用的系统)可能会更好,这一点很重要。如果你知道你的需求在可预见的将来会非常专门化,并且如果你有 C++ 可能无法满足的特定约束,那么你就应该自己去研究替代方案。即使你最终选择 C++ 作为你的语言,你至少会明白有哪些选择,并且清楚地知道你为什么选择这个方向。
- 你知道过程化程序是什么样子:数据定义和函数调用。为了找到这样一个程序的意义,你必须做一些工作,浏览函数调用和底层概念,在你的头脑中创建一个模型。这就是我们在设计过程程序时需要中间表示的原因——就其本身而言,这些程序往往令人困惑,因为表达的术语更倾向于计算机,而不是你正在解决的问题。
- 因为 C++ 向 C 语言中添加了许多新概念,所以您自然会认为 C++ 程序中的
main( )会比同等的 C 程序复杂得多。在这里,你会惊喜地发现:一个编写良好的 C++ 程序通常比同等的 C 程序简单得多,也容易理解得多。您将看到的是代表您的问题空间中的概念的对象的定义(而不是计算机表示的问题),以及发送到这些对象以代表该空间中的活动的消息。面向对象编程的乐趣之一是,对于一个设计良好的程序来说,通过阅读它很容易理解代码。通常代码也要少得多,因为许多问题都可以通过重用现有的库代码来解决。
二、创建和使用对象
本章将介绍足够的 C++ 语法和程序构造概念,让你能够编写和运行一些简单的面向对象的程序。在下一章,我将详细介绍“C++ 中的 C ”的基本语法。
首先阅读这一章,你将获得用 C++ 对象编程的基本感觉,你也会发现围绕这种语言的热情的一些原因。这应该足以让你看完第三章 ,这可能有点累,因为它包含了 C 语言的大部分细节,这些细节在 C++ 中也是可用的。
用户定义的数据类型或类是 C++ 区别于传统过程语言的地方。 类是一种新的数据类型,你或其他人创建它来解决一种特殊的问题。一旦创建了一个类,任何人都可以使用它,而不需要知道它是如何工作的,甚至不需要知道类是如何构建的。本章将类视为程序中可用的另一种内置数据类型。
其他人创建的类通常被打包成一个库。本章使用了所有 C++ 实现中的几个类库。一个特别重要的标准库是iostream,它(和其他的)允许你从文件和键盘中读取,并向文件和显示器中写入。您还会看到非常方便的string类和来自标准 C++ 库的vector容器。在本章结束时,你会看到使用一个预定义的类库是多么容易。
为了创建您的第一个程序,您必须了解用于构建应用程序的工具。
语言翻译的过程
所有的计算机语言都是从人类易于理解的东西(源代码)翻译成计算机可执行的东西(机器指令)。传统上,译者分为两类:口译员和编译员。
解释程序
解释器将源代码翻译成活动(可能包括多组机器指令),并立即执行这些活动。例如,BASIC 已经成为一种流行的解释语言。传统的 BASIC 解释器一次翻译并执行一行,然后忘记这一行已经被翻译了。这使得他们很慢,因为他们必须重新翻译任何重复的代码。BASIC 也被编译过,为了速度。更现代的解释器,如 Python 语言的解释器,将整个程序翻译成中间语言,然后由更快的解释器执行。
口译员有很多优势。从编写代码到执行代码的转变几乎是即时的,并且源代码总是可用的,因此当错误发生时解释器可以更加具体。解释者经常提到的好处是易于交互和快速开发(但不一定是执行)程序。
在构建大型项目时,解释语言通常有严重的局限性(Python 似乎是个例外)。解释器(或简化版)必须一直在内存中执行代码,即使是最快的解释器也可能引入不可接受的速度限制。大多数解释器要求将完整的源代码一次全部带入解释器。这不仅引入了空间限制,如果语言不提供本地化不同代码段效果的工具,还会导致更困难的错误。
编译程序
编译器将源代码直接翻译成汇编语言或机器指令。最终产品是一个或多个包含机器代码的文件。这是一个复杂的过程,通常需要几个步骤。使用编译器,从编写代码到执行代码的过渡时间要长得多。
根据编译程序编写者的敏锐度,编译器生成的程序需要更少的空间来运行,并且运行得更快。尽管大小和速度可能是使用编译器最常引用的原因,但在许多情况下,它们不是最重要的原因。一些语言(如 C )被设计成允许程序的各个部分被独立编译。这些片段最终被一个叫做链接器的工具组合成一个最终的可执行程序。这个过程叫做单独编译。
单独编译有很多好处。一个程序,一下子就超过了编译器或编译环境的极限,可以被分段编译。程序可以一次构建和测试一部分。一旦一个部分开始工作,它就可以被保存并作为一个构建模块。被测试和工作的部分的集合可以被合并到库中,供其他程序员使用。当每个部分被创建时,其他部分的复杂性被隐藏了。所有这些特性都支持创建大型程序。
随着时间的推移,编译器调试功能已经有了显著的改进。早期的编译器只生成机器码,程序员插入 print 语句,看看是怎么回事。这并不总是有效。现代编译器可以将有关源代码的信息插入到可执行程序中。强大的源代码级调试器使用这些信息,通过跟踪源代码来准确显示程序中正在发生的事情。
一些编译器通过执行内存编译来解决编译速度问题。大多数编译器处理文件,在编译过程的每一步读写它们。内存编译器将编译程序保存在 ram 中。对于小程序来说,这看起来像解释器一样灵敏。
编译过程
用 C 和 C++ 编程,需要了解编译过程中的步骤和工具。一些语言( C 和 C++)通过在源代码上运行预处理器来开始编译。预处理器是一个简单的程序,它用程序员定义的其他模式替换源代码中的模式(使用预处理器指令)。预处理器指令用于节省输入并增加代码的可读性。预处理代码通常被写入中间文件。
编译器通常分两步工作。第一遍分析预处理代码。编译器将源代码分解成小单元,并将其组织成一个名为树的结构。在表达式“A + B”中,元素“A”、“+”和“B”是解析树上的叶子。
一个全局优化器是,有时在第一遍和第二遍之间使用,以产生更小、更快的代码。第二遍,代码生成器遍历解析树,为树的节点生成汇编语言代码或机器码。如果代码生成器创建汇编代码,那么必须运行汇编程序。这两种情况下的最终结果都是一个对象模块(一个通常具有扩展名.o 或 .obj的文件)。
使用“对象”这个词来描述机器代码块是一个不幸的产物。这个词在面向对象编程被普遍使用之前就开始使用了。“对象”在讨论编译时与“目标”在同一意义上使用,而在面向对象编程中,它意味着“一个有边界的东西”。
链接器将一系列目标模块组合成一个可执行程序,该程序可由操作系统加载和运行。当一个目标模块中的函数引用另一个目标模块中的函数或变量时,链接器解析这些引用;它确保您声称在编译期间存在的所有外部函数和数据确实存在。链接器还添加了一个特殊的对象模块来执行启动活动。
链接器可以搜索称为库的特殊文件,以解析其所有引用。一个库在一个文件中包含一组目标模块。一个图书馆是由一个叫做图书馆员的程序创建和维护的。
静态类型检查
编译器在第一次通过时执行类型检查。类型检查测试函数中参数的正确使用,并防止多种编程错误。由于类型检查发生在编译期间,而不是程序运行时,因此被称为静态类型检查。
一些面向对象的语言(特别是 Java)在运行时执行一些类型检查(动态类型检查)。如果结合静态类型检查,动态类型检查比单独的静态类型检查更强大。然而,它也增加了程序执行的开销。
C++ 使用静态类型检查,因为这种语言不能假设任何特定的运行时支持坏操作。静态类型检查通知程序员在编译期间类型的误用,从而最大化执行速度。当你学习 C++ 时,你会发现大多数语言设计决策都倾向于高速的、面向生产的编程,这种编程正是以 C 语言而闻名的语言。
在 C++ 中可以禁用静态类型检查。您也可以进行自己的动态类型检查;你只需要写代码。
用于单独编译的工具
在构建大型项目时,单独编译尤其重要。在 C 和 C++ 中,程序可以由小的、可管理的、独立测试的部分组成。将程序分成几个部分的最基本的工具是创建命名子例程或子程序的能力。在 C 和 C++ 中,子程序被称为函数,函数是可以放在不同文件中的代码片段,可以单独编译。换句话说,函数是代码的原子单位,因为你不能将函数的一部分放在一个文件中,而将另一部分放在另一个文件中;整个函数必须放在一个文件中(尽管文件可以包含多个函数)。
当你调用一个函数时,你通常会给它传递一些参数,这些参数是你希望函数在执行过程中使用的值。当函数结束时,您通常会得到一个返回值,这个值是函数返回给您的结果。也可以编写不带参数也不返回值的函数。
创建一个有多个文件的程序,一个文件中的函数必须访问其他文件中的函数和数据。编译一个文件时, C 或 C++ 编译器必须知道其他文件中的函数和数据,特别是它们的名字和正确用法。编译器确保函数和数据被正确使用。这个告诉编译器外部函数和数据的名字以及它们应该是什么样子的过程被称为声明。一旦你声明了一个函数或变量,编译器知道如何检查以确保它被正确使用。
声明与定义
理解声明和定义之间的差异很重要,因为这些术语将在整本书中被精确地使用。基本上所有的 C 和 C++ 程序都需要声明。在你能写你的第一个程序之前,你需要理解写一个声明的正确方法。
声明为编译器引入了一个名字——一个标识符。它告诉编译器“这个函数或者这个变量在某个地方存在,这里是它应该的样子*。**一个定义,*则表示“在这里做这个变量”或者“在这里做这个函数”它为名字分配存储空间。无论你谈论的是变量还是函数,这个意思都是成立的。在这两种情况下,编译器在定义的时候都会分配存储空间。对于变量,编译器确定该变量有多大,并在内存中生成空间来保存该变量的数据。对于一个函数,编译器生成代码,最终占用内存中的存储空间。
你可以在很多不同的地方声明一个变量或者一个函数,但是在 C 和 C++ 中必须只有一个定义(这有时被称为 ODR,一个定义规则)。当链接器联合所有的对象模块时,如果它发现同一个函数或变量有多个定义,它通常会抱怨。
*一个定义也可以是一个声明。*如果编译器之前没有见过名字x,而你定义了int x,编译器会把这个名字看作一个声明,并立刻为它分配存储空间。
函数声明语法
在 C 和 C++ 中的函数声明给出了函数名,传递给函数的参数类型,以及函数的返回值。例如,下面是一个名为func1( )的函数的声明,它接受两个整数参数(整数在 C /C++ 中用关键字int表示)并返回一个整数:
int func1(int,int);
你看到的第一个关键字是返回值本身:int。按照使用顺序,参数在函数名后用括号括起来。分号表示语句的结束;在这种情况下,它告诉编译器“仅此而已—这里没有函数定义!”
C 和 C++ 声明试图模仿项目的使用形式。例如,如果a是另一个整数,上面的函数可以这样使用:
a = func1(2,3);
由于func1()返回一个整数, C 或 C++ 编译器将检查func1( )的使用,以确保a可以接受返回值,并且参数是适当的。
函数声明中的参数可能有名字。编译器会忽略这些名称,但它们可以作为助记手段帮助用户记忆。例如,您可以用具有相同含义的不同方式声明func1( ),比如:
int func1(int length, int width);
对于参数列表为空的函数来说, C 和 C++ 有很大的区别。在 C ,宣言
int func2();
表示“具有任意数量和类型参数的函数”这防止了类型检查,所以在 C++ 中它意味着“一个没有参数的函数”
函数定义
函数定义看起来像函数声明,除了它们有主体。主体是用大括号括起来的语句的集合。大括号表示代码块的开始和结束。要给func1( )一个定义,即空体(不包含代码的体),写
int func1(int length, int width) { }
请注意,在函数定义中,大括号取代了分号。因为大括号将一个语句或一组语句括起来,所以不需要分号。还要注意,如果要在函数体中使用参数,函数定义中的参数必须有名称(因为这里从来没有使用过,所以它们是可选的)。
变量声明语法
短语*“变量声明”的含义在历史上一直是混乱和矛盾的,理解正确的定义很重要,这样才能正确地阅读代码。变量声明告诉编译器一个变量看起来像什么。上面写着,*“我知道你以前没见过这个名字,但我保证它存在于某个地方,它是一个 X 类型的变量。”
在函数声明中,给出一个类型(返回值)、函数名、参数列表和一个分号。这足以让编译器判断出这是一个声明,以及函数应该是什么样子。根据推论,变量声明可能是后跟名称的类型。例如,
int a;
可以使用上面的逻辑将变量a声明为一个整数。冲突在这里:上面的代码中有足够的信息让编译器为一个名为a的整数创建空间,这就是所发生的事情。为了解决这个难题, C 和 C++ 需要一个关键字来说明“这只是一个声明;它在别处有定义。”关键词是 extern。这可能意味着该定义对文件是有用的,或者该定义出现在文件的后面。
声明一个变量而不定义它意味着在变量描述前使用extern关键字,就像这样:
extern int a;
extern也可以适用于函数声明。对于func1( ),看起来是这样的:
extern int func1(int length, int width);
该语句相当于前面的func1( )声明。由于没有函数体,编译器必须将其视为函数声明,而不是函数定义。因此,extern关键字对于函数声明来说是多余的和可选的。很不幸的是, C 的设计者没有要求使用extern进行函数声明;它会更一致,更少混乱(但是需要更多的输入,这可能解释了为什么做出这个决定)。清单 2-1 展示了更多声明和定义的例子。
清单 2-1 。声明和定义的更多示例
//: C02:Declare.cpp
// Demonstrates more Declarations & Definitions extern inti; // Declaration without definition
extern float f(float); // Function declaration
float b; // Declaration & definition
float f(float a) { // Definition
return a + 1.0;
}
int i; // Definition
int h(int x) { // Declaration & definition
return x + 1;
}
int main() {
b = 1.0;
i = 2;
f(b);
h(i);
} ///:∼
在函数声明中,参数标识符是可选的。在定义中,它们是必需的(只有在 C 语言中需要标识符,而不是 C++ )。
包括标题
大多数库包含大量的函数和变量。为了节省工作并确保对这些项目进行外部声明时的一致性, C 和 C++ 使用一种叫做头文件的设备。头文件是包含库的外部声明的文件;它习惯上的文件扩展名是.h,比如headerfile.h。
注意你可能还会看到一些使用不同扩展名的旧代码,比如
.hxx或.hpp,但这种情况越来越少了。
创建库的程序员提供头文件。要声明库中的函数和外部变量,用户只需包含头文件。要包含一个头文件,使用#include预处理指令。这告诉预处理器打开指定的头文件,并在出现#include语句的地方插入其内容。一个#include可以用两种方式命名一个文件:尖括号(<>)或者双引号(" ")。
尖括号中的文件名,例如
#include <header>
让预处理器以一种特定于您的实现的方式搜索文件,但是通常有某种“包含搜索路径”,您可以在您的环境中或者在编译器命令行上指定。设置搜索路径的机制因机器、操作系统和 C++ 实现而异,可能需要您进行一些调查。
双引号中的文件名,例如
#include "local.h"
告诉预处理器以一种“实现定义的方式”在(中根据规范搜索文件这通常意味着搜索相对于当前目录的文件。如果没有找到文件,那么重新处理include指令,就好像它有尖括号而不是引号。
要包含iostream头文件,您需要编写
#include<iostream>
预处理器将找到iostream头文件(通常在一个名为include的子目录中)并插入它。
标准 C++ 包含格式
随着 C++ 的发展,不同的编译器供应商为文件名选择了不同的扩展名。另外,各种操作系统对文件名都有不同的限制,尤其是对文件名长度的限制。这些问题导致了源代码的可移植性问题。为了平滑这些粗糙的边缘,标准使用了一种格式,允许文件名长于臭名昭著的八个字符,并消除了扩展名。例如,而不是包含iostream.h的旧样式,它看起来像
#include <iostream.h>
你现在可以写了
#include <iostream>
翻译器可以以适合特定编译器和操作系统需求的方式实现 include 语句,如果需要,可以截断名称并添加扩展名。当然,如果您想在供应商提供支持之前使用这种样式,也可以将编译器供应商提供的头文件复制到没有扩展的头文件中。
从 C 继承的库仍然可以使用传统的.h扩展。但是,您也可以通过在名称前添加一个“c”来将它们用于更现代的 C++ include 样式。因此,
#include <stdio.h>
#include <stdlib.h>
成为
#include <cstdio>
#include <cstdlib>
以此类推,对于所有的标准 C 头。这给读者提供了一个很好的区别,表明你什么时候使用的是 C 和 C++ 库。
新的 include 格式的效果与旧的不一样:使用.h得到旧的非模板版本,省略.h得到新的模板版本。如果试图在一个程序中混合这两种形式,通常会遇到问题。
连接
链接器收集由编译器生成的目标模块(通常使用类似于.o或.obj的文件扩展名),使其成为操作系统可以加载和运行的可执行程序。这是编译过程的最后阶段。
链接器特征因系统而异。一般来说,你只需要告诉链接器你想要链接在一起的目标模块和库的名字,以及可执行文件的名字,它就开始工作了。一些系统要求你自己调用链接器。对于大多数 C++ 包,您通过 C++ 编译器调用链接器。在许多情况下,链接器是在不可见的情况下为您调用的。
一些老的链接器不会多次搜索目标文件和库,它们从左到右搜索你给它们的列表。这意味着目标文件和库的顺序可能很重要。如果你有一个直到链接时才出现的神秘问题,一种可能是文件给链接器的顺序。
使用库
现在你已经知道了基本的术语,你可以理解如何使用一个库。要使用库,请按照下列步骤操作。
- 包括库的头文件。
- 使用库中的函数和变量。
- 将库链接到可执行程序中。
当目标模块没有组合成一个库时,这些步骤也适用。包含一个头文件并链接目标模块是在 C 和 C++ 中单独编译的基本步骤。
链接器如何搜索库
当您在 C 或 C++ 中对函数或变量进行外部引用时,链接器在遇到该引用时,可以做两件事情之一。如果它还没有遇到函数或变量的定义,它将标识符添加到它的“未解析引用”列表中如果链接器已经遇到了定义,则解析引用。
如果链接器在目标模块列表中找不到定义,它将搜索库。库有某种索引,所以链接器不需要查看库中的所有对象模块——它只需要查看索引。当链接器在库中找到一个定义时,整个目标模块(不仅仅是函数定义)都被链接到可执行程序中。注意,整个库并没有被链接,只有库中包含所需定义的对象模块被链接(否则程序会不必要地变大)。如果您想最小化可执行程序的大小,您可以考虑在构建自己的库时,在每个源代码文件中放入一个函数。这需要更多的编辑,但对用户来说是有帮助的。
因为链接器按照您给定的顺序搜索文件,所以您可以在库函数出现之前,通过将带有您自己的函数的文件(使用相同的函数名)插入到列表中来抢占库函数的使用。因为链接器在搜索库之前会使用您的函数来解析对该函数的任何引用,所以使用您的函数而不是库函数。注意,这也可能是一个 bug,这是 C++ 名称空间所不允许的。
秘密添加
当一个 C 或 C++ 可执行程序被创建时,某些项目被秘密链接进来。其中之一是启动模块,它包含初始化例程,每当一个 C 或 C++ 程序开始执行时,都必须运行这些例程。这些例程设置堆栈并初始化程序中的某些变量。
链接器总是在标准库中搜索程序中调用的任何“标准”函数的编译版本。因为总是要搜索标准库,所以只要在程序中包含适当的头文件,就可以使用该库中的任何内容;你不必告诉它去搜索标准库。例如,iostream函数就在标准 C++ 库中。要使用它们,只需包含<iostream>头文件。如果使用附加库,必须将库名显式添加到传递给链接器的文件列表中。
使用普通 C 库
仅仅因为你是在用 C++ 写代码,并不妨碍你使用 C 库函数。事实上,整个 C 库默认包含在标准 C++ 中。在这些功能中已经为您做了大量的工作,因此它们可以为您节省大量时间。
这本书将在方便的时候使用标准 C++(因此也是标准的 C )库函数,但是只使用标准的库函数,以确保程序的可移植性。在必须使用非 C++ 标准的库函数的少数情况下,将尽可能使用 POSIX 兼容函数。POSIX 是一种基于 Unix 标准化工作的标准,它包含了超出 C++ 库范围的函数。您通常可以在 Unix(尤其是 Linux)平台上找到 POSIX 函数,而且通常是在 DOS/Windows 下。例如,如果你正在使用多线程,你最好使用 POSIX 线程库,因为这样你的代码会更容易理解、移植和维护(和 POSIX 线程库通常只使用操作系统的底层线程工具,如果它们被提供的话)。
你的第一个 C++ 程序
现在,您已经了解了创建和编译程序的基本知识。该程序将使用标准的 C++ iostream类。这些读写文件和“标准”输入输出(通常来自控制台,但可能被重定向到文件或设备)。在这个简单的程序中,一个流对象将被用来在屏幕上打印一条消息。
使用 iostream 类
要在iostream类中声明函数和外部数据,请在语句中包含头文件,如下所示:
#include <iostream>
第一个程序使用了标准输出的概念,意思是“一个通用的发送输出的地方”您将看到以不同方式使用标准输出的其他示例,但是这里它将只进入控制台。iostream包自动定义一个名为cout的变量(一个对象),该变量接受所有绑定到标准输出的数据。
要将数据发送到标准输出,可以使用操作符<<。程序员把这个操作符称为“按位左移”,这将在下一章描述。可以说,按位左移与输出无关。但是 C++ 允许运算符重载。当重载一个运算符时,当该运算符用于特定类型的对象时,就赋予了它新的含义。对于iostream对象,操作符<<表示“发送到”例如,
cout << "rowdy!";
发送字符串“rowdy!”到名为cout(是“控制台输出”的简称)的对象。
这就足够让你开始操作了。 第十二章 详细介绍了运算符重载。
命名空间
正如在 第一章中提到的,在 C 语言中遇到的一个问题是,当你的程序达到一定的规模时,你的函数和标识符“用完了”。当然,你不会真的没有名字;然而,过一段时间后,想出新的想法确实变得更加困难。更重要的是,当一个程序达到一定的规模时,它通常会被分成几个部分,每个部分都由不同的人或团队来构建和维护。因为 C 实际上有一个单一的舞台,所有的标识符和函数名都在那里,这意味着所有的开发人员都必须小心,不要在可能冲突的情况下意外地使用相同的名字。这很快变得乏味、浪费时间,并且最终变得昂贵。
标准 C++ 有一个防止这种冲突的机制:namespace关键字。一个库或程序中的每一组 C++ 定义都被“包装”*在一个名称空间中;如果其他一些定义具有相同的名称,但是在不同的名称空间中,则不存在冲突。
名称空间是一种方便而有用的工具,但是它们的存在意味着在编写任何程序之前,您必须了解它们。如果您只是包含一个头文件,并使用该头文件中的一些函数或对象,那么当您试图编译程序时,您可能会得到听起来很奇怪的错误,结果是编译器无法找到您刚刚包含在头文件中的项的任何声明!看到这条消息几次后,你就会熟悉它的意思了(它是")你包含了头文件,但是所有的声明都在一个名称空间内,并且你没有告诉编译器你想使用那个名称空间中的声明。”)。
有一个关键字允许你说“我想在这个名称空间中使用声明和/或定义。“这个关键词,再恰当不过了,就是using。所有的标准 C++ 库都封装在一个名称空间中,这个名称空间就是std(代表“标准”)。由于这本书几乎只使用标准库,你会看到下面的在几乎每个程序中使用指令:
using namespace std;
这意味着您想要公开名为std的名称空间中的所有元素。在这个语句之后,您不必担心您的特定库组件在一个名称空间内,因为using指令使得这个名称空间在编写了using指令的整个文件中都可用。
在某人费尽心思隐藏元素之后,公开名称空间中的所有元素可能看起来有点适得其反,事实上,您应该小心不加思考地这样做(您将在本书后面了解到)。然而,using指令只公开了当前文件的那些名称,所以它并不像听起来那么激烈。(但是在头文件中这样做要三思——那是鲁莽的。
名称空间和头文件的包含方式有关系。在现代头文件包含被标准化之前(没有尾随的.h,如在<iostream>中),包含头文件的典型方式是使用.h,如<iostream.h>。那时,名称空间也不是语言的一部分。所以为了提供与现有代码的向后兼容性,如果你说
#include <iostream.h>
这意味着
#include <iostream>
using namespace std;
然而,在本书中,将使用标准的 include 格式(没有.h),因此using指令必须是明确的。
现在,这就是你需要知道的关于名称空间的全部内容,但是在第十章 的 中,这个主题被更彻底地讨论了。
程序结构基础
C 或 C++ 程序是变量、函数定义和函数调用的集合。当程序启动时,它执行初始化代码并调用一个特殊函数main( )。你把程序的主要代码放在这里。
如前所述,函数定义由返回类型(在 C++ 中必须指定)、函数名、圆括号中的参数列表和大括号中包含的函数代码组成。下面是一个函数定义示例:
int function() {
// Function code here (this is a comment)
}
这个函数有一个空的参数列表和一个只包含注释的函数体。
一个函数定义中可以有多组大括号,但函数体周围必须至少有一组大括号。由于main( )是一个函数,它必须遵循这些规则。在 C++ 中,main( )的返回类型总是为int。
C 和 C++ 是自由格式语言。除了少数例外,编译器会忽略换行符和空白,所以它必须有某种方法来确定语句的结尾。语句用分号分隔。
C 评论以/*开始,以*/结束。它们可以包括换行符。C++ 使用 C 风格的注释,并有一个额外的注释类型://。//开始一个以换行符结束的注释。对于单行注释来说,它比/* */更方便,在本书中被广泛使用。
“你好,世界!”
现在,最后,第一个节目。见清单 2-2 !
清单 2-2 。你好,世界!
//: C02:Hello.cpp
// Saying Hello with C++
#include <iostream> // Stream declarations
using namespace std;
int main() {
cout << "Hello, World! I am "
<< 8 << " Today!" << endl;
} ///:∼
通过<<操作符向cout对象传递一系列参数。它按从左到右的顺序打印出这些参数。特殊的 iostream 函数endl输出一行和一个换行符。使用iostream,您可以像这样将一系列参数串在一起,这使得该类易于使用。
在 C 中,双引号内的文本传统上被称为字符串。然而,标准 C++ 库有一个强大的类叫做string用于操作文本,所以我将使用更精确的术语“字符数组来表示双引号内的文本。
编译器为字符数组创建存储区,并将每个字符的等效 ASCII 码存储在该存储区中。编译器自动终止这个字符数组,用一个包含值 0 的额外存储来指示字符数组的结尾。
在字符数组中,你可以使用转义序列来插入特殊字符。它们由一个反斜杠(\)后跟一个特殊代码组成。例如\n表示换行符。你的编译器手册或本地指南给出了完整的转义序列;其他还有\t (tab)、\\(反斜杠)、\b(退格)。
请注意,该语句可以在多行中继续,并且整个语句以分号结束。
在上面的cout语句中,字符数组参数和常量混合在一起。因为操作符<<在与cout一起使用时有多种含义,所以你可以向cout发送各种不同的参数,它会计算出如何处理消息。
在本书中,你会注意到每个文件的第一行都是一个注释,以开始注释的字符(通常是//)开头,后面跟着一个冒号,清单的最后一行以注释结尾,后面跟着/:∼。这是一种我们用来从代码文件中轻松提取信息的技术。第一行还有文件的名称和位置,因此可以在文本和其他文件中引用它。
运行编译器
下载并解包了书的源代码后,在子目录CO2中找到程序。使用Hello.cpp作为参数调用编译器。对于像这样简单的单文件程序,大多数编译器会带你完成整个过程。例如,要使用 GNU C++ 编译器(可以在网上免费获得),你可以写
g++ Hello.cpp
其他编译器也有类似的语法;有关详细信息,请参考编译器文档。
关于 iostream 的更多信息
到目前为止,您只看到了iostream类的最基本的方面。(关于iostream的详细讨论已延至 第十九章 )。iostream提供的输出格式还包括十进制、八进制和十六进制的数字格式。清单 2-3 显示了使用iostream的另一个例子。
清单 2-3 。iostream 的另一种用途
//: C02:Stream2.cpp
// Demonstrates more streams features
#include <iostream>
using namespace std;
int main() {
// Specifying formats with manipulators:
cout << "a number in decimal: "
<< dec << 15 << endl;
cout << "in octal: " << oct << 15 << endl;
cout << "in hex: " << hex << 15 << endl;
cout << "a floating-point number: "
<< 3.14159 << endl;
cout << "non-printing char (escape): "
<< char(27) << endl;
} ///:∼
注 文档注释///:∨——这是在全书中重复表示结束的代码。你会在第十八章 (在“字符串应用”一节)中找到更多关于它的信息。
这个例子显示了iostream类使用 iostream 操纵器 ( 不打印任何东西,但是改变输出流的状态)以十进制、八进制和十六进制打印数字。浮点数的格式由编译器自动确定。此外,可以使用将转换为char将任何字符发送给流对象(一个char 是保存单个字符的数据类型)。这个造型看起来像一个函数调用char( ),还有角色的 ASCII 值。在清单 2-3 的程序中,char(27)向cout发送一个“escape”。
字符数组串联
C 预处理器的一个重要特性是字符数组串联。本书中的一些例子使用了这个特性。如果两个带引号的字符数组是相邻的,并且它们之间没有标点符号,编译器会将这些字符数组粘贴到一个字符数组中。这在代码清单有宽度限制时特别有用,如清单 2-4 。
清单 2-4 。字符数组串联
//: C02:Concat.cpp
// Demonstrates special use of Character array Concatenation
// in case of coding with width restrictions
#include <iostream>
using namespace std;
int main() {
cout << "This is far too long to put on a "
"single line but it can be broken up with "
"no ill effects\as long as there is no "
"punctuation separating adjacent character "
"arrays.\n";
} ///:∼
起初,清单 2-4 中的代码看起来像一个错误,因为在每一行的末尾没有熟悉的分号。记住 C 和 C++ 是自由格式语言;虽然您通常会在每一行的末尾看到一个分号,但实际要求是在每一条语句的末尾都有一个分号,而且一条语句可能会延续几行。
读取输入
iostream类提供了读取输入的能力。用于标准输入的对象是cin ( 为“控制台输入”)。cin通常期望来自控制台的输入,但是这个输入可以从其他来源重定向。本章稍后将展示重定向的一个示例。
与cin一起使用的 iostreams 运算符是>>。该操作符等待与其参数相同的输入。例如,如果您给它一个整数参数,它将等待来自控制台的整数。清单 2-5 显示了一个例子。
清单 2-5 。阅读输入
//: C02:Numconv.cpp
// Converts decimal to octal and hex
// Demonstrates use of cin operator
#include <iostream>
using namespace std;
int main() {
int number;
cout << "Enter a decimal number: ";
cin >> number;
cout << "value in octal = 0"
<< oct << number << endl;
cout << "value in hex = 0x"
<< hex << number << endl;
} ///:∼
这个程序将用户输入的数字转换成八进制和十六进制。
调用其他程序
虽然使用从标准输入读取并写入标准输出的程序的典型方式是在 Unix shell 脚本或 DOS 批处理文件中,但是任何程序都可以使用标准的C函数从 C 或 C++ 程序中调用,该函数在头文件<cstdlib>中声明,如清单 2-6 中的所示。
清单 2-6 。调用其他程序
//: C02:CallHello.cpp
// Call another program
#include <cstdlib> // Declare "system()"
using namespace std;
int main() {
system("Hello");
} ///:∼
要使用system( )函数,您需要给它一个字符数组,您通常会在操作系统命令提示符下输入这个数组。这也可以包括命令行参数,字符数组可以是你在运行时编造的(而不是像清单 2-6 所示的那样只使用静态字符数组)。命令执行,控制返回到程序。
这个程序展示了在 C++ 中使用普通的 C 库函数是多么容易:只需包含头文件并调用函数。如果你是从有 to C 语言背景的人开始学习这门语言,那么从 to C 语言到 C++ 的这种向上兼容性是一个很大的优势。
介绍字符串
虽然字符数组非常有用,但它非常有限。它只是内存中的一组字符,但是如果你想用它做任何事情,你必须管理所有的小细节。例如,带引号的字符数组的大小在编译时是固定的。如果你有一个字符数组,并且你想向它添加更多的字符,你需要理解很多东西(包括动态内存管理、字符数组复制和连接)才能实现你的愿望。这正是我们想让一个物体为我们做的事情。
标准的 C++ string类被设计用来处理(并隐藏)所有字符数组的低级操作,这些操作以前需要 CC程序员来完成。自从 C 语言出现以来,这些操作一直是浪费时间和错误的根源。因此,尽管在本书的后面有整整一章是专门讨论string类的,但是string是如此重要,它使生活变得如此简单,所以它将在这里被介绍,并在本书的大部分早期被使用。
要使用string s,您需要包含 C++ 头文件<string>。string类位于名称空间std中,因此需要一个using指令。因为操作符重载,使用string s 的语法非常直观,正如你在清单 2-7 中看到的。
清单 2-7 。使用字符串
//: C02:HelloStrings.cpp
// Demonstrates the basics of the C++ string class
#include <string>
#include <iostream>
using namespace std;
int main() {
string s1, s2; // Empty strings
string s3 = "Hello, World."; // Initialized
string s4("I am"); // Also initialized
s2 = "Today"; // Assigning to a string
s1 = s3 + " " + s4; // Combining strings
s1 += " 8 "; // Appending to a string
cout << s1 + s2 + "!" << endl;
} ///:∼
前两个string、s1和s2开始时是空的,而s3和s4展示了从字符数组初始化string对象的两种等价方式(你可以同样容易地从其他string对象初始化string对象)。
您可以使用=分配给任何string对象。这会用右边的内容替换字符串中以前的内容,您不必担心以前的内容会发生什么——这是自动为您处理的。要组合string s,您只需使用+操作符,这也允许您将字符数组与string s 组合起来。如果您想要将string或字符数组附加到另一个string上,您可以使用操作符+=。最后,注意iostream已经知道如何处理string s,所以你可以直接向cout发送一个string(或者一个产生string的表达式,这发生在s1 + s2 + "!"中)来打印它。
读写文件
在 C 中,打开和操作文件的过程需要大量的语言背景知识,让你为操作的复杂性做好准备。然而,C++ iostream库提供了一种简单的方法来操作文件,因此这种功能可以比在 C 中更早地引入。
要打开文件进行读写,必须包含<fstream>。虽然这将自动包含<iostream>,但是如果您计划使用cin、cout等,显式包含<iostream>通常是谨慎的。
要打开一个文件进行读取,您需要创建一个ifstream对象,它的行为类似于cin。要打开一个文件进行写入,您需要创建一个ofstream对象,它的行为类似于cout。一旦你打开了这个文件,你就可以像对待任何其他iostream对象一样读取或写入它。就这么简单(当然,这就是重点)。
iostream库中最有用的函数之一是getline( ),它允许你将一行(以换行符结束)读入一个string对象。第一个参数是您正在读取的ifstream对象,第二个参数是string对象。当函数调用结束时,string对象将包含该行。清单 2-8 包含一个简单的例子,它将一个文件的内容复制到另一个文件中。
清单 2-8 。将一个文件复制到另一个文件,一次一行
//: C02:Scopy.cpp
// Demonstrates use of the getline() function
#include <string>
#include <fstream>
using namespace std;
int main() {
ifstream in("Scopy.cpp"); // Open for reading
ofstream out("Scopy2.cpp"); // Open for writing
string s;
while(getline(in, s)) // Discards newline char
cout << s << "\n"; // ... must add it back
} ///:∼
要打开文件,你只需给ifstream和ofstream对象你想要创建的文件名,如清单 2-8 所示。
这里引入了一个新概念,就是while循环。虽然这将在下一章详细解释,但基本思想是while后面括号中的表达式控制后续语句的执行(也可以是多个语句,用花括号括起来)。只要括号中的表达式(在本例中是getline(in, s))产生“真”结果,那么由while控制的语句将继续执行。原来,getline( )将返回一个值,如果另一行被成功读取,该值可以被解释为“真”,当到达输入的末尾时,该值为“假”。因此,上面的while循环读取输入文件中的每一行,并将每一行发送到输出文件。
getline( )读入每一行的字符,直到它发现一个新行(终止字符可以改变,但这不会成为问题,直到 第十九章 关于 iostreams)。然而,它丢弃了换行符,并且没有将它存储在结果string对象中。因此,如果您希望复制的文件看起来像源文件一样,您必须重新添加换行符,如下所示。
另一个有趣的例子是将整个文件复制到一个单独的string对象中,如清单 2-9 所示。
清单 2-9 。将整个文件读入单个字符串
//: C02:FillString.cpp
// Demonstrates use of fstream
#include <string>
#include <iostream>
#include <fstream>
using namespace std;
int main() {
ifstream in("FillString.cpp");
string s, line;
while(getline(in, line))
s += line + "\n";
cout << s;
} ///:∼
由于string s 的动态特性,你不必担心为一个string分配多少存储空间;你可以不断添加东西,而string会不断扩大,以容纳你放入的任何东西。
将整个文件放入一个string中的一个好处是,string类有许多搜索和操作功能,允许您将文件作为单个字符串进行修改。然而,这有其局限性。首先,将文件视为行的集合而不仅仅是一大块文本通常很方便。例如,如果你想添加行号,如果你把每一行作为一个单独的string对象,那就简单多了。要实现这一点,您需要另一种方法。
引入向量
使用string s,你可以填充一个string对象,而不知道你将需要多少存储空间。将文件中的行读入单个string对象的问题是,你不知道你将需要多少个string;你只有在阅读了整个文件后才知道。为了解决这个问题,你需要一种可以自动膨胀的支架,你想放多少string物品就放多少。
其实,为什么要把自己局限在拿着string物体呢?事实证明,这种问题——在你写程序的时候不知道你有多少东西——经常发生。这个“容器”对象听起来更有用,如果它能容纳任何种类的对象的话!幸运的是,标准 C++ 库有一个现成的解决方案:标准容器类。容器类是标准 C++ 的真正动力之一。
在标准 C++ 库中的容器和算法与被称为 STL 的实体之间经常会有一点混淆。STL 在许多细微之处与标准 C++ 库不同。所以,尽管这是一个普遍的误解,C++ 标准并没有“包括”STL。这可能有点令人困惑,因为标准 C++ 库中的容器和算法与 SGI STL 有相同的根(通常是相同的名称)。在本书中,我会说“标准 C++ 库”或“标准库容器”或类似的东西,我会避免使用“STL”这个术语。
标准库是如此的有用,以至于最基本的标准容器vector在这一章中被介绍,并在整本书中被使用。你会发现,仅仅通过使用vector的基础知识,而不用担心底层的实现(,OOP 的一个重要目标),你就可以做很多事情。你会发现在大多数情况下,这里显示的用法是足够的。
vector类是一个模板,这意味着它可以高效地应用于不同的类型。也就是你可以创建一个shape s 的vector,一个cat s 的vector,一个string s 的vector等等。基本上,用一个模板你可以创建一个“任何东西的类”为了告诉编译器这个类将处理什么(在这种情况下,vector将保存什么),您将所需类型的名称放在尖括号中,这意味着<和>。因此string中的一个vector将被表示为vector<string>。当你这样做时,你最终得到一个定制的向量,它将只保存string对象,如果你试图把任何东西放入其中,你将从编译器得到一个错误消息。
*既然vector表达了容器的概念,那就一定有办法把东西放进容器,又把东西从容器里取出来。要在一个vector的末尾添加一个全新的元素,你可以使用成员函数push_back( ). ( 记住,因为它是一个成员函数,所以你可以使用一个“ . ”来调用它的一个特定对象。)这个成员函数的名字可能看起来有点冗长(如在push_back( )中,而不是像“put”这样简单的东西),因为有其他容器和其他成员函数可以将新的元素放入容器中。比如有一个insert( )成员函数把东西放在容器中间。vector支持这一点,但它的使用要复杂得多,我们在此不再深入探讨。还有一个push_front( )(不是vector的一部分)把事情放在开头。在vector中有更多的成员函数,在标准 C++ 库中有更多的容器,但是你会惊讶于仅仅了解一些简单的特性就能做这么多事情。
所以你可以用push_back( )把新元素放入vector中,但是你如何把这些元素取出来呢?这个解决方案更加巧妙和优雅。运算符重载用于使vector看起来像一个数组。数组(将在下一章更全面地描述)是一种几乎在每种编程语言中都可用的数据类型,所以您应该已经对它有些熟悉了。数组是集合,这意味着它们由许多聚集在一起的元素组成。数组的显著特征是这些元素大小相同,并且一个接一个地排列。最重要的是,这些元素可以通过索引来选择,这意味着你可以说*“我想要元素号 n”然后那个元素就会产生,通常很快。虽然编程语言中也有例外,但索引通常是使用方括号来实现的,所以如果你有一个数组a并且你想产生元素五,你就说a[4](注意索引总是从零开始*)。
这种非常紧凑和强大的索引符号使用操作符重载合并到vector中,就像<<和>>合并到iostream中一样。同样,你不需要知道重载是如何实现的(这个留到后面的章节),但是如果你知道为了让[ ]和vector一起工作,在幕后有一些魔术在进行,这是很有帮助的。
记住这一点,现在您可以看到一个使用vector的程序。要使用一个vector,你需要包含头文件<vector>,如清单 2-10 所示。
清单 2-10 。使用向量//: C02:Fillvector.cpp
// Demonstrates copying an entire file into a vector of string #include <string>
#include <iostream>
#include <fstream>
#include <vector>
using namespace std;
int main() {
vector <string> v;
ifstream in("Fillvector.cpp");
string line;
while(getline(in, line))
v.push_back(line); // Add the line to the end
// Add line numbers:
for(int i = 0; i < v.size(); i++)
cout << i << ": " << v[i] << endl;
} ///:∼
这个程序的大部分与前一个相似;打开一个文件,一次一行地读入string对象。然而,这些string物体被推到了vector v的背面。一旦while循环完成,整个文件就驻留在v内部的内存中。
程序中的下一条语句叫做for循环。它类似于while循环,只是增加了一些额外的控制。在for之后,括号内有一个“控制表达式”,就像while循环一样。然而,这个控制表达式由三部分组成:一部分初始化,一部分测试是否应该退出循环,另一部分改变一些东西,通常是遍历一系列项目。这个程序以最常用的方式展示了for循环:初始化部分inti = 0创建一个整数i作为循环计数器,并赋予其初始值 0。测试部分说,为了保持在循环中,i应该小于vector v中的元素数量。(这是使用成员函数size( ),生成的,这个函数在这里只是被偷偷放进去的,但是你必须承认它有一个相当明显的含义。)最后一部分使用了 C 和 C++ 的简写,即“自动递增”运算符,将i的值加 1。实际上,i++表示“获取i的值,加 1,并将结果放回i。因此,for循环的总体效果是获取一个变量i,并使其遍历从 0 到比vector小 1 的值。对于i的每个值,执行cout语句,这构建了一行,该行由i的值(被cout神奇地转换成一个字符数组)、一个冒号和一个空格、文件中的行以及由endl提供的换行符组成。当您编译并运行它时,您会看到结果是向文件中添加了行号。
由于>>操作符与iostream一起工作的方式,你可以很容易地修改清单 2-10 中的程序,使它将输入分成空格分隔的单词而不是行,如清单 2-11 所示。
清单 2-11 。将文件分解成空格分隔的单词
//: C02:GetWords.cpp
// Modifies program in Listing 2-10
#include <string>
#include <iostream>
#include <fstream>
#include <vector>
using namespace std;
int main() {
vector<string> words;
ifstream in("GetWords.cpp");
string word;
while(in >> word)
words.push_back(word);
for(int i = 0; i < words.size(); i++)
cout << words[i] << endl;
} ///:∼
表情
while(in >> word)
一次获取一个“单词”的输入,当该表达式的计算结果为“false”时,意味着已经到达了文件的末尾。当然,用空格分隔单词是相当粗糙的,但是这是一个简单的例子。在本书的后面,你会看到更复杂的例子,让你以任何你喜欢的方式分解输入。
为了演示使用任何类型的vector是多么容易,清单 2-12 展示了一个创建vector<int>的例子。
清单 2-12 。使用任何类型的向量
//: C02:Intvector.cpp
// Demonstrates creation of a vector that holds integers
#include <iostream>
#include <vector>
using namespace std;
int main() {
vector<int> v;
for(int i = 0; i < 10; i++)
v.push_back(i);
for(int i = 0; i < v.size(); i++)
cout << v[i] << ", ";
cout << endl;
for(int i = 0; i < v.size(); i++)
v[i] = v[i] * 10; // Assignment
for(int i = 0; i < v.size(); i++)
cout << v[i] << ", ";
cout << endl;
} ///:∼
要创建一个保存不同类型的vector,只需将该类型作为模板参数放入即可(尖括号中的参数)。模板和设计良好的模板库就是为了这么容易使用。
这个例子继续展示了vector的另一个基本特性。在表达式中
v[i] = v[i] * 10;
你可以看到vector并不仅限于放入和取出东西。您还可以通过使用方括号索引操作符将(从而改变)分配给vector的任何元素。这意味着vector是一个通用的、灵活的便笺式存储器,用于处理对象集合。在接下来的章节中,你肯定会用到它。
审查会议
- 本章的目的是向您展示面向对象编程是多么容易——如果有人已经为您定义了对象的话。在这种情况下,您包括一个头文件,创建对象,并向它们发送消息。如果你正在使用的类型是强大的和设计良好的,那么你将不必做很多工作,你的结果程序也将是强大的。
- 在展示使用库类时
OOP的简易性的过程中,本章还介绍了标准 C++ 库中一些最基本和最有用的类型:iostream族(特别是*,那些从控制台和文件*中读取和写入的类型、string类和vector模板。您已经看到了使用这些类型是多么简单,现在可能可以想象用它们可以完成许多事情,但是实际上它们可以做的事情还很多。 - 尽管在本书的早期你只会使用这些工具功能的有限子集,但它们仍然比学习像
C这样的低级语言的原始水平提高了一大步。虽然学习C的低级方面很有教育意义,但也很耗时。最后,如果你有对象来管理底层问题,你会更有效率。毕竟,OOP 的整个点就是隐藏细节,这样你就可以“用更大的画笔绘画” - 然而,尽管 OOP 很高级,但 C 的一些基本方面是你无法避免知道的,这些将在下一章讨论。**
三、C++ 中的 C
因为 C++ 是基于 C 的,所以你必须熟悉 C 的语法才能用 C++ 编程,就像你必须熟练掌握代数才能处理微积分一样。
如果你以前没见过 C,这一章会以 C++ 中使用的 C 的风格给你一个像样的背景。如果你熟悉 Kernighan 和 Ritchie 描述的 C 风格(通常称为 K & R C ),你会在 C++ 和标准 C 中发现一些新的和不同的特性。如果你熟悉标准 C,你应该浏览本章寻找 C++ 特有的特性。请注意,这里介绍了一些基本的 C++ 特性,它们是类似于 C 中特性的基本思想,或者通常是对 C 做事方式的修改。更复杂的 C++ 特性将在后面的章节中介绍。
本章快速介绍了 C 结构,并介绍了一些基本的 C++ 结构,前提是你已经有了用另一种语言编程的经验。这一章基本上涵盖了 C++ 的特性,这些特性确定了 c++ 的相似之处。
创建函数
在旧的(标准之前的)C 中,你可以用任意数量或类型的参数调用一个函数,编译器不会抱怨。在你运行这个程序之前,一切看起来都很好。你得到了神秘的结果(或者更糟的,程序崩溃了,没有任何原因的提示。缺少参数传递的帮助以及由此产生的难以理解的错误可能是 C 被称为“高级汇编语言”的一个原因。前标准 C 程序员只是适应了它。
标准 C 和 C++ 使用一个叫做函数原型的特性。使用函数原型,在声明和定义函数时,必须使用参数类型的描述。这个描述就是“原型”调用该函数时,编译器使用原型来确保传入了正确的参数,并且正确处理了返回值。如果程序员在调用函数时出错,编译器会捕捉到错误。
基本上,你在前一章已经学习了函数原型(没有这样命名),因为 C++ 中函数声明的形式需要正确的原型。在函数原型中,参数列表包含必须传递给函数的参数类型以及参数的标识符(对于声明是可选的)。在声明、定义和函数调用中,参数的顺序和类型必须匹配。下面是一个声明中的函数原型示例:
int translate(float x, float y, float z);
在函数原型中声明变量时,使用的形式与在普通变量定义中不同。就是不能说float x, y, z。您必须指出每个参数的类型。在函数声明中,以下形式也是可接受的:
int translate(float, float, float);
因为当调用函数时,编译器除了检查类型之外什么也不做,所以当有人阅读代码时,为了清楚起见才包括标识符。
在函数定义中,名字是必需的,因为参数在函数内部被引用,比如:
int translate(float x, float y, float z) {
x = y = z;
// ...
}
原来这条规则只适用于 C。在 C++ 中,一个参数在函数定义的参数列表中可能是未命名的。因为它是未命名的,所以当然不能在函数体中使用它。允许未命名的参数是为了给程序员一种在参数列表中保留空间的方法。无论是谁使用这个函数,都必须用正确的参数调用它。但是,创建该函数的人以后可以使用该参数,而不必修改调用该函数的代码。如果您保留列表中的参数名,也可以选择忽略列表中的参数,但是每次编译函数时,您都会收到一条恼人的警告消息,提示该值未被使用。如果删除该名称,警告将消失。
C 和 C++ 还有另外两种方法来声明参数列表。如果你有一个空的参数列表,你可以在 C++ 中将它声明为func( ),这告诉编译器实际上没有参数。你应该知道在 C++ 中这仅仅意味着一个空的参数列表。在 C 中,这意味着参数的数量不确定(这是 C 中的一个“漏洞”,因为它在这种情况下禁用了类型检查)。在 C 和 C++ 中,声明func(void);意味着一个空的参数列表。在这种情况下,void关键字的意思是“什么都没有”(在指针的情况下,它也可以表示“无类型”,您将在本章后面看到)。
参数列表的另一个选项出现在你不知道有多少个参数或者什么类型的参数的时候;这叫做变量参数列表。这个不确定的自变量列表用省略号(... ) *表示。*定义一个带有可变参数列表的函数比定义一个常规函数要复杂得多。如果(出于某种原因)您想要禁用函数原型的错误检查,您可以为具有固定参数集的函数使用可变参数列表。因此,您应该将变量参数列表的使用限制在 C 中,并避免在 C++ 中使用它们(您将会了解到,在 c++ 中有更好的替代方法)。
函数返回值
C++ 函数原型必须指定函数的返回值类型(在 C 语言中,如果省略返回值类型,它默认为int)。返回类型规范位于函数名之前。要指定不返回值,请使用void关键字。如果您试图从函数返回值,这将产生一个错误。下面是一些完整的功能原型:
int f1(void); // Returns an int, takes no arguments
int f2(); // Like f1() in C++ but not in Standard C!
float f3(float, int, char, double); // Returns a float
void f4(void); // Takes no arguments, returns nothing
要从函数返回值,可以使用return语句。return退出函数,回到函数调用后的点。如果return有一个参数,这个参数将成为函数的返回值。如果一个函数说它将返回一个特定的类型,那么每个return语句都必须返回那个类型。在一个函数定义中可以有多个return语句,如清单 3-1 所示。
清单 3-1 。几个退货单
//: C03:Return.cpp
// Use of "return"
#include <iostream>
using namespace std;
char cfunc(int i) {
if(i == 0)
return 'a';
if(i == 1)
return 'g';
if(i == 5)
return 'z';
return 'c';
}
int main() {
cout << "type an integer: ";
int val;
cin >> val;
cout << cfunc(val) << endl;
} ///:∼
在cfunc( )中,评估为true的第一个if通过return语句退出函数。注意,函数声明不是必需的,因为函数定义在被用于main( )之前就出现了,所以编译器从函数定义中知道了它。
使用 C 函数库
用 C++ 编程时,本地 C 函数库中的所有函数都是可用的。在定义自己的函数之前,你应该好好看看函数库;很有可能有人已经为你解决了你的问题,并且可能对它进行了更多的思考和调试。
不过,需要注意的是:许多编译器包含了许多额外的函数,这些函数使生活变得更加简单,并且很容易使用,但是它们不是标准 C 库的一部分。如果你确定你永远不会想把应用程序转移到另一个平台上(),谁又能确定这一点呢?),继续吧—使用这些功能,让您的生活更轻松。如果您希望您的应用程序是可移植的,您应该限制自己使用标准的库函数。如果您必须执行特定于平台的活动,请尝试将代码隔离在一个地方,以便在移植到另一个平台时可以很容易地对其进行更改。在 C++ 中,特定于平台的活动通常封装在一个类中,这是理想的解决方案。
使用库函数的公式如下:首先,在您的编程参考中找到该函数(许多编程参考会按类别以及字母顺序对该函数进行索引)。函数的描述应该包括演示代码语法的部分。这一部分的顶部通常至少有一个#include行/指令,向您显示包含函数原型的头文件。在您的文件中复制这个#include行/指令,以便正确声明函数。现在,您可以按照它在语法部分出现的方式调用该函数。如果你犯了一个错误,编译器会通过比较你的函数调用和头文件中的函数原型来发现它,并告诉你你的错误。默认情况下,链接器会搜索标准库,所以您只需:包含头文件并调用函数。
与图书管理员一起创建自己的图书馆
您可以将自己的函数收集到一个库中。大多数编程包都有一个管理对象模块组的管理员。每个库管理器都有自己的命令,但是总的想法是这样的:如果你想创建一个库,创建一个头文件,包含你的库中所有函数的函数原型。将这个头文件放在预处理器的搜索路径中的某个地方,可以是本地目录(这样就可以被include "header"找到)或包含目录(这样就可以被#include <header>找到)。现在,将所有的对象模块连同完成的库的名称一起交给图书管理员(大多数图书管理员需要一个通用的扩展名,比如.lib或.a)。将完成的库放在其他库所在的位置,以便链接器可以找到它。当你使用你的库时,你必须在命令行中添加一些东西,这样链接器就知道在库中搜索你调用的函数。您必须在当地手册中找到所有细节,因为它们因系统而异。
控制执行
本节涵盖 C++ 中的执行控制语句。在读写 C 或 C++ 代码之前,您必须熟悉这些语句。
C++ 使用 C 的所有执行控制语句。其中包括if-else、while、do-while、for,以及一个名为switch的选择语句。C++ 也允许臭名昭著的goto,这在本书中会避免。
真假
所有条件语句都使用条件表达式的真或假来确定执行路径。条件表达式的一个例子是A == B。这使用了条件运算符==来查看变量A是否等价于变量B.,表达式产生一个布尔型true或false(这些只是 C++ 中的关键字;在 C 语言中,如果表达式的值为非零值,则表达式为“真”)。其他条件运算符有>、<、>=等。条件语句将在本章后面更全面地介绍。
使用 if-else
if-else语句可以有两种形式:有或者没有else。这两种形态分别是
if(expression)
statement
或者
if(expression)
statement
else
statement
“表达式”评估为true或false。“语句”是指以分号结束的简单语句或复合语句,复合语句是用大括号括起来的一组简单语句。任何时候使用“陈述”这个词,它总是暗示这个陈述是简单的或复合的。注意,这个说法也可以是另一个if,所以可以串起来;参见清单 3-2 。
清单 3-2 。使用 if 和 if-else
//: C03:Ifthen.cpp
// Demonstration of if and if-else conditionals
#include <iostream>
using namespace std;
int main() {
int i;
cout << "type a number and 'Enter'" << endl;
cin >> i;
if(i > 5)
cout << "It's greater than 5" << endl;
else
if(i < 5)
cout << "It's less than 5 " << endl;
else
cout << "It's equal to 5 " << endl;
cout << "type a number and 'Enter'" << endl;
cin >> i;
if(i < 10)
if(i > 5) // "if" is just another statement
cout << "5 < i < 10" << endl;
else
cout << "i <= 5" << endl;
else // Matches "if(i < 10)"
cout << "i >= 10" << endl;
} ///:∼
通常缩进控制流语句的主体,以便读者可以容易地确定它的开始和结束位置。
使用 while
您可以通过while、do-while,和for控制循环。重复一个语句,直到控制表达式的计算结果为false。while循环的形式是
while(expression)
statement
表达式在循环开始时计算一次,在语句的每一次迭代之前再计算一次。清单 3-3 中的代码会留在while循环的主体中,直到你输入密码或按下 Control-C 键
清单 3-3 。使用 while
//: C03:Guess.cpp
// Guess a number (demonstrates "while")
#include <iostream>
using namespace std;
int main() {
int secret = 15;
int guess = 0;
// "!=" is the "not-equal" conditional:
while(guess != secret) { // Compound statement
cout << "guess the number: ";
cin >> guess;
}
cout << "You guessed it!" << endl;
} ///:∼
while的条件表达式不限于简单的测试,如清单 3-3;只要能产生一个true或false结果,它就可以像你喜欢的那样复杂。您甚至会看到代码中没有循环体,只有一个分号,就像这样:
while(/* Do a lot here */)
;
在这些情况下,程序员编写条件表达式不仅是为了执行测试,也是为了完成工作。
使用 do-while
do-while的形式是
do
statement
while(expression);
do-while不同于while,因为语句总是至少执行一次,即使表达式第一次计算结果为假。在常规的while中,如果条件第一次为假,语句永远不会执行。
如果在Guess.cpp中使用了do-while,如清单 3-4 所示,变量guess不需要初始虚拟值,因为它在被测试之前已经被cin语句初始化了。
清单 3-4 。使用 do-while
//: C03:Guess2.cpp
// The guess program using do-while
#include <iostream>
using namespace std;
int main() {
int secret = 15;
int guess; // No initialization needed here
do {
cout << "guess the number: ";
cin >> guess; // Initialization happens
} while(guess != secret);
cout << "You got it!" << endl;
} ///:∼
出于某种原因,大多数程序员倾向于避免使用do-while,而只使用while。
用于
一个for循环在第一次迭代前执行初始化。然后,它执行条件测试,并在每次迭代结束时,执行某种形式的步进。for回路的形式是
for(initialization; conditional; step)
statement
任何表达式初始化、条件、或步骤可能为空。初始化代码在最开始执行一次。在每次迭代之前测试条件(如果它在开始时评估为 false,则该语句永远不会执行)。在每个循环结束时,执行该步骤。
正如你在清单 3-5 中看到的,for循环通常用于计数任务。
清单 3-5 。用于
//: C03:Charlist.cpp
// Display all the ASCII characters
// Demonstrates "for"
#include <iostream>
using namespace std;
int main() {
for(int i = 0; i < 128; i = i + 1)
if (i != 26) // ANSI Terminal Clear screen
cout << " value: " << i
<< " character: "
<< char(i) // Type conversion
<< endl;
} ///:∼
您可能会注意到,变量i是在使用它的地方定义的,而不是在由大括号{表示的块的开始处。这与传统的过程语言(包括 C)不同,传统的过程语言要求所有变量都在块的开始定义。这将在本章后面讨论。
中断和继续关键字
在任何循环结构while、do-while,或for的主体内部,您可以使用break和continue来控制循环的流程。break退出循环,不执行循环中的其余语句。continue停止当前迭代的执行,并返回到循环的起点,开始新的迭代。
作为break和continue的例子,清单 3-6 包含了一个非常简单的菜单系统。
清单 3-6 。使用中断和继续关键字
//: C03:Menu.cpp
// Simple menu program demonstrating
// the use of "break" and "continue"
#include <iostream>
using namespace std;
int main() {
char c; // To hold response
while(true) {
cout << "MAIN MENU:" << endl;
cout << "l: left, r: right, q: quit -> ";
cin >> c;
if(c == 'q')
break; // Out of "while(1)"
if(c == 'l') {
cout << "LEFT MENU:" << endl;
cout << "select a or b: ";
cin >> c;
if(c == 'a') {
cout << "you chose 'a'" << endl;
continue; // Back to main menu
}
if(c == 'b') {
cout << "you chose 'b'" << endl;
continue; // Back to main menu
}
else {
cout << "you didn't choose a or b!"
<< endl;
continue; // Back to main menu
}
}
if(c == 'r') {
cout << "RIGHT MENU:" << endl;
cout << "select c or d: ";
cin >> c;
if(c == 'c') {
cout << "you chose 'c'" << endl;
continue; // Back to main menu
}
if(c == 'd') {
cout << "you chose 'd'" << endl;
continue; // Back to main menu
}
else {
cout << "you didn't choose c or d!"
<< endl;
continue; // Back to main menu
}
}
cout << "you must type l or r or q!" << endl;
}
cout << "quitting menu..." << endl;
} ///:∼
如果用户在主菜单中选择“q”,则使用break关键字退出;否则程序会无限期地继续执行。在每个子菜单选择之后,continue关键字用于弹出回到while循环的开始。
while(true)语句相当于说“永远做这个循环”当用户键入“q”时,break语句允许您打破这个无限的 while 循环
使用开关
一个switch语句基于一个整数表达式的值从代码片段中进行选择。其形式是
switch(selector) {
case integral-value1 : statement; break;
case integral-value2 : statement; break;
case integral-value3 : statement; break;
case integral-value4 : statement; break;
case integral-value5 : statement; break;
(...)
default: statement;
}
选择器是产生整数值的表达式。switch将选择器的结果与每个积分值进行比较。如果找到匹配项,就会执行相应的语句(简单语句或复合语句)。如果不匹配,则执行default语句。
您会注意到在上面的定义中,每个case都以一个break结束,这导致执行跳转到switch体的末尾(结束switch的右括号)。这是构建switch语句的常规方式,但是break是可选的。如果它不见了,你的case会跳到它后面的一个;也就是说,下面的case语句的代码一直执行到遇到break为止。虽然您通常不希望出现这种行为,但是对于一个有经验的程序员来说,这是非常有用的。
switch语句是一种实现多路选择(即从多个不同的执行路径中进行选择)的干净方式,但是它需要一个在编译时计算整数值的选择器。例如,如果你想使用一个string对象作为选择器,它在switch语句中就不起作用。对于一个string选择器,你必须使用一系列的if语句并比较条件中的string。
清单 3-7 中的菜单例子提供了一个特别好的switch例子。
清单 3-7 。使用开关
//: C03:Menu2.cpp
// A menu using a switch statement
#include <iostream>
using namespace std;
int main() {
bool quit = false; // Flag for quitting
while(quit == false) {
cout << "Select a, b, c or q to quit: ";
char response;
cin >> response;
switch(response) {
case 'a' : cout << "you chose 'a'" << endl;
break;
case 'b' : cout << "you chose 'b'" << endl;
break;
case 'c' : cout << "you chose 'c'" << endl;
break;
case 'q' : cout << "quitting menu" << endl;
quit = true;
break;
default : cout << "Please use a,b,c or q!"
<< endl;
}
}
} ///:∼
quit标志是一个bool,是“Boolean”的缩写,这是一种只能在 C++ 中找到的类型。它只能有关键字值true或false。选择“q”会将quit标志设置为true。下一次评估选择器时,quit == false返回false,因此while的主体不会执行。
使用和误用 goto
在 C++ 中支持goto关键字,因为它存在于 C 中。使用goto通常被认为是糟糕的编程风格,大多数时候确实如此。每当你使用goto的时候,看看你的代码,看看是否有另外一种方法。在极少数情况下,你可能会发现goto可以解决一个用其他方法无法解决的问题,但还是要仔细考虑。清单 3-8 是一个可能成为可信候选人的例子。
清单 3-8 。使用 goto
//: C03:gotoKeyword.cpp
// The infamous goto is supported in C++
#include <iostream>
using namespace std;
int main() {
long val = 0;
for(int i = 1; i < 1000; i++) {
for(int j = 1; j < 100; j += 10) {
val = i * j;
if(val > 47000)
goto DOWN;
// Break would only go to the outer 'for'
}
}
DOWN: // A label
cout << val << endl;
} ///:∼
另一种方法是设置一个在外部for循环中测试的布尔值,然后在内部 for 循环中执行一个break。然而,如果你有几个级别的for或while,这可能会变得尴尬。
递归
递归是一种有趣且有时有用的编程技术,通过它你可以调用你所在的函数。当然,如果这就是你所做的全部工作,你会一直调用你所在的函数,直到你耗尽内存,所以必须有一些方法来结束递归调用。在清单 3-9 中,这种触底是通过简单地说递归将只进行到cat超过“z”.来完成的
清单 3-9 。使用递归
//: C03:CatsInHats.cpp
// Simple demonstration of recursion
#include <iostream>
using namespace std;
void removeHat(char cat) {
for(char c = 'A'; c < cat; c++)
cout << " ";
if(cat <= 'Z') {
cout << "cat " << cat << endl;
removeHat(cat + 1); // Recursive call
} else
cout << "VOOM!!!" << endl;
}
int main() {
removeHat('A');
} ///:∼
在removeHat( )中可以看到,只要cat小于“Z”,就会在 removeHat( )内从调用removeHat( ),从而实现递归。每次调用removeHat( )时,它的参数都比当前的cat大 1,所以参数一直在增加。
当评估某种任意复杂的问题时,经常使用递归,因为对于解决方案,你并不受限于特定的“大小”;函数可以一直递归,直到问题结束。
操作员简介
您可以将运算符视为一种特殊类型的函数(您将了解到 C++ 运算符重载正是以这种方式处理运算符)。一个运算符接受一个或多个参数并生成一个新值。参数的形式与普通的函数调用不同,但效果是一样的。
根据您以前的编程经验,您应该对目前使用的操作符相当熟悉。加法(+)、减法和一元减号(-)、乘法(*)、除法(/)和赋值(=)这些概念在任何编程语言中都具有本质上相同的含义。本章后面将列举全部运算符。
优先
运算符优先级定义了当存在多个不同的运算符时,表达式的求值顺序。C 和 C++ 有确定求值顺序的特定规则。最容易记住的是乘法和除法发生在加法和减法之前。之后,如果一个表达式对你来说是不透明的,它可能对任何阅读代码的人来说都是不透明的,所以你应该使用括号来明确求值的顺序。例如,
A = X + Y - 2/2 + Z;
与带有特定括号组的相同语句有非常不同的含义,如
A = X + (Y - 2)/(2 + Z);
(试着用 X = 1,Y = 2,Z = 3 来评估结果。)
自动递增和自动递减
C,因此也是 C++,充满了快捷方式。快捷方式可以使代码更容易键入,有时更难阅读。也许 C 语言的设计者认为,如果你的眼睛不必扫描那么大的印刷区域,理解一段复杂的代码会更容易。
两个比较好的快捷方式是自动递增和自动递减操作符。您经常使用这些来改变循环变量,这些变量控制循环执行的次数。
自动减量操作符是--,意思是“减少一个单位”自动递增运算符是++,意思是“增加一个单位”例如,如果A是一个int,那么++A就相当于(A = A + 1)。结果,自动递增和自动递减运算符产生变量的值。如果操作符出现在变量之前,(即++A),则首先执行操作并产生结果值。如果运算符出现在变量之后(即A++),则产生当前值,然后进行运算;参见清单 3-10 。
清单 3-10 。自动递增和自动递减
//: C03:AutoIncrement.cpp
// Shows use of auto-increment
// and auto-decrement operators.
#include <iostream>
using namespace std;
int main() {
int i = 0;
int j = 0;
cout << ++i << endl; // Pre-increment
cout << j++ << endl; // Post-increment
cout << --i << endl; // Pre-decrement
cout << j-- << endl; // Post decrement
} ///:∼
如果你一直对“C++”这个名字感到疑惑,现在你明白了。它暗示着“超越 c 的一步”
数据类型简介
数据类型定义了你在自己编写的程序中使用存储(内存)的方式。通过指定数据类型,你可以告诉编译器如何创建一个特定的存储,以及如何操作这个存储。
数据类型可以是内置的,也可以是抽象的。内置数据类型是编译器能够理解的类型,是直接连接到编译器的类型。在 C 和 C++ 中,内置数据的类型几乎是相同的。相比之下,用户定义的数据类型是您或其他程序员作为类创建的数据类型。这些通常被称为抽象数据类型。编译器知道如何在启动时处理内置类型;它通过读取包含类声明的头文件来“学习”如何处理抽象数据类型(您将在后面的章节中了解这一点)。
基本内置类型
内置类型的标准 C 规范(由 C++ 继承)并没有说明每个内置类型必须包含多少位。相反,它规定了内置类型必须能够容纳的最小和最大值。当机器基于二进制时,这个最大值可以直接转换成保存该值所需的最小位数。但是,如果机器使用二进制编码的十进制(BCD)来表示数字,那么机器中保存每种数据类型的最大数字所需的空间量将会不同。可以存储在各种数据类型中的最小值和最大值在系统头文件limits.h和float.h中定义(在 C++ 中,您通常会用#include <climits>和<cfloat>来代替)。
C 和 C++ 有四种基本的内置数据类型,这里描述的是基于二进制的机器。一个char用于字符存储,使用最少 8 位(1 字节)的存储,尽管它可能更大。一个int存储一个整数,最少使用 2 个字节的存储空间。float和double类型存储浮点数,通常是 IEEE 浮点格式。float是单精度浮点,double是双精度浮点。
如上所述,您可以在作用域中的任何地方定义变量,并且可以同时定义和初始化它们。清单 3-11 显示了如何使用四种基本数据类型定义变量。
清单 3-11 。基本数据类型
//: C03:Basic.cpp
// Defining the four basic data
// types in C and C++
int main() {
// Definition without initialization:
char protein;
int carbohydrates;
float fiber;
double fat;
// Simultaneous definition & initialization:
char pizza = 'A', pop = 'Z';
int dongdings = 100, twinkles = 150,
heehos = 200;
float chocolate = 3.14159;
// Exponential notation:
double fudge_ripple = 6e-4;
} ///:∼
程序的第一部分定义了四种基本数据类型的变量,但没有初始化它们。如果你没有初始化一个变量,标准说它的内容是未定义的(通常,这意味着它们包含垃圾)。程序的第二部分同时定义和初始化变量(如果可能,最好在定义时提供一个初始值)。注意常量 6e-4 中指数符号的使用,意思是“6 乘以 10 的负四次方”
使用 bool、true 和 false
在bool成为标准 C++ 的一部分之前,每个人都倾向于使用不同的技术来产生类似布尔的行为。这产生了可移植性问题,并可能引入微妙的错误。
标准 C++ bool类型可以有两种状态,由内置常量true(转换为整数 1)和false(转换为整数 0)表示。
三个名字都是关键词。此外,还改编了一些语言元素,如表 3-1 所示。
表 3-1 。C++(附加)语言元素
| 元素 | bool 的用法 |
|---|---|
&& || ! | 接受 bool 参数并产生bool结果。 |
<><= >= == != | 产生bool结果。 |
if, for, while, do | 条件表达式转换为bool值。 |
? : | 第一个操作数转换为bool值。 |
因为有很多现有的代码使用一个int来表示一个标志,编译器将隐式地从一个int转换成一个bool(非零值将产生true,而零值将产生false)。理想情况下,编译器会给你一个警告,作为纠正这种情况的建议。
属于糟糕编程风格的一个习惯用法是使用++将标志设置为 true。这仍然是允许的,但是已经废弃了,这意味着在未来的某个时候,这将被视为非法。问题是,您正在进行从bool到int的隐式类型转换,增加值(可能超出了正常的 0 和 1 的bool值的范围),然后再隐式转换回来。
指针(将在本章后面介绍)也会在必要时自动转换成bool。
使用说明符
说明符修改了基本内置类型的含义,并将它们扩展到一个更大的集合。有四个说明符:long、short、signed和unsigned。
long和short修改一个数据类型将保存的最大值和最小值。一辆普通的int至少要有一辆short那么大。整数类型的大小等级为shortint、int、longint。只要满足最小/最大值要求,所有尺寸都可以相同。例如,在 64 位字的机器上,所有的数据类型都可能是 64 位。
浮点数的大小层次是float、double和longdouble。“长浮”不是合法类型。没有short浮点数。
signed和unsigned说明符告诉编译器如何使用整数类型和字符的符号位(浮点数总是包含一个符号)。一个unsigned数不跟踪符号,因此有一个额外的位可用,所以它可以存储两倍于一个signed数所能存储的正数。signed是默认设置,只有char才有必要;char可能会也可能不会默认为signed。通过指定signed char,可以强制使用符号位。
清单 3-12 通过使用sizeof操作符显示了数据类型的大小,这将在本章后面介绍。
清单 3-12 。使用说明符
//: C03:Specify.cpp
// Demonstrates the use of specifiers
#include <iostream>
using namespace std;
int main() {
char c;
unsigned char cu;
int i;
unsigned int iu;
short int is;
short iis; // Same as short int
unsigned short int isu;
unsigned short iisu;
long int il;
long iil; // Same as long int
unsigned long int ilu;
unsigned long iilu;
float f;
double d;
long double ld;
cout
<< "\n char = " << sizeof(c)
<< "\n unsigned char = " << sizeof(cu)
<< "\n int = " << sizeof(i)
<< "\n unsigned int = " << sizeof(iu)
<< "\n short = " << sizeof(is)
<< "\n unsigned short = " << sizeof(isu)
<< "\n long = " << sizeof(il)
<< "\n unsigned long = " << sizeof(ilu)
<< "\n float = " << sizeof(f)
<< "\n double = " << sizeof(d)
<< "\n long double = " << sizeof(ld)
<< endl;
} ///:∼
请注意,运行这个程序得到的结果可能会因机器/操作系统/编译器的不同而不同,因为(如前所述)唯一必须一致的是,每个不同的类型都包含标准中指定的最小值和最大值。
当你用short或long修改int时,关键字int是可选的,如上图所示。
指针简介
每当你运行一个程序,它首先被加载(通常从磁盘)到计算机的内存中。因此,程序的所有元素都位于内存的某个地方。存储器通常被布置成一系列连续的存储器位置;我们通常将这些位置称为 8 位字节,但实际上每个空间的大小取决于特定机器的架构,通常称为该机器的字长。每个空间都可以通过其地址与所有其他空间进行唯一区分。为了讨论的目的,让我们假设所有的机器都使用字节,这些字节有连续的地址,从零开始,一直到你的计算机有多少内存。
因为你的程序在运行时是存在内存中的,所以程序的每个元素都有一个地址。清单 3-13 是一个简单的程序。
清单 3-13 。简单的程序
//: C03:YourPets1.cpp
#include <iostream>
using namespace std;
int dog, cat, bird, fish;
void f(int pet) {
cout << "pet id number: " << pet << endl;
}
int main() {
int i, j, k;
} ///:∼
当程序运行时,程序中的每个元素在存储器中都有一个位置。甚至函数也占用存储。正如您将看到的,原来元素是什么以及您定义它的方式通常决定了该元素所在的内存区域。
在 C 和 C++ 中有一个运算符会告诉你一个元素的地址。这是&运算符。您所做的就是在标识符名称前面加上&,它将产生该标识符的地址。YourPets1.cpp可以被修改以打印出其所有元素的地址,如清单 3-14 所示。
清单 3-14 。修改程序
//: C03:YourPets2.cpp
#include <iostream>
using namespace std;
int dog, cat, bird, fish;
void f(int pet) {
cout << "pet id number: " << pet << endl;
}
int main() {
int i, j, k;
cout << "f(): " << (long)&f << endl;
cout << "dog: " << (long)&dog << endl;
cout << "cat: " << (long)&cat << endl;
cout << "bird: " << (long)&bird << endl;
cout << "fish: " << (long)&fish << endl;
cout << "i: " << (long)&i << endl;
cout << "j: " << (long)&j << endl;
cout << "k: " << (long)&k << endl;
} ///:∼
这个(long)是一个剧组的。上面写着“不要把这个当成正常类型;而是把它当成一个long。”这种转换并不重要,但是如果不存在,地址就会以十六进制形式打印出来,所以转换为long使得可读性更好一些。
这个程序的结果会因你的电脑、操作系统和其他各种因素而有所不同,但它总会给你一些有趣的见解。在我的电脑上运行一次,结果如下:
f(): 4198736
dog: 4323632
cat: 4323636
bird: 4323640
fish: 4323644
i: 6684160
j: 6684156
k: 6684152
您可以看到在main( )内部定义的变量与在main( )外部定义的变量位于不同的区域;随着你对这门语言了解的越来越多,你就会明白为什么了。还有,f( )似乎是在它自己的地区;在内存中,代码通常与数据分离。
另一个值得注意的有趣的事情是,一个接一个定义的变量在内存中是连续放置的。它们由数据类型所需的字节数分隔。这里唯一使用的数据类型是int,并且cat距离dog4 个字节,bird距离cat4 个字节,以此类推。因此,在这台机器上,int似乎有 4 个字节长。
除了这个有趣的实验展示了记忆是如何映射出来的,你还能用地址做什么呢?您可以做的最重要的事情是将它存储在另一个变量中以备后用。C 和 C++ 有一种特殊类型的变量来保存地址。这个变量叫做指针。
定义指针的操作符和用于乘法的操作符是一样的:*。编译器知道它不是乘法,因为它在上下文中被使用,你会看到。
定义指针时,必须指定它所指向的变量的类型。首先给出类型名,然后不是立即给出变量的标识符,而是在类型和标识符之间插入一个星号,说“等等,这是一个指针”。所以一个指向int的指针看起来像这样:
int* ip; // ip points to an int variable
*与类型的关联看起来很合理,也很容易阅读,但实际上可能有点欺骗性。你可能倾向于说“intpointer ”,好像它是一个单独的离散类型。然而,对于int或其他基本数据类型,可以说
int a, b, c;
而对于指针,你会喜欢说
int* ipa, ipb, ipc;
C 语法(通过继承,C++ 语法)不允许这样明智的表达式。在上面的定义中,只有ipa是指针,而ipb和ipc是普通的int(你可以说“* 与标识符绑定得更紧)。因此,每行只使用一个定义可以获得最佳结果;您仍然可以获得合理的语法而不会产生混淆,就像这样:
int* ipa;
int* ipb;
int* ipc;
由于 C++ 编程的一般准则是您应该总是在定义时初始化变量,所以这种形式实际上效果更好。例如,上面的变量没有被初始化为任何特定的值;他们装垃圾。这样说要好得多
int a = 47;
int* ipa = &a;
现在a和ipa都已经初始化,ipa保存着a的地址。
一旦你有了一个初始化的指针,你能用它做的最基本的事情就是用它来修改它所指向的值。要通过一个指针访问一个变量,你需要使用定义它的操作符来解引用这个指针,比如:
*ipa = 100;
现在a包含的值是 100 而不是 47。
这些是指针的基础:你可以保存一个地址,你可以用这个地址来修改原始变量。但是问题仍然存在:为什么要用一个变量作为代理来修改另一个变量呢?
对于指针的这个介绍性观点,我可以将答案分为两大类:
- 从函数内部改变“外部对象”。这可能是指针最基本的用法,我们将在这里对其进行研究。
- 来实现许多其他聪明的编程技术,您将在本书的其余部分了解这些技术。
修改外部对象
通常,当您将一个参数传递给一个函数时,会在函数内部创建该参数的副本。这被称为按值传递。您可以在清单 3-15 中看到按值传递的效果。
清单 3-15 。按值传递
//: C03:PassByValue.cpp
#include <iostream>
using namespace std;
void f(int a) {
cout << "a = " << a << endl;
a = 5;
cout << "a = " << a << endl;
}
int main() {
int x = 47;
cout << "x = " << x << endl;
f(x);
cout << "x = " << x << endl;
} ///:∼
在f( )中,a是一个局部变量,所以它只在对f( )的函数调用期间存在。因为是函数参数,a的值由调用函数时传递的参数初始化;在main( )中,参数是值为 47 的x,,所以当调用f( )时,这个值被复制到a中。
当你运行这个程序时,你会看到
x = 47
a = 47
a = 5
x = 47
当然,x最初是 47。当调用f( )时,会创建临时空间来保存函数调用期间的变量a,并通过复制x的值来初始化a,通过打印输出来验证。当然,你可以改变a的值,显示它被改变了。但是当f( )完成时,为a创建的临时空间消失了,您会看到a和x之间曾经存在的唯一连接发生在x的值被复制到a时。
当你在f( )内部时,x是外部对象(按照我的术语),改变局部变量不会影响外部对象,这是很自然的,因为它们是存储中两个独立的位置。但是如果你做想修改外部对象呢?这就是指针派上用场的地方。从某种意义上说,指针是另一个变量的别名。因此,如果你将一个指针而不是一个普通的值传递给一个函数,你实际上是将一个别名传递给外部对象,使函数能够修改那个外部对象,如清单 3-16 所示。
清单 3-16 。说明别名的传递
//: C03:PassAddress.cpp
#include <iostream>
using namespace std;
void f(int* p) {
cout << "p = " << p << endl;
cout << "*p = " << *p << endl;
*p = 5;
cout << "p = " << p << endl;
}
int main() {
int x = 47;
cout << "x = " << x << endl;
cout << "&x = " << &x << endl;
f(&x);
cout << "x = " << x << endl;
} ///:∼
现在f( )将一个指针作为参数,并在赋值过程中取消对该指针的引用,这将导致外部对象x被修改。输出是
x = 47
&x = 0065FE00
p = 0065FE00
*p = 47
p = 0065FE00
x = 5
请注意,p中包含的值与x的地址相同;指针p确实指向了x。如果这还不够令人信服的话,当p被解引用来赋值 5 时,您会看到x的值现在也变成了 5。
因此,将指针传入函数将允许该函数修改外部对象。稍后您将看到指针的许多其他用途,但这无疑是最基本的,也可能是最常见的用途。
C++ 参考文献介绍
指针在 C 和 C++ 中的工作方式大致相同,但是 C++ 增加了一种将地址传递给函数的方法。这是按引用传递,它存在于其他几种编程语言中,所以它不是 C++ 的发明。
您最初对引用的理解可能是它们是不必要的,您可以编写所有没有引用的程序。一般来说,这是真的,除了几个重要的地方,你将在本书的后面了解到。稍后您还将了解更多关于引用的内容,但基本思想与上面的指针使用演示相同:您可以使用引用传递参数的地址。引用和指针的区别在于,从语法上来说,调用接受引用的函数比调用接受指针的函数更干净(正是这种语法差异使得引用在某些情况下至关重要)。如果PassAddress.cpp被修改为使用引用,你可以在清单 3-17 中的main( )中看到函数调用的不同。
清单 3-17 。说明按引用传递
//: C03:PassReference.cpp
#include <iostream>
using namespace std;
void f(int& r) {
cout << "r = " << r << endl;
cout << "&r = " <<&r << endl;
r = 5;
cout << "r = " << r << endl;
}
int main() {
int x = 47;
cout << "x = " << x << endl;
cout << "&x = " << &x << endl;
f(x); // Looks like pass-by-value,
// is actually pass by reference
cout << "x = " << x << endl;
} ///:∼
在f( )的参数列表中,不是说int*来传递指针,而是说int&来传递引用。在f( )中,如果你只说r(如果r是指针,就会产生地址)你就会得到变量中的值 r 引用。如果给r赋值,实际上就是给r引用的变量赋值。事实上,获取保存在r中的地址的唯一方法是使用&操作符。
在main( )中,你可以看到引用在对f( )的调用的语法中的关键作用,它只是f(x)。尽管这看起来像普通的传值,但引用的效果是它实际上接受地址并传入,而不是复制值。输出是
x = 47
&x = 0065FE00
r = 47
&r = 0065FE00
r = 5
x = 5
所以你可以看到按引用传递允许一个函数修改外部对象,就像传递一个指针一样(你也可以观察到引用掩盖了一个地址正在被传递的事实;这将在本书的后面部分进行讨论)。因此,对于这个简单的介绍,您可以假设引用只是一种语法上不同的方式(有时被称为语法糖)来完成与指针相同的事情:允许函数改变外部对象。
作为修饰符的指针和引用
到目前为止,您已经看到了基本数据类型char、int、float,和double,以及说明符signed、unsigned、short,和long,这些说明符几乎可以与基本数据类型以任何组合使用。现在,您已经添加了与基本数据类型和说明符*、*正交的指针和引用,因此可能的组合增加了三倍;参见清单 3-18 。
清单 3-18 。所有可能的组合
//: C03:AllDefinitions.cpp
// All possible combinations of basic data types,
// specifiers, pointers and references
#include <iostream>
using namespace std;
void f1(char c, int i, float f, double d);
void f2(short int si, long int li, long double ld);
void f3(unsigned char uc, unsigned int ui,
unsigned short int usi, unsigned long int uli);
void f4(char* cp, int* ip, float* fp, double* dp);
void f5(short int* sip, long int* lip,
long double* ldp);
void f6(unsigned char* ucp, unsigned int* uip,
unsigned short int* usip,
unsigned long int* ulip);
void f7(char& cr, int& ir, float& fr, double& dr);
void f8(short int& sir, long int& lir,
long double& ldr);
void f9(unsigned char& ucr, unsigned int& uir,
unsigned short int& usir,
unsigned long int& ulir);
int main() {} ///:∼
指针和引用在将对象传入和传出函数时也起作用;你将在后面的章节中了解到这一点。
还有一种类型可以使用指针:void。如果你声明一个指针是一个void*,这意味着任何类型的地址都可以分配给这个指针(然而如果你有一个int*,你只能分配一个int变量的地址给这个指针)。例如,参见清单 3-19 。
清单 3-19 。空指针
//: C03:VoidPointer.cpp
int main() {
void* vp;
char c;
int i;
float f;
double d;
// The address of ANY type can be
// assigned to a void pointer:
vp = &c;
vp = &i;
vp = &f;
vp = &d;
} ///:∼
一旦你给一个void*赋值,你就失去了任何关于它是什么类型的信息。这意味着在你使用指针之前,你必须将它转换成正确的类型,如清单 3-20 所示。
清单 3-20 。从空指针强制转换
//: C03:CastFromVoidPointer.cpp
int main() {
int i = 99;
void* vp = &i;
// Can't dereference a void pointer:
// *vp = 3; // Compile time error
// Must cast back to int before dereferencing:
*((int*)vp) = 3;
} ///:∼
强制转换(int*)vp接受void*并告诉编译器把它当作一个int*,这样就可以成功地取消引用。您可能会注意到这种语法很难看,的确如此,但比这更糟糕的是—void*在语言的类型系统中引入了一个漏洞。也就是说,它允许甚至促进将一种类型作为另一种类型对待。在清单 3-19 中,通过将vp强制转换为int*,一个int被视为一个int,但是没有说它不能被强制转换为char*或double*,这将修改已经为int分配的不同数量的存储,可能会使程序崩溃。一般来说,应该避免使用void指针,并且只在极少数特殊情况下使用,像这样的情况直到本书的后面部分才会考虑。
你不能有一个void参考,原因将在第十一章中解释。
理解范围
作用域规则告诉你变量在哪里有效,在哪里被创建,在哪里被销毁(即超出作用域)。变量的范围从定义该变量的位置延伸到定义该变量之前与最近的左大括号匹配的第一个右大括号。也就是说,作用域是由其“最近的”一组大括号定义的。清单 3-21 说明了这一点。
清单 3-21 。辖域
//: C03:Scope.cpp
// How variables are scoped
int main() {
int scp1;
// scp1 visible here
{
// scp1 still visible here
//.....
int scp2;
// scp2 visible here
//.....
{
// scp1 & scp2 still visible here
//..
int scp3;
// scp1, scp2 & scp3 visible here
// ...
} // <-- scp3 destroyed here
// scp3 not available here
// scp1 & scp2 still visible here
// ...
} // <-- scp2 destroyed here
// scp3 & scp2 not available here
// scp1 still visible here
//..
} // <-- scp1 destroyed here
///:∼
代码显示了变量何时可见,何时不可用(也就是说,当它们超出范围)。变量只能在其作用域内使用。范围可以嵌套,由匹配的大括号对在其他匹配的大括号对内表示。嵌套意味着您可以在包围您所在作用域的作用域中访问变量。在清单 3-21 中,变量scp1在所有其他作用域中都可用,而scp3只在最里面的作用域可用。
动态定义变量
正如本章前面提到的,C 和 C++ 在定义变量时有很大的不同。这两种语言都要求在使用变量之前定义它们,但是 C(和许多其他传统的过程语言)强迫你在一个作用域的开始定义所有的变量,这样当编译器创建一个块时,它可以为这些变量分配空间。
阅读 C 代码时,当进入作用域时,通常首先看到的是一组变量定义。由于语言的实现细节,在块的开始声明所有变量需要程序员以特定的方式编写。大多数人在编写代码之前并不知道他们将要使用的所有变量,所以他们必须不断地跳回到代码块的开头来插入新的变量,这很笨拙,并且会导致错误。这些变量定义通常对读者来说意义不大,而且它们实际上容易让人混淆,因为它们出现在使用它们的上下文之外。
C++(但是不是 C)允许你在作用域的任何地方定义变量,所以你可以在使用变量之前定义它。此外,您可以在定义变量时对其进行初始化,这可以防止某类错误。以这种方式定义变量使代码更容易编写,并减少了被迫在一个范围内来回跳转所导致的错误。它使代码更容易理解,因为您看到的是在其使用上下文中定义的变量。当您同时定义和初始化一个变量时,这一点尤其重要——您可以通过变量的使用方式来了解初始化值的含义。
您还可以在for循环和while循环的控制表达式内、在if语句的条件内以及在switch的选择器语句内定义变量。清单 3-22 显示了动态变量定义。
清单 3-22 。动态变量定义
//: C03:OnTheFly.cpp
// On-the-fly variable definitions
#include <iostream>
using namespace std;
int main() {
//..
{ // Begin a new scope
int q = 0; // C requires definitions here
//..
// Define at point of use:
for(int i = 0; i < 100; i++) {
q++; // q comes from a larger scope
// Definition at the end of the scope:
int p = 12;
}
int p = 1; // A different p
} // End scope containing q & outer p
cout << "Type characters:" << endl;
while(char c = cin.get() != 'q') {
cout << c << " wasn't it" << endl;
if(char x = c == 'a' || c == 'b')
cout << "You typed a or b" << endl;
else
cout << "You typed " << x << endl;
}
cout << "Type A, B, or C" << endl;
switch(int i = cin.get()) {
case 'A': cout << "Snap" << endl; break;
case 'B': cout << "Crackle" << endl; break;
case 'C': cout << "Pop" << endl; break;
default: cout << "Not A, B or C!" << endl;
}
} ///:∼
在最里面的作用域中,p是在作用域结束之前定义的,所以这确实是一个无用的手势(但是它表明你可以在任何地方定义变量)。外作用域的p也是同样的情况。
在for循环的控制表达式中对i的定义就是一个例子,它能够在你需要的时候准确地定义变量*(只有在 C++ 中才能做到这一点)。i的范围是由for循环控制的表达式的范围,所以你可以在下一个for循环中调转方向重用i。这是 C++ 中一个方便且常用的习惯用法;i是循环计数器的经典名称,您不必不断发明新名称。*
*虽然这个例子也显示了在while、if,和switch语句中定义的变量,但是这种定义比for表达式中的定义要少得多,这可能是因为语法受到了很大的限制。例如,不能有任何括号。也就是说,你不能说
while((char c = cin.get()) != 'q')
添加额外的括号似乎是一件无辜而有用的事情,因为您不能使用它们,所以结果可能不是您想要的。问题的出现是因为!=比=具有更高的优先级,所以charc最终包含一个被转换为char的bool。打印出来后,在许多终端上你会看到一个笑脸字符。
一般来说,您可以考虑在while、if和switch语句中定义变量的能力,这是为了完整性,但是您可能使用这种变量定义的唯一地方是在for循环中(您会经常使用它)。
指定存储分配
创建变量时,您有许多选项来指定变量的生存期、如何为该变量分配存储以及编译器如何处理该变量。
全局变量
全局变量在所有函数体之外定义,可用于程序的所有部分(甚至是其他文件中的代码)。全局变量不受作用域的影响,并且总是可用的(例如,全局变量的生存期一直持续到程序结束)。如果一个文件中全局变量的存在是在另一个文件中使用extern关键字声明的,那么该数据可供第二个文件使用。清单 3-23 是使用全局变量的一个例子。
清单 3-23 。使用全局变量
//: C03:Global.cpp
//{L} Global2
// Demonstration of global variables
#include <iostream>
using namespace std;
int globe;
void func();
int main() {
globe = 12;
cout << globe << endl;
func(); // Modifies globe
cout << globe << endl;
} ///:∼
清单 3-24 访问globe作为extern。
清单 3-24 。访问全局变量
//: C03:Global2.cpp {O}
// Accessing external global variables
extern int globe;
// (The linker resolves the reference)
void func() {
globe = 47;
} ///:∼
变量globe的存储由Global.cpp ( 清单 3-23 )中的定义创建,同一变量由Global2.cpp ( 清单 3-24 )中的代码访问。由于Global2.cpp中的代码与Global.cpp中的代码是分开编译的,编译器必须通过声明得知变量存在于别处
extern int globe;
当您运行程序时,您会看到对func( )的调用确实影响了globe的单个全局实例。
在Global.cpp中,你可以看到特殊的注释标签
//{L} Global2
这意味着要创建最终的程序,必须链接名为Global2的目标文件(没有扩展名,因为不同系统的目标文件的扩展名不同)。在Global2.cpp中,第一行有另一个特殊的注释标签{O},,上面写着:“不要试图用这个文件创建可执行文件;它正在被编译,以便可以链接到其他可执行文件中。”
局部变量
局部变量出现在一个范围内;它们对于一个功能来说是“局部的”。它们通常被称为自动变量,因为它们在作用域进入时自动产生,在作用域关闭时自动消失。关键字auto使这变得显式,但是局部变量默认为auto,所以没有必要将某个东西声明为auto。
寄存器变量
寄存器变量是一种局部变量。关键字register告诉编译器尽可能快地访问这个变量。提高访问速度取决于具体实现,但是,顾名思义,这通常是通过将变量放入寄存器中来实现的。不保证变量会被放入寄存器中,甚至不保证访问速度会提高。这是对编译器的一个提示。
使用register变量有限制。您不能获取或计算register变量的地址。一个register变量只能在一个块中声明(不能有全局或staticregister变量)。然而,你可以使用一个register变量作为函数中的形式参数(例如,在参数列表中)。
一般来说,你不应该去猜测编译器的优化器,因为它可能比你做得更好。因此,最好避免使用register关键字。
静态关键字
关键字static有几个不同的含义。通常,被定义为函数局部变量的变量会在函数作用域结束时消失。当您再次调用该函数时,将重新创建变量的存储,并重新初始化这些值。如果你想让一个值在程序的整个生命周期中都存在,你可以定义一个函数的局部变量为static并给它一个初始值。仅在第一次调用函数时执行初始化,并且数据在函数调用之间保留其值。这样,函数可以在函数调用之间“记住”一些信息。
你可能想知道为什么不用全局变量来代替。一个static变量的美妙之处在于,它在函数范围之外是不可用的,所以它不可能被无意中改变。这使错误本地化。清单 3-25 显示了static变量的使用。
清单 3-25 。静态变量
//: C03:Static.cpp
// Using a static variable in a function
#include <iostream>
using namespace std;
void func() {
static int i = 0;
cout << "i = " << ++i << endl;
}
int main() {
for(int x = 0; x < 10; x++)
func();
} ///:∼
每次在 for 循环中调用func()时,它都会打印不同的值。如果不使用关键字static,打印的值将总是 1。
static的第二个意义与第一个意义相关,即“在一定范围之外不可用”。当static被应用于一个函数名或者一个在所有函数之外的变量时,这意味着“这个名字在这个文件之外是不可用的。”函数名或变量是文件的本地变量;我们说它有文件范围。作为示范,编译和链接清单 3-26 和清单 3-27 将导致链接器错误。
清单 3-26 。文件范围演示
//: C03:FileStatic.cpp
// File scope demonstration. Compiling and
// linking this file with FileStatic2.cpp
// will cause a linker error
// File scope means only available in this file:
static int fs;
int main() {
fs = 1;
} ///:∼
清单 3-27 。更多的演示
//: C03:FileStatic2.cpp {O}
// Trying to reference fs
extern int fs;
void func() {
fs = 100;
} ///:∼
即使在清单 3-27 中声称变量fs作为extern存在,链接器也不会发现它,因为它已经在FileStatic.cpp ( 清单 3-26 )中声明了static。
static说明符也可以在class中使用。这个解释将被推迟到你学习创建类的时候,这将在本书的后面发生。
extern 关键字
关键字extern已经被简单地描述和演示过了。它告诉编译器一个变量或函数存在,即使编译器还没有在当前编译的文件中看到它。这个变量或函数可以在另一个文件中定义,也可以在当前文件中定义。作为后者的一个例子,参见清单 3-28 。
清单 3-28 。extern 关键字
//: C03:Forward.cpp
// Forward function & data declarations
#include <iostream>
using namespace std;
// This is not actually external, but the
// compiler must be told it exists somewhere:
extern int i;
extern void func();
int main() {
i = 0;
func();
}
int i; // The data definition
void func() {
i++;
cout << i;
} ///:∼
当编译器遇到声明extern int i时,它知道i的定义必须作为全局变量存在于某个地方。当编译器到达i的定义时,看不到其他声明,所以它知道已经在文件中找到了前面声明的同一个i。如果你将i定义为static,你会告诉编译器I是全局定义的(通过extern),但是它也有文件范围(通过static),所以编译器会产生一个错误。
联动装置
要理解 C 和 C++ 程序的行为,你需要了解链接。在一个正在执行的程序中,一个标识符由保存一个变量或一个编译过的函数体的存储器来表示。链接描述了链接器所看到的这种存储。联动有两种:内部联动和外部联动。
内部链接 表示创建的存储只代表正在编译的文件的标识符。其他文件可能在内部链接中使用相同的标识符名称,或者对全局变量使用相同的标识符名称,链接器不会发现冲突——为每个标识符创建单独的存储。内部链接在 C 和 C++ 中由关键字static指定。
外部链接 意味着创建一个单独的存储来代表所有正在编译的文件的标识符。存储创建一次,链接器必须解析对该存储的所有其他引用。全局变量和函数名有外部链接。通过用关键字extern声明它们,可以从其他文件中访问它们。在所有函数(除了 C++ 中的const之外)和函数定义之外定义的变量默认为外部链接。你可以使用static关键字来强制他们进行内部链接。您可以通过用extern关键字定义一个标识符来明确声明它具有外部链接。用extern定义变量或函数在 C 中没有必要,但在 C++ 中对于const有时是必要的。
当函数被调用时,自动(局部)变量只是暂时存在于堆栈中。链接器不知道自动变量,所以这些变量没有链接。
常量
在旧的(标准之前的)C 中,如果你想做一个常量,你必须使用预处理器,就像这样:
#define PI 3.14159
在所有使用PI的地方,值 3.14159 都被预处理器替换了(在 C 和 C++ 中仍然可以使用这个方法)。
当您使用预处理器创建常量时,您将这些常量的控制权置于编译器的范围之外。对名字PI不执行类型检查,并且不能获取PI的地址(因此不能传递指向PI的指针或引用)。PI不能是用户自定义类型的变量。PI的含义从它被定义的点持续到文件的结尾;预处理器无法识别作用域。
C++ 引入了命名常量的概念,它就像一个变量,只是它的值不能改变。修饰符const告诉编译器一个名字代表一个常量。任何数据类型,无论是内置的还是用户定义的,都可以定义为const。如果您将某个东西定义为const,然后试图修改它,编译器将会产生一个错误。
您必须指定一个const的类型,如下所示:
const int x = 10;
在标准 C 和 C++ 中,您可以在参数列表中使用一个命名的常量,即使它填充的参数是一个指针或引用(也就是说,您可以使用一个const的地址)。一个const有一个作用域,就像一个常规变量一样,所以你可以在一个函数中“隐藏”一个const,并确保这个名字不会影响程序的其余部分。
const取自 C++ 并被合并到标准 C 中,尽管差别很大。在 C 语言中,编译器对待const就像一个附加了特殊标签的变量,标签上写着“不要改变我”当你在 C 中定义一个const时,编译器会为它创建存储,所以如果你在两个不同的文件中定义了不止一个同名的const(或者把定义放在头文件中),链接器会生成关于冲突的错误消息。const在 C 中的预期用途与其在 C++ 中的预期用途大相径庭(简而言之,在 C++ 中更好用)。
常量值
在 C++ 中,一个const必须总是有一个初始化值(在 C 中,这不是真的)。内置类型的常量值可以表示为十进制、八进制、十六进制或浮点数(遗憾的是,二进制数并不重要),或者表示为字符。
在没有任何其他线索的情况下,编译器假定一个常量值是一个十进制数。数字 47、0 和 1101 都被视为十进制数。
带有前导 0 的常量值被视为八进制数(基数为 8)。基数为 8 的数字只能包含数字 0-7;编译器将其他数字标记为错误。合法的八进制数是 017(以 10 为基数的 15)。
以 0x 开头的常量值被视为十六进制数(基数为 16)。以 16 为基数的数字包含数字 0–9 和 A–f 或 A–f,合法的十六进制数是 0x1fe(以 10 为基数的 510)。
浮点数可以包含小数点和指数幂(用 e 表示,意思是“10 的幂”)。小数点和e都是可选的。如果你把一个常量赋给一个浮点变量,编译器会把这个常量值转换成一个浮点数(这个过程是所谓的隐式类型转换 的一种形式)。然而,使用小数点或e来提醒读者您正在使用浮点数是一个好主意;一些较老的编译器也需要提示。
合法的浮点常量值是 1e4、1.0001、47.0、0.0 和-1.159e-77。可以添加后缀强制浮点数的类型:f或F强制一个float,L或l强制一个longdouble;否则号码将是一个double。
字符常量是用单引号括起来的字符,如:'A'、'0'、'。注意字符'??'(ASCII 96)和值0之间有很大的区别。特殊字符用反斜杠转义表示:'\n'(换行符)、'\t'(制表符)、'\\'(反斜杠)、'\r'(回车)、'\"'(双引号)、'\''(单引号)等。也可以用八进制:'\17'或十六进制:'??'来表示字符常量。
易变限定符
限定符const告诉编译器“这永远不会改变”(这允许编译器执行额外的优化),而限定符volatile告诉编译器“你永远不知道这何时会改变”,并阻止编译器基于该变量的稳定性执行任何优化。当您读取代码控制范围之外的一些值时,使用这个关键字,例如一个通信硬件中的寄存器。一个volatile变量总是在需要它的值的时候被读取,即使它刚刚被读取了一行。
一些存储“在你的代码控制之外”的特殊情况是在多线程程序中。如果你正在观察一个被另一个线程或进程修改的特殊标志,那么这个标志应该是volatile,这样编译器就不会假设它可以优化掉这个标志的多次读取。
注意,当编译器没有优化时,volatile可能没有任何效果,但当您开始优化代码时(此时编译器将开始寻找冗余读取),它可能会防止严重的错误。
const和volatile关键字将在后面的章节中进一步阐述。
运算符 及其用法
本节涵盖了 C 和 C++ 中的所有运算符。所有运算符都从其操作数中产生一个值。除了使用赋值、递增和递减运算符之外,不需要修改操作数就可以生成该值。修改一个操作数叫做副作用。修改操作数的运算符最常见的用途是产生副作用,但您应该记住,产生的值可供您使用,就像在没有副作用的运算符中一样。
分配
使用操作员=进行分配。它的意思是“把右边(通常称为右值)复制到左边(通常称为左值)。”右值是可以产生值的任何常量、变量或表达式,但左值必须是不同的命名变量(也就是说,必须有存储数据的物理空间)。例如,你可以给一个变量赋值一个常量值(A = 4;),但是你不能给常量值赋值——它不能是一个左值(你不能说4 = A;)。
数学运算符
基本的数学运算符与大多数编程语言中的运算符相同:加法(+)、减法(-)、除法(/)、乘法(*)和模数(%);这产生整数除法的余数)。整数除法会截断结果(不会舍入)。模数运算符不能用于浮点数。
C 和 C++ 也使用简写符号来同时执行一个操作和一个赋值。这由等号后面的运算符表示,并且与语言中的所有运算符一致(只要有意义)。例如,给变量x加 4,给结果赋值x,你说:x += 4;。
清单 3-29 展示了数学运算符的使用。
清单 3-29 。使用数学运算符
//: C03:Mathops.cpp
// Mathematical operators
#include <iostream>
using namespace std;
// A macro to display a string and a value.
#define PRINT(STR, VAR) \
cout << STR " = " << VAR << endl
int main() {
int i, j, k;
float u, v, w; // Applies to doubles, too
cout << "enter an integer: ";
cin >> j;
cout << "enter another integer: ";
cin >> k;
PRINT("j",j); PRINT("k",k);
i = j + k; PRINT("j + k",i);
i = j - k; PRINT("j - k",i);
i = k / j; PRINT("k / j",i);
i = k * j; PRINT("k * j",i);
i = k % j; PRINT("k % j",i);
// The following only works with integers:
j %= k; PRINT("j %= k", j);
cout << "Enter a floating-point number: ";
cin >> v;
cout << "Enter another floating-point number:";
cin >> w;
PRINT("v",v); PRINT("w",w);
u = v + w; PRINT("v + w", u);
u = v - w; PRINT("v - w", u);
u = v * w; PRINT("v * w", u);
u = v / w; PRINT("v / w", u);
// The following works for ints, chars,
// and doubles too:
PRINT("u", u); PRINT("v", v);
u += v; PRINT("u += v", u);
u -= v; PRINT("u -= v", u);
u *= v; PRINT("u *= v", u);
u /= v; PRINT("u /= v", u);
} ///:∼
当然,所有赋值的值可以更复杂。
预处理宏简介
注意使用宏PRINT( )来保存输入(和输入错误!).预处理宏通常都用大写字母来命名,以便突出。稍后您将了解到,宏可能很快变得危险(它们也可能非常有用)。
宏名后面的括号列表中的参数在右括号后面的所有代码中都被替换。预处理器删除名称PRINT并在调用宏的地方替换代码,因此编译器不会使用宏名生成任何错误消息,也不会对参数进行任何类型检查。
注意后者可能是有益的,详见本章末尾的调试宏。
关系运算符
关系运算符在操作数的值之间建立关系。如果关系为真,它们产生一个布尔值(在 C++ 中用关键字bool指定)true,如果关系为假,则产生false。关系运算符有小于(<)、大于(>)、小于或等于(<=)、大于或等于(>=)、等价(==)和不等价(!=)。它们可以用于 C 和 C++ 中的所有内置数据类型。在 C++ 中,它们可能被赋予用户定义数据类型的特殊定义。
注意你会在第十二章的中了解到这一点,其中涵盖了操作符重载。
逻辑运算符
逻辑运算符和 ( &&)和或 ( ||)根据其参数的逻辑关系产生一个true或false。请记住,在 C 和 C++ 中,如果一个语句有非零值,则该语句为true,如果该语句的值为零,则该语句为false。如果你打印一个bool,你通常会看到一个1代表true,一个0代表false。
清单 3-30 使用了关系和逻辑运算符。
清单 3-30 。使用关系和逻辑运算符
//: C03:Boolean.cpp
// Relational and logical operators.
#include <iostream>
using namespace std;
int main() {
int i,j;
cout << "Enter an integer: ";
cin >> i;
cout << "Enter another integer: ";
cin >> j;
cout << "i > j is " << (i > j) << endl;
cout << "i < j is " << (i < j) << endl;
cout << "i >= j is " << (i >= j) << endl;
cout << "i <= j is " << (i <= j) << endl;
cout << "i == j is " << (i == j) << endl;
cout << "i != j is " << (i != j) << endl;
cout << "i && j is " << (i && j) << endl;
cout << "i || j is " << (i || j) << endl;
cout << " (i < 10) && (j < 10) is "
<< ((i < 10) && (j < 10)) << endl;
} ///:∼
您可以在清单 3-30 中用float或double替换int的定义。但是,请注意,浮点数与零值的比较是严格的;一个数与另一个数相差极小,仍然是“不相等”比零大一点点的浮点数仍然是正确的。
按位运算符
按位运算符允许您操作数字中的单个位(因为浮点值使用特殊的内部格式,所以按位运算符仅适用于整数类型:char、int和long)。按位运算符对参数中的相应位执行布尔代数运算以产生结果。
如果两个输入位都是 1,按位和运算符(&)在输出位产生 1;否则它产生一个零。如果任一输入位为 1,按位或运算符(|)在输出位产生 1,只有当两个输入位都为 0 时才产生 0。如果一个或另一个输入位是 1,按位异或或异或 ( ^)在输出位产生 1,但不是两个都是 1。按位非 ( ∼,也叫一补码运算符)是一元运算符;它只接受一个参数(所有其他按位运算符都是二元运算符)。按位而非产生与输入位相反的值——如果输入位为零,则为 1;如果输入位为 1,则为 0。
按位运算符可以与=符号结合,以统一运算和赋值:&=、|=,和^=都是合法的运算(因为∼是一元运算符,所以不能与=符号结合)。
移位运算符
移位操作符也操纵比特。左移运算符(<<)将运算符左边的操作数向左移动运算符后面指定的位数。右移运算符(>>)将运算符左边的操作数向右移动运算符后面指定的位数。如果移位运算符后的值大于左操作数中的位数,则结果是未定义的。如果左边的操作数是无符号的,那么右移位就是逻辑移位,所以高位将用零填充。如果左操作数有符号,右移位可能是也可能不是逻辑移位(即*,*行为未定义)。
移位可以与等号(<<=和>>=)结合使用。左值被右值移位后的左值代替。
清单 3-31 是一个例子,展示了所有涉及位的操作符的使用。首先,有一个通用函数以二进制格式打印一个字节,这个函数是单独创建的,因此可以很容易地重用。头文件声明了函数。
清单 3-31 。所有涉及位的运算符
//: C03:printBinary.h
// Display a byte in binary
void printBinary(const unsigned char val);
///:∼
//Here's the implementation of the function:
//: C03:printBinary.cpp {O}
#include <iostream>
void printBinary(const unsigned char val) {
for(int i = 7; i >= 0; i--)
if(val & (1 << i))
std::cout << "1";
else
std::cout << "0";
} ///:∼
printBinary( )函数获取一个字节并逐位显示。
表情
(1 << i)
在每个连续的比特位置产生一个 1;以二进制表示:00000001、00000010 等。如果该位与val进行逐位和运算,结果为非零,则意味着在val的该位置有一个 1。
最后,这个函数用在清单 3-32 中,它显示了位操作符。
清单 3-32 。位操作运算符
//: C03:Bitwise.cpp
//{L} printBinary
// Demonstration of bit manipulation
#include "printBinary.h"
#include <iostream>
using namespace std;
// A macro to save typing:
#define PR(STR, EXPR) \
cout << STR; printBinary(EXPR); cout << endl;
int main() {
unsigned int getval;
unsigned char a, b;
cout << "Enter a number between 0 and 255: ";
cin >> getval; a = getval;
PR("a in binary: ", a);
cout << "Enter a number between 0 and 255: ";
cin >> getval; b = getval;
PR("b in binary: ", b);
PR("a | b = ", a | b);
PR("a & b = ", a & b);
PR("a ^ b = ", a ^ b);
PR("∼a = ", ∼a);
PR("∼b = ", ∼b);
// An interesting bit pattern:
unsigned char c = 0x5A;
PR("c in binary: ", c);
a |= c;
PR("a |= c; a = ", a);
b &= c;
PR("b &= c; b = ", b);
b ^= a;
PR("b ^= a; b = ", b);
} ///:∼
再一次,预处理宏被用来保存输入。它打印您选择的字符串,然后是表达式的二进制表示,然后是换行符。
在main( )中,变量是unsigned。这是因为,一般来说,当你处理字节时,你不想要符号。对于getval,必须使用int而不是char,因为cin >>语句会将第一个数字视为一个字符。通过将getval分配给a和b,该值被转换为单个字节(通过截断)。
<<和>>提供了位移位行为,但是当它们将位移出数字的末尾时,这些位就丢失了(通常说它们落入了神话中的位桶,一个被丢弃的位结束的地方,大概是为了它们可以被重用。。。).当操作比特时,你还可以执行旋转,这意味着从一端掉下来的比特会被插回到另一端,就像它们在绕着一个环旋转一样。尽管大多数计算机处理器都提供了机器级的 rotate 命令(因此您会在该处理器的汇编语言中看到它),但在 C 或 C++ 中没有对“rotate”的直接支持。大概 C 语言的设计者觉得关闭“旋转”是有道理的(正如他们所说,是为了一种最小语言),因为你可以构建自己的旋转命令。
例如,清单 3-33 显示了执行左右旋转的函数。
清单 3-33 。旋转
//: C03:Rotation.cpp {O}
// Perform left and right rotations
unsigned char rol(unsigned char val) {
int highbit;
if(val & 0x80) // 0x80 is the high bit only
highbit = 1;
else
highbit = 0;
// Left shift (bottom bit becomes 0):
val <<= 1;
// Rotate the high bit onto the bottom:
val |= highbit;
return val;
}
unsigned char ror(unsigned char val) {
int lowbit;
if(val & 1) // Check the low bit
lowbit = 1;
else
lowbit = 0;
val >>= 1; // Right shift by one position
// Rotate the low bit onto the top:
val |= (lowbit << 7);
return val;
} ///:∼
尝试在Bitwise.cpp中使用这些功能。注意rol( )和ror( )的定义(或者至少是声明)必须在函数被使用之前被Bitwise.cpp中的编译器看到。
按位函数通常使用起来非常有效,因为它们直接翻译成汇编语言语句。有时,一条 C 或 C++ 语句会生成一行汇编代码。
一元运算符
Bitwise not 不是唯一接受单个参数的运算符。它的同伴逻辑非 ( !),将接受一个true值并产生一个false值。一元减号(-)和一元加号(+)与二元减号和加号是相同的运算符;编译器通过你写表达式的方式来判断你想要的用法。例如,语句
x = -a;
有着明显的含义。编译器可以算出
x = a * -b;
但是读者可能会感到困惑,所以更安全的说法是
x = a * (-b);
一元减号产生值的负数。一元加号与一元减号一起提供了对称性,尽管它实际上并不做任何事情。
本章前面介绍了递增和递减运算符(++和--)。除了那些涉及赋值的操作符之外,只有这些操作符有副作用。这些运算符将变量增加或减少一个单位,尽管“单位”根据数据类型可能有不同的含义——对于指针尤其如此。
最后一个一元运算符是 C 和 C++ 中的 address-of ( &)、dereference ( *和->)和 cast 运算符,以及 C++ 中的new和delete。Address-of 和 dereference 与指针一起使用,如本章所述。铸造在本章后面介绍,new和delete在第四章 中介绍。
三元运算符
三元组if-else不常见,因为它有三个操作数。它是一个真正的操作符,因为它产生一个值,不像普通的if-else语句。它由三个表达式组成:如果第一个表达式(后跟一个?)的计算结果为true,那么?后面的表达式将被计算,其结果将成为操作符产生的值。如果第一个表达式是false,则执行第三个表达式(在 a :之后),其结果成为运算符产生的值。
条件运算符可用于其副作用或其产生的值。下面的代码片段演示了这两种情况:
a = --b ? b : (b = -99);
这里,条件产生右值。如果递减b的结果为非零,则将a赋给b的值。如果b变成零,a和b都被分配到-99。b总是递减,但只有当递减导致b变为 0 时,它才被赋值为-99。一个类似的陈述可以在没有a =的情况下使用,只是为了它的副作用:
--b ? b : (b = -99);
这里第二个 B 是多余的,因为运算符产生的价值没有被使用。在?和:之间需要一个表达式。在这种情况下,表达式可以简单地是一个常量,它可能会使代码运行得更快一些。
逗号运算符
逗号不限于在多个定义中分隔变量名,例如
int i, j, k;
当然,它也用在函数参数列表中。但是,它也可以用作分隔表达式的运算符,在这种情况下,它只产生最后一个表达式的值。逗号分隔列表中的所有其他表达式仅针对其副作用进行评估。
清单 3-34 增加一个变量列表,并使用最后一个作为右值。
清单 3-34 。使用逗号运算符
//: C03:CommaOperator.cpp
#include <iostream>
using namespace std;
int main() {
int a = 0, b = 1, c = 2, d = 3, e = 4;
a = (b++, c++, d++, e++);
cout << "a = " << a << endl;
// The parentheses are critical here. Without
// then, the statement will evaluate to:
(a = b++), c++, d++, e++;
cout << "a = " << a << endl;
} ///:∼
一般来说,最好避免使用逗号作为除分隔符之外的任何东西,因为人们不习惯将其视为运算符。
使用运算符时的常见陷阱
如上所述,使用操作符时的一个陷阱是,当你甚至一点也不确定一个表达式将如何求值时,试图摆脱没有括号的情况(关于表达式求值的顺序,请查阅你当地的 C 手册)。清单 3-35 显示了另一个极其常见的错误。
清单 3-35 。常见陷阱
//: C03:Pitfall.cpp
// Operator mistakes
int main() {
int a = 1, b = 1;
while(a = b) {
// ....
}
} ///:∼
当b不为零时,语句a = b将总是评估为真。变量a赋给b的值,b的值也是由运算符=产生的。一般来说,您希望在条件语句中使用等价运算符==,而不是赋值。这个咬了很多程序员(不过,有些编译器会给你指出问题,这是有帮助的)。
一个类似的问题是使用按位和和或而不是它们的逻辑对应物。按位和和或使用其中一个字符(&或|,而逻辑和和或使用两个(&&和||)。就像=和==一样,很容易只输入一个字符而不是两个。一个有用的助记手段是观察比特更小,所以它们不需要在它们的操作符中有同样多的字符。
铸造操作员
单词 cast 用于“铸造成一个模子”的意思如果有意义,编译器会自动将一种数据类型转换成另一种。例如,如果你将一个整数值赋给一个浮点变量,编译器将秘密调用一个函数(或者更有可能是插入代码)将int转换成float。强制转换允许您显式地进行这种类型转换,或者在通常不会发生的情况下强制进行这种转换。
要执行强制转换,请将所需的数据类型(包括所有修饰符)放在值左侧的括号中。该值可以是变量、常量、表达式产生的值或函数的返回值。清单 3-36 就是一个例子。
清单 3-36 。简单造型
//: C03:SimpleCast.cpp
int main() {
int b = 200;
unsigned long a = (unsigned long int)b;
} ///:∼
强制转换是强大的,但是它会导致令人头疼的问题,因为在某些情况下,它会迫使编译器将数据当作(比如)比实际大的数据来处理,因此它会占用更多的内存空间;这可能会破坏其他数据。这通常发生在对指针进行造型时,而不是像清单 3-36 中的简单造型时。
C++ 有一个附加的转换语法,它遵循函数调用语法。这种语法将圆括号放在参数周围,就像函数调用一样,而不是数据类型周围;参见清单 3-37 。
清单 3-37 。函数调用转换
//: C03:FunctionCallCast.cpp
int main() {
float a = float(200);
// This is equivalent to:
float b = (float)200;
} ///:∼
当然,在这种情况下,你并不真的需要石膏;你可以只说200.f或200.0f(实际上,编译器通常会对上面的表达式这么做)。强制转换通常用于变量,而不是常量。
C++ 显式强制转换
应该小心使用强制转换,因为您实际上是在对编译器说“忘记类型检查,而是把它当作另一种类型。”也就是说,你在 C++ 类型系统中引入了一个漏洞,阻止了编译器告诉你你在类型上做错了什么。更糟糕的是,编译器隐式地相信你,并且不执行任何其他检查来捕捉错误。一旦你开始选角,你就会面临各种各样的问题。事实上,任何使用大量类型转换的程序都应该被怀疑,不管你被告知多少次它只是“必须”这样做。一般来说,强制转换应该很少,并且只用于解决非常特殊的问题。
一旦您理解了这一点,并看到一个有缺陷的程序,您的第一反应可能是寻找罪魁祸首。但是如何定位 C 风格的造型呢?它们只是括号内的类型名,如果你开始寻找这样的东西,你会发现通常很难将它们与代码的其他部分区分开来。
标准 C++ 包括一个显式的强制转换语法,可以用来完全取代旧的 C 风格的强制转换(当然,如果不破坏代码,C 风格的强制转换是不合法的,但是编译器的作者可以很容易地为您标记旧风格的强制转换)。显式强制转换语法是这样的,你可以很容易地找到它们,正如你在表 3-2 中看到的它们的名字。
表 3-2 。C++ 显式强制转换语法
| static_cast | 对于“行为良好”和“相当行为良好”的强制转换,包括现在不需要强制转换就可以做的事情(比如自动类型转换)。 |
| const_cast | 丢弃const和/或volatile。 |
| reinterpret_cast | 赋予完全不同的意义。关键是您需要强制转换回原始类型才能安全地使用它。您强制转换的类型通常只用于位转换或其他神秘目的。这是所有演员中最危险的一个。 |
| dynamic_cast | 用于类型安全强制转换。 |
前三个显式强制转换将在接下来的章节中更完整地描述,而最后一个只有在你学得更多一点之后才能演示,比如在第十五章 中。
使用静态转换
一个static_cast用于所有定义明确的转换。其中包括编译器允许您在不进行强制转换的情况下进行的“安全”转换,以及定义良好的不太安全的转换。static_cast涵盖的转换类型包括典型的无强制转换、收缩(信息丢失)转换、从void*强制转换、隐式类型转换和类层次结构的静态导航。参见清单 3-38 中的示例。
清单 3-38 。使用静态转换
//: C03:static_cast.cpp
void func(int) {}
int main() {
int i = 0x7fff; // Max pos value = 32767
long l;
float f;
// (1) Typical castless conversions:
l = i;
f = i;
// Also works:
l = static_cast<long>(i);
f = static_cast<float>(i);
// (2) Narrowing conversions:
i = l; // May lose digits
i = f; // May lose info
// Says "I know," eliminates warnings:
i = static_cast<int>(l);
i = static_cast<int>(f);
char c = static_cast<char>(i);
// (3) Forcing a conversion from void* :
void* vp = &i;
// Old way produces a dangerous conversion:
float* fp = (float*)vp;
// The new way is equally dangerous:
fp = static_cast<float*>(vp);
// (4) Implicit type conversions, normally
// performed by the compiler:
double d = 0.0;
int x = d; // Automatic type conversion
x = static_cast<int>(d); // More explicit
func(d); // Automatic type conversion
func(static_cast<int>(d)); // More explicit
} ///:∼
在第(1)节中,您可以看到在 C 语言中使用的转换类型*、*,无论有无强制转换。从一个int提升到一个long或float不成问题,因为后者总是能够保存一个int能够包含的每一个值。虽然没有必要,但你可以使用static_cast来突出这些促销活动。
以另一种方式转换回来如(2)所示。在这里,您可能会丢失数据,因为一个int没有一个long或一个float那么“宽”;它不能容纳相同大小的数字。因此这些被称为收缩转换 。编译器仍然会执行这些操作,但是通常会给你一个警告。您可以消除此警告,并表明您确实想使用强制转换。
在 C++ 中,如果没有强制转换,就不允许从void*赋值(不像 C ),如(3)所示。这很危险,需要程序员知道他们在做什么。当你寻找 bug 时,至少static_cast比旧的标准类型更容易定位。
程序的第(4)部分显示了通常由编译器自动执行的隐式类型转换的种类。这些都是自动的,不需要造型,但是再次强调这个动作,以防你想弄清楚发生了什么或者以后寻找它。
使用常量 _ 转换
如果你想从一个const转换成一个非*--const或者从一个volatile转换成一个非volatile,你可以使用const_cast。这是const_cast允许的唯一转换;如果涉及到任何其他转换,必须使用单独的表达式来完成,否则会出现编译时错误;参见清单 3-39 。*
清单 3-39 。使用常量 _ 转换
//: C03:const_cast.cpp
int main() {
const int i = 0;
int* j = (int*)&i; // Deprecated form
j = const_cast<int*>(&i); // Preferred
// Can't do simultaneous additional casting:
//! long* l = const_cast<long*>(&i); // Error
volatile int k = 0;
int* u = const_cast<int*>(&k);
} ///:∼
如果获取一个const对象的地址,就会产生一个指向const的指针,如果不进行强制转换,这个指针就不能赋给非const指针。老式演员将完成这一点,但const_cast是一个合适的使用。同样的道理也适用于volatile。
使用 reinterpret _ cast
这是最不安全的转换机制,也是最容易产生错误的。一个reinterpret_cast假装一个对象只是一个位模式,可以被当作一个完全不同类型的对象来对待(为了一些不可告人的目的)。这是 C 语言恶名昭彰的低级位旋转。在对变量做任何其他事情之前,你实际上总是需要将变量返回到原始类型(或者将变量视为原始类型);参见清单 3-40 。
清单 3-40 。使用 reinterpret _ cast
//: C03:reinterpret_cast.cpp
#include <iostream>
using namespace std;
const int sz = 100;
struct X { int a[sz]; };
void print(X* x) {
for(int i = 0; i < sz; i++)
cout << x->a[i] << ' ';
cout << endl << "--------------------" << endl;
}
int main() {
X x;
print(&x);
int* xp = reinterpret_cast<int*>(&x);
for(int* i = xp; i < xp + sz; i++)
*i = 0;
// Can't use xp as an X* at this point
// unless you cast it back:
print(reinterpret_cast<X*>(xp));
// In this example, you can also just use
// the original identifier:
print(&x);
} ///:∼
在这个简单的例子中,struct X只包含一个int的数组,但是当你像在X x中那样在堆栈上创建一个数组时,每个int的值都是无用的(使用( )函数来显示struct的内容就可以看出这一点)。为了初始化它们,获取X的地址并将其转换为int指针,然后遍历数组将每个int设置为零。注意i的上限是如何通过将sz与xp相加计算出来的;编译器知道您实际上希望sz的指针位置大于xp,它会为您执行正确的指针算法。
reinterpret_cast的意思是,当你使用它时,你得到的东西是如此的陌生,以至于它不能被用于该类型的最初目的,除非你把它强制转换回去。这里,您可以看到在 print 调用中强制转换回一个X*,但是当然,因为您仍然拥有原始的标识符,所以您也可以使用它。但是xp只是作为一个int*有用,这是真正的“重新解释”原来的X。
一个reinterpret_cast通常表示不可取和/或不可移植的程序,但是当你决定必须使用它时,它是可用的。
sizeof—一个单独的运算符
操作符是独立的,因为它满足了一个不寻常的需求。sizeof提供有关为数据项分配的内存量的信息。正如本章前面所描述的,sizeof告诉你任何特定变量所使用的字节数。它还可以给出数据类型的大小(没有变量名);参见清单 3-41 。
清单 3-41 。使用 sizeof
//: C03:sizeof.cpp
#include <iostream>
using namespace std;
int main() {
cout << "sizeof(double) = " << sizeof(double);
cout << ", sizeof(char) = " << sizeof(char);
} ///:∼
根据定义,任何类型的char ( signed、unsigned,或普通)的sizeof总是 1,而不管char的底层存储实际上是否是 1 字节。对于所有其他类型,结果是以字节为单位的大小。
注意sizeof是运算符,不是函数。如果将它应用于类型,则必须与上面所示的带括号的形式一起使用,但如果将它应用于变量,则可以不使用括号;参见清单 3-42 。
清单 3-42 。对变量使用 sizeof
//: C03:sizeofOperator.cpp
int main() {
int x;
int i = sizeof x;
} ///:∼
sizeof还可以给你自定义数据类型的大小。这在本书后面会用到。
asm 关键字
关键字asm是一种转义机制,允许你在 C++ 程序中为你的硬件编写汇编代码。通常,您可以在汇编代码中引用 C++ 变量,这意味着您可以轻松地与 C++ 代码通信,并将汇编代码限制在效率调整或使用特殊处理器指令所必需的范围内。编写汇编语言时必须使用的确切语法取决于编译器,可以在编译器的文档中找到。
显式运算符
显式运算符是位和逻辑运算符的关键字。没有&、|、^等键盘字符的程序员,被迫使用 C 的恐怖三字母,不仅打字烦,读起来也晦涩难懂。这在 C++ 中用表 3-3 中显示的附加关键字进行了修复。
表 3-3 。C++(附加)关键字
| 关键字 | 意义 |
|---|---|
| 和 | &&(逻辑和) |
| 或者 | ||(逻辑或) |
| 不 | !(逻辑非) |
| 非 eq | !=(逻辑不等价) |
| 比特和 | &(按位和) |
| and_eq | &=(按位和-赋值) |
| 比多 | |(按位或) |
| or_eq | |=(按位或分配) |
| 异或运算 | ^(按位异或) |
| 异或等式 | ^=(按位异或赋值) |
| 完成 | ∼(补数) |
如果你的编译器符合标准 C *++,*它将支持这些关键字。
复合类型创建
基本数据类型及其变体是必不可少的,但是相当原始。C 和 C++ 提供了一些工具,允许您从基本数据类型中组合出更复杂的数据类型。正如你将看到的,其中最重要的是struct,它是 C++ 中class的基础。然而,创建更复杂类型的最简单的方法就是通过typedef将一个名字替换成另一个名字。
typedef 的别名
这个关键字承诺的要比它提供的多:typedef暗示了“类型定义”,而“别名”可能是一个更准确的描述,因为它确实是这样做的。下面是语法 :
typedef existing-type-description alias-name
当数据类型变得稍微复杂时,人们经常使用typedef,只是为了防止额外的击键。下面是一个常用的typedef:
typedef unsigned long ulong;
现在,如果你说ulong,编译器知道你指的是unsigned long。你可能认为使用预处理器替换可以很容易地实现这一点,但是在一些关键的情况下,编译器必须意识到你正在把一个名字当作一个类型来处理,所以typedef是必不可少的。
typedef派上用场的一个地方是指针类型。如前所述,如果你说
int* x, y;
这实际上产生了一个int*,也就是x,和一个int ( 不是 int*),也就是y。也就是说,*绑定到右边,而不是左边。然而,如果你使用一个typedef
typedef int* IntPtr;
IntPtr x, y;
那么x和y都属于int*类型。
你可以争辩说,避免使用原始类型的typedef更显式,因此可读性更好,事实上,当使用了许多typedef时,程序很快变得难以阅读。然而,typedef s 在 C 中与struct一起使用时变得尤其重要。
用结构组合变量
一个struct是一种将一组变量收集到一个结构中的方法。一旦你创建了一个struct,那么你就可以创建这个你发明的“新”类型变量的许多实例。例如,参见清单 3-43 。
清单 3-43 。简单的结构
//: C03:SimpleStruct.cpp
struct Structure1 {
char c;
int i;
float f;
double d;
};
int main() {
struct Structure1 s1, s2;
s1.c = 'a'; // Select an element using a '.'
s1.i = 1;
s1.f = 3.14;
s1.d = 0.00093;
s2.c = 'a';
s2.i = 1;
s2.f = 3.14;
s2.d = 0.00093;
} ///:∼
struct声明必须以分号结束。在main( )中,创建了Structure1的两个实例:s1和s2。这些都有自己单独的版本c、i、f和d。所以s1和s2代表完全独立变量的集合。要选择s1或s2中的一个元素,您可以使用一个.,这是您在上一章使用 C++ class对象时看到的语法;因为 es 是从 s 演变而来的,所以这就是语法的来源。
您将注意到的一件事是使用Structure1的笨拙之处(事实证明,这只是 C 所需要的,而不是 C++)。在 C 语言中,当你定义变量时,你不能只说Structure1,你必须说struct Structure1。这就是在 C 语言中typedef变得特别方便的地方;参见清单 3-44 。
清单 3-44 。另一个简单的结构
//: C03:SimpleStruct2.cpp
// Using typedef with struct
typedef struct {
char c;
int i;
float f;
double d;
} Structure2;
int main() {
Structure2 s1, s2;
s1.c = 'a';
s1.i = 1;
s1.f = 3.14;
s1.d = 0.00093;
s2.c = 'a';
s2.i = 1;
s2.f = 3.14;
s2.d = 0.00093;
} ///:∼
通过这样使用typedef,可以假装(在 C;在定义s1和s2时,尝试移除 C++ 的typedef),因为Structure2是一个内置类型,就像int o float(但注意它只有数据特征 — ,不包括行为,这是我们在 C++ 中使用真实对象得到的)。您会注意到struct标识符在开始时被省略了,因为目标是创建typedef。然而,有时您可能需要在定义过程中引用struct。在这些情况下,您实际上可以将struct的名称重复为struct名称和typedef。
清单 3-45 。允许结构引用自身
//: C03:SelfReferential.cpp
// Allowing a struct to refer to itself
typedef struct SelfReferential {
int i;
SelfReferential* sr; // Head spinning yet?
} SelfReferential;
int main() {
SelfReferential sr1, sr2;
sr1.sr = &sr2;
sr2.sr = &sr1;
sr1.i = 47;
sr2.i = 1024;
} ///:∼
如果你观察一会儿,你会看到sr1和sr2指向对方,并且各自持有一段数据。
实际上,struct名称不必与typedef名称相同,但通常这样做是为了使事情更简单。
指针和支柱
在上面的例子中,所有的struct都被当作对象来操作。然而,像任何存储一样,你可以获取一个struct对象的地址(如SelfReferential.cpp所示)。要选择一个特定的struct对象的元素,您可以使用一个.,如上所示。然而,如果你有一个指向struct对象的指针,你必须使用不同的操作符->选择该对象的一个元素,如清单 3-46 所示。
清单 3-46 。使用指向结构的指针
//: C03:SimpleStruct3.cpp
// Using pointers to structs
typedef struct Structure3 {
char c;
int i;
float f;
double d;
} Structure3;
int main() {
Structure3 s1, s2;
Structure3* sp = &s1;
sp->c = 'a';
sp->i = 1;
sp->f = 3.14;
sp->d = 0.00093;
sp = &s2; // Point to a different struct object
sp->c = 'a';
sp->i = 1;
sp->f = 3.14;
sp->d = 0.00093;
} ///:∼
在main( )中,struct指针sp最初指向s1,并且s1的成员通过用->选择它们而被初始化(并且你使用相同的操作符来读取那些成员)。但是然后sp被指向s2,那些变量以同样的方式被初始化。所以你可以看到指针的另一个好处是可以动态重定向指向不同的对象;您将会了解到,这为您的编程提供了更多的灵活性。
目前,这就是你需要知道的关于struct s 的全部内容,但是随着这本书的进展,你会对它们(尤其是它们更有力的继承者class es ),变得更加熟悉。
用 enum 阐明程序
枚举数据类型是一种将名称附加到数字上的方式,从而赋予阅读代码的人更多的意义。关键字enum(来自 C)通过给标识符赋值 0、1、2 等,自动枚举你给它的任何标识符列表。可以声明enum变量(总是用整数值表示)。一个enum的声明看起来类似于一个struct声明。当你想跟踪某种特征时,枚举数据类型是有用的,如清单 3-47 所示。
清单 3-47 。使用枚举
//: C03:Enum.cpp
// Keeping track of shapes
enum ShapeType {
circle,
square,
rectangle
}; // Must end with a semicolon like a struct
int main() {
ShapeType shape = circle;
// Activities here....
// Now do something based on what the shape is:
switch(shape) {
case circle: /* circle stuff */ break;
case square: /* square stuff */ break;
case rectangle: /* rectangle stuff */ break;
}
} ///:∼
shape是ShapeType枚举数据类型的变量,其值与枚举中的值进行比较。因为shape实际上只是一个int,然而,它可以是一个int能持有的任何值(包括负数)。您还可以将一个int变量与枚举中的一个值进行比较。
你应该意识到清单 3-47 中的打开类型的例子是一种有问题的编程方式。C++ 有一个更好的方法来编码这种东西,对它的解释必须推迟到本书的后面。
如果你不喜欢编译器赋值的方式,你可以自己来,就像这样:
enum ShapeType {
circle = 10, square = 20, rectangle = 50
};
如果你给一些名字赋值,而不给另一些名字赋值,编译器将使用下一个整数值。例如,与
enum snap { crackle = 25, pop };
编译器给pop的值是 26。
您可以看到当您使用枚举数据类型时,代码的可读性提高了多少。然而,在某种程度上,这仍然是一种尝试(在 C 中)来完成你在 C++ 中可以用class来做的事情,所以你会看到enum在 C++ 中用得更少。
枚举的类型检查
c 的枚举相当简单,只是将整数值与名字相关联,但是它们不提供类型检查。在 C++ 中,正如您现在可能已经预料到的,类型的概念是最基本的,对于枚举来说也是如此。当您创建命名枚举时,您实际上创建了一个新类型,就像您对类所做的那样;在翻译单元的持续时间内,枚举的名称成为保留字。
此外,C++ 中对枚举的类型检查比 C 中更严格。如果您有一个名为a的枚举color实例,您会特别注意到这一点。在 C 中,你可以说a++,,但在 C++ 中,你不能。这是因为递增枚举执行两次类型转换,其中一次在 C++ 中是合法的,另一次是非法的。首先,枚举的值从一个color隐式转换为一个int,然后值递增,然后int被转换回一个color。在 C++ 中,这是不允许的,因为color是一个独特的类型,不等同于int。这很有意义,因为你怎么知道blue的增量会出现在颜色列表中呢?如果你想增加一个color,那么它应该是一个类(带有一个增量操作)而不是一个enum,因为这个类可以变得更加安全。任何时候你写代码假设隐式转换为一个enum类型,编译器会标记这个固有的危险行为。
在 C++ 中,联合(下面描述)有类似的附加类型检查。
通过联合节省内存
有时一个程序会用同一个变量处理不同类型的数据。在这种情况下,您有两种选择:您可以创建一个包含所有可能需要存储的不同类型的struct,或者您可以使用一个union。一个union把所有的数据堆到一个空间里;它会计算出你放入union的最大物品所需的空间大小,并计算出union的大小。使用union节省内存。
每当您在union中放置一个值时,该值总是从union开始的相同位置开始,但是只使用必要的空间。因此,您创建了一个能够保存任何union变量的“超级变量”。所有union变量的地址都是相同的(在一个类或struct中,地址是不同的)。
清单 3-48 是一个union的简单使用。尝试移除各种元素,看看它对union的大小有什么影响。请注意,在一个union中声明一个数据类型的多个实例是没有意义的(除非您只是为了使用不同的名称)。
清单 3-48 。联合的大小和简单用途
//: C03:Union.cpp
// The size and simple use of a union
#include <iostream>
using namespace std;
union Packed { // Declaration similar to a class
char i;
short j;
int k;
long l;
float f;
double d;
// The union will be the size of a
// double, since that's the largest element
}; // Semicolon ends a union, like a struct
int main() {
cout << "sizeof(Packed) = "
<< sizeof(Packed) << endl;
Packed x;
x.i = 'c';
cout << x.i << endl;
x.d = 3.14159;
cout << x.d << endl;
} ///:∼
编译器根据您选择的联合成员执行适当的赋值。
一旦你执行了一个赋值,编译器不会关心你如何处理这个联合。在上面的例子中,你可以给x,赋值一个浮点值,比如
x.f = 2.222;
然后将它发送到输出,就好像它是一个int, like
cout << x.i;
这会产生垃圾。
使用数组
数组是一种复合类型,因为它们允许您将许多变量一个接一个地聚集在一个标识符名称下。如果你说
int a[10];
您为 10 个相互堆叠的int变量创建存储,但是没有每个变量的唯一标识符名称。而是都集中在a这个名字下。
要访问这些数组元素中的一个,可以使用与定义数组相同的方括号语法,如下所示:
a[5] = 47;
但是,你必须记住,即使a的大小是 10,你选择的数组元素从零开始(这有时被称为零索引 ),所以你只能选择数组元素 0-9,如清单 3-49 所示。
清单 3-49 。数组
//: C03:Arrays.cpp
#include <iostream>
using namespace std;
int main() {
int a[10];
for(int i = 0; i < 10; i++) {
a[i] = i * 10;
cout << "a[" << i << "] = " << a[i] << endl;
}
} ///:∼
数组访问速度极快。但是,如果索引超过了数组的末尾,就没有安全网了——您会踩到其他变量。另一个缺点是,您必须在编译时定义数组的大小;如果你想在运行时改变数组的大小,你不能用上面的语法来实现(C 有一种方法可以动态地创建数组,但是它要复杂得多)。上一章介绍的 C++ vector,提供了一个类似数组的对象,它可以自动调整自身的大小,所以如果在编译时无法知道数组的大小,这通常是一个更好的解决方案。
你可以创建任何类型的数组,甚至是struct的数组,如清单 3-50 所示。
清单 3-50 。结构数组
//: C03:StructArray.cpp
// An array of struct
typedef struct {
int i, j, k;
}
ThreeDpoint;
int main() {
ThreeDpoint p[10];
for(int i = 0; i < 10; i++) {
p[i].i = i + 1;
p[i].j = i + 2;
p[i].k = i + 3;
}
} ///:∼
注意struct标识符i是如何独立于for循环的i的。
为了查看数组中的每个元素与下一个元素是连续的,你可以打印出地址,如清单 3-51 所示。
清单 3-51 。数组地址
//: C03:ArrayAddresses.cpp
#include <iostream>
using namespace std;
int main() {
int a[10];
cout << "sizeof(int) = " << sizeof(int) << endl;
for(int i = 0; i < 10; i++)
cout << "&a[" << i << "] = "
<< (long)&a[i] << endl;
} ///:∼
当您运行这个程序时,您会看到每个元素都与前一个元素相差一个int大小。也就是说,它们一个堆叠在另一个之上。
指针和数组
数组的标识符不同于普通变量的标识符。首先,数组标识符不是左值;您不能分配给它。它实际上只是一个方括号语法的钩子,当你给出一个数组的名字,没有方括号,你得到的是数组的起始地址;参见清单 3-52 。
清单 3-52 。数组标识符
//: C03:ArrayIdentifier.cpp
#include <iostream>
using namespace std;
int main() {
int a[10];
cout << "a = " << a << endl;
cout << "&a[0] =" <<&a[0] << endl;
} ///:∼
当您运行这个程序时,您会看到这两个地址(将以十六进制打印,因为没有强制转换为long)是相同的。
因此,查看数组标识符的一种方式是作为一个指向数组开头的只读指针;而且,尽管你不能改变数组标识符来指向别的地方,你可以创建另一个指针并使用它在数组中移动。
事实上,方括号语法也适用于常规指针,正如你在清单 3-53 中看到的。
清单 3-53 。方括号语法
//: C03:PointersAndBrackets.cpp
int main() {
int a[10];
int* ip = a;
for(int i = 0; i < 10; i++)
ip[i] = i * 10;
} ///:∼
当你想把一个数组传递给一个函数时,命名一个数组产生它的起始地址的事实证明是非常重要的。如果你声明一个数组作为一个函数参数,你真正声明的是一个指针。所以在清单 3-54 中、func1( )、、func2( )实际上有相同的参数列表。
清单 3-54 。数组参数
//: C03:ArrayArguments.cpp
#include <iostream>
#include <string>
using namespace std;
void func1(int a[], int size) {
for(int i = 0; i < size; i++)
a[i] = i * i - i;
}
void func2(int* a, int size) {
for(int i = 0; i < size; i++)
a[i] = i * i + i;
}
void print(int a[], string name, int size) {
for(int i = 0; i < size; i++)
cout << name << "[" << i << "] = "
<< a[i] << endl;
}
int main() {
int a[5], b[5];
// Probably garbage values:
print(a, "a", 5);
print(b, "b", 5);
// Initialize the arrays:
func1(a, 5);
func1(b, 5);
print(a, "a", 5);
print(b, "b", 5);
// Notice the arrays are always modified:
func2(a, 5);
func2(b, 5);
print(a, "a", 5);
print(b, "b", 5);
} ///:∼
尽管func1( )和func2( )声明它们的参数不同,但在函数内部的用法是相同的。这个例子还揭示了其他一些问题:数组不能通过值传递;也就是说,您永远不会自动获得传递给函数的数组的本地副本。因此,当你修改一个数组时,你总是在修改外部对象。如果您期待普通参数提供的传值,这一开始可能会有点混乱。
您会注意到print( )对数组参数使用方括号语法。尽管在将数组作为参数传递时,指针语法和方括号语法实际上是相同的,但是方括号语法让读者更清楚地知道您的意思是该参数是一个数组。
还要注意,在每种情况下都传递了size参数。仅仅传递数组的地址是不够的;你必须知道函数中的数组有多大,这样你就不会超出数组的范围。
数组可以是任何类型,包括指针数组。事实上,当您想要将命令行参数传递到您的程序中时,C 和 C++ 有一个针对main( )的特殊参数列表,如下所示:
int main(int argc, char* argv[]) { // ...
第一个参数是数组中元素的数量,这是第二个参数。第二个参数总是一个char*的数组,因为参数是作为字符数组从命令行传递的(记住,数组只能作为指针传递)。命令行上每个空格分隔的字符簇都被转换成一个单独的数组参数。
清单 3-55 通过遍历数组打印出所有的命令行参数。
清单 3-55 。命令行参数
//: C03:CommandLineArgs.cpp
#include <iostream>
using namespace std;
int main(int argc, char* argv[]) {
cout << "argc = " << argc << endl;
for(int i = 0; i < argc; i++)
cout << "argv[" << i << "] = "
<< argv[i] << endl;
} ///:∼
您会注意到argv[0]是程序本身的路径和名称。这允许程序发现关于它自己的信息。它还在程序参数数组中增加了一个参数,因此获取命令行参数时的一个常见错误是在需要argv[1]时获取argv[0]。
在main( )中,不强制使用argc和argv作为标识符;那些标识符只是约定俗成的(但是如果不使用的话会让人很困惑)。此外,还有另一种方法来声明argv:
int main(int argc, char** argv) { // ...
这两种形式是等价的,但是我发现本书中使用的版本在阅读代码时是最直观的,因为它直接说,“这是一个字符指针数组。”
从命令行得到的只是字符数组;如果你想把一个参数当作另一种类型,你需要负责在你的程序中进行转换。为了便于转换成数字,标准 C 库中有一些 helper 函数,在<cstdlib>中声明。最简单的方法是使用atoi( )、atol( ),和atof( )将 ASCII 字符数组分别转换为int、long,和double浮点值。清单 3-56 使用atoi( );其他两个函数的调用方式相同。
清单 3-56 。使用 atoi()
//: C03:ArgsToInts.cpp
// Converting command-line arguments to ints
#include <iostream>
#include <cstdlib>
using namespace std;
int main(int argc, char* argv[]) {
for(int i = 1; i < argc; i++)
cout << atoi(argv[i]) << endl;
} ///:∼
在这个程序中,您可以在命令行中输入任意数量的参数。你会注意到for循环从值1开始,跳过argv[0]处的程序名。此外,如果在命令行中输入一个包含小数点的浮点数,atoi( )只取小数点之前的数字。如果你在命令行中输入非数字,这些从atoi( )返回为零。
探索浮点格式
本章前面介绍的printBinary( )函数对于深入研究各种数据类型的内部结构非常方便。其中最有趣的是浮点格式,它允许 C 和 C++ 在有限的空间内存储代表非常大和非常小的值的数字。虽然这里不能完全暴露细节,但是float s 和double s 内部的位分为三个区域:指数、尾数、符号位;因此,它使用科学记数法存储这些值。清单 3-57 允许你打印出各种浮点数的二进制模式,这样你就可以自己推导出你的编译器的浮点数格式中使用的模式(通常这是浮点数的 IEEE 标准,但你的编译器可能不遵循)。
清单 3-57 。二进制浮点型
//: C03:FloatingAsBinary.cpp
//{L} printBinary
//{T} 3.14159
#include "printBinary.h"
#include <cstdlib>
#include <iostream>
using namespace std;
int main(int argc, char* argv[]) {
if(argc != 2) {
cout << "Must provide a number" << endl;
exit(1);
}
double d = atof(argv[1]);
unsigned char* cp =
reinterpret_cast<unsigned char*> (&d);
for(int i = sizeof(double)-1; i >= 0 ; i -= 2) {
printBinary(cp[i-1]);
printBinary(cp[i]);
}
} ///:∼
首先,程序通过检查argc的值来保证你已经给了它一个参数,如果只有一个参数,这个值就是 2(如果没有参数,这个值就是 1,因为程序名总是argv的第一个元素)。如果失败,则打印一条消息,并调用标准 C 库函数exit( )来终止程序。
程序从命令行获取参数,并使用atof( )将字符转换为double。然后,通过获取地址并将其转换为一个unsigned char*,double 被视为一个字节数组。这些字节中的每一个都被传送到printBinary( )进行显示。
这个例子已经被设置为按照符号位首先出现的顺序打印字节——在我的机器上;你的可能会有所不同,所以你可能要重新安排打印的方式。您还应该意识到,浮点格式并不容易理解;例如,指数和尾数通常不排列在字节边界上,而是为每一个保留多个位,并且尽可能紧密地将它们打包到存储器中。要真正了解发生了什么,您需要找出数的每个部分的大小(符号位总是一位,但指数和尾数的大小不同),并分别打印出每个部分的位。
指针算法
如果你对指向一个数组的指针所能做的就是把它当作该数组的别名,那么指向数组的指针就没有什么意思了。然而,指针比这更灵活,因为它们可以被修改以指向其他地方(但是记住,数组标识符不能被修改以指向其他地方)。
指针算术指的是将一些算术运算符应用于指针。指针算法之所以是一个与普通算法不同的主题,是因为指针必须符合特殊的约束,才能使它们正常工作。例如,与指针一起使用的一个常见操作符是++,它给指针加 1。这实际上意味着指针被更改为移动到“下一个值”,不管这意味着什么。参见清单 3-58 中的示例。
清单 3-58 。指针增量
//: C03:PointerIncrement.cpp
#include <iostream>
using namespace std;
int main() {
int i[10];
double d[10];
int* ip = i;
double* dp = d;
cout << "ip = " << (long)ip << endl;
ip++;
cout << "ip = " << (long)ip << endl;
cout << "dp = " << (long)dp << endl;
dp++;
cout << "dp = " << (long)dp << endl;
} ///:∼
在计算机上运行一次,输出如下
ip = 6684124
ip = 6684128
dp = 6684044
dp = 6684052
这里有趣的是,尽管操作++对int*和double*来说看起来是相同的操作,但是你可以看到指针对int*只改变了 4 个字节,而对double*改变了 8 个字节。不是巧合,这是我机器上int和double的尺寸。这就是指针算法的诀窍:编译器计算出正确的数量来改变指针,使它指向数组中的下一个元素(指针算法只在数组中有意义)。这甚至适用于struct的数组,正如你在清单 3-59 中看到的。
清单 3-59 。指针增量和结构数组
//: C03:PointerIncrement2.cpp
#include <iostream>
using namespace std;
typedef struct {
char c;
short s;
int i;
long l;
float f;
double d;
long double ld;
} Primitives;
int main() {
Primitives p[10];
Primitives* pp = p;
cout << "sizeof(Primitives) = "
<< sizeof(Primitives) << endl;
cout << "pp = " << (long)pp << endl;
pp++;
cout << "pp = " << (long)pp << endl;
} ///:∼
在计算机上运行一次的输出是
sizeof(Primitives) = 40
pp = 6683764
pp = 6683804
所以你可以看到编译器也为指向struct s(和clas ses 和union s)的指针做了正确的事情。
指针算术也适用于操作符--、+,和-,但后两个操作符有局限性:不能将两个指针相加,如果减去指针,结果是两个指针之间的元素数。但是,您可以添加或减去一个整数值和一个指针。
清单 3-60 展示了指针算法的使用。
清单 3-60 。指针算法
//: C03:PointerArithmetic.cpp
#include <iostream>
using namespace std;
#define P(EX) cout << #EX << ": " << EX << endl;
int main() {
int a[10];
for(int i = 0; i < 10; i++)
a[i] = i; // Give it index values
int* ip = a;
P(*ip);
P(*++ip);
P(*(ip + 5));
int* ip2 = ip + 5;
P(*ip2);
P(*(ip2 - 4));
P(*--ip2);
P(ip2 - ip); // Yields number of elements
} ///:∼
它从另一个宏开始,但是这个宏使用了一个名为的预处理特性,字符串化(在表达式前用#符号实现)接受任何表达式并将其转换成一个字符数组。这非常方便,因为它允许打印表达式,后跟一个冒号,然后是表达式的值。在main( )中,你可以看到有用的简写。
尽管前缀和后缀版本的++和--对于指针是有效的,但是在这个例子中只使用了前缀版本,因为它们是在上面的表达式中指针被解引用之前应用的,所以它们允许我们看到操作的效果。注意,只有整数值被加和减;如果两个指针以这种方式组合,编译器是不允许的。
下面是程序的输出:
*ip: 0
*++ip: 1
*(ip + 5): 6
*ip2: 6
*(ip2 - 4): 2
*--ip2: 5
在所有情况下,指针算法都会根据所指向元素的大小调整指针,使其指向“正确的位置”。
如果指针算法一开始看起来有点让人不知所措,不要担心。大多数时候你只需要创建数组并用[ ]索引它们,你通常需要的最复杂的指针算法是++和--。指针算法通常是为更聪明和复杂的程序保留的,标准 C++ 库中的许多容器隐藏了这些聪明的细节,所以你不必担心它们。
调试提示
在理想的环境中,您有一个优秀的调试器,可以轻松地使程序的行为透明,这样您就可以快速发现错误。然而,大多数调试器都有盲点,这就需要你在程序中嵌入代码片段来帮助你理解发生了什么。此外,您可能正在一个没有调试器可用的环境(比如嵌入式系统,我在那里度过了成长的岁月)中进行开发,并且可能有非常有限的反馈(比如单行 LED 显示器)。在这些情况下,您在发现和显示程序执行信息的方式上变得富有创造性。本节给出了一些实现这一点的技巧。
调试标志
如果将调试代码硬连接到程序中,可能会遇到问题。你开始得到太多的信息,这使得错误很难隔离。当你认为你已经找到了错误,你开始撕掉调试代码,却发现你需要再把它放回去。您可以使用两种类型的标志来解决这些问题:预处理器调试标志和运行时调试标志。
预处理器调试标志
通过使用预处理器来#define一个或多个调试标志(最好在头文件中),您可以使用#ifdef语句测试标志,并有条件地包含调试代码。当您认为您的调试已经完成时,您可以简单地#undef这些标志,代码将自动被删除(并且您将减少可执行文件的大小和运行时开销)。
在开始构建项目之前,最好确定调试标志的名称,这样名称就会一致。传统上,预处理器标志通过全部大写来区别于变量。一个常见的标志名就是DEBUG(但是注意不要使用NDEBUG,它在 C 语言中是保留的)。语句的顺序可能是
#define DEBUG // Probably in a header file
//...
#ifdef DEBUG // Check to see if flag is defined
/* debugging code here */
#endif // DEBUG
大多数 C 和 C++ 实现还会让您从编译器命令行使用#define和#undef标志,这样您就可以用一个命令重新编译代码并插入调试信息(最好是通过makefile,一个稍后将描述的工具)。有关详细信息,请查看您的本地文档。
运行时调试标志
在某些情况下,在程序执行期间打开和关闭调试标志会更方便,尤其是在程序启动时使用命令行设置它们。仅仅为了插入调试代码而重新编译大型程序是乏味的。
要动态地打开和关闭调试代码,创建bool标志,如清单 3-61 所示。
清单 3-61 。动态调试标志
//: C03:DynamicDebugFlags.cpp
#include <iostream>
#include <string>
using namespace std;
// Debug flags aren't necessarily global:
bool debug = false;
int main(int argc, char* argv[]) {
for(int i = 0; i < argc; i++)
if(string(argv[i]) == "--debug=on")
debug = true;
bool go = true;
while(go) {
if(debug) {
// Debugging code here
cout << "Debugger is now on!" << endl;
} else {
cout << "Debugger is now off." << endl;
}
cout << "Turn debugger [on/off/quit]: ";
string reply;
cin >> reply;
if(reply == "on") debug = true; // Turn it on
if(reply == "off") debug = false; // Off
if(reply == "quit") break; // Out of 'while'
}
} ///:∼
这个程序继续允许你打开和关闭调试标志,直到你键入“quit”告诉它你想退出。请注意,它要求输入完整的单词,而不仅仅是字母(如果您愿意,可以将其缩短为字母)。此外,可以选择使用命令行参数在启动时打开调试;这个参数可以出现在命令行的任何地方,因为main( )中的启动代码会查看所有的参数。测试非常简单,因为表达式
string(argv[i])
这将获取argv[i]字符数组并创建一个string,然后可以很容易地将其与右侧的==进行比较。清单 3-61 中的程序搜索整个字符串--debug=on。你也可以寻找--debug=,然后看看之后是什么,提供更多的选择。尽管调试标志是相对较少的使用全局变量有意义的领域之一,但没有任何东西说它必须如此。请注意,变量是小写字母,提醒读者它不是预处理器标志。
将变量和表达式转换成字符串
编写调试代码时,编写由包含变量名的字符数组后跟变量组成的打印表达式是很乏味的。幸运的是,标准 C 包含了 stringize 操作符#,这在本章前面已经使用过。当您在预处理器宏中的参数前放置一个#时,预处理器会将该参数转换成一个字符数组。这一点,再加上没有插入标点的字符数组被连接成一个字符数组的事实,允许您在调试期间创建一个非常方便的宏来打印变量值,例如:
#define PR(x) cout << #x " = " << x << "\n";
如果通过调用宏PR(a)来打印变量a,它将具有与代码相同的效果
cout << "a = " << a << "\n";
同样的过程也适用于整个表达式。清单 3-62 使用一个宏创建一个速记来打印字符串化的表达式,然后计算表达式并打印结果。
清单 3-62 。字符串表达式
//: C03:StringizingExpressions.cpp
#include <iostream>
using namespace std;
#define P(A) cout << #A << ": " << (A) << endl;
int main() {
int a = 1, b = 2, c = 3;
P(a); P(b); P(c);
P(a + b);
P((c - a)/b);
} ///:∼
您可以看到像这样的技术是如何迅速变得不可或缺的,尤其是如果您没有调试器(或者必须使用多个开发环境)。当您想要去除调试时,您也可以插入一个#ifdef来使P(A)被定义为“无”。
C assert()宏
在标准头文件<cassert>中你会找到assert( ),这是一个方便的调试宏。当你使用assert( )时,你给它一个论点,这是一个你“断言为真”的表达式预处理器生成测试断言的代码。如果断言不是真的,程序将在发出一个错误消息告诉你断言是什么以及它失败后停止。参见清单 3-63 中一个简单的例子。
清单 3-63 。使用断言
//: C03:Assert.cpp
// Use of the assert() debugging macro
#include <cassert> // Contains the macro
using namespace std;
int main() {
int i = 100;
assert(i != 100); // Fails
} ///:∼
宏起源于标准 C,所以它也可以在头文件assert.h中找到。
完成调试后,可以通过放置以下代码行来移除宏生成的代码
#define NDEBUG
在程序中包含<cassert>之前,或者通过在编译器命令行上定义 NDEBUG。NDEBUG 是在<cassert>中使用的一个标志,用来改变宏生成代码的方式。
在本书的后面,你会看到一些更复杂的替代方法。
功能地址
一旦一个函数被编译并加载到计算机中执行,它就会占用一大块内存。这个内存,也就是这个函数,有一个地址。
c 语言从来都不是一门禁止他人涉足的语言。可以像使用变量地址一样,将函数地址与指针一起使用。函数指针的声明和使用起初看起来有点不透明,但它遵循了语言其余部分的格式。
定义函数指针
要定义一个指向没有参数和返回值的函数的指针,你可以说
void (*funcPtr)();
当你在看这样一个复杂的定义时,最好的方法是从中间开始,然后逐步解决。“从中间开始”就是从变量名开始,也就是funcPtr。“”意思是向右寻找最近的物品(本例中没有;右括号让您停下来),然后向左看(星号表示的指针),然后向右看(空参数列表表示没有参数的函数),然后向左看(void,表示该函数没有返回值)。这种左右运动适用于大多数声明。
要复习,中间开始(funcPtr 是一个)。。。),往右走(那里什么都没有——你被右括号挡住了),往左走找到*(。。。指向一个。。),向右走,找到空参数列表(。。。不接受参数的函数。。。),向左走找到void ( funcPtr是一个不带参数返回void的函数的指针)。
你可能想知道为什么*funcPtr需要括号。如果你不使用它们,编译器会看到
void *funcPtr();
您将声明一个函数(返回一个void*)而不是定义一个变量。你可以把编译器想成是在经历和你一样的过程,当它发现一个声明或者定义应该是什么的时候。它需要碰到那些括号,所以它返回到左边找到*,而不是继续到右边找到空的参数列表。
复杂的声明和定义
顺便说一下,一旦你弄清楚了 C 和 C++ 声明语法是如何工作的,你就可以创建更复杂的项目。例如,考虑清单 3-64 中的。
清单 3-64 。复杂的定义
//: C03:ComplicatedDefinitions.cpp
/* 1\. */ void * (*(*fp1)(int))[10];
/* 2\. */ float (*(*fp2)(int,int,float))(int);
/* 3\. */ typedef double (*(*(*fp3)())[10])();
fp3 a;
/* 4\. */ int (*(*f4())[10])();
int main() {} ///:∼
浏览每一个并使用左右方向的指引来找出答案。数字 1 表示,“fp1是一个函数的指针,该函数接受一个整数参数,并返回一个由 10 个void指针组成的数组的指针。”
数字 2 表示,“fp2是一个指向带三个参数(int、int,和float)的函数的指针,返回一个指向带整数参数并返回一个float的函数的指针。”
如果你正在创建许多复杂的定义,你可能想要使用一个typedef。数字 3 显示了一个typedef如何节省每次输入复杂描述的时间。它说,“一个fp3是一个没有参数的函数的指针,返回一个由 10 个指针组成的数组,这些指针指向没有参数并返回双精度值的函数。”然后它说,“a是这些fp3类型中的一种。”typedef通常用于从简单的描述构建复杂的描述。
数字 4 是函数声明,而不是变量定义。它说,“f4是一个函数,返回一个指针,指向一个由 10 个指针组成的数组,这些指针指向返回整数的函数。”
你很少需要像这样复杂的声明和定义。然而,如果你把它们搞清楚,你甚至不会对现实生活中可能遇到的稍微复杂的问题感到不安。
使用函数指针
一旦你定义了一个指向函数的指针,你必须在使用它之前把它分配给一个函数地址。正如数组arr[10]的地址是由不带括号的数组名(arr)产生的一样,函数func()的地址是由不带参数列表的函数名(func)产生的。你也可以使用更明确的语法&func()。要调用这个函数,你要用声明指针的同样方式去引用它(记住 C 和 C++ 总是试图让定义看起来和它们被使用的方式一样)。清单 3-65 展示了一个指向函数的指针是如何定义和使用的。
清单 3-65 。指向函数的指针
//: C03:PointerToFunction.cpp
// Defining and using a pointer to a function
#include <iostream>
using namespace std;
void func() {
cout << "func() called..." << endl;
}
int main() {
void (*fp)(); // Define a function pointer
fp = func; // Initialize it
(*fp)(); // Dereferencing calls the function
void (*fp2)() = func; // Define and initialize
(*fp2)();
} ///:∼
定义了指向函数fp的指针后,使用fp = func将它分配给函数func()的地址(注意函数名上缺少参数列表)。第二种情况显示了同时定义和初始化。
指向函数的指针数组
您可以创建的一个更有趣的构造是指向函数的指针数组。要选择一个函数,你只需进入数组并取消对指针的引用。这支持了表驱动代码的概念;不使用条件语句或 case 语句,而是根据状态变量(或状态变量的组合)选择要执行的函数。如果您经常在表中添加或删除函数(或者如果您想要动态地创建或更改这样的表),这种设计会很有用。
清单 3-66 使用预处理器宏创建一些虚拟函数,然后使用自动聚集初始化创建指向这些函数的指针数组。如您所见,通过更改少量代码,很容易在表中添加或删除函数(从而从程序中删除功能)。
清单 3-66 。使用指向函数的指针数组
//: C03:FunctionTable.cpp
// Using an array of pointers to functions
#include <iostream>
using namespace std;
// A macro to define dummy functions:
#define DF(N) void N() { \
cout << "function " #N " called..." << endl; }
DF(a); DF(b); DF(c); DF(d); DF(e); DF(f); DF(g);
void (*func_table[])() = { a, b, c, d, e, f, g };
int main() {
while(1) {
cout << "press a key from 'a' to 'g' "
"or q to quit" << endl;
char c, cr;
cin.get(c); cin.get(cr); // second one for CR
if ( c == 'q' )
break; // ... out of while(1)
if ( c < 'a' || c > 'g' )
continue;
(*func_table[c - 'a'])();
}
} ///:∼
在这一点上,您可能能够想象这种技术在创建某种解释器或列表处理程序时是如何有用的。
make:管理单独编译
当使用单独编译(将代码分解成许多翻译单元)时,您需要某种方法来自动编译每个文件,并告诉链接器将所有部分(连同适当的库和启动代码)构建到一个可执行文件中。大多数编译器允许您用一条命令行语句来完成这项工作。例如,对于 GNU C++ 编译器,你可能会说
g++ SourceFile1.cpp SourceFile2.cpp
这种方法的问题是编译器将首先编译每个单独的文件,而不管文件是否需要重新构建。由于一个项目中有许多文件,如果您只更改了一个文件,那么重新编译所有文件会变得非常困难。
这个问题的解决方案是一个名为make的程序,它是在 Unix 上开发的,但以某种形式随处可见。make实用程序通过遵循一个名为makefile的文本文件中的指令来管理项目中的所有单个文件。当您编辑项目中的一些文件并键入make时,make程序会遵循makefile中的指导方针,将源代码文件上的日期与相应目标文件上的日期进行比较,如果源代码文件的日期比其目标文件的日期更新,make会调用源代码文件上的编译器。make仅重新编译被更改的源代码文件和受修改文件影响的任何其他源代码文件。通过使用make,您不必在每次做出更改时都重新编译项目中的所有文件,也不必检查是否所有文件都构建正确。makefile包含了将你的项目放在一起的所有命令。学会使用make会节省你很多时间和挫败感。您还会发现make是您在 Linux/Unix 平台上安装新软件的典型方式(尽管这些makefile往往比本书中介绍的要复杂得多,并且您通常会在安装过程中为您的特定机器自动生成一个makefile)。
因为几乎所有的 C++ 编译器都以某种形式提供了make(即使没有,你也可以在任何编译器上使用免费提供的make),所以它将成为贯穿本书的工具。然而,编译器供应商也创造了他们自己的项目构建工具。这些工具会询问您项目中有哪些文件,并自行确定所有关系。这些工具使用类似于makefile的东西,通常称为项目文件,但是编程环境维护这个文件,所以你不必担心它。项目文件的配置和使用因开发环境的不同而不同,因此您必须找到关于如何使用它们的适当文档(尽管编译器供应商提供的项目文件工具通常使用起来非常简单,您可以通过试验来学习它们,这是最好的教育形式)。
本书中使用的makefile应该可以工作,即使你也在使用特定供应商的项目构建工具。
开展活动
当你输入make(或者不管你的“make”程序的名字是什么),make程序在当前目录中查找一个名为makefile的文件,如果这是你的项目,你已经创建了这个文件。这个文件列出了源代码文件之间的依赖关系。make查看文件上的日期。如果一个依赖文件的日期比它所依赖的文件的日期早,make执行在依赖关系之后给出的规则。
所有在makefile中的注释都以一个#开始,并延续到行尾。举个简单的例子,名为“hello”的程序的makefile可能包含
# A comment
hello.exe: hello.cpp
mycompiler hello.cpp
这表示hello.exe(目标)依赖于hello.cpp。当hello.cpp的日期比hello.exe新时,make执行“规则”mycompiler hello.cpp。可能有多个依赖关系和多个规则。许多make程序要求所有的规则都以制表符开始。除此之外,空白通常会被忽略,因此您可以设置可读性格式。
规则不限于对编译器的调用;你可以从make内调用任何你想要的程序。通过创建相互依赖的依赖规则集组,您可以修改您的源代码文件,键入make,并确保所有受影响的文件都将被正确地重建。
宏指令
一个makefile可能包含宏(注意这些和 C/C++ 预处理器宏 完全不同)。宏允许方便的字符串替换。
本书中的makefile使用宏来调用 C++ 编译器。举个例子,
CPP = mycompiler
hello.exe: hello.cpp
$(CPP) hello.cpp
=用于将CPP标识为宏,$和括号用于扩展宏。在这种情况下,扩展意味着宏调用$(CPP)将被替换为字符串mycompiler。有了上面的宏,如果你想换到另一个名为cpp的编译器,你只需要把宏改成
CPP = cpp
还可以添加编译器标志等。,或者使用单独的宏来添加编译器标志。
后缀规则
当你知道每次都是相同的基本过程时,告诉make如何为项目中的每一个cpp文件调用编译器就变得乏味了。由于make被设计成一个省时器,它也有一种方法来缩短动作,只要它们依赖于文件名后缀。这些缩写叫做后缀规则。后缀规则是教make如何将一种扩展名类型的文件(例如.cpp)转换成另一种扩展名类型的文件(.obj或.exe)的方法。一旦你教了make从一种文件生成另一种文件的规则,你所要做的就是告诉make哪些文件依赖于哪些其他文件。当make发现一个文件的日期早于它所依赖的文件时,它使用该规则创建一个新文件。
后缀规则告诉make它不需要显式的规则来构建一切,而是可以根据文件扩展名来决定如何构建。在这种情况下,它说,“要从一个以cpp结尾的文件构建一个以exe结尾的文件,调用下面的命令。”上面的例子看起来是这样的:
CPP = mycompiler
.SUFFIXES: .exe .cpp
.cpp.exe:
$(CPP) $<
.SUFFIXES指令告诉make它应该注意以下任何文件扩展名,因为它们对这个特定的makefile有特殊的意义。接下来你会看到后缀规则.cpp.exe,,它说,“这是如何将任何扩展名为cpp的文件转换为扩展名为exe的文件”(当cpp文件比exe文件更新时)。和之前一样,使用了$(CPP)宏,但是之后你会看到一些新的东西:$<。因为这是以$开头的宏,但这是make的特殊内置宏之一。$<只能在后缀规则中使用,它表示“触发规则的任何先决条件”(有时称为依赖),在这种情况下,它翻译为“需要编译的cpp文件”
一旦建立了后缀规则,您可以简单地说,例如,“make Union.exe”,后缀规则就会生效,即使在makefile中没有提到“Union”。
默认目标
在宏和后缀规则之后,make寻找文件中的第一个“目标”,并编译它,除非您另外指定。所以对于下面的makefile
CPP = mycompiler
.SUFFIXES: .exe .cpp
.cpp.exe:
$(CPP) $<
target1.exe:
target2.exe:
如果你只输入'make',那么target1.exe将被构建(使用默认后缀规则),因为那是make遇到的第一个目标。要构建target2.exe,你必须明确地说出“make target2.exe”。这变得很乏味,所以您通常创建一个依赖于所有其他目标的默认虚拟目标,如下所示:
CPP = mycompiler
.SUFFIXES: .exe .cpp
.cpp.exe:
$(CPP) $<
all: target1.exe target2.exe
这里,all不存在,也没有名为all的文件,所以每次你键入“make’,程序将all视为列表中的第一个目标(因此是默认目标),然后它会看到all不存在,所以它最好通过检查所有的依赖项来创建它。因此,它查看target1.exe,并(使用后缀规则)查看(1) target1.exe是否存在,以及(2)target1.cpp是否比target1.exe更新,如果是,则运行后缀规则(如果您为特定目标提供了显式规则,则使用该规则)。然后,它移动到默认目标列表中的下一个文件。因此,通过创建一个默认的目标列表(习惯上通常被称为'all ' ,但是你可以称它为任何东西)你可以简单地通过键入''来创建你的项目中的每个可执行文件。此外,你可以有其他非默认的目标列表做其他事情;例如,你可以设置输入''来重建你所有的文件,并进行调试。
makefile 示例
清单 3-67 中的给出了示例makefile。你会在每个子目录中发现不止一个makefile(它们有不同的名字;你用“make -f”调用一个特定的。这个是 GNU C++ 的。
清单 3-67 。makefile 示例
CPP = g++
OFLAG = -o
.SUFFIXES : .o .cpp .c
.cpp.o :
$(CPP) $(CPPFLAGS) -c $<
.c.o :
$(CPP) $(CPPFLAGS) -c $<
all: \
Return \
Declare \
Ifthen \
Guess \
Guess2
# Rest of the files for this chapter not shown
Return: Return.o
$(CPP) $(OFLAG)Return Return.o
Declare: Declare.o
$(CPP) $(OFLAG)Declare Declare.o
Ifthen: Ifthen.o
$(CPP) $(OFLAG)Ifthen Ifthen.o
Guess: Guess.o
$(CPP) $(OFLAG)Guess Guess.o
Guess2: Guess2.o
$(CPP) $(OFLAG)Guess2 Guess2.o
Return.o: Return.cpp
Declare.o: Declare.cpp
Ifthen.o: Ifthen.cpp
Guess.o: Guess.cpp
Guess2.o: Guess2.cpp
宏 CPP 被设置为编译器的名称。要使用不同的编译器,您可以编辑makefile或在命令行上更改宏的值,如下所示:
make CPP=cpp
然而,请注意,ExtractCode.cpp有一个自动的方案来为额外的编译器自动构建makefile。
第二个宏OFLAG是用来表示输出文件名称的标志。尽管许多编译器会自动假设输出文件与输入文件具有相同的基名,但其他编译器则不会(比如 Linux/Unix 编译器,默认情况下会创建一个名为a.out的文件)。
你可以看到这里有两个后缀规则,一个用于cpp文件,一个用于.c文件(以防任何 C 源代码需要编译)。默认目标是all,这个目标的每一行都用反斜杠“继续”,直到Guess2,它是列表中的最后一行,因此没有反斜杠。这一章中还有很多文件,但为了简洁起见,这里只显示了这些文件。
后缀规则负责从cpp文件创建目标文件(扩展名为.o),但是通常你需要明确地声明创建可执行文件的规则,因为通常一个可执行文件是通过链接许多不同的目标文件而创建的,而make无法猜测这些是什么。此外,在这种情况下(Linux/Unix ),可执行文件没有标准的扩展名,因此后缀规则不适用于这些简单的情况。因此,您会看到构建最终可执行文件的所有规则都已明确说明。
*这个makefile采取了使用尽可能少的make特征的绝对安全的路线;它只使用了目标和依赖的基本make概念,以及宏。通过这种方式,几乎可以保证与尽可能多的make程序一起工作。它往往会产生一个更大的makefile,但这并不坏,因为它是由ExtractCode.cpp自动生成的。
还有很多其他的make功能是这本书不会用到的,还有更新更聪明的版本和带有高级快捷方式的make变体,可以节省很多时间。您当地的文档可能会描述您特定的make的更多特征。同样,如果你的编译器供应商没有提供一个make或者它使用了一个非标准的make,你可以通过在互联网上搜索 GNU 档案(有很多)来找到几乎任何平台的 GNU make。
输入输出系统
C++ 支持两个完整的 I/O 系统:继承自 C 的 I/O 系统(使用类似printf()和scanf()的函数)和 C++ 定义的面向对象 I/O 系统(使用类似cout和cin的iostreams)。由于从 C 继承的 I/O 系统极其丰富、灵活和强大,您可能想知道为什么 C++ 还要定义另一个系统。答案在于 C 的 I/O 系统对对象一无所知。
因此,为了让 C++ 为面向对象编程提供完整的支持,C++ 必须创建一个 I/O 系统,它可以在用户定义的对象上操作。除了对对象的支持,使用 C++ 的 I/O 系统还有几个好处,你会在第十九章中看到。
头文件
清单 3-68 中的头文件包含了构建后面章节中的一些例子所需的代码。
清单 3-68 。头文件< require.h >
//: :require.h
// Test for error conditions in programs
// Local "using namespace std" for old compilers
#ifndef REQUIRE_H
#define REQUIRE_H
#include <cstdio>
#include <cstdlib>
#include <fstream>
#include <string>
inline void require(bool requirement,
const std::string &msg = "Requirement failed"){
using namespace std;
if (!requirement) {
fputs(msg.c_str(), stderr);
fputs("\n", stderr);
exit(1);
}
}
inline void requireArgs(int argc, int args,
const std::string&msg =
"Must use %d arguments") {
using namespace std;
if (argc != args + 1) {
fprintf(stderr, msg.c_str(), args);
fputs("\n", stderr);
exit(1);
}
}
inline void requireMinArgs(int argc, int minArgs,
const std::string&msg =
"Must use at least %d arguments") {
using namespace std;
if(argc < minArgs + 1) {
fprintf(stderr, msg.c_str(), minArgs);
fputs("\n", stderr);
exit(1);
}
}
inline void assure(std::ifstream& in,
const std::string& filename = "") {
using namespace std;
if(!in) {
fprintf(stderr, "Could not open file %s\n",
filename.c_str());
exit(1);
}
}
inline void assure(std::ofstream& out,
const std::string& filename = "") {
using namespace std;
if(!out) {
fprintf(stderr, "Could not open file %s\n",
filename.c_str());
exit(1);
}
}
#endif // REQUIRE_H ///:∼
审查会议
- 这一章是对 C++ 语法的所有基本特性的一次相当紧张的旅行,其中大部分是继承自 C 并与 C 共有的(并导致了 C++ 引以为豪的与 C 的向后兼容性。
- 虽然这里介绍了一些 C++ 的特性,但是这篇文章主要是为那些精通编程并且只需要了解 C 和 C++ 的语法基础的人准备的。
- 如果你已经是一个 C 程序员,除了对你来说很可能是新的 C++ 特性之外,你甚至可能在这里看到过一两件关于 C 的不熟悉的东西。
- C++ 既允许使用自己的基于对象的 I/O 系统,也允许使用从 C 继承的 I/O 系统,这反过来又允许向后兼容。
- 注意本章末尾的头文件
require.h。这个文件中的一些特性,比如内联函数,现在可能还不太容易理解。我建议您使用这个文件,直到第七章的开始,然后再转到第九章的中的的概念,这个文件已经在“改进的错误检查”一节中重复了,它所有的细微差别都已经得到了充分的阐述和详细的解释。**