C-到-C---迁移手册-全-
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
距离dog
4 个字节,bird
距离cat
4 个字节,以此类推。因此,在这台机器上,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
。这个文件中的一些特性,比如内联函数,现在可能还不太容易理解。我建议您使用这个文件,直到第七章的开始,然后再转到第九章的中的的概念,这个文件已经在“改进的错误检查”一节中重复了,它所有的细微差别都已经得到了充分的阐述和详细的解释。**
四、数据抽象
C++ 是一个提高生产力的工具。否则,你为什么要努力(这是一种努力,不管我们试图进行转换是多么容易)从某种你已经知道并能有效使用的语言转换到一种新的语言,在这种语言中,你会有一段时间效率较低,直到你掌握它为止?这是因为你已经确信,通过使用这个新工具,你将获得巨大的收益。
用计算机编程的术语来说,生产率意味着更少的人可以在更短的时间内编写更复杂、更令人印象深刻的程序。在选择语言的时候,当然还有其他的问题,比如效率(语言的本质会导致速度变慢和代码膨胀吗?)、安全性(这种语言是否有助于你确保你的程序总是按照你的计划运行,它是否优雅地处理错误?),和维护(这种语言是否有助于您创建易于理解、修改和扩展的代码?).这些肯定是本书将要探讨的重要因素。
但是原始生产力意味着以前需要你们三个人一周才能完成的程序现在只需要你们一个人一两天就能完成。这涉及到经济学的几个层面。你很高兴,因为你得到了来自生产某种东西的动力;你的客户(或老板)很高兴,因为产品生产得更快,用的人更少;顾客很高兴,因为他们得到的产品更便宜。大幅提高生产率的唯一方法是利用他人的代码。换句话说,就是使用图书馆。
库只是一堆别人编写并打包在一起的代码。通常,最小的包是一个扩展名为。和一个或多个头文件来告诉你的编译器库中有什么。链接器知道如何搜索库文件并提取适当的编译代码。但这只是提供图书馆的一种方式。在跨越许多架构的平台上,比如 Linux/Unix,交付库的唯一明智的方式通常是使用源代码,因此可以在新的目标上重新配置和重新编译。
因此,库可能是提高生产率的最重要的方法,C++ 的主要设计目标之一是使库的使用更容易。这意味着在 C 中使用库有些困难。理解这个因素会让你对 C++ 的设计有一个初步的了解,从而对如何使用它有一个初步的了解。
一个类似 ?? 的小图书馆
一个库通常从一个函数集合开始,但是如果你使用过第三方的 C 库,你就会知道通常不止这些,因为生命不仅仅是行为、动作和函数。还有特征(蓝色,磅数,纹理,亮度),用数据表示。而当你开始处理 C 中的一组特征时,把它们堆在一起变成一个struct
是非常方便的,尤其是当你想在你的问题空间中表示不止一个类似的东西的时候。然后你可以为每一件事做这个struct
的变量。
因此,大多数 C 库都有一组struct
和一组作用于这些struct
的函数。作为这种系统的一个例子,考虑一个行为像数组的编程工具,但是它的大小可以在运行时创建时确定。姑且称之为CStash
。尽管它是用 C++ 写的,但它的风格和你用 C 写的一样,正如你在清单 4-1 中看到的。
清单 4-1 。CStash
//: C04:CLib.h
// Header file for a C-like library
// An array-like entity created at runtime
typedef struct CStashTag {
int size; // Size of each space
int quantity; // Number of storage spaces
int next; // Next empty space
// Dynamically allocated array of bytes:
unsigned char* storage;
} CStash;
void initialize(CStash* s, int size);
void cleanup(CStash* s);
int add(CStash* s, const void* element);
void* fetch(CStash* s, int index);
int count(CStash* s);
void inflate(CStash* s, int increase);
///:∼
像CStashTag
这样的标签名通常用于struct
,以防您需要引用其内部的struct
。例如,当创建一个链表 ( 你的链表中的每个元素都包含一个指向下一个元素的指针),你需要一个指向下一个struct
变量的指针,所以你需要一种方法在struct
体中识别那个指针的类型。同样,你几乎会普遍地看到清单 4-1 中所示的typedef
对应于 C 库中的每个struct
。这样做是为了让你可以把struct
当作一个新类型,并像这样定义struct
的变量:
CStash A, B, C;
storage
指针是一个unsigned char*
。一个unsigned char
是一个 C 编译器支持的最小存储块,尽管在一些机器上它可以和最大的一样大。它依赖于实现,但通常只有一个字节长。你可能会认为因为CStash
被设计用来保存任何类型的变量,所以在这里使用void*
会更合适。但是,我们的目的不是将这种存储视为某种未知类型的块,而是视为连续字节的块。
实现文件(的源代码,如果你从商业上购买一个库,你可能得不到它;你可能只会得到一个编译过的 obj
或者lib
或者dll
等等。)如清单 4-2 所示。
清单 4-2 。实现文件的源代码
//: C04:CLib.cpp {O}
// Implementation of example C-like library
// Declare structure and functions:
#include "CLib.h"
#include <iostream>
#include <cassert>
using namespace std;
// Quantity of elements to add
// when increasing storage:
Const int increment = 100;
void initialize(CStash* s, int sz) {
s->size = sz;
s->quantity = 0;
s->storage = 0;
s->next = 0;
}
int add(CStash* s, const void* element) {
if(s->next >= s->quantity) //Enough space left?
inflate(s, increment);
// Copy element into storage,
// starting at next empty space:
int startBytes = s->next * s->size;
unsigned char* e = (unsigned char*)element;
for(int i = 0; i < s->size; i++)
s->next++;
return(s->next - 1); // Index number
}
void* fetch(CStash* s, int index) {
// Check index boundaries:
assert(0 <= index);
if(index >= s->next)
return 0; // To indicate the end
// Produce pointer to desired element:
return &(s->storage[index * s->size]);
}
int count(CStash* s) {
return s->next; // Elements in CStash
}
void inflate(CStash* s, int increase) {
assert(increase > 0);
int newQuantity = s->quantity + increase;
int newBytes = newQuantity * s->size;
int oldBytes = s->quantity * s->size;
unsigned char* b = new unsigned char[newBytes];
for(int i = 0; i < oldBytes; i++)
b[i] = s->storage[i]; // Copy old to new
delete [](s->storage); // Old storage
s->storage = b; // Point to new memory
s->quantity = newQuantity;
}
void cleanup(CStash* s) {
if(s->storage != 0) {
cout << "freeing storage" << endl;
delete []s->storage;
}
} ///:∼
initialize( )
通过将内部变量设置为适当的值,为structCStash
执行必要的设置。最初,storage
指针被设置为零—没有初始存储被分配。
add( )
函数将一个元素插入到CStash
的下一个可用位置。首先,它检查是否还有剩余的可用空间。如果没有,它将使用inflate( )
功能扩展存储空间,这将在后面描述。
因为编译器不知道被存储变量的具体类型(函数得到的只是一个void*
),所以不能只进行赋值,这当然是方便的事情。相反,您必须逐字节复制变量。执行复制的最直接方式是使用数组索引。通常,storage
中已经有数据字节,这由next
的值表示。从右字节偏移量开始,next
乘以每个元素的大小(字节中的*)产生startBytes
。然后将参数element
转换为unsigned char
,这样就可以逐字节寻址并复制到可用的storage
空间中。next
递增,以指示下一个可用的存储器,以及存储该值的“索引号”,以便通过fetch( )
使用该索引号检索该值。
fetch( )
检查以查看索引没有越界,然后返回使用index
参数计算的所需变量的地址。由于index
表示要偏移到CStash
中的元素数量,所以必须乘以每个元素占用的字节数,以产生以字节为单位的数值偏移量。当使用数组索引将这个偏移量用于索引到storage
时,您不会得到地址,而是得到地址处的字节。要生成地址,必须使用地址操作符&
。
对于一个经验丰富的 C 程序员来说,可能乍一看有点奇怪。似乎要经历很多麻烦去做一些手工可能会容易得多的事情。例如,如果您有一个名为intStash
的structCStash
,那么通过说intStash.next
而不是进行一个函数调用(这有开销),比如count(&intStash)
,来找出它有多少个元素似乎要简单得多。然而,如果您想改变CStash
的内部表示,从而改变计数的计算方式,函数调用接口提供了必要的灵活性。但是遗憾的是,大多数程序员不会费心去找出你对这个库的“更好”的设计。他们会查看struct
并直接获取next
值,甚至可能在未经您允许的情况下更改next
。要是有什么方法能让库设计者更好地控制这样的事情就好了!
动态存储分配
你永远不知道一个CStash
可能需要的最大存储量,所以storage
指向的内存是从堆中分配的。堆是一大块内存,用于在运行时分配较小的内存块。当你在编写程序时不知道你需要的内存大小时,你可以使用堆。也就是说,只有在运行时,你才会发现你需要空间来容纳 200 个Airplane
变量,而不是 20 个。在标准 C 中,动态内存分配函数包括malloc( )
、calloc( )
、realloc( )
、和、free( )
。然而,C++ 有一种更复杂(尽管使用起来更简单)的动态内存方法,通过关键字new
和delete
集成到语言中,而不是库调用。
inflate( )
函数使用new
为CStash
获取更大的空间。在这种情况下,你只会扩展内存而不会收缩,并且assert( )
会保证一个负数不会作为increase
值传递给inflate( )
。可以保存的新元素数( inflate( )
完成后的)计算为newQuantity
,乘以每个元素的字节数得到newBytes
,这将是分配中的字节数。为了让您知道要从旧位置复制多少字节,oldBytes
是使用旧的quantity
计算的。
实际的存储分配发生在 new-expression 中,它是涉及new
关键字的表达式,比如:
new unsigned char[newBytes];
新表达式的一般形式是
new Type;
其中
描述你想要在堆上分配的变量的类型。在这种情况下,您需要一个长度为newBytes
的unsigned char
数组,所以它显示为Type
。你也可以这样分配像一个int
一样简单的东西
new int;
虽然很少这样做,但你可以看到形式是一致的。
一个 new-expression 返回一个指向您所请求的精确类型的对象的指针。所以,如果你说new Type
,你会得到一个指向Type
的指针。如果你说new int
,你会得到一个指向int
的指针。如果你想要一个new unsigned char
数组,你得到一个指向该数组第一个元素的指针。编译器将确保您将 new-expression 的返回值赋给正确类型的指针。
当然,任何时候你请求内存的时候,如果没有更多的内存,请求都有可能失败。正如您将了解到的,如果内存分配操作不成功,C++ 会有一些机制发挥作用。
一旦新存储器被分配,旧存储器中的数据必须被复制到新存储器中;这也是通过数组索引实现的,在一个循环中一次复制一个字节。在数据被复制后,旧的存储空间必须被释放,以便程序的其他部分在需要新的存储空间时可以使用它。delete
关键字是new
的补充,必须应用它来释放任何用new
分配的存储空间(如果您忘记使用delete
,该存储空间仍然不可用,如果这种所谓的内存泄漏足够频繁,您将耗尽内存)。此外,当你删除一个数组时,有一个特殊的语法。就好像你必须提醒编译器,这个指针不只是指向一个对象,而是指向一个对象数组:你在要删除的指针前面放一组空的方括号,比如:
delete []myArray;
一旦旧存储器被删除,指向新存储器的指针可以被分配给storage
指针,数量被调整,并且inflate( )
已经完成它的工作。
注意,堆管理器相当原始。它给你大量的记忆,并在你使用它们的时候收回它们。没有用于堆压缩的固有工具,堆压缩压缩堆以提供更大的空闲块。如果一个程序分配并释放堆存储一段时间,你可能会得到一个碎片堆,它有很多空闲内存,但是没有足够大的块来分配你此刻正在寻找的大小。堆压缩器使程序变得复杂,因为它四处移动内存块,所以你的指针不会保留它们正确的值。有些操作环境有内置的堆压缩,但是它们要求你使用特殊的内存句柄 ( 可以临时转换成指针,在锁定内存之后,堆压缩器就不能移动它)来代替指针。您也可以构建自己的堆压缩方案,但这不是一项轻松的任务。
当您在编译时在堆栈上创建变量时,编译器会自动创建并释放该变量的存储。编译器确切地知道需要多少存储空间,并且因为作用域,它知道变量的生命周期。然而,使用动态内存分配,编译器不知道你将需要多少存储,和它不知道那个存储的生命周期。也就是说,存储不会自动清理。因此,您负责使用delete
来释放存储,这告诉堆管理器下次调用new
时可以使用存储。在库中发生这种情况的逻辑位置是在cleanup( )
函数中,因为所有的收尾工作都是在那里完成的。
为了测试这个库,创建了两个CStash
es。第一个保存int
个,第二个保存 80 个char
个的数组;参见清单 4-3 。
清单 4-3 。用两个 CStashes 测试类 C 库
//: C04:CLibTest.cpp
//{L} CLib
#include "CLib.h" // To be INCLUDED from Header FILE above
#include <fstream>
#include <iostream>
#include <string>
#include <cassert>
using namespace std;
int main() {
// Define variables at the beginning
// of the block, as in C:
CStashintStash, stringStash;
int i;
char* cp;
ifstream in;
string line;
const int bufsize = 80;
// Now remember to initialize the variables:
initialize(&intStash, sizeof(int));
for(i = 0; i < 100; i++)
add(&intStash, &i);
for(i = 0; i < count(&intStash); i++)
cout << "fetch(&intStash, " << i << ") = "
<< *(int*)fetch(&intStash, i)
<< endl;
// Holds 80-character strings:
initialize(&stringStash, sizeof(char)*bufsize);
in.open("CLibTest.cpp");
assert(in);
while(getline(in, line))
add(&stringStash, line.c_str());
i = 0;
while((cp = (char*)fetch(&stringStash, i++))!=0)
cout << "fetch(&stringStash, " << i << ") = "
<< cp << endl;
cleanup(&intStash);
cleanup(&stringStash);
} ///:∼
按照 C 要求的形式,所有的变量都是在main( )
作用域的开始处创建的。当然,您必须记得稍后通过调用initialize( )
来初始化块中的CStash
变量。库的一个问题是,你必须小心地向用户传达初始化和清理功能的重要性。如果不调用这些函数,会有很多麻烦。不幸的是,用户并不总是想知道初始化和清理是否是强制性的。他们知道他们想要完成什么,他们不在乎你上蹿下跳地说,“嘿,等等,你必须先做这个”一些用户甚至自己初始化结构的元素。在 C 中肯定没有任何机制可以阻止它(更多伏笔)。
intStash
是用整数填充的,而stringStash
是用字符数组填充的。这些字符数组是通过打开源代码文件CLibTest.cpp
,并将其中的行读入一个名为line
的string
,然后使用成员函数c_str( )
产生一个指向line
字符表示的指针。
加载每个Stash
后,会显示出来。使用for
循环打印intStash
,该循环使用count( )
建立其极限。stringStash
上印有while
,当fetch( )
归零表示出界时爆发。
你还会注意到一个额外的演员阵容
cp = (char*)fetch(&stringStash,i++)
这是由于 C++ 中更严格的类型检查,不允许简单地将一个void*
赋给任何其他类型( C 允许这样)。
错误的猜测
在我们研究创建一个 C 库的一般问题之前,还有一个更重要的问题你应该理解。请注意,CLib.h
头文件必须包含在任何引用CStash
的文件中,因为编译器甚至无法猜测该结构是什么样子。但是,它可以猜测一个函数是什么样子的;这听起来像是一个特性,但结果却是一个主要的缺陷。
尽管你应该总是通过包含一个头文件来声明函数,但是函数声明在 C 中并不是必需的。在 C 中(但在 C++ 中不是)调用一个你没有声明的函数是可能的。一个好的编译器会警告你可能应该先声明一个函数,但是 C 语言标准并没有强制这样做。这是一种危险的做法,因为 C 编译器可以假设你用int
参数调用的函数有一个包含int
的参数列表,即使它实际上可能包含一个float
。正如您将看到的,这可能会产生很难发现的错误。
每个单独的 C 实现文件(扩展名为.c
)都是一个翻译单元。也就是说,编译器在每个翻译单元上单独运行,运行时只感知那个单元。因此,你通过包含头文件提供的任何信息都非常重要,因为它决定了编译器对你程序其余部分的理解。头文件中的声明特别重要,因为无论在哪里包含头文件,编译器都知道该做什么。例如,如果你在一个头文件中有一个声明叫做void func(float)
,编译器知道如果你用一个整数参数调用那个函数,它应该在传递参数时把int
转换成float
(这被称为提升)。如果没有声明, C 编译器会简单地假设一个函数func(int)
存在,它不会进行提升,错误的数据会悄悄地传入func( )
。
对于每个翻译单元,编译器创建一个扩展名为.o
或.obj
或类似的目标文件。这些目标文件以及必要的启动代码必须由链接器收集到可执行程序中。在链接过程中,必须解析所有外部引用。比如在CLibTest.cpp
中,像initialize( )
、fetch( )
这样的函数被声明(也就是编译器被告知它们的样子)和使用,但是没有被定义。它们在CLib.cpp
的其他地方有定义。因此,CLib.cpp
中的调用是外部引用。当链接器把所有的目标文件放在一起时,它必须获取未解析的外部引用,并找到它们实际引用的地址。这些地址被放入可执行程序中以替换外部引用。
重要的是要认识到,在 C 中,链接器搜索的外部引用只是函数名,通常在它们前面有一个下划线。所以链接器所要做的就是匹配调用它的函数名和目标文件中的函数体,这就完成了。如果你不小心进行了一个被编译器解释为func(int)
的调用,并且在其他一些目标文件中有一个func(float)
的函数体,链接器会在一个地方看到_func
,在另一个地方看到_func
,它会认为一切正常。调用位置的func( )
将把一个int
推到堆栈上,func( )
函数体将期望一个float
在堆栈上。如果函数只读取值,不写入值,就不会炸栈。事实上,它从堆栈中读取的float
值甚至可能有某种意义。这更糟糕,因为更难找到漏洞。
怎么了?
我们的适应能力非常强,甚至在我们不应该适应的情况下。CStash
库的风格一直是 C 程序员的主食,但是如果你观察它一段时间,你可能会注意到它相当于。。。尴尬的。当你使用它时,你必须把这个结构的地址传递给库中的每一个函数。当阅读代码时,库的机制与函数调用的含义混淆了,当您试图理解发生了什么时,这是令人困惑的。
然而,在 C 中使用库的最大障碍之一是名称冲突的问题。 C 有一个单一的函数命名空间;也就是说,当链接器查找函数名时,它在单个主列表中查找。此外,当编译器处理翻译单元时,它只能处理具有给定名称的单个函数。
现在假设您决定从两个不同的供应商那里购买两个库,每个库都有一个必须初始化和清理的结构。两家厂商都认为initialize( )
和cleanup( )
是好名字。如果在一个翻译单元中包含了它们的头文件,那么 C 编译器会做什么呢?幸运的是, C 给出了一个错误,告诉你在声明函数的两个不同的参数列表中有一个类型不匹配。但是即使你不把它们包含在同一个翻译单元里,链接器还是会有问题。一个好的链接器会检测到名字冲突,但是有些链接器会按照你在链表中给它们的顺序搜索目标文件列表,然后取它们找到的第一个函数名。
注意这甚至可以被认为是一个特性,因为它允许你用自己的版本替换一个库函数。
无论哪种情况,都不能使用两个包含同名函数的 C 库。为了解决这个问题,C 库供应商通常会在所有函数名的开头加上一系列独特的字符。所以initialize( )
和cleanup( )
可能会变成CStash_initialize( )
和CStash_cleanup( )
。这是一件合乎逻辑的事情,因为它用函数的名字“装饰”了函数所处理的struct
的名字。
现在是时候迈出用 C++ 创建类的第一步了。struct
中的变量名不会与全局变量名冲突。那么,当这些函数在特定的struct
上运行时,为什么不在函数名中利用这一点呢?也就是说,为什么不让函数成为struct
的成员呢?
基本对象
第一步就是这样。C++ 函数可以作为“成员函数”放在struct
s 中清单 4-4 显示了将CStash
的 C 版本转换为 C++ Stash
后的样子。
清单 4-4 。将类 C 库转换为 C++
//: C04:CppLib.h
struct Stash {
int size; // Size of each space
int quantity; // Number of storage spaces
int next; // Next empty space
// Dynamically allocated array of bytes:
unsigned char* storage;
// Functions!
void initialize(int size);
void cleanup();
int add(const void* element);
void* fetch(int index);
int count();
void inflate(int increase);
}; ///:∼
首先,注意这里没有typedef
。C++ 编译器并不要求你创建一个typedef
,而是将该结构的名称转换成程序的新类型名称(就像int
、char
、float
和double
、都是类型名称)。
所有的数据成员和以前完全一样,但是现在函数在struct
的主体里面。此外,注意来自库的 C 版本的第一个参数已经被移除。在 C++ 中,编译器不会强迫你将结构的地址作为第一个参数传递给所有操作该结构的函数,而是秘密地为你做这件事。现在,函数的唯一参数是函数做什么,而不是函数运行的机制。
重要的是要认识到函数代码实际上与库的 C 版本是一样的。参数的数量是相同的(即使你看不到传入的结构地址,它仍然在那里),并且每个函数只有一个函数体。也就是说,仅仅因为你说
Stash A, B, C;
并不意味着每个变量都有不同的add( )
函数。
因此,生成的代码与您为库的 C 版本编写的代码几乎相同。有趣的是,这包括您可能会为产生Stash_initialize( )
、Stash_cleanup( )
等而做的“名称修饰”。当函数名在struct
中时,编译器会有效地做同样的事情。因此,Stash
结构内部的initialize( )
不会与任何其他结构内部名为initialize( )
的函数冲突,甚至不会与名为initialize( )
的全局函数冲突。大多数情况下,您不必担心函数名的修饰——您使用的是未修饰的名称。但是有时候你确实需要能够指定这个initialize( )
属于structStash
,而不属于任何其他的struct
。特别是,当你定义函数时,你需要完全指定它是哪一个。为了完成这个完整的规范,C++ 有一个操作符(::
) ,叫做作用域解析操作符(这样命名是因为名字现在可以在不同的作用域中——在全局作用域中的,或者在作用域中的struct
)。比如你要指定initialize( )
,属于Stash
,你就说Stash::initialize(int size)
。您可以在清单 4-5 中的函数定义中看到作用域解析操作符是如何使用的。
清单 4-5 。在函数定义中使用范围解析运算符
//: C04:CppLib.cpp {O}
// C library converted to C++
// Declare structure and functions:
#include "CppLib.h" // To be INCLUDED from Header FILE above
#include <iostream>
#include <cassert>
using namespace std;
// Quantity of elements to add
// when increasing storage:
const int increment = 100;
void Stash::initialize(int sz) {
size = sz;
quantity = 0;
storage = 0;
next = 0;
}
int Stash::add(const void* element) {
if(next >= quantity) // Enough space left?
inflate(increment);
// Copy element into storage,
// starting at next empty space:
int startBytes = next * size;
unsigned char* e = (unsigned char*)element;
for(int i = 0; i < size; i++)
storage[startBytes + i] = e[i];
next++;
return(next - 1); // Index number
}
void* Stash::fetch(int index) {
// Check index boundaries:
assert(0 <= index);
if(index >= next)
return 0; // To indicate the end
// Produce pointer to desired element:
return &(storage[index * size]);
}
int Stash::count() {
return next; // Number of elements in CStash
}
void Stash::inflate(int increase) {
assert(increase > 0);
int newQuantity = quantity + increase;
int newBytes = newQuantity * size;
int oldBytes = quantity * size;
unsigned char* b = new unsigned char[newBytes];
for(int i = 0; i < oldBytes; i++)
b[i] = storage[i]; // Copy old to new
delete []storage; // Old storage
storage = b; // Point to new memory
quantity = newQuantity;
}
void Stash::cleanup() {
if(storage != 0) {
cout << "freeing storage" << endl;
delete []storage;
}
} ///:∼
C 和 C++ 还有几个不同的地方。首先,头文件中的声明是编译器所需要的。在 C++ 中,不先声明函数就不能调用它。否则,编译器将发出一条错误消息。这是确保函数调用在调用点和定义点之间保持一致的重要方法。通过强制您在调用之前声明函数,C++ 编译器实际上确保了您将通过包含头文件来执行此声明。如果在定义函数的地方也包含相同的头文件,编译器会检查以确保头文件中的声明和函数定义匹配。这意味着头文件成为函数声明的有效存储库,并确保在项目的所有翻译单元中一致地使用函数。
*当然,全局函数仍然可以在每个定义和使用它们的地方手工声明。但是,在定义或使用结构之前,必须先声明结构,而将结构定义放在头文件中是最方便的地方,除非您有意将其隐藏在文件中。
注意这太乏味了,变得不太可能。
你可以看到所有的成员函数看起来几乎和它们是 C 函数时一样,除了作用域解析和库的 C 版本的第一个参数不再是显式的。当然,它仍然存在,因为函数必须能够处理特定的struct
变量。但是请注意,在成员函数中,成员选择也消失了!因此,你应该说size = sz;
,而不是说s–>size = sz;
,这样就省去了冗长的s–>
,它对你正在做的事情没有任何意义。C++ 编译器显然是在为你做这件事。实际上,它采用“秘密”的第一个参数(您之前手动传入的结构的地址)并在您引用一个struct
的数据成员时应用成员选择器。这意味着只要你在另一个struct
的成员函数中,你就可以通过简单地给出它的名字来引用任何成员(包括另一个成员函数)。编译器将在查找该名称的全局版本之前搜索局部结构的名称。您会发现,这个特性意味着您的代码不仅更容易编写,而且更容易阅读。
但是,如果出于某种原因,您希望能够得到结构的地址,该怎么办呢?在库的 C 版本中,这很简单,因为每个函数的第一个参数是一个名为s
的CStash*
。在 C++ 中,事情更加一致。有一个特殊的关键字,叫做this
,它产生了struct
的地址。它相当于库的 C 版本中的“s
”。所以你可以回复到 C 风格,说
this->size = Size;
编译器生成的代码是完全一样的,所以你不需要以这样的方式使用this
;偶尔,你会看到代码中到处都有人明确地使用this->
,但是它并没有增加任何代码的含义,并且经常表明一个没有经验的程序员。通常,你不会经常使用this
,但当你需要它的时候,它就在那里(本书后面的一些例子会用到 this
)。
还有最后一项要提。在 C 中,你可以像这样给任何其他指针赋值void*
int i = 10;
void* p = &i; // OK in both C and C++
int* ip = vp; // Only acceptable in C
编译器不会有任何抱怨。但在 C++ 中,这种语句是不允许的。为什么呢?因为 C 对类型信息不是那么讲究,所以它允许你把一个未指定类型的指针赋给一个指定类型的指针。C++ 却不是这样。在 C++ 中,类型是至关重要的,当有任何违反类型信息的情况时,编译器就会停止工作。这一点一直很重要,但在 C++ 中尤其重要,因为在struct
中有成员函数。如果在 C++ 中可以不受惩罚地传递指向struct
的指针,那么您最终可能会为一个struct
调用一个成员函数,而这个函数在逻辑上对于那个struct
来说并不存在!这是一个真正的灾难。因此,虽然 C++ 允许将任何类型的指针分配给一个void*
(这是void*
,的初衷,它需要足够大以容纳一个指向任何类型的指针),但它将而不是允许将一个void
指针分配给任何其他类型的指针。总是需要强制转换来告诉读者和编译器,你确实想把它当作目标类型。
这带来了一个有趣的问题。C++ 的一个重要目标是编译尽可能多的现有 C 代码,以便轻松过渡到新语言。然而,这并不意味着 C 允许的任何代码在 C++ 中都将被自动允许。C 编译器让你逃脱了许多危险和容易出错的事情。
注我们会在本书的过程中看到它们。
对于这些情况,C++ 编译器会生成警告和错误。这往往是一个优势,而不是一个障碍。事实上,在很多情况下,你试图在 C 语言中运行一个错误,只是找不到它,但只要你用 C++ 重新编译程序,编译器就会指出问题!在 C 中,你会经常发现你可以让程序编译,但是之后你必须让它工作。在 C++ 中,当程序正确编译时,也经常起作用!这是因为这种语言对类型的要求更加严格。
在清单 4-6 中的测试程序中使用 C++ 版本的Stash
的方式中,你可以看到许多新事物。
清单 4-6 。使用 C++ 版本的 CStash
//: C04:CppLibTest.cpp
//{L} CppLib
// Test of C++ library
#include "CppLib.h"
#include "../require.h" // To be INCLUDED from Header FILE in Chapter 3
#include <fstream>
#include <iostream>
#include <string>
using namespace std;
int main() {
Stash intStash;
intStash.initialize(sizeof(int));
for(int i = 0; i < 100; i++)
intStash.add(&i);
for(int j = 0; j < intStash.count(); j++)
cout << "intStash.fetch(" << j << ") = "
<< *(int*)intStash.fetch(j)
<< endl;
// Holds 80-character strings:
Stash stringStash;
const int bufsize = 80;
stringStash.initialize(sizeof(char) * bufsize);
ifstream in("CppLibTest.cpp");
assure(in, "CppLibTest.cpp");
string line;
while(getline(in, line))
stringStash.add(line.c_str());
int k = 0;
char* cp;
while((cp =(char*)stringStash.fetch(k++)) != 0)
cout << "stringStash.fetch(" << k << ") = "
<< cp << endl;
intStash.cleanup();
stringStash.cleanup();
} ///:∼
你会注意到的一件事是,所有的变量都是“动态”定义的(“??”,如前一章中所介绍的)。也就是说,它们被定义在作用域的任何一点,而不是被限制在作用域的开始——就像在 C 中一样。
代码与CLibTest.cpp
非常相似,但是当一个成员函数被调用时,调用发生在变量名称前面的.
的成员选择操作符。这是一种方便的语法,因为它模拟了结构的数据成员的选择。不同的是,这是一个函数成员,所以它有一个参数列表。
当然,编译器实际上生成的调用看起来更像原始的 C 库函数。因此,考虑到名字修饰和this
的传递,C++ 函数调用intStash.initialize(sizeof(int), 100)
就变成了类似于Stash_initialize(&intStash, sizeof(int), 100)
的东西。如果你想知道幕后发生了什么,请记住 AT & T 最初的 C++ 编译器cfront
产生了 C 代码作为其输出,然后由底层的 C 编译器编译。这种方法意味着cfront
可以快速移植到任何装有 C 编译器的机器上,它有助于快速传播 C++ 编译器技术。但是因为 C++ 编译器必须生成 C,你知道一定有某种方法在 C 中表示 C++ 语法。)
注意有些编译器还是允许你产生 C 代码的。
与ClibTest.cpp
相比还有一个变化,那就是引入了require.h
头文件。这是为这本书创建的一个头文件,用来执行比assert( )
提供的更复杂的错误检查。它包含几个函数,包括这里使用的用于文件的assure( ),
。该函数检查文件是否已经成功打开,如果没有,它向标准错误报告文件无法打开(因此它需要文件名作为第二个参数)并退出程序。require.h
函数将在整本书中使用,特别是为了确保有正确数量的命令行参数以及文件被正确打开。require.h
函数取代了重复和令人分心的错误检查代码,但是它们提供了本质上有用的错误信息。
什么是物体?
现在您已经看到了一个初始示例,是时候后退一步,看看一些术语了。将函数引入结构的行为是 C++ 添加到 C 的根源,它引入了一种新的思考结构的方式:作为概念。在 C 中,struct
是数据的集合,一种打包数据的方式,这样你就可以在一个丛中处理它。但是除了方便编程之外,很难把它想成别的东西。对这些结构起作用的功能在别处。然而,有了包中的函数,结构变成了一个新的生物,能够描述两种特性(就像 aC所做的)和行为。对象的概念——一个能够记住和动作的独立有界实体——出现了。
在 C++ 中,对象只是一个变量,最纯粹的定义是“一个存储区域”(这是一种更具体的说法,即一个对象必须有一个唯一的标识符,在 C++ 的情况下是一个唯一的内存地址)。它是一个可以存储数据的地方,这意味着还可以对这些数据执行操作。
不幸的是,当谈到这些术语时,不同语言之间并不完全一致,尽管它们已经被广泛接受。你有时也会遇到关于什么是面向对象语言的不同意见,尽管现在看来这已经很好地解决了。有些语言是基于对象的,这意味着它们拥有像你目前所见的 C++ 带函数结构这样的对象。然而,这只是面向对象语言的一部分,停止在数据结构中封装函数的语言是基于对象的,而不是面向对象的。
抽象数据类型化
用函数打包数据的能力允许您创建新的数据类型。这通常被称为封装。一个已有的数据类型可能有几个数据打包在一起。例如,float
有一个指数、一个尾数和一个符号位。你可以告诉它做一些事情:添加到另一个float
或者添加到一个int
,等等。它有特征和行为。
Stash
的定义创建了一个新的数据类型。可以add( )``fetch( )
inflate( )
。你通过说Stash s
来创造一个,就像你通过说float f
来创造一个float
一样。一个Stash
也有特点和行为。尽管它的行为像一个真实的内置数据类型,但我们称它为抽象数据类型,也许是因为它允许我们将概念从问题空间抽象到解决方案空间。此外,C++ 编译器将其视为一种新的数据类型,如果您说某个函数需要一个Stash
,编译器会确保您将一个Stash
传递给该函数。因此,抽象数据类型(有时称为用户定义类型)与内置类型的类型检查级别相同。
但是,您可以立即看到对对象执行操作的方式有所不同。你说object.memberFunction(arglist)
。这就是“为对象调用成员函数”但是在面向对象的说法中,这也被称为“向对象发送消息”所以对于一个Stash s
,语句s.add(&i)
向s
发送一条消息,说“add( )
这是给你自己的。”事实上,面向对象编程可以用一句话来概括:向对象发送消息。实际上,这就是你所做的一切——创建一堆对象并向它们发送消息。当然,诀窍是弄清楚你的对象和消息是什么*,但是一旦你完成了这个,C++ 中的实现就出奇的简单。
对象详细信息
研讨会上经常出现的一个问题是,“一个物体有多大,长什么样?”答案是“关于你对一个 C struct
的期望。”事实上, C 编译器为 C struct
(没有 C++ 修饰)生成的代码通常看起来与 C++ 编译器生成的代码完全相同。这让那些依赖于代码中的大小和布局细节的程序员感到放心,因为某些原因,他们直接访问结构字节而不是使用标识符(依赖于结构的特定大小和布局是不可移植的活动)。
一个 struct
的大小是其所有成员大小的总和。有时候编译器布局一个struct
的时候,会额外增加字节,让边界出来的很整齐;这可以提高执行效率。您可以使用sizeof
操作符来确定struct
的大小。清单 4-7 包含了一个小例子。
清单 4-7 。使用 sizeof 运算符查找结构的大小
//: C04:Sizeof.cpp
// Sizes of structs
#include "CLib.h"
#include "CppLib.h"
#include <iostream>
using namespace std;
struct A {
int i[100];
};
struct B {
void f();
};
void B::f() {}
int main() {
cout << "sizeof struct A = " << sizeof(A)
<< " bytes" << endl;
cout << "sizeof struct B = " << sizeof(B)
<< " bytes" << endl;
cout << "sizeof CStash in C = "
<< sizeof(CStash) << " bytes" << endl;
cout << "sizeof Stash in C++ = "
<< sizeof(Stash) << " bytes" << endl;
} ///:∼
第一个 print 语句产生 200,因为每个int
占用两个字节。
注意在你的电脑上你可能会得到不同的结果。
struct B
有点反常,因为它是一个没有数据成员的struct
。在 C 中,这是非法的,但是在 C++ 中我们需要创建一个struct
的选项,它的唯一任务是限定函数名的范围,所以这是允许的。然而,第二个 print 语句产生的结果是一个有点令人惊讶的非零值。在该语言的早期版本中,大小为零,但是当您创建这样的对象时,会出现一种尴尬的情况:它们与直接在它们之后创建的对象具有相同的地址,因此没有区别。对象的一个基本规则是每个对象必须有一个唯一的地址,所以没有数据成员的结构总是有一些最小的非零大小。
最后两条sizeof
语句向您展示了 C++ 中结构的大小与 C 中等价版本的大小相同。C++ 尽量不增加任何不必要的开销。
头文件礼仪
当您创建一个包含成员函数的struct
时,您正在创建一个新的数据类型。一般来说,您希望自己和他人可以轻松访问这种类型。此外,您希望将接口(声明)与实现(成员函数的定义)分开,这样就可以在不强制重新编译整个系统的情况下更改实现。通过将新类型的声明放在头文件中,可以达到这个目的。
对于大多数初学 C 的人来说,头文件是个谜。很多 C 的书都不强调,编译器也不强制函数声明,所以大部分时候看起来是可选的,除非声明了结构。在 C++ 中,头文件的使用变得非常清楚。对于简单的程序开发来说,它们实际上是强制性的,你可以在它们中放入非常具体的信息:声明。头文件告诉编译器你的库中有什么。即使您只拥有头文件以及目标文件或库文件,也可以使用该库;您不需要。cpp
文件。头文件是存储接口规范的地方。
虽然编译器没有强制要求,但是在 C 中构建大型项目的最佳方法是使用库,将相关的函数收集到同一个对象模块或库中,并使用头文件保存函数的所有声明。您可以将任何函数放入一个 C 库中,但是 C++ 抽象数据类型通过它们对struct
中数据的公共访问来确定关联的函数。任何成员函数都必须在struct
声明中声明;你不能把它放在别处。函数库的使用在 C 中被鼓励,在 C++ 中被制度化。
头文件的重要性
当使用库中的函数时, C 允许你忽略头文件,直接手工声明函数。在过去,人们有时会通过避免打开和包含文件的任务来加快编译器的速度(这通常不是现代编译器的问题)。例如,这里有一个极其懒惰的 C 函数printf( )
(来自<stdio.h>
)的声明:
printf(...)
省略号指定了一个变量参数列表,它说printf( )
有一些参数,每个参数都有一个类型,但是忽略它;只要接受你看到的任何论点。通过使用这种声明,可以暂停对参数的所有错误检查。
这种做法可能会导致微妙的问题。如果你手工声明函数,在一个文件中你可能会出错。由于编译器只看到你在那个文件中的手写声明,它也许能够适应你的错误。程序将会正确地链接,但是在那个文件中函数的使用将会出错。这是一个很难发现的错误,使用头文件很容易避免。
如果将所有的函数声明放在一个头文件中,并在使用函数和定义函数的地方都包含这个头文件,就可以确保整个系统中声明的一致性。您还可以通过在定义文件中包含头文件来确保声明和定义相匹配。
如果在 C++ 的头文件中声明了一个struct
,你必须包括头文件中所有使用struct
和定义struct
成员函数的地方。如果你试图调用一个常规函数,或者调用或定义一个成员函数,而没有先声明它,C++ 编译器会给出一个错误消息。通过强制正确使用头文件,该语言确保了库中的一致性,并通过强制在任何地方使用相同的接口来减少错误。
标题是你和你的库的用户之间的合同。契约描述了您的数据结构,并声明了函数调用的参数和返回值。它说,“这就是我的图书馆所做的。”用户需要这些信息中的一部分来开发应用程序,编译器需要所有这些信息来生成正确的代码。struct
的用户简单地包含头文件,创建该struct
的对象(实例),并链接到对象模块或库(即编译后的代码)。
编译器通过要求您在使用所有结构和函数之前声明它们来强制执行契约,对于成员函数,则是在定义它们之前声明。因此,您必须将声明放在头文件中,并在定义成员函数的文件和使用成员函数的文件中包含头文件。因为整个系统包含一个描述库的头文件,所以编译器可以确保一致性并防止错误。
为了正确组织代码并编写有效的头文件,您必须了解某些问题。第一个问题是关于你能把什么放进头文件。基本规则是“仅声明”(即,仅向编译器提供信息,但不通过生成代码或创建变量来分配存储)。这是因为头文件通常包含在一个项目的几个翻译单元中,如果一个标识符的存储分配在多个位置,链接器将出现多重定义错误。
注意这是 C++ 的一个定义规则:你可以任意多次声明事物,但是每个事物只能有一个实际的定义。
这条规则并不完全严格。如果您在头文件中定义了一个“文件静态”(只在文件中可见)的变量,那么在整个项目中将会有该数据的多个实例,但是链接器不会有冲突。基本上,你不想在头文件中做任何会在链接时引起歧义的事情。
多重声明问题
第二个头文件问题是这样的:当你在头文件中放入一个struct
声明时,这个文件有可能在一个复杂的程序中被多次包含。Iostreams 就是一个很好的例子。任何时候一个struct
做 I/O,它可能包括一个iostream
头。如果您正在处理的cpp
文件使用了不止一种struct
( 通常为每一种包含一个头文件),那么您将冒不止一次包含<iostream>
头文件并重新声明iostream
的风险。
编译器认为结构的重新声明(包括struct
s 和class
es)是错误的,因为它允许你对不同的类型使用相同的名字。为了防止在包含多个头文件时出现这种错误,您需要使用预处理器在头文件中构建一些智能(像 <iostream>
这样的标准 C++ 头文件已经拥有这种“智能”)。
只要两个声明匹配,C 和 C++ 都允许你重新声明一个函数,但是都不允许重新声明一个结构。在 C++ 中,这条规则尤其重要,因为如果编译器允许你重新声明一个结构,而两个声明不同,它会使用哪一个?
在 C++ 中,重新声明的问题经常出现,因为每个数据类型(结构和函数)通常都有自己的头文件,如果你想创建另一个使用第一个头文件的数据类型,你必须在另一个头文件中包含一个头文件。无论如何?在您的项目中,您可能会包括几个包含相同头文件的文件。在单次编译期间,编译器可以多次看到同一个头文件。除非你做些什么,编译器会看到你的结构的重新声明,并报告一个编译时错误。要解决这个问题,您需要对预处理器有更多的了解。
预处理器指令:#define,#ifdef,#endif
预处理指令#define
可以用来创建编译时标志。您有两种选择:您可以简单地告诉预处理器标志已经定义,而不指定值,就像
#define FLAG
或者你可以给它一个值(这是典型的 C 语言定义常量的方式),比如
#define PI 3.14159
在这两种情况下,预处理器现在都可以测试标签,看它是否已经被定义。
#ifdef FLAG
这将产生一个真实的结果,#ifdef
之后的代码将被包含在发送给编译器的包中。当预处理器遇到语句时,这种包含停止
#endif
或者
#endif // FLAG
同一行的#endif
后面的任何非注释都是非法的,尽管有些编译器可能接受它。#ifdef
/ #endif
对可以相互嵌套。
#define
的补码是#undef
(“未定义”的简称),这会让使用同一个变量的#ifdef
语句产生错误的结果。#undef
也会导致预处理器停止使用宏。#ifdef
的补码是#ifndef
,如果标签没有被定义,它将产生一个 true(这是我们将在头文件中使用的)。
在 C 预处理器中还有其他有用的特性。你应该检查你的当地文件以获得完整的一套。
头文件的标准
在每个包含结构的头文件中,您应该首先检查这个头文件是否已经包含在这个特定的cpp
文件中。通过测试一个预处理器标志可以做到这一点。如果没有设置这个标志,那么这个文件就没有被包含,你应该设置这个标志(这样这个结构就不能被重新声明)并声明这个结构。如果设置了标志,则该类型已经被声明,因此您应该忽略声明它的代码。下面是头文件的样子:
#ifndef HEADER_FLAG
#define HEADER_FLAG
// Type declaration here...
#endif // HEADER_FLAG
如您所见,第一次包含头文件时,头文件的内容(包括您的类型声明)将被预处理器包含。所有随后被包含在单个编译单元中的时候,类型声明都将被忽略。名称 HEADER_FLAG 可以是任何唯一的名称,但是要遵循的一个可靠标准是将头文件的名称大写,并用下划线代替句点(前导下划线,但是,为系统名称保留)。清单 4-8 显示了一个例子。
清单 4-8 。防止重新定义的简单标题
//: C04:Simple.h
// Simple header that prevents redefinition
#ifndef SIMPLE_H
#define SIMPLE_H
struct Simple {
int i,j,k;
initialize() { i = j = k = 0; }
};
#endif // SIMPLE_H ///:∼
虽然#endif
后面的SIMPLE_H
被注释掉,因此被预处理器忽略,但它对文档很有用。
这些防止多重包含的预处理语句通常被称为include guard。
标题中的名称空间
你会注意到使用指令出现在本书几乎所有的cpp
文件中,通常以的形式出现
using namespace std;
由于std
是包围整个标准 C++ 库的名称空间,这个特殊的using
指令允许标准 C++ 库中的名称被无限制地使用。然而,你几乎不会在头文件中看到using
指令(至少,不会在作用域之外)。原因是using
指令消除了对该特定名称空间的保护,并且这种影响持续到当前编译单元结束。如果你把一个using
指令(在一个作用域之外)放在一个头文件中,这意味着任何包含这个头文件的文件都会失去名称空间保护,这通常意味着其他头文件。因此,如果您开始将using
指令放在头文件中,实际上很容易“关闭”任何地方的名称空间,从而抵消名称空间的有益效果。
简而言之,不要把using
指令放在头文件中。
在项目中使用标题
当用 C++ 构建一个项目时,你通常会通过把许多不同的类型(数据结构和相关的函数)放在一起来创建它。你通常将每种类型或每组相关类型的声明放在一个单独的头文件中,然后在一个翻译单元中为该类型定义函数。使用该类型时,必须包含头文件才能正确执行声明。
有时在本书中会遵循这种模式,但是更多的情况下例子会非常小,所以所有的东西——结构声明、函数定义和main( )
函数——都可能出现在一个文件中。但是,请记住,在实践中,您可能希望使用单独的文件和头文件。
嵌套结构
将数据和函数名从全局名称空间中取出的便利扩展到了结构中。您可以将一个结构嵌套在另一个结构中,从而将关联的元素放在一起。声明语法是您所期望的,正如您在清单 4-9 中看到的,它将下推堆栈实现为一个简单的链表,因此它“从不”耗尽内存。
清单 4-9 。嵌套结构
//: C04:Stack.h
// Nested struct in linked list
#ifndef STACK_H
#define STACK_H
struct Stack {
struct Link {
void* data;
Link* next;
void initialize(void* dat, Link* nxt);
}* head;
void initialize();
void push(void* dat);
void* peek();
void* pop();
void cleanup();
};
#endif // STACK_H ///:∼
嵌套的struct
被称为Link
,它包含一个指向列表中下一个Link
的指针和一个指向存储在Link
中的数据的指针。如果next
指针为零,意味着你在列表的末尾。
注意,head
指针是在struct Link
的声明之后定义的,而不是一个单独的定义Link* head
。这是一个来自 C 的语法,但是它强调了结构声明后分号的重要性;分号表示该结构类型定义的逗号分隔列表的结尾。
注通常列表是空的。
嵌套结构有自己的initialize( )
函数,就像到目前为止出现的所有结构一样,以确保正确的初始化。 Stack
有一个initialize( )
和cleanup( )
函数,还有push( )
,它获取一个指向您希望存储的数据的指针(它假设这个数据已经被分配到堆上),还有pop( )
,它从Stack
的顶部返回data
指针并移除顶部元素。(当你 pop( )
一个元素,你就负责销毁data
指向的对象。)函数peek( )
也从顶部元素返回data
指针,但是它将顶部元素留在Stack
上。
清单 4-10 包含成员函数的定义。
清单 4-10 。包含成员函数定义的嵌套链表
//: C04:Stack.cpp {O}
// Linked list with nesting
// Includes definitions of member functions
#include "Stack.h" // To be INCLUDED from Header FILE above
#include "../require.h"
using namespace std;
void
Stack::Link::initialize(void* dat, Link* nxt) {
data = dat;
next = nxt;
}
void Stack::initialize() { head = 0; }
void Stack::push(void* dat) {
Link* newLink = new Link;
newLink->initialize(dat, head);
head = newLink;
}
void* Stack::peek() {
require(head != 0, "Stack empty");
return head->data;
}
void* Stack::pop() {
if(head == 0) return 0;
void* result = head->data;
Link* oldHead = head;
head = head->next;
delete oldHead;
return result;
}
void Stack::cleanup() {
require(head == 0, "Stack not empty");
} ///:∼
第一个定义特别有趣,因为它向您展示了如何定义嵌套结构的成员。您只需使用额外级别的范围解析来指定封闭的struct
的名称。Stack::Link::initialize( )
获取参数并将其分配给其成员。
Stack::initialize( )
将head
设置为零,因此对象知道它有一个空列表。
Stack::push( )
获取参数,该参数是指向您想要跟踪的变量的指针,并将它推送到Stack
。首先,它使用new
为它将在顶部插入的Link
分配存储空间。然后它调用Link
的initialize( )
函数给Link
的成员分配合适的值。注意next
指针被分配给当前的head
;然后head
被分配给新的Link
指针。这有效地将Link
推到了列表的顶部。
Stack::pop( )
在Stack
的当前顶端捕获data
指针;然后向下移动head
指针并删除Stack
的旧顶部,最后返回捕获的指针。当pop( )
移除最后一个元素时,则head
再次变为零,意味着Stack
为空。
实际上并不做任何清理工作。相反,它建立了一个严格的策略,即您(使用这个 Stack
对象的客户端程序员)负责弹出这个Stack
中的所有元素并删除它们。如果Stack
不为空,则require( )
用于指示出现了编程错误。
为什么Stack
析构函数不能对客户端程序员没有pop( )
的所有对象负责?问题是,Stack
拿着void
指针,你会在 第十三章 中了解到,为void*
调用delete
并不能很好地解决问题。谁对内存负责的主题甚至不是那么简单,你会在后面的章节中看到。
清单 4-11 包含了一个测试Stack
的例子。
清单 4-11 。测试堆栈
//: C04:StackTest.cpp
//{L} Stack
//{T} StackTest.cpp
// Test of nested linked list
#include "Stack.h"
#include "../require.h"
#include<fstream>
#include<iostream>
#include<string>
using namespace std;
int main(int argc, char* argv[]) {
requireArgs(argc, 1); // File name is argument
ifstream in(argv[1]);
assure(in, argv[1]);
Stack textlines;
textlines.initialize();
string line;
// Read file and store lines in the Stack:
while(getline(in, line))
textlines.push(new string(line));
// Pop the lines from the Stack and print them:
string* s;
while((s = (string*)textlines.pop()) != 0) {
cout << *s << endl;
delete s;
}
textlines.cleanup();
} ///:∼
这类似于前面的例子,但是它将文件中的行(作为 string
指针)推到Stack
上,然后将它们弹出,这导致文件以相反的顺序打印出来。注意,pop( )
成员函数返回一个void*
,在使用它之前,必须将其转换回一个string*
。为了打印string
,指针被解引用。
当textlines
被填充时,通过制作一个new string(line)
,为每个push( )
克隆line
的内容。从 new-expression 返回的值是一个指向新的string
的指针,该指针被创建并从line
复制信息。如果您只是简单地将line
的地址传递给push( )
,那么您将得到一个充满相同地址的Stack
,所有地址都指向line
。文件名取自命令行。为了保证在命令行上有足够的参数,您会看到在require.h
头文件中使用的第二个函数:requireArgs( )
,它将argc
与所需的参数数量进行比较,并打印一条适当的错误消息,如果没有足够的参数,就退出程序。
全局范围分辨率
作用域解析操作符让您摆脱编译器默认选择的名称(“最近”的名称)不是您想要的情况。例如,假设您有一个带有局部标识符a
的结构,并且您想从成员函数内部选择一个全局标识符a
。编译器默认选择本地的,所以你必须告诉它不这样做。当您想要使用范围解析指定全局名称时,可以使用前面不带任何内容的运算符。清单 4-12 显示了变量和函数的全局范围解析。
清单 4-12 。全局范围分辨率
//: C04:Scoperes.cpp
// Global scope resolution for a variable
// As well as a function
int a;
void f() {}
struct S {
int a;
void f();
};
void S::f() {
::f(); // Would be recursive otherwise!
::a++; // Select the global a
a--; // The a at struct scope
}
int main() { S s; f(); } ///:∼
在S::f( )
中没有范围解析,编译器将默认选择f( )
和a
的成员版本。
审查会议
- 在这一章中,你学习了 C++ 的基本“变化”:你可以在结构中放置函数。这种新类型的结构被称为抽象数据类型,使用这种结构创建的变量被称为该类型的对象,或实例。
- 调用一个对象的成员函数叫做向该对象发送消息。在面向对象编程中的主要动作是向对象发送消息。
- 虽然将数据和函数打包在一起对于代码组织来说是一个很大的好处,并且使库的使用更容易,因为它通过隐藏名字来防止名字冲突,但是你还可以做更多的事情来使 C++ 编程更安全。
- 在下一章,你将学习如何保护一个结构的一些成员,这样只有你才能操纵它们。
- 这在结构的用户可以更改的内容和只有程序员可以更改的内容之间建立了一个清晰的界限。**
五、隐藏实现
虽然 C 是世界上最受欢迎和广泛使用的编程语言之一,但 C++ 的发明是由一个主要的编程因素促成的:日益增加的复杂性。多年来,计算机程序变得越来越大,越来越复杂。即使 C 语言是一种优秀的编程语言,它也有其局限性。在 C 中,一旦一个程序从 20,000 行代码超过 100,000 行代码,它就变得难以管理,难以从整体上把握。C++ 的目的就是打破这个壁垒。C++ 的基本本质在于允许程序员理解、领会和管理更复杂和更大的程序。
C++ 从 C 中吸取了最好的想法,并将它们与几个新概念结合起来。结果是一种不同的组织你的程序的方式。在 C 中,一个程序是围绕着它的代码组织的(例如,“发生了什么?”)而在 C++ 中,程序是围绕其数据组织的(例如,“谁受到了影响?”).用 C 编写的程序是由其函数定义的,任何函数都可以对程序使用的任何类型的数据进行操作。在 C++ 中,程序是围绕数据组织的,基本前提是数据控制对代码的访问。因此,您定义了数据和允许对该数据进行操作的例程,而数据类型精确地定义了什么样的操作适用于该数据。
为了支持这一面向对象编程的基本原则,C++ 具有封装的特性,因此它可以将代码和它所处理的数据绑定在一起,保护它们免受外部干扰和误用。通过以这种方式链接代码和数据,创建了一个对象。因此,对象是支持封装的设备。
在一个对象中,代码/数据或两者都可以是该对象的private/public
或protected
,这只有在继承对象的情况下才起作用。我们将在本章中讨论这个访问控制和更多(比如类);继承的主题将在后面的章节中讨论。
在前一章中,我们已经讨论了通过 C++ 尽可能多地使用现有的 C 代码和库来提高生产率的必要性。一个典型的 C 库包含一个struct
和一些作用于该struct
的相关函数。到目前为止,您已经看到了 C++ 如何获取概念上与相关联的函数,并通过将函数声明放在struct
的范围内,改变调用struct
函数的方式,消除将结构地址作为第一个参数的传递,并向程序添加新的类型名(,这样您就不必为* struct
标签创建类型集)来使它们真正与*相关联。**
这都是方便的;它帮助你组织你的代码,使它更容易写和读。然而,当在 C++ 中使库更容易时,还有其他重要的问题,尤其是安全和控制的问题。本章着眼于结构中的边界问题。
设定限值
在任何关系中,重要的是要有各方都尊重的界限。当您创建一个库时,您与使用该库构建应用程序或另一个库的客户端程序员建立了关系。
在 a C struct
中,就像在 C 中的大多数事情一样,没有规则。客户端程序员可以用那个struct
做任何他们想做的事情,并且没有办法强制任何特定的行为。例如,即使你在上一章看到了名为initialize( )
和cleanup( )
的函数的重要性,客户程序员也可以选择不调用这些函数。
注我们将在下一章探讨更好的方法。
即使你真的希望客户端程序员不要直接操纵你的struct
的一些成员,在 C 中也没有办法阻止它。对这个世界来说一切都是赤裸裸的。
控制对成员的访问有两个原因。第一是让客户程序员不要接触他们不应该接触的工具——这些工具是数据类型的内部机制所必需的,但不是客户程序员解决特定问题所需的接口的一部分。这实际上是对客户程序员的一种服务,因为他们可以很容易地看到对他们来说什么是重要的,什么是可以忽略的。
访问控制的第二个原因是允许库设计者改变结构的内部工作,而不用担心它会如何影响客户程序员。在上一章的Stack
示例中,为了提高速度,您可能希望以大块的方式分配存储,而不是每次添加元素时都创建新的存储。如果接口和实现被清楚地分离和保护,您可以完成这一点,并且只需要客户端程序员重新链接。
C++ 访问控制
C++ 引入了三个新的关键字来设置结构中的边界:public
、private
和protected
。它们的用法和含义非常简单。这些访问说明符 只在一个结构声明中使用,它们改变所有跟在它们后面的声明的边界。无论何时使用访问说明符,后面都必须跟一个冒号。
Public
表示所有人都可以使用下面的所有成员声明。public
成员就像struct
成员。例如,清单 5-1 中的struct
声明是相同的。
清单 5-1 。C++ 的 public 就像 C 的 struct 一样
//: C05:Public.cpp
// Uses identical struct declarations
struct A {
int i;
char j;
float f;
void func();
};
void A::func() {}
struct B {
public:
int i;
char j;
float f;
void func();
};
void B::func() {}
int main() {
A a; B b;
a.i = b.i = 1;
a.j = b.j = 'c';
a.f = b.f = 3.14159;
a.func();
b.func();
} ///:∼
另一方面,private
关键字意味着除了你——该类型的创建者——之外,没有人可以访问该类型的函数成员。private
是你和客户端程序员之间的一堵砖墙;如果有人试图访问一个private
成员,他们会得到一个编译时错误。在清单 5-1 的struct B
中,您可能想要隐藏部分表示(即数据成员),只有您可以访问;你可以在清单 5-2 中看到这一点。
清单 5-2 。私有访问说明符
//: C05:Private.cpp
// Setting the Boundary
// and Hiding Portions of the Representation
struct B {
private:
char j;
float f;
public:
int i;
void func();
};
void B::func() {
i = 0;
j = '0';
f = 0.0;
};
int main() {
B b;
b.i = 1; // OK, public
//! b.j = '1'; // Illegal, private
//! b.f = 1.0; // Illegal, private
} ///:∼
虽然func( )
可以访问B
的任何成员(因为func( )
是B
的成员,因此自动授予其权限),但是像main( )
这样的普通全局函数却不能。当然,其他结构的成员函数也不能。只有在结构声明(“契约”)中明确说明的函数才能访问private
成员。
访问说明符没有规定的顺序,它们可能会出现多次。它们影响在它们之后和下一个访问说明符之前声明的所有成员。
另一个访问说明符:protected
最后一个访问说明符是protected
。protected
的行为就像private
,除了一个我们现在不能谈论的例外:“继承的”结构(不能访问 private
成员)被授予访问protected
成员的权限。这将在第十四章引入继承时变得更加清楚。出于当前目的,考虑protected
就像private
一样。
朋友
如果您想显式授予对一个不是当前结组合员的函数的访问权限,该怎么办?这是通过在结构声明中声明一个friend
函数来实现的。重要的是,friend
声明出现在结构声明中,因为您(和编译器)必须能够阅读结构声明,并看到关于该数据类型的大小和行为的每一条规则。在任何关系中,一个非常重要的规则是“谁可以访问我的私有实现?”**
该类控制哪些代码可以访问其成员。如果你不是一个friend
,没有神奇的方法从外面“闯入”;你不能声明一个新类,然后说:“你好,我是Blah
的朋友!”期待看到Blah
的private
和protected
成员。
可以将一个全局函数声明为friend
,也可以将另一个结构的成员函数,甚至整个结构声明为friend
。清单 5-3 显示了一个例子。
清单 5-3 。宣布成为朋友
//: C05:Friend.cpp
// Friend allows special access
// Declaration (incomplete type specification):
struct X;
struct Y {
void f(X*);
};
struct X { // Definition
private:
int i;
public:
void initialize();
friend void g(X*, int); // Global friend
friend void Y::f(X*); // Struct member friend
friend struct Z; // Entire struct is a friend
friend void h();
};
void X::initialize() {
i = 0;
}
void g(X* x, int i) {
x->i = i;
}
void Y::f(X* x) {
x->i = 47;
}
struct Z {
private:
int j;
public:
void initialize();
void g(X* x);
};
void Z::initialize() {
j = 99;
}
void Z::g(X* x) {
x->i += j;
}
void h() {
X x;
x.i = 100; // Direct data manipulation
}
int main() {
X x;
Z z;
z.g(&x);
} ///:∼
struct Y
有一个成员函数f( )
,它将修改一个X
类型的对象。这是一个有点难的问题,因为 C++ 编译器要求你在引用它之前声明所有的东西,所以struct Y
必须在它的成员Y::f(X*)
在struct X
中被声明为朋友之前声明。但是要声明Y::f(X*)
,必须先声明struct X
!
下面是解决方案。注意,Y::f(X*)
接受了一个X
对象的地址。这很重要,因为编译器总是知道如何传递地址,不管传递的对象是什么,地址的大小都是固定的,即使它没有关于类型大小的完整信息。然而,如果你试图传递整个对象,编译器必须看到X
的整个结构定义,才能知道它的大小和如何传递,然后才允许你声明一个像Y::g(X)
这样的函数。
通过传递一个X
的地址,编译器允许你在声明Y::f(X*)
之前做一个X
的不完整类型规范。这在《宣言》中已经实现。
struct X;
这个声明简单地告诉编译器有一个以这个名字命名的struct
,所以只要你不需要比名字更多的知识,就可以引用它。
现在,在struct X
中,函数Y::f(X*)
可以被声明为friend
没有问题。如果你试图在编译器看到Y
的完整规范之前声明它,它会给你一个错误。这是一个安全特性,用于确保一致性和消除错误。
注意另外两个friend
函数。第一个将一个普通的全局函数g( )
声明为一个friend
。但是g( )
以前没有在全局范围内声明过!事实证明,friend
可以以这种方式同时声明函数和并赋予其friend
状态。这延伸到整个结构,例如
friend struct Z;
是对Z
的不完整的类型规范,它给出了整个结构friend
的状态。
嵌套的朋友
嵌套一个结构并不会自动赋予它对private
成员的访问权。要完成这个,你必须遵循一个特定的形式:首先,声明(而不定义)嵌套结构,然后声明为friend
,最后定义结构。结构定义必须与friend
声明分开,否则它会被编译器视为非成员。清单 5-4 显示了一个例子。
清单 5-4 。嵌套的朋友
//: C05:NestFriend.cpp
// Demonstrates Nested friends
#include <iostream>
#include <cstring> // memset()
using namespace std;
const int sz = 20;
struct Holder {
private:
int a[sz];
public:
void initialize();
struct Pointer;
friend struct Pointer;
struct Pointer {
private:
Holder* h;
int* p;
public:
void initialize(Holder* h);
// Move around in the array:
void next();
void previous();
void top();
void end();
// Access values:
int read();
void set(int i);
};
};
void Holder::initialize() {
memset(a, 0, sz * sizeof(int));
}
void Holder::Pointer::initialize(Holder* rv) {
h = rv;
p = rv->a;
}
void Holder::Pointer::next() {
if(p < &(h->a[sz - 1])) p++;
}
void Holder::Pointer::previous() {
if(p > &(h->a[0])) p--;
}
void Holder::Pointer::top() {
p = &(h->a[0]);
}
void Holder::Pointer::end() {
p = &(h->a[sz - 1]);
}
int Holder::Pointer::read() {
return *p;
}
void Holder::Pointer::set(int i) {
*p = i;
}
int main() {
Holder h;
Holder::Pointer hp, hp2;
int i;
h.initialize();
hp.initialize(&h);
hp2.initialize(&h);
for(i = 0; i < sz; i++) {
hp.set(i);
hp.next();
}
hp.top();
hp2.end();
for(i = 0; i < sz; i++) {
cout << "hp = " << hp.read()
<< ", hp2 = " << hp2.read() << endl;
hp.next();
hp2.previous();
}
} ///:∼
一旦Pointer
被声明,它就被授权访问Holder
的私有成员,方法是
friend struct Pointer;
struct Holder
包含一个int
的数组,Pointer
允许你访问它们。因为Pointer
与Holder
有很强的关联,所以让它成为Holder
的成员结构是明智的。但是因为Pointer
是一个独立于Holder
的类,你可以在main( )
中创建一个以上的类,并用它们来选择数组的不同部分。Pointer
是一个结构,而不是一个原始的 C 指针,所以你可以保证它总是安全地指向Holder
内部。
标准的 C 库函数memset( )
(在<cstring>
中)在清单 5-4 的程序中使用是为了方便。对于起始地址之后的n
字节(n
是第三个参数),它将从特定地址(第一个参数)开始的所有内存设置为特定值(第二个参数)。当然,你可以简单地使用一个循环来遍历所有的内存,但是memset( )
是可用的,经过了良好的测试(所以你不太可能引入错误),并且可能比你手工编码更有效。
它是纯净的吗?
类定义给了你一个审计线索,所以你可以通过查看类来发现哪些函数有权限修改类的私有部分。如果一个函数是一个friend
,这意味着它不是一个成员,但是你无论如何都要允许修改私有数据,并且它必须在类定义中列出,这样每个人都可以看到它是一个特权函数。
C++ 是一种混合面向对象的语言,而不是一种纯粹的语言,添加friend
是为了避开突然出现的实际问题。指出这使得语言不那么“纯粹”是很好的,因为 C++ 被设计成实用的 ??,而不是渴望抽象的理想。
对象布局
第四章声明了为 C 编译器编写的struct
和后来用 C++ 编译的struct
将保持不变。这主要指的是struct
的对象布局——也就是说,单个变量的存储位于为对象分配的内存中。如果 C++ 编译器改变了 C struct
s 的布局,那么您编写的任何 C 代码,如果不恰当地利用了struct
中变量位置的知识,都将崩溃。
然而,当您开始使用访问说明符时,您已经完全进入了 C++ 领域,事情发生了一些变化。在一个特定的访问块(一组由访问说明符分隔的声明)中,变量保证是连续布局的,就像在 C 中一样。但是,访问块可能不会按照您声明它们的顺序出现在对象中。尽管编译器会通常完全按照您看到的方式来布置块,但这并没有什么规则,因为特定的机器架构和/或操作环境可能会明确支持private
和protected
,这可能需要将这些块放在特殊的内存位置。语言规范不想限制这种优势。
访问说明符是结构的一部分,不影响从结构中创建的对象。在程序运行之前,所有的访问规范信息都会消失;通常这发生在编译期间。在一个正在运行的程序中,对象成为“存储区域”,仅此而已。如果你真的想,你可以打破所有规则,直接访问内存,就像你在 C 里做的那样。C++ 不是为了防止你做不明智的事情;它只是为你提供了一个更容易、更令人满意的选择。
一般来说,在编写程序时,依赖任何特定于实现的东西都不是一个好主意。当您必须有特定于实现的依赖项时,将它们封装在一个结构中,以便任何移植更改都集中在一个地方。
上课了
访问控制通常被称为实现隐藏。在结构中包含函数(通常被称为封装)会产生具有特征和行为的数据类型,但是访问控制会在该数据类型中设置边界——有两个重要原因。首先是确定客户端程序员能使用什么,不能使用什么。您可以将您的内部机制构建到结构中,而不用担心客户端程序员会认为这些机制是他们应该使用的接口的一部分。
这直接导致了第二个原因,即将接口从实现中分离出来。如果该结构在一组程序中使用,但是客户端程序员除了向public
接口发送消息之外什么也不能做,那么您可以修改任何属于private
的东西,而不需要修改他们的代码。
封装和访问控制加在一起,发明了比C更多的东西。我们现在处于面向对象编程的世界,在这里,一个结构描述一类对象,就像你描述一类鱼或一类鸟一样:属于这个类的任何对象都将共享这些特征和行为。这就是结构声明的含义,它描述了这种类型的所有对象的外观和行为。
在最初的 OOP 语言 Simula-67 中,关键字class
用于描述一种新的数据类型。这显然启发了 Stroustrup(C++ 语言的首席设计师)为 c++ 选择了相同的关键字,以强调这是整个语言的焦点:创建新的数据类型,而不仅仅是带有函数的C。这当然看起来像是一个新关键字的充分理由。
然而,在 C++ 中使用class
几乎是一个不必要的关键字。它与struct
关键字完全相同,除了一点:class
默认为private
,而struct
默认为public
。清单 5-5 显示了产生相同结果的两个结构。
清单 5-5 。比较结构和类
//: C05:Class.cpp
// Similarity of struct and class
struct A {
private:
int i, j, k;
public:
int f();
void g();
};
int A::f() {
return(i + j + k);
}
void A::g() {
i = j = k = 0;
}
// Identical results are produced with:
class B {
int i, j, k;
public:
int f();
void g();
};
int B::f() {
return(i + j + k);
}
void B::g() {
i = j = k = 0;
}
int main() {
A a;
B b;
a.f(); a.g();
b.f(); b.g();
} ///:∼
class
是 C++ 中基本的 OOP 概念。这是本书中而不是将被设置为粗体的关键词之一——随着一个词像“类”一样频繁地重复,它变得令人讨厌。“向类的转变是如此重要,以至于 C++ 设计者们倾向于将struct
完全抛弃,但是向后兼容 C 的需要不允许这样做。
许多人更喜欢创建更像struct
-而不是 class- 的类的风格,因为您通过从public
元素开始覆盖了类的默认到private
行为,比如:
class X {
public:
void interface_function();
private:
void private_function();
int internal_representation;
};
这背后的逻辑是,读者首先看到感兴趣的成员更有意义,然后他们可以忽略任何写有private
的内容。事实上,所有其他成员都必须在类中声明的唯一原因是,编译器知道对象有多大,可以正确地分配它们,这样可以保证一致性。
然而,本书中的示例将把private
成员放在第一位,就像这样:
class X {
void private_function();
int internal_representation;
public:
void interface_function();
};
有些人甚至不厌其烦地修饰自己的私人名字,就像这样:
class Y {
public:
void f();
private:
int mX; // "Self-decorated" name
};
因为mX
已经隐藏在Y
的范围内,所以m
(对于“成员”)是不必要的。然而,在具有许多全局变量的项目中(这是您应该努力避免的,但在现有项目中有时是不可避免的),能够在成员函数定义中区分哪些数据是全局的,哪些数据是成员是有用的。
修改存储以使用访问控制
从 第四章 中提取例子并修改它们以使用类和访问控制是有意义的。请注意,接口的客户端程序员部分现在是如何被清楚地区分的,所以客户端程序员不可能意外地操作了他们不应该操作的类的一部分。参见清单 5-6 。
清单 5-6 。更新存储以使用访问控制
//: C05:Stash.h
// Converted to use access control
#ifndef STASH_H
#define STASH_H
class Stash {
int size; // Size of each space
int quantity; // Number of storage spaces
int next; // Next empty space
// Dynamically allocated array of bytes:
unsigned char* storage;
void inflate(int increase);
public:
void initialize(int size);
void cleanup();
int add(void* element);
void* fetch(int index);
int count();
};
#endif // STASH_H ///:∼
inflate( )
函数被设为private
,因为它只被add( )
函数使用,因此是底层实现的一部分,而不是接口。这意味着,在以后的某个时候,您可以更改底层实现来使用不同的系统进行内存管理。
除了包含文件的名称之外,头是本例中唯一更改的内容。实现文件和测试文件是相同的。
修改堆栈以使用访问控制
作为第二个例子,清单 5-7 显示了Stack
变成了一个类。现在嵌套的数据结构是private
,这很好,因为它确保了客户端程序员既不必查看它,也不必依赖于Stack
的内部表示。
清单 5-7 。将堆栈转换为类
//: C05:Stack2.h
// Nested structs via linked list
#ifndef STACK2_H
#define STACK2_H
class Stack {
struct Link {
void* data;
Link* next;
void initialize(void* dat, Link* nxt);
}* head;
public:
void initialize();
void push(void* dat);
void* peek();
void* pop();
void cleanup();
};
#endif // STACK2_H ///:∼
和以前一样,实现没有改变,所以这里不再重复。测试也是一样的。唯一改变的是类接口的健壮性。访问控制的真正价值是防止您在开发过程中越界。事实上,编译器是唯一知道类成员保护级别的东西。没有将访问控制信息分解到成员名称中,并传递给链接器。所有的保护检查都是由编译器完成的;它在运行时消失了。
请注意,呈现给客户端程序员的界面现在是真正的下推堆栈。它碰巧被实现为一个链表,但是你可以改变它,而不影响客户端程序员与之交互的内容,或者(更重要的是)一行客户端代码。
处理类别
C++ 中的访问控制允许你把接口和实现分开,但是实现隐藏只是部分的。为了正确地创建和操作对象,编译器仍然必须看到对象所有部分的声明。您可以想象一种编程语言,它只需要对象的公共接口,并允许隐藏私有实现,但是 C++ 尽可能静态地(在编译时)执行类型检查。这意味着如果出现错误,您将尽早了解。这也意味着你的程序更有效率。然而,包含私有实现有两个影响:实现是可见的,即使您不容易访问它,并且它可能导致不必要的重新编译。
隐藏实现
一些项目不能让他们的实现对客户程序员可见。它可能会在库头文件中显示公司不想让竞争对手知道的战略信息。例如,您可能正在处理一个安全性成为问题的系统,例如加密算法,并且您不想在头文件中暴露任何可能帮助人们破解代码的线索。或者你可能把你的库放在一个“敌对”的环境中,程序员无论如何都会使用指针和类型转换直接访问私有组件。在所有这些情况下,将实际结构编译在实现文件中而不是在头文件中公开是很有价值的。
减少重新编译
如果一个文件被接触(即被修改)或者如果它所依赖的另一个文件(即一个包含的头文件)被接触,您的编程环境中的项目管理器将导致该文件的重新编译。这意味着,无论何时对一个类进行更改,无论是对公共接口还是私有成员声明,都将强制对包含该头文件的任何内容进行重新编译。对于一个处于早期阶段的大型项目来说,这可能非常笨拙,因为底层的实现可能会经常改变;如果项目非常大,编译的时间会阻碍快速周转。
解决这一问题的技术有时被称为处理类——除了一个指针,即微笑,关于实现的一切都消失了。指针指的是一个结构,其定义和所有成员函数定义都在实现文件中。因此,只要接口不变,头文件就不会受到影响。实现可以随意更改,只需要重新编译实现文件,并与项目重新链接。
清单 5-8 包含了一个演示该技术的简单例子。头文件只包含公共接口和一个不完全指定的类的指针。
清单 5-8 。处理类别
//: C05:Handle.h
// Handle classes header file
#ifndef HANDLE_H
#define HANDLE_H
class Handle {
struct Hire; // Class declaration only
Hire* smile;
public:
void initialize();
void cleanup();
int read();
void change(int);
};
#endif // HANDLE_H ///:∼
这是客户端程序员能够看到的全部内容。这条线
struct Hire;
是不完整的类型规范或类声明(类定义包括类的主体)。它告诉编译器Hire
是一个结构名,但是它没有给出关于struct
的任何细节。这些信息只够创建一个指向struct
的指针;在提供结构体之前,您不能创建对象。在这种技术中,结构体隐藏在实现文件中(参见清单 5-9 )。
清单 5-9 。
//: C05:Handle.cpp {O}
// Handle implementation
#include "Handle.h" // To be INCLUDED from Header FILE above
#include "../require.h" // To be INCLUDED from Header FILE in *Chapter 3*
// Define Handle's implementation:
struct Handle::Hire {
int i;
};
void Handle::initialize() {
smile = new Hire;
smile->i = 0;
}
void Handle::cleanup() {
delete smile;
}
int Handle::read() {
return smile->i;
}
void Handle::change(int x) {
smile->i = x;
} ///:∼
Hire
是一个嵌套结构,因此必须使用范围解析来定义,例如:
struct Handle::Hire {
在Handle::initialize( )
中,存储被分配给Hire
结构,而在Handle::cleanup( )
中,该存储被释放。这个存储用来代替您通常放入类的private
部分的所有数据元素。当你编译Handle.cpp
时,这个结构定义隐藏在目标文件中,没有人能看到它。如果你改变了Hire
的元素,唯一需要重新编译的文件是Handle.cpp
,因为头文件没有被改动。
Handle
的使用类似于 any class 的使用:包含头部、创建对象和发送消息(参见清单 5-10 )。
清单 5-10 。使用 Handle 类
//: C05:UseHandle.cpp
//{L} Handle
// Use the Handle class
#include "Handle.h"
int main() {
Handle u;
u.initialize();
u.read();
u.change(1);
u.cleanup();
} ///:∼
客户端程序员唯一可以访问的是公共接口,所以只要实现是唯一改变的,文件就永远不需要重新编译。因此,尽管这不是完美的实现隐藏,但这是一个很大的改进。
审查会议
- C++ 中的访问控制为类的创建者提供了有价值的控制。该类的用户可以清楚地看到他们可以使用什么,忽略什么。然而,更重要的是确保没有客户端程序员变得依赖于类的底层实现的任何部分。如果你作为类的创建者知道这一点,你可以改变的底层实现,因为没有的客户端程序员会因为不能访问类的这一部分而受到影响。
- 当你有能力改变底层实现时,你不仅可以在以后的某个时间改进你的设计,而且你也有犯错误的自由。无论你计划和设计得多么仔细,你都会犯错误。知道犯这些错误是相对安全的意味着你会更有实验性,你会学得更快,你会更快地完成你的项目。
- 一个类的公共接口是客户程序员所看到的,所以这是类在分析和设计过程中得到“正确”的最重要的部分。但即使这样,你也有一些改变的余地。如果你第一次没有得到正确的接口,你可以添加更多的功能,只要你不删除任何客户程序员已经在他们的代码中使用的功能。*
六、初始化和清理
第四章通过将一个典型的 C 库的所有分散组件封装到一个结构中(一种抽象数据类型,从现在开始称为类,对库的使用做了重大改进。
这不仅提供了库组件的单一统一入口点,而且还隐藏了类名中的函数名。在 第五章 中,介绍了访问控制(实现隐藏 )。这为类设计者提供了一种建立明确界限的方法,以确定允许客户端程序员操作什么,什么是不允许的。这意味着数据类型操作的内部机制是在类的设计者的控制和判断之下,客户程序员很清楚他们可以并且应该注意哪些成员。
封装和访问控制一起在提高库的易用性方面迈出了重要的一步。他们提供的“新数据类型的概念在某些方面比来自 C 的现有内置数据类型要好。C++ 编译器现在可以为该数据类型提供类型检查保证,从而确保使用该数据类型时的安全级别。
然而,说到安全,编译器能为我们做的比 C 提供的多得多。在这一章和以后的章节中,你将会看到 C++ 中设计的附加特性,这些特性使你程序中的错误几乎跳出来抓住你,有时甚至在你编译程序之前,但通常是以编译器警告和错误的形式。出于这个原因,您很快就会习惯这种听起来不太可能的情况,即编译的 C++ 程序通常第一次就能正确运行。
其中两个安全问题是初始化和清理。当程序员忘记初始化或清理变量时,很大一部分 C 错误就发生了。对于 C 库来说,尤其是这样,当客户程序员不知道如何初始化一个struct
,或者甚至不知道他们必须如何初始化。
注意库通常不包含初始化函数,所以客户端程序员被迫手工初始化struct
。
清理是一个特殊的问题,因为 C 程序员习惯于在完成后忘记变量,所以库的struct
可能需要的任何清理经常被错过。
在 C++ 中,初始化和清理的概念对于方便库的使用和消除当客户端程序员忘记执行这些活动时出现的许多微妙的错误是必不可少的。本章分析了 C++ 中有助于保证正确初始化和清理的特性。
用构造器保证初始化
前面定义的Stash
和Stack
类都有一个名为initialize()
的函数,它的名字暗示了在以任何其他方式使用对象之前应该调用它。不幸的是,这意味着客户端程序员必须确保正确的初始化。客户端程序员在匆忙让您的惊人的库解决他们的问题时,很容易错过初始化这样的细节。在 C++ 中,初始化太重要了,不能留给客户端程序员。类设计者可以通过提供一个叫做构造器的特殊函数来保证每个对象的初始化。如果一个类有一个构造器,编译器会在一个对象被创建的时候,在客户程序员得到这个对象之前,自动调用这个构造器。客户端程序员甚至不能选择构造器调用;它由编译器在定义对象时执行。
下一个挑战是如何命名这个函数。有两个问题。首先,您使用的任何名称都有可能与您希望用作该类成员的名称发生冲突。第二是因为编译器负责调用构造器,所以它必须总是知道要调用哪个函数。C++ 设计者选择的解决方案似乎是最简单和最符合逻辑的:构造器的名称与类名相同。初始化时自动调用这样的函数是有意义的。
下面是一个带有构造器的简单类:
class X {
int i;
public:
X(); // Constructor
};
现在,当一个对象被定义时,比如:
void f() {
X a;
// ...
}
同样的事情发生,就好像a
是一个int
;为该对象分配存储空间。但是当程序到达定义了a
的序列点(执行点)时,构造器被自动调用。也就是说,编译器在定义的时候悄悄地为对象a
插入对X::X()
的调用。像任何成员函数一样,构造器的第一个(" secret" )参数是this
指针——调用它的对象的地址。然而,在构造器的情况下,this
指向一个未初始化的内存块,正确初始化这个内存是构造器的工作。
像任何函数一样,构造器可以有参数,允许您指定如何创建对象,赋予它初始化值,等等。构造器参数为您提供了一种方法来保证对象的所有部分都被初始化为适当的值。例如,如果一个名为Tree
的类有一个构造器,它采用一个整数参数来表示树的高度,那么您必须创建一个树对象,如下所示:
Tree t(12); // 12-foot tree
如果Tree(int)
是你唯一的构造器,编译器不会让你用其他方式创建对象。
注意我们将在下一章看到多重构造器和调用构造器的不同方式。
这就是构造器的全部内容。这是一个特别命名的函数,在对象创建时,编译器会自动为每个对象调用它。尽管它很简单,但它非常有价值,因为它消除了一大类问题,使代码更容易编写和阅读。例如,在前面的代码片段中,您看不到对某个initialize()
函数的显式函数调用,该函数在概念上与定义是分开的。在 C++ 中,定义和初始化是统一的概念——不能缺一不可。
构造器和析构函数都是非常不常见的函数类型:它们没有返回值。这与void
返回值明显不同,在后者中,函数不返回任何内容,但您仍然可以选择将它设置为其他内容。构造器和析构函数不返回任何东西,你没有选择。将一个对象带入和带出程序的行为是特殊的,像出生和死亡,编译器总是自己调用函数,以确保它们发生。如果有返回值,并且您可以选择自己的返回值,编译器就必须知道如何处理返回值,否则客户端程序员就必须显式调用构造器和析构函数,这就消除了它们的安全性。
用析构函数保证清理
作为一名 C 程序员,你经常会想到初始化的重要性,但是很少会想到清理。毕竟清理一个int
需要做什么?忘了它吧。然而,对于库来说,一旦你使用完一个对象,仅仅让它“??”释放“??”就不那么安全了。如果它修改了一些硬件,或者在屏幕上放了一些东西,或者在堆上分配了存储空间,那该怎么办呢?如果你只是忘记了它,你的对象就永远不会在离开这个世界时达到终结。在 C++ 中,清理和初始化一样重要,因此用析构函数来保证。
析构函数的语法类似于构造器的语法:类名用作函数名。但是,析构函数通过前导波浪符号(∼
)与构造器区分开来。此外,析构函数从来没有任何参数,因为析构从来不需要任何选项。下面是析构函数的声明:
class Y {
public:
∼Y();
};
当对象超出范围时,编译器会自动调用析构函数。您可以通过对象的定义点看到构造器在哪里被调用,但是析构函数调用的唯一证据是对象周围范围的右括号。然而析构函数仍然被调用,即使你使用goto
跳出一个作用域。(goto
仍然存在于 C++ 中,是为了向后兼容 C ,以备不时之需。)你应该注意到,由标准的 C 库函数setjmp()
和longjmp()
实现的非局部 goto ,不会导致析构函数被调用。
注意这是规范,即使你的编译器没有这样实现。依赖规范中没有的特性意味着你的代码是不可移植的。
清单 6-1 展示了到目前为止你所看到的构造器和析构函数的特性。
清单 6-1 。构造器和析构函数
//: C06:Constructor1.cpp
// Demonstrates features of constructors & destructors
#include <iostream>
using namespace std;
class Tree {
int height;
public:
Tree(int initialHeight); // Constructor
∼Tree(); // Destructor
void grow(int years);
void printsize();
};
Tree::Tree(int initialHeight) {
height = initialHeight;
}
Tree::∼Tree() {
cout << "inside Tree destructor" << endl;
printsize();
}
void Tree::grow(int years) {
height += years;
}
void Tree::printsize() {
cout << "Tree height is " << height << endl;
}
int main() {
cout << "before opening brace" << endl;
{
Tree t(12);
cout << "after Tree creation" << endl;
t.printsize();
t.grow(4);
cout << "before closing brace" << endl;
}
cout << "after closing brace" << endl;
} ///:∼
下面是这个程序的输出:
before opening brace
after Tree creation
Tree height is 12
before closing brace
inside Tree destructor
Tree height is 16
after closing brace
您可以看到析构函数在包围它的作用域的右括号处被自动调用。
消除定义块
在 C 中,你必须在一个程序块的开始定义所有的变量,在左括号之后。这在编程语言中并不少见,给出的理由通常是“??”良好的编程风格同时,每次需要一个新的变量时,返回到块的开头似乎不太方便。此外,当变量定义接近其使用点时,代码可读性更好。
也许这些争论是文体上的。然而,在 C++ 中,强制在作用域的开始定义所有对象有一个严重的问题。如果构造器存在,则必须在创建对象时调用它。但是,如果构造器有一个或多个初始化参数,你怎么知道在作用域的开始会有初始化信息呢?在一般的编程情况下,你不会。因为 C 没有private
的概念,所以这种定义和初始化的分离是没有问题的。然而,C++ 保证当一个对象被创建时,它同时被初始化。这可以确保没有未初始化的对象在系统中运行。 C 不在乎;事实上, C 鼓励了这种做法,它要求你在必须拥有初始化信息之前,在一个块的开始定义变量。
一般来说,在获得构造器的初始化信息之前,C++ 不允许创建对象。正因为如此,如果你必须在作用域的开始定义变量,这种语言是不可行的。事实上,这种语言的风格似乎鼓励对一个对象的定义尽可能接近它的使用点。在 C++ 中,任何适用于“对象”的规则都会自动引用内置类型的对象。这意味着任何内置类型的类对象或变量也可以在作用域中的任何点定义。这也意味着您可以等到有了变量的信息后再定义它,这样您就可以同时定义和初始化了。参见清单 6-2 中的示例。
清单 6-2 。在任何地方定义变量
//: C06:DefineInitialize.cpp
// Demonstrates that you can define variables anywhere
#include "../require.h" // To be INCLUDED from Header FILE in *Chapter 3*
#include <iostream>
#include <string>
using namespace std;
class G {
int i;
public:
G(int ii);
};
G::G(int ii) { i = ii; }
int main() {
cout << "initialization value? ";
int retval = 0;
cin >> retval;
require(retval != 0);
int y = retval + 3;
G g(y);
} ///:∼
你可以看到一些代码被执行;然后retval
被定义、初始化,并用于捕获用户输入;然后定义y
和g
。另一方面, C 不允许在除了作用域开头的任何地方定义变量。
一般来说,您应该在尽可能靠近变量使用点的地方定义变量,并且在定义变量时总是初始化它们。
注意这是对内置类型的风格建议,初始化是可选的。
这是一个安全问题。通过减少变量在该范围内可用的时间,您就减少了它在该范围的其他部分被误用的机会。此外,可读性也得到了提高,因为读者不必为了知道变量的类型而来回跳转到范围的开头。
对于循环
在 C++ 中,你经常会看到在for
表达式中定义了一个for
循环计数器,比如:
for(int j = 0; j < 100; j++) {
cout << "j = " << j << endl;
}
for(int i = 0; i < 100; i++)
cout << "i = " << i << endl;
上面的语句是重要的特例,给新 C++ 程序员造成了困惑。
变量i
和j
直接在for
表达式中定义(,这在 C 中是做不到的)。然后它们可用于for
回路。这是一个非常方便的语法,因为上下文消除了关于i
和j
的目的的所有疑问,所以为了清晰起见,你不需要使用像i_loop_counter
这样笨拙的名字。
然而,如果你期望变量i
和j
的生命期超出for
循环的范围,可能会导致一些混乱——它们没有超出。
第三章 指出while
和switch
语句也允许在它们的控制表达式中定义对象,尽管这种用法似乎远不如for
循环重要。
注意隐藏封闭范围内变量的局部变量。通常,对嵌套变量和该作用域的全局变量使用相同的名称会引起混淆,并且容易出错。
较小的望远镜是好设计的标志,至少对我来说是这样。如果你的一个功能有几个页面,也许你想用这个功能做太多的事情。更细粒度的函数不仅更有用,而且也更容易发现 bug。
存储分配
现在,变量可以在作用域中的任何点定义,所以看起来变量的存储可能直到它的定义点才被定义。实际上,编译器更有可能遵循 C 中的惯例,在一个作用域的左括号处为该作用域分配所有存储空间。这无关紧要,因为作为一名程序员,在它被定义之前,你不能访问它。虽然存储是在块的开始分配的,但是构造器调用直到定义对象的序列点才发生,因为标识符直到那时才可用。编译器甚至检查以确保你没有把对象定义(和构造器调用)放在序列点只能有条件地通过它的地方,比如在一个switch
语句中或者一个goto
可以跳过它的地方。取消注释清单 6-3 中的语句会产生一个警告或错误。
清单 6-3 。C++ 中不允许跳过构造器
//: C06:Nojump.cpp
// Demonstrates that you can't jump past constructors in C++
class X {
public:
X();
};
X::X() {}
void f(int i) {
if(i < 10) {
//! goto jump1; // Error: goto bypasses init
}
X x1; // Constructor called here
jump1:
switch(i) {
case 1 :
X x2; // Constructor called here
break;
//! case 2 : // Error: case bypasses init
X x3; // Constructor called here
break;
}
}
int main() {
f(9);
f(11);
}///:∼
在这段代码中,goto
和switch
都有可能跳过调用构造器的序列点。即使构造器没有被调用,这个对象也会在作用域内,所以编译器会给出一个错误消息。这再次保证了一个对象不能被创建,除非它也被初始化。
当然,这里讨论的所有存储分配都发生在堆栈上。编译器通过向下移动堆栈指针来分配存储空间(一个相对术语,它可能表示实际堆栈指针值的增加或减少,这取决于您的计算机)。也可以使用new
在堆上分配对象,这将在第十三章的中进一步探讨。
用构造器和析构函数存放
前几章的例子有明显的映射到构造器和析构函数的函数:initialize()
和cleanup()
。清单 6-4 显示了使用构造器和析构函数的Stash
头。
清单 6-4 。使用构造器和析构函数隐藏头
//: C06:Stash2.h
// Demonstrates Stash header file with constructors & destructors
#ifndef STASH2_H
#define STASH2_H
class Stash {
int size; // Size of each space
int quantity; // Number of storage spaces
int next; // Next empty space
// Dynamically allocated array of bytes:
unsigned char* storage;
void inflate(int increase);
public:
Stash(int size);
∼Stash();
int add(void* element);
void* fetch(int index);
int count();
};
#endif // STASH2_H //
/:∼
唯一改变的成员函数定义是initialize()
和cleanup()
,它们被一个构造器和析构函数所取代(参见清单 6-5 )。
清单 6-5 。用构造器&析构函数实现 Stash
//: C06:Stash2.cpp {O}
// Demonstrates implementation of Stash
// with constructors & destructors
#include "Stash2.h" // To be INCLUDED from Header FILE above
#include "../require.h"
#include <iostream>
#include <cassert>
using namespace std;
const int increment = 100;
Stash::Stash(int sz) {
size = sz;
quantity = 0;
storage = 0;
next = 0;
}
int Stash::add(void* element) {
if(next >= quantity) // Enough space left?
inflate(increment);
// Copy element into storage,
// starting at next empty space:
int startBytes = (next * size);
unsigned char* e = (unsigned char*)element;
for(int i = 0; i < size; i++)
storage[startBytes + i] = e[i];
next++;
return(next - 1); // Index number
}
void* Stash::fetch(int index) {
require(0 <= index, "Stash::fetch (-)index");
if(index >= next)
return 0; // To indicate the end
// Produce pointer to desired element:
return &(storage[index * size]);
}
int Stash::count() {
return next; // Number of elements in CStash
}
void Stash::inflate(int increase) {
require(increase > 0,
"Stash::inflate zero or negative increase");
int newQuantity = (quantity + increase);
int newBytes = (newQuantity * size);
int oldBytes = (quantity * size);
unsigned char* b = new unsigned char[newBytes];
for(int i = 0; i < oldBytes; i++)
b[i] = storage[i]; // Copy old to new
delete [](storage); // Old storage
storage = b; // Point to new memory
quantity = newQuantity;
}
Stash::∼Stash() {
if(storage != 0) {
cout << "freeing storage" << endl;
delete []storage;
}
} ///:∼
你可以看到require.h
函数被用来监视程序员的错误,而不是assert()
。失败的assert()
的输出没有require.h
功能的输出有用。
因为inflate()
是私有的,所以require()
可能失败的唯一原因是其他成员函数之一意外地传递了一个不正确的值给inflate()
。如果您确定这不可能发生,您可以考虑移除require()
,但是您可能要记住,在类稳定之前,总有可能会有新的代码被添加到类中,从而导致错误。require()
的成本很低(并且可以使用预处理器自动移除)并且代码健壮性的价值很高。
注意清单 6-6 中Stash
对象的定义是如何在需要它们之前出现的,以及初始化是如何作为构造器参数列表中定义的一部分出现的。
清单 6-6 。测试存储(带构造器&析构函数)
//: C06:Stash2Test.cpp
//{L} Stash2
// Demonstrates testing of Stash
// (with constructors & destructors)
#include "Stash2.h"
#include "../require.h"
#include <fstream>
#include <iostream>
#include <string>
using namespace std;
int main() {
Stash intStash(sizeof(int));
for(int i = 0; i < 100; i++)
int Stash.add(&i);
for(int j = 0; j < intStash.count(); j++)
cout << "intStash.fetch(" << j << ") = "
<< *(int*) intStash.fetch(j)
<< endl;
const int bufsize = 80;
Stash stringStash(sizeof(char) * bufsize);
ifstream in("Stash2Test.cpp");
assure(in, " Stash2Test.cpp");
string line;
while(getline(in, line))
stringStash.add((char*)line.c_str());
int k = 0;
char* cp;
while((cp = (char*)stringStash.fetch(k++))!=0)
cout << "stringStash.fetch(" << k << ") = "
<< cp << endl;
} ///:∼
还要注意cleanup()
调用是如何被消除的,但是当intStash
和stringStash
超出范围时,析构函数仍然会被自动调用。
在Stash
例子中需要注意的一点是:我非常小心地只使用内置类型;也就是那些没有析构函数的。如果你试图将类对象复制到Stash
中,你会遇到各种各样的问题,而且它不会正常工作。标准 C++ 库实际上可以将对象的正确副本复制到它的容器中,但是这是一个相当混乱和复杂的过程。在下面的Stack
例子中(清单 6-7 ,你会看到指针被用来回避这个问题。
带有构造器和析构函数的堆栈
用构造器和析构函数重新实现链表(在Stack
内部)显示了构造器和析构函数如何灵活地与new
和delete
一起工作。清单 6-7 包含了修改后的头文件。
清单 6-7 。带有构造器/析构函数的堆栈
//: C06:Stack3.h
// Demonstrates the modified header file
#ifndef STACK3_H
#define STACK3_H
class Stack {
struct Link {
void* data;
Link* next;
Link(void* dat, Link* nxt);
∼Link();
}* head;
public:
Stack();
∼Stack();
void push(void* dat);
void* peek();
void* pop();
};
#endif // STACK3_H ///:∼
不仅Stack
有构造器和析构函数,嵌套的struct Link
也有,正如你在清单 6-8 中看到的。
清单 6-8 。用构造器/析构函数实现堆栈
//: C06:Stack3.cpp {O}
// Demonstrates implementation of Stack
// with constructors/destructors
#include "Stack3.h" // To be INCLUDED from Header FILE above
#include "../require.h"
using namespace std;
Stack::Link::Link(void* dat, Link* nxt) {
data = dat;
next = nxt;
}
Stack::Link::∼Link() { }
Stack::Stack() { head = 0; }
void Stack::push(void* dat) {
head = new Link(dat, head);
}
void* Stack::peek() {
require(head != 0, "Stack empty");
return head->data;
}
void* Stack::pop() {
if(head == 0) return 0;
void* result = head->data;
Link* oldHead = head;
head = head->next;
delete oldHead;
return result;
}
Stack::∼Stack() {
require(head == 0, "Stack not empty");
} ///:∼
Link::Link()
构造器简单地初始化了data
和next
指针,所以在Stack::push()
中的行
head = new Link(dat, head);
不仅分配了一个新的链接(使用关键字 new
创建动态对象,在第四章中介绍),而且它还巧妙地初始化了那个链接的指针。
你可能想知道为什么Link
的析构函数不做任何事情——特别是,为什么它不做delete
的data
指针?有两个问题。在第四章的中,引入了Stack
,指出如果一个void
指针指向一个对象,你就不能正确地delete
(断言将在第十三章中被证明)。但是另外,如果Link
析构函数删除了data
指针,pop()
最终会返回一个指向被删除对象的指针,这肯定是一个 bug。这有时被称为所有权的问题:Link
和Stack
只保存指针,但不负责清理它们。这意味着你必须非常小心,你知道谁是负责人。例如,如果你不pop()
和delete
所有Stack
上的指针,它们不会被Stack
的析构函数自动清除。这可能是一个棘手的问题,并导致内存泄漏,所以知道谁负责清理对象可以决定一个成功的程序和一个有错误的程序之间的区别;这就是为什么如果Stack
对象在销毁时不是空的,那么Stack::∼Stack()
会打印一条错误消息。
因为Link
对象的分配和清理隐藏在Stack
中——这是底层实现的一部分——你看不到它在测试程序中发生,尽管你负责删除从pop()
返回的指针。参见清单 6-9 。
清单 6-9 。测试堆栈(带有构造器/析构函数)
//: C06:Stack3Test.cpp
//{L} Stack3
//{T} Stack3Test.cpp
// Demonstrates testing of Stack
// (with constructors/destructors)
#include "Stack3.h"
#include "../require.h"
#include <fstream>
#include <iostream>
#include <string>
using namespace std;
int main(int argc, char* argv[]) {
requireArgs(argc, 1); // File name is argument
ifstream in(argv[1]);
assure(in, argv[1]);
Stack textlines;
string line;
// Read file and store lines in the stack:
while(getline(in, line))
textlines.push(new string(line));
// Pop the lines from the stack and print them:
string* s;
while((s = (string*) textlines.pop()) != 0) {
cout << *s << endl;
delete s;
}
} ///:∼
在这种情况下,textlines
中的所有行都被弹出并删除,但如果没有,您会得到一条require()
消息,这意味着存在内存泄漏。
聚合初始化
一个集合就像它听起来的那样:一堆聚集在一起的东西。该定义包括混合类型的集合,如struct
s 和class
es。数组是单一类型的集合。
初始化聚合可能容易出错且繁琐。在 C++ 中,称为聚合初始化的东西使它更加安全。当你创建一个聚集的对象时,你所要做的就是赋值,初始化将由编译器负责。这种赋值有几种形式,取决于您正在处理的聚合类型,但是在所有情况下,赋值中的元素都必须用花括号括起来。对于内置类型的数组,这非常简单。
int a[5] = { 1, 2, 3, 4, 5 };
如果你试图给出比数组元素更多的初始化器,编译器会给出一个错误信息。但是如果你给更少的初始值,比如:,会发生什么呢
int b[6] = {0};
这里,编译器将对第一个数组元素使用第一个初始化器,然后对所有没有初始化器的元素使用零。注意,如果你定义了一个没有初始化列表的数组,这种初始化行为不会发生。因此,上面的表达式是一种简洁的将数组初始化为零的方法,不需要使用for
循环,也没有任何一个减一错误的可能性。(取决于编译器,它也可能比for
循环更高效。)
数组的第二种简写方式是自动计数,让编译器根据初始化器的数量来确定数组的大小,比如:
int c[] = { 1, 2, 3, 4 };
现在,如果你决定向数组中添加另一个元素,你只需添加另一个初始化器。如果您可以设置您的代码,使其只需要在一个地方进行更改,那么您就减少了修改过程中出错的机会。但是如何确定数组的大小呢?表达式(sizeof () / sizeof (*c))
( 整个数组的大小除以第一个元素的大小)的作用是,如果数组的大小发生变化,它不需要改变,例如:
for(int i = 0; i < (sizeof (c) / sizeof (*c)); i++)
c[i]++;
因为结构也是聚合,所以它们可以用类似的方式初始化。因为一个 C 样式struct
有它的所有成员public
,它们可以被直接赋值,比如:
struct X {
int i;
float f;
char c;
};
X x1 = { 1, 2.2, 'c' };
如果有这样的对象数组,可以通过对每个对象使用一组嵌套的花括号来初始化它们,例如:
X x2[3] = { {1, 1.1, 'a'}, {2, 2.2, 'b'} };
这里,第三个对象被初始化为零。
如果任何一个数据成员是private
( ,这是在 C++ 中设计良好的类的典型情况),或者即使一切都是public
,但是有一个构造器,事情就不同了。在上面的例子中,初始化器被直接分配给集合的元素,但是构造器是一种通过正式接口强制初始化的方式。这里,必须调用构造器来执行初始化。所以如果你有一个看起来像是的struct
struct Y {
float f;
int i;
Y(int a);
};
您必须指示构造器调用。最好的方法是显式的,比如:
Y y1[] = { Y(1), Y(2), Y(3) };
您得到三个对象和三个构造器调用。任何时候你有一个构造器,不管是有所有成员public
的struct
还是有数据成员private
的class
,所有的初始化都必须通过构造器,即使你使用的是聚合初始化。
清单 6-10 显示了第二个显示多个构造器参数的例子。
清单 6-10 。使用多个构造器参数(带聚合初始化)
//: C06:Multiarg.cpp
// Demonstrates use of multiple constructor arguments
// (with aggregate initialization)
#include <iostream>
using namespace std;
class Z {
int i, j;
public:
Z(int ii, int jj);
void print();
};
Z::Z(int ii, int jj) {
i = ii;
j = jj;
}
void Z::print() {
cout << "i = " << i << ", j = " << j << endl;
}
int main() {
Z zz[] = { Z(1,2), Z(3,4), Z(5,6), Z(7,8) };
for(int i = 0; i < (sizeof (zz) / sizeof (*zz)); i++)
zz[i].print();
} ///:∼
注意,看起来像是为数组中的每个对象调用了一个显式的构造器。
默认构造器
默认构造器是一个可以不带参数调用的函数。默认的构造器被用来创建一个“普通对象”,但是当编译器被告知创建一个对象但是没有给出任何细节的时候也很重要。例如,如果你取先前定义的struct Y
并在如下定义中使用它:
Y y2[2] = { Y(1) };
编译器会抱怨找不到默认的构造器。数组中的第二个对象希望创建时没有参数,这就是编译器寻找默认构造器的地方。事实上,如果您简单地定义一组Y
对象,例如:
Y y3[7];
编译器会抱怨,因为它必须有一个默认的构造器来初始化数组中的每个对象。
如果像这样创建一个单独的对象,也会出现同样的问题:
Y y4;
记住,如果你有一个构造器,编译器会确保构造总是发生,不管情况如何。
默认的构造器是如此重要的,以至于如果(只有如果)一个结构(struct
或 class
)没有构造器,编译器会自动为你创建一个。所以清单 6-11 中的代码是有效的。
清单 6-11 。生成自动默认构造器
//: C06:AutoDefaultConstructor.cpp
// Demonstrates automatically-generated default constructor
class V {
int i; // private
}; // No constructor
int main() {
V v, v2[10];
}
///:∼
然而,如果定义了任何构造器,并且没有默认的构造器,那么上面的V
实例将会产生编译时错误。
您可能认为编译器合成的构造器应该进行一些智能初始化,比如将对象的所有内存设置为零。但这并不会— 增加额外的开销,但不在程序员的控制之内。如果你想把内存初始化为零,你必须自己写默认的构造器。
虽然编译器会为你创建一个默认的构造器,但是编译器合成的构造器的行为很少是你想要的。您应该将此功能视为安全网,但要谨慎使用。一般来说,你应该显式定义你的构造器,不要让编译器替你做。
审查会议
- C++ 提供的看似复杂的机制应该给你一个强烈的暗示,告诉你在语言中初始化和清理的重要性。
- C++ 设计者对 CC的生产率的第一个观察是,很大一部分编程问题是由变量的不正确初始化引起的。这种类型的错误很难发现,类似的问题也适用于不适当的清理。
- 因为构造器和析构函数允许您“保证”正确的初始化和清理(编译器不允许在没有正确的构造器和析构函数调用的情况下创建和销毁对象),所以您可以获得完全的控制和安全。
- 聚合初始化也以类似的方式包含在内——它防止你使用内置类型的聚合犯典型的初始化错误,并使你的代码更加简洁。
- 在 C++ 中,编码过程中的安全性是一个大问题。初始化和清理是其中重要的一部分,但是随着这本书的进展,你也会看到其他的安全问题。
七、函数重载和默认参数
任何编程语言的一个重要特征就是方便地使用名字。
当你创建一个对象(一个变量)时,你给一个存储区域命名。功能是动作的名称。通过编造名字来描述手头的系统,你创建了一个更容易被人们理解和改变的程序。这很像写散文——目标是与你的读者交流。
当将人类语言中的细微差别的概念映射到编程语言时,出现了一个问题。通常,同一个词根据上下文表达不同的意思。也就是说,一个单词有多重含义——它“超载了”这是非常有用的,尤其是在涉及到细微差别的时候。你说“洗衬衫,洗车。“如果被迫说,“衬衫 _ 洗衬衫,汽车 _ 洗车”那就太傻了,这样听者就不必对所做的动作进行任何区分。人类的语言有内在的冗余,所以即使你漏掉了几个单词,你仍然可以确定意思。我们不需要唯一的标识符;我们可以从上下文推断出意思。
然而,大多数编程语言要求每个函数都有一个唯一的标识符。如果你想打印三种不同类型的数据:int
、char
和float
,你通常需要创建三个不同的函数名,例如:print_int()
、print_char()
和print_float()
。当你写程序时,这会给你带来额外的工作,当读者试图理解它时,也会给他们带来额外的工作。
在 C++ 中,另一个因素迫使函数名重载:构造器。因为构造器的名字是由类名预先确定的,所以似乎只能有一个构造器。但是如果你想用多种方式创建一个对象呢?例如,假设您构建了一个类,它可以用标准方式初始化自己,也可以通过从文件中读取信息来初始化自己。您需要两个构造器,一个不带参数(默认构造器),另一个带参数string
,这是初始化对象的文件名。两者都是构造器,所以它们必须有相同的名字:类名。因此,函数重载对于允许相同的函数名(在本例中是构造器)用于不同的参数类型是必不可少的。
尽管函数重载对于构造器来说是必须的,但它是一种普遍的便利,可以用于任何函数,而不仅仅是类成员函数。此外,函数重载意味着如果你有两个包含同名函数的库,只要参数列表不同,它们就不会冲突。在这一章中,你会详细地看到所有这些因素。
本章的主题是函数名的方便使用。函数重载允许你为不同的函数使用相同的名字,但是还有第二种方法使调用函数更方便。如果你想以不同的方式调用同一个函数呢?当函数有很长的参数列表时,如果所有调用的大多数参数都是相同的,那么编写函数调用会变得很乏味(读起来也很混乱)。C++ 中一个常用的特性叫做默认参数。默认参数是编译器在函数调用中没有指定的情况下插入的参数。因此,调用f("hello")
、f("hi", 1)
和f("howdy", 2, 'c')
都可以是对同一个函数的调用。它们也可能是对三个重载函数的调用,但是当参数列表如此相似时,您通常会想要相似的行为,即调用单个函数。
函数重载和默认参数其实并不复杂。当你读到本章末尾的时候,你将理解何时使用它们,以及在编译和链接过程中实现它们的底层机制。
更多名称装饰
在第四章中,引入了 名称修饰的概念。在代码中
void f();
class X { void f(); };
class X
范围内的功能f()
与f()
的全局版本不冲突。编译器通过为f()
和X::f()
的全局版本制造不同的内部名称来执行这个范围。在第四章的中,有人建议名字只是类名和函数名的“修饰”,所以编译器使用的内部名字可能是_f
和_X_f
。然而,事实证明函数名修饰涉及的不仅仅是类名。
原因如下。假设您想重载两个函数名,
void print(char);
void print(float);
无论它们是在一个类中还是在全局范围中,都没有关系。如果编译器只使用函数名的作用域,它就不能生成唯一的内部标识符。在这两种情况下,你都会以_print
结束。重载函数的思想是使用相同的函数名,但使用不同的参数列表。因此,为了使重载工作,编译器必须用参数类型的名称来修饰函数名。上面的函数定义在全局范围内,产生的内部名字可能看起来像_print_char
和_print_float
。值得注意的是,编译器修饰名字的方式没有标准,所以你会看到不同编译器的结果非常不同。
注意你可以通过告诉编译器生成汇编语言输出来看看它是什么样子。
当然,如果您想为特定的编译器和链接器购买编译过的库,这会带来问题——但是即使名称修饰是标准化的,也会有其他的障碍,因为不同的编译器生成代码的方式不同。
这里是一个关于汇编语言代码片段的例子:
………………………………………………………………………………………………………………………………………………………………………
IF LCODE ; if large code model
Extrn _func1:far ; then far function
ELSE
Extrn _func1:near ; else near function
ENDIF
………………………………………………………………………………………………………………………………………………………………………
……………………………………………………………………………………………………………………………………………………………………………………………
Begcode func2 ; begin code for func2
Public func2 ; make func2 global
IF LCODE ; if large code model
_func2 proc far ; then define func2 function
ELSE
_func2 proc near ; else define func2 function
ENDIF
……………………………………………………………………………………………………………………………………………………………………………………………
这就是函数重载的全部内容:只要参数列表不同,不同的函数可以使用相同的函数名。编译器修饰名称、范围和参数列表,以产生供它和链接器使用的内部名称。
返回值重载
很常见的问题是,"为什么只有作用域和参数列表?为什么不返回值?“乍一看,用内部函数名来修饰返回值似乎是有意义的。然后你也可以重载返回值,就像这样:
void f();
int f();
当编译器可以从上下文中明确地确定含义时,这很好,就像在int x = f();
中一样。然而,在 C 中你总是可以调用一个函数并忽略返回值(也就是说,你可以调用它的副作用)。在这种情况下,编译器如何区分哪个调用是有意义的?可能更糟的是,读者很难知道哪个函数调用是什么意思。只对返回值重载有点太微妙了,因此在 C++ 中是不允许的。
类型安全链接
这种名称装饰还有一个额外的好处。当客户端程序员错误地声明一个函数,或者更糟的是,一个函数没有先声明就被调用,编译器从调用的方式推断出函数声明时,C 中就会出现一个特别棘手的问题。有时这个函数声明是正确的,但是当它不正确时,它可能是一个很难发现的 bug。
因为所有函数在 C++ 中使用之前都必须声明,所以这个问题出现的机会大大减少了。C++ 编译器拒绝为您自动声明一个函数,所以您可能会包含适当的头文件。然而,如果出于某种原因,您仍然设法错误地声明了一个函数,无论是通过手工声明还是包含错误的头文件(可能是一个过时的头文件),名称修饰提供了一个安全网,通常被称为类型安全链接。
考虑下面的场景。一个文件中有一个函数的定义。
//: C07:Def.cpp {O}
// Function definition
void f(int) {}
///:∼
在第二个文件中,函数被错误地声明,然后被调用。
//: C07:Use.cpp
//{L} Def
// WRONG Function declaration
void f(char);
int main() {
//! f(1); // Causes a linker error
} ///:∼
尽管您可以看到该函数实际上是f(int)
,但编译器并不知道这一点,因为它被告知(通过显式声明)该函数是f(char)
。因此,编译是成功的。在 C 中,链接器也会成功,但是在 C++ 中不会。因为编译器修饰了名字,定义变成了类似于f_int
的东西,而函数的使用是f_char
。当链接器试图解析对f_char
的引用时,它只能找到f_int
,并给出一个错误消息。这是类型安全的链接。尽管问题并不经常出现,但一旦出现,就很难发现,尤其是在大型项目中。在这种情况下,只要通过 C++ 编译器运行一个 C 程序,就可以很容易地找到一个棘手的错误。
过载示例
让我们修改前面的例子来使用函数重载。如前所述,重载的一个直接有用的地方是在构造器中。你可以在清单 7-1 中的类的版本中看到这一点。
清单 7-1 。函数重载
//: C07:Stash3.h
// Function overloading
#ifndef STASH3_H
#define STASH3_H
class Stash {
int size; // Size of each space
int quantity; // Number of storage spaces
int next; // Next empty space
// Dynamically allocated array of bytes:
unsigned char* storage;
void inflate(int increase);
public:
Stash(int size); // Zero quantity
Stash(int size, int initQuantity);
∼Stash();
int add(void* element);
void* fetch(int index);
int count();
};
#endif // STASH3_H ///:∼
第一个Stash()
构造器和之前的一样,但是第二个构造器有一个Quantity
参数来指示要分配的存储位置的初始数量。在定义中,你可以看到quantity
的内部值被设置为零,还有storage
指针。在第二个构造器中,对inflate(initQuantity)
的调用将quantity
增加到分配的大小(参见清单 7-2 )。
清单 7-2 。更多函数重载
//: C07:Stash3.cpp {O}
// Function overloading
#include "Stash3.h" // To be INCLUDED from Header FILE above
#include "../require.h" // To be INCLUDED from Header FILE in *Chapter 3*
#include <iostream>
#include <cassert>
using namespace std;
const int increment = 100;
Stash::Stash(int sz) {
size = sz;
quantity = 0;
next = 0;
storage = 0;
}
Stash::Stash(int sz, int initQuantity) {
size = sz;
quantity = 0;
next = 0;
storage = 0;
inflate(initQuantity);
}
Stash::∼Stash() {
if(storage != 0) {
cout << "freeing storage" << endl;
delete []storage;
}
}
int Stash::add(void* element) {
if(next >= quantity) // Enough space left?
inflate(increment);
// Copy element into storage,
// starting at next empty space:
int startBytes = next * size;
unsigned char* e = (unsigned char*)element;
for(int i = 0; i < size; i++)
storage[startBytes + i] = e[i];
next++;
return(next - 1); // Index number
}
void* Stash::fetch(int index) {
require(0 <= index, "Stash::fetch (-)index");
if(index >= next)
return 0; // To indicate the end
// Produce pointer to desired element:
return &(storage[index * size]);
}
int Stash::count() {
return next;
// Number of elements in CStash
}
void Stash::inflate(int increase) {
assert(increase >= 0);
if(increase == 0) return;
int newQuantity = quantity + increase;
int newBytes = newQuantity * size;
int oldBytes = quantity * size;
unsigned char* b = new unsigned char[newBytes];
for(int i = 0; i < oldBytes; i++)
b[i] = storage[i];
// Copy old to new
delete [](storage);
// Release old storage
storage = b; // Point to new memory
quantity = newQuantity; // Adjust the size
} ///:∼
当您使用第一个构造器时,不会为storage
分配内存。分配发生在你第一次尝试add()
一个对象的时候,以及在add()
内部超过当前内存块的任何时候。
两个构造器都在清单 7-3 中的测试程序中进行了测试。
清单 7-3 。测试程序
//: C07:Stash3Test.cpp
//{L} Stash3
// Function overloading
#include "Stash3.h"
#include "../require.h"
#include <fstream>
#include <iostream>
#include <string>
using namespace std;
int main() {
Stash intStash(sizeof(int));
for(int i = 0; i < 100; i++)
intStash.add(&i);
for(int j = 0; j < intStash.count(); j++)
cout << "intStash.fetch(" << j << ") = "
<< *(int*)intStash.fetch(j)
<< endl;
const int bufsize = 80;
Stash stringStash(sizeof(char) * bufsize, 100);
ifstream in("Stash3Test.cpp");
assure(in, "Stash3Test.cpp");
string line;
while(getline(in, line))
stringStash.add((char*)line.c_str());
int k = 0;
char* cp;
while((cp = (char*)stringStash.fetch(k++))!=0)
cout << "stringStash.fetch(" << k << ") = "
<< cp << endl;
} ///:∼
对stringStash
的构造器调用使用了第二个参数;大概你知道一些关于你正在解决的特定问题的特别之处,这允许你为Stash
选择一个初始大小。
联盟
如你所见,C++ 中struct
和class
的唯一区别是struct
默认为public
,class
默认为private
。如你所料,struct
也可以有构造器和析构函数。但事实证明,union
也可以有构造器、析构函数、成员函数,甚至访问控制。你可以在清单 7-4 中再次看到重载的用途和好处。
清单 7-4 。联合
//: C07:UnionClass.cpp
// Unions with constructors and member functions
#include <iostream>
using namespace std;
union U {
private: // Access control too!
int i;
float f;
public:
U(int a);
U(float b);
∼U();
int read_int();
float read_float();
};
U::U(int a) { i = a; }
U::U(float b) { f = b;}
U::∼U() { cout << "U::∼U()\n"; }
int U::read_int() { return i; }
float U::read_float() { return f; }
int main() {
U X(12), Y(1.9F);
cout << X.read_int() << endl;
cout << Y.read_float() << endl;
} ///:∼
从清单 7-4 中的代码你可能会认为union
和class
之间唯一的区别是数据存储的方式(也就是说, int
和float
覆盖在同一块存储器)。然而,union
在继承过程中不能用作基类,从面向对象设计的角度来看,这是非常有限的。
注你会在第十四章中了解到关于继承的知识。
尽管成员函数在某种程度上规范了对union
的访问,但是一旦union
被初始化,仍然没有办法防止客户端程序员选择错误的元素类型。在清单 7-4 中,你可以说X.read_float()
,尽管这并不恰当。然而,一个“安全的”union
可以被封装在一个类中。在清单 7-5 中,注意enum
是如何阐明代码的,重载是如何在构造器中派上用场的。
清单 7-5 。安全的结合
//: C07:SuperVar.cpp
// A super-variable
#include <iostream>
using namespace std;
class SuperVar {
enum {
character,
integer,
floating_point
} vartype; // Define one
union { // Anonymous union
char c;
int i;
float f;
};
public:
SuperVar(char ch);
SuperVar(int ii);
SuperVar(float ff);
void print();
};
SuperVar::SuperVar(char ch) {
vartype = character;
c = ch;
}
SuperVar::SuperVar(int ii) {
vartype = integer;
i = ii;
}
SuperVar::SuperVar(float ff) {
vartype = floating_point;
f = ff;
}
voidSuperVar::print() {
switch (vartype) {
case character:
cout << "character: " << c << endl;
break;
case integer:
cout << "integer: " << i << endl;
break;
case floating_point:
cout << "float: " << f << endl;
break;
}
}
int main() {
SuperVarA('c'), B(12), C(1.44F);
A.print();
B.print();
C.print();
} ///:∼
在清单 7-5 中,enum
没有类型名(它是一个未标记的枚举)。如果您打算立即定义enum
的实例,这是可以接受的,就像这里所做的那样。以后不需要引用enum’s
类型名,所以类型名是可选的。
union
没有类型名和变量名。这被称为匿名联合,它为union
创建了空间,但不需要使用变量名和点运算符访问union
元素。例如,匿名union
的一个例子是
//: C07:AnonymousUnion.cpp
int main() {
union {
int i;
float f;
};
// Access members without using qualifiers:
i = 12;
f = 1.22;
} ///:∼
请注意,您可以像访问普通变量一样访问匿名联合的成员。唯一的区别是两个变量占据相同的空间。如果匿名union
在文件范围内(在所有函数和类之外),那么它必须被声明为static
,这样它就有了内部链接。
虽然SuperVar
现在是安全的,但它的有用性有点可疑;首先使用一个union
的原因是为了节省空间,相对于union
中的数据而言,vartype
的添加占用了相当多的空间,因此节省的空间被有效地消除了。有几个备选方案可以使这个方案可行。如果vartype
控制不止一个union
实例——如果它们都是同一类型——那么你只需要一个用于这个组,它不会占用更多的空间。一个更有用的方法是在所有的vartype
代码周围放上#ifdef
,这样可以保证在开发和测试过程中正确使用。对于运输代码,可以消除额外的空间和时间开销。
默认参数
在Stash3.h
( 清单 7-1 )中,检查Stash()
的两个构造器。他们看起来没什么不同,不是吗?事实上,第一个构造器似乎是第二个的特例,初始的size
被设置为零。创建和维护一个相似功能的两个不同版本有点浪费精力。
C++ 提供了一个补救方法,用默认参数来。默认参数是在声明中给定的值,如果在函数调用中没有提供值,编译器会自动插入该值。在Stash
的例子中,你可以替换这两个函数
Stash(int size); // Zero quantity
Stash(int size, int initQuantity);
使用单一功能
Stash(int size, int initQuantity = 0);
简单地删除了Stash(int)
定义——只需要一个Stash(int, int)
定义。
现在,两个对象定义
Stash A(100), B(100, 0);
会产生完全相同的结果。两种情况下调用的是相同的构造器,但是对于A
,当编译器发现第一个参数是int
并且没有第二个参数时,它会自动替换第二个参数。编译器已经看到了默认的参数,所以它知道如果它替换了第二个参数,它仍然可以调用函数,这就是你让它成为默认参数的目的。
默认参数很方便,因为函数重载也很方便。这两个特性都允许您在不同的情况下使用单个函数名。不同之处在于,使用缺省参数时,当你不想把它们放入自己的参数中时,编译器会替换它们。前面的示例是使用默认参数而不是函数重载的好地方;否则你会得到两个或更多具有相似特征和相似行为的函数。如果函数有非常不同的行为,使用默认参数通常没有意义(就此而言,您可能想问两个行为非常不同的函数是否应该有相同的名称)。
使用默认参数时,有两条规则你必须知道。首先,只能默认尾随参数。也就是说,不能有一个默认参数后跟一个非默认参数。其次,一旦你在一个特定的函数调用中开始使用缺省参数,那么该函数的参数列表中所有后续的参数都必须是缺省的(这遵循第一条规则)。
默认参数只放在函数声明中(通常放在头文件中)。编译器必须先看到默认值,然后才能使用它。有时,出于文档的目的,人们会将默认参数的注释值放在函数定义中,例如:
Void fn(int x /* = 0 */) { // ...
占位符参数
函数声明中的参数可以在没有标识符的情况下声明。当这些与默认参数一起使用时,看起来可能有点滑稽。你可以用结束
void f(int x, int = 0, float = 1.1);
在 C++ 中,函数定义中也不需要标识符。
void f(int x, int, float flt) { /* ... */ }
在函数体中,可以引用x
和flt
,但不能引用中间的参数,因为它没有名字。尽管如此,函数调用仍然必须为占位符提供一个值:f(1)
或f(1,2,3.0)
。该语法允许您将参数作为占位符放入,而不使用它。其思想是,您可能希望稍后更改函数定义以使用占位符,而不更改调用该函数的所有代码。当然,您可以通过使用命名参数来完成同样的事情,但是如果您为函数体定义了参数而没有使用它,大多数编译器会给您一个警告消息,假设您犯了一个逻辑错误。通过有意省略参数名称,可以隐藏此警告。
更重要的是,如果您开始使用一个函数参数,后来决定不再需要它,您可以有效地删除它,而不会生成警告,并且不会干扰任何调用该函数以前版本的客户端代码。
选择重载还是默认参数
函数重载和默认参数都为调用函数名提供了便利。然而,知道使用哪种技术有时会令人困惑。例如,考虑下面这个为你自动管理内存块而设计的工具(清单 7-6 )。
清单 7-6 。管理内存块(头文件)
//: C07:Mem.h
#ifndef MEM_H
#define MEM_H
typedef unsigned char byte;
classMem {
byte* mem;
int size;
void ensureMinSize(int minSize);
public:
Mem();
Mem(int sz);
∼Mem();
int msize();
byte* pointer();
byte* pointer(int minSize);
};
#endif // MEM_H ///:∼
一个Mem
对象持有一个byte
块,并确保你有足够的存储空间。默认构造器不分配任何存储,第二个构造器确保在Mem
对象中有sz
存储。析构函数释放存储,msize()
告诉你Mem
对象中当前有多少字节,pointer()
产生一个指向存储起始地址的指针(Mem
是一个相当低级的工具)。有一个pointer()
的重载版本,其中客户端程序员可以说他们想要一个指向至少minSize
大的字节块的指针,成员函数确保了这一点。
构造器和pointer()
成员函数都使用private ensureMinSize()
成员函数来增加内存块的大小(注意,如果调整了内存大小,保存pointer()
的结果是不安全的)。
清单 7-7 显示了这个类的实现。
清单 7-7 。管理内存块(源代码对象 cpp 文件)
//: C07:Mem.cpp {O}
#include "Mem.h" // To be INCLUDED from Header FILE above
#include <cstring>
using namespace std;
Mem::Mem() { mem = 0; size = 0; }
Mem::Mem(int sz) {
mem = 0;
size = 0;
ensureMinSize(sz);
}
Mem::∼Mem() { delete []mem; }
int Mem::msize() { return size; }
void Mem::ensureMinSize(int minSize) {
if(size < minSize) {
byte* newmem = new byte[minSize];
memset(newmem + size, 0, minSize - size);
memcpy(newmem, mem, size);
delete []mem;
mem = newmem;
size = minSize;
}
}
byte* Mem::pointer() { return mem; }
byte* Mem::pointer(int minSize) {
ensureMinSize(minSize);
return mem;
} ///:∼
您可以看到,ensureMinSize()
是唯一负责分配内存的函数,,它是从第二个构造器和第二个重载形式的pointer()
中使用的。在 ensureMinSize()
内部,如果size
足够大,什么都不需要做。如果必须分配新的存储空间以使块更大(默认构造后块的大小为零也是这种情况),则使用标准 C 库函数memset()
将新的“额外”部分设置为零,该函数在第五章 中介绍。随后的函数调用是对标准 C 库函数memcpy()
的调用,在这种情况下,它将现有字节从mem
复制到newmem
(通常以高效的方式)。最后,旧的内存被删除,新的内存和大小被分配给适当的成员。
Mem
类被设计用作其他类中的工具,以简化它们的内存管理(它也可以用来隐藏更复杂的内存管理系统,例如由操作系统提供的)。通过创建一个简单的“字符串”类,在清单 7-8 中对其进行了适当的测试。
清单 7-8 。测试 Mem 类
//: C07:MemTest.cpp
// Testing the Mem class
//{L} Mem
#include "Mem.h"
#include <cstring>
#include <iostream>
using namespace std;
classMyString {
Mem* buf;
public:
MyString();
MyString(char* str);
∼MyString();
void concat(char* str);
void print(ostream &os);
};
MyString::MyString() { buf = 0; }
MyString::MyString(char* str) {
buf = new Mem(strlen(str) + 1);
strcpy((char*)buf->pointer(), str);
}
void MyString::concat(char* str) {
if(!buf) buf = new Mem;
strcat((char*)buf->pointer(
buf->msize() + strlen(str) + 1), str);
}
void MyString::print(ostream &os) {
if(!buf) return;
os << buf->pointer() << endl;
}
MyString::∼MyString() { delete buf; }
int main() {
MyStrings("My test string");
s.print(cout);
s.concat(" some additional stuff");
s.print(cout);
MyString s2;
s2.concat("Using default constructor");
s2.print(cout);
} ///:∼
这个类所能做的就是创建一个MyString
,连接文本,并打印到一个ostream
。该类只包含一个指向Mem
、的指针,但是请注意默认构造器和第二个构造器之间的区别,前者将指针设置为零,后者创建一个Mem
并将数据复制到其中。默认构造器的优点是,你可以很便宜地创建一个空的MyString
对象的大数组,因为每个对象的大小只有一个指针,默认构造器唯一的开销就是赋值给零。当你连接数据时,MyString
的成本才开始增加;此时,Mem
对象被创建,如果它还没有被创建的话。然而,如果您使用默认的构造器并且从不连接任何数据,那么析构函数调用仍然是安全的,因为调用零的delete
被定义为它不会试图释放存储空间或者导致问题。
如果你看看这两个构造器,乍一看,它似乎是默认参数的首选。但是,如果您删除默认构造器并使用默认参数编写剩余的构造器,如
MyString(char* str = "");
一切都会正常工作,但是您会失去之前的效率优势,因为总是会创建一个Mem
对象。要恢复效率,您必须以这种方式修改构造器:
MyString::MyString(char* str) {
if(!*str) { // Pointing at an empty string
buf = 0;
return;
}
buf = new Mem(strlen(str) + 1);
strcpy((char*)buf->pointer(), str);
}
这实际上意味着,与使用非默认值的情况相比,默认值成为一个标志,导致执行一段单独的代码。虽然对于这样的小构造器来说,这似乎是无害的,但通常这种做法会带来问题。
如果您不得不寻找默认值,而不是将其视为普通值,那么这应该是一个线索,您将在一个函数体中有效地结束两个不同的函数:一个版本用于正常情况,一个版本用于默认值。你也可以把它分成两个不同的函数体,让编译器来选择。
这导致了效率的轻微(但通常看不见的)提高,因为额外的参数没有被传递,条件的额外代码没有被执行。更重要的是,你为两个独立的函数保存代码两个独立的函数,而不是使用默认参数将它们合并成一个,这将导致更容易维护,尤其是如果函数很大的话。
另一方面,考虑一下Mem
类。如果您查看两个构造器和两个pointer()
函数的定义,您会发现在这两种情况下使用默认参数根本不会导致成员函数定义发生变化。因此,该类很容易如下所示:
清单 7-9 。管理内存块(修改后的头文件)
//: C07:Mem2.h
#ifndef MEM2_H
#define MEM2_H
typedef unsigned char byte;
class Mem {
byte* mem;
int size;
void ensureMinSize(int minSize);
public:
Mem(int sz = 0);
∼Mem();
int msize();
byte* pointer(int minSize = 0);
};
#endif // MEM2_H ///:∼
注意,调用ensureMinSize(0)
总是非常有效。
尽管在这两种情况下,一些决策过程是基于效率问题的,但是您必须小心不要陷入只考虑效率的陷阱(迷人,因为它是!).
类设计中最重要的问题是类的接口(它的public
成员,对客户端程序员可用)。如果这些产生了一个易于使用和重用的类,那么你就成功了;如果有必要的话,你总是可以调整效率,但是一个设计糟糕的类的影响可能是可怕的,因为程序员过于关注效率问题。
您主要关心的应该是这个接口对使用它和阅读结果代码的人有意义。注意,在MemTest.cpp
中,不管是否使用默认构造器,也不管效率是高还是低,MyString
的用法都不会改变。
审查会议
- 作为一个指导原则,您不应该使用默认参数作为有条件地执行代码的标志。如果可以的话,你应该将函数分解成两个或更多的重载函数。
- 默认参数应该是您通常放在那个位置的值。这是一个比所有其他值更有可能出现的值,所以客户端程序员通常可以忽略它,或者只在他们想改变默认值时才使用它。
- 包含默认参数是为了使函数调用更容易,尤其是当那些函数有许多带有典型值的参数时。不仅编写调用更容易,阅读它们也更容易,特别是如果类创建者可以对参数排序,使得修改最少的默认值出现在列表的最后。
- 默认参数的一个特别重要的用途是,当你开始使用一个有一组参数的函数时,在使用了一段时间后,你发现你需要添加参数。通过默认所有的新参数,你确保所有使用先前接口的客户端代码不会被干扰。
八、常量
创建常量(由 const 关键字表示)的概念是为了让程序员在变化和不变化之间画一条线。这在 C++ 编程项目中提供了安全性和控制。
自从诞生以来,const
已经有了许多不同的用途。与此同时,它又回到了 C 语言中,在那里它的含义发生了变化。所有这些起初看起来有点混乱,在这一章中你将学习何时、为何以及如何使用const
关键字。最后有一个关于volatile
的讨论,它是const
的近亲(因为它们都涉及变化),并且有相同的语法。
使用const
的第一个动机似乎是为了避免使用预处理器#define
进行值替换。此后,它被用于指针、函数参数、返回类型、类对象和成员函数。所有这些都有稍微不同但概念上兼容的含义,将在本章的单独章节中讨论。
值替换
当用 C 语言编程时,预处理器被自由地用于创建宏和替换值。因为预处理器只是简单地进行文本替换,没有类型检查的概念和工具,预处理器值替换引入了一些微妙的问题,这些问题在 C++ 中可以通过使用const
值来避免。
在 C # 中,用值替换名称的预处理器的典型用法如下:
#define BUFSIZE 100
BUFSIZE
是一个只在预处理过程中存在的名字,所以它不占用存储空间,可以放在头文件中,为所有使用它的翻译单元提供一个值。对于代码维护来说,使用值替换而不是所谓的“幻数”非常重要如果您在代码中使用神奇的数字,不仅读者不知道这些数字来自哪里或它们代表什么,而且如果您决定更改一个值,您必须执行手动编辑,并且您没有任何线索可循,以确保您不会错过某个值(或意外更改了一个您不应该更改的值)。
大多数时候,BUFSIZE
会表现得像一个普通变量,但不是所有时候。此外,没有类型信息。这可以隐藏很难发现的错误。C++ 使用const
通过将值替换引入编译器的领域来消除这些问题。现在你可以说
const int bufsize = 100;
你可以在编译器在编译时必须知道值的任何地方使用bufsize
。编译器可以使用bufsize
来执行常量折叠,这意味着编译器将通过在编译时执行必要的计算,将复杂的常量表达式简化为简单的表达式。这在数组定义中尤其重要,比如
char buf[bufsize];
您可以将const
用于所有内置类型(char
、int
、float
和double
)及其变体(以及类对象,您将在本章后面看到)。由于预处理器可能引入的细微错误,您应该总是使用const
而不是#define
值替换。
头文件中的常量
要使用const
而不是#define
,您必须能够将const
定义放在头文件中,就像使用#define
一样。这样,您可以将const
的定义放在一个地方,并通过包含头文件将其分发给翻译单元。C++ 中的 A const
默认为“内部联动;也就是说,它只在定义它的文件中可见,在链接时不能被其他翻译单元看到。在定义const
和时,必须始终为其赋值,除非使用extern
进行显式声明,例如:
extern const int bufsize;
通常,C++ 编译器避免为const
创建存储,而是将定义保存在其符号表中。然而,当您将extern
与const
一起使用时,您会强制分配存储空间(对于某些其他情况也是如此,比如获取const
的地址)。必须分配存储,因为extern
说“使用外部链接”,这意味着几个翻译单元必须能够引用该项目,这要求它有存储。
在一般情况下,当extern
不是定义的一部分时,不分配存储。当使用const
时,它只是在编译时被折叠起来。
从不为一个const
分配存储的目标对于复杂的结构也是失败的。每当编译器必须分配存储空间时,就要防止常量合并(因为编译器没有办法确切知道存储空间的值是多少;如果它知道这一点,它就不需要分配存储)。
因为编译器不能总是避免为const
、const
定义分配存储空间,所以必须默认为内部链接,也就是说,链接只在特定翻译单元内进行。否则,复杂的const
会出现链接器错误,因为它们会导致存储被分配到多个cpp
文件中。然后链接器会在多个目标文件中看到相同的定义,并抱怨。因为一个const
默认为内部链接,所以链接器不会试图跨翻译单元链接那些定义,也没有冲突。对于内置类型(在大多数涉及常量表达式的情况下使用),编译器总是可以执行常量合并。
安全常量
const
的使用不限于在常量表达式中替换#define
s。如果你用一个在运行时产生的值初始化一个变量,并且你知道这个值在这个变量的生命周期内不会改变,那么把它设为一个const
是一个很好的编程习惯,这样如果你不小心试图改变它,编译器会给你一个错误信息。参见清单 8-1 中的示例。
清单 8-1 。使用 const 确保安全
//: C08:Safecons.cpp
// Using const for safety
#include <iostream>
using namespace std;
const int i = 100;
const int j = i + 10;
long address = (long)&j; // Forces storage
char buf[j + 10]; // Still a const expression
int main() {
cout << "type a character & CR:";
const char c = cin.get(); // Can't change
const char c2 = c + 'a';
cout << c2;
// ...
} ///:∼
你可以看到i
是一个编译时的const
,但是j
是从i
开始计算的。然而,因为i
是一个const
,所以j
的计算值仍然来自一个常量表达式,它本身就是一个编译时常量。下一行需要j
的地址,因此迫使编译器为j
分配存储空间。然而这并不妨碍使用j
来确定buf
的大小,因为编译器知道j
是const
并且该值是有效的,即使在程序中的某个点分配了存储来保存该值。
在main( )
中,您会在标识符c
中看到不同种类的const
,因为在编译时无法知道其值。这意味着需要存储,并且编译器不会试图在它的符号表中保存任何东西(与 C 中的行为相同)。初始化必须仍然发生在定义点,一旦初始化发生,值就不能改变。你可以看到c2
是从c
中计算出来的,而且作用域对于const
s 和其他类型一样有效——这是对使用#define
的又一个改进。
实际上,如果你认为一个值不应该改变,你应该把它变成一个const
。这不仅提供了防止意外更改的保障,还允许编译器通过消除存储和内存读取来生成更高效的代码。
总计
可以使用const
进行聚合,但是实际上可以肯定,编译器不会复杂到在其符号表中保存一个聚合,所以存储将被分配。在这些情况下,const
意味着一块不能改变的存储器。“然而,该值不能在编译时使用,因为编译器在编译时不需要知道存储器的内容。在清单 8-2 中,您可以看到非法的语句。
清单 8-2 。常量和集合
//: C08:Constag.cpp
// Constants and aggregates
const int i[] = { 1, 2, 3, 4 };
//! float f[i[3]]; // Illegal
struct S { int i, j; };
const S s[] = { { 1, 2 }, { 3, 4 } };
//! double d[s[1].j]; // Illegal
int main() {} ///:∼
在数组定义中,编译器必须能够生成移动堆栈指针以容纳数组的代码。在清单 8-2 中的两个非法定义中,编译器报错,因为它在数组定义中找不到常量表达式。
与 C 的区别
常量是在早期版本的 C++ 中引入的,而标准的 C 规范仍在完善中。尽管 C 委员会随后决定将const
包含在 C 中,但不知何故,它对他们来说意味着“一个不能改变的普通变量。“在 C 中,a const
总是占用存储,它的名字是 global。C 编译器不能将const
视为编译时常量。在 C 中,如果你说
const int bufsize = 100;
char buf[bufsize];
您将得到一个错误,尽管这看起来是一件合理的事情。因为bufsize
占用了某个地方的存储,所以 C 编译器在编译时无法知道这个值。你可以选择说
const int bufsize;
在 C 中是这样,但在 C++ 中不是,C 编译器将它作为一个声明接受,表明在其他地方分配了存储。因为 C 默认为const
s 的外部联动,这是有意义的。对于const
s,C++ 默认为内部链接,因此如果您想在 C++ 中完成同样的事情,您必须使用extern
显式地将链接更改为外部,例如:
extern const int bufsize; // Declaration only
这一行也适用于 c 语言。
在 C++ 中,const
不一定会创建存储。在 C 中,a const
总是创建存储。在 C++ 中,存储是否被保留给一个const
取决于它是如何被使用的。一般来说,如果一个const
只是用来用一个值替换一个名字(就像你使用一个 #define
),那么就不需要为const
创建存储。如果没有创建存储(这取决于数据类型的复杂性和编译器的复杂程度),值可以在类型检查后合并到代码中以获得更高的效率,而不是像#define
那样在之前。但是,如果您获取了一个const
( ,甚至是在不知情的情况下,通过将它传递给一个采用引用参数的函数,或者您将它定义为extern
,那么就会为const
创建存储。
在 C++ 中,在所有函数之外的const
具有文件范围(即,它在文件之外是不可见的)。即默认为内部联动。这与 C++ 中的所有其他标识符(和 C 中的 const
有很大的不同!)默认为外部链接。因此,如果你在两个不同的文件中声明了一个同名的const
,并且你没有获取地址或者将那个名字定义为extern
,那么理想的 C++ 编译器不会为const
分配存储空间,而是简单地将它合并到代码中。因为const
有隐含的文件作用域,所以你可以把它放在 C++ 头文件中,在链接时不会有冲突。
由于 C++ 中的一个const
默认为内部链接,所以你不能只在一个文件中定义一个const
,而在另一个文件中将其引用为一个extern
。要给一个const
外部链接,以便它可以从另一个文件中被引用,您必须显式地将其定义为extern
,就像这样:
extern const int x = 1;
注意,通过给它一个初始化器,并说它是extern
,你强制为const
( ,尽管编译器仍然可以选择在这里进行常量折叠)创建存储。初始化将它建立为一个定义,而不是声明。宣言
extern const int x;
在 C++ 中意味着定义存在于别处(再次强调,这在 C 中不一定成立)。现在你可以明白为什么 C++ 需要一个const
定义来拥有一个初始化式:初始化式区分了声明和定义(在 C 中它总是一个定义,所以不需要初始化式)。使用extern const
声明,编译器不能进行常量折叠,因为它不知道值。
C 语言对const
的处理不是很有用,如果你想在一个常量表达式中使用一个已命名的值(必须在编译期计算),C 语言几乎强迫你在预处理器中使用#define
。
两颗北极指极星
指针可以做成const
。在处理const
指针时,编译器仍然会努力阻止存储分配并进行常量折叠,但在这种情况下,这些功能似乎不太有用。更重要的是,如果你试图改变一个const
指针,编译器会告诉你,这增加了很大的安全性。
对指针使用const
时,有两种选择:const
可以应用于指针所指向的内容,或者const
可以应用于指针本身存储的地址。这些的语法一开始有点混乱,但是通过练习会变得很舒服。
指向常量的指针
与任何复杂的定义一样,指针定义的诀窍是从标识符开始读取,然后一步步地读取。const
说明符绑定到它“最接近”的东西因此,如果您想防止对所指向的元素进行任何更改,您可以编写如下定义:
const int* u;
从标识符开始,你读“u
是一个指针,它指向一个 const int
”在这里,不需要初始化,因为你在说u
可以指向任何东西(也就是说,它不是const
),但是它所指向的东西不能被改变。
这是有点令人困惑的部分。你可能会想,为了使指针本身不变,也就是说,为了防止对包含在u
中的地址进行任何更改,你可以简单地将const
移动到int
的另一边,就像这样:
const int* u;
认为这个应该读作“v
是一个指向int
的const
指针”并不疯狂然而,它实际上的读法是“v
是一个普通指针,指向一个恰好是const
的int
也就是说,const
已经再次将自己绑定到了int
上,效果和之前的定义一样。这两个定义相同的事实是令人困惑的地方;为了防止读者产生这种困惑,你应该坚持第一种形式。
常量指针
要使指针本身成为一个const
,你必须把const
说明符放在*
的右边,就像这样:
int d = 1;
int* const w = &d;
现在上面写着:“w
是指针,也就是const
,指向一个int
。”因为指针本身现在是const
,编译器要求给它一个初始值,这个值在指针的生命周期内保持不变。但是,可以通过下面的语句来改变该值所指向的内容
*w = 2;
您还可以使用两种合法形式中的任何一种来创建指向const
对象的const
指针:
int d = 1;
const int* const x = &d; // (1)
int const* const x2 = &d; // (2)
现在指针和对象都不能改变了。
有些人认为第二种形式更一致,因为const
总是放在它所修饰的对象的右边。您必须决定哪一种更适合您的特定编码风格。
清单 8-3 显示了一个可编译文件中的上述行。
清单 8-3 。指针
//: C08:ConstPointers.cpp
const int* u;
int const* v;
int d = 1;
int* const w = &d;
const int* const x = &d; // (1)
int const* const x2 = &d; // (2)
int main() {} ///:∼
格式化
这本书强调在一行中只放一个指针定义,并尽可能在定义点初始化每个指针。因此,将“*
”附加到数据类型的格式样式是可能的,看起来像
int* u = &i;
好像int*
是一个独立的类型。这使得代码更容易理解,但不幸的是,事情实际上并不是这样。事实上,‘*
’绑定到标识符,而不是类型。它可以放在类型名和标识符之间的任何位置。所以你可以写作
int *u = &i, v = 0;
它创建了一个int* u
,和一个非指针int v
。因为读者常常觉得这令人困惑,所以最好遵循本书中所示的形式。
赋值和类型检查
C++ 非常注重类型检查,这也延伸到了指针赋值。你可以将一个非const
对象的地址赋给一个const
指针,因为你只是保证不改变那些可以改变的东西。然而,你不能把一个const
对象的地址分配给一个非const
指针,因为这样你就可以通过指针改变对象。当然,您总是可以使用强制类型转换来强制进行这样的赋值,但是这是一种糟糕的编程实践,因为这样会破坏对象的const
属性,以及const
承诺的任何安全性。参见清单 8-4 中的示例。
清单 8-4 。指针分配
//: C08:PointerAssignment.cpp
int d = 1;
const int e = 2;
int* u = &d; // OK -- d not const
//! int* v = &e; // Illegal -- e const
int* w = (int*)&e; // Legal but bad practice
int main() {} ///:∼
尽管 C++ 有助于防止错误,但如果你想破坏安全机制,它并不能保护你免受伤害。
字符数组文字
不强制使用严格const
属性的地方是字符数组文字。你可以说
char* cp = "howdy";
编译器会毫无怨言地接受它。从技术上来说,这是一个错误,因为字符数组文字(在本例中为"howdy"
)是由编译器创建的,作为一个常量字符数组,引用的字符数组的结果是它在内存中的起始地址。修改数组中的任何字符都是运行时错误,尽管不是所有的编译器都正确地执行这一点。
所以字符数组实际上是常量字符数组。当然,编译器允许您将它们作为非const
处理,因为有太多现有的 C 代码依赖于此。然而,如果您试图更改字符数组中的值,行为是未定义的,尽管它可能在许多机器上工作。
如果您希望能够修改字符串,请将它放在一个数组中,例如:
charcp[] = "howdy";
因为编译器通常不会强制区别,所以不会提醒你使用后一种形式,所以这一点变得相当微妙。
函数参数和返回值
使用const
来指定函数参数和返回值是常量概念容易混淆的另一个地方。如果您通过值传递对象,指定const
对客户端没有意义(这意味着传递的参数不能在函数内部修改)。如果你通过值返回一个用户定义类型的对象作为一个const
,这意味着返回值不能被修改。如果你是传递和返回地址,const
是承诺地址的目的地不会改变。
按常量值传递
当通过值传递函数参数时,可以指定它们是const
,比如
void f1(const int i) {
i++; // Illegal -- compile-time error
}
但这意味着什么呢?你承诺变量的初始值不会被函数f1( )
改变。但是,因为参数是通过值传递的,所以您会立即制作原始变量的副本,这样就隐式地保持了对客户端的承诺。
在函数内部,const
的含义是:参数不能改变。所以它实际上是函数创建者的工具,而不是调用者的。
为了避免调用者混淆,你可以将参数设为函数中的const
,而不是在参数列表中。你可以用一个指针来做这件事,但是一个更好的语法是通过引用来实现的,这个主题将在第十一章的中充分展开。简而言之,引用就像一个被自动解引用的常量指针,所以它的作用就像是对象的别名。要创建一个引用,可以在定义中使用&
。因此,非混淆函数定义如下所示:
void f2(int ic) {
const int& i = ic;
i++; // Illegal -- compile-time error
}
Again
,你会得到一个错误消息,但是这次本地对象的const
属性不是函数签名的一部分;它只对函数的实现有意义,因此对客户端是隐藏的。
按常量值返回
类似的道理也适用于返回值。如果你说一个函数的返回值是const
,比如
const int g();
您承诺原始变量( 内的)不会被修改。同样,因为你是通过值返回的,所以它是被复制的,所以原始值不能通过返回值被修改。
起初,这可以使const
的规范看起来毫无意义。在清单 8-5 中,你可以看到通过值返回const
的效果明显缺乏。
清单 8-5 。通过值返回常量
//: C08:Constval.cpp
// Returning consts by value
// has no meaning for built-in types
int f3() { return 1; }
const int f4() { return 1; }
int main() {
const int j = f3(); // Works fine
int k = f4(); // But this works fine too!
} ///:∼
对于内置类型,是否以const
的形式通过值返回并不重要,因此在通过值返回内置类型时,应该避免让客户端程序员感到困惑,并去掉const
。
当您处理用户定义的类型时,通过值作为const
返回变得很重要。如果一个函数通过值作为const
返回一个类对象,那么这个函数的返回值不能是一个左值(也就是说,它不能被赋值或者修改)。参见清单 8-6 中的示例。
清单 8-6 。由值返回的常量
//: C08:ConstReturnValues.cpp
// Constant return by value
// Result cannot be used as an lvalue
class X {
int i;
public:
X(int ii = 0);
void modify();
};
X::X(int ii) { i = ii; }
void X::modify() { i++; }
X f5() {
return X();
}
const X f6() {
return X();
}
void f7(X& x) { // Pass by non-const reference
x.modify();
}
int main() {
f5() = X(1); // OK -- non-const return value
f5().modify(); // OK
//! f7(f5()); // Causes warning or error
// Causes compile-time errors:
//! f7(f5());
//! f6() = X(1);
//! f6().modify();
//! f7(f6());
} ///:∼
f5( )
返回一个非constX
对象,而f6( )
返回一个const X
对象。只有非const
返回值可以作为左值使用。因此,当通过值返回一个对象时,如果你想防止它被用作左值,使用const
是很重要的。
当通过值返回内置类型时,const
没有意义的原因是编译器已经防止它成为左值(因为它总是值,而不是变量)。只有当你通过值返回用户定义类型的对象时,它才成为一个问题。
函数f7( )
将其参数作为非const
引用(c++ 中处理地址的附加方式,也是第十一章的主题)。这实际上与采用非const
指针是一样的;只是语法不同而已。这在 C++ 中不能编译的原因是因为创建了一个临时的。
临时工
有时候,在表达式求值期间,编译器必须创建临时对象 。这些物品和其他物品一样:它们需要储藏,它们必须被建造和摧毁。区别在于你永远看不到它们——编译器负责决定它们是否需要以及它们存在的细节。但是临时演员有一个特点:他们是自动的。因为你通常无法得到一个临时的对象,告诉它做一些会改变那个临时的东西几乎肯定是一个错误,因为你将无法使用那个信息。通过自动设置所有的临时变量const
,编译器会在你出错时通知你。
在清单 8-6 中,f5( )
返回一个非constX
对象。但是在表情上
f7(f5());
编译器必须制造一个临时对象来保存f5( )
的返回值,这样它就可以被传递给f7( )
。如果f7( )
以价值来衡量它的论点,这没什么大不了的;然后,临时文件将被复制到f7( )
中,临时文件X
将不会发生任何变化。然而,f7( )
通过引用得到它的参数,这意味着在这个例子中它得到临时X
的地址。因为f7( )
没有通过const
引用获取它的参数,所以它有修改临时对象的权限。但是编译器知道一旦表达式求值完成,临时变量就会消失,因此你对临时变量X
所做的任何修改都会丢失。通过自动生成所有临时对象const
,,这种情况会导致一个编译时错误消息,这样您就不会被一个很难发现的错误所困扰。
但是,请注意合法的表达方式:
f5() = X(1);
f5().modify();
尽管这些符合编译器的要求,但它们实际上是有问题的。f5( )
返回一个X
对象,为了满足上面的表达式,编译器必须创建一个临时来保存返回值。所以在这两个表达式中,临时对象都被修改,一旦表达式结束,临时对象就被清除。结果,修改丢失了,所以这段代码可能是一个 bug——但是编译器不会告诉你任何关于它的信息。像这样的表达式足够简单,您可以发现问题,但是当事情变得更复杂时,错误就有可能从这些裂缝中溜走。
保存类对象的const
属性的方法将在本章后面介绍。
传递和返回地址
如果您传递或返回一个地址(指针或引用),客户端程序员就有可能接受它并修改原始值。如果您将指针或引用设为const
,就可以防止这种情况发生,这可能会让您避免一些痛苦。事实上,每当你把一个地址传递给一个函数时,如果可能的话,你应该把它变成一个const
。如果你不这样做,你就排除了对任何一个const
使用该功能的可能性。
选择是返回一个指向const
的指针还是引用取决于你想让你的客户程序员用它做什么。清单 8-7 展示了使用const
指针作为函数参数和返回值。
清单 8-7 。常量指针作为函数参数和返回值
//: C08:ConstPointer.cpp
// Constant pointer arg/return
void t(int*) {}
void u(const int* cip) {
//! *cip = 2; // Illegal -- modifies value
int i = *cip; // OK -- copies value
//! int* ip2 = cip; // Illegal: non-const
}
const char* v() {
// Returns address of static character array:
return "result of function v()";
}
const int* const w() {
static int i;
return &i;
}
int main() {
int x = 0;
int* ip = &x;
const int* cip = &x;
t(ip); // OK
//! t(cip); // Not OK
u(ip); // OK
u(cip); // Also OK
//! char* cp = v(); // Not OK
const char* ccp = v(); // OK
//! int* ip2 = w(); // Not OK
const int* const ccip = w(); // OK
const int* cip2 = w(); // OK
//! *w() = 1; // Not OK
} ///:∼
函数t( )
以一个普通的非const
指针作为参数,u( )
以一个const
指针作为参数。在u( )
中,您可以看到试图修改const
指针的目的地是非法的,但是您当然可以将信息复制到非const
变量中。编译器还阻止你使用存储在const
指针中的地址创建非const
指针。
函数v( )
和w( )
测试返回值语义。v( )
返回一个从字符数组文字创建的const char*
。在编译器创建字符数组并将其存储在静态存储区域后,该语句实际上产生了字符数组的地址。如前所述,这个字符数组在技术上是一个常量,用v( )
的返回值来恰当地表示。
w( )
的返回值要求指针和它所指向的都必须是const
。和v( )
一样,w( )
返回的值在函数返回后有效,只是因为它是static
。你永远不要返回指向局部栈变量的指针,因为在函数返回和栈被清理后它们将是无效的。
注意你可能返回的另一个公共指针是在堆上分配的存储地址,它在函数返回后仍然有效。
在main( )
中,使用各种参数测试函数。您可以看到,t( )
将接受一个非const
指针参数,但是如果您试图将一个指向const
的指针传递给它,并不能保证t( )
会留下指针的目的地,所以编译器会给您一个错误消息。u( )
接受一个const
指针,因此它将接受两种类型的参数。因此,采用const
指针的函数比不采用指针的函数更通用。
正如所料,v( )
的返回值只能分配给一个指向const
的指针。您可能还会期望编译器拒绝将w( )
的返回值赋给一个非const
指针,而是接受一个const int* const
,但是看到它也接受一个const int*
,这可能有点令人惊讶,?? 与返回类型并不完全匹配。同样,因为值(包含在指针中的地址)正在被复制,所以原始变量不变的承诺被自动保持。因此,const int* const
中的第二个const
只有当你试图将其用作左值时才有意义,在这种情况下,编译器会阻止你。
标准参数传递
在 C 中,通过值传递是很常见的,当你想传递一个地址时,你唯一的选择就是使用指针。然而,这两种方法在 C++ 中都不是首选的。相反,当传递一个参数时,你的第一选择是通过引用传递,而且是通过const
引用。对于客户端程序员来说,语法与按值传递的语法相同,所以不会对指针产生混淆——他们甚至不需要考虑指针。对于函数的创建者来说,传递一个地址实际上总是比传递整个类对象更有效,如果你通过const
引用传递,这意味着你的函数不会改变那个地址的目的地,所以从客户端程序员的角度来看,效果与通过值传递完全一样(只是更有效)。
由于引用的语法(对调用者来说,看起来像是按值传递)可以将一个临时对象传递给一个采用const
引用的函数,而你永远不能将一个临时对象传递给一个采用指针的函数;对于指针,必须显式获取地址。因此,通过引用传递会产生一种在 C 中从未出现过的新情况:一个总是为const
的临时变量可以将其地址传递给一个函数。这就是为什么,要允许临时变量通过引用传递给函数,参数必须是一个const
引用。清单 8-8 展示了这一点。
清单 8-8 。临时工
//: C08:ConstTemporary.cpp
// Temporaries are const
class X {};
X f() { return X(); } // Return by value
void g1(X&) {} // Pass by non-const reference
void g2(const X&) {} // Pass by const reference
int main() {
// Error: const temporary created by f():
//! g1(f());
// OK: g2 takes a const reference:
g2(f());
} ///:∼
f( )
通过值返回一个class X
的对象。这意味着当你立即获取f( )
的返回值并将其传递给另一个函数时,就像在对g1( )
和g2( )
的调用中一样,会创建一个临时变量,这个临时变量就是const
。因此,g1( )
中的调用是错误的,因为g1( )
没有引用const
,但是对g2( )
的调用是正确的。
班级
本节展示了对类使用const
的方法。您可能希望在一个类中创建一个局部const
,以便在编译时计算的常量表达式中使用。然而,const
的含义在类内部是不同的,所以为了创建一个类的const
数据成员,你必须理解这些选项。
你也可以创建一个完整的对象const
(正如你刚刚看到的,编译器总是创建临时对象const
)。但是保留对象的const
属性更复杂。编译器可以确保内置类型的const
属性,但是它不能监控类的复杂性。为了保证类对象的const
属性,引入了const
成员函数:一个const
对象只能调用一个const
成员函数。
类中的常量
对于常量表达式,您希望使用const
的地方之一是在类内部。典型的例子是当你在一个类中创建一个数组时,你想用一个const
而不是一个#define
来确定数组的大小,并在涉及数组的计算中使用。数组大小是您希望隐藏在类中的东西,因此,例如,如果您使用像size
这样的名称,您可以在另一个类中使用该名称而不会发生冲突。预处理器从定义开始就将所有的#define
视为全局的,所以这不会达到预期的效果。
您可能会认为合乎逻辑的选择是在类中放置一个const
。这不会产生预期的结果。在一个类内部,const
部分回复到它在 c 中的含义,它在每个对象内部分配存储,代表一个初始化一次就不能改变的值。在类中使用const
意味着“这是对象生命周期的常量。然而,每个不同的对象可能包含该常量的不同值。
因此,当你在一个类中创建一个普通的( non - static
) const
时,你不能给它一个初始值。当然,这种初始化必须发生在构造器中,但是是在构造器中的一个特殊位置。因为一个const
必须在它被创建的时候被初始化,在构造器的主体内部const
必须已经被初始化。否则,您只能选择等待,直到构造器体中的某个时刻,这意味着const
将暂时不初始化。此外,没有什么可以阻止您在构造器体的不同位置更改const
的值。
构造器初始值设定项列表
这个特殊的初始化点被称为构造器初始化列表 ,它最初是为在继承中使用而开发的(在第十四章中涉及)。构造器初始化列表——顾名思义,只出现在构造器的定义中——是一个“构造器调用”列表,出现在函数参数列表和冒号之后,但在构造器体的左括号之前。这是为了提醒您,列表中的初始化发生在任何主构造器代码执行之前。这是放置所有const
初始化的地方。清单 8-9 中显示了const
在一个类中的正确形式。
清单 8-9 。初始化类中的常量
//: C08:ConstInitialization.cpp
// Initializing const in classes
#include <iostream>
using namespace std;
class Fred {
const int size;
public:
Fred(int sz);
void print();
};
Fred::Fred(int sz) : size(sz) {}
void Fred::print() { cout << size << endl; }
int main() {
Fred a(1), b(2), c(3);
a.print(), b.print(), c.print();
} ///:∼
清单 8-9 中显示的构造器初始化列表的形式起初令人困惑,因为你不习惯看到一个内置类型被当作它有一个构造器。
内置类型的“构造器”
随着语言的发展,越来越多的人致力于使用户定义的类型看起来像内置类型,很明显,有时使内置类型看起来像用户定义的类型是有帮助的。在构造器初始化列表中,你可以把一个内置类型当作它有一个构造器,如清单 8-10 所示。
清单 8-10 。内置构造器
//: C08:BuiltInTypeConstructors.cpp
#include <iostream>
using namespace std;
class B {
int i;
public:
B(int ii);
void print();
};
B::B(int ii) : i(ii) {}
void B::print() { cout << I << endl; }
int main() {
B a(1), b(2);
float pi(3.14159);
a.print(); b.print();
cout << pi << endl;
} ///:∼
这在初始化const
数据成员时尤其重要,因为它们必须在进入函数体之前初始化。
将内置类型的这个“构造器”扩展到一般情况是有意义的(它只是意味着赋值),这就是为什么float pi(3.14159)
定义在清单 8-10 中有效。
将内置类型封装在类中以保证用构造器初始化通常是有用的。例如,清单 8-11 显示了一个Integer
类。
清单 8-11 。装入胶囊
//: C08:EncapsulatingTypes.cpp
#include <iostream>
using namespace std;
class Integer {
int i;
public:
Integer(int ii = 0);
void print();
};
Integer::Integer(int ii) : i(ii) {}
void Integer::print() { cout << I << ' '; }
int main() {
Integer i[100];
for(int j = 0; j < 100; j++)
i[j].print();
} ///:∼
main( )
中的数组Integer
全部自动初始化为零。这种初始化不一定比for
循环或memset( )
更昂贵。许多编译器很容易将这个过程优化得非常快。
类中的编译时常量
上面对const
的使用很有趣,在某些情况下可能很有用,但它没有解决最初的问题,即如何在类中创建一个编译时的常量?答案需要使用一个额外的关键字(要到第十章才会全面介绍):static
。在这种情况下,static
关键字的意思是“只有一个实例,不管创建了多少个该类的对象,”,这正是我们在这里所需要的:一个恒定的类成员,不能从该类的一个对象改变到另一个对象。因此,内置类型的static const
可以被视为编译时常量。
当在类内部使用时,static const
有一个特性有点不寻常:你必须在定义static const
的时候提供初始化器。这是只发生在static const
身上的事情;尽管您可能想在其他情况下使用它,但它不会起作用,因为所有其他数据成员都必须在构造器或其他成员函数中初始化。
清单 8-12 展示了在一个表示字符串指针堆栈的类中创建和使用一个名为size
的static const
。
清单 8-12 。使用静态常量
//: C08:StringStack.cpp
// Using static const to create a
// compile-time constant inside a class
#include <string>
#include <iostream>
using namespace std;
class StringStack {
static const int size = 100;
const string* stack[size];
int index;
public:
StringStack();
void push(const string* s);
const string* pop();
};
StringStack::StringStack() : index(0) {
memset(stack, 0, size * sizeof(string*));
}
void StringStack::push(const string* s) {
if(index < size)
stack[index++] = s;
}
const string* StringStack::pop() {
if(index > 0) {
const string* rv = stack[--index];
stack[index] = 0;
return rv;
}
return 0;
}
string iceCream[] = {
"pralines& cream",
"fudge ripple",
"jamocha almond fudge",
"wild mountain blackberry",
"raspberry sorbet",
"lemon swirl",
"rocky road",
"deep chocolate fudge"
};
const int iCsz =
sizeof iceCream / sizeof *iceCream;
int main() {
StringStack ss;
for(int i = 0; i < iCsz; i++)
ss.push(&iceCream[i]);
const string* cp;
while((cp = ss.pop()) != 0)
cout << *cp << endl;
} ///:∼
因为size
用于确定数组stack
的大小,所以它确实是一个编译时常量,但是它隐藏在类内部。
注意,push( )
以一个const string*
作为参数,pop( )
返回一个const string*
,StringStack
持有const string*
。如果这不是真的,你就不能用一个StringStack
来保存iceCream
中的指针。然而,它也阻止你做任何会改变由StringStack
包含的对象的事情。当然,并不是所有的容器都有这种限制。
旧代码中的“枚举黑客”
在 C++ 的旧版本中,类内部不支持static const
。这意味着const
对于类中的常量表达式是无用的。然而,人们仍然想这样做,所以一个典型的解决方案(通常被称为“enum hack”)是使用一个没有实例的未标记的enum
。枚举必须在编译时建立所有的值,它对于类是局部的,并且它的值可用于常量表达式。因此,你通常会在清单 8-13 中看到类似的代码。
清单 8-13 。枚举黑客
//: C08:EnumHack.cpp
#include <iostream>
using namespace std;
class Bunch {
enum { size = 1000 };
int i[size];
};
int main() {
cout << "sizeof(Bunch) = " << sizeof(Bunch)
<< ", sizeof(i[1000]) = "
<< sizeof(int[1000]) << endl;
} ///:∼
这里使用enum
保证不占用对象中的存储,枚举器都是在编译时计算的。您还可以显式建立枚举数的值,例如:
enum { one = 1, two = 2, three };
对于整型enum
类型,编译器将继续从最后一个值开始计数,因此枚举器three
将得到值 3。
在上面的StringStack.cpp
示例中,该行
Static const int size = 100;
反而会是
enum { size = 100 };
虽然您经常会在遗留代码中看到enum
技术,但是语言中添加了static const
特性来解决这个问题。然而,没有压倒性的理由让你必须选择static const
而不是enum
hack,在本书中使用enum
hack 是因为在写作时它被更多的编译器支持。
常量对象和成员函数
类成员函数可以做成const
。这是什么意思?要理解,首先要掌握const
对象的概念。
一个const
对象被定义为与内置类型相同的用户定义类型,例如
const int i = 1;
const blob b(2);
这里,b
是一个类型为blob
的const
对象。它的构造器是用两个参数调用的。为了让编译器执行const
属性,它必须确保在对象的生命周期中没有对象的数据成员被改变。它可以很容易地确保没有公共数据被修改,但是如何知道哪些成员函数将改变数据,哪些对于一个const
对象是“安全的”?
如果你声明一个成员函数const
,你告诉编译器这个函数可以被一个const
对象调用。没有明确声明const
的成员函数被视为修改对象中数据成员的函数,编译器不允许你为const
对象调用它。
然而,这还不止于此。仅仅声明一个成员函数是const
并不能保证它会那样做,所以编译器会强迫你在定义函数时重申const
规范。(const
成为函数签名的一部分,所以编译器和链接器都检查* const
属性。)然后,如果您试图更改对象的任何成员或者调用非const
成员函数,它会在函数定义期间通过发出错误消息来强制执行const
属性。因此,您声明的任何成员函数在定义中都保证会以这种方式运行。*
为了理解声明const
成员函数的语法,首先要注意,在函数声明之前加上const
意味着返回值是const
,这样不会产生想要的结果。相反,您必须将const
说明符放在和参数列表*之后。参见清单 8-14 。
清单 8-14 。常量成员函数
//: C08:ConstMember.cpp
class X {
int i;
public:
X(int ii);
int f() const;
};
X::X(int ii) : i(ii) {}
int X::f() const { return i; }
int main() {
X x1(10);
const X x2(20);
x1.f();
x2.f();
} ///:∼
请注意,const
关键字必须在定义中重复出现,否则编译器会将其视为不同的函数。由于f( )
是一个const
成员函数,如果它试图以任何方式改变i
或者调用另一个不是const
的成员函数,编译器会将其标记为错误。
你可以看到用const
和非const
对象调用const
成员函数是安全的。因此,你可以把它看作是成员函数的最一般的形式(正因为如此,成员函数不会自动地默认为 const
),这是很不幸的。任何不修改成员数据的函数都应该声明为const
,这样它就可以和const
对象一起使用。
清单 8-15 对比了一个const
和非const
成员函数。
清单 8-15 。对比常量和非常量成员函数
//: C08:Quoter.cpp
// Random quote selection
#include <iostream>
#include <cstdlib> // Random number generator
#include <ctime> // To seed random generator
using namespace std;
class Quoter {
int lastquote;
public:
Quoter();
int lastQuote() const;
const char* quote();
};
Quoter::Quoter(){
lastquote = -1;
srand(time(0)); // Seed random number generator
}
int Quoter::lastQuote() const {
return lastquote;
}
const char* Quoter::quote() {
static const char* quotes[] = {
"Are we having fun yet?",
"Doctors always know best",
"Is it ... Atomic?",
"Fear is obscene",
"There is no scientific evidence "
"to support the idea "
"that life is serious",
"Things that make us happy, make us wise",
};
const int qsize = sizeof quotes/sizeof *quotes;
int qnum = rand() % qsize;
while(lastquote >= 0 && qnum == lastquote)
qnum = rand() % qsize;
return quotes[lastquote = qnum];
}
int main() {
Quoter q;
const Quoter cq;
cq.lastQuote(); // OK
//! cq.quote(); // Not OK; non const function
for(int i = 0; i < 20; i++)
cout << q.quote() << endl;
} ///:∼
构造器和析构函数都不能是const
成员函数,因为它们实际上总是在初始化和清理期间对对象进行一些修改。成员函数quote( )
也不能是const
,因为它修改了数据成员lastquote
(参见return
语句)。然而,lastQuote( )
不做任何修改,因此它可以是const
,并且可以被const
对象cq
安全地调用。
可变:按位与逻辑常量
如果您想创建一个const
成员函数,但是您仍然想改变对象中的一些数据,该怎么办呢?这有时被称为按位 const
和逻辑 const
(有时也被称为基于成员的 const
) 。按位const
表示对象中的每一位都是永久的,所以对象的一个位图像永远不会改变。逻辑const
意味着,尽管整个对象在概念上是不变的,但在每个成员的基础上可能会有变化。然而,如果编译器被告知一个对象是const
,它将小心翼翼地保护该对象以确保按位const
属性。为了影响逻辑const
属性,有两种方法可以在const
成员函数中改变数据成员。
第一种方法是历史方法,称为抛弃 const
属性。它以一种相当奇怪的方式表演。您获取this
(产生当前对象地址的关键字)并将其转换为指向当前类型对象的指针。看来this
已经是这样的指针了。然而,在一个const
成员函数中,它实际上是一个const
指针,所以通过将它转换成一个普通的指针,可以为该操作移除const
属性。清单 8-16 显示了一个例子。
*清单 8-16 。丢弃常量属性
//: C08:Castaway.cpp
// "Casting away" const attribute
class Y {
int i;
public:
Y();
void f() const;
};
Y::Y() { i = 0; }
void Y::f() const {
//! i++; // Error -- const member function
((Y*)this)->i++; // OK: cast away const-ness
// Better: use C++ explicit cast syntax:
(const_cast<Y*>(this))->i++;
}
int main() {
const Y yy;
yy.f(); // Actually changes it!
} ///:∼
这种方法是可行的,您将看到它在遗留代码中的使用,但它不是首选的技术。问题是,const
属性的缺失隐藏在一个成员函数定义中,除非您可以访问源代码,否则您无法从类接口中得知对象的数据实际上被修改了(并且您必须怀疑const
属性被丢弃了,并寻找类型转换)。为了公开一切,你应该在类声明中使用mutable
关键字来指定一个特定的数据成员可以在const
对象中被改变,如清单 8-17 所示。
清单 8-17 。可变关键字
//: C08:Mutable.cpp
// The "mutable" keyword
class Z {
int i;
mutable int j;
public:
Z();
void f() const;
};
Z::Z() : i(0), j(0) {}
void Z::f() const {
//! i++; // Error -- const member function
j++; // OK: mutable
}
int main() {
const Z zz;
zz.f(); // Actually changes it!
} ///:∼
这样,类的用户可以从声明中看出哪些成员可能在const
成员函数中被修改。
罗马性
如果一个对象被定义为const
,那么它就是要放入只读存储器(ROM)中的候选者,这通常是嵌入式系统编程中的一个重要考虑因素。然而,仅仅制造一个对象const
是不够的;对 ROMability 的要求要严格得多。当然,对象必须是按位 - const
,而不是逻辑 - const
。如果逻辑const
属性仅通过mutable
关键字实现,这很容易看出,但是如果const
属性被丢弃在const
成员函数中,编译器可能检测不到。还有两条附加规则。
class
或struct
必须没有用户定义的构造器或析构函数。- 不能有基类(在第十四章中涉及)或带有用户定义的构造器或析构函数的成员对象。
写操作对 ROMable 类型的const
对象的任何部分的影响是未定义的。虽然一个合适形式的对象可以放在 ROM 中,但是从来没有对象需要放在 ROM 中。
挥发性关键字
volatile
的语法与const
的语法相同,但是volatile
意味着“这些数据可能会在编译器的知识范围之外发生变化。”不知何故,环境正在改变数据(可能通过多任务、多线程或中断),并且volatile
告诉编译器不要对该数据做任何假设,尤其是在优化期间。
如果编译器说,“我之前把这个数据读入了一个寄存器,我还没有动那个寄存器,”,正常情况下它不需要再次读取数据。但是如果数据是volatile
,编译器不能做出这样的假设,因为数据可能已经被另一个进程改变了,它必须重新读取该数据,而不是优化代码来删除通常是冗余的读取。
使用与创建const
对象相同的语法创建volatile
对象。您还可以创建const volatile
对象,这些对象不能由客户端程序员更改,而是通过一些外部代理进行更改。清单 8-18 包含了一个可能代表与某个通信硬件相关的类的例子。
清单 8-18 。volatile 关键字
//: C08:Volatile.cpp
// The volatile keyword
classComm {
const volatile unsigned char byte;
volatile unsigned char flag;
enum { bufsize = 100 };
unsigned char buf[bufsize];
int index;
public:
Comm();
void isr() volatile;
char read(int index) const;
};
Comm::Comm() : index(0), byte(0), flag(0) {}
// Only a demo; won't actually work
// as an interrupt service routine:
void Comm::isr() volatile {
flag = 0;
buf[index++] = byte;
// Wrap to beginning of buffer:
if(index >= bufsize) index = 0;
}
charComm::read(int index) const {
if(index < 0 || index >= bufsize)
return 0;
return buf[index];
}
int main() {
volatile Comm Port;
Port.isr(); // OK
//! Port.read(0); // Error, read() not volatile
} ///:∼
与const
一样,您可以将volatile
用于数据成员、成员函数和对象本身。你只能为volatile
对象调用volatile
成员函数。
isr( )
实际上不能作为中断服务例程的原因是,在成员函数中,必须秘密传递当前对象的地址(this
),而一个 ISR 一般根本不想要参数。为了解决这个问题,你可以让isr( )
成为一个static
成员函数,这个主题在第十章中有介绍。
volatile
的语法与 const
相同,所以两者的讨论常被放在一起。这两者合起来被称为c-v 限定词。
审查会议
- 关键字
const
让你能够将对象、函数参数、返回值和成员函数定义为常量,并且在不损失任何预处理好处的情况下,消除预处理器进行值替换。 - 所有这些都为编程中的类型检查和安全性提供了一种重要的额外形式。使用所谓的“const 正确性”(在任何可能的地方使用 const)可以成为项目的救星。
- 尽管您可以忽略
const
并继续使用旧的 C 编码实践,但它会帮助您。第十一章及以后开始大量使用引用,在那里你会看到更多关于在函数参数中使用const
的重要性。**
九、内联函数
C++ 从 C 继承的一个重要特性就是效率。如果 C++ 的效率比 C 低得多,将会有相当多的程序员无法证明使用 c++ 的合理性。
在 C 中,保持效率的方法之一是通过使用宏,它允许你使看起来乍看之下是一个函数调用,而没有正常的函数调用开销。宏是用预处理器而不是编译器本身来实现的,预处理器直接用宏代码替换所有的宏调用,所以推送参数、进行汇编语言调用、返回参数和执行汇编语言返回都没有成本。所有的工作都是由预处理器执行的,所以您拥有了函数调用的便利性和可读性,但它不会让您付出任何代价(就内存空间或消耗的时间等函数调用开销而言)。
在 C++ 中使用预处理宏有两个问题。第一个也适用于 C 语言:一个宏看起来像一个函数调用,但并不总是如此。这会导致隐藏难以发现的 bug。第二个问题是 C++ 特有的:预处理器没有访问类成员数据的权限。这意味着预处理宏不能用作类成员函数。
为了保持预处理宏的效率,但是为了增加真正函数的安全性和类范围,C++ 有了内联函数。在这一章中,你将会看到 C++ 中预处理宏的问题,这些问题是如何通过内联函数解决的,以及关于内联工作方式的指导和见解。
预处理器陷阱
预处理器宏问题的关键在于,你可能会被愚弄,以为预处理器的行为和编译器的行为是一样的。当然,这是为了让宏看起来和行为起来像一个函数调用,所以很容易陷入这种虚构。当细微的差异出现时,困难就开始了。
举个简单的例子,考虑以下情况:
#define F (x) (x + 1)
现在,如果给F
打电话,比如
F(1)
预处理器出乎意料地将其扩展为
(x) (x + 1)(1)
该问题的出现是因为在宏定义中F
和它的左括号之间有间隙。当这个间隙被去掉后,你就可以用这个间隙调用宏了
F (1)
并且它仍然会适当地膨胀到
(1 + 1)
上面的例子相当简单,问题马上就会变得很明显。真正的困难出现在宏调用中使用表达式作为参数时。
有两个问题。首先,表达式可能会在宏内部展开,因此它们的求值优先级与您预期的不同。例如,
#define FLOOR(x,b) x>=b?0:1
现在,如果参数使用表达式,比如
if(FLOOR(a&0x0f,0x07)) // ...
宏将扩展到
if(a&0x0f>=0x07?0:1)
&
的优先级比>=
低,所以宏观评价会让你大吃一惊。一旦发现了问题,就可以通过在宏定义中的每一处都加上括号来解决。(在创建预处理器宏时,这是一个很好的做法。)因此,
#define FLOOR(x,b) ((x)>=(b)?0:1)
然而,发现问题可能是困难的,直到您认为正确的宏行为是理所当然的之后,您才可能发现问题。在前面宏的未区分版本中,大多数表达式将正确工作,因为>=
的优先级低于大多数运算符,如+、/
、– –
,甚至是按位移位运算符。因此,您可以很容易地认为它适用于所有表达式,包括使用按位逻辑运算符的表达式。
前面的问题可以通过仔细的编程实践来解决:在宏中用括号括起所有内容。然而,第二个困难更微妙。与普通函数不同,每次在宏中使用参数时,都会对该参数进行计算。只要只使用普通变量调用宏,这种评估就是良性的,但是如果对参数的评估有副作用,那么结果可能会令人惊讶,并且肯定不会模仿函数行为。
例如,此宏确定其参数是否在某个范围内:
#define BAND(x) (((x)>5 && (x)<10) ? (x) : 0)
只要你使用一个“普通的”参数,宏的工作方式就非常像一个实函数。但是一旦你放松下来,开始相信是一个真实的函数,问题就开始了,正如你在清单 9-1 中看到的。
清单 9-1 。宏副作用
//: C09:MacroSideEffects.cpp
#include "../require.h" // To be INCLUDED from Header FILE
// *ahead* (Section: Improved error
// checking) Or *Chapter 3*
#include <fstream>
using namespace std;
#define BAND(x) (((x)>5 && (x)<10) ? (x) : 0)
int main() {
ofstream out("macro.out");
assure(out, "macro.out");
for(int i = 4; i < 11; i++) {
int a = i;
out << "a = " << a << endl << '\t';
out << "BAND(++a)=" << BAND(++a) << endl;
out << "\t a = " << a << endl;
}
} ///:∼
注意宏名中所有大写字符的使用。这是一个有用的实践,因为它告诉读者这是一个宏而不是一个函数,所以如果有问题,它可以作为一个小小的提醒。
下面是程序产生的输出,这完全不是您对真实函数的预期:
a = 4
BAND(++a)=0
a = 5
a = 5
BAND(++a)=8
a = 8
a = 6
BAND(++a)=9
a = 9
a = 7
BAND(++a)=10
a = 10
a = 8
BAND(++a)=0
a = 10
a = 9
BAND(++a)=0
a = 11
a = 10
BAND(++a)=0
a = 12
当a
为 4 时,只出现条件的第一部分,所以表达式只计算一次,宏调用的副作用是a
变成了 5,这是你在相同情况下从普通函数调用中所期望的。然而,当数字在范围内时,两个条件都被测试,这导致两个增量。结果是通过再次计算参数产生的,这将导致第三次增量。一旦数字超出范围,两个条件仍然被测试,所以你得到两个增量。副作用是不同的,取决于论点。
这显然不是您想要的看起来像函数调用的宏的行为。在这种情况下,显而易见的解决方案是使它成为一个真正的函数,这当然会增加额外的开销,并且如果您大量调用该函数,可能会降低效率。不幸的是,问题可能并不总是如此明显,您可能会在不知不觉中获得一个包含函数和宏混合在一起的库,因此像这样的问题可能会隐藏一些非常难以发现的错误。例如,cstdio
中的putc()
宏可能会对其第二个参数求值两次。这是在标准 c 中规定的。另外,如果不小心将toupper()
作为一个宏来实现,可能会对参数求值不止一次,这会给你带来意想不到的结果。
宏和访问
当然,在 C 语言中需要小心地编码和使用预处理宏,如果不是因为一个问题:宏没有成员函数所需的作用域的概念,那么在 C++ 中也可以做到这一点。预处理器只是执行文本替换,所以你不能说
class X {
int i;
public:
#define VAL(X::i) // Error
或者任何相近的东西。此外,没有迹象表明你指的是哪个对象。根本没有办法在宏中表达类的作用域。如果没有预处理器宏的替代方案,程序员会为了提高效率而制作一些数据成员public
,从而暴露底层实现并防止该实现发生变化,同时消除private
提供的保护。
内联函数
在解决访问private
类成员的宏的 C++ 问题时,所有与预处理宏相关的问题都被消除了。这是通过将宏的概念置于它们所属的编译器的控制之下来实现的。C++ 将宏实现为内联函数,这在任何意义上都是一个真正的函数。您期望从普通函数中得到的任何行为,都可以从内联函数中得到。唯一的区别是内联函数被就地扩展,就像预处理宏一样,因此函数调用的开销被消除了。因此,你应该(几乎)永远不要使用宏,只使用内联函数。
在类体中定义的任何函数都是自动内联的,但是您也可以通过在非类函数前面加上inline
关键字来使其内联。但是,要使它生效,您必须在声明中包含函数体,否则编译器会将其视为普通的函数声明。因此,
Inline int plusOne(int x);
除了声明该函数之外,没有任何其他作用(该函数以后可能会也可能不会获得内联定义)。成功的方法提供了功能体:
inline int plusOne(int x) { return ++x; }
请注意,编译器将检查(一如既往)函数参数列表和返回值的使用是否正确(执行任何必要的转换),这是预处理器无法做到的。此外,如果您试图将上述内容编写为预处理宏,您将会得到一个不想要的副作用。
您几乎总是希望将内联定义放在头文件中。当编译器看到这样的定义时,它会将函数类型(签名结合返回值)和函数体放在其符号表中。当您使用函数时,编译器会检查以确保调用是正确的并且返回值被正确使用,然后用函数体替换函数调用,从而消除了开销。内联代码确实会占用空间,但是如果函数很小,这实际上比执行普通函数调用(将参数压入堆栈并执行调用)所生成的代码占用的空间要少。
头文件中的内联函数有一个特殊的状态,因为您必须在每个使用该函数的文件中包含包含函数和的头文件,但是您不会以多个定义错误结束(然而,在所有包含内联函数的地方定义必须相同)。
类内联
要定义一个内联函数,通常必须在函数定义之前加上inline
关键字。然而,这在类定义中是不必要的。你在类定义中定义的任何函数都是自动内联的,正如你在清单 9-2 中看到的。
清单 9-2 。类内部的内联
//: C09:Inline.cpp
// Inlines inside classes
#include <iostream>
#include <string>
using namespace std;
class Point {
int i, j, k;
public:
Point(): i(0), j(0), k(0) {}
Point(int ii, int jj, int kk)
: i(ii), j(jj), k(kk) {}
void print(const string& msg = "") const {
if(msg.size() != 0) cout << msg << endl;
cout << "i = " << I << ", "
<< "j = " << j << ", "
<< "k = " << k << endl;
}
};
int main() {
Point p, q(1,2,3);
p.print("value of p");
q.print("value of q");
} ///:∼
这里,两个构造器和print( )
函数默认都是内联的。注意在main( )
中,您使用内联函数的事实是透明的,也应该是透明的。一个函数的逻辑行为必须相同,不管它是不是内联的(否则你的编译器会崩溃)。您将看到的唯一区别是性能。
当然,在类声明中处处使用内联是一种诱惑,因为这样可以省去定义外部成员函数的额外步骤。但是,请记住,内联的目的是为编译器提供更好的优化机会。但是内联一个大函数将导致代码在调用该函数的任何地方都被复制,产生代码膨胀,这可能会降低速度优势。
注唯一可靠的方法是用你的编译器去实验发现内联对你的程序的影响。
访问功能
内联在类中最重要的用途之一是访问函数 。这是一个小函数,允许您读取或更改对象的部分状态,即一个或多个内部变量。内联对于访问函数如此重要的原因可以在清单 9-3 中看到。
清单 9-3 。内联访问功能
//: C09:Access.cpp
// Inline access functions
class Access {
int i;
public:
int read() const { return i; }
void set(int ii) { i = ii; }
};
int main() {
Access A;
A.set(100);
int x = A.read();
} ///:∼
在这里,类用户从不直接接触类内部的状态变量,它们可以保持private
,处于类设计者的控制之下。所有对private
数据成员的访问都可以通过成员函数接口来控制。此外,访问效率非常高。以read()
为例。如果没有内联,为调用read()
而生成的代码通常会包括将this
压入堆栈并进行汇编语言调用。对于大多数机器,这段代码的大小会比内联创建的代码大,执行时间肯定会更长。
如果没有内联函数,注重效率的类设计者会倾向于简单地使i
成为公共成员,通过允许用户直接访问i
来消除开销。从设计的角度来看,这是灾难性的,因为i
变成了公共接口的一部分,这意味着类设计者永远不能改变它。你被一只叫做i
的int
卡住了。这是一个问题,因为稍后您可能会发现将状态信息表示为float
比int
更有用,但是因为inti
是公共接口的一部分,所以您不能更改它。或者你可能想执行一些额外的计算作为读取或设置i
的一部分,如果是public
你就不能这么做。另一方面,如果您一直使用成员函数来读取和更改对象的状态信息,您可以根据自己的意愿修改对象的底层表示。
此外,使用成员函数控制数据成员允许您向成员函数添加代码,以检测数据何时被更改,这在调试过程中非常有用。如果一个数据成员是public
,任何人都可以在你不知道的情况下随时更改它。
访问器和变异器
有些人进一步将访问函数的概念分为访问器(从对象读取状态信息)和变异器(改变对象的状态)。此外,函数重载可以用来为的访问器和赋值器提供相同的函数名;你如何调用函数决定了你是否正在读取或修改状态信息(见清单 9-4 )。
清单 9-4 。访问器和赋值器
//: C09:Rectangle.cpp
// Accessors & mutators
class Rectangle {
int wide, high;
public:
Rectangle(int w = 0, int h = 0)
: wide(w), high(h) {}
int width() const { return wide; } // Read
void width(int w) { wide = w; } // Set
int height() const { return high; } // Read
void height(int h) { high = h; } // Set
};
int main() {
Rectangle r(19, 47);
// Change width & height:
r.height(2 * r.width());
r.width(2 * r.height());
} ///:∼
构造器使用构造器初始化列表(在第八章中有简要介绍,在第十四章中有完整介绍)来初始化wide
和high
的值(对于内置类型使用伪构造器形式)。
成员函数名不能使用与数据成员相同的标识符,因此您可能会尝试用前导下划线来区分数据成员。但是,带有前导下划线的标识符是保留的,因此您不应该使用它们。
你可以选择使用" get 和" set 来表示访问器和赋值器,如清单 9-5 所示。
清单 9-5 。使用获取和设置
//: C09:Rectangle2.cpp
// Accessors & mutators with "get" and "set"
class Rectangle {
int width, height;
public:
Rectangle(int w = 0, int h = 0)
: width(w), height(h) {}
int getWidth() const { return width; }
void setWidth(int w) { width = w; }
int getHeight() const { return height; }
void setHeight(int h) { height = h; }
};
int main() {
Rectangle r(19, 47);
// Change width & height:
r.setHeight(2 * r.getWidth());
r.setWidth(2 * r.getHeight());
} ///:∼
当然,访问器和赋值器不一定是内部变量的简单管道。有时他们可以进行更复杂的计算。清单 9-6 使用标准的 C 库时间函数来产生一个简单的Time
类。
清单 9-6 。使用时间函数
//: C09:Cpptime.h
// A simple time class
#ifndef CPPTIME_H
#define CPPTIME_H
#include <ctime>
#include <cstring>
class Time {
std::time_t t;
std::tm local;
char asciiRep[26];
unsigned char lflag, aflag;
void updateLocal() {
if(!lflag) {
local = *std::localtime(&t);
lflag++;
}
}
void updateAscii() {
if(!aflag) {
updateLocal();
std::strcpy(asciiRep,std::asctime(&local));
aflag++;
}
}
public:
Time() { mark(); }
void mark() {
lflag = aflag = 0;
std::time(&t);
}
const char* ascii() {
updateAscii();
return asciiRep;
}
// Difference in seconds:
int delta(Time* dt) const {
return int(std::difftime(t, dt->t));
}
int daylightSavings() {
updateLocal();
return local.tm_isdst;
}
int dayOfYear() { // Since January 1
updateLocal();
return local.tm_yday;
}
int dayOfWeek() { // Since Sunday
updateLocal();
return local.tm_wday;
}
int since1900() { // Years since 1900
updateLocal();
return local.tm_year;
}
int month() { // Since January
updateLocal();
return local.tm_mon;
}
int dayOfMonth() {
updateLocal();
return local.tm_mday;
}
int hour() { // Since midnight, 24-hour clock
updateLocal();
return local.tm_hour;
}
int minute() {
updateLocal();
return local.tm_min;
}
int second() {
updateLocal();
return local.tm_sec;
}
};
#endif // CPPTIME_H ///:∼
标准的 C 库函数对时间有多种表示,这些都是Time
类的一部分。然而,没有必要更新它们,所以取而代之的是使用time_t t
作为基本表示,tm local
和 ASCII 字符表示asciiRep
都有标志来指示它们是否已经更新到当前的time_t
。两个private
函数updateLocal()
和updateAscii()
检查标志并有条件地执行更新。
构造器调用mark()
函数(,用户也可以调用该函数来强制对象表示当前时间,这将清除两个标志,以指示本地时间和 ASCII 表示现在无效。ascii()
函数调用updateAscii()
,它将标准 C 库函数asctime()
的结果复制到本地缓冲区,因为asctime()
使用了一个静态数据区,如果在别处调用该函数,该数据区将被覆盖。ascii()
函数返回值是这个本地缓冲区的地址。
所有以daylightSavings()
开头的函数都使用updateLocal()
函数,这导致生成的复合内联相当大。这似乎不值得,尤其是考虑到您可能不会经常调用这些函数。然而,这并不意味着所有的函数都应该是非内联的。如果你让其他函数非内联,至少让updateLocal()
保持内联,这样它的代码会在非内联函数中重复,消除额外的函数调用开销。
清单 9-7 是一个小测试程序。
清单 9-7 。测试一个简单的时间类
//: C09:Cpptime.cpp
// Testing a simple time class
#include "Cpptime.h" // To be INCLUDED from Header FILE above
#include <iostream>
using namespace std;
int main() {
Time start;
for(int i = 1; i < 1000; i++) {
cout << i << ' ';
if(i%10 == 0) cout << endl;
}
Time end;
cout << endl;
cout << "start = " << start.ascii();
cout << "end = " << end.ascii();
cout << "delta = " << end.delta(&start);
} ///:∼
创建一个Time
对象,然后执行一些耗时的活动,然后创建第二个Time
对象来标记结束时间。它们显示开始、结束和经过的时间。
使用内联进行存储和堆栈
有了内联,你现在可以更有效地转换Stash
和Stack
类;参见清单 9-8 。
清单 9-8 。隐藏头文件 (带内联函数)
//: C09:Stash4.h
// Inline functions
#ifndef STASH4_H
#define STASH4_H
#include "../require.h"
class Stash {
int size; // Size of each space
int quantity; // Number of storage spaces
int next; // Next empty space
// Dynamically allocated array of bytes:
unsigned char* storage;
void inflate(int increase);
public:
Stash(int sz) : size(sz), quantity(0),
next(0), storage(0) {}
Stash(int sz, int initQuantity) : size(sz),
quantity(0), next(0), storage(0) {
inflate(initQuantity);
}
Stash::∼Stash() {
if(storage != 0)
delete []storage;
}
int add(void* element);
void* fetch(int index) const {
require(0 <= index, "Stash::fetch (-)index");
if(index >= next)
return 0; // To indicate the end
// Produce pointer to desired element:
return &(storage[index * size]);
}
int count() const { return next; }
};
#endif // STASH4_H ///:∼
小函数作为内联显然很好,但是请注意,两个最大的函数仍然保留为非 - 内联,因为内联它们可能不会带来任何性能提升;参见清单 9-9 。
清单 9-9 。隐藏源代码 cpp 文件 (带内联函数)
//: C09:Stash4.cpp {O}
#include "Stash4.h" // To be INCLUDED from Header FILE above
#include <iostream>
#include <cassert>
using namespace std;
const int increment = 100;
int Stash::add(void* element) {
if(next >= quantity) // Enough space left?
inflate(increment);
// Copy element into storage,
// starting at next empty space:
int startBytes = next * size;
unsigned char* e = (unsigned char*) element;
for(int i = 0; i < size; i++)
storage[startBytes + i] = e[i];
next++;
return(next - 1); // Index number
}
void Stash::inflate(int increase) {
assert(increase >= 0);
if(increase == 0) return;
int newQuantity = quantity + increase;
int newBytes = newQuantity * size;
int oldBytes = quantity * size;
unsigned char* b = new unsigned char[newBytes];
for(int i = 0; i < oldBytes; i++)
b[i] = storage[i]; // Copy old to new
delete [](storage); // Release old storage
storage = b; // Point to new memory
quantity = newQuantity; // Adjust the size
} ///:∼
清单 9-10 中的测试程序再次验证了一切正常。
清单 9-10 。测试 隐藏(使用内联函数
//: C09:Stash4Test.cpp
//{L} Stash4
#include "Stash4.h"
#include "../require.h"
#include <fstream>
#include <iostream>
#include <string>
using namespace std;
int main() {
Stash intStash(sizeof(int));
for(int i = 0; i < 100; i++)
intStash.add(&i);
for(int j = 0; j <intStash.count(); j++)
cout << "intStash.fetch(" << j << ") = "
<< *(int*)intStash.fetch(j)
<< endl;
const int bufsize = 80;
Stash stringStash(sizeof(char) * bufsize, 100);
ifstream in("Stash4Test.cpp");
assure(in, "Stash4Test.cpp");
string line;
while(getline(in, line))
stringStash.add((char*)line.c_str());
int k = 0;
char* cp;
while((cp = (char*)stringStash.fetch(k++))!=0)
cout << "stringStash.fetch(" << k << ") = "
<< cp << endl;
} ///:∼
这与之前使用的测试程序相同,因此输出应该基本相同。
Stack
类更好地利用了内联,正如你在清单 9-11 中看到的。
清单 9-11 。堆栈头文件 (带内联函数)
//: C09:Stack4.h
// With inlines
#ifndef STACK4_H
#define STACK4_H
#include "../require.h"
class Stack {
struct Link {
void* data;
Link* next;
Link(void* dat, Link* nxt):
data(dat), next(nxt) {}
}* head;
public:
Stack() : head(0) {}
∼Stack() {
require(head == 0, "Stack not empty");
}
void push(void* dat) {
head = new Link(dat, head);
}
void* peek() const {
return head ? head->data : 0;
}
void* pop() {
if(head == 0) return 0;
void* result = head->data;
Link* oldHead = head;
head = head->next;
delete oldHead;
return result;
}
};
#endif // STACK4_H ///:∼
注意,在先前版本的Stack
中存在但为空的Link
析构函数已经被移除。在pop()
中,delete oldHead
表达式只是释放了那个Link
所使用的内存(并没有破坏Link
所指向的data
对象)。
大多数内联函数工作得很好,非常明显,特别是对于Link
。甚至pop()
看起来也是合理的,尽管任何时候你有条件或局部变量,内联是否有益还不清楚。在这里,函数足够小,可能不会伤害任何东西。
如果你所有的函数都是内联的,使用这个库就变得非常简单,因为不需要链接,正如你在清单 9-12 中的测试例子中看到的那样(注意这里没有Stack4.cpp
)。
清单 9-12 。测试 堆栈(使用内联函数
//: C09:Stack4Test.cpp
//{T} Stack4Test.cpp
#include "Stack4.h" // To be INCLUDED from Header FILE above
#include "../require.h"
#include <fstream>
#include <iostream>
#include <string>
using namespace std;
int main(int argc, char* argv[]) {
requireArgs(argc, 1); // File name is argument
ifstream in(argv[1]);
assure(in, argv[1]);
Stack textlines;
string line;
// Read file and store lines in the stack:
while(getline(in, line))
textlines.push(new string(line));
// Pop the lines from the stack and print them:
string* s;
while((s = (string*)textlines.pop()) != 0) {
cout << *s << endl;
delete s;
}
} ///:∼
人们有时会编写包含所有内联函数的类,这样整个类都在头文件中。在程序开发过程中,这可能是无害的,尽管有时会导致编译时间变长。一旦程序稍微稳定下来,您可能会想要返回并在适当的地方使函数非内联。
内联和编译器
为了理解什么时候内联是有效的,了解编译器在遇到内联时做什么是有帮助的。与任何函数一样,编译器在其符号表中保存函数类型(即函数原型,包括名称和参数类型 ??,以及函数返回值)。另外,当编译器看到内联的函数类型和函数体解析无误时,函数体的代码也被带入符号表。代码是否以源代码形式、编译后的汇编指令或其他表示形式存储取决于编译器。
当调用内联函数时,编译器首先确保调用能够正确进行。也就是说,所有参数类型必须是函数的参数列表中的精确类型,或者编译器必须能够将类型转换为正确的类型,并且返回值必须是目标表达式中的正确类型(或可转换为正确类型)。当然,这正是编译器对任何函数所做的,并且与预处理器所做的明显不同,因为预处理器不能检查类型或进行转换。
如果所有函数类型信息都符合调用的上下文,那么内联代码将直接替换函数调用,从而消除调用开销,并允许编译器进行进一步优化。同样,如果内联是一个成员函数,对象的地址(this
)被放在适当的位置,这当然是预处理器不能执行的另一个动作。
限制
在两种情况下,编译器不能执行内联。在这些情况下,它简单地通过获取内联定义并为函数创建存储,就像它为非内联函数所做的那样,来恢复函数的普通形式。如果它必须在多个翻译单元中这样做(这通常会导致多重定义错误),链接器被告知忽略多重定义。
如果函数太复杂,编译器无法执行内联。这取决于特定的编译器,但是在大多数编译器放弃的时候,内联可能不会给你带来任何效率。一般来说,任何类型的循环都被认为太复杂而不能扩展为内联,如果你仔细想想,循环在函数内部花费的时间可能比函数调用开销所需的时间要多得多。如果函数只是简单语句的集合,编译器内联它大概不会有什么问题,但是如果语句很多,函数调用的开销会比执行主体的开销小很多。记住,每次你调用一个大的内联函数时,整个函数体都被插入到每次调用的位置,所以你很容易得到代码膨胀而没有任何明显的性能提升。
如果函数的地址是隐式或显式获取的,编译器也不能执行内联。如果编译器必须产生一个地址,那么它将为函数代码分配存储空间并使用产生的地址。然而,在不需要地址的地方,编译器可能仍然会内联代码。
理解内联只是给编译器的一个建议是很重要的;编译器根本不需要内联任何东西。好的编译器会内联小而简单的函数,同时智能地忽略太复杂的内联。这将给你你想要的结果——一个函数调用的真正语义和一个宏的效率。
正向引用
如果你在想象编译器是如何实现内联的,你可能会迷惑自己,以为存在比实际更多的限制。特别是,如果一个内联引用了一个还没有在类中声明的函数(不管这个函数是不是内联的),编译器似乎不能处理它,如清单 9-13 所示。
清单 9-13 。内联评估顺序
//: C09:EvaluationOrder.cpp
class Forward {
int i;
public:
Forward() : i(0) {}
// Call to undeclared function:
int f() const { return g() + 1; }
int g() const { return i; }
};
int main() {
Forward frwd;
frwd.f();
} ///:∼
在f()
中,对g()
进行调用,尽管g()
尚未声明。这是可行的,因为语言定义规定,在类声明的右括号之前,类中的任何内联函数都不应被计算。
当然,如果g()
反过来调用f()
,你会得到一组递归调用,这对编译器来说太复杂了,无法内联。(此外,您必须在f()
或g()
中执行一些测试,以迫使其中一个“触底”,否则递归将是无限的。)
构造器和析构函数中隐藏的活动
构造器和析构函数是两个容易让人误以为内联比实际更有效的地方。构造器和析构函数可能有隐藏的活动,因为类可以包含子对象,必须调用它们的构造器和析构函数。这些子对象可能是成员对象,也可能因为继承而存在(在第十四章中涉及)。作为一个带有成员对象的类的例子,参见清单 9-14 。
清单 9-14 。说明内联中隐藏的活动(对于具有成员对象的类)
//: C09:Hidden.cpp
// Hidden activities in inlines
#include <iostream>
using namespace std;
class Member {
int i, j, k;
public:
Member(int x = 0) : i(x), j(x), k(x) {}
∼Member() { cout << "∼Member" << endl; }
};
classWithMembers {
Member q, r, s; // Have constructors
int i;
public:
WithMembers(int ii) : i(ii) {} // Trivial?
∼WithMembers() {
cout << "∼WithMembers" << endl;
}
};
int main() {
WithMembers wm(1);
} ///:∼
Member
的构造器很简单,可以内联,因为没有什么特别的事情发生——没有继承或成员对象导致额外的隐藏活动。但是在class WithMembers
中,发生的事情比看上去的要多。成员对象q
、r
和s
的构造器和析构函数都是自动调用的,而且那些构造器和析构函数也是内联的,所以与普通成员函数的区别很大。这并不一定意味着你应该总是把构造器和析构函数定义成非内联的;有些情况下是有道理的。此外,当您通过快速编写代码来绘制程序的初始“草图”时,使用内联通常更方便。但是如果你关心效率,这是一个值得一看的地方。
减少混乱
如果你想优化和减少混乱 ,使用inline
关键字。使用这种方法,早先的Rectangle.cpp
例子显示在清单 9-15 中。
清单 9-15 。使用 inline 关键字
//: C09:Noinsitu.cpp
// Removing in situ functions
class Rectangle {
int width, height;
public:
Rectangle(int w = 0, int h = 0);
int getWidth() const;
void setWidth(int w);
int getHeight() const;
void setHeight(int h);
};
inline Rectangle::Rectangle(int w, int h)
: width(w), height(h) {}
inline int Rectangle::getWidth() const {
return width;
}
inline void Rectangle::setWidth(int w) {
width = w;
}
inline int Rectangle::getHeight() const {
return height;
}
inline void Rectangle::setHeight(int h) {
height = h;
}
int main() {
Rectangle r(19, 47);
// Transpose width & height:
int iHeight = r.getHeight();
r.setHeight(r.getWidth());
r.setWidth(iHeight);
} ///:∼
现在,如果您想比较内联函数和非内联函数的效果,您可以简单地删除inline
关键字。(内联函数通常应该放在头文件中,而非内联函数必须放在它们自己的翻译单元中。)如果你想把函数放到文档中,这是一个简单的剪切粘贴操作。
更多预处理功能
前面我说过,你几乎总是想用inline
函数代替预处理宏。例外情况是当你需要在 C 预处理器(也是 C++ 预处理器)中使用三个特殊的特性:字符串化 、字符串连接和标记粘贴。本书前面介绍的字符串化是通过#
指令执行的,它允许您获取一个标识符并将其转换成一个字符数组。当两个相邻的字符数组之间没有标点符号时,就会发生字符串串联 ,在这种情况下,它们被组合在一起。这两个特性在编写调试代码时特别有用。因此,
#define DEBUG(x) cout << #x " = " << x << endl
打印任何变量的值。您还可以获得一个在语句执行时打印出来的跟踪,例如
#define TRACE(s) cerr << #s << endl; s
#s
字符串化输出语句,第二个s
重复语句,如下所示:
for(int i = 0; I < 100; i++)
TRACE(f(i));
因为TRACE()
宏中实际上有两条语句,所以单行for
循环只执行第一条。解决方法是在宏中用逗号代替分号。
令牌粘贴
令牌粘贴 ,用##
指令实现,在你制作代码的时候非常有用。它允许您将两个标识符粘贴在一起,以自动创建一个新的标识符。举个例子,
#define FIELD(a) char* a##_string; int a##_size
class Record {
FIELD(one);
FIELD(two);
FIELD(three);
// ...
};
每次调用FIELD()
宏都会创建一个标识符来保存一个字符数组,另一个标识符保存该数组的长度。不仅更容易阅读,还可以消除编码错误,使维护更容易。
改进的错误检查
到目前为止,require.h
函数一直在使用,没有定义它们(尽管assert()
也被用来在适当的时候帮助检测程序员错误)。现在是时候定义这个头文件了。内联函数在这里很方便,因为它们允许将所有内容放在头文件中,这简化了使用包的过程。您只需要包含头文件,不需要担心链接实现文件。
您应该注意到,异常提供了一种更有效的方法来处理多种错误——尤其是那些您想要恢复的错误——而不仅仅是暂停程序。然而,require.h
处理的条件是那些阻止程序继续运行的条件,比如用户没有提供足够的命令行参数或者文件无法打开。因此,他们调用标准 C 库函数exit()
是可以接受的。
清单 9-16 就是这个头文件(你在第三章中也看到了,因为它被用来构建前几章中的一些例子。留给我自己,这是最合适的地方,因为它利用了内联)。
清单 9-16 。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(intargc, intminArgs,
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 ///:∼
默认值提供合理的消息,必要时可以更改。
您会注意到,没有使用char*
参数,而是使用了const string&
参数。这使得char*
和string
都可以作为这些函数的参数,因此更加有用(您可能希望在自己的编码中遵循这种形式)。
在对requireArgs()
和requireMinArgs()
的定义中,您在命令行上需要的参数数量增加了 1,因为argc
总是将正在执行的程序的名称作为参数 0,因此它的值总是比命令行上的实际参数数量多 1。
注意每个函数中局部using namespace std
声明的使用。这是因为在撰写本文时,一些编译器没有在namespace std
中包含标准的 C 库函数,所以显式限定会导致编译时错误。本地声明允许require.h
使用正确和不正确的库,而不需要为任何包含这个头文件的人开放名称空间std
。
清单 9-17 是一个测试require.h
的简单程序。
清单 9-17 。测试要求. h
//: C09:ErrTest.cpp
//{T} ErrTest.cpp
// Testing require.h
#include "../require.h"
#include <fstream>
using namespace std;
int main(int argc, char* argv[]) {
int i = 1;
require(i, "value must be nonzero");
requireArgs(argc, 1);
requireMinArgs(argc, 1);
ifstream in(argv[1]);
assure(in, argv[1]);
// Use the file name
ifstream nofile("nofile.xxx");
// Fails:
//! assure(nofile);
// The default argument
ofstream out("tmp.txt");
assure(out);
} ///:∼
您可能想更进一步打开文件,给require.h
添加一个宏,比如:
#define IFOPEN(VAR, NAME) \
ifstream VAR(NAME); \
assure(VAR, NAME);
它可以这样使用:
IFOPEN(in, argv[1])
乍一看,这似乎很吸引人,因为这意味着需要输入的内容更少。这不是非常不安全,但这是一条最好避开的路。再次注意,宏看起来像函数,但行为不同;它实际上创建了一个对象(in
),其作用域超出了宏的范围。你可能理解这一点,但是对于新的程序员和代码维护人员来说,这只是他们需要解决的又一个问题。C++ 已经够复杂的了,所以只要有可能,就尽量说服自己不要使用预处理宏。
审查会议
- 能够隐藏一个类的底层实现是非常重要的,因为以后你可能会想要改变这个实现。
- 您将为了效率做出这些改变,或者因为您对问题有了更好的理解,或者因为您想要在实现中使用的一些新类变得可用。
- 任何危及底层实现隐私的事情都会降低语言的灵活性。因此,内联函数非常重要,因为它几乎消除了对预处理器宏的需求以及随之而来的问题。
- 用
inlines
,成员函数可以作为efficient
作为预处理器宏。 inline
函数当然可以是类定义中的overused
。程序员被诱惑这样做,因为这样更容易,所以它会发生。然而,这并不是一个大问题,因为以后,当寻求尺寸缩减时,您可以将函数更改为非inlines
,而不会影响它们的功能。- 开发指南应该是“首先让代码工作,然后优化它。”
- 从这一点开始,我将只提及本章中给出的头文件
require.h
。
十、名称控制
创建名字是编程中的一项基本活动,当一个项目变得很大时,名字的数量很容易变得令人难以招架。
C++ 允许您对名称的创建和可见性、名称的存储位置以及名称的链接进行大量控制。
在人们知道术语“重载”是什么意思之前,C 中的关键字static
就已经重载了,而 C++ 又增加了另一个意思。所有使用static
的潜在概念似乎是“保持其位置的东西”(如静电),无论这是指内存中的物理位置还是文件中的可见性。
在这一章中,你将学习static
如何控制存储和可见性,以及一种通过 C++ 的名称空间特性来控制名称访问的改进方法。您还将了解如何使用用 c 编写和编译的函数。
来自 C 的静态元素
在 C 和 C++ 中,关键字static
有两个基本含义,不幸的是经常会踩到对方的脚趾。
- 在固定地址分配一次;也就是说,每次调用函数时,对象是在一个特殊的静态数据区域中创建的,而不是在堆栈中创建的。这就是静态存储的概念。
- 对于特定的翻译单元是局部的(对于 C++ 中的类范围也是局部的,您将在后面看到)。这里,
static
控制名字的可见性,这样名字在翻译单元或类之外就看不见了。这也描述了链接的概念,它决定了链接器将看到什么名称。
本节将着眼于从 c 语言继承而来的static
的含义。
函数内部的静态变量
当您在函数中创建局部变量时,编译器会在每次调用该函数时通过将堆栈指针下移适当的量来为该变量分配存储空间。如果变量有一个初始化器,那么每次通过序列点时都会执行初始化。
但是,有时您希望在函数调用之间保留一个值。您可以通过创建一个全局变量来实现这一点,但是这样一来,该变量就不在函数的单独控制之下了。C 和 C++ 允许你在函数内部创建一个static
对象;这个对象的存储不在堆栈上,而是在程序的静态数据区。该对象只初始化一次,即第一次调用函数时,然后在函数调用之间保留其值。例如,在清单 10-1 的中,该函数在每次被调用时返回数组中的下一个字符。
清单 10-1 。函数中的静态变量
//: C10:StaticVariablesInfunctions.cpp
#include "../require.h" // To be INCLUDED from Header FILE in *Chapter 9*
#include <iostream>
using namespace std;
char oneChar(const char* charArray = 0) {
static const char* s;
if(charArray) {
s = charArray;
return *s;
}
else
require(s, "un-initialized s");
if(*s == '\0')
return 0;
return *s++;
}
char* a = "abcdefghijklmnopqrstuvwxyz";
int main() {
// oneChar(); // require() fails
oneChar(a); // Initializes s to a
char c;
while((c = oneChar()) != 0)
cout << c << endl;
} ///:∼
static char* s
在调用oneChar()
之间保存它的值,因为它的存储不是函数堆栈框架的一部分,而是在程序的静态存储区。当您用一个char*
参数调用oneChar()
时,s
被赋给该参数,并返回数组的第一个字符。每一次不带参数的对oneChar()
的后续调用都会产生charArray
的默认值 0,这向函数表明您仍在从s
的先前初始化值中提取字符。该函数将继续产生字符,直到它到达字符数组的空终止符,在这一点上,它停止递增指针,以便它不会溢出数组的末尾。
但是如果您调用oneChar()
而没有参数,也没有预先初始化s
的值,会发生什么呢?在s
的定义中,你可以提供一个初始化器,比如
static char* s = 0;
但是如果你没有为一个内置类型的静态变量提供一个初始化器,编译器保证变量会在程序启动时被初始化为零(转换成合适的类型)。所以在oneChar()
中,第一次调用函数时,s
为零。在这种情况下,if(!s)
有条件就会抓住它。
上面对s
的初始化非常简单,但是对静态对象(像所有其他对象一样)的初始化可以是任意的表达式,包括常量和先前声明的变量和函数。
你要知道上面的函数非常容易出现多线程问题;每当你设计包含静态变量的函数时,你应该记住多线程的问题。
函数内部的静态类对象
用户定义类型的静态对象的规则是相同的,包括对象需要一些初始化。但是,赋零只对内置类型有意义;用户定义的类型必须用构造器调用来初始化。因此,如果你在定义静态对象时没有指定构造器参数,那么这个类必须有一个默认的构造器,正如你在清单 10-2 中看到的。
清单 10-2 。函数内部的静态类对象
//: C10:StaticObjectsInFunctions.cpp
#include <iostream>
using namespace std;
class X {
int i;
public:
X(int ii = 0) : i(ii) {} // Default
∼X() { cout << "X::∼X()" << endl; }
};
void f() {
static X x1(47);
static X x2; // Default constructor required
}
int main() {
f();
} ///:∼
f()
中类型为X
的静态对象既可以用构造器参数列表初始化,也可以用默认构造器初始化。这种构造发生在控制第一次通过定义时,而且只有第一次。
静态对象析构函数
当main()
退出或者当标准 C 库函数exit()
被显式调用时,静态对象的析构函数(即所有具有静态存储的对象,而不仅仅是上面例子中的本地静态对象)被调用。在大多数实现中,main()
只是在终止时调用exit()
。这意味着在析构函数中调用exit()
可能是危险的,因为你可能会以无限递归结束。如果你使用标准 C 库函数abort()
退出程序,静态对象析构函数不会被调用。
您可以通过使用标准 C 库函数atexit()
来指定离开main()
(或调用exit()
)时发生的动作。在这种情况下,atexit()
注册的函数可能会在离开main()
之前构造的任何对象的析构函数之前被调用(或者调用exit()
)。
像普通的销毁一样,静态对象的销毁与初始化的顺序相反。但是,只有已构造的对象才会被销毁。幸运的是,C++ 开发工具跟踪初始化顺序和已经构造的对象。全局对象总是在进入main()
之前被构造,当main()
退出时被销毁,但是如果一个包含局部静态对象的函数从来没有被调用,那么这个对象的构造器永远不会被执行,所以析构函数也不会被执行(参见清单 10-3 )。
清单 10-3 。静态对象析构函数
//: C10:StaticDestructors.cpp
// Static object destructors
#include <fstream>
using namespace std;
ofstream out("statdest.out"); // Trace file
classObj {
char c; // Identifier
public:
Obj(char cc) : c(cc) {
out << "Obj::Obj() for " << c << endl;
}
∼Obj() {
out << "Obj::∼Obj() for " << c << endl;
}
};
Obj a('a'); // Global (static storage)
// Constructor & destructor always called
void f() {
static Obj b('b');
}
void g() {
static Obj c('c');
}
int main() {
out << "inside main()" << endl;
f(); // Calls static constructor for b
// g() not called
out << "leaving main()" << endl;
} ///:∼
在Obj
中,char c
作为一个标识符,因此构造器和析构函数可以打印出它们正在处理的对象的信息。Obj a
是一个全局对象,所以在进入main()
之前总是会调用它的构造器,但是只有在调用这些函数时才会调用f()
内的static Obj b
和g()
内的static Obj c
的构造器。
为了演示调用了哪些构造器和析构函数,只调用了f()
。该程序的输出是
Obj::Obj() for a
inside main()
Obj::Obj() for b
leaving main()
Obj::∼Obj() for b
Obj::∼Obj() for a
在进入main()
之前调用a
的构造器,调用b
的构造器只是因为调用了f()
。当main()
退出时,已经被构造的对象的析构函数以与它们的构造相反的顺序被调用。这意味着如果g()
被调用,那么b
和c
的析构函数被调用的顺序取决于是f()
还是g()
先被调用。
注意,跟踪文件ofstream
对象out
也是一个静态对象——因为它是在所有函数之外定义的,所以它位于静态存储区域。重要的是它的定义(相对于extern
声明)出现在文件的开头,在可能使用out
之前。否则,您将在对象被正确初始化之前使用它。
在 C++ 中,全局静态对象的构造器在进入main()
之前被调用,所以你现在有了一个简单且可移植的方法在进入main()
之前执行代码,在退出main()
之后用析构函数执行代码。在 C 语言中,这总是一种尝试,需要你在编译器供应商的汇编语言启动代码中寻找。
控制链接
通常,文件范围内的任何名称(即,没有嵌套在类或函数中的名称)在程序中的所有翻译单元中都是可见的。这通常被称为外部链接 ,因为在链接时,该名称对于翻译单元外部的链接器是可见的。全局变量和普通函数有外部联系。
有时候你会想限制一个名字的可见性。您可能希望在文件范围内有一个变量,以便该文件中的所有函数都可以使用它,但是您不希望该文件之外的函数看到或访问该变量,或者无意中导致名称与文件之外的标识符冲突。
在文件作用域中被显式声明为static
的对象或函数名对于其翻译单元是局部的(在本书中,声明发生在cpp
文件中)。那个名字有内在联系。这意味着您可以在其他翻译单元中使用相同的名称,而不会发生名称冲突。
内部链接的一个优点是名字可以放在头文件中,不用担心链接时会有冲突。通常放在头文件中的名字,比如const
定义和inline
函数,默认为内部链接。(不过,const
在 C++ 中默认只有内部联动;在 C 中,它默认为外部链接。)注意,链接仅指在链接/加载时具有地址的元素;因此,类声明和局部变量没有联系。
困惑
这里有一个例子可以说明static
的两个意思是如何相互交叉的。所有的全局对象都隐式地拥有静态存储类,所以如果你说(在文件范围内),
int a = 0;
然后,a
的存储将在程序的静态数据区,并且在进入main()
之前,a
的初始化将发生一次。此外,a
的可见性在所有翻译单元中都是全局的。在可见性方面,与static
( 只在这个翻译单元中可见)相反的是extern
,它明确声明名称的可见性是跨所有翻译单元的。所以上面的定义相当于说。
extern int a = 0;
但是如果你说,
static int a = 0;
你所做的只是改变了可见性,所以a
有了内部链接。存储类保持不变—无论可见性是static
还是extern
,对象都驻留在静态数据区。
一旦进入局部变量,static
就会停止改变可见性,转而改变存储类。
如果将看似局部变量的内容声明为extern
,这意味着存储存在于其他地方(因此该变量实际上是函数的全局变量)。例如,参见清单 10-4 和清单 10-5 。
清单 10-4 。本地外部
//: C10:LocalExtern.cpp
//{L} LocalExtern2
#include<iostream>
int main() {
extern int i;
std::cout << i;
} ///:∼
清单 10-5 。另一个本地的外来者
//: C10:LocalExtern2.cpp {O}
int i = 5;
///:∼
对于函数名(对于非成员函数),static
和extern
只能改变可见性,所以如果你说
extern void f();
这和未经修饰的声明
void f();
如果你说,
static void f();
这意味着f()
只在这个翻译单元内可见。这有时称为文件静态 。
其他存储类说明符
你会看到常用的static
和extern
。还有另外两种不常出现的存储类说明符。auto
说明符几乎从不使用,因为它告诉编译器这是一个局部变量。auto
是“自动”的缩写,指的是编译器自动为变量分配存储的方式。编译器总能从定义变量的上下文中确定这个事实,所以auto
是多余的。
一个register
变量是一个局部(auto
)变量,伴随着一个提示编译器这个特殊的变量将被大量使用,所以编译器应该尽可能地把它保存在一个寄存器中。因此,它是一个优化辅助工具*。不同的编译器对这个提示有不同的反应;他们可以选择忽略它。如果你取变量的地址,那么register
说明符几乎肯定会被忽略。你应该避免使用register
,因为编译器通常能比你做得更好。
名称空间
尽管名字可以嵌套在类中,但是全局函数、全局变量和类的名字仍然在一个全局名字空间中。static
关键字通过允许你给变量和函数内部链接(也就是说,使它们成为静态文件)来给你一些控制。但是在一个大型项目中,缺乏对全局名称空间的控制会导致问题。为了解决类的这些问题,供应商通常会创建不太可能冲突的长而复杂的名称,但这样一来,您就不得不键入这些名称。(经常用一个typedef
来简化这个。)这不是一个优雅的、受语言支持的解决方案。
您可以使用 C++ 的名称空间特性将全局名称空间细分为更易于管理的部分。与class
、struct
、enum
和union
类似,namespace
关键字将其成员的名字放在一个不同的空间中。虽然其他关键字有额外的目的,但是创建新的名称空间是namespace
的唯一目的。
创建名称空间
命名空间的创建非常类似于class
的创建;参见清单 10-6 。
清单 10-6 。创建名称空间
//: C10:MyLib.cpp
namespace MyLib {
// Declarations
}
int main() {} ///:∼
这将产生一个包含所包含声明的新名称空间。与class
、struct
、union
和enum
有显著差异,但是:
-
命名空间定义只能出现在全局范围内,或者嵌套在另一个命名空间内。
-
命名空间定义的右括号后不需要终止分号。
-
A namespace definition can be “continued” over multiple header files using a syntax that, for a class, would appear to be a redefinition (see Listing 10-7).
清单 10-7 。说明名称空间定义的延续
//: C10:Header1.h #ifndef HEADER1_H #define HEADER1_H namespace MyLib { extern int x; void f(); // ... } #endif // HEADER1_H ///:∼ //: C10:Header2.h #ifndef HEADER2_H #define HEADER2_H #include "Header1.h" // To be INCLUDED from Header FILE above // Add more names to MyLib namespace MyLib { // NOT a redefinition! extern int y; void g(); // ... } #endif // HEADER2_H ///:∼ //: C10:Continuation.cpp #include "Header2.h" // To be INCLUDED from Header FILE above int main() {} ///:∼
-
A namespace name can be aliased to another name, so you don’t have to type an unwieldy name created by a library vendor, as shown in Listing 10-8.
清单 10-8 。说明名称空间定义的延续(在多个头文件上)
//: C10:BobsSuperDuperLibrary.cpp namespace BobsSuperDuperLibrary { class Widget { /* ... */ }; classPoppit { /* ... */ }; // ... } // Too much to type! I'll alias it: namespace Bob = BobsSuperDuperLibrary; int main() {} ///:∼
-
不能像创建类那样创建命名空间的实例。
未命名的 名称空间
每个翻译单元都包含一个未命名的名称空间,你可以在没有标识符的情况下通过说“namespace
来添加它,如清单 10-9 中的所示。
清单 10-9 。未命名的名称空间
//: C10:UnnamedNamespaces.cpp
namespace {
class Arm { /* ... */ };
class Leg { /* ... */ };
class Head { /* ... */ };
class Robot {
Arm arm[4];
Leg leg[16];
Head head[3];
// ...
} xanthan;
int i, j, k;
}
int main() {} ///:∼
该空间中的名称在该翻译单元中自动可用,没有任何限制。保证未命名空间对于每个翻译单元是唯一的。如果您将本地名称放在一个未命名的名称空间中,您不需要通过使它们成为static
来给它们内部链接。
C++ 反对使用文件静态,支持未命名的名称空间。
老友记
你可以通过在一个封闭的类中声明将friend
声明注入到一个名称空间中,如清单 10-10 所示。
清单 10-10 。将朋友注入名称空间
//: C10:FriendInjection.cpp
namespace Me {
class Us {
//...
friend void you();
};
}
int main() {} ///:∼
现在函数you()
是名称空间Me
的成员。
如果在全局命名空间的类中引入友元,则该友元被全局注入。
使用名称空间
您可以通过三种方式在名称空间中引用名称:使用范围解析操作符指定名称,使用using
指令引入名称空间中的所有名称,或者使用using
声明一次引入一个名称。
范围分辨率
名称空间中的任何名称都可以使用作用域解析操作符显式指定,就像引用一个类中的名称一样,如清单 10-11 所示。
清单 10-11 。在名称空间中显式指定名称(使用范围解析运算符)
//: C10:ScopeResolution.cpp
namespace X {
class Y {
static int i;
public:
void f();
};
class Z;
voidfunc();
}
int X::Y::i = 9;
class X::Z {
int u, v, w;
public:
Z(int i);
int g();
};
X::Z::Z(int i) { u = v = w = i; }
int X::Z::g() { return u = v = w = 0; }
void X::func() {
X::Z a(1);
a.g();
}
int main(){} ///:∼
注意,定义X::Y::i
很容易引用嵌套在类X
中的类Y
的数据成员,而不是名称空间X
。
到目前为止,名称空间看起来非常像类。
using 指令
因为在名称空间中键入标识符的完整限定可能会很快变得很繁琐,所以using
关键字允许您一次导入整个名称空间。当与namespace
关键字结合使用时,这被称为使用指令 。using
指令使名字看起来好像属于最近的封闭名称空间范围,因此您可以方便地使用非限定名。考虑一个简单的名称空间,如清单 10-12 所示。
清单 10-12 。演示了一个简单的名称空间
//: C10:NamespaceInt.h
#ifndef NAMESPACEINT_H
#define NAMESPACEINT_H
namespace Int {
enum sign { positive, negative };
class Integer {
int i;
sign s;
public:
Integer(int ii = 0)
: i(ii),
s(i>= 0 ? positive : negative)
{}
sign getSign() const { return s; }
void setSign(sign sgn) { s = sgn; }
// ...
};
}
#endif // NAMESPACEINT_H ///:∼
using
指令的一个用途是将Int
中的所有名字放入另一个名称空间,让这些名字嵌套在名称空间中,如清单 10-13 所示。
清单 10-13 。说明 using 指令
//: C10:NamespaceMath.h
#ifndef NAMESPACEMATH_H
#define NAMESPACEMATH_H
#include "NamespaceInt.h" // To be INCLUDED from Header FILE above
namespace Math {
using namespace Int;
Integer a, b;
Integer divide(Integer, Integer);
// ...
}
#endif // NAMESPACEMATH_H ///:∼
你也可以在一个函数中声明 Int 中的所有名字,但是让这些名字嵌套在函数中,如清单 10-14 所示。
清单 10-14 。说明 using 指令(尽管方式不同)
//: C10:Arithmetic.cpp
#include "NamespaceInt.h"
void arithmetic() {
using namespace Int;
Integer x;
x.setSign(positive);
}
int main(){} ///:∼
如果没有using
指令,命名空间中的所有名称都需要完全限定。
最初,using
指令的一个方面可能看起来有点违反直觉。用一个using
指令引入的名字的可见性是该指令的作用域。但是您可以覆盖来自using
指令的名字,就好像它们已经被全局声明到那个作用域一样!参见清单 10-15 中的示例。
清单 10-15 。说明命名空间覆盖
//: C10:NamespaceOverriding1.cpp
#include "NamespaceMath.h" // To be INCLUDED from Header FILE
// above
int main() {
using namespace Math;
Integer a;
// Hides Math::a;
a.setSign(negative);
// Now scope resolution is necessary
// to select Math::a :
Math::a.setSign(positive);
} ///:∼
假设您有第二个名称空间,其中包含了namespace Math
中的一些名字(参见清单 10-16 )。
清单 10-16 。说明名称空间覆盖(同样,尽管以不同的方式)
//: C10:NamespaceOverriding2.h
#ifndef NAMESPACEOVERRIDING2_H
#define NAMESPACEOVERRIDING2_H
#include "NamespaceInt.h"
namespace Calculation {
using namespace Int;
Integer divide(Integer, Integer);
// ...
}
#endif // NAMESPACEOVERRIDING2_H ///:∼
因为这个名称空间也是用一个using
指令引入的,所以有可能会发生冲突。然而,歧义出现在名称的使用处,而不是在using
指令处,正如你在清单 10-17 中看到的。
清单 10-17 。说明压倒一切的模糊性
//: C10:OverridingAmbiguity.cpp
#include "NamespaceMath.h"
#include "NamespaceOverriding2.h" // To be INCLUDED from Header
// FILE above
void s() {
using namespace Math;
using namespace Calculation;
// Everything's ok until:
//! divide(1, 2); // Ambiguity
}
int main() {} ///:∼
因此,可以编写using
指令来引入多个名称冲突的名称空间,而不会产生歧义。
using 声明
您可以使用 using 声明 将名称一次注入到当前作用域中。与using
指令不同的是,using
声明是当前作用域内的声明,而using
指令将名称视为作用域内的全局声明。这意味着它可以覆盖来自using
指令的名字(见清单 10-18 )。
清单 10-18 。说明 using 声明
//: C10:UsingDeclaration.h
#ifndef USINGDECLARATION_H
#define USINGDECLARATION_H
namespace U {
inline void f() {}
inline void g() {}
}
namespace V {
inline void f() {}
inline void g() {}
}
#endif // USINGDECLARATION_H ///:∼
//: C10:UsingDeclaration1.cpp
#include "UsingDeclaration.h" // To be INCLUDED from Header // FILE above
void h() {
using namespace U; // Using directive
using V::f; // Using declaration
f(); // Calls V::f();
U::f(); // Must fully qualify to call
}
int main() {} ///:∼
using
声明只是给出了标识符的完整名称,但没有类型信息。这意味着如果名称空间包含一组同名的重载函数,using
声明将声明重载集中的所有函数。
您可以将using
声明放在普通声明可以出现的任何地方。除了一点之外,using
声明在所有方面都像普通声明一样工作:因为你没有给出参数列表,所以using
声明有可能导致具有相同参数类型的函数重载(,这在普通重载中是不允许的)。然而,这种模糊性直到使用时才显现出来,而不是在声明时。
一个using
声明也可以出现在一个名称空间中,它和其他任何地方具有相同的效果——这个名称是在空间中声明的(参见清单 10-19 )。
清单 10-19 。阐释命名空间中的 using 声明
//: C10:UsingDeclaration2.cpp
#include "UsingDeclaration.h"
namespace Q {
using U::f;
using V::g;
// ...
}
void m() {
using namespace Q;
f(); // Calls U::f();
g(); // Calls V::g();
}
int main() {} ///:∼
using
声明是一个别名,它允许您在不同的名称空间中声明相同的函数。如果您最终通过导入不同的名称空间来重新声明同一个函数,这是可以的;不会有任何含糊不清或重复。
名称空间的使用
这些规则中的一些乍一看可能有点令人生畏,尤其是如果你觉得你会一直使用它们。然而,一般来说,只要您理解名称空间是如何工作的,您就可以轻松地使用名称空间。要记住的关键点是,当你引入一个全局的using
指令(通过任何作用域之外的using namespace
)时,你已经打开了那个文件的名称空间。这对于一个实现文件(cpp
文件)来说通常没问题,因为using
指令只在该文件编译结束之前有效。也就是说,它不影响任何其他文件,所以您可以一次一个实现文件地调整名称空间的控制。例如,如果您发现一个名字冲突是因为在一个特定的实现文件中有太多的using
指令,这是一件简单的事情,修改这个文件,使它使用显式限定或using
声明来消除冲突,而不修改其他的实现文件。
头文件是一个不同的问题。实际上,您永远不希望在头文件中引入一个全局的using
指令,因为这将意味着包含您的头文件的任何其他文件也将打开名称空间(并且头文件可以包含其他头文件)。
因此,在头文件中,你应该使用显式限定或者限定范围的using
指令和using
声明。这是你将在本书中找到的实践,通过遵循它,你将不会“污染”全局名称空间,并把你自己扔回到 C++ 的前名称空间世界。
C++ 中的静态成员
有时,您需要一个存储空间供一个类的所有对象使用。在 C 语言中,你会使用一个全局变量,但这不是很安全。任何人都可以修改全局数据,其名称可能会与大型项目中的其他相同名称冲突。如果数据可以像全局数据一样存储,但隐藏在一个类中,并与该类明确关联,这将是非常理想的。
这是通过类中的static
数据成员来完成的。对于一个static
数据成员有一个单独的存储,不管您创建了多少个该类的对象。所有对象为该数据成员共享相同的static
存储空间,因此这是它们相互“通信”的一种方式。但是static
数据属于类;它的名字作用于类内部,可以是public
、private
或protected
。
为静态数据成员定义存储
因为不管创建了多少个对象,数据都只有一个存储,所以必须在一个地方定义这个存储。编译器不会为您分配存储空间。如果声明了一个static
数据成员但没有定义,链接器将报告一个错误。
定义必须出现在类之外(不允许内联),并且只允许一个定义。因此,通常将其放在类的实现文件中。语法有时会给人带来麻烦,但它实际上是相当符合逻辑的。例如,如果在类中创建静态数据成员,例如:
class A {
static int i;
public:
//...
};
然后,您必须在定义文件中为该静态数据成员定义存储,如下所示:
int A::i = 1;
如果你要定义一个普通的全局变量,你会说
int i = 1;
但是这里使用范围解析操作符和类名来指定A::i
。
有些人很难接受A::i
就是private
的想法,然而似乎有什么东西在公开地操纵着它。这不是打破了保护机制吗?这是一种完全安全的做法,原因有二。首先,这种初始化唯一合法的地方是在定义中。事实上,如果static
数据是一个带有构造器的对象,你应该调用构造器而不是使用=
操作符。第二,一旦定义完成,最终用户就不能进行第二次定义;链接器将报告一个错误。并且类创建者被迫创建定义,否则代码在测试期间不会链接。这确保了定义只出现一次,并且在类创建者的手中。
静态成员的整个初始化表达式都在类的范围内。例如,参见清单 10-20 。
清单 10-20 。说明静态初始化器的范围
//: C10:Statinit.cpp
// Scope of static initializer
#include <iostream>
using namespace std;
int x = 100;
class WithStatic {
static int x;
static int y;
public:
void print() const {
cout << "WithStatic::x = " << x << endl;
cout << "WithStatic::y = " << y << endl;
}
};
int WithStatic::x = 1;
int WithStatic::y = x + 1;
// WithStatic::x NOT ::x
int main() {
WithStatic ws;
ws.print();
} ///:∼
这里,限定符WithStatic::
将WithStatic
的范围扩展到了整个定义。
静态数组初始化
第八章介绍了static const
变量,它允许你在类体内定义一个常量值。也可以创建static
对象的数组,包括const
和非const
。语法相当一致,正如你在清单 10-21 中看到的。
清单 10-21 。静态数组的语法
//: C10:StaticArray.cpp
// Initializing static arrays in classes
class Values {
// static consts are initialized in-place:
static const int scSize = 100;
static const long scLong = 100;
// Automatic counting works with static arrays.
// Arrays, Non-integral and non-const statics
// must be initialized externally:
static const int scInts[];
static const long scLongs[];
static const float scTable[];
static const char scLetters[];
static int size;
static const float scFloat;
static float table[];
static char letters[];
};
int Values::size = 100;
const float Values::scFloat = 1.1;
const int Values::scInts[] = {
99, 47, 33, 11, 7
};
const long Values::scLongs[] = {
99, 47, 33, 11, 7
};
const float Values::scTable[] = {
1.1, 2.2, 3.3, 4.4
};
const char Values::scLetters[] = {
'a', 'b', 'c', 'd', 'e',
'f', 'g', 'h', 'i', 'j'
};
float Values::table[4] = {
1.1, 2.2, 3.3, 4.4
};
char Values::letters[10] = {
'a', 'b', 'c', 'd', 'e',
'f', 'g', 'h', 'i', 'j'
};
int main() { Values v; } ///:∼
对于整型类型的static const
s,你可以在类内部提供定义,但是对于其他所有类型(包括整型类型的数组,即使它们是static const
)你必须为成员提供一个外部定义。这些定义有内部联系,所以可以放在头文件中。初始化静态数组的语法与任何聚合相同,包括自动计数。
你也可以创建类类型的static const
对象和这些对象的数组。然而,你不能使用整型内置类型的static const
所允许的“内联语法”来初始化它们(参见清单 10-22 )。
清单 10-22 。说明类对象的静态数组
//: C10:StaticObjectArrays.cpp
// Static arrays of class objects
class X {
int i;
public:
X(int ii) : i(ii) {}
};
class Stat {
// This doesn't work, although
// you might want it to:
//! static const X x(100);
// Both const and non-const static class
// objects must be initialized externally:
static X x2;
static X xTable2[];
static const X x3;
static const X xTable3[];
};
X Stat::x2(100);
X Stat::xTable2[] = {
X(1), X(2), X(3), X(4)
};
const X Stat::x3(100);
const X Stat::xTable3[] = {
X(1), X(2), X(3), X(4)
};
int main() { Stat v; } ///:∼
类对象的const
和非const static
数组的初始化必须以相同的方式执行,遵循典型的static
定义语法。
嵌套类和局部类
您可以轻松地将静态数据成员放入嵌套在其他类中的类中。这种成员的定义是一种直观而明显的扩展——您只需使用另一个级别的范围解析。然而,在局部类中不能有static
数据成员(局部类是在函数中定义的类)。例如,参考清单 10-23 中的代码。
清单 10-23 。阐释静态成员和局部类
//: C10:Local.cpp
// Static members & local classes
#include <iostream>
using namespace std;
// Nested class CAN have static data members:
class Outer {
class Inner {
static int i; // OK
};
};
int Outer::Inner::i = 47;
// Local class cannot have static data members:
void f() {
class Local {
public:
//! Static int i; // Error
// (How would you define i?)
} x;
}
int main() { Outer x; f(); } ///:∼
您可以看到局部类中的static
成员的直接问题:如何在文件范围内描述数据成员以定义它?实际上,很少使用局部类。
静态成员函数
你也可以创建static
成员函数,像static
数据成员一样,为整个类工作,而不是为一个类的特定对象工作。不要让一个全局函数存在于并“污染”全局或局部命名空间,而是将该函数放入类中。当你创建一个static
成员函数时,你表达了与一个特定类的关联。
你可以用普通的方式调用一个static
成员函数,用点或箭头,与一个对象相关联。然而,更典型的是使用范围解析操作符单独调用一个static
成员函数,没有任何特定的对象,如清单 10-24 所示。
清单 10-24 。演示了一个简单的静态成员函数
//: C10:SimpleStaticMemberFunction.cpp
class X {
public:
static void f(){};
};
int main() {
X::f();
} ///:∼
当你在一个类中看到static
成员函数时,记住设计者希望这个函数在概念上与类作为一个整体相关联。
一个static
成员函数不能访问普通数据成员,只能访问static
数据成员。它只能调用其他的static
成员函数。正常情况下,调用任何一个成员函数都会悄悄传入当前对象的地址(this
),但是一个static
成员没有this
,这就是它不能访问普通成员的原因。因此,您可以获得全局函数带来的微小速度提升,因为static
成员函数没有传递this
的额外开销。与此同时,您还可以获得在类中使用该函数的好处。
对于数据成员,static
表示一个类的所有对象只存在一个成员数据存储区。这类似于使用static
来定义函数“内部”的对象,这意味着只有一个局部变量的副本用于该函数的所有调用。
清单 10-25 是显示一起使用的static
数据成员和static
成员函数的例子。
清单 10-25 。说明静态数据成员和静态成员函数(组合使用)
//: C10:StaticMemberFunctions.cpp
class X {
int i;
static int j;
public:
X(int ii = 0) : i(ii) {
// Non-static member function can access
// static member function or data:
j = i;
}
intval() const { return i; }
static int incr() {
//! i++; // Error: static member function
// cannot access non-static member data
return ++j;
}
static int f() {
//! val(); // Error: static member function
// cannot access non-static member function
returnincr(); // OK -- calls static
}
};
int X::j = 0;
int main() {
X x;
X* xp = &x;
x.f();
xp->f();
X::f(); // Only works with static members
} ///:∼
因为没有this
指针,static
成员函数既不能访问非static
数据成员,也不能调用非static
成员函数。
注意在main()
中,可以使用通常的点或箭头语法选择一个static
成员,将该函数与一个对象相关联,但也可以不与任何对象相关联(因为一个 static
成员与一个类相关联,而不是一个特定的对象),使用类名和范围解析操作符。
这里有一个有趣的特性:由于static
成员对象的初始化方式,您可以将同一个类的static
数据成员放在该类的“内部”。清单 10-26 是一个例子,通过使构造器私有,只允许一个E
类型的对象存在。您可以访问该对象,但是不能创建任何新的E
对象。
注意这就是所谓的“独生子女”模式!
清单 10-26 。说明“单例”模式
//: C10:Singleton.cpp
// Static member of same type, ensures that
// only one object of this type exists.
// Also referred to as the "singleton" pattern.
#include <iostream>
using namespace std;
class E {
static Ee;
int i;
E(int ii) : i(ii) {}
E(const E&); // Prevent copy-construction
public:
static E* instance() { return &e; }
int val() const { return i; }
};
E E::e(47);
int main() {
//! E x(1); // Error -- can't create an E
// You can access the single instance:
cout << E::instance()->val() << endl;
} ///:∼
e
的初始化发生在类声明完成之后,因此编译器拥有分配存储和调用构造器所需的所有信息。
为了完全防止创建任何其他对象,还添加了其他东西:第二个私有构造器叫做复制构造器 。在这本书的这一点上,你不能知道为什么这是必要的,因为复制构造器直到下一章才会被介绍。然而,作为一个预览,如果你要删除在清单 10-26 中定义的复制构造器,你将能够创建一个E
对象,如下所示:
E e = *Egg::instance();
E e2(*Egg::instance());
这两种方法都使用复制构造器,所以为了防止复制构造器被声明为私有的。
注意没有定义是必要的,因为它从来没有被调用过。
下一章的很大一部分是关于复制构造器的讨论,所以你应该很清楚了。
静态初始化依赖关系
在特定的翻译单元中,静态对象的初始化顺序保证是对象定义在该翻译单元中出现的顺序。销毁的顺序保证与初始化的顺序相反。
然而,不能保证静态对象在翻译单元中的初始化顺序,语言也没有提供指定这种顺序的方法。这可能会导致严重的问题。举一个瞬间灾难的例子(它将暂停原始的操作系统并终止复杂系统的进程),如果一个文件包含
//: C10:Out.cpp {O}
// First file
#include <fstream>
std::ofstream out("out.txt"); ///:∼
另一个文件在它的初始化器中使用了out
对象
//: C10:Oof.cpp
// Second file
//{L} Out
#include <fstream>
Extern std::ofstream out;
classOof {
public:
Oof() { std::out << "ouch"; }
} oof;
int main() {} ///:∼
这个计划可能行得通,也可能行不通。如果编程环境构建程序时,第一个文件在第二个文件之前初始化,那么就不会有问题。然而,如果第二个文件在第一个文件之前被初始化,Oof
的构造器依赖于还没有被构造的out
的存在,这将导致混乱。
这个问题只发生在相互依赖的静态对象初始化器上。翻译单元中的静态数据在该单元中第一次调用函数之前被初始化——但也可能是在main()
之后。如果静态对象在不同的文件中,你不能确定它们的初始化顺序。
*一个更微妙的例子可以在手臂上找到。在全局范围的一个文件中,
extern int y;
int x = y + 1;
在另一个全局范围的文件中
extern int x;
int y = x + 1;
对于所有静态对象,链接加载机制保证在程序员指定的动态初始化发生之前,静态初始化为零。在前面的例子中,fstream out
对象占用的存储空间的清零没有特殊的意义,所以在调用构造器之前,它确实是未定义的。但是,对于内置类型,初始化为零没有意义,如果按照上面显示的顺序初始化文件,y
开始静态初始化为零,因此x
变为一,y
动态初始化为二。但是,如果以相反的顺序初始化文件,x
静态初始化为零,y
动态初始化为一,x
则变为二。
程序员必须意识到这一点,因为他们可以创建一个具有静态初始化依赖关系的程序,并让它在一个平台上工作,但将它移到另一个编译环境中,它会突然神秘地不起作用。
解决问题
处理这个问题有三种方法。
- 别这么做。避免静态初始化依赖是最好的解决方案。
- 如果您必须这样做,请将关键的静态对象定义放在一个文件中,这样您就可以通过将它们按正确的顺序放置来方便地控制它们的初始化。
- 如果您确信在翻译单元中分散静态对象是不可避免的——就像在一个库的情况下,您无法控制使用它的程序员——有两种编程技术可以解决这个问题。
技巧一
这种技术是由杰瑞·施瓦茨在创建 iostream 库时首创的(因为cin
、cout
和cerr
的定义是static
,并且位于一个单独的文件中)。它实际上不如第二种技术,但是它已经存在很长时间了,所以你可能会遇到使用它的代码;因此,理解它的工作原理是很重要的。
这项技术需要在库头文件中添加一个额外的类。这个类负责库的静态对象的动态初始化。清单 10-27 显示了一个简单的例子。
清单 10-27 。说明“技术一”
//: C10:Initializer.h
// Static initialization technique
#ifndef INITIALIZER_H
#define INITIALIZER_H
#include <iostream>
extern int x; // Declarations, not definitions
extern int y;
class Initializer {
static int initCount;
public:
Initializer() {
std::cout << "Initializer()" << std::endl;
// Initialize first time only
if(initCount++ == 0) {
std::cout << "performing initialization"
<< std::endl;
x = 100;
y = 200;
}
}
∼Initializer() {
std::cout << "∼Initializer()" << std::endl;
// Clean up last time only
if(--initCount == 0) {
std::cout << "performing cleanup"
<< std::endl;
// Any necessary cleanup here
}
}
};
// The following creates one object in each
// file where Initializer.h is included, but that
// object is only visible within that file:
static Initializer init;
#endif // INITIALIZER_H ///:∼
x
和y
的声明只声明了这些对象的存在,但是它们没有为这些对象分配存储空间。然而,Initializer init
的定义在包含头文件的每个文件中为该对象分配存储空间。但是因为名字是static
(这次控制的是可视性,而不是存储分配的方式;默认情况下,存储在文件范围内),它只在翻译单元内可见,因此链接器不会抱怨多个定义错误。
清单 10-28 包含了x
、y
和initCount
的定义。
清单 10-28 。说明清单 10-27 中头文件的定义
//: C10:InitializerDefs.cpp {O}
// Definitions for Initializer.h
#include "Initializer.h" // To be INCLUDED from Header FILE
// above
// Static initialization will force
// all these values to zero:
int x;
int y;
int Initializer::initCount;
///:∼
评论当然,在包含头文件的时候,init
的一个文件静态实例也被放在这个文件中。
假设库用户创建了另外两个文件(参见清单 10-29 和 10-30 )。
清单 10-29 。说明静态初始化(针对第一个文件)
//: C10:Initializer.cpp {O}
// Static initialization
#include "Initializer.h"
///:∼
清单 10-30 。说明了更多的静态初始化(对于第二个文件)
//: C10:Initializer2.cpp
//{L} InitializerDefs Initializer
// Static initialization
#include "Initializer.h"
using namespace std;
int main() {
cout << "inside main()" << endl;
cout << "leaving main()" << endl;
} ///:∼
现在先初始化哪个翻译单元已经不重要了。第一次初始化包含Initializer.h
的翻译单元时,initCount
将为零,因此将执行初始化。
注意这很大程度上取决于这样一个事实,即在任何动态初始化发生之前,静态存储区被设置为零。
对于所有剩余的翻译单元,initCount
将是非零的,初始化将被跳过。清理以相反的顺序发生,∼Initializer()
确保它只会发生一次。
这个例子使用内置类型作为全局静态对象。该技术也适用于类,但是这些对象必须由Initializer
类动态初始化。一种方法是创建没有构造器和析构函数的类,而是使用不同的名字初始化和清除成员函数。然而,更常见的方法是拥有指向对象的指针,并使用Initializer()
中的new
来创建它们。
技巧二
在技术一被使用很久之后,有人(我不知道是谁)提出了本节中解释的技术,它比技术一简单和干净得多。花了这么长时间才发现的事实是对 C++ 复杂性的一种赞颂。
这种技术依赖于这样一个事实,即函数内部的静态对象只在第一次调用函数时被初始化。请记住,我们在这里真正要解决的问题不是何时静态对象被初始化(可以单独控制),而是确保初始化以正确的顺序发生。
这种手法非常工整巧妙。对于任何初始化依赖项,都将静态对象放在返回对该对象的引用的函数中。这样,访问静态对象的唯一方法是调用函数,如果该对象需要访问它所依赖的其他静态对象,它必须调用它们的函数。第一次调用函数时,它会强制进行初始化。静态初始化的顺序保证是正确的,是因为代码的设计,而不是因为链接器建立的任意顺序。
举个例子,清单 10-31 和清单 10-32 包含两个相互依赖的类。第一个包含一个仅由构造器初始化的bolo
,因此您可以判断该类的静态实例是否调用了构造器(静态存储区在程序启动时被初始化为零,如果没有调用构造器,它会为bolo
生成一个false
值)。
清单 10-31 。说明第一个依赖类
//: C10:Dependency1.h
#ifndef DEPENDENCY1_H
#define DEPENDENCY1_H
#include <iostream>
class Dependency1 {
bool init;
public:
Dependency1() : init(true) {
std::cout << "Dependency1 construction"
< <std::endl;
}
void print() const {
std::cout << "Dependency1 init: "
<< init << std::endl;
}
};
#endif // DEPENDENCY1_H ///:∼
清单 10-32 。说明第二个依赖类
//: C10:Dependency2.h
#ifndef DEPENDENCY2_H
#define DEPENDENCY2_H
#include "Dependency1.h" // To be INCLUDED from Header FILE
// above
class Dependency2 {
Dependency1 d1;
public:
Dependency2(const Dependency1& dep1): d1(dep1){
std::cout << "Dependency2 construction ";
print();
}
void print() const { d1.print(); }
};
#endif // DEPENDENCY2_H ///:∼
构造器也会在它被调用的时候发出声明,你可以print()
对象的状态来发现它是否已经被初始化。
第二个类是从第一个类的对象初始化的,这将导致依赖关系(清单 10-32 )。
构造器声明自己并打印出d1
对象的状态,这样您就可以看到在调用构造器时它是否已经被初始化了。
为了演示什么会出错,清单 10-33 中的代码首先将静态对象定义放在了错误的顺序中,因为如果链接器碰巧在初始化Dependency1
对象之前初始化了Dependency2
对象,就会出现这种情况。然后颠倒顺序,以显示如果顺序恰好是“正确的”,它是如何正确工作的。最后,演示技术二。
清单 10-33 。说明技术二
//: C10:Technique2.cpp
#include "Dependency2.h" // To be INCLUDED from Header FILE
// above
using namespace std;
// Returns a value so it can be called as
// a global initializer:
int separator() {
cout << "---------------------" << endl;
return 1;
}
// Simulate the dependency problem:
extern Dependency1 dep1;
Dependency2 dep2(dep1);
Dependency1 dep1;
int x1 = separator();
// But if it happens in this order it works OK:
Dependency1 dep1b;
Dependency2 dep2b(dep1b);
int x2 = separator();
// Wrapping static objects in functions succeeds
Dependency1&d1() {
static Dependency1 dep1;
return dep1;
}
Dependency2&d2() {
static Dependency2 dep2(d1());
return dep2;
}
int main() {
Dependency2& dep2 = d2();
} ///:∼
为了提供更具可读性的输出,创建了函数separator()
。诀窍是你不能全局调用一个函数,除非这个函数被用来执行变量的初始化,所以separator()
返回一个空值,用来初始化几个全局变量。
函数d1()
和d2()
包装Dependency1
和Dependency2
对象的静态实例。现在,您可以访问静态对象的唯一方法是调用函数,这将在第一次函数调用时强制静态初始化。这意味着初始化保证是正确的,当你运行程序并查看输出时,你会看到这一点。
下面是如何组织代码来使用这种技术。通常,静态对象会被定义在单独的文件中(因为出于某种原因,您被迫这样做;请记住,在单独的文件中定义静态对象是导致问题的原因),所以应该在单独的文件中定义包装函数。但是它们需要在头文件中声明,参见清单 10-34 和清单 10-35 。
清单 10-34 。说明了第一个头文件
//: C10:Dependency1StatFun.h
#ifndef DEPENDENCY1STATFUN_H
#define DEPENDENCY1STATFUN_H
#include "Dependency1.h"
extern Dependency1& d1();
#endif // DEPENDENCY1STATFUN_H ///:∼
实际上,“extern”对于函数声明来说是多余的。这里是第二个头文件(清单 10-35 )。
清单 10-35 。示出了第二头文件
//: C10:Dependency2StatFun.h
#ifndef DEPENDENCY2STATFUN_H
#define DEPENDENCY2STATFUN_H
#include "Dependency2.h"
extern Dependency2& d2();
#endif // DEPENDENCY2STATFUN_H ///:∼
现在,在之前放置静态对象定义的实现文件中,改为放置包装函数定义,如清单 10-36 和 10-37 所示。
清单 10-36 。说明第一个实现文件
//: C10:Dependency1StatFun.cpp {O}
#include "Dependency1StatFun.h" // To be INCLUDED from Header FILE
// above
Dependency1&d1() {
static Dependency1 dep1;
return dep1;
} ///:∼
据推测,其他代码也可能放在这些文件中。这是另一个文件(清单 10-37 )。
清单 10-37 。示出了第二实现文件
//: C10:Dependency2StatFun.cpp {O}
#include "Dependency1StatFun.h"
#include "Dependency2StatFun.h" // To be INCLUDED from Header FILE
// above
Dependency2&d2() {
static Dependency2 dep2(d1());
return dep2;
} ///:∼
所以现在有两个文件可以以任何顺序链接,如果它们包含普通的静态对象,可以产生任何顺序的初始化。但是因为它们包含包装函数,不存在不正确初始化的威胁(见清单 10-38 )。
清单 10-38 。说明初始化不受链接顺序的影响
//: C10:Technique2b.cpp
//{L} Dependency1StatFun Dependency2StatFun
#include "Dependency2StatFun.h"
int main() { d2(); } ///:∼
当您运行这个程序时,您会看到静态对象Dependency1
的初始化总是发生在静态对象Dependency2
的初始化之前。您还可以看到,这是一种比技术一简单得多的方法。
您可能想将d1()
和d2()
作为内联函数写在它们各自的头文件中,但是这是您绝对不能做的事情。一个内联函数 可以在它出现的每个文件中被复制——这种复制包括静态对象定义。因为内联函数自动默认为内部链接,这将导致跨各种翻译单元的多个静态对象,这肯定会导致问题。因此,您必须确保每个包装函数只有一个定义,这意味着不要将包装函数内联。
替代连杆规格
如果你用 C++ 写一个程序,你想使用 C 库,会发生什么?如果您声明了 C 函数,
float f(int a, char b);
C++ 编译器将把这个名字修饰成类似于_f_int_char
的东西,以支持函数重载(和类型安全链接)。然而,编译你的 C 库的 C 编译器最确定的是而不是修饰了这个名字,所以它的内部名字将是_f
。因此,链接器将不能解析你对f()
的 C++ 调用。
C++ 中提供的转义机制是交替链接规范,它是通过重载extern
关键字在语言中产生的。extern
后面是一个字符串,它指定了声明的链接,后面是声明,比如:
extern "C" float f(int a, char b);
这告诉编译器把 C 链接到f()
,这样编译器就不会修饰名字。该标准支持的唯一两种类型的链接规范是“C”
和“C++,”
,但是编译器供应商可以选择以同样的方式支持其他语言。
如果您有一组具有替代链接的声明,请将它们放在大括号内,如下所示:
extern "C" {
float f(int a, char b);
double d(int a, char b);
}
或者,对于头文件,
extern "C" {
#include "Myheader.h"
}
大多数 C++ 编译器供应商都在头文件中处理可用于 C 和 C++ 的替代链接规范,所以您不必担心。
审查会议
static
关键字可能会引起混淆,因为在某些情况下,它控制存储的位置,而在其他情况下,它控制名称的可见性和链接。- 随着 C++ 名称空间的引入,您有了一个改进的和更加灵活的选择来控制大型项目中名称的扩散。
- 在类中使用 static 是控制程序名称的另一种方式。名字不会与全局名字冲突,可见性和访问保持在程序内部,给你更大的控制权来维护你的代码。**
十一、引用和复制构造器
引用就像被编译器自动取消引用的常量指针。
尽管 Pascal 中也有引用,但 C++ 版本来自 Algol 语言。在 C++ 中,它们对于支持操作符重载的语法是必不可少的(参见第十二章),但它们也是控制参数传入和传出函数的一种便利方式。
本章将首先简要介绍 C 和 C++ 中指针的区别,然后介绍引用。但是这一章的大部分将深入到一个对新 C++ 程序员来说相当困惑的问题:复制构造器,一个特殊的构造器(需要引用),它从一个相同类型的现有对象创建一个新对象。编译器使用复制构造器通过值将对象传入和传出函数。最后,说明了有点模糊的 C++ 指向成员的指针特性。**
*C++ 中的指针
C 和 C++ 中的指针最重要的区别是 C++ 是一种更强类型的语言。这一点与void*
有关。c 不允许你随便把一种类型的指针赋给另一种类型,但是允许你通过void*
来完成这个任务。因此,
bird *b;
rock *r;
void *v;
v = r;
b = v;
因为 C 的这个“特性”允许你像对待其他类型一样安静地对待任何类型,所以它在类型系统中留下了一个大洞。C++ 不允许这样;编译器会给你一个错误消息,如果你真的想把一种类型当作另一种类型,你必须使用强制转换把它明确地告诉编译器和读者。
注 第三章介绍了 C++ 改进的“显式”强制转换语法。
C++ 中的引用
引用(&
)就像一个常量指针,它被自动解引用。它通常用于函数参数列表和函数返回值。但是你也可以做一个独立的参考。例如,见清单 11-1 。
清单 11-1 。说明独立式参考
//: C11:FreeStandingReferences.cpp
#include <iostream>
using namespace std;
// Ordinary free-standing reference:
int y;
int& r = y;
// When a reference is created, it must
// be initialized to a live object.
// However, you can also say:
const int& q = 12; // (1)
// References are tied to someone else's storage:
int x = 0; // (2)
int& a = x; // (3)
int main() {
cout << "x = " << x << ", a = " << a << endl;
a++;
cout << "x = " << x << ", a = " << a << endl;
} ///:∼
在第(1)行,编译器分配一块存储空间,用值 12 初始化它,并将引用绑定到那块存储空间。关键是任何引用都必须绑定到某人的存储块。当你访问一个引用时,你就是在访问那个存储。因此,如果你写像(2)和(3)这样的行,那么增加a
实际上是增加x
,如main( )
所示。再说一次,考虑引用最简单的方法是把它当作一个漂亮的指针。这个“指针”的一个优点是你永远不必担心它是否已经被初始化(编译器强制它)以及如何去引用它(编译器这样做)。
使用引用时有一定的规则 。
- 创建引用时必须对其进行初始化。(指针可以随时初始化。)
- 一旦引用被初始化为一个对象,它就不能被更改为引用另一个对象。(指针可以随时指向另一个对象。)
- 不能有空引用。您必须始终能够假设引用连接到合法的存储区。
函数 中的引用
最常见的引用是函数参数和返回值。当引用被用作函数参数时,对函数内部引用的任何修改都会导致函数外部参数的改变。当然,你可以通过传递一个指针来做同样的事情,但是引用的语法要干净得多。
注如果你愿意,你可以把引用看作仅仅是一种语法上的便利。
如果你从一个函数返回一个引用,你必须像从一个函数返回一个指针一样小心。当函数返回时,无论引用连接到什么都不应该消失;否则你将引用未知的内存。参见清单 11-2 中的示例。
清单 11-2 。演示简单的 C++ 引用
//: C11:Reference.cpp
// Simple C++ references
int *f(int* x) {
(*x)++;
return x; // Safe, x is outside this scope
}
int& g(int& x) {
x++; // Same effect as in f()
return x; // Safe, outside this scope
}
int& h() {
int q;
//! return q; // Error
static int x;
return x; // Safe, x lives outside this scope
}
int main() {
int a = 0;
f(&a); // Ugly (but explicit)
g(a); // Clean (but hidden)
} ///:∼
对f( )
的调用没有使用引用的方便和简洁,但是很明显传递的是一个地址。在对g( )
的调用中,一个地址正在被传递(通过一个引用),但是您没有看到它。
常量引用
只有当参数是非const
对象时,Reference.cpp
中的引用参数才有效。如果是const
对象,函数g( )
不会接受实参,这其实是一件好事,因为函数确实修改了外面的实参。如果你知道这个函数将遵守一个对象的const attribute
,使参数成为一个const
引用将允许这个函数在所有情况下使用。这意味着,对于内置类型,函数不会修改参数,对于用户自定义类型,函数只会调用const
成员函数,不会修改任何public
数据成员。
在函数参数中使用const
引用尤其重要,因为您的函数可能会接收一个临时对象。这可能是作为另一个函数的返回值创建的,或者是由您的函数的用户显式创建的。临时对象总是const
,所以如果你不使用const
引用,编译器不会接受这个参数。清单 11-3 是一个非常简单的例子。
清单 11-3 。说明引用的传递为常量
//: C11:ConstReferenceArguments.cpp
// Passing references as const
void f(int&) {}
void g(const int&) {}
int main() {
//! f(1); // Error
g(1);
} ///:∼
对f(1)
的调用会导致编译时错误,因为编译器必须首先创建一个引用。它通过为一个int
分配存储空间,将其初始化为 1,并产生绑定到引用的地址。存储器必须是的const
,因为改变它是没有意义的——你永远也不可能再得到它。对于所有的临时对象,你必须做出相同的假设:它们是不可访问的。当你改变这些数据时,编译器告诉你是有价值的,因为结果会丢失信息。
指针引用
在 C 中,如果你想修改指针的内容而不是它所指向的内容,你的函数声明应该是这样的
void f(int**);
当你传入指针时,你必须接受它的地址,比如:
int i = 47;
int* ip = &i;
f(&ip);
用 C++ 中的引用 ,语法更干净。函数参数变成了对指针的引用,你不再需要获取指针的地址,因此清单 11-4 中的代码。
清单 11-4 。示出了对指针的引用
//: C11:ReferenceToPointer.cpp
#include <iostream>
using namespace std;
void increment(int*& i) { i++; }
int main() {
int* i = 0;
cout << "i = " << i << endl;
increment(i);
cout << "i = " << i << endl;
} ///:∼
通过运行这个程序,您将向自己证明指针是递增的,不是它所指向的。
论证传递准则
向函数传递参数时,您通常的习惯应该是通过const
引用传递。虽然乍一看,这似乎只是一个效率问题(在设计和汇编程序时,您通常不希望自己关心效率调整),但这涉及到更多的问题:正如您将在本章的剩余部分看到的,需要一个复制构造器来按值传递对象,而这并不总是可用的。
对于这样一个简单的习惯来说,效率的节省是巨大的:通过值传递一个参数需要一个构造器和析构函数调用,但是如果你不打算修改参数,那么通过const
引用传递只需要一个压入堆栈的地址。
事实上,实际上唯一一次传递地址不是更可取的时候是当你要对一个对象做这样的破坏,以至于通过值传递是唯一安全的方法(而不是修改外部对象,这是调用者通常不期望的)。这是下一节的主题。
复制构造器
现在您已经理解了 C++ 中引用的基础,您已经准备好处理语言中更容易混淆的概念之一:复制构造器,通常称为X(X&)
(" X of X ref ")。此构造器对于在函数调用期间通过值控制用户定义类型的传递和返回是必不可少的。事实上,这很重要,如果你自己没有提供复制构造器,编译器会自动合成一个,你会看到的。
通过值传递和返回
为了理解对复制构造器的需求,考虑一下 C 在函数调用期间通过值传递和返回变量的方式。如果声明一个函数并进行函数调用,如:
int f(int x, char c);
int g = f(a, b);
编译器如何知道如何传递和返回那些变量?它就是知道!它必须处理的类型范围很小(char
、int
、float
、double
以及它们的变体),因此这些信息被内置到编译器中。
如果您知道如何用您的编译器生成汇编代码,并确定对f( )
的函数调用所生成的语句,您将得到相当于
push b
push a
call f()
add sp, 4
mov g, register a
这段代码经过了大量的清理,使其具有通用性;根据变量是全局变量(在这种情况下,它们将是_b
和_a
)还是局部变量(编译器将从堆栈指针中索引它们),对于b
和a
的表达式会有所不同。对于g
的表达也是如此。对f( )
调用的外观将取决于您的名称修饰方案,而register a
取决于 CPU 寄存器在您的汇编程序中是如何命名的。然而,代码背后的逻辑将保持不变。
在 C 和 C++ 中,参数首先从右到左推入堆栈,然后进行函数调用。调用代码负责清除堆栈中的参数(这是add sp, 4
的原因)。但是请注意,为了通过值传递参数,编译器只是将副本压入堆栈。它知道它们有多大,并且推动这些参数会产生它们的精确副本。
f( )
的返回值放在寄存器中。同样,编译器知道关于返回值类型的所有信息,因为该类型内置于语言中,所以编译器可以通过将它放在寄存器中来返回它。对于 C # 中的原始数据类型,复制值的位的简单行为等同于复制对象。
传递和返回大型物体
现在让我们考虑用户定义的类型。如果你创建了一个类,你想通过值传递这个类的一个对象,编译器怎么知道该做什么?这不是编译器内置的类型;这是你创造的一种类型。为了研究这个问题,你可以从一个简单的结构开始,这个结构显然太大而不能在寄存器中返回,如清单 11-5 中的所示。
清单 11-5 。说明大型建筑的经过
//: C11:PassingBigStructures.cpp
struct Big {
char buf[100];
int i;
long d;
} B, B2;
Big bigfun(Big b) {
b.i = 100; // Do something to the argument
return b;
}
int main() {
B2 = bigfun(B);
} ///:∼
这里解码汇编输出稍微复杂一点,因为大多数编译器使用“助手”函数,而不是将所有功能内联。在main( )
中,对bigfun( )
的调用如你所料开始:B
的全部内容被压入堆栈。
注意在这里你可能会看到一些编译器用Big
的地址和大小来加载寄存器,然后调用一个帮助器函数将Big
推到堆栈上。
在前面的代码片段中,在进行函数调用之前,只需要将参数推送到堆栈上。然而,在PassingBigStructures.cpp
( 清单 11-5 )中,您将看到一个额外的动作:在进行调用之前,推送B2
的地址,尽管这显然不是一个参数。为了理解这里发生的事情,你需要理解编译器在进行函数调用时的约束。
函数-调用堆栈帧
当编译器为一个函数调用生成代码时,它首先将所有参数推入堆栈,然后它进行调用。在函数内部,生成代码来进一步下移堆栈指针,以便为函数的局部变量提供存储空间。(“下”在这里是相对的;在推送过程中,您的机器可能会递增或递减堆栈指针。)但是在汇编语言调用过程中,CPU 会推送程序代码中函数调用来自的地址,所以汇编语言返回可以使用那个地址返回到调用点。当然,这个地址是神圣的,因为没有它,你的程序将会完全丢失。图 11-1 显示了在函数中调用和分配局部变量存储后堆栈帧的样子。
图 11-1 。栈框架
为函数的其余部分生成的代码希望内存完全按照这种方式布局,这样它就可以小心地从函数参数和局部变量中进行选择,而不会触及返回地址。我将把这个内存块称为函数框架,它是一个函数在函数调用过程中使用的所有东西。
您可能认为尝试在堆栈上返回值是合理的。编译器可以简单地推它,函数可以返回一个偏移量来指示返回值在堆栈中的起始位置。
再入
出现问题是因为 C 和 C++ 中的函数支持中断;也就是说,语言是可重入的。它们还支持递归函数调用。这意味着在程序执行的任何时候,一个中断都可以在不中断程序的情况下发生。当然,编写中断服务程序(ISR) 的人负责保存和恢复 ISR 中使用的所有寄存器,但是如果 ISR 需要使用堆栈中更低的任何内存,这必须是一件安全的事情。
注意你可以把一个 ISR 想象成一个普通的函数,没有参数,void
返回值保存和恢复 CPU 状态。ISR 函数调用是由一些硬件事件触发的,而不是来自程序内部的显式调用。
现在想象一下,如果一个普通的函数试图返回堆栈上的值,会发生什么。你不能接触返回地址之上的栈的任何部分,所以函数必须把值推到返回地址之下。但是当执行汇编语言返回时,堆栈指针必须指向返回地址(或者在它的正下方,这取决于你的计算机),所以就在返回之前,函数必须向上移动堆栈指针,从而清除它的所有局部变量。如果你试图在返回地址下面返回栈上的值,你在那个时刻变得脆弱,因为一个中断可能会出现。ISR 会向下移动堆栈指针来保存它的返回地址和局部变量,并覆盖你的返回值。
为了解决这个问题,调用者可以在调用函数之前负责在堆栈上为返回值分配额外的存储空间。但是,C 不是这样设计的,C++ 必须兼容。您很快就会看到,C++ 编译器使用了一种更有效的方案。
您的下一个想法可能是返回某个全局数据区域中的值,但这也不行。可重入性意味着任何函数都可以是任何其他函数的中断例程,包括您当前所在的同一个函数。因此,如果您将返回值放在一个全局区域中,您可能会返回到同一个函数中,这将覆盖该返回值。同样的逻辑也适用于递归。
返回值的唯一安全的地方是在寄存器中,所以我们又回到了当寄存器不足以容纳返回值时该怎么办的问题上。答案是将返回值的目的地地址推送到堆栈上,作为函数参数之一,让函数将返回信息直接复制到目的地。这不仅解决了所有问题,而且效率更高。这也是为什么在PassingBigStructures.cpp
(清单 11-5)中,编译器在调用main( )
中的bigfun( )
之前推送B2
的地址。如果您查看bigfun( )
的汇编输出,您可以看到它期望这个隐藏的参数,并在函数中执行复制到目标的操作。
下面将讨论与这种可重入函数相关的汇编语言代码。为了从键盘输入字符,你使用一个系统服务来读取一个字符串( syscall 8)。可以使用的特定组assembly language instructions
是
li $v0, 8 # system call code to Read a String
la $a0, buffer # load address of input buffer into $a0
li $a1, 60 # Length of buffer
syscall
这显然是一个以十六进制表示读取值的不可重入函数。
编写可重入代码有两条规则。
- 所有局部变量必须在堆栈上动态分配。
- 全局数据段中不应存在任何读/写数据。
因此,为了使这样的函数可重入,必须从全局数据段中移除字符缓冲区的空间分配,并且必须将代码插入到函数中,以便在堆栈上为字符缓冲区动态分配空间。
假设您想在堆栈上为 32 个字符的输入缓冲区分配空间,在$a0 中初始化一个指针指向这个缓冲区中的第一个字符,然后从键盘读入一个字符串。这可以通过以下汇编语言代码来实现:
addiu $sp, $sp, -32 # Allocate Space on top of stack
move $a0, $sp # Initialize $a0 as a pointer to the buffer
li $a1, 32 # Specify length of buffer
li $v # System call code to Read String
syscall
位复制与初始化
到目前为止,一切顺利!传递和返回大型简单结构有一个可行的过程。但是请注意,您所拥有的只是一种将位从一个地方复制到另一个地方的方法,这对于 C 语言查看变量的原始方式来说当然很好。但是在 C++ 中,对象可以比一片比特复杂得多;它们有意义。这个意义可能不太适合复制它的位。
考虑一个简单的例子:一个类知道在任何时候在有多少属于它的类型的对象(见清单 11-6 )。从第十章,你知道这样做的方法是通过包含一个static
数据成员。
清单 11-6 。说明了一个对其对象进行计数的类(通过包含一个静态数据成员)
//: C11:HowMany.cpp
// A class that counts its objects
#include <fstream>
#include <string>
using namespace std;
ofstream out("HowMany.out");
classHowMany {
static int objectCount;
public:
HowMany() { objectCount++; }
static void print(const string&msg = "") {
if(msg.size() != 0) out << msg << ": ";
out << "objectCount = "
<< objectCount << endl;
}
∼HowMany() {
objectCount--;
print("∼HowMany()");
}
};
int HowMany::objectCount = 0;
// Pass and return BY VALUE:
HowManyf(HowMany x) {
x.print("x argument inside f()");
return x;
}
int main() {
HowMany h;
HowMany::print("after construction of h");
HowMany h2 = f(h);
HowMany::print("after call to f()");
} ///:∼
类HowMany
包含一个static int objectCount
和一个static
成员函数print( )
来报告那个objectCount
的值,以及一个可选的消息参数。每当创建一个对象时,构造器递增计数,析构函数递减计数。
然而,输出并不是您所期望的。
after construction of h: objectCount = 1
x argument inside f(): objectCount = 1
∼HowMany(): objectCount = 0
after call to f(): objectCount = 0
∼HowMany(): objectCount = -1
∼HowMany(): objectCount = -2
创建h
后,对象计数为 1,没问题。但是在调用了f( )
之后,您会期望对象计数为 2,因为h2
现在也在范围内。取而代之的是,计数为 0,这表明出现了可怕的错误。最后的两个析构函数使对象计数变为负数,这是不应该发生的事情,这一事实证实了这一点。
看一下f( )
里面的点,它发生在参数通过值传递之后。这意味着原始对象h
存在于函数框架之外,在函数框架内还有一个额外的对象,它是通过值传递的副本。然而,该参数是使用 C 的原始位复制概念传递的,而 C++ HowMany
类需要真正的初始化来保持其完整性,因此默认的位复制无法产生预期的效果。
当本地对象在对f( )
的调用结束时超出范围时,析构函数被调用,该析构函数递减objectCount
,因此函数外的objectCount
为零。h2
的创建也是使用位复制来执行的,所以这里也不会调用构造器,当h
和h2
超出范围时,它们的析构函数会导致objectCount
的负值。
复制构造
出现这个问题是因为编译器假设如何从现有对象创建新对象。当您通过值传递对象时,您将从现有对象(函数框架外的原始对象)创建一个新对象(函数框架内的传递对象)。当从一个函数返回一个对象时,这通常也是正确的。在表达式中
HowMany h2 = f(h);
先前未构造的对象h2
是从f( )
的返回值创建的,因此新对象也是从现有对象创建的。
编译器的假设是你想使用一个位拷贝来执行这个创建,并且在许多情况下这可能工作得很好,但是在HowMany
中它不能运行,因为初始化的意义超出了简单的拷贝。另一个常见的例子发生在类包含指针的时候:它们指向什么,你应该复制它们还是应该把它们连接到新的内存中?
幸运的是,您可以干预这个过程,防止编译器进行位复制。您可以通过定义自己的函数来做到这一点,只要编译器需要从现有对象创建一个新对象,就可以使用这个函数。从逻辑上来说,你在创建一个新的对象,所以这个函数是一个构造器,从逻辑上来说,这个构造器的单个参数和你正在构造的对象有关。但是那个对象不能通过值传递到构造器中,因为你试图定义处理通过值传递的函数,并且从语法上来说传递指针是没有意义的,因为毕竟你是从一个现有的对象创建新的对象。在这里,引用帮助了我们,所以我们使用源对象的引用。这个函数被称为复制构造器,通常被称为X(X&)
,这是它在一个名为X
的类中的表现。
如果创建复制构造器,编译器在从现有对象创建新对象时不会执行位复制。它总是调用你的复制构造器。所以,如果你不创建复制构造器,编译器会做一些明智的事情,但是你可以选择接管整个过程的控制权。
现在有可能修复HowMany.cpp
中的问题;见清单 11-7 。
清单 11-7 。说明如何解决问题
//: C11:HowMany2.cpp
// The copy-constructor
#include <fstream>
#include <string>
using namespace std;
ofstream out("HowMany2.out");
class HowMany2 {
string name; // Object identifier
static int objectCount;
public:
HowMany2(const string &id = "") : name(id) {
++objectCount;
print("HowMany2()");
}
∼HowMany2() {
--objectCount;
print("∼HowMany2()");
}
// The copy-constructor:
HowMany2(const HowMany2 &h) : name(h.name) {
name += " copy";
++objectCount;
print("HowMany2(const HowMany2&)");
}
void print(const string &msg = "") const {
if(msg.size() != 0)
out << msg << endl;
out << '\t' << name << ": "
<< "objectCount = "
<< objectCount << endl;
}
};
int HowMany2::objectCount = 0;
// Pass and return BY VALUE:
HowMany2 f(HowMany2 x) {
x.print("x argument inside f()");
out << "Returning from f()" << endl;
return x;
}
int main() {
HowMany2 h("h");
out << "Entering f()" << endl;
HowMany2 h2 = f(h);
h2.print("h2 after call to f()");
out << "Call f(), no return value" << endl;
f(h);
out << "After call to f()" << endl;
} ///:∼
这里有一些新的变化,所以你可以更好地了解正在发生的事情。首先,当打印关于对象的信息时,stringname
作为对象标识符。在构造器中,您可以放置一个标识符字符串(通常是对象的名称),使用string
构造器将其复制到name
。默认的= ""
创建一个空的string
。构造器像以前一样递增**objectCount
,析构函数递减。**
接下来是复制构造器,HowMany2(const HowMany2&)
。复制构造器只能从现有对象创建新对象,所以现有对象的名称被复制到name
,后面跟着单词“copy ”,这样您就可以知道它是从哪里来的。如果你仔细观察,你会发现构造器初始化列表*中的调用name(h.name)
实际上是在调用string
复制构造器。
在复制构造器内部,对象计数就像在普通构造器内部一样递增。这意味着当通过值传递和返回时,您现在将获得一个准确的对象计数。
对print( )
函数进行了修改,以打印出消息、对象标识符和对象计数。它现在必须访问特定对象的name
数据,所以它不再是一个static
成员函数。
在main( )
内部,可以看到已经添加了对f( )
的第二次调用。然而,这个调用使用了常见的忽略返回值的 C 方法。但是现在您知道了值是如何返回的(也就是说,函数中的代码处理返回过程,将结果放入一个目的地,该目的地的地址作为隐藏参数传递),您可能想知道当返回值被忽略时会发生什么。程序的输出会对此有所启发。
在显示输出之前,清单 11-8 是一个小程序,它使用iostream
给任何文件添加行号。
清单 11-8 。说明如何向任何文件添加行号(使用 iostream)
//: C11:Linenum.cpp
//{T} Linenum.cpp
// Add line numbers
#include "../require.h" // To be INCLUDED from Header FILE in *Chapter 9*
#include <vector>
#include <string>
#include <fstream>
#include <iostream>
#include <cmath>
using namespace std;
int main(int argc, char* argv[]) {
requireArgs(argc, 1, "Usage: linenum file\n"
"Adds line numbers to file");
ifstream in(argv[1]);
assure(in, argv[1]);
string line;
vector<string> lines;
while(getline(in, line)) // Read in entire file
lines.push_back(line);
if(lines.size() == 0) return 0;
int num = 0;
// Number of lines in file determines width:
const int width =
int(log10((double)lines.size())) + 1;
for(int i = 0; i < lines.size(); i++) {
cout.setf(ios::right, ios::adjustfield);
cout.width(width);
cout << ++num << ") " << lines[i] << endl;
}
} ///:∼
使用您在本书前面看到的相同代码将整个文件读入一个vector<string>
。当打印行号时,您希望所有的行都相互对齐,这需要调整文件中的行数,以便行号允许的宽度一致。使用vector::size( )
可以很容易的确定行数,但是你真正需要知道的是是否超过 10 行,100 行,1000 行等等。如果你取文件中行数的对数,以 10 为底,将其截成一个int
,并在值上加 1,你会发现你的行数的最大宽度。
您会注意到在for
循环中有几个奇怪的调用:setf( )
和width( )
。这些是 i ostream
调用,在这种情况下,允许您控制输出的调整和宽度。然而,每次输出一行时都必须调用它们,这就是为什么它们在for
循环中的原因。
当Linenum.cpp
应用于HowMany2.out
时,结果为
1) HowMany2()
2) h: objectCount = 1
3) Entering f()
4) HowMany2(const HowMany2&)
5) h copy: objectCount = 2
6) x argument inside f()
7) h copy: objectCount = 2
8) Returning from f()
9) HowMany2(const HowMany2&)
10) h copy copy: objectCount = 3
11) ∼HowMany2()
12) h copy: objectCount = 2
13) h2 after call to f()
14) h copy copy: objectCount = 2
15) Call f(), no return value
16) HowMany2(const HowMany2&)
17) h copy: objectCount = 3
18) x argument inside f()
19) h copy: objectCount = 3
20) Returning from f()
21) HowMany2(const HowMany2&)
22) h copy copy: objectCount = 4
23) ∼HowMany2()
24) h copy: objectCount = 3
25) ∼HowMany2()
26) h copy copy: objectCount = 2
27) After call to f()
28) ∼HowMany2()
29) h copy copy: objectCount = 1
30) ∼HowMany2()
31) h: objectCount = 0
正如您所料,首先发生的是为h
调用普通的构造器,这将对象计数增加到 1。但是,当输入f( )
时,编译器会悄悄地调用复制构造器来执行传值操作。创建了一个新对象,它是f( )
的函数框架内h
(因此得名h copy
)的副本,因此对象计数变为 2,这是由复制构造器提供的。
第八行表示从f( )
返回的开始。但是在局部变量h copy
可以被销毁之前(它在函数的最后超出了作用域),它必须被复制到返回值中,而返回值恰好是h2
。一个先前未构造的对象(h2
)是从一个现有的对象(f( )
中的局部变量)创建的,所以当然在第九行再次使用了复制构造器。现在名称变成了h2
标识符的h copy copy
,因为它是从f( )
中的本地对象拷贝而来的。在对象返回之后,但在函数结束之前,对象计数暂时变为 3,但随后本地对象hcopy
被销毁。在第 13 行对f( )
的调用完成后,只有两个对象,h
和h2
,您可以看到h2
确实以h copy copy
结束。
临时对象
第 15 行开始调用f(h)
,这次忽略返回值。您可以在第 16 行看到,复制构造器像前面一样被调用来传递参数。和以前一样,第 21 行显示了为返回值调用复制构造器。但是复制构造器必须有一个地址作为它的目的地(一个this
指针)。这个地址是哪里来的?
事实证明,编译器可以在需要的时候创建一个临时对象来正确地计算表达式。在这种情况下,它会创建一个您甚至看不到的值,作为被忽略的返回值f( )
的目的地。这个临时对象的生命周期越短越好,这样景观就不会被那些等待被破坏和占用宝贵资源的临时对象弄得乱七八糟。在某些情况下,临时对象可能会立即被传递给另一个函数,但是在这种情况下,在函数调用之后就不需要它了,所以一旦函数调用通过调用本地对象的析构函数而结束(第 23 和 24 行),临时对象就会被销毁(第 25 和 26 行)。
最后,在第 28-31 行,h2
对象被销毁,随后是h
,对象计数正确地回到零。
默认复制构造器
因为复制构造器通过值来实现传递和返回,所以在简单结构的情况下,编译器为您创建一个复制构造器是很重要的——实际上与它在 c 中所做的一样。但是,到目前为止,您所看到的都是默认的原始行为:位复制。
当涉及到更复杂的类型时,如果你不创建一个复制构造器,C++ 编译器仍然会自动创建一个。然而,同样,位复制没有意义,因为它不一定实现正确的含义。
下面的例子展示了编译器采用的更智能的方法。假设您创建了一个由几个现有类的对象组成的新类。这被恰当地称为组合,这是从现有类创建新类的方法之一。现在假设一个天真的用户试图通过这种方式创建一个新类来快速解决问题。你不知道复制构造器,所以你没有创建一个。清单 11-9 展示了编译器在为你的新类创建默认复制构造器时做了什么。
清单 11-9 。说明默认复制构造器的创建
//: C11:DefaultCopyConstructor.cpp
// Automatic creation of the copy-constructor
#include <iostream>
#include <string>
using namespace std;
class WithCC { // With copy-constructor
public:
// Explicit default constructor required:
WithCC() {}
WithCC(const WithCC&) {
cout << "WithCC(WithCC&)" << endl;
}
};
classWoCC { // Without copy-constructor
string id;
public:
WoCC(const string &ident = "") : id(ident) {}
void print(const string &msg = "") const {
if(msg.size() != 0) cout << msg << ": ";
cout << id << endl;
}
};
class Composite {
WithCC withcc; // Embedded objects
WoCC wocc;
public:
Composite() : wocc("Composite()") {}
void print(const string &msg = "") const {
wocc.print(msg);
}
};
int main() {
Composite c;
c.print("Contents of c");
cout << "Calling Composite copy-constructor"
<< endl;
Composite c2 = c; // Calls copy-constructor
c2.print("Contents of c2");
} ///:∼
类WithCC
包含一个复制构造器,它简单地声明它已经被调用,这带来了一个有趣的问题。在类Composite
中,使用默认的构造器创建了一个WithCC
对象。如果WithCC
中根本没有构造器,编译器会自动创建一个默认的构造器,在这种情况下它什么也不做。然而,如果你添加了一个复制构造器,你已经告诉编译器你将处理构造器的创建,所以它不再为你创建一个默认的构造器,并且会报错,除非你像对WithCC
那样显式地创建一个默认的构造器。
类WoCC
没有复制构造器,但是它的构造器会在内部string
中存储一条消息,这条消息可以使用print( )
打印出来。该构造器在Composite
的构造器初始化列表中被显式调用(在第八章中有简要介绍,在第十四章中有完整介绍)。这样做的原因稍后会变得明显。
类Composite
有WithCC
和WoCC
的成员对象,没有明确定义的复制构造器
注意嵌入对象wocc
在构造器-初始化器列表中初始化,因为它必须是。
然而,在main( )
中,使用定义中的复制构造器创建一个对象:
Composite c2 = c;
Composite
的复制构造器是由编译器自动创建的,程序的输出揭示了它的创建方式。
Contents of c: Composite()
Calling Composite copy-constructor
WithCC(WithCC&)
Contents of c2: Composite()
为了给使用复合(和继承,在第十四章中介绍)的类创建一个复制构造器,编译器递归调用所有成员对象和基类的复制构造器。也就是说,如果成员对象也包含另一个对象,它的复制构造器也被调用。所以在这种情况下,编译器调用WithCC
的复制构造器。输出显示这个构造器被调用。因为WoCC
没有复制构造器,编译器为它创建了一个只执行位复制的构造器,并在Composite
复制构造器中调用它。对main()
中的Composite::print( )
的调用表明,这是因为c2.wocc
的内容与c.wocc
的内容相同。编译器合成复制构造器的过程被称为基于成员的初始化 。
最好是创建自己的复制构造器,而不是让编译器替你做。这保证了它将在你的控制之下。
复制构造的替代方案
在这一点上,您可能会头晕,您可能会想,在不了解复制构造器的情况下,您怎么可能编写出一个工作类。但是记住:只有当你打算通过值传递你的类的一个对象时,你才需要一个复制构造器。如果这永远不会发生,你就不需要复制构造器。
防止传值
“但是,”你说,“如果我不创建一个复制构造器,编译器会为我创建一个。那么我怎么知道一个对象永远不会被传值呢?”
有一个简单的技术可以防止传值:声明一个private
复制构造器。你甚至不需要创建一个定义,除非你的一个成员函数或者一个friend
函数需要执行一个传值操作。如果用户试图通过值传递或返回对象,编译器会产生一个错误消息,因为复制构造器是private
。它不能再创建默认的复制构造器,因为您已经明确声明您将接管该任务。清单 11-10 就是一个例子。
清单 11-10 。说明防止复制构造
//: C11:NoCopyConstruction.cpp
// Preventing copy-construction
Class NoCC {
int i;
NoCC(const NoCC&); // No definition
public:
NoCC(int ii = 0) : i(ii) {}
};
void f(NoCC);
int main() {
NoCC n;
//! f(n); // Error: copy-constructor called
//! NoCC n2 = n; // Error: c-c called
//! NoCCn3(n); // Error: c-c called
} ///:∼
注意使用了更一般的形式
NoCC(const NoCC&);
使用const
。
修改外部对象的功能
引用语法比指针语法更好用,但是它混淆了读者的意思。例如,在 iostreams 库中,get( )
函数的一个重载版本将一个char&
作为参数,该函数的全部目的是通过插入get( )
的结果来修改其参数。但是,当您使用这个函数读取代码时,您不会立即发现外部对象被修改了:
char c;
cin.get(c);
相反,这个函数调用看起来像一个传值函数,这表明外部对象是被而不是修改的。
因此,从代码维护的角度来看,在传递要修改的参数的地址时,使用指针可能更安全。如果你总是将地址作为const
引用传递,除了当你打算通过地址修改外部对象时,你通过非const
指针传递,那么你的代码对读者来说更容易理解。
指向成员的指针
指针是保存某个位置地址的变量。您可以更改指针在运行时选择的内容,指针的目标可以是数据或函数。C++ 指向成员的指针遵循同样的概念,除了它选择的是类内的一个位置。这里的困境是,一个指针需要一个地址,但是类内部没有“地址”;选择一个类的成员意味着偏移到该类中。只有将偏移量与特定对象的起始地址结合起来,才能产生实际的地址。指向成员的指针的语法要求您在解引用指向成员的指针的同时选择一个对象。
为了理解这个语法,考虑一个简单的结构,这个结构有一个指针sp
和一个对象so
。您可以使用清单 11-11 中所示的语法选择成员。
清单 11-11 。说明在简单结构中选择成员的语法
//: C11:SimpleStructure.cpp
struct Simple { int a; };
int main() {
Simple so, *sp = &so;
sp->a;
so.a;
} ///:∼
现在假设你有一个指向整数的普通指针,ip
。要访问ip
所指向的内容,您可以用一个‘*
’取消对指针的引用,如下所示:
*ip = 4;
最后,考虑一下,如果你有一个指针恰好指向一个类对象内部的某个东西,即使它实际上代表了一个对象的偏移量,会发生什么。要访问它所指向的内容,必须用*
取消对它的引用。但是它是一个对象的偏移量,所以你也必须引用那个特定的对象。因此,*
与对象解引用相结合。所以新的语法变成了指向对象的指针的–>*
,对象或引用的.*
,就像这样:
objectPointer->*pointerToMember = 47;
object.*pointerToMember = 47;
现在,定义pointerToMember
的语法是什么?像任何指针一样,你必须说出它所指向的类型,并且在定义中使用了一个*
。唯一的区别是你必须说明这个指向成员的指针是和什么类的对象一起使用的。当然,这是通过类名和范围解析操作符来实现的。因此,
int ObjectClass::*pointerToMember;
定义一个名为pointerToMember
的指向成员变量的指针,该变量指向ObjectClass
中的任何一个int
。您也可以在定义成员指针时(或在任何其他时候)初始化它,如:
int ObjectClass::*pointerToMember = &ObjectClass::a;
实际上没有ObjectClass::a
的“地址”,因为你只是引用这个类,而不是这个类的一个对象。因此,&ObjectClass::a
只能用作指向成员的指针语法。
清单 11-12 显示了如何创建和使用指向成员的指针。
清单 11-12 。说明数据成员的指向成员的语法(也演示了指向成员的指针的创建&用法)
//: C11:PointerToMemberData.cpp
#include <iostream>
using namespace std;
class Data {
public:
int a, b, c;
void print() const {
cout << "a = " << a << ", b = " << b
<< ", c = " << c << endl;
}
};
int main() {
Data d, *dp = &d;
int Data::*pmInt = &Data::a;
dp->*pmInt = 47;
pmInt = &Data::b;
d.*pmInt = 48;
pmInt = &Data::c;
dp->*pmInt = 49;
dp->print();
} ///:∼
显然,除了特殊情况(这正是它们为设计的),这些都太难用了。
此外,指向成员的指针非常有限:它们只能被分配到类中的特定位置。例如,你不能像普通指针那样递增或比较它们。
功能
类似的练习产生了成员函数的指向成员的语法(见清单 11-13 )。指向一个函数的指针(在第三章的结尾介绍)是这样定义的:
int (*fp)(float);
(*fp)
周围的括号是强制编译器正确评估定义所必需的。如果没有它们,这个函数似乎会返回一个int*
。
在定义和使用指向成员函数的指针时,括号也起着重要的作用。如果在一个类中有一个函数,那么可以通过在普通的函数指针定义中插入类名和作用域解析操作符来定义指向该成员函数的指针。
清单 11-13 。阐释成员函数的成员指针语法
//: C11:PmemFunDefinition.cpp
class Simple2 {
public:
int f(float) const { return 1; }
};
int (Simple2::*fp)(float) const;
int (Simple2::*fp2)(float) const = &Simple2::f;
int main() {
fp = &Simple2::f;
} ///:∼
在fp2
的定义中,你可以看到一个指向成员函数的指针也可以在它被创建时初始化,或者在其他任何时候初始化。与非成员函数不同,在获取成员函数的地址时,&
是而不是可选的。但是,您可以给出不带参数列表的函数标识符,因为重载决策可以由指向成员的指针的类型来确定。
一个例子
指针的价值在于你可以在运行时改变它所指向的内容,这为你的编程提供了重要的灵活性,因为通过指针你可以在运行时选择或改变行为。指向成员的指针也不例外;它允许您在运行时选择成员。通常,你的类只有公开可见的成员函数(数据成员通常被认为是底层实现的一部分),所以清单 11-14 在运行时选择成员函数。
清单 11-14 。说明运行时成员函数的选择
//: C11:PointerToMemberFunction.cpp
#include <iostream>
using namespace std;
class Widget {
public:
void f(int) const { cout << "Widget::f()\n"; }
void g(int) const { cout << "Widget::g()\n"; }
void h(int) const { cout << "Widget::h()\n"; }
void i(int) const { cout << "Widget::i()\n"; }
};
int main() {
Widget w;
Widget* wp = &w;
void (Widget::*pmem)(int) const = &Widget::h;
(w.*pmem)(1);
(wp->*pmem)(2);
} ///:∼
当然,期望普通用户创建如此复杂的表达式并不是特别合理。如果用户必须直接操作指向成员的指针,那么typedef
是合适的。要真正清理这些东西,您可以使用指向成员的指针作为内部实现机制的一部分。清单 11-15 是对清单 11-14 的修改,在类中使用了一个指向成员的指针。用户需要做的就是输入一个数字来选择一个功能。
清单 11-15 。说明在类中使用指向成员的指针
//: C11:PointerToMemberFunction2.cpp
#include <iostream>
using namespace std;
class Widget {
void f(int) const { cout<< "Widget::f()\n"; }
void g(int) const { cout<< "Widget::g()\n"; }
void h(int) const { cout<< "Widget::h()\n"; }
void i(int) const { cout<< "Widget::i()\n"; }
enum { cnt = 4 };
void (Widget::*fptr[cnt])(int) const;
public:
Widget() {
fptr[0] = &Widget::f; // Full spec required
fptr[1] = &Widget::g;
fptr[2] = &Widget::h;
fptr[3] = &Widget::i;
}
void select(int i, int j) {
if(i < 0 || i >= cnt) return;
(this->*fptr[i])(j);
}
int count() { return cnt; }
};
int main() {
Widget w;
for(int i = 0; i < w.count(); i++)
w.select(i, 47);
} ///:∼
在类接口和main( )
中,你可以看到整个实现,包括函数,都被隐藏起来了。代码甚至必须要求函数的count( )
。这样,类实现者可以改变底层实现中的函数数量,而不会影响使用该类的代码。
构造器中指向成员的指针的初始化可能看起来过分指定了。难道你不应该说
fptr[1] = &g;
因为名字g
出现在成员函数中,自动在类的作用域内?问题是这不符合指向成员的指针语法,这是每个人,尤其是编译器,都需要知道发生了什么的语法。类似地,当指向成员的指针被解引用时,看起来就像
(this->*fptr[i])(j);
也是超规定的;this
看起来多余。同样,该语法要求在取消引用对象时,指向成员的指针总是绑定到对象。
审查会议
- C++ 中的指针和 C 中的指针几乎一模一样,这很好。否则,很多 C 代码在 C++ 下都无法正常编译。您将产生的唯一编译时错误发生在危险的赋值中。如果这些确实是我们想要的,那么编译时错误可以通过一个简单的(并且显式的!)演员阵容。
- C++ 还增加了来自 Algol 和 Pascal 的引用,就像一个常量指针,被编译器自动解引用。引用保存一个地址,但是你把它当作一个对象。引用对于使用操作符重载(下一章的主题)的简洁语法是必不可少的,但是它们也为普通函数传递和返回对象增加了语法上的便利。
- 复制构造器引用了一个与它的参数类型相同的现有对象,它用于从一个现有对象创建一个新对象。当你通过值传递或返回一个对象时,编译器自动调用
copy-constructor
。虽然编译器会自动为您创建一个copy-constructor
,但是如果您认为您的类需要它,您应该自己定义它以确保正确的行为发生。如果你不想让对象通过值传递或返回,你应该创建一个私有的copy-constructor
。 - 指向成员的指针与普通指针具有相同的功能:您可以在运行时选择特定的存储区域(数据或函数)。指向成员的指针只是碰巧使用类成员,而不是全局数据或函数。您获得了编程灵活性,允许您在运行时改变行为。**
十二、运算符重载
操作符重载只是“语法糖”,这意味着它只是你进行函数调用的另一种方式。
不同之处在于,这个函数的参数没有出现在括号内,而是出现在您一直认为是不可变操作符的字符周围或旁边。
运算符的使用和普通的函数调用有两个不同之处。语法不同;一个操作符经常被放在参数之间,有时放在参数之后。第二个区别是编译器决定调用哪个“函数”。例如,如果您将运算符+与浮点参数一起使用,编译器会“调用”该函数来执行浮点加法(“调用”通常是插入内联代码的动作,或者浮点处理器指令)。如果将 operator +与浮点数和整数一起使用,编译器会“调用”一个特殊函数将 int 转换为 float,然后“调用”浮点加法代码。
但是在 C++ 中,可以定义新的操作符来处理类。这个定义就像一个普通的函数定义,除了函数名由关键字 operator 后跟运算符组成。这是唯一的区别,它变成了一个像其他函数一样的函数,编译器在看到合适的模式时会调用它。
警告和保证
操作符过载很容易让人变得过于热情。起初,这是一个有趣的玩具。但是请记住它的 only 语法糖,调用函数的另一种方式。从这个角度来看,你没有理由重载一个操作符,除非它能让涉及你的类的代码更容易编写,尤其是更容易被阅读。(记住,读代码比写代码多得多。)如果不是这样,那就不用麻烦了。
对运算符重载的另一个常见反应是恐慌;突然,C 运算符不再有熟悉的含义了。"一切都变了,我所有的 C 代码将做不同的事情!"这不是真的。在只包含内置数据类型的表达式中使用的所有运算符都不能更改。你永远不能重载操作符
1 << 4;
行为不同,或者
1.414 << 2;
有意义。只有包含用户定义类型的表达式才能有重载运算符。
语法
定义一个重载的操作符就像定义一个函数,但是这个函数的名字是operator@
,其中@
代表被重载的操作符。重载运算符的参数列表中的参数数量取决于两个因素:
- 无论是一元运算符(一个参数)还是二元运算符(两个参数)。
- 运算符是定义为全局函数(一元有一个参数,二元有两个参数)还是成员函数(一元有零个参数,二元有一个参数——对象成为左边的参数)。
清单 12-1 包含了一个小类,显示了操作符重载的语法。
清单 12-1 。阐释运算符重载的语法
//: C12:OperatorOverloadingSyntax.cpp
#include <iostream>
using namespace std;
class Integer {
int i;
public:
Integer(int ii) : i(ii) {}
const Integer
operator+(const Integer& rv) const {
cout << "operator+" << endl;
return Integer(i + rv.i);
}
Integer&
operator+=(const Integer& rv) {
cout << "operator+=" << endl;
i += rv.i;
return *this;
}
};
int main() {
cout << "built-in types:" << endl;
int i = 1, j = 2, k = 3;
k += i + j;
cout << "user-defined types:" << endl;
Integer ii(1), jj(2), kk(3);
kk += ii + jj;
} ///:∼
这两个重载操作符被定义为内联成员函数,它们在被调用时进行声明。对于二元运算符,单个参数出现在运算符的右侧。一元运算符在定义为成员函数时没有参数。对运算符左侧的对象调用成员函数。
对于非条件操作符(条件操作符通常返回一个布尔值),如果两个参数是相同的类型,那么您几乎总是希望返回一个与您正在操作的类型相同的对象或引用。
注意如果它们不是同一个类型,那么它应该产生什么样的解释就由你决定了。
通过这种方式,可以构建复杂的表达式,如:
kk += ii + jj;
operator+
产生一个新的Integer
(临时的),用作operator+=
的rv
参数。一旦不再需要这个临时文件,它就会被销毁。
过载运算符
尽管您可以重载 C # 中几乎所有可用的运算符,但运算符重载的使用相当有限。特别是不能组合 C 中目前没有意义的运算符(比如**
表示取幂),不能改变运算符的求值优先级,也不能改变一个运算符所需的参数个数。这是有道理的——所有这些行为都会产生混淆含义而不是澄清含义的操作符。
接下来的两个小节给出了所有“常规”操作符的例子,以您最可能使用的形式重载。
一元运算符
清单 12-2 显示了重载所有一元运算符的语法,既有全局函数(非成员friend
函数)的形式,也有成员函数的形式。这些将扩展前面显示的Integer
类,并添加一个新的byte
类。您的特定操作符的含义将取决于您希望使用它们的方式,但是在做一些意想不到的事情之前,请考虑客户端程序员。
清单 12-2 。阐释重载一元运算符的语法
//: C12:OverloadingUnaryOperators.cpp
#include <iostream>
using namespace std;
// Non-member functions:
class Integer {
long i;
Integer* This() { return this; }
public:
Integer(long ll = 0) : i(ll) {}
// No side effects takes const& argument:
friend const Integer&
operator+(const Integer& a);
friend const Integer
operator-(const Integer& a);
friend const Integer
operator∼(const Integer& a);
friend Integer*
operator&(Integer& a);
friend int
operator!(const Integer& a);
// Side effects have non-const& argument:
// Prefix:
friend const Integer&
operator++(Integer& a);
// Postfix:
friend const Integer
operator++(Integer& a, int);
// Prefix:
friend const Integer&
operator--(Integer& a);
// Postfix:
friend const Integer
operator--(Integer& a, int);
};
// Global operators:
const Integer& operator+(const Integer& a) {
cout << "+Integer\n";
return a; // Unary + has no effect
}
const Integer operator-(const Integer& a) {
cout << "-Integer\n";
return Integer(-a.i);
}
const Integer operator∼(const Integer& a) {
cout << "∼Integer\n";
return Integer(∼a.i);
}
Integer* operator&(Integer& a) {
cout << "&Integer\n";
return a.This(); // &a is recursive!
}
int operator!(const Integer& a) {
cout << "!Integer\n";
return !a.i;
}
// Prefix; return incremented value
const Integer& operator++(Integer& a) {
cout << "++Integer\n";
a.i++;
return a;
}
// Postfix; return the value before increment:
const Integer operator++(Integer& a, int) {
cout << "Integer++\n";
Integer before(a.i);
a.i++;
return before;
}
// Prefix; return decremented value
const Integer& operator--(Integer& a) {
cout << "--Integer\n";
a.i--;
return a;
}
// Postfix; return the value before decrement:
const Integer operator--(Integer& a, int) {
cout << "Integer--\n";
Integer before(a.i);
a.i--;
return before;
}
// Show that the overloaded operators work:
void f(Integer a) {
+a;
-a;
∼a;
Integer* ip = &a;
!a;
++a;
a++;
--a;
a--;
}
// Member functions (implicit "this"):
class Byte {
unsigned char b;
public:
Byte(unsigned char bb = 0) : b(bb) {}
// No side effects: const member function:
const Byte& operator+() const {
cout << "+Byte\n";
return *this;
}
const Byte operator-() const {
cout << "-Byte\n";
return Byte(-b);
}
const Byte operator∼() const {
cout << "∼Byte\n";
return Byte(∼b);
}
Byte operator!() const {
cout << "!Byte\n";
return Byte(!b);
}
Byte* operator&() {
cout << "&Byte\n";
return this;
}
// Side effects: non-const member function:
const Byte& operator++() { // Prefix
cout << "++Byte\n";
b++;
return *this;
}
const Byte operator++(int) { // Postfix
cout << "Byte++\n";
Byte before(b);
b++;
return before;
}
const Byte& operator--() { // Prefix
cout << "--Byte\n";
--b;
return *this;
}
const Byte operator--(int) { // Postfix
cout << "Byte--\n";
Byte before(b);
--b;
return before;
}
};
void g(Byte b) {
+b;
-b;
∼b;
Byte bp = &b;
!b;
++b;
b++;
--b;
b--;
}
int main() {
Integer a;
f(a);
Byte b;
g(b);
} ///:∼
这些函数根据其参数的传递方式进行分组。后面给出了如何传递和返回参数的准则。上面的形式(和将在下一节中介绍)通常是您将使用的形式,所以在重载您自己的操作符时,以它们作为模式开始。
增量和减量和
重载的++
和– –
操作符提出了一个难题,因为您希望能够调用不同的函数,这取决于它们是出现在它们所作用的对象之前(前缀)还是之后(后缀)。解决方案很简单,但人们有时会觉得一开始有点困惑。例如,当编译器看到++a
(一个前增量),它生成一个对operator++(a)
的调用;但是当它看到a++
时,它生成一个对operator++(a, int)
的调用。也就是说,编译器通过调用不同的重载函数来区分这两种形式。在OverloadingUnaryOperators.cpp
(清单 12-2)中,对于成员函数版本,如果编译器看到++b
,就会生成对B::operator++( )
的调用;如果它看到b++
,它就调用B::operator++(int)
。
用户看到的只是前缀和后缀版本调用了不同的函数。然而,在底层,两个函数调用具有不同的签名,因此它们链接到两个不同的函数体。编译器为参数int
传递一个伪常量值(因为该值从未被使用,所以从未被赋予标识符),以便为后缀版本生成不同的签名。
二元运算符
清单 12-3 & 12-4 对二元运算符重复OverloadingUnaryOperators.cpp
的例子,这样你就有了一个你可能想要重载的所有运算符的例子。同样,全局版本(见清单 12-3 )和成员函数版本(见清单 12-4 )都被显示出来。
清单 12-3 。说明重载二元操作符的语法(对于非成员重载操作符
//: C12:Integer.h
// Non-member overloaded operators
#ifndef INTEGER_H
#define INTEGER_H
#include <iostream>
// Non-member functions:
class Integer {
long i;
public:
Integer(long ll = 0) : i(ll) {}
// Operators that create new, modified value:
friend const Integer
operator+(const Integer& left,
const Integer& right);
friend const Integer
operator-(const Integer& left,
const Integer& right);
friend const Integer
operator*(const Integer& left,
const Integer& right);
friend const Integer
operator/(const Integer& left,
const Integer& right);
friend const Integer
operator%(const Integer& left,
const Integer& right);
friend const Integer
operator^(const Integer& left,
const Integer& right);
friend const Integer
operator&(const Integer& left,
const Integer& right);
friend const Integer
operator|(const Integer& left,
const Integer& right);
friend const Integer
operator<<(const Integer& left,
const Integer& right);
friend const Integer
operator>>(const Integer& left,
const Integer& right);
// Assignments modify & return lvalue:
friend Integer&
operator+=(Integer& left,
const Integer& right);
friend Integer&
operator-=(Integer& left,
const Integer& right);
friend Integer&
operator*=(Integer& left,
const Integer& right);
friend Integer&
operator/=(Integer& left,
const Integer& right);
friend Integer&
operator%=(Integer& left,
const Integer& right);
friend Integer&
operator^=(Integer& left,
const Integer& right);
friend Integer&
operator&=(Integer& left,
const Integer& right);
friend Integer&
operator|=(Integer& left,
const Integer& right);
friend Integer&
operator>>=(Integer& left,
const Integer& right);
friend Integer&
operator<<=(Integer& left,
const Integer& right);
// Conditional operators return true/false:
friend int
operator==(const Integer& left,
const Integer& right);
friend int
operator!=(const Integer& left,
const Integer& right);
friend int
operator<(const Integer& left,
const Integer& right);
friend int
operator>(const Integer& left,
const Integer& right);
friend int
operator<=(const Integer& left,
const Integer& right);
friend int
operator>=(const Integer& left,
const Integer& right);
friend int
operator&&(const Integer& left,
const Integer& right);
friend int
operator||(const Integer& left,
const Integer& right);
// Write the contents to an ostream:
void print(std::ostream& os) const { os << i; }
};
#endif // INTEGER_H ///:∼
//: C12:Integer.cpp {O}
// Implementation of overloaded operators
#include "Integer.h" // TO be INCLUDED from Header FILE above
#include "../require.h" // TO be INCLUDED From Header FILE in *Chapter 9*
const Integer
operator+(const Integer& left,
const Integer& right) {
return Integer(left.i + right.i);
}
const Integer
operator-(const Integer& left,
const Integer& right) {
return Integer(left.i - right.i);
}
const Integer
operator*(const Integer& left,
const Integer& right) {
return Integer(left.i * right.i);
}
const Integer
operator/(const Integer& left,
const Integer& right) {
require(right.i != 0, "divide by zero");
return Integer(left.i / right.i);
}
const Integer
operator%(const Integer& left,
const Integer& right) {
require(right.i != 0, "modulo by zero");
return Integer(left.i % right.i);
}
const Integer
operator^(const Integer& left,
const Integer& right) {
return Integer(left.i ^ right.i);
}
const Integer
operator&(const Integer& left,
const Integer& right) {
return Integer(left.i & right.i);
}
const Integer
operator|(const Integer& left,
const Integer& right) {
return Integer(left.i | right.i);
}
const Integer
operator<<(const Integer& left,
const Integer& right) {
return Integer(left.i << right.i);
}
const Integer
operator>>(const Integer& left,
const Integer& right) {
return Integer(left.i >> right.i);
}
// Assignments modify & return lvalue:
Integer& operator+=(Integer& left,
const Integer& right) {
if(&left == &right) {/* self-assignment */}
left.i += right.i;
return left;
}
Integer& operator-=(Integer& left,
const Integer& right) {
if(&left == &right) {/* self-assignment */}
left.i -= right.i;
return left;
}
Integer& operator*=(Integer& left,
const Integer& right) {
if(&left == &right) {/* self-assignment */}
left.i *= right.i;
return left;
}
Integer& operator/=(Integer& left,
const Integer& right) {
require(right.i != 0, "divide by zero");
if(&left == &right) {/* self-assignment */}
left.i /= right.i;
return left;
}
Integer& operator%=(Integer& left,
const Integer& right) {
require(right.i != 0, "modulo by zero");
if(&left == &right) {/* self-assignment */}
left.i %= right.i;
return left;
}
Integer& operator^=(Integer& left,
const Integer& right) {
if(&left == &right) {/* self-assignment */}
left.i ^= right.i;
return left;
}
Integer& operator&=(Integer& left,
const Integer& right) {
if(&left == &right) {/* self-assignment */}
left.i &= right.i;
return left;
}
Integer& operator|=(Integer& left,
const Integer& right) {
if(&left == &right) {/* self-assignment */}
left.i |= right.i;
return left;
}
Integer& operator>>=(Integer& left,
const Integer& right) {
if(&left == &right) {/* self-assignment */}
left.i >>= right.i;
return left;
}
Integer& operator<<=(Integer& left,
const Integer& right) {
if(&left == &right) {/* self-assignment */}
left.i <<= right.i;
return left;
}
// Conditional operators return true/false:
int operator==(const Integer& left,
const Integer& right) {
return left.i == right.i;
}
int operator!=(const Integer& left,
const Integer& right) {
return left.i != right.i;
}
int operator<(const Integer& left,
const Integer& right) {
return left.i < right.i;
}
int operator>(const Integer& left,
const Integer& right) {
return left.i > right.i;
}
int operator<=(const Integer& left,
const Integer& right) {
return left.i <= right.i;
}
int operator>=(const Integer& left,
const Integer& right) {
return left.i >= right.i;
}
int operator&&(const Integer& left,
const Integer& right) {
return left.i && right.i;
}
int operator||(const Integer& left,
const Integer& right) {
return left.i || right.i;
} ///:∼
//: C12:IntegerTest.cpp
//{L} Integer
#include "Integer.h"
#include <fstream>
using namespace std;
ofstream out("IntegerTest.out");
void h(Integer& c1, Integer& c2) {
// A complex expression:
c1 += c1 * c2 + c2 % c1;
#define TRY(OP) \
out << "c1 = "; c1.print(out); \
out << ", c2 = "; c2.print(out); \
out << "; c1 " #OP " c2 produces "; \
(c1 OP c2).print(out); \
out << endl;
TRY(+) TRY(-) TRY(*) TRY(/)
TRY(%) TRY(^) TRY(&) TRY(|)
TRY(<<) TRY(>>) TRY(+=) TRY(-=)
TRY(*=) TRY(/=) TRY(%=) TRY(^=)
TRY(&=) TRY(|=) TRY(>>=) TRY(<<=)
// Conditionals:
#define TRYC(OP) \
out << "c1 = "; c1.print(out); \
out << ", c2 = "; c2.print(out); \
out << "; c1 " #OP " c2 produces "; \
out << (c1 OP c2); \
out << endl;
TRYC(<) TRYC(>) TRYC(==) TRYC(!=) TRYC(<=)
TRYC(>=) TRYC(&&) TRYC(||)
}
int main() {
cout << "friend functions" << endl;
Integer c1(47), c2(9);
h(c1, c2);
} ///:∼
清单 12-4 。说明重载二元运算符的语法(对于成员重载运算符)
//: C12:Byte.h
// Member overloaded operators
#ifndef BYTE_H
#define BYTE_H
#include "../require.h"
#include <iostream>
// Member functions (implicit "this"):
class Byte {
unsigned char b;
public:
Byte(unsigned char bb = 0) : b(bb) {}
// No side effects: const member function:
const Byte
operator+(const Byte& right) const {
return Byte(b + right.b);
}
const Byte
operator-(const Byte& right) const {
return Byte(b - right.b);
}
const Byte
operator*(const Byte& right) const {
return Byte(b * right.b);
}
const Byte
operator/(const Byte& right) const {
require(right.b != 0, "divide by zero");
return Byte(b / right.b);
}
const Byte
operator%(const Byte& right) const {
require(right.b != 0, "modulo by zero");
return Byte(b % right.b);
}
const Byte
operator^(const Byte& right) const {
return Byte(b ^ right.b);
}
const Byte
operator&(const Byte& right) const {
return Byte(b & right.b);
}
const Byte
operator|(const Byte& right) const {
return Byte(b | right.b);
}
const Byte
operator<<(const Byte& right) const {
return Byte(b << right.b);
}
const Byte
operator>>(const Byte& right) const {
return Byte(b >> right.b);
}
// Assignments modify & return lvalue.
// operator= can only be a member function:
Byte& operator=(const Byte& right) {
// Handle self-assignment:
if(this == &right) return *this;
b = right.b;
return *this;
}
Byte& operator+=(const Byte& right) {
if(this == &right) {/* self-assignment */}
b += right.b;
return *this;
}
Byte& operator-=(const Byte& right) {
if(this == &right) {/* self-assignment */}
b -= right.b;
return *this;
}
Byte& operator*=(const Byte& right) {
if(this == &right) {/* self-assignment */}
b *= right.b;
return *this;
}
Byte& operator/=(const Byte& right) {
require(right.b != 0, "divide by zero");
if(this == &right) {/* self-assignment */}
b /= right.b;
return *this;
}
Byte& operator%=(const Byte& right) {
require(right.b != 0, "modulo by zero");
if(this == &right) {/* self-assignment */}
b %= right.b;
return *this;
}
Byte& operator^=(const Byte& right) {
if(this == &right) {/* self-assignment */}
b ^= right.b;
return *this;
}
Byte& operator&=(const Byte& right) {
if(this == &right) {/* self-assignment */}
b &= right.b;
return *this;
}
Byte& operator|=(const Byte& right) {
if(this == &right) {/* self-assignment */}
b |= right.b;
return *this;
}
Byte& operator>>=(const Byte& right) {
if(this == &right) {/* self-assignment */}
b >>= right.b;
return *this;
}
Byte& operator<<=(const Byte& right) {
if(this == &right) {/* self-assignment */}
b <<= right.b;
return *this;
}
// Conditional operators return true/false:
int operator==(const Byte& right) const {
return b == right.b;
}
int operator!=(const Byte& right) const {
return b != right.b;
}
int operator<(const Byte& right) const {
return b < right.b;
}
int operator>(const Byte& right) const {
return b > right.b;
}
int operator<=(const Byte& right) const {
return b <= right.b;
}
int operator>=(const Byte& right) const {
return b >= right.b;
}
int operator&&(const Byte& right) const {
return b && right.b;
}
int operator||(const Byte& right) const {
return b || right.b;
}
// Write the contents to an ostream:
void print(std::ostream& os) const {
os << "0x" << std::hex << int(b) << std::dec;
}
};
#endif // BYTE_H ///:∼
//: C12:ByteTest.cpp
#include "Byte.h" // To be INCLUDED from Header FILE above
#include <fstream>
using namespace std;
ofstream out("ByteTest.out");
void k(Byte& b1, Byte& b2) {
b1 = b1 * b2 + b2 % b1;
#define TRY2(OP) \
out << "b1 = "; b1.print(out); \
out << ", b2 = "; b2.print(out); \
out << "; b1 " #OP " b2 produces "; \
(b1 OP b2).print(out); \
out << endl;
b1 = 9; b2 = 47;
TRY2(+) TRY2(-) TRY2(*) TRY2(/)
TRY2(%) TRY2(^) TRY2(&) TRY2(|)
TRY2(<<) TRY2(>>) TRY2(+=) TRY2(-=)
TRY2(*=) TRY2(/=) TRY2(%=) TRY2(^=)
TRY2(&=) TRY2(|=) TRY2(>>=) TRY2(<<=)
TRY2(=) // Assignment operator
// Conditionals:
#define TRYC2(OP) \
out << "b1 = "; b1.print(out); \
out << ", b2 = "; b2.print(out); \
out << "; b1 " #OP " b2 produces "; \
out << (b1 OP b2); \
out << endl;
b1 = 9; b2 = 47;
TRYC2(<) TRYC2(>) TRYC2(==) TRYC2(!=) TRYC2(<=)
TRYC2(>=) TRYC2(&&) TRYC2(||)
// Chained assignment:
Byte b3 = 92;
b1 = b2 = b3;
}
int main() {
out << "member functions:" << endl;
Byte b1(47), b2(9);
k(b1, b2);
} ///:∼
可以看到operator=
只允许是成员函数。这个后面解释。
注意,所有的赋值操作符都有检查自赋值的代码;这是一个总的指导方针。在某些情况下,这是不必要的;例如,对于operator+=
,你经常希望让说出A+=A
,并让它把A
加到自己身上。检查自我分配最重要的地方是operator=
,因为对于复杂的对象,可能会出现灾难性的结果。(在某些情况下这是可以的,但是在写operator=
的时候你应该一直记在心里。)
前面三个清单(即清单 12-2、12-3 和 12-4)中显示的所有操作符都被重载以处理单一类型。也可以重载操作符来处理混合类型,例如,你可以把苹果加到橘子上。然而,在开始详尽的操作符重载之前,应该先看看本章后面的自动类型转换一节。通常,在正确的位置进行类型转换可以为您节省许多重载操作符。
参数和返回值和
当你看着OverloadingUnaryOperators.cpp
、Integer.h
和Byte.h
并看到参数传递和返回的所有不同方式时,一开始可能会有点困惑。尽管你可以用你想要的任何方式传递和返回参数,这些例子中的选择并不是随机选择的。它们遵循一种逻辑模式,与你在大多数选择中想要使用的模式相同。
- 与任何函数参数一样,如果您只需要读取参数而不需要更改它,默认情况下会将其作为
const
引用传递。普通算术运算(如+
、–
、、等)。)和布尔值不会改变它们的参数,所以你主要使用const
引用。当函数是一个类成员时,这就转化成了一个const
成员函数。只有使用运算符赋值(如+=
和operator=
)来改变左边的参数,左边的参数不是常量,但它仍然作为地址传入,因为它将被改变。 - 您应该选择的返回值类型取决于运算符的预期含义。(同样,您可以对参数和返回值做任何您想做的事情。)如果操作符的效果是产生一个新值,那么就需要生成一个新的对象作为返回值。例如,
Integer::operator+
必须产生一个作为操作数总和的Integer
对象。该对象通过值作为const
返回,因此结果不能作为左值修改。 - 所有的赋值操作符都修改左值。为了允许赋值的结果被用在链式表达式中,比如
a=b=c
,你应该返回一个对刚刚被修改的左值的引用。但是这个参考应该是一个const
还是非const
?尽管你从左向右读a=b=c
,编译器从右向左解析它,所以你不会被迫返回一个非const
来支持赋值链接。然而,人们有时确实希望能够对刚刚被赋值的对象执行操作,比如在将b
赋值给它之后,在a
上调用func( )
。因此,所有赋值操作符的返回值应该是对左值的非const
引用。 - 对于逻辑运算符,每个人都期望在最坏的情况下得到一个
int
,在最好的情况下得到一个bool
。(在大多数编译器支持 C++ 的内置bool
之前开发的库将使用int
或等效的typedef
。)
由于前缀和后缀版本的不同,递增和递减运算符呈现出两难的局面。两个版本都改变了对象,因此不能将对象视为const
。前缀版本返回对象被更改后的值,因此您期望得到被更改的对象。因此,使用 prefix,您可以返回*this
作为引用。postfix 版本应该在值被改变之前返回值,所以您被迫创建一个单独的对象来表示该值并返回它。因此,使用 postfix,如果想保留预期的含义,就必须通过值返回。(注意,你有时会发现递增和递减操作符返回一个int
或bool
来指示,例如,一个被设计来在列表中移动的对象是否在列表的末尾。)现在的问题是:这些应该被返回为const
还是非const
?如果你允许对象被修改,有人写(++a).func( )
,func( )
会对a
本身进行操作,但是有了(a++).func( )
,func( )
会对后缀operator++
返回的临时对象进行操作。临时对象是自动const
的,所以这将被编译器标记,但是为了一致性的缘故,将它们都设为const
可能更有意义,正如这里所做的。或者你可以选择让前缀版本不使用const
而使用后缀版本const
。由于您可能希望赋予递增和递减运算符多种含义,因此需要根据具体情况来考虑它们。
以常量 的形式按值返回
作为一个const
通过值返回起初看起来有点微妙,所以它值得更多的解释。考虑二进制operator+
。如果在像f(a+b)
这样的表达式中使用它,a+b
的结果就变成了一个临时对象,用于调用f( )
。因为是临时的,所以自动const
,所以你显式的做不做返回值const
都没有影响。
然而,您也可以向a+b
的返回值发送一条消息,而不仅仅是将它传递给一个函数。比如你可以说(a+b).g( )
,其中g( )
是Integer
的某个成员函数,在这种情况下。通过生成返回值const
,您声明只有一个const
成员函数可以被调用来获取返回值。这是正确的,因为它防止你在一个很可能丢失的对象中存储潜在的有价值的信息。
返回优化
当创建新对象以按值返回时,请注意使用的形式。例如,在operator+
中,表格是
return Integer(left.i + right.i);
乍一看,这可能像是对构造器的函数调用,但事实并非如此。语法是临时对象的语法;语句说“创建一个临时的Integer
对象并返回它。”因此,您可能认为结果与创建一个命名的本地对象并返回它是一样的。然而,这是完全不同的。如果你说
Integer tmp(left.i + right.i);
return tmp;
会发生三件事。首先,tmp
对象被创建,包括其构造器调用。其次,复制构造器将tmp
复制到外部返回值的位置。第三,在作用域的末尾为tmp
调用析构函数。
相比之下,“返回临时”方法的工作方式完全不同。当编译器看到你这样做时,它知道你除了返回它所创建的对象之外,没有其他需要。编译器通过将对象直接构建到外部返回值的位置来利用这一点。这只需要一个普通的构造器调用(不需要复制构造器),也没有析构函数调用,因为你从来没有真正创建一个本地对象。因此,尽管除了程序员的认知之外,它没有任何成本,但它的效率显著提高。这通常被称为返回值优化。
异常运算符
几个额外的运算符对于重载有稍微不同的语法。
下标operator[ ]
必须是一个成员函数,它需要一个参数。因为operator[ ]
意味着被调用的对象的行为类似于一个数组,所以您通常会从这个操作符返回一个引用,所以它可以方便地用在等号的左边。该运算符通常是重载的;你会在本书的其余部分看到例子。
操作符new
和delete
控制动态存储分配,并且可以以多种不同的方式重载。这个话题在第十三章中有所涉及。
运算符逗号
当逗号运算符出现在为其定义逗号的类型的对象旁边时,将调用该运算符。然而,operator
是而不是对函数参数列表的调用,只对公开的对象,用逗号分隔。这个运算符似乎没有太多实际用途;这是为了语言的一致性。清单 12-5 展示了一个例子,展示了当逗号出现在一个对象的前面和后面时,如何调用逗号函数。
清单 12-5 。重载逗号运算符
//: C12:OverloadingOperatorComma.cpp
#include <iostream>
using namespace std;
class After {
public:
const After& operator,(const After&) const {
cout << "After::operator,()" << endl;
return *this;
}
};
class Before {};
Before& operator,(int, Before& b) {
cout << "Before::operator,()" << endl;
return b;
}
int main() {
After a, b;
a, b; // Operator comma called
Before c;
1, c; // Operator comma called
} ///:∼
全局函数允许将逗号放在所讨论的对象之前。所显示的用法相当模糊和可疑。虽然您可能会在更复杂的表达式中使用逗号分隔的列表,但在大多数情况下使用它太微妙了。
操作员->
当你想让一个对象看起来像一个指针时,通常使用operator–>
。由于这种对象比典型的指针具有更多的内置“智能”,因此这种对象通常被称为智能指针。如果您想在指针周围“包装”一个类以使指针安全,或者在迭代器的常见用法中,这些特别有用,迭代器是一个对象,它遍历其他对象的集合 / 容器,并一次选择一个,而不提供对容器实现的直接访问。
指针取消引用运算符必须是成员函数。它有额外的、非典型的约束:它必须返回一个也有指针取消引用操作符的对象(或对对象的引用),或者它必须返回一个指针,该指针可用于选择指针取消引用操作符箭头所指向的对象。一个简单的例子见清单 12-6 。
清单 12-6 。智能指针示例
//: C12:SmartPointer.cpp
#include <iostream>
#include <vector>
#include "../require.h"
using namespace std;
class Obj {
static int i, j;
public:
void f() const { cout << i++ << endl; }
void g() const { cout << j++ << endl; }
};
// Static member definitions:
int Obj::i = 47;
int Obj::j = 11;
// Container:
class ObjContainer {
vector<Obj*> a;
public:
void add(Obj* obj) { a.push_back(obj); }
friend class SmartPointer;
};
class SmartPointer {
ObjContainer& oc;
int index;
public:
SmartPointer(ObjContainer& objc) : oc(objc) {
index = 0;
}
// Return value indicates end of list:
bool operator++() { // Prefix
if(index >= oc.a.size()) return false;
if(oc.a[++index] == 0) return false;
return true;
}
bool operator++(int) { // Postfix
return operator++(); // Use prefix version
}
Obj* operator->() const {
require(oc.a[index] != 0, "Zero value "
"returned by SmartPointer::operator->()");
return oc.a[index];
}
};
int main() {
const int sz = 10;
Obj o[sz];
ObjContainer oc;
for(int i = 0; i < sz; i++)
oc.add(&o[i]); // Fill it up
SmartPointer sp(oc); // Create an iterator
do {
sp->f(); // Pointer dereference operator call
sp->g();
} while(sp++);
} ///:∼
类Obj
定义了在这个程序中被操作的对象。函数f( )
和g( )
使用static
数据成员简单地打印出感兴趣的值。使用add( )
函数将指向这些对象的指针存储在ObjContainer
类型的容器中。看起来像一个指针数组,但是你会注意到没有办法再把指针取出来。然而,SmartPointer
被声明为一个friend
类,所以它有权限查看容器内部。SmartPointer
类看起来非常像一个智能指针;您可以使用operator++
向前移动它(您也可以定义一个operator– –
),它不会超过它所指向的容器的末尾,并且它(通过指针解引用操作符)产生它所指向的值。请注意,SmartPointer
是为其创建的容器定制的;与普通指针不同,没有“通用”智能指针。在main( )
中,一旦容器oc
被Obj
对象填满,就会创建一个SmartPointer sp
。智能指针调用发生在表达式中,如:
sp->f(); // Smart pointer calls
sp->g();
这里,即使sp
实际上没有f( )
和g( )
成员函数,指针解引用操作符也会自动为SmartPointer::operator–>
返回的Obj*
调用这些函数。编译器执行所有检查以确保函数调用正常工作。
尽管指针取消引用操作符的底层机制比其他操作符更复杂,但目标是完全一样的:为类的用户提供更方便的语法。
一个嵌套的迭代器
更常见的是,智能指针或迭代器类嵌套在它所服务的类中。在清单 12-7 中,清单 12-6 中的代码被重写以将SmartPointer
嵌套在ObjContainer
中。
清单 12-7 。嵌套的智能指针/迭代器
//: C12:NestedSmartPointer.cpp
#include <iostream>
#include <vector>
#include "../require.h"
using namespace std;
class Obj {
static int i, j;
public:
void f() { cout << i++ << endl; }
void g() { cout << j++ << endl; }
};
// Static member definitions:
int Obj::i = 47;
int Obj::j = 11;
// Container:
class ObjContainer {
vector<Obj*> a;
public:
void add(Obj* obj) { a.push_back(obj); }
class SmartPointer;
friend class SmartPointer;
class SmartPointer {
ObjContainer& oc;
unsigned int index;
public:
SmartPointer(ObjContainer& objc) : oc(objc) {
index = 0;
}
// Return value indicates end of list:
bool operator++() { // Prefix
if(index >= oc.a.size()) return false;
if(oc.a[++index] == 0) return false;
return true;
}
bool operator++(int) { // Postfix
return operator++(); // Use prefix version
}
Obj* operator->() const {
require(oc.a[index] != 0, "Zero value "
"returned by SmartPointer::operator->()");
return oc.a[index];
}
};
// Function to produce a smart pointer that
// points to the beginning of the ObjContainer:
SmartPointer begin() {
return SmartPointer(*this);
}
};
int main() {
const int sz = 10;
Obj o[sz];
ObjContainer oc;
for(int i = 0; i < sz; i++)
oc.add(&o[i]); // Fill it up
ObjContainer::SmartPointer sp = oc.begin();
do {
sp->f(); // Pointer dereference operator call
sp->g();
} while(++sp);
} ///:∼
除了类的实际嵌套之外,这里只有两个不同之处。第一个是在类的声明中,这样它就可以是一个friend
,如:
class SmartPointer;
friend SmartPointer;
编译器必须首先知道这个类的存在,然后才能被告知它是一个friend
。
第二个不同是在ObjContainer
成员函数begin( )
中,它产生一个指向ObjContainer
序列开始的SmartPointer
。虽然这只是为了方便,但它很有价值,因为它遵循了标准 C++ 库中使用的部分形式。
运算符->*
operator–>*
是一个二元操作符,其行为类似于所有其他二元操作符。它是为那些想要模仿内置的成员指针语法所提供的行为的情况而提供的,如前一章所述。
就像operator->
一样,指向成员的指针解引用操作符通常与某种表示智能指针的对象一起使用,尽管这里显示的例子会更简单一些,所以容易理解。定义operator->*
的诀窍在于,它必须返回一个对象,这个对象的operator( )
可以用你正在调用的成员函数的参数来调用。
函数调用operator( )
必须是一个成员函数,它的独特之处在于它允许任意数量的参数。它让你的对象看起来像是一个函数。尽管您可以用不同的参数定义几个重载的operator( )
函数,但它通常用于只有一个操作的类型,或者至少是一个特别突出的操作。
要创建一个operator->*
,你必须首先创建一个带有operator( )
的类,这是operator->*
将返回的对象类型。这个类必须以某种方式捕获必要的信息,以便当调用operator( )
时(这是自动发生的),指向成员的指针将被对象解引用。在清单 12-8 中,FunctionObject
构造器捕获并存储指向对象的指针和指向成员函数的指针,然后operator( )
使用它们进行实际的指向成员的指针调用。
清单 12-8 。指向成员运算符的指针
//: C12:PointerToMemberOperator.cpp
#include <iostream>
using namespace std;
class Dog {
public:
int run(int i) const {
cout << "run\n";
return i;
}
int eat(int i) const {
cout << "eat\n";
return i;
}
int sleep(int i) const {
cout << "ZZZ\n";
return i;
}
typedef int (Dog::*PMF)(int) const;
// operator->* must return an object
// that has an operator():
class FunctionObject {
Dog* ptr;
PMF pmem;
public:
// Save the object pointer and member pointer
FunctionObject(Dog* wp, PMF pmf)
: ptr(wp), pmem(pmf) {
cout << "FunctionObject constructor\n";
}
// Make the call using the object pointer
// and member pointer
int operator()(int i) const {
cout << "FunctionObject::operator()\n";
return (ptr->*pmem)(i); // Make the call
}
};
FunctionObject operator->*(PMF pmf) {
cout << "operator->*" << endl;
return FunctionObject(this, pmf);
}
};
int main() {
Dog w;
Dog::PMF pmf = &Dog::run;
cout << (w->*pmf)(1) << endl;
pmf = &Dog::sleep;
cout << (w->*pmf)(2) << endl;
pmf = &Dog::eat;
cout << (w->*pmf)(3) << endl;
} ///:∼
Dog
有三个成员函数,它们都接受一个int
参数并返回一个int
。PMF
是一个typedef
,用于简化定义指向Dog
成员函数的指针。
由operator->*
创建并返回一个FunctionObject
。注意,operator->*
知道成员指针被调用的对象(this
)和成员指针,并将它们传递给存储值的FunctionObject
构造器。当调用operator->*
时,编译器立即返回并调用operator( )
获取operator->*
的返回值,并传入给operator->*
的参数。FunctionObject::operator( )
接受参数,然后使用其存储的对象指针和成员指针解引用“真正的”成员指针。
请注意,您在这里所做的事情,就像使用operator->
一样,是将您自己插入到对operator->*
的调用中。这允许您在必要时执行一些额外的操作。
这里实现的operator->*
机制只对采用int
参数并返回int
的成员函数有效。这是限制性的,但是如果您试图为每种不同的可能性创建重载机制,这似乎是一个禁止性的任务。幸运的是,C++ 的template
机制(在第十六章中讨论)就是为处理这样的问题而设计的。
运营商你不能霸王
可用集合中有某些运算符不能重载。限制的一般原因是安全。如果这些操作符过载,它会以某种方式危及或破坏安全机制,使事情变得更困难,或者混淆现有的实践。
- 成员选择
operator.
。目前,点对类中的任何成员都有意义,但是如果你允许它被重载,那么你就不能以正常的方式访问成员;相反,你必须使用指针和箭头operator->
。 - 指向成员解引用的指针
operator.*
,原因与operator.
相同。 - 没有指数运算符。对此最流行的选择是 Fortran 中的
operator**
,但是这带来了困难的解析问题。此外,C 没有指数运算符,所以 C++ 似乎也不需要,因为您可以随时执行函数调用。取幂运算符会增加一个方便的符号,但是没有新的语言功能来解释编译器增加的复杂性。 - 没有用户定义的运算符。也就是说,您不能创建当前不在集合中的新运算符。问题的一部分是如何确定优先级,问题的一部分是没有足够的必要来考虑必要的麻烦。
- 您不能更改优先规则。如果不让人们玩它们,它们就很难被记住。
非成员操作符
在前面的一些例子中,操作者可能是成员,也可能不是成员,这似乎没有太大的区别。这通常会引发选择哪个的问题。一般来说,如果没什么区别;它们应该是成员,以强调操作符和它的类之间的关联。当左边的操作数总是当前类的一个对象时,这样做很好。
但是,有时您希望左边的操作数是某个其他类的对象。当操作符<<
和>>
为iostream
重载时,你会经常看到这种情况。因为iostream
是一个基本的 C++ 库,你可能想为你的大多数类重载这些操作符,所以这个过程值得记忆;参见清单 12-9 。
清单 12-9 。Iostream 运算符重载
//: C12:IostreamOperatorOverloading.cpp
// Example of non-member overloaded operators
#include "../require.h"
#include <iostream>
#include <sstream>
// "String streams"
#include <cstring>
using namespace std;
class IntArray {
enum { sz = 5 };
int i[sz];
public:
IntArray() { memset(i, 0, sz* sizeof(*i)); }
int& operator[](int x) {
require(x >= 0 && x < sz,
"IntArray::operator[] out of range");
return i[x];
}
friend ostream&
operator<<(ostream& os, const IntArray& ia);
friend istream&
operator>>(istream& is, IntArray& ia);
};
ostream&
operator<<(ostream& os, const IntArray& ia) {
for(int j = 0; j < ia.sz; j++) {
os << ia.i[j];
if(j != ia.sz -1)
os << ", ";
}
os << endl;
return os;
}
istream& operator>>(istream& is, IntArray& ia){
for(int j = 0; j < ia.sz; j++)
is >> ia.i[j];
return is;
}
int main() {
stringstream input("47 34 56 92 103");
IntArray I;
input >> I;
I[4] = -1; // Use overloaded operator[]
cout << I;
} ///:∼
这个类还包含一个重载的operator [ ]
,它返回对数组中合法值的引用。因为返回了引用,所以表达式
I[4] = -1;
不仅看起来比使用指针文明得多,而且也达到了预期的效果。
重要的是,重载的 shift 操作符通过引用传递并返回,因此这些操作将影响外部对象。在函数定义中,像
os << ia.i[j];
调用现有的重载运算符函数(即<iostream>
中定义的函数)。在这种情况下,被调用的函数是ostream& operator<<(ostream&, int)
,因为ia.i[j]
解析为int
。
一旦在istream
或ostream
上执行了所有的动作,它就会被返回,因此可以在更复杂的表达式中使用。
在main( )
中,使用了一种新型的iostream
(在<sstream>
中声明)。这是一个接受一个string
(它可以从一个char
数组中创建,如此处所示)并将其转换成一个iostream
的类。在清单 12-9 的例子中,这意味着不用打开文件或在命令行上输入数据就可以测试移位操作符。
本示例(清单 12-9 )中所示的插入器和提取器的形式是标准的。如果您想为自己的类创建这些操作符,请复制上面的函数签名和返回类型,并遵循主体的形式。
基本指南
表 12-1 中的指南推荐用于在成员和非成员之间进行选择。
表 12-1 。选择成员的准则
操作员 | 推荐用途 |
---|---|
All Unary Operators |
成员 |
= ( ) [ ] –> –>* |
必须是会员吗 |
+= –= /= *= ^= &= |= %= >>= <<= |
成员 |
All Other Binary Operators |
非成员 |
重载赋值
新 C++ 程序员的一个常见困惑是赋值。这是毫无疑问的,因为=
符号是编程中的一个基本操作,甚至可以复制机器级别的寄存器。此外,当使用=
符号时,复制构造器(在第十一章中描述)有时也会被调用,例如:
MyType b;
MyType a = b;
a = b;
在第二行中,对象a
正在被定义。一个新的对象正在以前不存在的地方被创建。因为现在你已经知道 C++ 编译器对对象初始化有多谨慎,所以你知道构造器必须总是在定义对象的地方被调用。但是哪个构造者?a
是从一个现有的MyType
对象(等号右边的上的b
)创建的,所以只有一个选择:复制构造器。即使涉及到等号,也要调用复制构造器。
第三行,事情就不一样了。等号的左边是一个先前初始化的对象。显然,你不会为一个已经创建的对象调用构造器。在这种情况下,调用MyType::operator=
来调用a
,将出现在右侧的任何内容作为参数。
注意你可以有多个operator=
函数来接受不同类型的右边参数。
这种行为不限于复制构造器。任何时候你使用一个=
而不是构造器的普通函数调用形式来初始化一个对象,编译器会寻找一个接受右边任何东西的构造器;见清单 12-10 。
清单 12-10 。复制与初始化
//: C12:CopyingVsInitialization.cpp
class Fi {
public:
Fi() {}
};
class Fee {
public:
Fee(int) {}
Fee(const Fi&) {}
};
int main() {
Fee fee = 1; // Fee(int)
Fi fi;
Fee fum = fi; // Fee(Fi)
} ///:∼
当处理=
符号时,重要的是记住这个区别:如果对象还没有被创建,需要初始化;否则使用赋值operator=
。
避免编写使用=
进行初始化的代码更好;相反,总是使用显式构造器形式。带有等号的两个结构就变成了
Fee fee(1);
Fee fum(fi);
操作员的行为=
在Integer.h
和Byte.h
中,你看到了operator=
只能是一个成员函数。它与=
左侧的物体紧密相连。如果有可能全局定义operator=
,那么您可能会尝试重新定义内置的=
符号,如下所示:
int operator=(int, MyType); // Global = not allowed!
编译器通过强迫你将operator=
变成一个成员函数来回避这个问题。
当您创建一个operator=
时,您必须将所有必需的信息从右边的对象复制到当前对象(也就是说,operator=
被调用的对象)中,以执行您认为对您的类“分配”的任何事情。对于简单的对象,这是显而易见的,正如你在清单 12-11 中看到的。
清单 12-11 。简单赋值
//: C12:SimpleAssignment.cpp
// Simple operator=()
#include <iostream>
using namespace std;
class Value {
int a, b;
float c;
public:
Value(int aa = 0, int bb = 0, float cc = 0.0)
: a(aa), b(bb), c(cc) {}
Value& operator=(const Value& rv) {
a = rv.a;
b = rv.b;
c = rv.c;
return *this;
}
friend ostream&
operator<<(ostream& os, const Value& rv) {
return os << "a = " << rv.a << ", b = "
<< rv.b << ", c = " << rv.c;
}
};
int main() {
Value a, b(1, 2, 3.3);
cout << "a: " << a << endl;
cout << "b: " << b << endl;
a = b;
cout << "a after assignment: " << a << endl;
} ///:∼
这里,=
左边的对象复制了右边对象的所有元素,然后返回一个对自身的引用,这允许创建一个更复杂的表达式。
这个例子包括一个常见的错误。当你分配两个相同类型的对象时,你应该总是首先检查自分配:对象是否被分配给它自己?在某些情况下,比如这种情况,无论如何执行赋值操作都是无害的,但是如果对类的实现进行了更改,就会有所不同,如果您没有养成这样做的习惯,您可能会忘记并导致难以发现的错误。
类中的指针
如果对象没那么简单会怎么样?例如,如果对象包含指向其他对象的指针呢?简单地复制一个指针意味着你将得到两个指向相同存储位置的对象。在这种情况下,你需要自己记账。
解决这个问题有两种常见的方法。最简单的技术是当你做赋值或复制构造时,复制指针所指的任何东西。这很简单,如清单 12-12 所示。
清单 12-12 。用指针复制
//: C12:CopyingWithPointers.cpp
// Solving the pointer aliasing problem by
// duplicating what is pointed to during
// assignment and copy-construction.
#include "../require.h"
#include <string>
#include <iostream>
using namespace std;
class Dog {
string nm;
public:
Dog(const string& name) : nm(name) {
cout << "Creating Dog: " << *this << endl;
}
// Synthesized copy-constructor & operator= are correct.
// Create a Dog from a Dog pointer:
Dog(const Dog* dp, const string& msg)
: nm(dp->nm + msg) {
cout << "Copied dog " << *this << " from "
<< *dp << endl;
}
∼Dog() {
cout << "Deleting Dog: " << *this << endl;
}
void rename(const string& newName) {
nm = newName;
cout << "Dog renamed to: " << *this << endl;
}
friend ostream&
operator<<(ostream& os, const Dog& d) {
return os << "[" << d.nm << "]";
}
};
class DogHouse {
Dog* p;
string houseName;
public:
DogHouse(Dog* dog, const string& house)
: p(dog), houseName(house) {}
DogHouse(const DogHouse& dh)
: p(new Dog(dh.p, " copy-constructed")),
houseName(dh.houseName
+ " copy-constructed") {}
DogHouse& operator=(const DogHouse& dh) {
// Check for self-assignment:
if(&dh != this) {
p = new Dog(dh.p, " assigned");
houseName = dh.houseName + " assigned";
}
return *this;
}
void renameHouse(const string& newName) {
houseName = newName;
}
Dog* getDog() const { return p; }
∼DogHouse() { delete p; }
friend ostream&
operator<<(ostream& os, const DogHouse& dh) {
return os << "[" << dh.houseName
<< "] contains " << *dh.p;
}
};
int main() {
DogHouse fidos(new Dog("Fido"), "FidoHouse");
cout << fidos << endl;
DogHouse fidos2 = fidos; // Copy construction
cout << fidos2 << endl;
fidos2.getDog()->rename("Spot");
fidos2.renameHouse("SpotHouse");
cout << fidos2 << endl;
fidos = fidos2; // Assignment
cout << fidos << endl;
fidos.getDog()->rename("Max");
fidos2.renameHouse("MaxHouse");
} ///:∼
Dog
是一个简单的类,只包含一个保存狗的名字的string
。然而,你通常会知道什么时候Dog
发生了什么,因为构造器和析构函数在被调用时会打印信息。请注意,第二个构造器有点像复制构造器,只是它采用了一个指向Dog
的指针而不是引用,并且它有一个第二个参数,这是一条连接到参数Dog
名称的消息。这用于帮助跟踪程序的行为。
您可以看到,每当成员函数打印信息时,它并不直接访问该信息,而是将*this
发送到cout
。这又叫ostreamoperator<<
。这样做是有价值的,因为如果你想重新格式化Dog
信息的显示方式(就像通过添加[ and ]所做的那样),你只需要在一个地方完成。
一个DogHouse
包含一个Dog*
,并演示了当你的类包含指针时,你总是需要定义的四个函数:所有必要的普通构造器、复制构造器、operator=
( 要么定义它,要么不允许它),以及一个析构函数。operator=
检查自我分配是理所当然的,尽管在这里并没有严格的必要。这实际上消除了你忘记检查自我赋值的可能性,如果你确实改变了代码以使它变得重要。
参考计数
在清单 12-12 的中,复制构造器和operator=
为指针指向的内容创建了一个新的副本,析构函数删除了它。但是,如果您的对象需要大量内存或很高的初始化开销,您可能希望避免这种复制。解决这个问题的一种常见方法叫做引用计数。你赋予被指向的对象智能,让它知道有多少对象指向它。那么复制构造或赋值意味着将另一个指针附加到一个现有的对象上,并增加引用计数。销毁意味着减少引用计数,如果引用计数变为零,则销毁对象。
但是如果你想写对象(清单 12-12 中的中的Dog
)呢?可能不止一个对象在使用这个Dog
,所以你会修改别人的Dog
和你的,这看起来不太友好。为了解决这个“混叠”问题,使用了一种称为写时复制的附加技术。在写入内存块之前,您要确保没有其他人在使用它。如果引用计数大于 1,那么在写入之前,您必须为自己创建一个该块的个人副本,这样您就不会打扰到其他人。参见清单 12-13 中引用计数和写时复制的简单例子。
清单 12-13 。说明引用计数和写入时复制
//: C12:ReferenceCounting.cpp
// Reference count, copy-on-write
#include "../require.h"
#include <string>
#include <iostream>
using namespace std;
class Dog {
string nm;
int refcount;
Dog(const string& name)
: nm(name), refcount(1) {
cout << "Creating Dog: " << *this << endl;
}
// Prevent assignment:
Dog& operator=(const Dog& rv);
public:
// Dogs can only be created on the heap:
static Dog* make(const string& name) {
return new Dog(name);
}
Dog(const Dog& d)
: nm(d.nm + " copy"), refcount(1) {
cout << "Dog copy-constructor: "
<< *this << endl;
}
∼Dog() {
cout << "Deleting Dog: " << *this << endl;
}
void attach() {
++refcount;
cout << "Attached Dog: " << *this << endl;
}
void detach() {
require(refcount != 0);
cout << "Detaching Dog: " << *this << endl;
// Destroy object if no one is using it:
if(--refcount == 0) delete this;
}
// Conditionally copy this Dog.
// Call before modifying the Dog, assign
// resulting pointer to your Dog*.
Dog* unalias() {
cout << "Unaliasing Dog: " << *this << endl;
// Don't duplicate if not aliased:
if(refcount == 1) return this;
--refcount;
// Use copy-constructor to duplicate:
return new Dog(*this);
}
void rename(const string& newName) {
nm = newName;
cout << "Dog renamed to: " << *this << endl;
}
friend ostream&
operator<<(ostream& os, const Dog& d) {
return os << "[" << d.nm << "], rc = "
<< d.refcount;
}
};
class DogHouse {
Dog* p;
string houseName;
public:
DogHouse(Dog* dog, const string& house)
: p(dog), houseName(house) {
cout << "Created DogHouse: "<< *this << endl;
}
DogHouse(const DogHouse& dh)
: p(dh.p),
houseName("copy-constructed " +
dh.houseName) {
p->attach();
cout << "DogHouse copy-constructor: "
<< *this << endl;
}
DogHouse& operator=(const DogHouse& dh) {
// Check for self-assignment:
if(&dh != this) {
houseName = dh.houseName + " assigned";
// Clean up what you're using first:
p->detach();
p = dh.p; // Like copy-constructor
p->attach();
}
cout << "DogHouse operator= : "
<< *this << endl;
return *this;
}
// Decrement refcount, conditionally destroy
∼DogHouse() {
cout << "DogHouse destructor: "
<< *this << endl;
p->detach();
}
void renameHouse(const string& newName) {
houseName = newName;
}
void unalias() { p = p->unalias(); }
// Copy-on-write. Anytime you modify the
// contents of the pointer you must
// first unalias it:
void renameDog(const string& newName) {
unalias();
p->rename(newName);
}
// ... or when you allow someone else access:
Dog* getDog() {
unalias();
return p;
}
friend ostream&
operator<<(ostream& os, const DogHouse& dh) {
return os << "[" << dh.houseName
<< "] contains " << *dh.p;
}
};
int main() {
DogHouse
fidos(Dog::make("Fido"), "FidoHouse"),
spots(Dog::make("Spot"), "SpotHouse");
cout << "Entering copy-construction" << endl;
DogHouse bobs(fidos);
cout << "After copy-constructing bobs" << endl;
cout << "fidos:" << fidos << endl;
cout << "spots:" << spots << endl;
cout << "bobs:" << bobs << endl;
cout << "Entering spots = fidos" << endl;
spots = fidos;
cout << "After spots = fidos" << endl;
cout << "spots:" << spots << endl;
cout << "Entering self-assignment" << endl;
bobs = bobs;
cout << "After self-assignment" << endl;
cout << "bobs:" << bobs << endl;
// Comment out the following lines:
cout << "Entering rename(\"Bob\")" << endl;
bobs.getDog()->rename("Bob");
cout << "After rename(\"Bob\")" << endl;
} ///:∼
类Dog
是由一个DogHouse
指向的对象。它包含一个引用计数以及控制和读取引用计数的函数。这里有一个复制构造器,所以你可以从现有的 ?? 中创建一个新的。
attach( )
函数增加一个Dog
的引用计数,表示有另一个对象正在使用它,而detach( )
减少引用计数。如果引用计数变为零,那么就没有人再使用它了,所以成员函数通过说delete this
来销毁自己的对象。
在您进行任何修改(比如重命名一个Dog
)之前,您应该确保您没有更改某个其他对象正在使用的Dog
。你可以通过调用DogHouse::unalias( )
来实现,后者又调用Dog::unalias( )
。如果引用计数为 1(意味着没有其他人指向那个Dog
),后一个函数将返回现有的Dog
指针,但是如果引用计数大于 1,将复制Dog
。
复制构造器不是创建自己的内存,而是将Dog
分配给源对象的Dog
。然后,因为现在有一个额外的对象在使用那个内存块,所以它通过调用Dog::attach( )
来增加引用计数。
operator=
处理一个已经在=
左侧创建的对象,所以它必须首先通过为那个Dog
调用detach( )
来清理它,如果没有其他人在使用它,这将销毁旧的Dog
。然后operator=
重复复制构造器的行为。请注意,它首先检查您是否将同一个对象分配给了它自己。
析构函数调用detach( )
有条件地析构Dog
。
要实现写入时复制,您必须控制写入内存块的所有操作。例如,renameDog( )
成员函数允许你改变内存块中的值。但是首先,它使用unalias( )
来防止修改一个别名Dog
(一个有不止一个DogHouse
对象指向它的Dog
)。如果你需要从一个DogHouse
中产生一个指向一个Dog
的指针,你首先需要unalias( )
这个指针。
main( )
测试必须正确工作以实现引用计数的各种函数:构造器、复制构造器、operator=
和析构函数。它还通过调用renameDog( )
来测试写入时复制。
下面是输出(经过一点重新格式化):
Creating Dog: [Fido], rc = 1
Created DogHouse: [FidoHouse]
contains [Fido], rc = 1
Creating Dog: [Spot], rc = 1
Created DogHouse: [SpotHouse]
contains [Spot], rc = 1
Entering copy-construction
Attached Dog: [Fido], rc = 2
DogHouse copy-constructor:
[copy-constructed FidoHouse]
contains [Fido], rc = 2
After copy-constructing bobs
fidos:[FidoHouse] contains [Fido], rc = 2
spots:[SpotHouse] contains [Spot], rc = 1
bobs:[copy-constructed FidoHouse]
contains [Fido], rc = 2
Entering spots = fidos
Detaching Dog: [Spot], rc = 1
Deleting Dog: [Spot], rc = 0
Attached Dog: [Fido], rc = 3
DogHouse operator= : [FidoHouse assigned]
contains [Fido], rc = 3
After spots = fidos
spots:[FidoHouse assigned] contains [Fido],rc = 3
Entering self-assignment
DogHouse operator= : [copy-constructed FidoHouse]
contains [Fido], rc = 3
After self-assignment
bobs:[copy-constructed FidoHouse]
contains [Fido], rc = 3
Entering rename("Bob")
After rename("Bob")
DogHouse destructor: [copy-constructed FidoHouse]
contains [Fido], rc = 3
Detaching Dog: [Fido], rc = 3
DogHouse destructor: [FidoHouse assigned]
contains [Fido], rc = 2
Detaching Dog: [Fido], rc = 2
DogHouse destructor: [FidoHouse]
contains [Fido], rc = 1
Detaching Dog: [Fido], rc = 1
Deleting Dog: [Fido], rc = 0
通过研究输出、跟踪源代码和试验程序,您将加深对这些技术的理解。
自动运算符=创建
因为将一个对象分配给同类型的另一个对象是大多数人期望可能的活动,所以如果你不创建一个type::operator=(type)
,编译器会自动创建一个。这个操作符的行为模仿了自动创建的复制构造器的行为;如果该类包含对象(或者是从另一个类继承的),那么这些对象的operator=
将被递归调用。这称为成员式分配。参见清单 12-14 中的示例。
清单 12-14 。阐释成员式分配
//: C12:AutomaticOperatorEquals.cpp
#include <iostream>
using namespace std;
class Cargo {
public:
Cargo& operator=(const Cargo&) {
cout << "inside Cargo::operator=()" << endl;
return *this;
}
};
class Truck {
Cargo b;
};
int main() {
Truck a, b;
a = b; // Prints: "inside Cargo::operator=()"
} ///:∼
为Truck
自动生成的operator=
调用Cargo::operator=
。
一般来说,你不想让编译器为你做这些。任何复杂程度的类(尤其是当它们包含指针时!)你想显式创建一个operator=
。如果你真的不想让人们执行赋值,就把operator=
声明为一个private
函数。
注意你不需要定义它,除非你在类内部使用它。
自动类型转换
在 C 和 C++ 中,如果编译器发现一个表达式或函数调用使用的类型不是它所需要的类型,它通常会自动执行从它所拥有的类型到它想要的类型的类型转换。在 C++ 中,通过定义自动类型转换函数,您可以为用户定义的类型实现同样的效果。这些函数有两种类型:一种特定类型的构造器和一种重载运算符。
构造器转换
如果您定义一个构造器,该构造器将另一种类型的对象(或引用)作为其单个参数,则该构造器允许编译器执行自动类型转换。参见清单 12-15 中的示例。
清单 12-15 。说明自动类型转换
//: C12:AutomaticTypeConversion.cpp
// Type conversion constructor
class One {
public:
One() {}
};
class Two {
public:
Two(const One&) {}
};
void f(Two) {}
int main() {
One one;
f(one); // Wants a Two, has a One
} ///:∼
当编译器看到用一个One
对象调用f( )
时,它会查看f( )
的声明,并注意到它需要一个Two
。然后,它会查看是否有办法从一个One
获取一个Two
,并找到构造器Two::Two(One)
,它会悄悄地调用它。得到的Two
对象被交给f( )
。
在这种情况下,自动类型转换让你免去了定义两个重载版本f( )
的麻烦。然而,代价是对Two
的隐藏构造器调用,如果你关心对f( )
调用的效率,这可能很重要。
阻止构造器转换
有时,通过构造器进行自动类型转换会导致问题。要关闭它,您可以通过以关键字explicit
开头来修改构造器(它只适用于构造器)。清单 12-16 使用这个关键字修改清单 12-15 中Two
类的构造器。
清单 12-16 。说明显式关键字的使用
//: C12:ExplicitKeyword.cpp
// Using the "explicit" keyword
class One {
public:
One() {}
};
class Two {
public:
explicit Two(const One&) {}
};
void f(Two) {}
int main() {
One one;
//! f(one); // No auto conversion allowed
f(Two(one)); // OK -- user performs conversion
} ///:∼
通过使Two
的构造器显式化,编译器被告知不要使用该特定构造器执行任何自动转换(该类中的其他非explicit
构造器仍然可以执行自动转换)。如果用户想要进行转换,必须写出代码。在清单 12-16 的中,f(Two(one))
从one
创建了一个Two
类型的临时对象,就像编译器在清单 12-15 的中所做的一样。
运算符转换
产生自动类型转换的第二种方法是通过运算符重载。您可以创建一个成员函数,该函数接受当前类型,并使用关键字operator
后跟您想要转换的类型,将其转换为所需的类型。这种形式的操作符重载是独特的,因为你似乎没有指定返回类型——返回类型是你重载的操作符的名称。参见清单 12-17 中的示例。
清单 12-17 。阐释运算符重载转换
//: C12:OperatorOverloadingConversion.cpp
class Three {
int i;
public:
Three(int ii = 0, int = 0) : i(ii) {}
};
class Four {
int x;
public:
Four(int xx) : x(xx) {}
operator Three() const { return Three(x); }
};
void g(Three) {}
int main() {
Four four(1);
g(four);
g(1); // Calls Three(1,0)
} ///:∼
使用构造器技术,目标类执行转换,但是使用操作符,源类执行转换。构造器技术的价值在于,您可以在创建新类时向现有系统添加新的转换路径。然而,创建一个单参数构造器总是定义一个自动的类型转换(即使它有不止一个参数,如果其余的参数是默认的),这可能不是你想要的(在这种情况下你可以使用explicit
关闭它)。此外,没有办法使用从用户定义类型到内置类型的构造器转换;这只有在运算符重载的情况下才有可能。
反身性
使用全局重载操作符而不是成员操作符的一个最方便的原因是,在全局版本中,自动类型转换可以应用于任一操作数,而对于成员对象,左边的操作数必须已经是正确的类型。如果你希望两个操作数都被转换,全局版本可以节省大量的代码;参见清单 12-18 。
清单 12-18 。说明重载中的反身性
//: C12:ReflexivityInOverloading.cpp
class Number {
int i;
public:
Number(int ii = 0) : i(ii) {}
const Number
operator+(const Number& n) const {
return Number(i + n.i);
}
friend const Number
operator-(const Number&, const Number&);
};
const Number
operator-(const Number& n1,
const Number& n2) {
return Number(n1.i - n2.i);
}
int main() {
Number a(47), b(11);
a + b; // OK
a + 1; // 2nd arg converted to Number
//! 1 + a; // Wrong! 1st arg not of type Number
a - b; // OK
a - 1; // 2nd arg converted to Number
1 - a; // 1st arg converted to Number
} ///:∼
类Number
有一个成员operator+
和一个friendoperator–
。因为有一个接受单个int
参数的构造器,所以一个int
可以自动转换成一个Number
,但是只有在正确的条件下。在main( )
中,您可以看到向另一个Number
添加一个Number
工作正常,因为它与重载操作符完全匹配。此外,当编译器看到一个Number
后跟一个+
和一个int
时,它可以匹配到成员函数Number::operator+
,并使用构造器将int
参数转换为Number
。但是当它看到一个int
、一个+
和一个Number
时,它不知道该怎么做,因为它只有Number::operator+
,这要求左操作数已经是一个Number
对象。因此,编译器会发出一个错误。
有了friendoperator–
,事情就不一样了。编译器需要尽可能地填充它的两个参数;它并不局限于用一个Number
作为左边的参数。因此,如果它看到
1 – a
它可以使用构造器将第一个参数转换成一个Number
。
有时,您希望能够通过使操作符成为成员来限制它们的使用。例如,当矩阵乘以向量时,向量必须在右边。但是如果您希望您的操作符能够转换任何一个参数,那么就让操作符成为一个friend
函数。
幸运的是,编译器不会接受1 – 1
并将两个参数都转换成Number
对象,然后调用operator–
。这意味着现有的 C 代码可能会突然开始以不同的方式工作。编译器首先匹配“最简单”的可能性,这是表达式1 – 1
的内置操作符。
类型转换示例
自动类型转换非常有用的一个例子是任何封装字符串的类(在这种情况下,您只需使用标准的 C++ string
类实现该类,因为它很简单)。如果没有自动类型转换,如果你想使用标准 C 库中所有现有的字符串函数,你必须为每个函数创建一个成员函数,如清单 12-19 所示。
清单 12-19 。不使用自动类型转换
//: C12:Strings1.cpp
// No auto type conversion
#include "../require.h"
#include <cstring>
#include <cstdlib>
#include <string>
using namespace std;
class Stringc {
string s;
public:
Stringc(const string& str = "") : s(str) {}
int strcmp(const Stringc& S) const {
return ::strcmp(s.c_str(), S.s.c_str());
}
// ... etc., for every function in string.h
};
int main() {
Stringc s1("hello"), s2("there");
s1.strcmp(s2);
} ///:∼
这里只创建了strcmp( )
函数,但是您必须为可能需要的<cstring>
中的每个人创建一个相应的函数。幸运的是,您可以提供一个自动的类型转换,允许访问<cstring>
中的所有函数,如清单 12-20 中的所示。
清单 12-20 。使用自动类型转换
//: C12:Strings2.cpp
// With auto type conversion
#include "../require.h"
#include <cstring>
#include <cstdlib>
#include <string>
using namespace std;
class Stringc {
string s;
public:
Stringc(const string& str = "") : s(str) {}
operator const char*() const {
return s.c_str();
}
};
int main() {
Stringc s1("hello"), s2("there");
strcmp(s1, s2); // Standard C function
strspn(s1, s2); // Any string function!
} ///:∼
现在任何带有char*
参数的函数也可以带有Stringc
参数,因为编译器知道如何从Stringc
生成char*
。
自动类型转换中的陷阱
因为编译器必须选择如何安静地执行类型转换,所以如果您没有正确地设计转换,它会遇到麻烦。一个简单而明显的情况发生在一个类X
上,它可以将自己转换成一个带有operator Y( )
的类Y
的对象。如果类Y
有一个接受类型X
的单个参数的构造器,这表示相同的类型转换。编译器现在有两种从X
到Y
的方法,所以当转换发生时,它会产生一个模糊错误;参见清单 12-21 。
清单 12-21 。说明自动类型转换中的二义性
//: C12:TypeConversionAmbiguity.cpp
class Orange; // Class declaration
class Apple {
public:
operator Orange() const; // Convert Apple to Orange
};
class Orange {
public:
Orange(Apple); // Convert Apple to Orange
};
void f(Orange) {}
int main() {
Apple a;
//! f(a); // Error: ambiguous conversion
} ///:∼
这个问题最明显的解决方法就是不去做。只需为从一种类型到另一种类型的自动转换提供一条路径。
当您提供到多种类型的自动转换时,会出现一个更难发现的问题。这有时被称为扇出;参见清单 12-22 。
清单 12-22 。说明“扇出”
//: C12:TypeConversionFanout.cpp
class Orange {};
class Pear {};
class Apple {
public:
operator Orange() const;
operator Pear() const;
};
// Overloaded eat():
void eat(Orange);
void eat(Pear);
int main() {
Apple c;
//! eat(c);
// Error: Apple -> Orange or Apple -> Pear ???
} ///:∼
类Apple
自动转换为Orange
和Pear
。关于这一点的阴险之处在于,直到有人无意中出现并创建了两个过载版本的eat( )
时,才出现问题。(只有一个版本,main( )
中的代码运行良好。)
同样,解决方案——以及自动类型转换的通用口号——是只提供一个从一种类型到另一种类型的自动转换。您可以转换为其他类型;它们不应该是自动的。您可以用像makeA( )
和makeB( )
这样的名字创建显式函数调用。
隐藏活动
自动类型转换可能会引入比您想象的更多的底层活动。作为一个小脑筋急转弯,看看前面对CopyingVsInitialization.cpp
程序的如下修改(清单 12-23 )。
清单 12-23 。阐释自动类型转换中的隐藏活动
//: C12:CopyingVsInitialization2.cpp
class Fi {};
class Fee {
public:
Fee(int) {}
Fee(const Fi&) {}
};
class Fo {
int i;
public:
Fo(int x = 0) : i(x) {}
operator Fee() const { return Fee(i); }
};
int main() {
Fo fo;
Fee fee = fo;
} ///:∼
没有从Fo
对象创建Fee fee
的构造器。然而,Fo
可以自动转换成Fee
。没有从Fee
创建Fee
的复制构造器,但是这是编译器可以为你创建的特殊函数之一。(默认构造器、复制构造器、 operator=
和析构函数可以由编译器自动合成。)所以对于相对无伤大雅的说法
Fee fee = fo;
调用自动类型转换运算符,并创建一个复制构造器。
小心使用自动类型转换。和所有的操作符重载一样,当它显著地减少了编码任务时,它是非常好的,但是通常不值得免费使用。
审查会议
- 1.存在操作符重载的全部原因是为了那些使生活变得更容易的情况。没什么特别神奇的;重载操作符只是名字有趣的函数,当编译器发现正确的模式时,它会为你调用函数。
- 2.但是如果操作符重载没有给你(类的创建者)或类的用户提供显著的好处,不要通过添加它来混淆这个问题。
十三、动态对象创建
有时您知道程序中对象的确切数量、类型和生命周期,但并不总是如此。一个空中交通系统需要处理多少架飞机?一个 CAD 系统会使用多少个形状?一个网络中有多少个节点?
要解决一般的编程问题,您必须能够在运行时创建和销毁对象。当然,C 一直提供动态内存分配函数malloc()
和free()
(以及malloc()
的变体),它们在运行时从堆(也称为自由存储 )中分配存储。
然而,这在 C++ 中根本行不通。构造器不允许你给它传递内存的地址来初始化,这是有原因的。如果可以做到这一点,您可以执行以下一项或多项操作:
- 忘记吧。那么 C++ 中有保证的对象初始化就不能得到保证。
- 在初始化对象之前,意外地对它做了一些事情,期望正确的事情发生(类似于在汽车中错误地移动点火钥匙并被卡住)。
- 给它一个错误大小的物体(类似于试图用摩托车的点火钥匙启动汽车)。
当然,即使你做了所有正确的事情,任何修改你的程序的人也容易犯同样的错误。不正确的初始化是造成大部分编程问题的原因,因此保证构造器调用在堆上创建的对象尤为重要。
那么 C++ 如何保证正确的初始化和清理,而also
却允许你在堆上动态创建对象呢?
答案是将动态对象创建引入语言的核心。malloc()
和free()
是库函数,因此不受编译器的控制。然而,如果有一个操作符来执行动态存储分配和初始化的组合动作,有和另一个操作符来执行清理和释放存储的组合动作,编译器仍然可以保证为所有对象调用构造器和析构函数。
在本章中,你将学习 C++ 的new
和delete
如何通过在堆上安全地创建对象来优雅地解决这个问题。
对象创建
创建 C++ 对象时,会发生两个事件。
- 为该对象分配存储空间。
- 调用构造器来初始化 即存储。
现在你应该明白第二步总是发生。C++ 强制执行它,因为未初始化的对象是程序错误的主要来源。在哪里或者如何创建对象并不重要——总是调用构造器。
然而,步骤 1 可以以几种方式发生,或者在交替的时间发生。
- 在程序开始之前,可以在静态存储区中分配存储空间。这种存储存在于程序的整个生命周期中。
- 每当到达一个特定的执行点(左大括号),就可以在堆栈上创建存储。该存储在互补执行点(右大括号)自动释放。这些堆栈分配操作内置于处理器的指令集中,非常有效。然而,当你写程序时,你必须准确地知道你需要多少个变量,这样编译器才能生成正确的代码。
- 可以从称为堆(也称为自由存储)的内存池中分配存储。这叫做动态内存分配 。为了分配这个内存,在运行时调用一个函数;这意味着你可以在任何时候决定你需要多少内存。您还负责决定何时释放内存,这意味着该内存的生命周期可以根据您的选择而定;它不是由范围决定的。
这三个区域通常被放在一个连续的物理内存中:静态区域、堆栈和堆(按照编译器编写器确定的顺序)。然而,没有规则。堆栈可能在一个特殊的位置,堆可以通过从操作系统调用内存块来实现。作为一名程序员,这些事情通常对你是屏蔽的,所以你需要考虑的是当你调用它时内存就在那里。
c 对堆的处理方法
为了在运行时动态分配内存,C 在其标准库中提供了函数:malloc()
及其变体calloc()
和realloc()
从堆中产生内存,以及free()
将内存释放回堆中。这些功能很实用,但是很原始,需要程序员的理解和关注。要使用 C 的动态内存函数在堆上创建一个类的实例,你必须做类似于清单 13-1 中的事情。
清单 13-1 。带有类对象的 malloc()
//: C13:MallocClass.cpp
// Malloc with class objects
// What you'd have to do if not for "new"
#include "../require.h" // To be INCLUDED from *Chapter 9*
#include <cstdlib> // malloc() & free()
#include <cstring> // memset()
#include <iostream>
using namespace std;
classObj {
int i, j, k;
enum { sz = 100 };
charbuf[sz];
public:
void initialize() { // Can't use constructor
cout << "initializing Obj" << endl;
i = j = k = 0;
memset(buf, 0, sz);
}
void destroy() const { // Can't use destructor
cout << "destroying Obj" << endl;
}
};
int main() {
Obj *obj = (Obj*)malloc(sizeof(Obj));
require(obj != 0);
obj->initialize();
// ... sometime later:
obj->destroy();
free(obj);
} ///:∼
您可以看到使用malloc()
为行中的对象创建存储:
Obj* obj = (Obj*)malloc(sizeof(Obj));
这里,用户必须确定对象的大小(一个错误的位置)。malloc()
返回一个void*
,因为它只是产生一个内存的补丁,而不是一个对象。C++ 不允许将void*
赋给任何其他指针,所以必须进行强制转换。
因为malloc()
可能找不到任何内存(在这种情况下,它返回零),所以您必须检查返回的指针以确保它成功。
但最糟糕的问题是这条线:
Obj->initialize();
如果用户正确地做到了这一步,他们必须记住在使用对象之前初始化它。请注意,没有使用构造器,因为无法显式调用该构造器;当一个对象被创建时,编译器会为你调用它。这里的问题是,用户现在可以选择在使用对象之前忘记执行初始化,从而重新引入了一个主要的错误来源。
还发现很多程序员似乎觉得 C 的动态内存函数太混乱太复杂;使用虚拟内存机器的 C 程序员在静态存储区域分配巨大的变量数组,以避免考虑动态内存分配,这种情况并不少见。因为 C++ 试图让普通程序员安全、轻松地使用库,所以 C 的动态内存方法是不可接受的。
操作员新增
C++ 中的解决方案是将创建一个对象所需的所有操作合并到一个名为new
的操作符中。当你用new
(使用新表达式)创建一个对象时,它在堆上分配足够的存储空间来容纳该对象,并调用该存储空间的构造器。因此,如果你说
MyType *fp = new MyType(1,2);
在运行时,调用相当于malloc(sizeof(MyType))
的函数(通常,它实际上是对malloc()
的调用),调用MyType
的构造器,将结果地址作为this
指针,使用(1, 2)
作为参数列表。当指针被分配给fp
时,它是一个活动的、初始化的对象;在那之前你连手都拿不到。它也是自动正确的MyType
类型,所以没有铸造是必要的。
默认的new
在将地址传递给构造器之前检查以确保内存分配成功,因此您不必显式地确定调用是否成功。在这一章的后面,你会发现如果没有记忆会发生什么。
您可以使用任何可用于该类的构造器来创建 new-expression。如果构造器没有参数,则编写不带构造器参数列表的 new-expression,如下所示:
MyType *fp = new MyType;
注意在堆上创建对象的过程变得多么简单——一个表达式,内置了所有的大小调整、转换和安全检查。在堆上创建一个对象和在栈上一样容易。
操作员删除
new-expression 的补充是 delete-expression ,它首先调用析构函数,然后释放内存(通常调用free()
)。正如 new-expression 返回指向对象的指针一样,delete-expression 需要对象的地址,如下所示:
delete fp;
这将析构并释放先前创建的动态分配的MyType
对象的存储空间。
只能为由new
创建的对象调用delete
。如果你malloc()
(或calloc()
或realloc()
)一个对象,然后delete
它,行为是未定义的。因为new
和delete
的大多数默认实现都使用malloc()
和free()
,所以您可能最终会在不调用析构函数的情况下释放内存。
如果你删除的指针是零,什么都不会发生。出于这个原因,人们经常建议在删除后立即将指针设置为零,以防止删除两次。多次删除一个对象绝对不是一件好事,而且会引发问题。清单 13-2 显示了初始化的发生。
清单 13-2 。说明新建和删除
//: C13:Tree.h
#ifndef TREE_H
#define TREE_H
#include <iostream>
class Tree {
int height;
public:
Tree(int treeHeight) : height(treeHeight) {}
∼Tree() { std::cout << "*"; }
Friend std::ostream&
operator<<(std::ostream &os, const Tree* t) {
return os << "Tree height is: "
<< t->height << std::endl;
}
};
#endif // TREE_H ///:∼
//: C13:NewAndDelete.cpp
// Simple demo of new & delete
#include "Tree.h" // Header FILE to be INCLUDED from above
using namespace std;
int main() {
Tree *t = new Tree(40);
cout << t;
delete t;
} ///:∼
您可以通过打印出Tree
的值来证明构造器被调用。在这里,这是通过重载operator<<
来使用ostream
和Tree*
来完成的。但是,请注意,即使函数被声明为friend
,它也被定义为内联函数!这仅仅是一种便利;将friend
函数定义为类的内联函数不会改变friend
的状态,也不会改变它是一个全局函数而不是类成员函数的事实。还要注意,返回值是整个输出表达式的结果,这是一个ostream&
( ,它必须满足函数的返回值类型)。
内存管理器开销
当您在堆栈上创建自动对象时,对象的大小和它们的生命周期就内置在生成的代码中,因为编译器知道确切的类型、数量和范围。在堆上创建对象会带来额外的时间和空间开销。下面是一个典型的场景。
注你可以用calloc()
或者realloc()
代替malloc()
。
您调用malloc()
,它从池中请求一块内存。(这段代码实际上可能是malloc()
的一部分。)
在池中搜索足够大的内存块来满足请求。这是通过检查某种地图或目录来完成的,该地图或目录显示哪些块当前正在使用,哪些块是可用的。这是一个快速的过程,但它可能需要多次尝试,所以它可能不是确定性的——也就是说,你不一定能指望malloc()
总是花费完全相同的时间。
在返回指向该块的指针之前,必须记录该块的大小和位置,这样对malloc()
的进一步调用将不会使用它,并且当您调用free()
时,系统知道要释放多少内存。
所有这一切的实现方式可以千差万别。例如,没有什么可以阻止在处理器中实现内存分配原语。如果你很好奇,你可以写测试程序来猜测你的malloc()
是如何实现的。你也可以阅读库源代码,如果你有的话(GNU C 源代码总是可用的)。
重新设计的早期示例
使用new
和delete
,本书前面介绍的Stash
例子可以用本书到目前为止讨论的所有特性重写。研究新代码还会给你一个有用的主题回顾。
在书中的这一点上,Stash
和Stack
类都不会“拥有”它们所指向的对象;也就是说,当Stash
或Stack
对象超出范围时,它不会为它所指向的所有对象调用delete
。这是不可能的,因为为了更通用,它们持有void
指针。如果你delete
一个void
指针,唯一发生的事情就是内存被释放,因为没有类型信息,编译器也没有办法知道调用什么析构函数。
删除 void*大概是一个 Bug
值得指出的是,如果你为一个void*
调用delete
,它几乎肯定会成为你程序中的一个 bug,除非那个指针的目的地非常简单;特别是,它不应该有析构函数。清单 13-3 显示了会发生什么。
清单 13-3 。示出了不良空指针删除的情况
//: C13:BadVoidPointerDeletion.cpp
// Deleting void pointers can cause memory leaks
#include <iostream>
using namespace std;
class Object {
void *data; // Some storage
const int size;
const char id;
public:
Object(int sz, char c) : size(sz), id(c) {
data = new char[size];
cout << "Constructing object " << id
<< ", size = " << size << endl;
}
∼Object() {
cout << "Destructing object " << id << endl;
delete []data; // OK, just releases storage,
// no destructor calls are necessary
}
};
int main() {
Object* a = new Object(40, 'a');
delete a;
void* b = new Object(40, 'b');
delete b;
} ///:∼
类Object
包含一个被初始化为“原始”数据的void*
(它不指向有析构函数的对象)。在Object
析构函数中,调用delete
来调用这个void*
没有任何负面影响,因为你唯一需要做的事情就是释放存储空间。
然而,在main()
中,你可以看到delete
知道它正在处理什么类型的对象是非常必要的。以下是输出结果:
Constructing object a, size = 40
Destructing object a
Constructing object b, size = 40
因为delete a
知道a
指向一个Object
,析构函数被调用,因此分配给data
的存储空间被释放。然而,如果你像在delete b
的情况下一样通过void*
操作一个对象,唯一发生的事情是Object
的存储被释放——但是析构函数没有被调用,所以没有释放data
指向的内存。当这个程序编译时,你可能看不到任何警告信息;编译器假设你知道你在做什么。所以你得到一个非常安静的内存泄漏。
如果你的程序有内存泄漏,搜索所有的delete
语句并检查被删除的指针的类型。如果是一个void*
,那么您可能已经找到了内存泄漏的一个来源。
注意然而,C++ 为内存泄漏提供了大量的其他机会。
带指针的清理责任
为了使Stash
和Stack
容器灵活(能够容纳任何类型的对象),它们将容纳void
指针。这意味着当一个指针从Stash
或Stack
对象返回时,你必须在使用它之前将它转换成合适的类型;如前所述,在删除它之前,还必须将它转换为正确的类型,否则会出现内存泄漏。
另一个内存泄漏问题与确保容器中的每个对象指针都被真正调用有关。容器不能“拥有”指针,因为它把它作为一个void*
持有,因此不能执行适当的清理。使用者必须负责清理物品。如果将指向在堆栈上创建的对象和在堆上创建的对象的指针添加到同一个容器中,就会产生严重的问题,因为删除表达式对于没有在堆上分配的指针来说是不安全的。()当你从容器中取回一个指针时,你怎么知道它的对象被分配到了哪里?)因此,您必须确保存储在以下版本的Stash
和Stack
中的对象只能在堆上创建,要么通过精心编程,要么通过创建只能在堆上构建的类。
确保客户端程序员负责清理容器中的所有指针也很重要。在前面的例子中,您已经看到了Stack
类如何在其析构函数中检查所有的Link
对象是否已经被弹出。然而,对于指针的Stash
,需要另一种方法。
存放指针
这个新版本的Stash
类叫做PStash
,拥有指针指向在堆中独立存在的对象,而之前章节中的旧Stash
通过值将对象复制到Stash
容器中。使用new
和delete
,保存指向已经在堆上创建的对象的指针既容易又安全。清单 13-4 包含了“指针Stash
的头文件
清单 13-4 。“指针存储”的头文件
//: C13:PStash.h
// Holds pointers instead of objects
#ifndef PSTASH_H
#define PSTASH_H
class PStash {
int quantity; // Number of storage spaces
int next; // Next empty space
// Pointer storage:
void** storage;
void inflate(int increase);
public:
PStash() : quantity(0), storage(0), next(0) {}
∼PStash();
int add(void* element);
void* operator[](int index) const; // Fetch
// Remove the reference from this PStash:
void* remove(int index);
// Number of elements in Stash:
int count() const { return next; }
};
#endif // PSTASH_H ///:∼
底层数据元素非常相似,但是现在storage
是一个由void
指针组成的数组,并且该数组的存储分配是通过new
而不是malloc()
来执行的。在表达式中
void** st = new void*[quantity + increase];
分配的对象类型是一个void*
,所以表达式分配了一个void
指针数组。
析构函数删除保存void
指针的存储,而不是试图删除它们所指向的内容(如前所述,这将释放它们的存储并且不调用析构函数,因为void
指针没有类型信息)。
另一个变化是用operator[ ]
替换了fetch()
函数,这在语法上更有意义。然而,还是会返回一个void*
,所以用户必须记住哪些类型存储在容器中,并在取出它们时转换指针(这个问题将在后面的章节中讨论)。
清单 13-5 显示了成员函数的定义。
清单 13-5 。“指针存储”的实现
//: C13:PStash.cpp {O}
// Pointer Stash definitions
#include "PStash.h" // To be INCLUDED from above
#include "../require.h"
#include <iostream>
#include <cstring> // 'mem' functions
using namespace std;
int PStash::add(void* element) {
const int inflateSize = 10;
if(next >= quantity)
inflate(inflateSize);
storage[next++] = element;
return(next - 1); // Index number
}
// No ownership:
PStash::∼PStash() {
for(int i = 0; i < next; i++)
require(storage[i] == 0,
"PStash not cleaned up");
delete []storage;
}
// Operator overloading replacement for fetch
void* PStash::operator[](int index) const {
require(index >= 0,
"PStash::operator[] index negative");
if(index >= next)
return 0; // To indicate the end
// Produce pointer to desired element:
return storage[index];
}
void* PStash::remove(int index) {
void* v = operator[](index);
// "Remove" the pointer:
if(v != 0) storage[index] = 0;
return v;
}
void PStash::inflate(int increase) {
const int psz = sizeof(void*);
void** st = new void*[quantity + increase];
memset(st, 0, (quantity + increase) * psz);
memcpy(st, storage, quantity * psz);
quantity += increase;
delete []storage; // Old storage
storage = st; // Point to new memory
} ///:∼
除了存储一个指针而不是整个对象的副本之外,add()
函数实际上和以前一样。
修改了inflate()
代码来处理void*
数组的分配,而不是之前的设计,之前的设计只处理原始字节。在这里,标准的 C 库函数memset()
首先用于将所有新的内存设置为零,而不是使用之前的通过数组索引进行复制的方法(这并不是绝对必要的,因为 PStash 大概是正确地管理所有的内存——但是多一点额外的关心通常不会有什么坏处)。然后memcpy()
将现有数据从旧位置移动到新位置。通常,像memset()
和memcpy()
这样的函数已经随着时间的推移进行了优化,所以它们可能比前面显示的循环更快。但是对于像inflate()
这样可能不会经常使用的函数,您可能看不到性能差异。然而,函数调用比循环更简洁的事实可能有助于防止编码错误。
为了将对象清理的责任完全放在客户端程序员的肩上,有两种方法可以访问PStash
中的指针:operator[]
,它简单地返回指针,但将它作为容器的一个成员;第二个成员函数叫做remove()
,它返回指针,但也通过将该位置赋为零将它从容器中移除。当调用PStash
的析构函数时,它检查以确保所有的对象指针都已被移除;如果没有,系统会通知您,这样您就可以防止内存泄漏(在接下来的章节中将会有更好的解决方案)。
一次测试
清单 13-6 是为PStash
改写的旧的Stash
测试程序。
清单 13-6 。“指针存储”的测试程序
//: C13:PStashTest.cpp
//{L} PStash
// Test of pointer Stash
#include "PStash.h"
#include "../require.h"
#include <iostream>
#include <fstream>
#include <string>
using namespace std;
int main() {
PStash intStash;
// 'new' works with built-in types, too. Note
// the "pseudo-constructor" syntax:
for(int i = 0; i < 25; i++)
intStash.add(new int(i));
for(int j = 0; j < intStash.count(); j++)
cout << "intStash[" << j << "] = "
<< *(int*)intStash[j] << endl;
// Clean up:
for(int k = 0; k < intStash.count(); k++)
delete intStash.remove(k);
ifstream in ("PStashTest.cpp");
assure(in, "PStashTest.cpp");
PStash stringStash;
string line;
while(getline(in, line))
stringStash.add(new string(line));
// Print out the strings:
for(int u = 0; stringStash[u]; u++)
cout << "stringStash[" << u << "] = "
<< *(string*)stringStash[u] << endl;
// Clean up:
for(int v = 0; v < stringStash.count(); v++)
delete (string*)stringStash.remove(v);
} ///:∼
和以前一样,Stash
es 被创建并填充了信息,但是这次信息是从new
表达式中产生的指针。在第一种情况下,请注意这一行。
intStash.add(new int(i));
表达式new int(i)
使用伪构造器的形式,所以在堆上为新的int
对象创建存储,并且int
被初始化为值i
。
打印时,PStash::operator[ ]
返回的值必须转换成合适的类型;对程序中其余的PStash
对象重复这一过程。这是使用void
指针作为底层表示的不良效果,将在后面的章节中解决。
第二个测试打开源代码文件,一次一行地将它读入另一个PStash
。使用getline()
将每一行读入一个string
,然后从line
创建一个newstring
来制作该行的独立副本。如果每次只传入line
的地址,就会得到一大串指向line
的指针,其中只包含从文件中读取的最后一行。
当获取指针时,您会看到表达式。
*(string*)stringStash[v]
从operator[ ]
返回的指针必须被转换成string*
以赋予它正确的类型。然后string*
被解引用,所以表达式计算为一个对象,此时编译器看到一个string
对象要发送给cout
。
在堆上创建的对象必须通过使用remove()
语句来销毁,否则您将在运行时得到一条消息,告诉您还没有完全清除PStash
中的对象。注意,在使用int
指针的情况下,不需要强制转换,因为int
没有析构函数,您需要的只是释放内存,如下所示:
delete intStash.remove(k);
然而,对于string
指针,如果你忘记进行类型转换,你将会有另一个(安静的)内存泄漏,所以类型转换是必要的。
delete (string*)stringStash.remove(k);
这些问题中的一部分(但不是全部)可以使用模板来解决(你将在第十六章中了解到)。
对数组 使用 new 和 delete
在 C++ 中,您可以同样轻松地在堆栈或堆上创建对象数组,并且(当然)为数组中的每个对象调用构造器。然而,有一个限制:必须有一个默认的构造器,除了栈上的聚合初始化(参考第六章),因为必须为每个对象调用一个不带参数的构造器。
当使用new
在堆上创建对象数组时,您必须做一些其他的事情。这种阵列的一个例子是
MyType* fp = new MyType[100];
这在堆上为 100 个MyType
对象分配了足够的存储空间,并为每个对象调用构造器。然而,现在你只需要一个MyType*
,它和你说的完全一样
MyType* fp2 = new MyType;
创建单个对象。因为您编写了代码,所以您知道fp
实际上是一个数组的起始地址,所以使用类似于fp[3]
的表达式来选择数组元素是有意义的。但是当你破坏了阵列会发生什么?这些声明
delete fp2; // OK
delete fp; // Not the desired effect
看起来完全一样,它们的效果也会一样。对于给定地址指向的MyType
对象会调用析构函数,然后释放存储。对于fp2
来说,这很好,但是对于fp
来说,这意味着其他 99 个析构函数调用将不会被调用。然而,适当数量的存储仍将被释放,因为它被分配在一个大块中,并且整个块的大小被分配例程藏在某个地方。
解决方案要求你给编译器信息,这实际上是一个数组的起始地址。这是通过以下语法实现的:
delete []fp;
空括号告诉编译器生成代码,获取数组中的对象数,数组创建时存储在某个地方,并为这些数组对象调用析构函数。这实际上是对早期形式的一种改进语法(在旧代码中仍可能偶尔看到);例如,
delete [100]fp;
强迫程序员在数组中包含对象的数量,并引入了程序员出错的可能性。让编译器处理它的额外开销非常低,而且在一个地方指定对象的数量比在两个地方指定对象的数量更好。
使指针更像数组
顺便说一下,上面定义的fp
可以改为指向任何东西,这对数组的起始地址没有意义。将其定义为常量更有意义,因此任何修改指针的尝试都将被标记为错误。要获得这种效果,你可以尝试
int const *q = new int[10];
或者
const int *q = new int[10];
但是在这两种情况下,const
将绑定到int
——也就是说,所指向的,而不是指针本身的质量。相反,你必须说
int* const q = new int[10];
现在可以修改q
中的数组元素了,但是对q
(像q++
)的任何修改都是非法的,因为这是一个普通的数组标识符。
存储空间不足
当operator new()
找不到足够大的连续存储块来存放所需对象时会发生什么?调用一个名为 new- 处理程序的特殊函数。或者更确切地说,检查指向函数的指针,如果指针非零,则调用它所指向的函数。
new-handler 的默认行为是抛出一个异常,这个主题在第十七章中有所涉及。然而,如果您在程序中使用堆分配,明智的做法是至少用一条消息替换 new-handler,这条消息表明您已经用完了内存,然后中止程序。这样,在调试过程中,您将对发生的事情有所了解。对于最后一个程序,您将需要使用更强大的恢复功能。
您通过包含new.h
来替换 new-handler,然后用您想要安装的函数的地址来调用set_new_handler()
;参见清单 13-7 。
清单 13-7 。处理内存不足的情况
//: C13:NewHandler.cpp
// Changing the new-handler
#include <iostream>
#include <cstdlib>
#include <new>
using namespace std;
int count = 0;
void out_of_memory() {
cerr << "memory exhausted after " << count
<< " allocations!" << endl;
exit(1);
}
int main() {
set_new_handler(out_of_memory);
while(1) {
count++;
new int[1000];
// Exhausts memory
}
} ///:∼
new-handler 函数必须不带参数,并且有一个void
返回值。while
循环将继续分配int
对象(并丢弃它们的返回地址),直到空闲存储空间耗尽。在下一次调用new
时,没有存储空间可以分配,因此将调用 new-handler。
new-handler 的行为与operator new()
联系在一起,所以如果你重载了operator new()
(将在下一节讨论)new-handler 在默认情况下不会被调用。如果你仍然希望新的处理程序被调用,你将不得不在你的重载operator new()
中编写代码。
当然,您可以编写更复杂的新处理程序,甚至可以尝试回收内存(通常称为垃圾收集器 )。这不是程序员新手的工作。
重载新增和删除
当你创建一个新的表达式时,会发生两件事。首先,使用operator new()
分配存储,然后调用构造器。在删除表达式中,调用析构函数,然后使用operator delete()
释放存储空间。构造器和析构函数的调用永远不受你的控制(,否则你可能会不小心破坏它们,但是你可以改变存储分配函数operator new()
和operator delete()
。
new
和delete
使用的内存分配系统是为通用目的而设计的。然而,在特殊情况下,它不能满足你的需要。改变分配器最常见的原因是效率:您可能会创建和销毁某个特定类的如此多的对象,以至于成为速度瓶颈。C++ 允许你重载new
和delete
来实现你自己的存储分配方案,所以你可以处理这样的问题。
另一个问题是堆碎片。通过分配不同大小的对象,可以分解堆,从而有效地耗尽存储空间。也就是说,存储可能是可用的,但是由于碎片化,没有足够大的碎片来满足您的需求。通过为特定的类创建自己的分配器,可以确保这种情况永远不会发生。
在嵌入式和实时系统中,一个程序可能需要在有限的资源下运行很长时间。这样的系统可能还要求内存分配总是花费相同的时间,并且不允许堆耗尽或碎片。自定义内存分配器是解决方案;否则,程序员会避免在这种情况下一起使用new
和delete
,从而错过宝贵的 C++ 资产。
当你重载operator new()
和operator delete()
时,重要的是要记住你只是改变了原始存储的分配方式。编译器将简单地调用你的new
而不是默认版本来分配存储,然后调用那个存储的构造器。所以,尽管编译器分配存储并且在看到new
时调用构造器,但是当你重载new
时你所能改变的只是存储分配部分。(delete
也有类似的限制。)
当你重载operator new()
时,你也替换了耗尽内存的行为,所以你必须决定在你的operator new()
中做什么:返回零,写一个循环调用新的处理程序并重试分配,或者(通常是*)抛出一个bad_alloc
异常。
重载new
和delete
就像重载任何其他操作符一样。然而,您可以选择重载全局分配器或者为特定的类使用不同的分配器。
重载全局新增和删除
当new
和delete
的全局版本对整个系统不满意时,这是极端的方法。如果你重载了全局版本,你会使缺省值完全不可访问——你甚至不能从你的重定义中调用它们。
重载的new
必须接受一个参数size_t
(标准的 C 标准大小类型)。这个参数由编译器生成并传递给你,它是你负责分配的对象的大小。你必须返回一个指向那个大小(或者更大,如果你有理由这样做的话)的对象的指针,或者如果你找不到内存的话返回一个指向零的指针(在这种情况下,构造器是而不是调用的!).然而,如果你找不到内存,你可能应该做一些比返回 0 更有用的事情,比如调用 new-handler 或者抛出一个异常,来表示有问题。
operator new()
的返回值是一个void*
、而不是指向任何特定类型的指针。你所做的只是产生内存,而不是一个已完成的对象——直到调用构造器时才会发生,这是编译器保证的行为,并且不受你的控制。
operator delete()
将void*
放入由operator new()
分配的内存中。这是一个void*
,因为operator delete()
只有在析构函数被调用后才能获得指针,析构函数从内存中移除了对象 ness 。返回类型为void
。
关于如何重载全局new
和delete
的简单例子,参见清单 13-8 。
清单 13-8 。重载全局 new 和 delete
//: C13:GlobalOperatorNew.cpp
// Overload global new/delete
#include <cstdio>
#include <cstdlib>
using namespace std;
void* operator new(size_t sz) {
printf("operator new: %d Bytes\n", sz);
void* m = malloc(sz);
if(!m) puts("out of memory");
return m;
}
void operator delete(void* m) {
puts("operator delete");
free(m);
}
class S {
int i[100];
public:
S() { puts("S::S()"); }
∼S() { puts("S::∼S()"); }
};
int main() {
puts("creating & destroying an int");
int* p = new int(47);
delete p;
puts("creating & destroying an s");
S *s = new S;
delete s;
puts("creating & destroying S[3]");
S *sa = new S[3];
delete []sa;
} ///:∼
这里你可以看到重载new
和delete
的一般形式。这些为分配器(使用了标准的 C 库函数malloc()
和free()
,这可能也是默认的 new
和 delete
所使用的!)。然而,他们也打印关于他们正在做什么的消息。注意使用了printf()
和puts()
而不是iostream
。这是因为当一个iostream
对象被创建时(像全局cin
、cout
和cerr
),它调用new
来分配内存。有了printf()
,你不会陷入死锁,因为它不会调用new
来初始化自己。
在main()
中,创建内置类型的对象来证明重载的new
和delete
在那种情况下也被调用。然后创建一个类型为S
的对象,后面是一个S
数组。对于数组,您将从请求的字节数中看到,额外的内存被分配来存储关于它保存的对象数量的信息(数组中的)。在所有情况下,都使用全局过载版本的new
和delete
。
重载新增和删除为一类 为一类
尽管你不必明确地说static
,当你重载一个类的new
和delete
时,你正在创建static
成员函数。和以前一样,语法与重载任何其他运算符相同。当编译器看到你使用new
创建你的类的对象时,它选择成员operator new()
而不是全局版本。然而,new
和delete
的全局版本用于所有其他类型的对象(除非有自己的new
和delete
)。
在清单 13-9 中,为类Framis
创建了一个原始的存储分配系统。程序启动时在静态数据区留出一块内存,该内存用于为类型为Framis
的对象分配空间。为了确定哪些块已经被分配,使用一个简单的字节数组,每个块一个字节。
清单 13-9 。重载本地(对于一个类)新建和删除
//: C13:Framis.cpp
// Local overloaded new & delete
#include <cstddef> // Size_t
#include <fstream>
#include <iostream>
#include <new>
using namespace std;
ofstream out("Framis.out");
class Framis {
enum { sz = 10 };
char c[sz]; // To take up space, not used
static unsigned char pool[];
static bool alloc_map[];
public:
enum { psize = 100 }; // framis allowed
Framis() { out << "Framis()\n"; }
∼Framis() { out << "∼Framis() ... "; }
void* operator new(size_t) throw(bad_alloc);
void operator delete(void*);
};
unsigned char Framis::pool[psize * sizeof(Framis)];
bool Framis::alloc_map[psize] = {false};
// Size is ignored -- assume a Framis object
void*
Framis::operator new(size_t) throw(bad_alloc) {
for(int i = 0; i < psize; i++)
if(!alloc_map[i]) {
out << "using block " << i << " ... ";
alloc_map[i] = true; // Mark it used
return pool + (i * sizeof(Framis));
}
out << "out of memory" << endl;
throw bad_alloc();
}
void Framis::operator delete(void* m) {
if(!m) return; // Check for null pointer
// Assume it was created in the pool
// Calculate which block number it is:
unsigned long block = (unsigned long)m
- (unsigned long)pool;
block /= sizeof(Framis);
out << "freeing block " << block << endl;
// Mark it free:
alloc_map[block] = false;
}
int main() {
Framis *f[Framis::psize];
try {
for(int i = 0; i < Framis::psize; i++)
f[i] = new Framis;
new Framis; // Out of memory
} catch(bad_alloc) {
cerr << "Out of memory!" << endl;
}
delete f[10];
f[10] = 0;
// Use released memory:
Framis *x = new Framis;
delete x;
for(int j = 0; j < Framis::psize; j++)
delete f[j]; // Delete f[10] OK
} ///:∼
用于Framis
堆的内存池是通过分配足够大的字节数组来保存psize Framis
对象而创建的。分配图有psize
个元素长,所以每个块都有一个bool
。使用设置第一个元素的聚合初始化技巧,将分配映射中的所有值初始化为false
,这样编译器会自动将其余所有值初始化为它们正常的默认值(在bool
的情况下,该值为false
)。
局部operator new()
的语法与全局相同。它所做的只是在分配图中搜索一个false
值,然后将该位置设置为true
以表明它已经被分配,并返回相应内存块的地址。如果找不到任何内存,它会向跟踪文件发出一条消息,并抛出一个bad_alloc
异常。
这是你在这本书里看到的第一个例外的例子。由于异常的详细讨论被推迟到第十七章中,这是它们的一个非常简单的用法。在operator new()
中,有两个异常处理的工件。首先,函数参数列表后面是throw(bad_alloc)
,它告诉编译器和读者这个函数可能抛出一个bad_alloc
类型的异常。第二,如果没有更多的内存,函数实际上会在语句throw bad_alloc
中抛出异常。当抛出一个异常时,函数停止执行,控制权传递给一个异常处理程序,它被表示为一个catch
子句。
在main()
中,你看到了画面的另一部分,也就是 try-catch 子句。try
块用大括号括起来,包含所有可能抛出异常的代码——在本例中,是对涉及Framis
对象的new
的任何调用。紧跟在try
块之后的是一个或多个catch
子句,每个子句指定它们捕获的异常的类型。在这种情况下,catch(bad_alloc)
表示bad_alloc
异常将在这里被捕获。这个特殊的catch
子句仅在抛出bad_alloc
异常时执行,并且在组中最后一个catch
子句结束后继续执行(这里只有一个,但可能有更多的)。
在这个例子中,使用iostream
是可以的,因为全局operator new()
和delete()
没有被触动。
operator delete()
假设Framis
地址是在池中创建的。这是一个合理的假设,因为每当你在堆上创建一个单独的Framis
对象时,就会调用局部的operator new()
——而不是它们的数组:全局的new
用于数组。因此,用户可能不小心调用了operator delete()
,而没有使用空括号语法来指示数组销毁。这将导致一个问题。此外,用户可能正在删除指向堆栈上创建的对象的指针。如果您认为这些事情可能会发生,您可能需要添加一行来确保该地址在地址池内并且在正确的边界上。
注意您也可能开始看到过载的new
和delete
对于查找内存泄漏的潜力。
operator delete()
计算该指针所代表的池中的块,然后将该块的分配映射标志设置为 false,以指示该块已被释放。
在main()
中,动态分配足够多的Framis
对象耗尽内存;这将检查内存不足行为。然后释放其中一个对象,并创建另一个对象来表明释放的内存被重用。
因为这种分配方案特定于Framis
对象,所以它可能比用于默认new
和delete
的通用内存分配方案要快得多。但是,你应该注意,如果使用了继承,它不会自动工作(继承在第十四章中有所涉及)。
重载数组 的新建和删除
如果你重载了一个类的操作符new()
和delete()
,那么每当你创建这个类的对象时,这些操作符都会被调用。然而,如果你创建一个这些类对象的数组,全局operator new()
被调用来为数组一次分配足够的存储空间,全局operator delete()
被调用来释放这些存储空间。您可以通过重载该类的特殊数组版本operator new[ ]
和operator delete[ ]
来控制对象数组的分配。参见清单 13-10 中两个不同版本被调用的例子。
清单 13-10 。对数组使用运算符 new()
//: C13:ArrayOperatorNew.cpp
// Operator new for arrays
#include <new> // Size_t definition
#include <fstream>
using namespace std;
ofstream trace("ArrayOperatorNew.out");
class Widget {
enum { sz = 10 };
int i[sz];
public:
Widget() { trace << "*"; }
∼Widget() { trace << "∼"; }
void* operator new(size_tsz) {
trace << "Widget::new: "
<< sz << " bytes" << endl;
return ::new char[sz];
}
void operator delete(void* p) {
trace << "Widget::delete" << endl;
::delete []p;
}
void* operator new[](size_tsz) {
trace << "Widget::new[]: "
<< sz << " bytes" << endl;
return ::new char[sz];
}
void operator delete[](void* p) {
trace << "Widget::delete[]" << endl;
::delete []p;
}
};
int main() {
trace << "new Widget" << endl;
Widget *w = new Widget;
trace << "\ndelete Widget" << endl;
delete w;
trace << "\n new Widget[25]" << endl;
Widget *wa = new Widget[25];
trace << "\n delete []Widget" << endl;
delete []wa;
} ///:∼
这里,new
和delete
的全局版本被调用,因此除了添加跟踪信息之外,效果与没有new
和delete
的重载版本相同。当然,你可以在过载的new
和delete
中使用任何你想要的内存分配方案。
您可以看到数组new
和delete
的语法与单个对象版本的语法相同,只是增加了括号。在这两种情况下,您都有必须分配的内存大小。传递给数组版本的大小将是整个数组的大小。值得记住的是,重载的operator new()
需要做的唯一一件事就是将一个指针交还给一个足够大的内存块。虽然你可以在内存上执行初始化,通常这是构造器的工作,编译器会自动调用你的内存。
构造器和析构函数只是打印出字符,这样你就可以看到它们何时被调用。下面是一个编译器的跟踪文件:
new Widget
Widget::new: 40 bytes
*
delete Widget
∼Widget::delete
new Widget[25]
Widget::new[]: 1004 bytes
*************************
delete []Widget
∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼Widget::delete[]
正如您所料,创建一个单独的对象需要 40 个字节。
注这台电脑用四个字节表示一个int
。
调用operator new()
,然后调用构造器(由*
表示)。作为补充,调用delete
会导致析构函数被调用,然后是operator delete()
。
当一个由Widget
对象组成的数组被创建时,将使用operator new()
的数组版本。但是请注意,请求的大小比预期多了四个字节。这额外的四个字节是系统保存数组信息的地方,特别是数组中对象的数量。这样,当你说
delete []Widget;
括号告诉编译器这是一个对象数组,因此编译器生成代码来查找数组中对象的数量,并多次调用析构函数。您可以看到,尽管数组operator new()
和operator delete()
对于整个数组块只被调用一次,但是默认的构造器和析构函数对于数组中的每个对象都被调用。
构造器调用
考虑到
MyType *f = new MyType;
调用new
分配一个MyType
大小的存储,然后调用该存储的MyType
构造器,如果new
中的存储分配失败会发生什么?在这种情况下,不会调用构造器,所以尽管你仍然有一个没有成功创建的对象,至少你没有调用构造器并给它一个零this
指针。清单 13-11 证明了这一点。
清单 13-11 。说明在 new 失败的情况下,构造器不会发挥作用
//: C13:NoMemory.cpp
// Constructor isn't called if new fails
#include <iostream>
#include <new>
// bad_alloc definition
using namespace std;
class NoMemory {
public:
NoMemory() {
cout << "NoMemory::NoMemory()" << endl;
}
void* operator new(size_tsz) throw(bad_alloc){
cout << "NoMemory::operator new" << endl;
throw bad_alloc(); // "Out of memory"
}
};
int main() {
NoMemory *nm = 0;
try {
nm = new NoMemory;
} catch(bad_alloc) {
cerr << "Out of memory exception" << endl;
}
cout << "nm = " << nm << endl;
} ///:∼
当程序运行时,它不打印构造器消息,只打印来自operator new()
的消息和异常处理程序中的消息。因为new
永远不会返回,所以构造器永远不会被调用,所以它的消息不会被打印。
将nm
初始化为零很重要,因为new
表达式永远不会完成,并且指针应该为零以确保不会被误用。但是,您实际上应该在异常处理程序中做更多的事情,而不仅仅是打印出一条消息并继续运行,就好像对象已经成功创建一样。理想情况下,您将做一些事情,使程序从问题中恢复,或者至少在记录错误后退出。
在 C++ 的早期版本中,如果存储分配失败,从new
返回零是标准的做法。这将阻止建设的发生。然而,如果你试图用符合标准的编译器从new
返回零,它会告诉你应该抛出bad_alloc
。
放置新的
重载operator new()
还有另外两种不太常见的用法。
- 您可能希望将一个对象放在内存中的特定位置。这对于面向硬件的嵌入式系统尤其重要,在这种系统中,一个对象可能与一个特定的硬件同义。
- 当调用
new
时,您可能希望能够从不同的分配器中进行选择。
这两种情况都用相同的机制解决:重载的operator new()
可以接受多个参数。
正如你之前看到的,第一个参数总是对象的大小,这是由编译器秘密计算和传递的。但是其他参数可以是您想要的任何东西:您想要对象放置的地址、对内存分配函数或对象的引用,或者其他任何对您来说方便的东西。
起初,你在通话中向operator new()
传递额外参数的方式可能看起来有点奇怪。您将参数列表(不带的size_t
参数,由编译器处理)放在关键字new
之后,您正在创建的对象的类名之前。例如,
X* xp = new(a) X;
将把a
作为第二个参数传递给operator new()
。当然,这只有在宣布了这样一个operator new()
的情况下才能奏效。
见清单 13-12 中的例子,展示了如何将一个对象放置在一个特定的位置。
清单 13-12 。举例说明使用运算符 new()放置的情况
//: C13:PlacementOperatorNew.cpp
// Placement with operator new()
#include <cstddef> // Size_t
#include <iostream>
using namespace std;
class X {
int i;
public:
X(int ii = 0) : i(ii) {
cout << "this = " << this << endl;
}
∼X() {
cout << "X::∼X(): " << this << endl;
}
void* operator new(size_t, void* loc) {
return loc;
}
};
int main() {
int l[10];
cout << "l = " << l << endl;
X *xp = new(l) X(47); // X at location l
xp->X::∼X(); // Explicit destructor call
// ONLY use with placement!
} ///:∼
注意operator new()
只返回传递给它的指针。因此,调用者决定对象的位置,构造器作为 new-expression 的一部分被调用。
虽然这个例子只显示了一个额外的参数,但是如果您出于其他目的需要它们,也可以添加更多的参数。
当你想摧毁这个物体时,就会出现两难的局面。只有一个版本的operator delete()
,所以没有办法说,“为这个对象使用我特殊的释放器你想调用析构函数,但是你不想让动态内存机制释放内存,因为它没有在堆上分配。
答案是一个非常特殊的语法。您可以显式调用析构函数,如
xp->X::∼X(); // Explicit destructor call
这里需要一个严厉的警告。有些人认为这是一种在作用域结束之前销毁对象的方法,而不是调整作用域或者(更准确地说)使用动态对象创建,如果他们想在运行时确定对象的生存期。
如果你为一个在堆栈上创建的普通对象这样调用析构函数,你会遇到严重的问题,因为析构函数会在作用域的末尾被再次调用。如果以这种方式为在堆上创建的对象调用析构函数,析构函数会执行,但不会释放内存,这可能不是您想要的。可以这样显式调用析构函数的唯一原因是为了支持operator new
的放置语法。
还有一个位置operator delete()
,只有当位置new
表达式的构造器抛出异常时才会调用它(,以便在异常期间自动清理内存)。位置operator delete()
有一个参数列表,对应于构造器抛出异常之前调用的位置operator new()
。
这个主题将在关于异常处理的第十七章中探讨。
审查会议
- 在堆栈上创建自动对象是方便的和最优有效的,但是要解决一般的编程问题,你必须能够在程序执行期间的任何时候创建和销毁对象,特别是响应来自程序外部的信息。
- 虽然 C 的动态内存分配将从堆中获得存储,但它不提供 C++ 中所必需的易用性和有保证的结构。通过使用 new 和 delete 将动态对象创建引入语言的核心,您可以像在堆栈上创建对象一样容易地在堆上创建对象。
- 此外,你还可以获得很大的灵活性。如果 new 和 delete 不符合您的需要,您可以更改它们的行为,特别是当它们不够高效时。
- 此外,您还可以修改当堆用尽存储空间时会发生什么。*
十四、继承与组合
C++ 最引人注目的特性之一是代码重用。但是要成为革命性的,你需要做的不仅仅是复制和修改代码。
与 C++ 中的大多数东西一样,解决方案围绕着类。您通过创建新的类来重用代码,但是您不是从头开始创建它们,而是使用其他人已经构建和调试的现有类。
诀窍是使用这些类而不需要修改现有的代码。在这一章中,你将看到实现这一点的两种方法。第一种非常简单:只需在新类中创建现有类的对象。这被称为复合,因为新类是由现有类的对象组成的。
第二种方法更微妙。您创建一个新类作为现有类的类型。您实际上是采用现有类的形式并向其添加代码,而不修改现有类。这种神奇的行为被称为继承 ,大部分工作由编译器完成。继承是面向对象编程的基石之一,它还有其他的含义,将在下一章探讨。
事实证明,对于组合和继承来说,许多语法和行为都是相似的(,这是有道理的;它们都是从现有类型创建新类型的方法。在这一章中,你将学习这些代码重用机制。
作文语法
实际上,你一直在使用组合来创建类。你只是主要用内置类型(和有时是string
s)来编写类。事实证明,使用用户定义类型的组合几乎一样容易。考虑清单 14-1 中的,它显示了一个由于某种原因而有价值的类。
清单 14-1 。一个有价值且有用的可重用类
//: C14:Useful.h
// A class to reuse
#ifndef USEFUL_H
#define USEFUL_H
class X {
int i;
public:
X() { i = 0; }
void set(int ii) { i = ii; }
int read() const { return i; }
int permute() { return i = i * 47; }
};
#endif // USEFUL_H ///:∼
这个类中的数据成员是private
,所以在一个新类中嵌入一个类型为X
的对象作为public
对象是完全安全的,这使得接口变得简单明了,正如你在清单 14-2 中看到的。
清单 14-2 。使用组合重用代码
//: C14:Composition.cpp
// Reuse code with composition
#include "Useful.h" // To be INCLUDED from Header FILE above
class Y {
int i;
public:
X x; // Embedded object
Y() { i = 0; }
void f(int ii) { i = ii; }
int g() const { return i; }
};
int main() {
Y y;
y.f(47);
y.x.set(37); // Access the embedded object
} ///:∼
访问嵌入对象(称为子对象)的成员函数只需要选择另一个成员。
更常见的是制作嵌入对象private
,因此它们成为底层实现的一部分(这意味着如果你愿意,你可以改变实现)。新类的public
接口函数涉及到嵌入对象的使用,但是它们不一定模仿对象的接口;参见清单 14-3 。
清单 14-3 。带有私有嵌入对象的组合
//: C14:Composition2.cpp
// Private embedded objects
#include "Useful.h"
class Y {
int i;
X x; // Embedded object
public:
Y() { i = 0; }
void f(int ii) { i = ii; x.set(ii); }
int g() const { return i * x.read(); }
void permute() { x.permute(); }
};
int main() {
Y y;
y.f(47);
y.permute();
} ///:∼
这里,permute()
函数被带到新的类接口,但是X
的其他成员函数在Y
的成员中使用。
继承语法
合成的语法是显而易见的,但是执行继承有一种新的不同的形式。
当你继承时,你在说,“这个新的类就像那个旧的类。”您可以像往常一样在代码中给出类的名称,但是在类体的左括号之前,您要加上一个冒号和基类的名称 ( 或基类,用逗号分隔,表示多重继承)。当你这样做的时候,你自动获得了基类中的所有数据成员和成员函数。清单 14-4 显示了一个例子。
清单 14-4 。说明简单继承
//: C14:Inheritance.cpp
// Simple inheritance
#include "Useful.h"
#include <iostream>
using namespace std;
class Y : public X {
int i; // Different from X's i
public:
Y() { i = 0; }
int change() {
i = permute(); // Different name call
return i;
}
void set(int ii) {
i = ii;
X::set(ii); // Same-name function call
}
};
int main() {
cout << "sizeof(X) = " << sizeof(X) << endl;
cout << "sizeof(Y) = "
<< sizeof(Y) << endl;
Y D;
D.change();
// X function interface comes through:
D.read();
D.permute();
// Redefined functions hide base versions:
D.set(12);
} ///:∼
你可以看到Y
继承自X
,这意味着Y
将包含X
中的所有数据元素和X
中的所有成员函数。事实上,Y
包含了一个X
的子对象,就好像你在Y
内部创建了一个X
的成员对象,而不是从X
继承而来。成员对象和基类存储都被称为子对象。
X
的所有private
元素仍然是Y
中的private
;也就是说,Y
继承X
并不意味着Y
可以打破保护机制。X
的private
元素仍然存在,它们占据了空间——只是你不能直接访问它们。
在main()
中,你可以看到Y
的数据元素与X
的数据元素组合在一起,因为sizeof(Y)
是sizeof(X)
的两倍大。
您会注意到基类前面有public
。继承时,一切默认为private
。如果基类前面没有public
,这意味着基类的所有public
成员都是派生类中的private
。这几乎从来不是你想要的;期望的结果是将基类public
的所有public
成员保留在派生类中。您可以在继承过程中使用public
关键字来实现这一点。
在change()
中,基类permute()
函数被调用。派生类可以直接访问所有的public
基类函数。
派生类中的set()
函数重新定义了基类中的set()
函数。也就是说,如果您为类型为Y
的对象调用函数read()
和permute()
,您将获得这些函数的基类版本(您可以在main()
中看到这种情况)。但是如果你为一个Y
对象调用set()
,你会得到一个重新定义的版本。这意味着如果你不喜欢在继承过程中得到的函数版本,你可以改变它的功能。
注意你也可以像change()
一样添加全新的功能。
然而,当你重定义一个函数时,你可能仍然想调用基类版本。如果在set()
中,你简单地调用set()
,你将得到函数的本地版本——递归函数调用。若要调用基类版本,必须使用范围解析运算符显式命名基类。
构造器初始化列表
您已经看到了在 C++ 中保证正确的初始化是多么重要,在组合和继承期间也是如此。创建对象时,编译器保证调用其所有子对象的构造器。在迄今为止的例子中,所有的子对象都有默认的构造器,这就是编译器自动调用的。但是,如果您的子对象没有默认构造器,或者如果您想更改构造器中的默认参数,会发生什么情况呢?这是一个问题,因为新的类构造器没有权限访问子对象的private
数据元素,所以它不能直接初始化它们。
解决方案很简单:调用子对象的构造器。C++ 为此提供了一个特殊的语法,构造器初始化列表。构造器初始化列表的形式呼应了继承的行为。有了继承,你可以把基类放在冒号之后,类体的左括号之前。在构造器初始化列表中,将对子对象构造器的调用放在构造器参数列表和冒号之后,但在函数体的左括号之前。对于继承自Bar
的类MyType
,它可能看起来像这样
MyType::MyType(inti) : Bar(i) { // ...
如果Bar
有一个接受单个int
参数的构造器。
成员对象初始化
事实证明,在使用复合时,您也使用这种完全相同的语法来初始化成员对象。对于组合,您给出对象的名称,而不是类名。如果在初始化列表中有不止一个构造器调用,用逗号分隔调用,如:
MyType2::MyType2(int i) : Bar(i), m(i+1) { // ...
这是类MyType2
的构造器的开始,它继承自Bar
,包含一个名为m
的成员对象。请注意,虽然您可以在构造器初始化列表中看到基类的类型,但您只能看到成员对象标识符。
初始化列表中的内置类型
构造器初始化列表允许你显式调用成员对象的构造器。事实上,没有其他方法可以调用这些构造器。这个想法是,在你进入新类的构造器体之前,所有的构造器都被调用。这样,您对子对象的成员函数的任何调用都将始终指向已初始化的对象。如果不为所有成员对象和基类对象调用某个构造器,就无法到达构造器的左括号,即使编译器必须对默认构造器进行隐藏调用。这进一步加强了 C++ 的保证,即没有对象(或对象的一部分)可以在不调用其构造器的情况下离开起始门。
这种在到达构造器的左括号时初始化所有成员对象的想法也是一种方便的编程帮助。一旦您点击了左大括号,您就可以假设所有的子对象都被正确地初始化了,并专注于您想要在构造器中完成的特定任务。然而,有一个问题:内置类型的成员对象怎么办,它们没有构造器?
为了保持语法的一致性,你可以将内置类型视为只有一个构造器,这个构造器只有一个参数:一个与你正在初始化的变量类型相同的变量,如清单 14-5 所示。
清单 14-5 。演示伪构造器
//: C14:PseudoConstructor.cpp
class X {
int i;
float f;
char c;
char *s;
public:
X() : i(7), f(1.4), c('x'), s("howdy") {}
};
int main() {
X x;
int i(100); // Applied to ordinary definition
int* ip = new int(47);
} ///:∼
这些“伪构造器调用”的动作是执行一个简单的赋值。这是一种方便的技术,也是一种很好的编码风格,所以您会经常看到它的使用。
在类外创建内置类型的变量时,甚至可以使用伪构造器语法,如下所示:
int i(100);
int* ip = new int(47);
这使得内置类型的行为有点像对象。但是请记住,这些不是真正的构造器。特别是,如果没有显式地进行伪构造器调用,就不会执行初始化。
结合组合与继承
当然,你可以一起使用合成和继承。清单 14-6 展示了使用它们创建一个更复杂的类。
清单 14-6 。说明组合的组合和继承
//: C14:Combined.cpp
// Inheritance & composition
class A {
int i;
public:
A(int ii) : i(ii) {}
∼A() {}
void f() const {}
};
class B {
int i;
public:
B(int ii) : i(ii) {}
∼B() {}
void f() const {}
};
class C : public B {
A a;
public:
C(int ii) : B(ii), a(ii) {}
∼C() {} // Calls ∼A() and ∼B()
void f() const { // Redefinition
a.f();
B::f();
}
};
int main() {
C c(47);
} ///:∼
C
继承自B
并有一个A
类型的成员对象(“由……组成”)。您可以看到构造器初始化列表包含对基类构造器和成员对象构造器的调用。
函数C::f()
重新定义了它继承的B::f()
,也调用了基类版本。另外,它叫a.f()
。请注意,您唯一可以谈论函数重定义的时间是在继承期间;对于成员对象,你只能操作对象的公共接口,而不能重定义它。此外,如果没有定义C::f()
,调用C
类的对象f()
将不会调用a.f()
,而会调用B::f()
。
自动析构函数调用
虽然你经常需要在初始化列表中进行显式的构造器调用,但是你从来不需要进行显式的析构函数调用,因为任何类都只有一个析构函数,而且它不需要任何参数。然而,编译器仍然确保调用了所有的析构函数,这意味着调用了整个层次结构中的所有析构函数,从派生程度最高的析构函数开始,一直到根。
值得强调的是,构造器和析构函数很不寻常,因为层次结构中的每个人都被调用,而普通的成员函数只调用那个函数,而不调用任何基类版本。如果你还想调用你正在重写的普通成员函数的基类版本,你必须显式地这样做。
构造器和析构函数调用和的顺序
当一个对象有许多子对象时,知道构造器和析构函数调用的顺序是很有趣的。清单 14-7 展示了它是如何工作的。
清单 14-7 。演示构造器/析构函数调用的顺序
//: C14:Order.cpp
// Constructor/destructor order
#include <fstream>
using namespace std;
ofstream out("order.out");
#define CLASS(ID) class ID { \
public: \
ID(int) { out << #ID " constructor\n"; } \
∼ID() { out << #ID " destructor\n"; } \
};
CLASS(Base1);
CLASS(Member1);
CLASS(Member2);
CLASS(Member3);
CLASS(Member4);
class Derived1 : public Base1 {
Member1 m1;
Member2 m2;
public:
Derived1(int) : m2(1), m1(2), Base1(3) {
out << "Derived1 constructor\n";
}
∼Derived1() {
out << "Derived1 destructor\n";
}
};
class Derived2 : public Derived1 {
Member3 m3;
Member4 m4;
public:
Derived2() : m3(1), Derived1(2), m4(3) {
out << "Derived2 constructor\n";
}
∼Derived2() {
out << "Derived2 destructor\n";
}
};
int main() {
Derived2 d2;
} ///:∼
首先,创建一个ofstream
对象,将所有输出发送到一个文件。然后,为了节省一些打字和演示一个宏技术,这个宏技术将在第十六章中被一个改进的技术所取代,一个宏被创建来构建一些类,这些类然后被用于继承和组合。每个构造器和析构函数都向跟踪文件报告自己。请注意,构造器不是默认构造器;他们各自有一个int
论点。参数本身没有标识符;它存在的唯一原因是强迫你显式调用初始化列表中的构造器。
注意消除标识符可以防止编译器警告信息。
这个程序的输出是
Base1 constructor
Member1 constructor
Member2 constructor
Derived1 constructor
Member3 constructor
Member4 constructor
Derived2 constructor
Derived2 destructor
Member4 destructor
Member3 destructor
Derived1 destructor
Member2 destructor
Member1 destructor
Base1 destructor
您可以看到,构造是从类层次结构的最根开始的,在每一层,首先调用基类构造器,然后是成员对象构造器。析构函数的调用顺序与构造器完全相反——由于潜在的依赖性,这一点很重要(在派生类的构造器或析构函数中,您必须能够假设基类子对象仍然可用,并且已经被构造——或者还没有被销毁)。
有趣的是,成员对象的构造器调用顺序完全不受构造器初始化列表中调用顺序的影响。该顺序由成员对象在类中的声明顺序决定。如果你可以通过构造器初始化列表改变构造器调用的顺序,你可以在两个不同的构造器中有两个不同的调用序列,但是可怜的析构函数不知道如何正确地颠倒析构函数调用的顺序,你可能会以一个依赖问题结束。
姓名隐藏
如果您继承了一个类并为它的一个成员函数提供了一个新的定义,有两种可能。第一个是在派生类定义中提供与基类定义中完全相同的签名和返回类型。对于普通成员函数,这叫做重定义 ,当基类成员函数是virtual
函数时,叫做重写(virtual
函数是正常情况,将在第十五章中详细介绍)。但是如果你改变了派生类中的成员函数参数列表或者返回类型会怎么样呢?参见清单 14-8 。
清单 14-8 。说明隐藏重载名称(在继承过程中)
//: C14:NameHiding.cpp
// Hiding overloaded names during inheritance
#include <iostream>
#include <string>
using namespace std;
class Base {
public:
int f() const {
cout << "Base::f()\n";
return 1;
}
int f(string) const { return 1; }
void g() {}
};
class Derived1 : public Base {
public:
void g() const {}
};
class Derived2 : public Base {
public:
// Redefinition:
int f() const {
cout << "Derived2::f()\n";
return 2;
}
};
class Derived3 : public Base {
public:
// Change return type:
void f() const { cout << "Derived3::f()\n"; }
};
class Derived4 : public Base {
public:
// Change argument list:
int f(int) const {
cout << "Derived4::f()\n";
return 4;
}
};
int main() {
string s("hello");
Derived1 d1;
int x = d1.f();
d1.f(s);
Derived2 d2;
x = d2.f();
//! d2.f(s); // string version hidden
Derived3 d3;
//! x = d3.f(); // return int version hidden
x = d3.g();
Derived4 d4;
//! x = d4.f(); // f() version hidden
x = d4.f(1);
} ///:∼
在Base
中,你可以看到一个重载的函数f()
,Derived1
没有对f()
做任何改变,但是它重新定义了g()
。在main()
中可以看到f()
的两个重载版本在Derived1
中都有。然而,Derived2
重新定义了f()
的一个重载版本,而没有重新定义另一个,结果是第二个重载形式不可用。在Derived3
中,改变返回类型隐藏了两个基类版本,Derived4
显示改变参数列表也隐藏了两个基类版本。一般来说,每当你从基类重定义一个重载函数名时,所有其他版本都会自动隐藏在新类中。在第十五章的中,你会看到virtual
关键字的增加会对函数重载产生更多的影响。
如果您通过修改基类成员函数的签名和/或返回类型来更改基类的接口,那么您使用该类的方式与继承通常支持的方式不同。这并不一定意味着你做错了,只是继承的最终目的是支持多态,如果你改变了函数签名或者返回类型,那么你实际上是在改变基类的接口。如果这是您想要做的,那么您使用继承主要是为了重用代码,而不是维护基类的公共接口(这是多态的一个重要方面)。一般来说,当你以这种方式使用继承时,这意味着你正在获取一个通用类,并根据特定的需求对其进行特殊化,这通常(但不总是)被认为是组合领域。
例如,考虑来自第九章的Stack
类。该类的一个问题是,每次从容器中获取指针时,都必须进行强制转换。这不仅乏味,而且不安全;你可以把指针指向任何你想要的东西。乍一看似乎更好的一种方法是使用继承来专门化通用的Stack
类。参见清单 14-9 中使用第九章中的类的例子。
清单 14-9 。使用继承专门化通用堆栈类
//: C14:InheritStack.cpp
// Specializing the Stack class
#include "../C09/Stack4.h" // Refer Chapter 9
#include "../require.h" // To be INCLUDED from *Chapter 9*
#include <iostream>
#include <fstream>
#include <string>
using namespace std;
class StringStack : public Stack {
public:
void push(string* str) {
Stack::push(str);
}
string* peek() const {
return (string*)Stack::peek();
}
string* pop() {
return (string*)Stack::pop();
}
∼StringStack() {
string* top = pop();
while(top) {
delete top;
top = pop();
}
}
};
int main() {
ifstream in("InheritStack.cpp");
assure(in, "InheritStack.cpp");
string line;
StringStack textlines;
while(getline(in, line))
textlines.push(new string(line));
string* s;
while((s = textlines.pop()) != 0) { // No cast!
cout << *s << endl;
delete s;
}
} ///:∼
因为Stack4.h
中的所有成员函数都是内联的,所以不需要链接任何东西。
StringStack
专门处理Stack
,这样push()
将只接受String
指针。以前,Stack
会接受void
指针,所以用户没有类型检查来确保插入了正确的指针。此外,peek()
和pop()
现在返回String
指针而不是void
指针,所以使用指针不需要强制转换。
令人惊讶的是,这种额外的类型检查安全在push()
、peek()
和pop()
中是免费的!编译器得到了它在编译时使用的额外的类型信息,但是函数是内联的,没有生成额外的代码。
名字隐藏在这里起作用,特别是因为push()
函数有不同的签名:参数列表是不同的。如果在同一个类中有两个版本的push()
,那将会是重载,但是在这种情况下,重载不是你想要的,因为它仍然允许你将任何类型的指针作为void*
传入push()
。幸运的是,C++ 在基类中隐藏了push(void*)
版本,支持在派生类中定义的新版本,因此它只允许将push()string
指针指向StringStack
。
因为您现在可以保证您确切地知道容器中是什么类型的对象,析构函数工作正常,所有权问题得到了解决——或者至少是所有权问题的一种解决方法。这里,如果您将一个string
指针push()
放在StringStack
上,那么(根据StringStack
的语义)您也将该指针的所有权传递给了StringStack
。如果你pop()
了这个指针,你不仅得到了这个指针,还得到了这个指针的所有权。当调用析构函数时,任何留在StringStack
上的指针都会被这个析构函数删除。由于这些总是string
指针,并且delete
语句是在string
指针而不是void
指针上工作的,所以会发生适当的销毁,一切都会正常工作。
有一个缺点:这个类只对string
指针的有效。如果你想让一个Stack
与其他类型的对象一起工作,你必须写一个新版本的类,这样它只能与你的新类型的对象一起工作。这很快变得乏味,最终使用模板解决,正如你将在第十六章中看到的。
我们可以对这个例子做一个额外的观察:它在继承的过程中改变了Stack
的接口。如果接口不同,那么一个StringStack
就真的不是一个Stack
,你将永远无法正确地使用一个StringStack
作为一个Stack
。这使得继承的使用在这里受到质疑;如果你没有创建一个属于 Stack
类型的StringStack
,那么你为什么要继承?更合适的版本StringStack
将在本章稍后展示。
不自动继承的函数
并非所有函数都自动从基类继承到派生类中。构造器和析构函数处理对象的创建和析构,它们只能知道如何处理特定类的对象方面,因此必须调用它们下面层次结构中的所有构造器和析构函数。因此,构造器和析构函数不继承,必须专门为每个派生类创建。
此外,operator=
不继承,因为它执行类似构造器的活动。也就是说,仅仅因为你知道如何从一个右边的对象分配一个=
左边的对象的所有成员,并不意味着分配在继承后仍然有相同的意义。
如果您没有自己创建这些函数,编译器会合成这些函数来代替继承。
注意对于构造器,你不能为了让编译器合成默认构造器和复制构造器而创建任何构造器。
这在第六章中有简要描述。合成的构造器使用基于成员的初始化,合成的operator=
使用基于成员的赋值。清单 14-10 展示了一个由编译器合成的函数的例子。
清单 14-10 。图示合成函数
//: C14:SynthesizedFunctions.cpp
// Functions that are synthesized by the compiler
#include <iostream>
using namespace std;
classGameBoard {
public:
GameBoard() { cout << "GameBoard()\n"; }
GameBoard(constGameBoard&) {
cout << "GameBoard(constGameBoard&)\n";
}
GameBoard& operator=(constGameBoard&) {
cout << "GameBoard::operator=()\n";
return *this;
}
∼GameBoard() { cout << "∼GameBoard()\n"; }
};
class Game {
GameBoard gb; // Composition
public:
// Default GameBoard constructor called:
Game() { cout << "Game()\n"; }
// You must explicitly call the GameBoard
// copy-constructor or the default constructor
// is automatically called instead:
Game(const Game& g) : gb(g.gb) {
cout << "Game(const Game&)\n";
}
Game(int) { cout << "Game(int)\n"; }
Game& operator=(const Game& g) {
// You must explicitly call the GameBoard
// assignment operator or no assignment at
// all happens for gb!
gb = g.gb;
cout << "Game::operator=()\n";
return *this;
}
class Other {}; // Nested class
// Automatic type conversion:
operator Other() const {
cout << "Game::operator Other()\n";
return Other();
}
∼Game() { cout<< "∼Game()\n"; }
};
class Chess : public Game {};
void f(Game::Other) {}
class Checkers : public Game {
public:
// Default base-class constructor called:
Checkers() { cout << "Checkers()\n"; }
// You must explicitly call the base-class
// copy constructor or the default constructor
// will be automatically called instead:
Checkers(const Checkers& c) : Game(c) {
cout << "Checkers(const Checkers& c)\n";
}
Checkers& operator=(const Checkers& c) {
// You must explicitly call the base-class
// version of operator=() or no base-class
// assignment will happen:
Game::operator=(c);
cout << "Checkers::operator=()\n";
return *this;
}
};
int main() {
Chess d1; // Default constructor
Chess d2(d1); // Copy-constructor
//! Chess d3(1); // Error: no int constructor
d1 = d2; // Operator= synthesized
f(d1); // Type-conversion IS inherited
Game::Other go; /* This declaration is only fordemonstrating to you the next line of code which has been commented out for obvious reasons!(otherwise, the program will not compile!!)*/
//! d1 = go; // Operator= not synthesized
// for differing types
Checkers c1, c2(c1);
c1 = c2;
} ///:∼
GameBoard
和Game
的构造器和operator=
声明它们自己,这样你就可以看到它们何时被编译器使用。另外,operator Other()
执行从Game
对象到嵌套类Other
对象的自动类型转换。类Chess
只是从Game
继承而来,没有创建任何函数(看看编译器如何响应)。函数f()
接受一个Other
对象来测试自动类型转换函数。
在main()
中,调用派生类Chess
的合成默认构造器和复制构造器。这些构造器的Game
版本作为构造器调用层次的一部分被调用。即使看起来像继承,新的构造器实际上是由编译器合成的。正如您所料,没有带参数的构造器会被自动创建,因为这对编译器来说太复杂了。
operator=
也被合成为一个使用成员赋值的Chess
中的新函数(因此基类版本被调用),因为该函数没有显式地写在新类中。当然,析构函数是由编译器自动合成的。
由于所有这些关于重写处理对象创建的函数的规则,最初看起来可能有点奇怪,自动类型转换操作符是继承的。但是这也不是太不合理——如果在Game
中有足够多的片段来组成一个Other
对象,那么这些片段仍然存在于从Game
派生的任何东西中,并且类型转换操作符仍然有效(即使你可能实际上想要重新定义它)。
operator=
是合成的只用于分配同类型的对象。如果你想把一种类型赋给另一种类型,你必须自己写这个operator=
。
如果你仔细观察Game
,你会发现复制构造器和赋值操作符对成员对象复制构造器和赋值操作符有明确的调用。你通常会想这样做,因为否则,在复制构造器的情况下,将使用默认的成员对象构造器,而在赋值操作符的情况下,根本不会对成员对象进行赋值!
最后,看看Checkers
,它明确写出了默认构造器、复制构造器和赋值操作符。在默认构造器的情况下,默认基类构造器被自动调用,这通常是您想要的。但是,这是很重要的一点,一旦你决定写你自己的复制构造器和赋值操作符,编译器就认为你知道你在做什么,不会像在合成函数中那样自动调用基类版本。如果你想让基类版本被调用(,你通常会这么做,那么你必须自己显式地调用它们。在Checkers
复制构造器中,这个调用出现在构造器初始化列表中:
Checkers(const Checkers& c) : Game(c) {
在Checkers
赋值操作符中,基类调用是函数体的第一行,如:
Game::operator=(c);
这些调用应该是您在继承类时使用的规范形式的一部分。
继承和静态成员函数
static
成员函数的作用与非static
成员函数相同。
- 它们继承到派生类中。
- 如果重新定义静态成员,基类中所有其他重载函数都将隐藏。
- 如果你改变了基类中一个函数的签名,那么这个函数名的所有基类版本都被隐藏了(这实际上是上一点的一个变种)。
然而,static
成员函数不能是virtual
(这个话题在第十五章中有详细介绍)。
选择构图 vs .继承
组合和继承都将子对象放在新类中。两者都使用构造器初始化列表来构造这些子对象。您现在可能想知道这两者之间的区别,以及何时选择一个而不是另一个。
当您希望在新类中包含现有类的功能,而不是其接口时,通常会使用组合。也就是说,您嵌入了一个对象来实现新类的特性,但是新类的用户看到的是您定义的接口,而不是原始类的接口。要做到这一点,您需要遵循在新类中嵌入现有类的private
对象的典型路径。
然而,有时允许类用户直接访问新类的组成是有意义的,也就是说,使成员对象public
。成员对象自己使用访问控制,所以这是一件安全的事情,当用户知道你在组装一堆部件时,这使得界面更容易理解。
一个Car
类就是一个很好的例子;见清单 14-11 。
清单 14-11 。图解公共构图
//: C14:Car.cpp
// Public composition
class Engine {
public:
void start() const {}
void rev() const {}
void stop() const {}
};
class Wheel {
public:
void inflate(int psi) const {}
};
class Window {
public:
void rollup() const {}
void rolldown() const {}
};
class Door {
public:
Window window;
void open() const {}
void close() const {}
};
class Car {
public:
Engine engine;
Wheel wheel[4];
Door left, right; // 2-door
};
int main() {
Car car;
car.left.window.rollup();
car.wheel[0].inflate(72);
} ///:∼
因为Car
的组成是问题分析的一部分(,而不仅仅是底层设计的一部分,所以使成员public
有助于客户程序员理解如何使用该类,并且对于类的创建者来说,需要更少的代码复杂性。
稍微思考一下,你就会发现使用“车辆”对象来组成Car
是没有意义的——汽车不包含车辆,它是车辆。是-a 关系用继承表示,是-a 关系用合成表示。
子类型
现在假设您想要创建一种类型的ifstream
对象,它不仅可以打开一个文件,还可以跟踪文件的名称。你可以使用组合并在新类中嵌入一个ifstream
和一个string
,如清单 14-12 中的所示。
清单 14-12 。使用复合嵌入 ifstream 和字符串(文件名)
//: C14:FName1.cpp
// An ifstream with a file name
#include "../require.h"
#include <iostream>
#include <fstream>
#include <string>
using namespace std;
class FName1 {
ifstream file;
string fileName;
bool named;
public:
FName1() : named(false) {}
FName1(const string &fname)
: fileName(fname), file(fname.c_str()) {
assure(file, fileName);
named = true;
}
string name() const { return fileName; }
void name(const string &newName) {
if(named) return; // Don't overwrite
fileName = newName;
named = true;
}
operator ifstream&() { return file; }
};
int main() {
FName1 file("FName1.cpp");
cout << file.name() << endl;
// Error: close() not a member:
//! file.close();
} ///:∼
然而,这里有一个问题。通过包含从FName1
到ifstream&
的自动类型转换操作符,试图允许在任何使用ifstream
对象的地方使用FName1
对象。但是在main
(),这条线
file.close();
将不编译,因为自动类型转换只发生在函数调用中,而不是在成员选择期间。所以这种做法行不通。
第二种方法是将close()
的定义添加到FName1
中,如下所示:
void close() { file.close(); }
如果您只希望从ifstream
类中引入几个函数,这将是可行的。在这种情况下,您只使用了类的一部分,并且组合是合适的。
但是如果你想让班上的所有人都通过呢?这被称为子类型化,因为你正在从一个现有类型创建一个新类型,并且你希望你的新类型具有与现有类型完全相同的接口(加上你想要添加的任何其他成员函数),所以你可以在你使用现有类型的任何地方使用它。这就是继承的重要性。在清单 14-13 中,你可以看到子类型完美地解决了前面例子中的问题(清单 14-12 )。
清单 14-13 。说明子类型解决了清单 14-12 中的问题
//: C14:FName2.cpp
// Subtyping solves the problem
#include "../require.h"
#include <iostream>
#include <fstream>
#include <string>
using namespace std;
class FName2 : public ifstream {
string fileName;
bool named;
public:
FName2() : named(false) {}
FName2(const string &fname)
: ifstream(fname.c_str()), fileName(fname) {
assure(*this, fileName);
named = true;
}
string name() const { return fileName; }
void name(const string &newName) {
if(named) return; // Don't overwrite
fileName = newName;
named = true;
}
};
int main() {
FName2 file("FName2.cpp");
assure(file, "FName2.cpp");
cout << "name: " << file.name() << endl;
string s;
getline(file, s); // These work too!
file.seekg(-200, ios::end);
file.close();
} ///:∼
现在,ifstream
对象可用的任何成员函数都可以用于FName2
对象。您还可以看到,像getline()
这样期望使用ifstream
的非成员函数也可以使用FName2
。那是因为一个FName2
是的一种ifstream
;它不仅仅包含一个。这是一个非常重要的问题,将在本章末尾和下一章探讨。
私人继承
您可以通过在基类列表中去掉public
或者显式地声明private
来私有地继承一个基类(这可能是一个更好的策略,因为用户很清楚您的意思)。当你私有继承的时候,你是在“实现;也就是说,您正在创建一个新的类,它具有基类的所有数据和功能,但是该功能是隐藏的,所以它只是底层实现的一部分。类用户不能访问底层的功能,一个对象不能被当作基类的一个实例(就像在FName2.cpp
中一样)。
您可能想知道private
继承的目的是什么,因为使用 composition 在新类中创建一个private
对象的替代方法似乎更合适。private
继承被包含在语言中是为了完整性,但是如果仅仅是为了减少混乱,你通常会希望使用复合而不是private
继承。但是,有时您可能希望生成与基类相同的接口的一部分,并且不允许将该对象视为基类对象。private
遗传提供了这种能力。
登报私下继承成员
当你私有继承时,基类的所有public
成员都变成了private
。如果你想让它们中的任何一个可见,只需说出它们的名字(没有参数或返回值)以及派生类的public
部分中的using
关键字,如清单 14-14 所示。
清单 14-14 。展示私有继承
//: C14:PrivateInheritance.cpp
class Pet {
public:
char eat() const { return 'a'; }
int speak() const { return 2; }
float sleep() const { return 3.0; }
float sleep(int) const { return 4.0; }
};
class Goldfish : Pet { // Private inheritance
public:
using Pet::eat; // Name publicizes member
using Pet::sleep; // Both overloaded members exposed
};
int main() {
Goldfish bob;
bob.eat();
bob.sleep();
bob.sleep(1);
//! bob.speak(); // Error: private member function
} ///:∼
因此,如果你想隐藏基类的部分功能,private
继承是有用的。
请注意,给出/公开重载函数的名称会公开基类中重载函数的所有版本。在用private
继承代替复合之前,你要慎重考虑;当结合运行时类型识别时,继承有特殊的复杂性
注运行时类型识别在第二十章的中讨论。
受保护的关键字
既然已经向您介绍了继承,关键字protected
终于有了意义。在理想的世界中,private
成员总是严格保密的,但是在实际项目中,有时你想对外界隐藏一些东西,但是允许派生类的成员访问。protected
这个关键词是对实用主义的肯定;它说,“对于类用户来说,这个是 private
,但是对于从这个类继承的任何人都是可用的。”
最好的方法是保留数据成员private
——您应该始终保留更改底层实现的权利。然后,您可以通过protected
成员函数允许对您的类的继承者进行受控访问;参见清单 14-15 。
清单 14-15 。说明 protected 关键字的用法
//: C14:Protected.cpp
// The protected keyword
#include <fstream>
using namespace std;
class Base {
int i;
protected:
int read() const { return i; }
void set(int ii) { i = ii; }
public:
Base(int ii = 0) : i(ii) {}
int value(int m) const { return m*i; }
};
class Derived : public Base {
int j;
public:
Derived(int jj = 0) : j(jj) {}
void change(int x) { set(x); }
};
int main() {
Derived d;
d.change(10);
} ///:∼
你会在本书后面的例子中找到需要protected
的例子。
受保护的继承
当你继承时,基类默认为private
,这意味着所有的公共成员函数对新类的用户来说都是private
。通常,你将继承public
,这样基类的接口也是派生类的接口。但是,您也可以在继承过程中使用protected
关键字。
受保护的派生对其他类来说意味着“??”实现了“??”,但是对于派生类和友元来说,“??”是“??”。这是一个你不经常使用的东西,但是为了完整起见,它在语言中。
运算符重载和继承
除了赋值运算符之外,其他运算符都会自动继承到派生类中。这可以通过从C12:Byte.h
继承来证明,如清单 14-16 所示。
清单 14-16 。阐释重载运算符的继承
//: C14:OperatorInheritance.cpp
// Inheriting overloaded operators
#include "../C12/Byte.h" // Refer Chapter 12
#include <fstream>
using namespace std;
ofstream out("ByteTest.out");
class Byte2 : public Byte {
public:
// Constructors don't inherit:
Byte2(unsigned char bb = 0) : Byte(bb) {}
// operator= does not inherit, but
// is synthesized for memberwise assignment.
// However, only the SameType = SameType
// operator= is synthesized, so you have to
// make the others explicitly:
Byte2& operator=(const Byte& right) {
Byte::operator=(right);
return *this;
}
Byte2& operator=(inti) {
Byte::operator=(i);
return *this;
}
};
// Similar test function as in C12:ByteTest.cpp:
void k(Byte2& b1, Byte2& b2) {
b1 = b1 * b2 + b2 % b1;
#define TRY2(OP) \
out << "b1 = "; b1.print(out); \
out << ", b2 = "; b2.print(out); \
out << "; b1 " #OP " b2 produces "; \
(b1 OP b2).print(out); \
out << endl;
b1 = 9; b2 = 47;
TRY2(+) TRY2(-) TRY2(*) TRY2(/)
TRY2(%) TRY2(^) TRY2(&) TRY2(|)
TRY2(<<) TRY2(>>) TRY2(+=) TRY2(-=)
TRY2(*=) TRY2(/=) TRY2(%=) TRY2(^=)
TRY2(&=) TRY2(|=) TRY2(>>=) TRY2(<<=)
TRY2(=) // Assignment operator
// Conditionals:
#define TRYC2(OP) \
out << "b1 = "; b1.print(out);
out << ", b2 = "; b2.print(out); \
out << "; b1 " #OP " b2 produces "; \
out << (b1 OP b2); \
out << endl;
b1 = 9; b2 = 47;
TRYC2(<) TRYC2(>) TRYC2(==) TRYC2(!=) TRYC2(<=)
TRYC2(>=) TRYC2(&&) TRYC2(||)
// Chained assignment:
Byte2 b3 = 92;
b1 = b2 = b3;
}
int main() {
out << "member functions:" << endl;
Byte2 b1(47), b2(9);
k(b1, b2);
} ///:∼
测试代码与C12:ByteTest.cpp
(参见清单 12-4 )中的代码相同,除了使用Byte2
代替Byte
。通过这种方式,所有的操作符都可以通过继承与Byte2
一起工作。
当您检查类Byte2
时,您将看到构造器必须被显式定义,并且只有将Byte2
分配给Byte2
的operator=
被合成;你需要的任何其他赋值操作符,你必须自己合成。
多重继承
您可以从一个类继承,因此一次从多个类继承似乎是有意义的。确实可以,但是作为设计的一部分,它是否有意义是一个持续争论的话题。有一点是大家都同意的:除非你已经编程很长时间了,并且完全理解了这门语言,否则你不应该尝试这样做。到那个时候,你可能会意识到,无论你多么认为你绝对必须使用多重继承,你几乎总是可以逃脱单一继承。
最初,多重继承似乎很简单:在继承过程中,在基类列表中添加更多的类,用逗号分隔。然而,多重继承引入了许多模糊的可能性,这就是为什么后面的一章(事实上,本书的最后一章,第二十一章)专门讨论这个主题。
增量开发
继承和组合的优点之一是,它们支持增量开发,允许您引入新代码,而不会导致现有代码中的错误。如果 bug 确实出现了,它们会被隔离在新代码中。通过从(继承或用组合一个现有的函数类,添加数据成员和成员函数(,并在继承过程中重新定义现有的成员函数),您可以保留现有的代码——其他人可能仍在使用——不被触及,不被绑定。如果一个 bug 发生了,你知道它在你的新代码中,这比你修改现有代码要短得多,也更容易阅读。
令人惊讶的是,这些类是如此清晰地分开的。为了重用代码,您甚至不需要成员函数的源代码,只需要描述类的头文件和带有已编译成员函数的目标文件或库文件。
注无论是继承还是作曲都是如此。
重要的是要认识到程序开发是一个渐进的过程,就像人类的学习一样。你可以做尽可能多的分析,但当你着手一个项目时,你仍然不会知道所有的答案。如果你开始像一个有机的、进化的生物一样“成长”你的项目,而不是像一个玻璃盒子摩天大楼一样一次完成,你会有更多的成功和更直接的反馈。
尽管实验性的继承是一种有用的技术,但是在事情稳定下来后的某个时刻,你需要重新审视你的类层次结构,着眼于将它压缩成一个合理的结构。请记住,在这一切之下,继承意味着表达这样一种关系:“这个新类是那个旧类的一种类型。”你的程序不应该关心推来推去,而是应该创建和操作各种类型的对象,用问题空间给你的术语来表达一个模型。
向上投射
在本章的前面,你看到了从ifstream
派生的一个类的对象如何拥有一个ifstream
对象的所有特征和行为。在FName2.cpp
中,任何ifstream
成员函数都可以被一个FName2
对象调用。
然而,继承最重要的方面不是它为新类提供了成员函数。它表达了新类和基类之间的关系。这种关系可以概括为:“新的类是现有类的一种类型。”
这种描述不仅仅是一种解释继承的奇特方式——它直接受到编译器的支持。例如,考虑一个表示乐器的基类Instrument
和一个派生类Wind
。因为继承意味着基类中的所有函数在派生类中也是可用的,所以您可以发送到基类的任何消息也可以发送到派生类。所以如果Instrument
类有一个play()
成员函数,那么Wind
仪器也会有。这意味着你可以准确地说Wind
对象也是Instrument
的一种类型。清单 14-17 显示了编译器是如何支持这个概念的。
清单 14-17 。说明继承和向上转换和
//: C14:Instrument.cpp
// Inheritance & upcasting
enum note { middleC, Csharp, Cflat }; // Etc.
class Instrument {
public:
void play(note) const {}
};
// Wind objects are Instruments
// because they have the same interface:
class Wind : public Instrument {};
void tune(Instrument &i) {
// ...
i.play(middleC);
}
int main() {
Wind flute;
tune(flute); // Upcasting
} ///:∼
这个例子中有趣的是tune()
函数,它接受一个Instrument
引用。然而,在main()
中,tune()
函数是通过传递一个对Wind
对象的引用来调用的。鉴于 C++ 非常注重类型检查,接受一种类型的函数很容易接受另一种类型,这似乎很奇怪,直到你意识到一个Wind
对象也是一个Instrument
对象,并且没有一个函数tune()
可以调用一个不在Wind
中的Instrument
(这是继承所保证的)。在tune()
内部,代码为Instrument
和从Instrument
派生的任何东西工作,将Wind
引用或指针转换成Instrument
引用或指针的行为被称为向上转换。
为什么是“上抛?”
这个术语的由来是历史的,是基于传统上绘制类继承图的方式:根在页面的顶部,向下生长。
注当然,你可以用任何你觉得有用的方式来画你的图。
Instrument.cpp
的继承图如图 14-1 中的所示。
图 14-1 。仪器继承图
从 derived 到 base 的转换在继承图上向上移动,所以它通常被称为向上转换。向上转换总是安全的,因为你正在从一个更具体的类型转换到一个更一般的类型——类接口唯一可能发生的事情是它可能丢失成员函数,而不是获得它们。这就是为什么编译器允许向上转换,而不需要任何显式转换或其他特殊符号。
向上转换和复制构造器
如果你允许编译器为一个派生类合成一个复制构造器,它会自动调用基类的复制构造器,然后所有成员对象的复制构造器(或对内置类型执行位复制)所以你会得到正确的行为,如清单 14-18 所示。
清单 14-18 。演示复制构造器的正确创建
//: C14:CopyConstructor.cpp
// Correctly creating the copy-constructor
#include <iostream>
using namespace std;
class Parent {
int i;
public:
Parent(int ii) : i(ii) {
cout << "Parent(int ii)\n";
}
Parent(const Parent& b) : i(b.i) {
cout<< "Parent(const Parent&)\n";
}
Parent() : i(0) { cout << "Parent()\n"; }
friend ostream&
operator <<(ostream& os, const Parent& b) {
return os << "Parent: " << b.i << endl;
}
};
class Member {
int i;
public:
Member(int ii) : i(ii) {
cout << "Member(int ii)\n";
}
Member(const Member& m) : i(m.i) {
cout << "Member(const Member&)\n";
}
friend ostream&
operator<<(ostream& os, const Member& m) {
return os << "Member: " << m.i<< endl;
}
};
class Child : public Parent {
int i;
Member m;
public:
Child(int ii) : Parent(ii), i(ii), m(ii) {
cout << "Child(int ii)\n";
}
friend ostream&
operator<<(ostream& os, const Child& c){
return os << (Parent&)c << c.m
<< "Child: " << c.i << endl;
}
};
int main() {
Child c(2);
cout << "calling copy-constructor: " << endl;
Child c2 = c; // Calls copy-constructor
cout << "values in c2:\n" << c2;
} ///:∼
Child
的operator<<
很有趣,因为它调用其中Parent
部分的operator<<
的方式:通过将Child
对象强制转换为Parent&
(如果您强制转换为基类对象而不是引用,通常会得到不希望的结果):
return os << (Parent&)c << c.m
由于编译器随后将其视为Parent
,它调用operator<<
的Parent
版本。
您可以看到Child
没有显式定义的复制构造器。然后编译器通过调用Parent
复制构造器和Member
复制构造器来合成复制构造器(因为这是它将要合成的四个函数之一,还有默认构造器——如果你没有创建任何构造器的话——还有operator=
和析构函数)。这显示在输出中:
Parent(int ii)
Member(int ii)
Child(int ii)
calling copy-constructor:
Parent(const Parent&)
Member(const Member&)
values in c2:
Parent: 2
Member: 2
Child: 2
然而,如果你试图为Child
编写自己的复制构造器,却犯了一个无心的错误,而且做得很糟糕,比如
Child(const Child& c) : i(c.i), m(c.m) {}
然后,默认的构造器将被自动调用为Child
的基类部分,因为当编译器没有其他选择调用构造器时,它就依靠默认的构造器(记住,必须总是为每个对象调用某个构造器,不管它是否是另一个类的子对象)。输出将会是
Parent(int ii)
Member(int ii)
Child(int ii)
calling copy-constructor:
Parent()
Member(const Member&)
values in c2:
Parent: 0
Member: 2
Child: 2
这可能不是您所期望的,因为通常您希望基类部分作为复制构造的一部分从现有对象复制到新对象。
要修复这个问题,您必须记住,每当您编写自己的复制构造器时,都要正确地调用基类复制构造器(就像编译器那样)。乍一看,这似乎有点奇怪,但这是向上投射的另一个例子:
Child(const Child& c)
: Parent(c), i(c.i), m(c.m) {
cout << "Child(Child&)\n";
}
奇怪的地方在于Parent
复制构造器被称为Parent(c)
。将一个Child
对象传递给一个Parent
构造器是什么意思?但是Child
是从Parent
继承的,所以一个Child
引用就是一个Parent
引用。基类复制构造器调用将对Child
的引用提升为对Parent
的引用,并使用它来执行复制构造。当你写自己的复制构造器时,你几乎总是想做同样的事情。
组合与继承(重温)
确定应该使用复合还是继承的一个最清楚的方法是询问你是否需要从新类进行向上转换。在本章的前面,Stack
类是使用继承来专门化的。然而,StringStack
对象可能只会被用作string
容器,永远不会向上转换,所以更合适的选择是组合;参见清单 14-19 。
清单 14-19 。比较合成和继承
//: C14:InheritStack2.cpp
// Composition vs. inheritance
#include "../C09/Stack4.h"
#include "../require.h"
#include <iostream>
#include <fstream>
#include <string>
using namespace std;
class StringStack {
Stack stack; // Embed instead of inherit
public:
void push(string* str) {
stack.push(str);
}
string* peek() const {
return (string*)stack.peek();
}
string* pop() {
return (string*)stack.pop();
}
};
int main() {
ifstream in("InheritStack2.cpp");
assure(in, "InheritStack2.cpp");
string line;
StringStack textlines;
while(getline(in, line))
textlines.push(new string(line));
string* s;
while((s = textlines.pop()) != 0) // No cast!
cout << *s << endl;
} ///:∼
该文件与InheritStack.cpp
( 清单 14-9 )相同,除了在StringStack
中嵌入了一个Stack
对象,并为嵌入的对象调用成员函数。仍然没有时间或空间开销,因为子对象占用相同的空间,所有额外的类型检查都发生在编译时。
尽管这可能会更令人困惑,但是您也可以使用private
继承来表达“根据…实现”这也将充分解决问题。然而,它变得重要的一个地方是多重继承可能是正当的。在这种情况下,如果您看到一个设计中可以使用复合而不是继承,您可能能够消除多重继承的需要。
指针和参考向上投射
在Instrument.cpp
中,向上转换发生在函数调用期间——函数外部的Wind
对象被引用,并成为函数内部的Instrument
引用。向上转换也可能发生在对指针或引用的简单赋值过程中:
Wind w;
Instrument* ip = &w; // Upcast
Instrument& ir = w; // Upcast
像函数调用一样,这两种情况都不需要显式强制转换。
危机时刻
当然,任何向上转换都会丢失关于对象的类型信息。如果你说
Wind w;
Instrument* ip = &w;
编译器只能将ip
作为一个Instrument
指针来处理,除此之外别无他法。也就是说,它不能知道ip
?? 实际上恰好指向一个Wind
对象。所以当你调用play()
成员函数的时候
ip->play(middleC);
编译器只能知道它在为一个Instrument
指针调用play()
,并调用Instrument::play()
的基类版本,而不是它应该做的,即调用Wind::play()
。所以你不会得到正确的行为。
这是一个重大问题;在第十五章中通过引入面向对象编程的第三个基石:多态,借助virtual
函数在 C++ 中实现。
审查会议
- 继承和组合 都允许你从现有类型中创建一个新类型,并且都将现有类型的子对象嵌入到新类型中。
- 但是,通常情况下,当您希望强制新类型与基类的类型相同时,可以使用组合来重用现有类型,作为新类型和继承的基础实现的一部分(
by the way
,类型等价保证接口等价)。由于派生类有基类接口,它可以向上转换到基类,这对于多态是至关重要的,正如你将在第十五章中看到的。 - 尽管通过组合和继承的代码重用对于快速项目开发非常有帮助,但是在允许其他程序员依赖它之前,您通常会想要重新设计您的类层次结构。
- 你的目标是一个层次结构,其中每个类都有一个特定的用途,并且既不太大(也就是说,包含了太多的功能,以至于难以重用),也不太小(也就是说,你不能单独使用它或者不添加功能)。
十五、多态和虚函数
多态 ( 用虚函数在 C++ 中实现)是继数据抽象和继承之后,面向对象编程语言的第三个基本特征。
它提供了接口与实现分离的另一个维度,将什么与如何解耦。 What 表示接口细节, how 表示实现细节。你已经在第五章的中学习了隐藏实现。这里的想法类似于首先模拟一个系统(什么方面),而不用担心让一个工作系统自己就位(如何到位方面)。
多态允许改进代码组织和可读性,以及创建可扩展的程序,这些程序不仅可以在最初创建项目时“成长”,还可以在需要新功能时“成长”。
封装通过组合特征和行为来创建新的数据类型。访问控制通过制作细节private
将接口与实现分开。这种机械的组织对于具有过程化编程背景的人来说很有意义。但是虚函数 在类型方面处理解耦。在第十四章的中,你看到了继承是如何允许把一个对象当作它自己的类型或它的基础类型。这种能力至关重要,因为它允许许多类型(派生自相同的基类型)被视为一种类型,并且一段代码可以平等地处理所有这些不同的类型。虚函数允许一种类型表达它与另一种相似类型的区别,只要它们都是从同一个基类型派生的。这种区别通过可以通过基类调用的函数的行为差异来表达。
在这一章中,你将学习虚函数,从简单的例子开始,除了函数的虚拟部分外,所有的东西都被去掉了。
C++ 程序员的进化
C 程序员似乎分三步习得 C++。首先,简单地说是“更好的 C”,因为 C++ 强迫你在使用它们之前声明所有的函数,并且对如何使用变量更加挑剔。用 C++ 编译器编译一个 C 程序,你就能发现其中的错误。
第二步是“基于对象”的 C++。这意味着您很容易看到将数据结构与作用于它的函数、构造器和析构函数的值以及一些简单的继承组合在一起的代码组织的好处。大多数已经使用 C 语言一段时间的程序员很快就看到了它的用处,因为无论何时他们创建一个库,这正是他们想要做的。有了 C++,你有了编译器的帮助。
你可能会在基于对象的层次上停滞不前,因为你可以很快到达那里,并且不需要太多的脑力劳动就可以获得很多好处。也很容易让人觉得你在创建数据类型——你创建类和对象,你向这些对象发送消息,一切都很好很整洁。
但是不要被骗了。如果您就此打住,您就错过了这门语言最伟大的部分,那就是向真正的面向对象编程的飞跃。您只能通过虚函数来实现这一点。
虚函数增强了类型的概念,而不仅仅是将代码封装在结构内部和墙的后面,因此它们无疑是新 C++ 程序员最难理解的概念。然而,它们也是理解面向对象编程的转折点。如果你没有使用虚函数,你还不了解 OOP。
因为虚函数与类型的概念密切相关,而类型是面向对象编程的核心,所以在传统的过程语言中没有虚函数的类似物。作为一个过程化的程序员,你在考虑虚函数时没有参照物,就像你在考虑语言中的其他特性一样。过程语言中的特性可以在算法层面上理解,但是虚函数只能从设计的角度来理解。
向上抛
在第十四章中,你看到了一个对象如何被用作它自己的类型或者它的基本类型。此外,还可以通过基本类型的地址对其进行操作。取一个对象的地址(或者是一个指针或者是一个引用)并把它当作基类的地址被称为向上转换,因为继承树是以基类在顶部的方式绘制的。
你也看到了一个问题的出现,它体现在清单 15-1 中。
清单 15-1 。说明继承和向上转换问题
//: C15:Instrument2.cpp
// Inheritance & upcasting
#include <iostream>
using namespace std;
enum note { middleC, Csharp, Eflat }; // Etc.
class Instrument {
public:
void play(note) const {
cout << "Instrument::play" << endl;
}
};
// Wind objects are Instruments
// because they have the same interface:
class Wind : public Instrument {
public:
// Redefine interface function:
void play(note) const {
cout << "Wind::play" << endl;
}
};
void tune(Instrument &i) {
// ...
i.play(middleC);
}
int main() {
Wind flute;
tune(flute); // Upcasting
} ///:∼
函数tune( )
接受(通过引用)一个Instrument
,但也毫无怨言地接受从Instrument
派生的任何东西。在main( )
中,当Wind
对象被传递给tune( )
时,你可以看到这种情况,不需要强制转换。这是可以接受的;Instrument
中的接口必须存在于Wind
中,因为Wind
是从Instrument
中公开继承的。从Wind
到Instrument
的向上转换可能会“缩小”该界面,但绝不会小于到Instrument
的完整界面。
在处理指针时也是如此;唯一的区别是,当对象被传递给函数时,用户必须显式地获取对象的地址。
问题
运行程序就能看出Instrument2.cpp
的问题。输出为Instrument::play
。这显然不是期望的输出,因为您碰巧知道该对象实际上是一个Wind
,而不仅仅是一个Instrument
。这个称呼竟然产生了Wind::play
。就此而言,从Instrument
派生的类的任何对象都应该使用它的play( )
版本,不管情况如何。
鉴于 C 语言处理函数的方式,Instrument2.cpp
的行为并不令人惊讶。为了理解这些问题,您需要了解绑定的概念。
函数调用绑定
将函数调用连接到函数体称为绑定。当在程序运行前进行绑定时(由编译器和链接器进行,称为早期绑定。你可能以前没有听说过这个术语,因为它从来都不是过程语言的选项:C 编译器只有一种函数调用,那就是早期绑定。
清单 15-1 中的程序问题是由早期绑定引起的,因为当它只有一个Instrument
地址时,编译器不知道该调用哪个正确的函数。这个解决方案叫做延迟绑定,这意味着绑定发生在运行时,基于对象的类型。后期绑定也叫动态绑定 或运行时绑定 。当一种语言实现后期绑定时,必须有某种机制在运行时确定对象的类型,并调用适当的成员函数。在编译语言的情况下,编译器仍然不知道实际的对象类型,但是它会插入代码来找出并调用正确的函数体。后期绑定机制因语言而异,但是您可以想象某种类型的信息必须安装在对象中。稍后您将看到这是如何工作的。
使用虚函数
为了对特定的函数进行后期绑定,C++ 要求在基类中声明函数时使用virtual
关键字。后期绑定只发生在virtual
函数中,并且只有当你使用那些virtual
函数存在的基类的地址时,尽管它们也可能在早期的基类中定义。
要创建一个成员函数作为virtual
,只需在函数声明之前加上关键字virtual
。只有声明需要virtual
关键字,而不是定义。如果一个函数在基类中被声明为virtual
,那么它在所有的派生类中都是virtual
。派生类中virtual
函数的重定义通常称为覆盖 。
注意,您只需要在基类中声明一个函数virtual
。所有与基类声明的签名匹配的派生类函数都将使用虚拟机制来调用。您可以在派生类声明中使用virtual
关键字(这样做没有坏处),但是这是多余的,而且可能会引起混淆。
要从Instrument2.cpp
获得想要的行为,只需在play( )
前的基类中添加virtual
关键字,如清单 15-2 所示。
清单 15-2 。用虚拟关键字 说明后期绑定
//: C15:Instrument3.cpp
// Late binding with the virtual keyword
#include <iostream>
using namespace std;
enum note { middleC, Csharp, Cflat }; // Etc.
class Instrument {
public:
virtual void play(note) const {
cout << "Instrument::play" << endl;
}
};
// Wind objects are Instruments
// because they have the same interface:
class Wind : public Instrument {
public:
// Override interface function:
void play(note) const {
cout << "Wind::play" << endl;
}
};
void tune(Instrument &i) {
// ...
i.play(middleC);
}
int main() {
Wind flute;
tune(flute);
// Upcasting
} ///:∼
除了添加了关键字virtual
之外,这个文件与Instrument2.cpp
完全相同,但是行为却有很大的不同:现在输出是Wind::play
。
扩展性
当play( )
在基类中被定义为virtual
时,您可以在不改变tune( )
函数的情况下添加任意多的新类型。在一个设计良好的 OOP 程序中,你的大部分或者全部函数都会遵循tune( )
的模型,只与基类接口进行通信。这样的程序是可扩展的,因为您可以通过从公共基类继承新的数据类型来添加新的功能。操纵基类接口的函数根本不需要改变来适应新的类。
清单 15-3 显示了带有更多虚函数和许多新类的仪器示例,所有这些都可以与旧的、未改变的tune( )
函数一起正常工作。
清单 15-3 。在 OOP 中展示可扩展性
//: C15:Instrument4.cpp
// Extensibility in OOP
#include <iostream>
using namespace std;
enum note { middleC, Csharp, Cflat }; // Etc.
class Instrument {
public:
virtual void play(note) const {
cout << "Instrument::play" << endl;
}
virtual char* what() const {
return "Instrument";
}
// Assume this will modify the object:
virtual void adjust(int) {}
};
class Wind : public Instrument {
public:
void play(note) const {
cout << "Wind::play" << endl;
}
char* what() const { return "Wind"; }
void adjust(int) {}
};
class Percussion : public Instrument {
public:
void play(note) const {
cout << "Percussion::play" << endl;
}
char* what() const { return "Percussion"; }
void adjust(int) {}
};
class Stringed : public Instrument {
public:
void play(note) const {
cout << "Stringed::play" << endl;
}
char* what() const { return "Stringed"; }
void adjust(int) {}
};
class Brass : public Wind {
public:
void play(note) const {
cout << "Brass::play" << endl;
}
char* what() const { return "Brass"; }
};
class Woodwind : public Wind {
public:
void play(note) const {
cout << "Woodwind::play" << endl;
}
char* what() const { return "Woodwind"; }
};
// Identical function from before:
void tune(Instrument&i) {
// ...
i.play(middleC);
}
// New function:
void f(Instrument&i) { i.adjust(1); }
// Upcasting during array initialization:
Instrument* A[] = {
new Wind,
new Percussion,
new Stringed,
new Brass,
};
int main() {
Wind flute;
Percussion drum;
Stringed violin;
Brass flugelhorn;
Woodwind recorder;
tune(flute);
tune(drum);
tune(violin);
tune(flugelhorn);
tune(recorder);
f(flugelhorn);
} ///:∼
您可以看到在Wind
下面添加了另一个继承级别,但是无论有多少个级别,virtual
机制都能正常工作。adjust( )
功能没有被Brass
和Woodwind
覆盖。发生这种情况时,会自动使用继承层次结构中“最接近”的定义;编译器保证对于一个虚函数总是有一些定义,所以你永远不会得到一个没有绑定到函数体的调用。
注那可就惨了。
数组A[ ]
包含指向基类Instrument
的指针,所以向上转换发生在数组初始化的过程中。这个数组和函数f( )
将在后面的讨论中用到。
在对tune( )
的调用中,向上转换在每个不同类型的对象上执行,然而期望的行为总是发生。这可以描述为“向对象发送消息,并让对象担心如何处理它。”virtual
函数是当你试图分析一个项目时使用的透镜:基类应该出现在哪里,以及你可能想要如何扩展程序?然而,即使您在最初创建程序时没有发现正确的基类接口和虚函数,您也会在以后,甚至很久以后,当您开始扩展或维护程序时发现它们。这不是分析或设计错误;它仅仅意味着你没有或者不可能在第一时间知道所有的信息。由于 C++ 中紧密的类模块化,当这种情况发生时,它不是一个大问题,因为你在系统的一部分所做的改变不会像在 C 中那样传播到系统的其他部分。
C++ 如何实现后期绑定
延迟绑定是如何发生的?所有的工作都由编译器在幕后进行,当您要求时,它会安装必要的后期绑定机制(您通过创建虚函数来要求)。因为程序员经常从理解 C++ 中虚函数的机制中受益,所以本节将详细阐述编译器实现这种机制的方式。
关键字virtual
告诉编译器它不应该执行早期绑定。相反,它应该自动安装执行后期绑定所需的所有机制。这意味着如果你通过基类Instrument
的地址调用Brass
对象的play( )
,你将得到正确的函数。
为了实现这一点,典型的编译器为每个包含virtual
函数的类创建一个表(称为 VTABLE)。编译器将该特定类的虚函数地址放在 VTABLE 中。在每个具有虚函数的类中,它秘密地放置一个指针,称为 vpointer(缩写为 VPTR),指向该对象的 VTABLE。当您通过基类指针进行虚函数调用时(即,当您进行多态调用时),编译器会悄悄地插入代码来获取 VPTR,并在 VTABLE 中查找函数地址,从而调用正确的函数并导致后期绑定发生。
所有这些——为每个类设置 VTABLE、初始化 VPTR、插入虚拟函数调用的代码——都是自动发生的,所以您不必担心。使用虚函数,即使编译器不知道对象的具体类型,也能为对象调用正确的函数。下面几节将更详细地介绍这一过程。
存储类型信息
您可以看到,在任何一个类中都没有存储显式的类型信息。但是前面的例子和简单的逻辑告诉你,对象中一定存储了某种类型的信息;否则,该类型无法在运行时建立。这是真的,但是类型信息是隐藏的。参见清单 15-4 检查使用虚函数和不使用虚函数的类的大小。
清单 15-4 。说明了对象大小的比较(有虚函数和没有虚函数)
//: C15:Sizes.cpp
// Object sizes with/without virtual functions
#include <iostream>
using namespace std;
classNoVirtual {
int a;
public:
void x() const {}
int i() const { return 1; }
};
class OneVirtual {
int a;
public:
virtual void x() const {}
int i() const { return 1; }
};
class TwoVirtuals {
int a;
public:
virtual void x() const {}
virtual int i() const { return 1; }
};
int main() {
cout << "int: " << sizeof(int) << endl;
cout << "NoVirtual: "
<< sizeof(NoVirtual) << endl;
cout << "void* : " << sizeof(void*) << endl;
cout << "OneVirtual: "
<< sizeof(OneVirtual) << endl;
cout << "TwoVirtuals: "
<< sizeof(TwoVirtuals) << endl;
} ///:∼
如果没有虚函数,对象的大小正是您所期望的:单个int
的大小。对于OneVirtual
中的单个虚函数,对象的大小是NoVirtual
的大小加上void
指针的大小。结果是,如果你有一个或多个虚函数,编译器会在结构中插入一个指针(VPTR)。OneVirtual
和TwoVirtuals
没有尺寸差异。这是因为 VPTR 指向一个函数地址表。您只需要一个表,因为所有的虚函数地址都包含在这个表中。
此示例需要至少一个数据成员。如果没有数据成员,C++ 编译器会强制对象为非零大小,因为每个对象必须有一个不同的地址。如果你想象索引到一个零大小的对象数组,你就会明白。“虚拟”成员被插入到原本大小为零的对象中。当因为关键字virtual
而插入类型信息时,这将代替虚拟成员。试着注释掉清单 15-4 中所有类的int a
来看看这个。
描绘虚拟功能
为了准确理解使用虚函数时发生了什么,可视化幕后发生的活动是很有帮助的。图 15-1 是Instrument4.cpp
中指针A[ ]
数组的示意图。
图 15-1 。仪器指针数组
Instrument
指针数组没有特定的类型信息;它们各自指向一个类型为Instrument
的对象。Wind
、Percussion
、Stringed
、Brass
都属于这一类,因为它们是从Instrument
派生出来的(因此与Instrument
有相同的接口,可以响应相同的消息),所以它们的地址也可以放入数组中。然而,编译器不知道它们只不过是Instrument
对象,所以留给它自己的设备,它通常会调用所有函数的基类版本。但是在这种情况下,所有这些函数都是用关键字virtual
声明的,所以会发生一些不同的事情。
每次创建包含虚函数的类,或者从包含虚函数的类派生时,编译器都会为该类创建一个唯一的 VTABLE,如图右侧所示。在该表中,它放置了在该类或基类中声明为虚拟的所有函数的地址。如果不重写基类中声明为虚拟的函数,编译器将使用派生类中基类版本的地址。
注你可以在Brass
VTABLE 中的adjust
条目中看到这个。
然后它将 VPTR(在清单 15-4Sizes.cpp
中的中发现)放入类中。像这样使用简单继承时,每个对象只有一个 VPTR。VPTR 必须初始化为指向适当 VTABLE 的起始地址。(这发生在构造器中,稍后您将看到更详细的内容。)
一旦 VPTR 被初始化为合适的 VTABLE,对象实际上“知道”它是什么类型。但是这种自知是没有价值的,除非在调用虚函数的时候使用它。
当您通过基类地址调用虚函数时(这种情况下,编译器没有执行早期绑定所需的所有信息),会发生一些特殊的事情。编译器生成不同的代码来执行函数调用,而不是执行典型的函数调用,函数调用只是针对特定地址的汇编语言CALL
。图 15-2 显示了通过Instrument
指针对Brass
对象的adjust( )
调用的样子。(一个Instrument
参考产生了同样的结果。)
图 15-2 。调用以调整黄铜对象
编译器从指向对象起始地址的Instrument
指针开始。所有的Instrument
对象或者从Instrument
派生的对象都有它们的 VPTR 在同一个地方(通常在对象的开头),所以编译器可以从对象中挑选出 VPTR。VPTR 指向 VTABLE 的起始地址。不管对象的具体类型如何,所有 VTABLE 函数地址都以相同的顺序排列。play( )
第一,what( )
第二,adjust( )
第三。编译器知道,不管具体的对象类型是什么,adjust( )
函数都位于 VPTR+2 位置。因此,它不会说“在绝对位置调用函数Instrument::adjust
”(早期绑定——错误的操作),而是生成代码,实际上是说“在 VPTR+2 调用函数”。因为 VPTR 的获取和实际函数地址的确定发生在运行时,所以您得到了想要的后期绑定。你向对象发送一条消息,对象就知道如何处理它。
引擎盖下
查看由虚函数调用生成的汇编语言代码会很有帮助,因此您可以看到后期绑定确实正在发生。这是调用的一个编译器的输出
i.adjust(1);
在函数f(Instrument &i)
内部:
push 1
pushsi
movbx, word ptr [si]
call word ptr [bx+4]
addsp, 4
C++ 函数调用的参数和 C 函数调用一样,都是从右向左推送到堆栈上的(支持 C 的变量参数列表需要这个顺序),所以参数1
是先推送到堆栈上的。在函数的这一点上,寄存器si
(英特尔 X86 处理器架构的一部分)包含了i
的地址。这也被推到堆栈上,因为它是感兴趣的对象的起始地址。记住起始地址对应于this
的值,并且this
在每个成员函数调用之前作为一个参数被悄悄地推到堆栈上,所以成员函数知道它正在处理哪个特定的对象。因此,在成员函数调用之前,您总是会看到比压入堆栈的参数数量多一个的参数(除了没有this
的static
成员函数)。
现在必须执行实际的虚函数调用。首先,必须产生 VPTR,这样才能找到 VTABLE。对于这个编译器,VPTR 被插入到对象的开头,所以this
的内容对应于 VPTR。这条线
mov bx, word ptr [si]
取si
(也就是this
)指向的字,就是 VPTR。它将 VPTR 放入寄存器bx
。
包含在bx
中的 VPTR 指向 VTABLE 的起始地址,但是要调用的函数指针不在 VTABLE 的位置 0,而是在位置 2(因为它是列表中的第三个函数)。对于这种内存模型,每个函数指针都是两个字节长,所以编译器在 VPTR 上加 4 来计算正确函数的地址。注意这是一个在编译时建立的常量值,所以唯一重要的是位置 2 的函数指针是adjust( )
的指针。幸运的是,编译器会为您处理所有的簿记工作,并确保特定类层次结构的所有 VTABLEs 中的所有函数指针都以相同的顺序出现,而不管您在派生类中重写它们的顺序。
一旦计算出 VTABLE 中适当函数指针的地址,该函数就被称为。因此,在语句中一次提取并调用地址
call word ptr [bx+4]
最后,将堆栈指针向上移回,以清除调用前推送的参数。在 C 和 C++ 汇编代码中,您经常会看到调用者清除参数,但这可能会因处理器和编译器的实现而异。
安装 Vpointer
因为 VPTR 决定了对象的虚函数行为,所以您可以看到 VPTR 总是指向正确的 VTABLE 是多么重要。在 VPTR 被正确初始化之前,你永远不希望能够调用一个虚函数。当然,可以保证初始化的地方是在构造器中,但是Instrument
的例子都没有构造器。
这就是创建默认构造器的必要之处。在Instrument
的例子中,编译器创建了一个默认的构造器,它除了初始化 VPTR 之外什么也不做。当然,在您可以对所有的Instrument
对象做任何事情之前,这个构造器会被自动调用,所以您知道调用虚函数总是安全的。在构造器中自动初始化 VPTR 的含义将在后面的章节中讨论。
对象不同
重要的是要认识到向上转换只处理地址。如果编译器有一个对象,它知道确切的类型,因此(在 C++ 中)不会对任何函数调用使用后期绑定——或者至少,编译器不需要使用来使用后期绑定。为了提高效率,大多数编译器在调用对象的虚函数时会执行早期绑定,因为它们知道对象的确切类型。参见清单 15-5 中的示例。
清单 15-5 。说明早期绑定和虚函数
//: C15:Early.cpp
// Early binding & virtual functions
#include <iostream>
#include <string>
using namespace std;
class Pet {
public:
virtual string speak() const { return ""; }
};
class Dog : public Pet {
public:
string speak() const { return "Bark!"; }
};
int main() {
Dog ralph;
Pet* p1 = &ralph;
Pet& p2 = ralph;
Pet p3;
// Late binding for both:
cout << "p1->speak() = " << p1->speak() << endl;
cout << "p2.speak() = " << p2.speak() << endl;
// Early binding (probably):
cout << "p3.speak() = " << p3.speak() << endl;
} ///:∼
在p1–>speak( )
和p2.speak( )
中使用了地址,这意味着信息不完整:p1
和p2
可以代表一个Pet
或从Pet
派生出来的某个东西的地址,所以必须使用虚拟机制。当调用p3.speak( )
时,没有歧义。编译器知道确切的类型,知道它是一个对象,所以它不可能是从Pet
派生的对象——它就是一个Pet
。因此,可能会使用早期绑定。但是,如果编译器不想这么辛苦,它仍然可以使用后期绑定,同样的行为也会发生。
为什么是虚函数?
此时,您可能想知道,“如果这种技术如此重要,如果它能一直进行‘正确’的函数调用,为什么它是一种选择呢?为什么我需要知道这件事?”
这是一个很好的问题,答案是 C++ 基本哲学的一部分:“??”,因为它没有 ?? 那么有效从前面的汇编语言输出中可以看出,建立虚函数调用需要两条(更复杂的)汇编指令,而不是一个简单的绝对地址调用。这需要代码空间和执行时间。
一些面向对象语言已经采取了这样的方法,即后期绑定是面向对象编程所固有的,它应该总是发生,它不应该是一个选项,用户不应该知道它。这是创建语言时的一个设计决策,这个特定的路径适用于许多语言。然而,C++ 来自 C 传统,效率是关键。毕竟,创建 C 语言是为了取代汇编语言来实现操作系统(从而使 Unix 操作系统比它的前辈更具可移植性)。发明 C++ 的主要原因之一是让 C 程序员更有效率。而当 C 程序员遇到 C++ 时问的第一个问题就是,“我会得到什么样的大小和速度影响?”如果答案是,“除了函数调用之外,一切都很好,因为你总是会有一点额外的开销,”许多人会坚持使用 C,而不是改为 C++。此外,内联函数是不可能的,因为虚函数必须有一个地址才能放入 VTABLE。所以虚函数是一个选项,语言默认为非虚函数,这是最快的配置。
因此,virtual
关键字用于效率调整。然而,在设计您的类时,您不应该担心效率调优。如果你打算使用多态,那么就在任何地方使用虚函数。在寻找加速代码的方法时,你只需要寻找可以被非虚拟化的函数(,在其他领域通常会有更大的收获;一个好的剖析器会比你通过猜测更好地找到瓶颈。
轶事证据表明,转向 C++ 的大小和速度影响在 C 的大小和速度的 10%以内,并且通常非常接近。你可能获得更好的大小和速度效率的原因是因为你可以用比使用 C 更小、更快的方式设计 C++ 程序。
抽象基类和纯虚函数
通常在设计中,您希望基类只为其派生类提供一个接口。也就是说,你不希望任何人实际上创建一个基类的对象,只是向上转换到它,以便它的接口可以被使用。这是通过使该类抽象来实现的,如果你给它至少一个纯虚函数,就会发生这种情况。您可以识别一个纯虚函数,因为它使用了virtual
关键字,后面跟有= 0
。如果有人试图创建抽象类的对象,编译器会阻止他们。这是一个允许您实施特定设计的工具。
当一个抽象类被继承时,所有的纯虚函数都必须被实现,否则继承的类也会变成抽象的。创建一个纯虚函数允许你把一个成员函数放到一个接口中,而不必被迫为这个成员函数提供一个可能没有意义的代码体。同时,一个纯虚函数迫使继承的类为它提供一个定义。
在所有的仪器示例中,基类Instrument
中的函数总是哑函数。如果调用了这些函数,那么一定是出了问题。这是因为Instrument
的目的是为从它派生的所有类创建一个公共接口。
建立通用接口的唯一原因是,它可以针对不同的子类型进行不同的表达(见图 15-3 )。它创建了一个基本形式,确定了所有派生类的共同点——除此之外别无其他。所以Instrument
是一个合适的抽象类。当您只想通过一个公共接口操作一组类,但是公共接口不需要有一个实现(或者至少是一个完整的实现)时,您可以创建一个抽象类。
图 15-3 。仪器的通用接口
如果你有一个像Instrument
这样的抽象类,那么这个类的对象几乎总是没有任何意义。也就是说,Instrument
只是用来表达接口,而不是特定的实现,所以创建一个只有Instrument
的对象是没有意义的,而且你可能想阻止用户这样做。这可以通过让Instrument
中的所有虚函数打印错误信息来实现,但是这会将错误信息的出现延迟到运行时,并且需要用户进行可靠的详尽测试。在编译时发现问题要好得多。
下面是用于纯虚拟声明的语法:
virtual void f() = 0;
通过这样做,您告诉编译器为 VTABLE 中的函数保留一个槽,但不要将地址放在那个特定的槽中。即使一个类中只有一个函数被声明为纯虚拟的,VTABLE 也是不完整的。
如果一个类的 VTABLE 不完整,当有人试图创建该类的对象时,编译器应该做什么?它不能安全地创建抽象类的对象,所以编译器会给出一个错误消息。因此,编译器保证了抽象类的纯度。通过使一个类成为抽象的,你可以确保客户程序员不会误用它。
清单 15-6 显示了修改后的Instrument4.cpp
使用纯虚函数。因为这个类除了纯虚函数什么都没有,所以它被称为纯抽象类。
清单 15-6 。说明了一个纯抽象类
//: C15:Instrument5.cpp
// Pure abstract base classes
#include <iostream>
using namespace std;
enum note { middleC, Csharp, Cflat }; // Etc.
class Instrument {
public:
// Pure virtual functions:
virtual void play(note) const = 0;
virtual char* what() const = 0;
// Assume this will modify the object:
virtual void adjust(int) = 0;
};
// Rest of the file is the same ...
class Wind : public Instrument {
public:
void play(note) const {
cout << "Wind::play" << endl;
}
char* what() const { return "Wind"; }
void adjust(int) {}
};
class Percussion : public Instrument {
public:
void play(note) const {
cout << "Percussion::play" << endl;
}
char* what() const { return "Percussion"; }
void adjust(int) {}
};
class Stringed : public Instrument {
public:
void play(note) const {
cout << "Stringed::play" << endl;
}
char* what() const { return "Stringed"; }
void adjust(int) {}
};
class Brass : public Wind {
public:
void play(note) const {
cout << "Brass::play" << endl;
}
char* what() const { return "Brass"; }
};
class Woodwind : public Wind {
public:
void play(note) const {
cout << "Woodwind::play" << endl;
}
char* what() const { return "Woodwind"; }
};
// Identical function from before:
void tune(Instrument&i) {
// ...
i.play(middleC);
}
// New function:
void f(Instrument&i) { i.adjust(1); }
int main() {
Wind flute;
Percussion drum;
Stringed violin;
Brass flugelhorn;
Woodwind recorder;
tune(flute);
tune(drum);
tune(violin);
tune(flugelhorn);
tune(recorder);
f(flugelhorn);
} ///:∼
纯虚函数很有用,因为它们明确了类的抽象性,并告诉用户和编译器如何使用它。
注意,纯虚函数防止抽象类通过值传递给函数。因此,这也是一种防止对象切片的方法(稍后将对此进行描述)。通过将类抽象为,您可以确保在向上转换到该类的过程中始终使用指针或引用。
仅仅因为一个纯虚函数阻止了 VTABLE 的完成,并不意味着你不想要其他函数的函数体。通常你会希望调用一个函数的基类版本,即使它是虚拟的。将公共代码放在尽可能靠近层次结构的根总是一个好主意。这不仅节省了代码空间,还允许轻松传播更改。
纯虚拟定义
在基类中提供一个纯虚函数的定义是可能的。你仍然告诉编译器不要允许抽象基类的对象,纯虚函数仍然必须在派生类中定义,以便创建对象。但是,您可能希望一些或所有派生类定义调用一段公共代码,而不是在每个函数中复制这段代码。清单 15-7 显示了纯虚拟定义的样子。
清单 15-7 。说明纯虚拟定义
//: C15:PureVirtualDefinitions.cpp
// Pure virtual base definitions
#include <iostream>
using namespace std;
class Pet {
public:
virtual void speak() const = 0;
virtual void eat() const = 0;
// Inline pure virtual definitions illegal:
//! virtual void sleep() const = 0 {}
};
// OK, not defined inline
void Pet::eat() const {
cout << "Pet::eat()" << endl;
}
void Pet::speak() const {
cout << "Pet::speak()" << endl;
}
class Dog : public Pet {
public:
// Use the common Pet code:
void speak() const { Pet::speak(); }
void eat() const { Pet::eat(); }
};
int main() {
Dog simba; // Richard's dog
simba.speak();
simba.eat();
} ///:∼
在Pet
VTABLE 中的槽仍然是空的,但是碰巧有一个同名的函数,您可以在派生类中调用它。
这个特性的另一个好处是,它允许您在不干扰现有代码的情况下,从普通虚拟变为纯虚拟。
注意这是一种定位不覆盖虚函数的类的方法。
继承和虚拟表
您可以想象当您执行继承并覆盖一些虚函数时会发生什么。编译器为您的新类创建一个新的 VTABLE,并使用您没有覆盖的任何虚函数的基类函数地址插入您的新函数地址。不管怎样,对于每个可以被创建的对象(也就是说,它的类没有纯虚数),VTABLE 中总是有一个完整的函数地址集,所以你永远无法调用一个不存在的地址(,那将是灾难性的)。
但是当你在派生类中继承并添加新的虚函数时会发生什么呢?参见清单 15-8 。
清单 15-8 。说明在派生类中添加虚函数
//: C15:AddingVirtuals.cpp
// Adding virtuals in derivation
#include <iostream>
#include <string>
using namespace std;
class Pet {
string pname;
public:
Pet(const string &petName) : pname(petName) {}
virtual string name() const { return pname; }
virtual string speak() const { return ""; }
};
class Dog : public Pet {
string name;
public:
Dog(const string &petName) : Pet(petName) {}
// New virtual function in the Dog class:
virtual string sit() const {
return Pet::name() + " sits";
}
string speak() const { // Override
return Pet::name() + " says 'Bark!'";
}
};
int main() {
Pet* p[] = {new Pet("generic"),new Dog("bob")};
cout << "p[0]->speak() = "
<< p[0]->speak() << endl;
cout << "p[1]->speak() = "
<< p[1]->speak() << endl;
//! cout << "p[1]->sit() = "
//! << p[1]->sit() << endl; // Illegal
} ///:∼
类Pet
包含两个虚函数,speak( )
和name( )
。Dog
增加了第三个虚函数sit( )
,也覆盖了speak( )
的含义。图 15-4 将帮助你想象正在发生的事情。它描述了编译器为Pet
和Dog
创建的 VTABLEs。
图 15-4 。宠物和狗的虚拟桌子
注意,编译器将speak( )
地址的位置映射到Dog
虚拟表中与Pet
虚拟表中完全相同的位置。类似地,如果一个类Pug
是从Dog
继承来的,那么它的版本sit( )
将被放在它的 VTABLE 中与它在Dog
中的位置完全相同的位置。这是因为(正如您在汇编语言示例中看到的那样)编译器生成的代码在 VTABLE 中使用一个简单的数字偏移量来选择虚函数。不管对象属于哪个子类型,它的 VTABLE 都以相同的方式布局,所以对虚函数的调用总是以相同的方式进行。
然而,在这种情况下,编译器只使用指向基类对象的指针。基类只有speak( )
和name( )
函数,所以编译器只允许你调用这些函数。如果它只有一个指向基类对象的指针,它怎么可能知道你正在使用一个Dog
对象呢?该指针可能指向其他类型,但没有sit( )
函数。在 VTABLE 中的那个点上,它可能有也可能没有其他的函数地址,但是在任何一种情况下,对那个 VTABLE 地址进行虚拟调用都不是您想要做的。所以编译器通过防止你对只存在于派生类中的函数进行虚拟调用来完成它的工作。
在一些不太常见的情况下,您可能知道指针实际上指向一个特定子类的对象。如果你想调用一个只存在于那个子类中的函数,那么你必须转换指针。您可以删除上一个程序产生的错误信息,如下所示:
((Dog*)p[1])->sit()
在这里,你碰巧知道p[1]
指向一个Dog
对象,但一般情况下你并不知道。如果你的问题是你必须知道所有对象的确切类型,你应该重新考虑一下,因为你可能没有正确使用虚函数。但是,在某些情况下,如果您知道保存在通用容器中的所有对象的确切类型,那么这种设计会发挥最佳效果(或者您别无选择)。这就是运行期类型识别【RTTI】的问题。
`RTTI 就是将基类指针向下转换为派生类指针(“向上”和“向下”是相对于典型的类图而言的,基类在顶部)。向上投射是自动发生的,没有强制,因为它是完全安全的。将强制转换为是不安全的,因为没有关于实际类型的编译时信息,所以你必须确切地知道对象是什么类型。如果你把它转换成错误的类型,你就有麻烦了。(RTTI 将在本章稍后描述,第二十章也完全致力于这个主题。)
对象切片
使用多态时,传递对象的地址和通过值传递对象有明显的区别。您在这里看到的所有示例,以及实际上您应该看到的所有示例,都传递地址而不是值。这是因为地址都有相同的大小,所以传递派生类型的对象(通常是较大的对象)的地址与传递基类型的对象(通常是较小的对象)的地址是一样的。如前所述,这是使用多态的目标:操作基类型的代码也可以透明地操作派生类型对象。
如果你向上转换到一个对象而不是一个指针或引用,会发生一些让你吃惊的事情:对象被“分割”直到所有剩下的都是对应于你转换的目标类型的子对象。在清单 15-9 中,你可以看到当一个对象被切片时会发生什么。
清单 15-9 。说明对象切片
//: C15:ObjectSlicing.cpp
#include <iostream>
#include <string>
using namespace std;
class Pet {
string pname;
public:
Pet(const string& name) : pname(name) {}
virtual string name() const { return pname; }
virtual string description() const {
return "This is " + pname;
}
};
class Dog : public Pet {
string favoriteActivity;
public:
Dog(const string& name, const string& activity)
: Pet(name), favoriteActivity(activity) {}
string description() const {
return Pet::name() + " likes to " +
favoriteActivity;
}
};
void describe(Pet p) { // Slices the object
cout << p.description() << endl;
}
int main() {
Pet p("Bob");
Dog d("Peter", "sleep");
describe(p);
describe(d);
} ///:∼
函数describe( )
通过值传递一个类型为Pet
的对象。然后它为Pet
对象调用虚拟函数description( )
。在main( )
中,你可能期望第一个调用产生“这是鲍勃”,第二个产生“彼得喜欢睡觉”实际上,这两个调用都使用了description( )
的基类版本。
在这个项目中发生了两件事。首先,因为describe( )
接受一个Pet
对象(而不是指针或引用),任何对describe( )
的调用都会导致一个大小为Pet
的对象被压入堆栈,并在调用后被清除。这意味着如果一个从Pet
继承的类的对象被传递给describe( )
,编译器接受它,但是它只复制对象的Pet
部分。它切掉物体的衍生部分,如图图 15-5 所示。
图 15-5 。显示了从基本内容(宠物)派生的部分(狗)的切片
现在你可能想知道虚函数调用。Dog::description( )
利用了Pet
(仍然存在)和Dog
的一部分,它们已经不存在了,因为它们已经被切掉了!那么当调用虚函数时会发生什么呢?
因为对象是通过值传递的,所以您免于灾难。因此,编译器知道对象的精确类型,因为派生对象已被强制成为基对象。当通过值传递时,使用Pet
对象的复制构造器,它将 VPTR 初始化为Pet
VTABLE,并且只复制对象的Pet
部分。这里没有显式的复制构造器,所以编译器合成了一个。在所有的解释下,对象在切片过程中真正变成了一个Pet
。
对象切片实际上删除了现有对象的一部分,因为它将它复制到新对象中,而不是像使用指针或引用时那样简单地改变地址的含义。因此,向上转换成一个对象并不常见;事实上,这通常是需要注意和预防的事情。注意,在这个例子中,如果description( )
在基类中被做成一个纯虚函数(这并不是不合理的,因为它在基类中并不真正做任何事情),那么编译器会阻止对象切片,因为这不允许你“创建”一个基类的对象(当你通过值向上转换时就会发生这种情况)。这可能是纯虚函数最重要的价值:如果有人试图这样做,通过生成编译时错误消息来防止对象切片。
超载和越权
在第十四章中,你看到了在基类中重新定义一个重载函数隐藏了该函数的所有其他基类版本。当涉及到虚函数时,行为会有一点不同。清单 15-10 显示了第十四章的NameHiding.cpp
示例的修改版本。
清单 15-10 。证明虚函数限制重载
//: C15:NameHiding2.cpp
// Virtual functions restrict overloading
#include <iostream>
#include <string>
using namespace std;
class Base {
public:
virtual int f() const {
cout << "Base::f()\n";
return 1;
}
virtual void f(string) const {}
virtual void g() const {}
};
class Derived1 : public Base {
public:
void g() const {}
};
class Derived2 : public Base {
public:
// Overriding a virtual function:
int f() const {
cout << "Derived2::f()\n";
return 2;
}
};
class Derived3 : public Base {
public:
// Cannot change return type:
//! void f() const{ cout<< "Derived3::f()\n";}
};
class Derived4 : public Base {
public:
// Change argument list:
int f(int) const {
cout << "Derived4::f()\n";
return 4;
}
};
int main() {
string s("hello");
Derived1 d1;
int x = d1.f();
d1.f(s);
Derived2 d2;
x = d2.f();
//! d2.f(s); // string version hidden
Derived4 d4;
x = d4.f(1);
//! x = d4.f(); // f() version hidden
//! d4.f(s); // string version hidden
Base &br = d4; // Upcast
//! br.f(1); // Derived version unavailable
br.f(); // Base version available
br.f(s); // Base version available
} ///:∼
首先要注意的是,在Derived3
中,编译器不允许你改变被覆盖函数的返回类型(如果f( )
不是虚拟的,它会允许)。这是一个重要的限制,因为编译器必须保证你可以通过基类多态地调用这个函数,如果基类期望从f( )
返回一个int
,那么f( )
的派生类版本必须保持这个契约,否则事情将会失败。
第十四章中显示的规则仍然有效:如果你覆盖了基类中的一个重载成员函数,其他重载版本将隐藏在派生类中。在main( )
中,测试Derived4
的代码显示,即使新版本的f( )
实际上没有覆盖现有的虚函数接口,也会发生这种情况——两个基类版本的f( )
都被f(int)
隐藏了。但是,如果将d4
向上转换为Base
,那么只有基类版本可用(因为这是基类契约所承诺的),派生类版本不可用(因为它没有在基类中指定)。
变型返回类型
上面的Derived3
类表明你不能在重写过程中修改虚函数的返回类型。这通常是正确的,但是有一种特殊情况,您可以稍微修改返回类型。如果你返回一个指向基类的指针或者引用,那么这个函数的重写版本可能会返回一个指向基类的指针或者引用。见清单 15-11 中的例子。
清单 15-11 。说明变体返回类型
//: C15:VariantReturn.cpp
// Returning a pointer or reference to a derived
// type during overriding
#include <iostream>
#include <string>
using namespace std;
class PetFood {
public:
virtual string foodType() const = 0;
};
class Pet {
public:
virtual string type() const = 0;
virtual PetFood* eats() = 0;
};
class Bird : public Pet {
public:
string type() const { return "Bird"; }
class BirdFood : public PetFood {
public:
string foodType() const {
return "Bird food";
}
};
// Upcast to base type:
PetFood* eats() { return &bf; }
private:
BirdFood bf;
};
class Cat : public Pet {
public:
string type() const { return "Cat"; }
class CatFood : public PetFood {
public:
string foodType() const { return "Birds"; }
};
// Return exact type instead:
CatFood* eats() { return &cf; }
private:
CatFood cf;
};
int main() {
Bird b;
Cat c;
Pet* p[] = { &b, &c, };
for(int i = 0; i < sizeof p / sizeof *p; i++)
cout << p[i]->type() << " eats "
<< p[i]->eats()->foodType() << endl;
// Can return the exact type:
Cat::CatFood* cf = c.eats();
Bird::BirdFood* bf;
// Cannot return the exact type:
//! bf = b.eats();
// Must downcast:
bf = dynamic_cast<Bird::BirdFood*>(b.eats());
} ///:∼
Pet::eats( )
成员函数返回一个指向PetFood
的指针。在Bird
中,这个成员函数和基类一样被重载,包括返回类型。也就是说,Bird::eats( )
将BirdFood
向上转换为PetFood
。
但是在Cat
中,eats( )
的返回类型是指向CatFood
的指针,是从PetFood
派生出来的类型。返回类型是从基类函数的返回类型继承的,这是编译的唯一原因。这样,合同仍然得到履行;eats( )
总是返回一个PetFood
指针。
如果你多形性地思考,这似乎没有必要。为什么不像Bird::eats( )
那样,将所有的返回类型都向上转换为PetFood*
?这通常是一个很好的解决方案,但是在main( )
的结尾,您会看到不同之处:Cat::eats( )
可以返回PetFood
的精确类型,而Bird::eats( )
的返回值必须向下转换为精确类型。
所以能够返回确切的类型更通用一点,不会因为自动向上转换而丢失特定的类型信息。然而,返回基本类型通常会解决你的问题,所以这是一个相当特殊的特性。
虚拟函数和构造器
当一个包含虚函数的对象被创建时,它的 VPTR 必须被初始化以指向正确的 VTABLE。这必须在有可能调用虚函数之前完成。正如您可能猜到的那样,因为构造器的工作是将一个对象变为现实,所以构造器的工作也是设置 VPTR。编译器秘密地将代码插入初始化 VPTR 的构造器的开头。并且如第十四章所述,如果你没有为一个类显式创建构造器,编译器会为你合成一个。如果类有虚函数,合成的构造器将包含正确的 VPTR 初始化代码。这有几个含义。
首先是效率问题。使用内联函数的原因是为了减少小函数的调用开销。如果 C++ 没有提供内联函数,预处理器可能会被用来创建这些“宏”然而,预处理器没有访问或类的概念,因此不能用来创建成员函数宏。此外,对于必须由编译器插入隐藏代码的构造器,预处理宏根本不起作用。
在寻找效率漏洞时,你必须意识到编译器正在你的构造器中插入隐藏代码。它不仅必须初始化 VPTR,还必须检查this
的值(以防operator new( )
返回零)并调用基类构造器。综上所述,这段代码可能会影响到您认为很小的内联函数调用。特别是,构造器的大小可能会超过您从减少函数调用开销中获得的节省。如果您进行了大量的内联构造器调用,您的代码大小可能会增加,但在速度上没有任何好处。
当然,你可能不会马上把所有的小构造器都变成非内联的,因为它们更容易写成内联的。但是当您调整代码时,请记住考虑移除内联构造器。
构造器调用 的顺序
构造器和虚函数的第二个有趣的方面涉及到构造器调用的顺序以及在构造器中进行虚函数调用的方式。
基类构造器总是在继承类的构造器中调用。这是有意义的,因为构造器有一项特殊的工作:确保对象构建正确。派生类只能访问自己的成员,而不能访问基类的成员。只有基类构造器可以正确初始化自己的元素。因此,所有的构造器都被调用是很重要的;否则整个对象就不会被正确构造。这就是为什么编译器对派生类的每个部分都强制调用构造器。如果你没有在构造器初始化列表中显式调用基类构造器,它将调用默认构造器。如果没有默认的构造器,编译器会报错。
构造器调用的顺序很重要。当您继承时,您知道基类的所有信息,并且可以访问基类的任何public
和protected
成员。这意味着当你在派生类中时,你必须能够假设基类的所有成员都是有效的。在一个普通的成员函数中,构造已经发生了,所以对象的所有部分的所有成员都已经被建立了。但是,在构造器内部,您必须能够假设您使用的所有成员都已经构建好了。保证这一点的唯一方法是首先调用基类构造器。然后,当你在派生类构造器中时,你可以在基类中访问的所有成员都已经被初始化了。知道所有成员在构造器内部都是有效的,也是你应该尽可能在构造器初始化列表中初始化所有成员对象(即使用组合放置在类中的对象)的原因。如果遵循这种做法,可以假设当前对象的所有基类成员和成员对象都已经初始化。
构造器 内部虚函数的行为
构造器调用的层次结构带来了一个有趣的难题。如果你在一个构造器中调用一个虚函数,会发生什么?在一个普通的成员函数中,你可以想象会发生什么——虚拟调用在运行时被解析,因为对象不知道它是属于成员函数所在的类,还是从它派生的某个类。为了一致性,您可能认为这是构造器内部应该发生的事情。
事实并非如此。如果在构造器中调用虚函数,则只使用该函数的本地版本。也就是说,虚拟机制在构造器中不起作用。
这种行为有两个原因。从概念上讲,构造器的工作是将对象变为现实(这可不是一个普通的壮举)。在任何构造器内部,对象可能只是部分形成的;你只能知道基类对象被初始化了,却无法知道哪些类是从你那里继承来的。然而,虚函数调用“向前”或“向外”到达继承层次。它调用派生类中的函数。如果您可以在构造器中这样做,您将调用一个可能操纵尚未初始化的成员的函数,这肯定会导致灾难。
第二个原因是机械性的。当一个构造器被调用时,它做的第一件事就是初始化它的 VPTR。然而,它只能知道它是“当前”类型——构造器是为这种类型编写的。构造器代码完全不知道对象是否在另一个类的基类中。当编译器为该构造器生成代码时,它为该类的构造器生成代码,而不是基类,也不是从基类派生的类(因为类无法知道谁继承了它)。所以它使用的 VPTR 必须是针对那个类的的 VTABLE。VPTR 在对象的剩余生命周期内保持初始化为那个 VTABLE,除非这不是最后一次构造器调用。如果后来调用了一个派生程度更高的构造器,该构造器会将 VPTR 设置为 VTABLE,依此类推,直到最后一个构造器完成。VPTR 的状态由最后调用的构造器决定。这也是为什么构造器按照从基础到最派生的顺序被调用的另一个原因。
但是当所有这一系列构造器调用发生时,每个构造器都将 VPTR 设置为自己的 VTABLE。如果它对函数调用使用虚拟机制,那么它将只通过自己的 VTABLE 产生一个调用,而不是最具派生性的 VTABLE(在调用了所有构造器之后就会出现这种情况)。此外,许多编译器认识到构造器内部正在进行虚函数调用,并执行早期绑定,因为它们知道后期绑定将只产生对局部函数的调用。在这两种情况下,您都不会从构造器内部的虚函数调用中获得最初预期的结果。
析构函数和虚拟析构函数
不能对构造器使用virtual
关键字,但是析构函数可以而且通常必须是虚拟的。
构造器有一项特殊的工作,首先通过调用基构造器,然后按照继承的顺序调用更多的派生构造器(它还必须沿着 wa y 调用成员-对象构造器),将一个对象一片一片地放在一起。类似地,析构函数有一个特殊的工作:它必须分解一个可能属于一个类层次结构的对象。为此,编译器生成调用所有析构函数的代码,但是按照与构造器调用它们的相反的顺序。也就是说,析构函数从派生程度最高的类开始,一直向下到基类。这是安全和可取的做法,因为当前的析构函数总是知道基类成员是活动的。如果需要调用析构函数内部的基类成员函数,这样做是安全的。因此,析构函数可以执行自己的清理,然后调用下一个析构函数,后者将执行自己的清理,等等。每个析构函数都知道它的类是从派生的,但不知道从它派生的是什么。
您应该记住,构造器和析构函数是这种调用层次结构必须发生的唯一地方(因此编译器会自动生成适当的层次结构)。在所有其他函数中,无论是否是虚拟的,只有那个函数会被调用(,而不是基类版本)。在普通函数(虚拟或非)中调用同一函数的基类版本的唯一方法是显式调用该函数。
正常情况下,析构函数的作用已经足够了。但是如果你想通过一个指向它的基类的指针来操作一个对象(也就是说,通过它的泛型接口来操作对象),会发生什么呢?这项活动是面向对象编程的一个主要目标。当您想要为已经用new
在堆上创建的对象使用delete
这种类型的指针时,问题就出现了。如果指针指向基类,编译器只能知道在delete
期间调用基类版本的析构函数。
听起来熟悉吗?
这与创建虚函数来解决一般情况下的问题是一样的。幸运的是,虚函数适用于析构函数,就像它们适用于除了构造器之外的所有其他函数一样;参见清单 15-12 。
清单 15-12 。说明虚拟和非虚拟析构函数的行为
//: C15:VirtualDestructors.cpp
// Behavior of virtual vs. non-virtual destructor
#include <iostream>
using namespace std;
class Base1 {
public:
∼Base1() { cout << "∼Base1()\n"; }
};
class Derived1 : public Base1 {
public:
∼Derived1() { cout << "∼Derived1()\n"; }
};
class Base2 {
public:
virtual ∼Base2() { cout << "∼Base2()\n"; }
};
class Derived2 : public Base2 {
public:
∼Derived2() { cout << "∼Derived2()\n"; }
};
int main() {
Base1* bp = new Derived1; // Upcast
delete bp;
Base2* b2p = new Derived2; // Upcast
delete b2p;
} ///:∼
当你运行程序时,你会看到delete bp
只调用基类析构函数,而delete b2p
调用派生类析构函数,后跟基类析构函数,这是我们想要的行为。忘记创建析构函数virtual
是一个潜在的错误,因为它通常不会直接影响程序的行为,但是它会悄悄地引入内存泄漏。此外,一些破坏正在发生的事实会进一步掩盖问题。
即使析构函数像构造器一样是一个“异常”函数,析构函数也有可能是虚的,因为对象已经知道它是什么类型(而在构造过程中却不知道)。一旦一个对象被构造,它的 VPTR 就被初始化,因此虚函数调用就可以发生了。
纯虚拟析构函数
虽然纯虚析构函数在标准 C++ 中是合法的,但是在使用它们时有一个附加的约束:你必须为纯虚析构函数提供一个函数体。这似乎违反直觉;虚函数需要函数体怎么可能“纯”?但是如果你记住构造器和析构函数是特殊的操作,那就更有意义了,尤其是如果你记住一个类层次结构中的所有析构函数总是被调用的。如果你可以抛开纯虚拟析构函数的定义,那么在析构过程中会调用什么函数体呢?因此,编译器和链接器强制纯虚拟析构函数体的存在是绝对必要的。
如果它是纯的,但它必须有一个函数体,它的价值是什么?您将看到的纯虚析构函数和非纯虚析构函数之间的唯一区别是,纯虚析构函数确实导致基类是抽象的,因此您不能创建基类的对象(尽管如果基类的任何其他成员函数都是纯虚的,这也是正确的)。
然而,当你从一个包含纯虚析构函数的类继承一个类时,事情就有点混乱了。不像其他所有的纯虚函数,你不需要在派生类中提供一个纯虚析构函数的定义。清单 15-13 中的代码编译和链接就是证明。
清单 15-13 。说明纯虚拟析构函数
//: C15:UnAbstract.cpp
// Pure virtual destructors
// seem to behave strangely
classAbstractBase {
public:
virtual ∼AbstractBase() = 0;
};
AbstractBase::∼AbstractBase() {}
class Derived : public AbstractBase {};
// No overriding of destructor necessary?
int main() { Derived d; } ///:∼
通常,基类中的纯虚函数会导致派生类是抽象的,除非给它(以及所有其他纯虚函数)一个定义。但在这里,情况似乎并非如此。但是,请记住,如果您没有创建析构函数,编译器会自动为每个类创建一个析构函数定义。这就是这里发生的事情——基类析构函数被悄悄覆盖,因此定义由编译器提供,Derived
实际上不是抽象的。
这就带来了一个有趣的问题:纯虚拟析构函数的意义是什么?不像普通的纯虚函数,你必须给它一个函数体。在派生类中,你不需要提供定义,因为编译器会为你合成析构函数。那么常规的虚析构函数和纯虚析构函数有什么区别呢?
唯一的区别发生在只有一个纯虚函数的类中:析构函数。在这种情况下,析构函数的纯度的唯一作用是防止基类的实例化。如果有其他的纯虚函数,它们会阻止基类的实例化,但是如果没有其他的,那么纯虚析构函数会阻止基类的实例化。因此,虽然添加一个虚析构函数是必要的,但它是否是纯析构函数并不重要。
当你运行清单 15-14 中的代码时,你可以看到纯虚函数体是在派生类版本之后被调用的,就像其他任何析构函数一样。
清单 15-14 。说明纯虚析构函数需要一个函数体(也说明了虚函数体是在派生类版本之后调用的)
//: C15:PureVirtualDestructors.cpp
// Pure virtual destructors
// require a function body
#include <iostream>
using namespace std;
class Pet {
public:
virtual ∼Pet() = 0;
};
Pet::∼Pet() {
cout << "∼Pet()" << endl;
}
class Dog : public Pet {
public:
∼Dog() {
cout << "∼Dog()" << endl;
}
};
int main() {
Pet* p = new Dog; // Upcast
delete p; // Virtual destructor call
} ///:∼
作为一个指导原则,任何时候你在一个类中有一个虚函数,你应该立即添加一个虚析构函数(,即使它什么也不做)。这样,你可以确保以后不会有任何意外。
析构函数中的虚数
在毁灭过程中会发生一些你可能不会立即想到的事情。如果你在一个普通的成员函数中调用了一个虚函数,那么这个函数是使用后期绑定机制调用的。对于析构函数,不管是不是虚的,都不是这样。在析构函数内部,只调用成员函数的“本地”版本;虚拟机制被忽略了,正如你在清单 15-15 中看到的。
清单 15-15 。阐释析构函数内部的虚拟调用
//: C15:VirtualsInDestructors.cpp
// Virtual calls inside destructors
#include <iostream>
using namespace std;
class Base {
public:
virtual ∼Base() {
cout << "Base1()\n";
f();
}
virtual void f() { cout << "Base::f()\n"; }
};
class Derived : public Base {
public:
∼Derived() { cout << "∼Derived()\n"; }
void f() { cout << "Derived::f()\n"; }
};
int main() {
Base* bp = new Derived; // Upcast
delete bp;
} ///:∼
在析构函数调用期间,Derived::f( )
是被而不是调用,即使f( )
是虚拟的。
这是为什么呢?假设虚拟机制被用在析构函数内部。那么虚拟调用就有可能解析到一个比当前析构函数在继承层次上“更远的(更衍生的)的函数。但是析构函数是从“外部的”调用的(从最派生的析构函数一直到基本析构函数),所以实际调用的函数依赖于对象中已经被销毁的部分!相反,编译器在编译时解析调用,并且只调用函数的“本地”版本。注意,构造器也是如此(如前所述),但在构造器的情况下,类型信息不可用,而在析构函数中,信息(即 VPTR)是存在的,但它不可靠。
*创建基于对象的层次
在展示容器类Stack
和Stash
的过程中,贯穿本书的一个问题是“所有权问题”“所有者”指的是负责为已经动态创建(使用new
)的对象调用delete
的人或事。使用容器的问题是它们需要足够灵活来容纳不同类型的对象。为此,容器保存了void
指针,所以它们不知道自己保存的对象的类型。删除一个void
指针不会调用析构函数,所以容器不能负责清理它的对象。
在示例C14:InheritStack.cpp
( 清单 14-9 )中给出了一个解决方案,其中Stack
被继承到一个新类中,该类只接受和产生string
指针。因为它知道它只能保存指向string
对象的指针,所以它可以正确地删除它们。这是一个很好的解决方案,但是它要求您为您想要保存在容器中的每个类型继承一个新的容器类。
注意虽然现在这看起来很乏味,但实际上在第十六章中,当引入模板时,它会工作得很好。
问题是你想让容器保存不止一种类型,但是你不想使用void
指针。另一种解决方案是通过强制容器中的所有对象从同一个基类继承来使用多态。也就是说,容器保存基类的对象,然后你可以调用虚函数——特别是,你可以调用虚析构函数来解决所有权问题。
这个解决方案使用所谓的单根层次或基于对象的层次(因为层次的根类通常被命名为“object”)。事实证明,使用单根层次结构还有许多其他好处;事实上,除了 C++ 之外,其他所有面向对象语言都强制使用这种层次结构。当你创建一个类时,你自动地直接或间接地从一个公共基类继承,这个基类是由语言的创建者建立的。在 C++ 中,人们认为强制使用这个公共基类会导致太多的开销,所以它被忽略了。但是,您可以选择在自己的项目中使用公共基类。
为了解决所有权问题,您可以为基类创建一个极其简单的Object
,它只包含一个虚拟析构函数。然后,Stack
可以保存从Object
继承的类。参见清单 15-16 。
清单 15-16 。说明了单根层次结构(也称为基于对象的层次结构)
//: C15:OStack.h
// Using a singly-rooted hierarchy
#ifndef OSTACK_H
#define OSTACK_H
class Object {
public:
virtual ∼Object() = 0;
};
// Required definition:
inline Object::∼Object() {}
class Stack {
struct Link {
Object* data;
Link* next;
Link(Object* dat, Link* nxt) :
data(dat), next(nxt) {}
}* head;
public:
Stack() : head(0) {}
∼Stack(){
while(head)
delete pop();
}
void push(Object* dat) {
head = new Link(dat, head);
}
Object* peek() const {
return head ? head->data : 0;
}
Object* pop() {
if(head == 0) return 0;
Object* result = head->data;
Link* oldHead = head;
head = head->next;
delete oldHead;
return result;
}
};
#endif // OSTACK_H ///:∼
为了通过将所有内容保存在头文件中来简化事情,纯虚拟析构函数的(必需)定义被内联到头文件中,并且pop( )
(可能被认为对于内联来说太大了)也被内联。
Link
对象现在持有指向Object
的指针,而不是void
指针,并且Stack
将只接受和返回Object
指针。现在Stack
更加灵活,因为它可以容纳许多不同类型的物品,但也会破坏掉留在Stack
上的任何物品。新的限制(当模板被应用于第十六章中的问题时将最终被移除)是放置在Stack
上的任何东西必须从Object
继承。如果您是从零开始创建您的类,这很好,但是如果您已经有了一个像string
这样的类,并且希望能够放到Stack
中,那该怎么办呢?在这种情况下,新类必须同时是一个string
和一个Object
,这意味着它必须从两个类中继承。这被称为多重继承,这是本书后面整整一章的主题。在第二十一章中,你会看到多重继承充满了复杂性,这是一个你应该少用的特性。然而,在清单 15-17 中,一切都很简单,我们不会遇到任何多重继承的陷阱。
清单 15-17 。测试清单 15-16 中的 OStack
//: C15:OStackTest.cpp
//{T} OStackTest.cpp
#include "OStack.h" // To be INCLUDED from above
#include "../require.h" // To be INCLUDED from *Chapter 9*
#include <fstream>
#include <iostream>
#include <string>
using namespace std;
// Use multiple inheritance. We want
// both a string and an Object:
class MyString: public string, public Object {
public:
∼MyString() {
cout << "deleting string: " << *this << endl;
}
MyString(string s) : string(s) {}
};
int main(int argc, char* argv[]) {
requireArgs(argc, 1); // File name is argument
ifstream in(argv[1]);
assure(in, argv[1]);
Stack textlines;
string line;
// Read file and store lines in the stack:
while(getline(in, line))
textlines.push(new MyString(line));
// Pop some lines from the stack:
MyString* s;
for(int i = 0; i < 10; i++) {
if((s=(MyString*)textlines.pop())==0) break;
cout << *s << endl;
delete s;
}
cout << "Letting the destructor do the rest:" << endl;
} ///:∼
虽然这与先前版本的Stack
测试程序相似,但是您会注意到只有 10 个元素从堆栈中弹出,这意味着可能还有一些对象。因为Stack
知道它持有Object
,析构函数可以适当地清理东西,你会在程序的输出中看到这一点,因为MyString
对象在被销毁时打印消息。
创建容纳Object
s 的容器并不是一种不合理的方法——如果你有一个单根层次结构(由语言或每个类从Object
继承的要求强制执行)。在这种情况下,所有东西都保证是一个Object
,所以使用容器并不复杂。然而,在 C++ 中,你不能期望每个类都这样,所以如果你采用这种方法,你一定会被多重继承绊倒。你会在第十六章中看到,模板以一种更简单、更优雅的方式解决了这个问题。
运算符重载
你可以像其他成员函数一样使用操作符virtual
。然而,实现virtual
操作符经常变得令人困惑,因为您可能在两个对象上操作,这两个对象都具有未知的类型。数学组件通常就是这种情况(为此,您经常重载运算符)。例如,考虑一个处理矩阵、向量和标量值的系统,这三者都是从类Math
派生的,如清单 15-18 所示。
清单 15-18 。用重载运算符 说明多态
//: C15:OperatorPolymorphism.cpp
// Polymorphism with overloaded operators
#include <iostream>
using namespace std;
class Matrix;
class Scalar;
class Vector;
class Math {
public:
virtual Math& operator*(Math& rv) = 0;
virtual Math& multiply(Matrix*) = 0;
virtual Math& multiply(Scalar*) = 0;
virtual Math& multiply(Vector*) = 0;
virtual ∼Math() {}
};
class Matrix : public Math {
public:
Math& operator*(Math& rv) {
return rv.multiply(this); // 2nd dispatch
}
Math& multiply(Matrix*) {
cout << "Matrix * Matrix" << endl;
return *this;
}
Math& multiply(Scalar*) {
cout << "Scalar * Matrix" << endl;
return *this;
}
Math& multiply(Vector*) {
cout << "Vector * Matrix" << endl;
return *this;
}
};
class Scalar : public Math {
public:
Math& operator*(Math& rv) {
return rv.multiply(this); // 2nd dispatch
}
Math& multiply(Matrix*) {
cout << "Matrix * Scalar" << endl;
return *this;
}
Math& multiply(Scalar*) {
cout << "Scalar * Scalar" << endl;
return *this;
}
Math& multiply(Vector*) {
cout << "Vector * Scalar" << endl;
return *this;
}
};
class Vector : public Math {
public:
Math& operator*(Math& rv) {
return rv.multiply(this); // 2nd dispatch
}
Math& multiply(Matrix*) {
cout << "Matrix * Vector" << endl;
return *this;
}
Math& multiply(Scalar*) {
cout << "Scalar * Vector" << endl;
return *this;
}
Math& multiply(Vector*) {
cout << "Vector * Vector" << endl;
return *this;
}
};
int main() {
Matrix m; Vector v; Scalar s;
Math* math[] = { &m, &v, &s };
for(int i = 0; i < 3; i++)
for(int j = 0; j < 3; j++) {
Math& m1 = *math[i];
Math& m2 = *math[j];
m1 * m2;
}
} ///:∼
为了简单起见,只有operator*
被重载。目标是能够将任意两个Math
对象相乘并产生期望的结果——注意,将一个矩阵乘以一个向量与将一个向量乘以一个矩阵是非常不同的操作。
问题是,在main( )
中,表达式m1 * m2
包含两个向上转换的Math
引用,因此包含两个未知类型的对象。虚函数只能进行一次分派,即确定一个未知对象的类型。为了确定这两种类型,在这个例子中使用了一种称为多重分派 的技术,由此看起来是单个虚拟函数调用的结果是第二个虚拟调用。进行第二次调用时,您已经确定了这两种类型的对象,并且可以执行适当的活动了。一开始它并不透明,但是如果你盯着这个例子看一会儿,它应该开始变得有意义了。
向下投射
正如您可能猜到的,既然有向上转换(在继承层次中向上移动)这样的事情,那么也应该有向下转换来向下移动层次。但是向上转换很容易,因为当你沿着继承层次向上移动时,这些类总是会收敛到更一般的类。也就是说,当你向上转换时,你总是明确地从一个祖先类中派生出来(通常只有一个,除了多重继承的情况),但是当你向下转换时,通常有几种可能性可以转换。更具体地说,Circle
是Shape
的一种类型(这是向上转换),但是如果你试图向下转换Shape
,它可能是Circle
、Square
、Triangle
等等。所以进退两难的问题是找到一种安全的方法。
注意但是一个更重要的问题是首先问问自己为什么向下转换,而不是仅仅使用多态来自动找出正确的类型。
C++ 提供了一个特殊的显式强制转换(在第三章中介绍)称为dynamic_cast
,这是一个类型安全的向下转换操作。当您使用dynamic_cast
尝试向下强制转换为特定类型时,只有在强制转换正确且成功的情况下,返回值才会是指向所需类型的指针;否则,它将返回零,表明这不是正确的类型。清单 15-19 包含了一个最小的例子。
清单 15-19 。举例说明一个动态 _ 强制转换
//: C15:DynamicCast.cpp
#include <iostream>
using namespace std;
class Pet { public: virtual ∼Pet(){}};
class Dog : public Pet {};
class Cat : public Pet {};
int main() {
Pet* b = new Cat; // Upcast
// Try to cast it to Dog*:
Dog* d1 = dynamic_cast<Dog*>(b);
// Try to cast it to Cat*:
Cat* d2 = dynamic_cast<Cat*>(b);
cout << "d1 = " << (long)d1 << endl;
cout << "d2 = " << (long)d2 << endl;
} ///:∼
当您使用dynamic_cast
时,您必须使用真正的多态层次结构(具有虚函数的层次结构),因为dynamic_cast
使用存储在 VTABLE 中的信息来确定实际类型。这里,基类包含一个虚析构函数,这就足够了。在main( )
中,将Cat
指针向上转换为Pet
,然后尝试向下转换为Dog
指针和Cat
指针。两个指针都被打印出来,当你运行程序时,你会看到不正确的向下转换产生了一个零结果。当然,每当你向下转换时,你要负责检查以确保转换的结果是非零的。此外,你不应该假设指针会完全相同,因为有时指针调整会在向上转换和向下转换时发生(特别是在多重继承的情况下)。
运行一个dynamic_cast
需要一点额外的开销;不多,但是如果你做了大量的dynamic_cast
ing(在这种情况下,你应该认真地质疑你的程序设计)这可能会成为一个性能问题。在某些情况下,你可能在向下转换过程中知道一些特殊的东西,允许你确定你正在处理什么类型,在这种情况下,dynamic_cast
的额外开销变得不必要,你可以使用static_cast
来代替。清单 15-20 显示了它是如何工作的。
清单 15-20 。用 static_cast 演示类层次结构的导航
//: C15:StaticHierarchyNavigation.cpp
// Navigating class hierarchies with static_cast
#include <iostream>
#include <typeinfo>
using namespace std;
class Shape { public: virtual ∼Shape() {}; };
class Circle : public Shape {};
class Square : public Shape {};
class Other {};
int main() {
Circle c;
Shape* s = &c; // Upcast: normal and OK
// More explicit but unnecessary:
s = static_cast<Shape*>(&c);
// (Since upcasting is such a safe and common
// operation, the cast becomes cluttering)
Circle* cp = 0;
Square* sp = 0;
// Static Navigation of class hierarchies
// requires extra type information:
if(typeid(s) == typeid(cp)) // C++ RTTI
cp = static_cast<Circle*>(s);
if(typeid(s) == typeid(sp))
sp = static_cast<Square*>(s);
if(cp != 0)
cout << "It's a circle!" << endl;
if(sp != 0)
cout << "It's a square!" << endl;
// Static navigation is ONLY an efficiency hack;
// dynamic_cast is always safer. However:
// Other* op = static_cast<Other*>(s);
// Conveniently gives an error message, while
Other* op2 = (Other*)s;
// does not
} ///:∼
在这个程序中,使用了 C++ 的运行时类型信息(RTTI)机制(一个在第二十章中详细描述的新特性)。RTTI 允许您发现向上转换时丢失的类型信息。dynamic_cast
实际上是 RTTI 的一种形式。这里,typeid
关键字(在头文件<typeinfo>
中声明)用于检测指针的类型。您可以看到,向上转换的Shape
指针的类型被依次与Circle
指针和Square
指针进行比较,以查看是否匹配。RTTI 不仅仅是typeid
,你也可以想象使用一个虚拟函数来实现你自己的类型信息系统是相当容易的。
创建一个Circle
对象,并将地址向上转换为一个Shape
指针;表达式的第二个版本展示了如何使用static_cast
来更明确地表达向上转换。然而,由于向上强制转换总是安全的,而且这是一件很常见的事情,所以为向上强制转换进行显式强制转换只会造成混乱,而且没有必要。
RTTI 用于确定类型,然后static_cast
用于执行向下转换。但是请注意,在这个设计中,这个过程实际上与使用dynamic_cast
是一样的,客户端程序员必须做一些测试来发现实际上成功的转换。在使用static_cast
而不是dynamic_cast
之前,你通常会想要一个比清单 15-20 中的更确定的情况(同样,在使用dynamic_cast
之前,你要仔细检查你的设计)。
如果一个类层次结构没有virtual
函数(,这是一个有问题的设计)或者如果你有其他允许你安全向下转换的信息,静态向下转换比使用dynamic_cast
稍微快一点。另外,static_cast
不会像传统的施法者那样让你脱离等级,所以更安全。然而,静态导航类层次总是有风险的,除非有特殊情况,否则应该使用dynamic_cast
。
审查会议
- 多态—用 C++ 实现,带有虚函数—意为不同形式在面向对象编程中,你有相同的界面(基类 s 中的公共接口)和使用该界面的不同形式:虚函数的不同版本。
- 在这一章中你已经看到,如果不使用数据抽象和继承,就不可能理解,甚至不可能创建一个多态的例子。多态是一个不能被孤立看待的特性(例如,像 const 或 switch 语句),而是作为类关系的“大图的一部分,只能协同工作。
- 人们经常被 C++ 的其他非面向对象的特性所迷惑,比如重载和默认参数,它们有时被描述为面向对象。不要上当;如果不是后期绑定,就不是多态。
- 为了在你的程序中有效地使用多态——以及面向对象技术——你必须扩展你的编程视角,不仅包括单个类的成员和消息,还包括类之间的共性以及它们彼此之间的关系。
- 尽管这需要巨大的努力,但这是一场值得努力的斗争,因为结果是更快的程序开发、更好的代码组织、可扩展的程序和更容易的代码维护。
- 多态完善了语言的面向对象特性,但是 C++ 还有两个主要特性:模板(在第十六章第一节中介绍)和异常处理(在第十七章第三节中介绍)。这两个特性为您提供了与每个面向对象特性一样多的编程能力:抽象数据类型、继承和多态。*`
十六、模板介绍
继承和组合提供了一种重用目标代码的方法。C++ 中的模板特性提供了一种重用源代码的方法。
尽管 C++ 模板是一种通用的编程工具,但当它们被引入语言时,它们似乎不鼓励使用基于对象的容器类层次结构(在第十五章末尾演示)。
这一章不仅演示了模板的基础知识,也是对容器的介绍,容器是面向对象编程的基本组件,几乎完全是通过标准 C++ 库中的容器实现的。你会发现这本书通篇都在使用容器的例子——Stash
和Stack
——正是为了让你对容器感到舒服;在这一章中,还将添加迭代器的概念。虽然容器是使用模板的理想例子,但是模板还有许多其他的用途。
容器
假设你想创建一个栈,就像我们在整本书中所做的那样。清单 16-1 中的堆栈类将保存int
s,以保持简单。
清单 16-1 。说明了一个简单的整数堆栈
//: C16:IntStack.cpp
// Simple integer stack
//{L} fibonacci
#include "fibonacci.h" // SEE ahead in this Section
#include "../require.h" // To be INCLUDED from *Chapter 9*
#include <iostream>
using namespace std;
class IntStack {
enum { ssize = 100 };
int stack[ssize];
int top;
public:
IntStack() : top(0) {}
void push(int i) {
require(top < ssize, "Too many push()es");
stack[top++] = i;
}
int pop() {
require(top > 0, "Too many pop()s");
return stack[--top];
}
};
int main() {
IntStack is;
// Add some Fibonacci numbers, for interest:
for(int i = 0; i < 20; i++)
is.push(fibonacci(i));
// Pop & print them:
for(int k = 0; k < 20; k++)
cout << is.pop() << endl;
} ///:∼
类IntStack
是下推堆栈的一个简单例子。为了简单起见,这里创建了一个固定大小的类,但是您也可以修改它,通过从堆中分配内存来自动扩展,就像在本书中讨论的Stack
类一样。
向堆栈中添加一些整数,并再次弹出它们。为了让这个例子更有趣,整数是用fibonacci()
函数创建的,它生成传统的兔子繁殖数。清单 16-2 是声明该函数的头文件。
清单 16-2 。斐波那契数列生成器的头文件
//: C16:fibonacci.h
// Fibonacci number generator
int fibonacci(int n); ///:∼
清单 16-3 是实现。
清单 16-3 。斐波那契数生成器的实现
//: C16:fibonacci.cpp {O}
#include "../require.h"
int fibonacci(int n) {
const int sz = 100;
require(n < sz);
static int f[sz]; // Initialized to zero
f[0] = f[1] = 1;
// Scan for unfilled array elements:
int i;
for(i = 0; i < sz; i++)
if(f[i] == 0) break;
while(i <= n) {
f[i] = f[i-1] + f[i-2];
i++;
}
return f[n];
} ///:∼
这是一个相当有效的实现,因为它只生成一次数字。它使用了一个int
的static
数组,并且依赖于编译器将一个static
数组初始化为零的事实。第一个for
循环将索引i
移动到第一个数组元素为零的位置,然后while
循环将斐波那契数添加到数组中,直到到达所需的元素。但是请注意,如果通过元素n
的斐波那契数已经初始化,它将完全跳过while
循环。
对容器的需求
显然,整数栈不是一个重要的工具。当您开始使用new
在堆上创建对象并用delete
销毁它们时,就真正需要容器了。在一般的编程问题中,当你写程序时,你不知道你需要多少对象。例如,在一个空中交通管制系统中,你不希望限制系统可以处理的飞机数量。你不希望程序仅仅因为超出了某个数字而中止。在计算机辅助设计系统中,你要处理许多形状,但是只有用户决定(在运行时)你到底需要多少形状。一旦你注意到这种趋势,你会在你自己的编程环境中发现很多例子。
C 依靠虚拟内存来处理他们的“内存管理”的程序员经常发现new,
delete
的想法,以及容器类令人不安。显然,C 中的一种做法是创建一个巨大的全局数组,比程序需要的任何东西都要大。这可能不需要太多的思考(或者对malloc()
和free()
的了解),但是它确实产生了移植性不好并且隐藏了微妙错误的程序。
此外,如果在 C++ 中创建一个巨大的全局对象数组,构造器和析构函数的开销会大大降低速度。C++ 方法的工作要好得多:当你需要一个对象时,用new
创建它,并把它的指针放在一个容器中。稍后,把它捞出来,做点什么。这样,您只创建绝对需要的对象。通常在程序启动时,你并不具备所有的初始化条件。new
允许您等到环境中发生某些事情,然后才能真正创建对象。
因此,在最常见的情况下,您将创建一个容器来保存指向一些感兴趣的对象的指针。您将使用new
创建这些对象,并将结果指针放入容器中(潜在地在这个过程中向上抛掷它),稍后当您想要对该对象做一些事情时将它取出。这种技术产生了最灵活、最通用的程序。
模板概述
现在出现了一个问题。你有一个保存整数的IntStack
。但是你想要一堆形状、飞机、植物或其他东西。对于一种鼓吹可重用性的语言来说,每次都重新编写源代码似乎不是一个非常明智的方法。一定有更好的办法。
在这种情况下,有三种重用源代码的技术:C 方式,这里为了对比而介绍;Smalltalk 方法,它极大地影响了 c++;和模板的 C++ 方法。
C 解决方案
当然,你正试图摆脱 C 语言的方法,因为它混乱不堪,容易出错,而且完全不优雅。在这种方法中,您复制了一个Stack
的源代码,并手工进行了修改,在这个过程中引入了新的错误。这当然不是一个非常有效的技术。
闲聊解决方案
Smalltalk(以及 Java,以其为例)采用了一种简单明了的方法:您想要重用代码,那么就使用继承。为了实现这一点,每个容器类保存通用基类Object
的项目(类似于第十五章结尾的例子)。但是因为 Smalltalk 中的库是如此的重要,你不能从头开始创建一个类。相反,您必须始终从现有的类中继承它。你找到一个尽可能接近你想要的类,从它继承,并做一些改变。显然,这是一个好处,因为它最小化了您的工作(并且解释了为什么您在成为一个有效的 Smalltalk 程序员之前花费大量时间学习类库)。
但这也意味着 Smalltalk 中的所有类最终都是单个继承树的一部分。创建新类时,必须从该树的一个分支继承。树的大部分已经在那里了(它是 Smalltalk 类库),在树的根部是一个名为Object
的类——每个 Smalltalk 容器持有的同一个类。
这是一个巧妙的技巧,因为这意味着 Smalltalk(和 Java)类层次结构中的每个类都是从Object
派生的,所以每个类都可以保存在每个容器中(包括容器本身)。这种基于基本泛型类型(通常被命名为Object
,在 Java 中也是如此)的单树层次被称为基于对象的层次。你可能听说过这个术语,并认为它是 OOP 中的一些新的基本概念,比如多态。它只是指一个以Object
(或者类似的名字)为根的类层次结构和包含Object
的容器类。
因为 Smalltalk 类库比 C++ 有更长的历史和经验,并且因为最初的 C++ 编译器没有容器类库,所以在 C++ 中复制 Smalltalk 类库似乎是个好主意。这是作为早期 C++ 实现的一个实验来完成的,因为它代表了大量的代码,所以许多人开始使用它。在尝试使用容器类的过程中,他们发现了一个问题。
问题是在 Smalltalk(和大多数其他 OOP 语言)中,所有的类都是自动从一个层次结构中派生出来的,但是在 C++ 中却不是这样。您可能有一个不错的基于对象的层次结构及其容器类,但是您可能会从另一个没有使用该层次结构的供应商那里购买一组形状类或飞机类。(首先,使用这种层次结构会增加开销,这是 C 程序员避免的。)在基于对象的层次结构中,如何将单独的类树插入到容器类中?图 16-1 显示了问题的样子。
图 16-1 。如何在基于对象的层次结构中将单独的类树(形状)插入到容器类中?
因为 C++ 支持多个独立的层次结构,所以 Smalltalk 的基于对象的层次结构不太好用。解决方案似乎显而易见。如果您可以有许多继承层次,那么您应该能够从多个类继承。多重继承会解决问题。解决方法见图 16-2 (类似的例子在第十五章末尾给出)。
图 16-2 。通过多重继承解决问题
现在OShape
有了Shape
的特征和行为,但是因为它也是从Object
派生出来的,所以可以放在Container
里。额外继承成OCircle,
OSquare
等。是必要的,这样那些类就可以向上转换到OShape
中,从而保持正确的行为。你可以看到事情正在迅速变得混乱。
编译器供应商发明并包含了他们自己的基于对象的容器类层次结构,其中大部分已经被模板版本所取代。你可以争论多重继承对于解决一般的编程问题是必要的,但是你会在第二十一章中看到,除非在特殊情况下,否则最好避免它的复杂性。
模板解决方案
尽管具有多重继承的基于对象的层次结构在概念上很简单,但使用起来却很痛苦。在他的原著中,Stroustrup 展示了他认为的基于对象的层次结构的更好的替代方案。容器类是作为大型预处理器宏创建的,其参数可以替换为您想要的类型。当您想要创建一个容器来保存一个特定的类型时,您需要进行几次宏调用。
不幸的是,这种方法被所有现有的 Smalltalk 文献和编程经验所混淆,而且有点笨拙。基本上没人懂。
与此同时,Stroustrup 和贝尔实验室的 C++ 团队修改了他最初的宏方法,将其简化并从预处理器领域转移到编译器领域。这种新的代码替换设备被称为template
,它代表了一种完全不同的重用代码的方式。模板重用源代码,而不是像继承和组合那样重用目标代码。容器不再保存一个名为Object
的通用基类,而是保存一个未指定的参数。当你使用一个模板时,参数被编译器替换,很像旧的宏方法,但是更干净和更容易使用。
现在,当你想使用一个容器类时,不用担心继承或组合,你可以使用容器的模板版本,并为你的特定问题去掉一个特定的版本,如图 16-3 所示。
图 16-3 。通过源模板重用代码
编译器会为您完成这项工作,您最终会得到完成工作所需的容器,而不是笨拙的继承层次结构。在 C++ 中,模板实现了参数化类型的概念。模板方法的另一个好处是,可能不熟悉或不习惯继承的程序员新手仍然可以马上使用固定的容器类(就像我们在整本书中对vector
所做的那样)。
模板语法
template
关键字告诉编译器,后面的类定义将操作一个或多个未指定的类型。从模板生成实际的类代码时,必须指定这些类型,以便编译器可以替换它们。为了演示语法,请看清单 16-4 中一个产生边界检查数组的小例子。
清单 16-4 。说明模板语法
//: C16:Array.cpp
#include "../require.h"
#include <iostream>
using namespace std;
template<class T>
class Array {
enum { size = 100 };
T A[size];
public:
T& operator[](int index) {
require(index >= 0 && index < size,
"Index out of range");
return A[index];
}
};
int main() {
Array<int> ia;
Array<float> fa;
for(int i = 0; i < 20; i++) {
ia[i] = i * i;
fa[i] = float(i) * 1.414;
}
for(int j = 0; j < 20; j++)
cout << j << ": " << ia[j]
<< ", " << fa[j] << endl;
} ///:∼
您可以看到,除了行之外,它看起来像一个普通的类
template<class T>
它说T
是替换参数,它代表一个类型名。此外,你会看到T
在类中的任何地方都被使用,在那里你通常会看到容器持有的特定类型。
在Array
中,用相同的函数插入和提取元素:重载的operator []
。它返回一个引用,所以它可以用在等号的两边(即,既作为左值又作为右值)。请注意,如果索引超出界限,则使用require()
函数打印一条消息。由于operator[]
是一个inline
,您可以使用这种方法来保证不发生数组边界冲突,然后删除发货代码的require()
。
在main()
中,您可以看到创建保存不同类型对象的Array
是多么容易。当你说
Array<int> ia;
Array<float> fa;
编译器两次扩展Array
模板(这被称为实例化,以创建两个新生成的类,你可以把它们想象成Array_int
和Array_float
。
注不同的编译器可能会以不同的方式修饰名字。
这些类就像您手工执行替换时生成的类一样,只是编译器会在您定义对象ia
和fa
时为您创建它们。还要注意,重复的类定义要么被编译器避免,要么被链接器合并。
非内联函数定义
当然,有时候你会想要非内联成员函数定义。在这种情况下,编译器需要在成员函数定义之前看到template
声明。清单 16-5 显示了修改后的代码形式清单 16-4 显示了非内联成员定义。
清单 16-5 。说明非内联模板/函数定义
//: C16:Array2.cpp
// Non-inline template definition
#include "../require.h"
template<class T>
class Array {
enum { size = 100 };
T A[size];
public:
T& operator[](int index);
};
template<class T>
T& Array<T>::operator[](int index) {
require(index >= 0 && index < size,
"Index out of range");
return A[index];
}
int main() {
Array<float> fa;
fa[0] = 1.414;
} ///:∼
任何对模板类名的引用都必须附有模板参数列表,如Array<T>::operator[]
所示。您可以想象,在内部,类名是用模板参数列表中的参数修饰的,以便为每个模板实例化产生一个惟一的类名标识符。
头文件
即使你创建了非内联函数定义,你通常也会想把一个模板的所有声明和定义放到一个头文件中。这似乎违反了正常的头文件规则“不要放入任何分配存储空间的东西”(防止链接时出现多个定义错误),但是模板定义是特殊的。任何以template<...>
开头的东西都意味着编译器不会在那时为它分配存储,而是等待直到它被告知(通过模板实例化),并且在编译器和链接器中的某个地方有一个机制来删除同一个模板的多个定义。因此,为了方便使用,您几乎总是将整个模板声明和定义放在头文件中。
有时,您可能需要将模板定义放在单独的cpp
文件中,以满足特殊需求(例如,强制模板实例化只存在于单个 Windows dll
文件中)。大多数编译器都有某种机制允许这样做;要使用它,您必须研究特定编译器的文档。
有些人认为将实现的所有源代码放在一个头文件中会使人们有可能窃取和修改您的代码,如果他们从您这里购买一个库的话。这可能是一个问题,但这可能取决于你如何看待这个问题。他们购买的是产品还是服务?如果它是一个产品,那么你就要尽你所能去保护它,而且很可能你不想给源代码,只给编译好的代码。但是许多人将软件视为一种服务,甚至更多的是一种订阅服务。客户需要你的专业知识;他们希望你继续维护这段可重用的代码,这样他们就不必这么做了,这样他们就可以专注于完成他们的工作。我个人认为,大多数客户会把你视为一种有价值的资源,不希望危及他们与你的关系。至于那几个想偷而不是买或者做原创的,他们大概无论如何都跟不上你。
IntStack 作为模板
清单 16-6 显示了来自IntStack.cpp
的容器和迭代器,使用模板实现为通用容器类。
清单 16-6 。说明了一个简单的整数堆栈模板
//: C16:StackTemplate.h
// Simple stack template
#ifndef STACKTEMPLATE_H
#define STACKTEMPLATE_H
#include "../require.h"
template<class T>
class StackTemplate {
enum { ssize = 100 };
T stack[ssize];
int top;
public:
StackTemplate() : top(0) {}
void push(const T& i) {
require(top < ssize, "Too many push()es");
stack[top++] = i;
}
T pop() {
require(top > 0, "Too many pop()s");
return stack[--top];
}
int size() { return top; }
};
#endif // STACKTEMPLATE_H ///:∼
请注意,模板对它所保存的对象做了某些假设。例如,StackTemplate
假设在push()
函数中有某种针对T
的赋值操作。你可以说一个模板对于它能够容纳的类型“暗示了一个接口”。
换句话说,模板为 C++ 提供了一种弱类型机制,c++ 通常是一种强类型语言。弱类型并不要求一个对象必须是某种精确的类型才能被接受,它只要求它要调用的成员函数对于一个特定的对象来说是可用的。因此,弱类型代码可以应用于任何可以接受这些成员函数调用的对象,因此更加灵活。
清单 16-7 包含了测试模板的修改后的例子。
清单 16-7 。测试清单 16-6 中的整数堆栈模板
//: C16:StackTemplateTest.cpp
// Test simple stack template
//{L} fibonacci
#include "fibonacci.h"
#include "StackTemplate.h" // To be INCLUDED from above
#include <iostream>
#include <fstream>
#include <string>
using namespace std;
int main() {
StackTemplate<int> is;
for(int i = 0; i < 20; i++)
is.push(fibonacci(i));
for(int k = 0; k < 20; k++)
cout << is.pop() << endl;
ifstream in("StackTemplateTest.cpp");
assure(in, "StackTemplateTest.cpp");
string line;
StackTemplate<string> strings;
while(getline(in, line))
strings.push(line);
while(strings.size() > 0)
cout << strings.pop() << endl;
} ///:∼
唯一不同的是is
的打造。在模板参数列表中,你可以指定栈和迭代器应该持有的对象类型。为了演示模板的通用性,还创建了一个StackTemplate
来保存string
。这是通过从源代码文件中读入行来测试的。
模板中的常量
模板参数不限于类类型;也可以使用内置类型。这些参数的值随后成为模板特定实例化的编译时常量。您甚至可以为这些参数使用默认值。清单 16-8 允许你在实例化过程中设置Array
类的大小,但也提供了一个默认值。
清单 16-8 。说明如何使用内置类型作为模板参数
//: C16:Array3.cpp
// Built-in types as template arguments
#include "../require.h"
#include <iostream>
using namespace std;
template<class T, int size = 100>
class Array {
T array[size];
public:
T& operator[](int index) {
require(index >= 0 && index < size,
"Index out of range");
return array[index];
}
int length() const { return size; }
};
class Number {
float f;
public:
Number(float ff = 0.0f) : f(ff) {}
Number& operator=(const Number& n) {
f = n.f;
return *this;
}
operator float() const { return f; }
friend ostream&
operator<<(ostream& os, const Number& x) {
return os << x.f;
}
};
template<class T, int size = 20>
class Holder {
Array<T, size>* np;
public:
Holder() : np(0) {}
T& operator[](int i) {
require(0 <= i && i < size);
if(!np) np = new Array<T, size>;
return np->operator[](i);
}
int length() const { return size; }
∼Holder() { delete np; }
};
int main() {
Holder<Number> h;
for(int i = 0; i < 20; i++)
h[i] = i;
for(int j = 0; j < 20; j++)
cout << h[j] << endl;
} ///:∼
和以前一样,Array
是一个检查过的对象数组,它可以防止索引越界。类Holder
很像Array
,除了它有一个指向Array
的指针,而不是一个Array
类型的嵌入对象。此指针未在构造器中初始化;初始化被延迟到第一次访问。这叫做惰性初始化;如果您正在创建许多对象,但不能访问所有对象,并且希望节省存储空间,那么您可能会使用这样的技术。
您会注意到,两个模板中的size
值从来没有存储在类内部,但是它被用作成员函数中的数据成员。
作为模板堆叠和隐藏
在本书中反复出现的Stash
和Stack
容器类的“所有权”问题来自于这样一个事实,即这些容器不能确切地知道它们持有什么类型。离他们最近的是在OStackTest.cpp
( 清单 15-17 )的第十五章结尾看到的Stack``Object
集装箱。
如果客户端程序员没有显式删除容器中保存的所有对象指针,那么容器应该能够正确删除这些指针。也就是说,容器"拥有任何没有被移除的对象,因此负责清理它们。问题是清理需要知道对象的类型,而创建一个通用容器类需要而不是知道对象的类型。然而,使用模板,您可以编写不知道对象类型的代码,并轻松地为您想要包含的每种类型实例化该容器的新版本。单个实例化的容器确实知道它们持有的对象的类型,因此可以调用正确的析构函数(假设,在涉及多态的典型情况下,已经提供了一个虚拟析构函数)。
对于Stack
,这变得非常简单,因为所有的成员函数都可以合理地内联;见清单 16-9 。
清单 16-9 。示出了作为模板的堆栈的创建
//: C16:TStack.h
// The Stack as a template
#ifndef TSTACK_H
#define TSTACK_H
template<class T>
class Stack {
struct Link {
T* data;
Link* next;
Link(T* dat, Link* nxt):
data(dat), next(nxt) {}
}* head;
public:
Stack() : head(0) {}
∼Stack(){
while(head)
delete pop();
}
void push(T* dat) {
head = new Link(dat, head);
}
T* peek() const {
return head ? head->data : 0;
}
T* pop(){
if(head == 0) return 0;
T* result = head->data;
Link* oldHead = head;
head = head->next;
delete oldHead;
return result;
}
};
#endif // TSTACK_H ///:∼
如果你将此与第十五章的结尾的OStack.h
例子进行比较,你会发现Stack
实际上是相同的,除了Object
被替换为T
。测试程序也几乎相同,除了消除了从string
和Object
多重继承的必要性(甚至消除了对Object
本身的需求)。现在没有MyString
类来宣布它的销毁,所以在清单 16-10 中添加了一个小的新类来显示一个Stack
容器清理它的对象。
清单 16-10 。测试清单 16-9 中的模板堆栈
//: C16:TStackTest.cpp
//{T} TStackTest.cpp
#include "TStack.h" // To be INCLUDED from above
#include "../require.h"
#include <fstream>
#include <iostream>
#include <string>
using namespace std;
class X {
public:
virtual ∼X() { cout << "∼X " << endl; }
};
int main(int argc, char* argv[]) {
requireArgs(argc, 1); // File name is argument
ifstream in(argv[1]);
assure(in, argv[1]);
Stack<string> textlines;
string line;
// Read file and store lines in the Stack:
while(getline(in, line))
textlines.push(new string(line));
// Pop some lines from the stack:
string* s;
for(int i = 0; i < 10; i++) {
if((s = (string*)textlines.pop())==0) break;
cout << *s << endl;
delete s;
} // The destructor deletes the other strings.
// Show that correct destruction happens:
Stack<X> xx;
for(int j = 0; j < 10; j++)
xx.push(new X);
} ///:∼
X
的析构函数是虚拟的,不是因为它在这里是必需的,而是因为xx
以后可以用来保存从X
派生的对象。
注意为string
和X
创建不同种类的Stack
是多么容易。因为有了模板,您可以两全其美:Stack
类的易用性和适当的清理。
模板化指针存储
将PStash
代码重新组织成一个模板并不那么简单,因为有许多成员函数不应该被内联。然而,作为一个模板,那些函数定义仍然属于头文件(编译器和链接器处理任何多重定义问题)。清单 16-11 中的代码看起来与普通的PStash
非常相似,除了你会注意到增量的大小(由inflate()
使用)已经被模板化为一个带有默认值的非类参数,因此增量大小可以在实例化时修改(注意这意味着增量大小是固定的;您可能还会认为增量大小应该在对象的整个生命周期中是可变的)。
清单 16-11 。说明了模板化的指针存储
//: C16:TPStash.h
#ifndef TPSTASH_H
#define TPSTASH_H
template<class T, int incr = 10>
class PStash {
int quantity; // Number of storage spaces
int next; // Next empty space
T** storage;
void inflate(int increase = incr);
public:
PStash() : quantity(0), next(0), storage(0) {}
∼PStash();
int add(T* element);
T* operator[](int index) const; // Fetch
// Remove the reference from this PStash:
T* remove(int index);
// Number of elements in Stash:
int count() const { return next; }
};
template<class T, int incr>
int PStash<T, incr>::add(T* element) {
if(next >= quantity)
inflate(incr);
storage[next++] = element;
return(next - 1); // Index number
}
// Ownership of remaining pointers:
template<class T, int incr>
PStash<T, incr>::∼PStash() {
for(int i = 0; i < next; i++) {
delete storage[i]; // Null pointers OK
storage[i] = 0; // Just to be safe
}
delete []storage;
}
template<class T, int incr>
T* PStash<T, incr>::operator[](int index) const {
require(index >= 0,
"PStash::operator[] index negative");
if(index >= next)
return 0; // To indicate the end
require(storage[index] != 0,
"PStash::operator[] returned null pointer");
// Produce pointer to desired element:
return storage[index];
}
template<class T, int incr>
T* PStash<T, incr>::remove(int index) {
// operator[] performs validity checks:
T* v = operator[](index);
// "Remove" the pointer:
if(v != 0) storage[index] = 0;
return v;
}
template<class T, int incr>
void PStash<T, incr>::inflate(int increase) {
const int psz = sizeof(T*);
T** st = new T*[quantity + increase];
memset(st, 0, (quantity + increase) * psz);
memcpy(st, storage, quantity * psz);
quantity += increase;
delete []storage; // Old storage
storage = st; // Point to new memory
}
#endif // TPSTASH_H ///:∼
这里使用的默认增量很小,以保证调用inflate()
发生。这样你可以确保它正常工作。
为了测试模板化PStash
的所有权控制,清单 16-12 中的类将报告它自己的创建和销毁,并保证所有已创建的对象也被销毁。AutoCounter
将只允许在堆栈上创建其类型的对象。
清单 16-12 。测试(模板化指针存储的)所有权控制
//: C16:AutoCounter.h
#ifndef AUTOCOUNTER_H
#define AUTOCOUNTER_H
#include "../require.h"
#include <iostream>
#include <set> // Standard C++ Library container
#include <string>
class AutoCounter {
static int count;
int id;
class CleanupCheck {
std::set<AutoCounter*> trace;
public:
void add(AutoCounter* ap) {
trace.insert(ap);
}
void remove(AutoCounter* ap) {
require(trace.erase(ap) == 1,
"Attempt to delete AutoCounter twice");
}
∼CleanupCheck() {
std::cout << "∼CleanupCheck()"<< std::endl;
require(trace.size() == 0,
"All AutoCounter objects not cleaned up");
}
};
static CleanupCheck verifier;
AutoCounter() : id(count++) {
verifier.add(this); // Register itself
std::cout << "created[" << id << "]"
<< std::endl;
}
// Prevent assignment and copy-construction:
AutoCounter(const AutoCounter&);
void operator=(const AutoCounter&);
public:
// You can only create objects with this:
static AutoCounter* create() {
return new AutoCounter();
}
∼AutoCounter() {
std::cout << "destroying[" << id
<< "]" << std::endl;
verifier.remove(this);
}
// Print both objects and pointers:
friend std::ostream& operator<<(
std::ostream& os, const AutoCounter& ac){
return os << "AutoCounter " << ac.id;
}
friend std::ostream& operator<<(
std::ostream& os, const AutoCounter* ac){
return os << "AutoCounter " << ac->id;
}
};
#endif // AUTOCOUNTER_H ///:∼
AutoCounter
类做两件事。首先,它对AutoCounter
的每个实例进行顺序编号:该编号的值保存在id
中,该编号是使用static
数据成员count
生成的。
其次,也是更复杂的,嵌套类CleanupCheck
的一个static
实例(称为verifier
)跟踪所有被创建和销毁的AutoCounter
对象,并在您没有清理它们时向您报告(例如,是否有内存泄漏)。这个行为是使用标准 C++ 库中的set
类完成的,这是一个很好的例子,说明了设计良好的模板如何使生活变得简单。
set
类在它持有的类型上被模板化;在这里,它被实例化以保存AutoCounter
指针。一个set
将只允许添加每个不同对象的一个实例;在add()
中,你可以看到这是通过set::insert()
函数实现的。如果你试图添加已经添加的东西,实际上会通知你它的返回值;然而,由于添加了对象地址,你可以依靠 C++ 保证所有对象都有唯一的地址。
在remove()
中,set::erase()
用于从set
中移除一个AutoCounter
指针。返回值告诉您移除了元素的多少个实例;在我们的例子中,我们只期望零或一。然而,如果该值为零,则意味着该对象已经从set
中删除,并且您正试图第二次删除它,这是一个编程错误,将通过require()
报告。
CleanupCheck
的析构函数通过确保set
的大小为零来做最后的检查——这意味着所有的对象都被适当地清理了。如果它不为零,那么您有一个内存泄漏,这是通过require()
报告的。
AutoCounter
的构造器和析构函数向verifier
对象注册和取消注册。注意,构造器、复制构造器和赋值操作符都是private
,所以创建对象的唯一方法是使用static create()
成员函数。这是一个简单的工厂的例子,它保证所有的对象都是在堆上创建的,所以verifier
不会对赋值和复制构造感到困惑。
因为所有的成员函数都被内联了,所以实现文件的唯一原因是包含静态数据成员定义;参见清单 16-13 。
清单 16-13 。在清单 16-12 中实现自动计算器
//: C16:AutoCounter.cpp {O}
// Definition of static class members
#include "AutoCounter.h" // To be INCLUDED from above
AutoCounter::CleanupCheck AutoCounter::verifier;
int AutoCounter::count = 0;
///:∼
有了AutoCounter
,你现在可以测试PStash
的设备了。清单 16-14 不仅显示了PStash
析构函数清理了它当前拥有的所有对象,还展示了AutoCounter
类如何检测还没有被清理的对象。
清单 16-14 。使用自动计数器测试模板化的指针存储
//: C16:TPStashTest.cpp
//{L} AutoCounter
#include "AutoCounter.h"
#include "TPStash.h" // To be INCLUDED from above
#include <iostream>
#include <fstream>
using namespace std;
int main() {
PStash<AutoCounter> acStash;
for(int i = 0; i < 10; i++)
acStash.add(AutoCounter::create());
cout << "Removing 5 manually:" << endl;
for(int j = 0; j < 5; j++)
delete acStash.remove(j);
cout << "Remove two without deleting them:"
<< endl;
// ... to generate the cleanup error message.
cout << acStash.remove(5) << endl;
cout << acStash.remove(6) << endl;
cout << "The destructor cleans up the rest:"
<< endl;
// Repeat the test from earlier chapters:
ifstream in("TPStashTest.cpp");
assure(in, "TPStashTest.cpp");
PStash<string> stringStash;
string line;
while(getline(in, line))
stringStash.add(new string(line));
// Print out the strings:
for(int u = 0; stringStash[u]; u++)
cout << "stringStash[" << u << "] = "
<< *stringStash[u] << endl;
} ///:∼
当AutoCounter
元素 5 和 6 从PStash
中移除时,它们变成了调用者的责任,但是由于调用者从不清理它们,它们导致了内存泄漏,然后在运行时被AutoCounter
检测到。
当您运行该程序时,您会看到错误消息并不像预期的那样具体。如果您使用AutoCounter
中介绍的方案来发现您自己系统中的内存泄漏,您可能希望让它打印出关于尚未清理的对象的更详细的信息。有更复杂的方法可以做到这一点,在本书后面你会看到。
打开和关闭所有权
让我们回到所有权问题。通过值保存对象的容器通常不担心所有权,因为它们清楚地拥有它们所包含的对象。但是如果你的容器保存了指针(这在 C++、中更常见,尤其是在多态中),那么这些指针很可能还会在程序中的其他地方使用,你不一定要删除这个对象,因为这样程序中的其他指针就会引用一个被销毁的对象。为了防止这种情况发生,您必须在设计和使用容器时考虑所有权。
很多程序比这个简单很多,不会遇到所有权问题;一个容器保存只由该容器使用的对象的指针。在这种情况下,所有权非常简单:容器拥有它的对象。
处理所有权问题的最好方法是给客户程序员一个选择。这通常是通过构造器参数来实现的,默认为指明所有权(最简单的情况)。此外,可能有、 get”和“set”功能来查看和修改容器的所有权。如果容器具有移除对象的功能,所有权状态通常会影响移除,因此您也可以在移除功能中找到控制销毁的选项。您可以为容器中的每个元素添加所有权数据,这样每个位置都会知道它是否需要销毁;这是引用计数的一种变体,只不过是容器而不是对象知道指向一个对象的引用的数量(见清单 16-15 )。
清单 16-15 。展示具有运行时可控所有权的堆栈
//: C16:OwnerStack.h
// Stack with runtime controllable ownership
#ifndef OWNERSTACK_H
#define OWNERSTACK_H
template<class T> class Stack {
struct Link {
T* data;
Link* next;
Link(T* dat, Link* nxt)
: data(dat), next(nxt) {}
}* head;
bool own;
public:
Stack(bool own = true) : head(0), own(own) {}
∼Stack();
void push(T* dat) {
head = new Link(dat,head);
}
T* peek() const {
return head ? head->data : 0;
}
T* pop();
bool owns() const { return own; }
void owns(bool newownership) {
own = newownership;
}
// Auto-type conversion: true if not empty:
operator bool() const { return head != 0; }
};
template<class T> T* Stack<T>::pop() {
if(head == 0) return 0;
T* result = head->data;
Link* oldHead = head;
head = head->next;
delete oldHead;
return result;
}
template<class T> Stack<T>::∼Stack() {
if(!own) return;
while(head)
delete pop();
}
#endif // OWNERSTACK_H ///:∼
默认行为是容器销毁它的对象,但是你可以通过修改构造器参数或者使用owns()
读/写成员函数来改变这一点。
与您可能看到的大多数模板一样,整个实现包含在头文件中。清单 16-16 是一个练习所有权能力的小测试。
清单 16-16 。在清单 16-15 中测试堆栈的所有权
//: C16:OwnerStackTest.cpp
//{L} AutoCounter
#include "AutoCounter.h"
#include "OwnerStack.h" // To be INCLUDED from above
#include "../require.h"
#include <iostream>
#include <fstream>
#include <string>
using namespace std;
int main() {
Stack<AutoCounter> ac; // Ownership on
Stack<AutoCounter> ac2(false); // Turn it off
AutoCounter* ap;
for(int i = 0; i < 10; i++) {
ap = AutoCounter::create();
ac.push(ap);
if(i % 2 == 0)
ac2.push(ap);
}
while(ac2)
cout << ac2.pop() << endl;
// No destruction necessary since
// ac "owns" all the objects
} ///:∼
ac2
对象不拥有你放入其中的对象,因此ac
是负责所有权的“主”容器。如果在容器生命周期的中途,您想改变容器是否拥有它的对象,您可以使用owns()
来实现。
也有可能改变所有权的粒度,使其基于一个对象接一个对象,但是这可能会使所有权问题的解决方案比问题更复杂。
按值保存对象
实际上,如果没有模板,在通用容器中创建对象的副本是一个复杂的问题。有了模板,事情就相对简单了;你只是说你持有的是对象而不是指针,如清单 16-17 所示。
清单 16-17 。使用模板说明按值保存对象
//: C16:ValueStack.h
// Holding objects by value in a Stack
#ifndef VALUESTACK_H
#define VALUESTACK_H
#include "../require.h"
template<class T, int ssize = 100>
class Stack {
// Default constructor performs object
// initialization for each element in array:
T stack[ssize];
int top;
public:
Stack() : top(0) {}
// Copy-constructor copies object into array:
void push(const T& x) {
require(top < ssize, "Too many push()es");
stack[top++] = x;
}
T peek() const { return stack[top]; }
// Object still exists when you pop it;
// it just isn't available anymore:
T pop() {
require(top > 0, "Too many pop()s");
return stack[--top];
}
};
#endif // VALUESTACK_H ///:∼
所包含对象的复制构造器通过按值传递和返回对象来完成大部分工作。在push()
内部,对象到Stack
数组的存储是通过T::operator=
完成的。为了保证它的工作,一个名为SelfCounter
的类跟踪对象的创建和复制构造;参见清单 16-18 。
清单 16-18 。使用 SelfCounter 使清单 16-17 中的 ValueStack 工作
//: C16:SelfCounter.h
#ifndef SELFCOUNTER_H
#define SELFCOUNTER_H
#include "ValueStack.h" // To be INCLUDED from above
#include <iostream>
class SelfCounter {
static int counter;
int id;
public:
SelfCounter() : id(counter++) {
std::cout << "Created: " << id << std::endl;
}
SelfCounter(const SelfCounter& rv) : id(rv.id){
std::cout << "Copied: " << id << std::endl;
}
SelfCounter operator=(const SelfCounter& rv) {
std::cout << "Assigned " << rv.id << " to "
<< id << std::endl;
return *this;
}
∼SelfCounter() {
std::cout << "Destroyed: "<< id << std::endl;
}
friend std::ostream& operator<<(
std::ostream& os, const SelfCounter& sc){
return os << "SelfCounter: " << sc.id;
}
};
#endif // SELFCOUNTER_H ///:∼
//: C16:SelfCounter.cpp {O}
#include "SelfCounter.h" // To be INCLUDED from above
int SelfCounter::counter = 0; ///:∼
//: C16:ValueStackTest.cpp
//{L} SelfCounter
#include "ValueStack.h"
#include "SelfCounter.h"
#include <iostream>
using namespace std;
int main() {
Stack<SelfCounter> sc;
for(int i = 0; i < 10; i++)
sc.push(SelfCounter());
// OK to peek(), result is a temporary:
cout << sc.peek() << endl;
for(int k = 0; k < 10; k++)
cout << sc.pop() << endl;
} ///:∼
当创建一个Stack
容器时,为数组中的每个对象调用所包含对象的默认构造器。您最初会看到 100 个SelfCounter
对象被莫名其妙地创建,但这只是数组初始化。这可能有点贵,但是在这样一个简单的设计中没有办法解决这个问题。如果您通过允许大小动态增长来使Stack
更通用,则会出现更复杂的情况,因为在清单 16-18 所示的实现中,这将涉及创建一个新的(更大的)数组,将旧数组复制到新数组,并销毁旧数组(事实上,这是标准 C++ 库vector
类所做的)。
迭代器简介
一个迭代器是一个对象,它遍历一个包含其他对象的容器,一次选择一个对象,但不提供对该容器实现的直接访问。迭代器提供了访问元素的标准方式,不管容器是否提供了直接访问元素的方式。您将看到迭代器最常与容器类结合使用;迭代器是标准 C++ 容器设计和使用中的一个基本概念。
在许多方面,迭代器是一个智能指针,事实上你会注意到迭代器通常模仿大多数指针操作。然而,与指针不同,迭代器被设计为安全的,所以你不太可能做等同于离开数组末尾的事情(或者如果你这样做了,你会更容易发现它)。
考虑本章的第一个例子。清单 16-19 增加了一个简单的迭代器。
清单 16-19 。用迭代器演示了一个简单的整数堆栈
//: C16:IterIntStack.cpp
// Simple integer stack with iterators
//{L} fibonacci
#include "fibonacci.h"
#include "../require.h"
#include <iostream>
using namespace std;
class IntStack {
enum { ssize = 100 };
int stack[ssize];
int top;
public:
IntStack() : top(0) {}
void push(int i) {
require(top < ssize, "Too many push()es");
stack[top++] = i;
}
int pop() {
require(top > 0, "Too many pop()s");
return stack[--top];
}
friend class IntStackIter;
};
// An iterator is like a "smart" pointer:
class IntStackIter {
IntStack& s;
int index;
public:
IntStackIter(IntStack& is) : s(is), index(0) {}
int operator++() { // Prefix
require(index < s.top,
"iterator moved out of range");
return s.stack[++index];
}
int operator++(int) { // Postfix
require(index < s.top,
"iterator moved out of range");
return s.stack[index++];
}
};
int main() {
IntStack is;
for(int i = 0; i < 20; i++)
is.push(fibonacci(i));
// Traverse with an iterator:
IntStackIter it(is);
for(int j = 0; j < 20; j++)
cout << it++ << endl;
} ///:∼
IntStackIter
被创建为仅与IntStack
一起工作。注意,IntStackIter
是IntStack
的friend
,这使得它可以访问IntStack
的所有private
元素。
像指针一样,IntStackIter
的工作是遍历IntStack
并检索值。在这个简单的例子中,IntStackIter
只能向前移动(使用operator++
的前缀和后缀形式)。然而,定义迭代器的方式没有边界,除了它所使用的容器的约束。迭代器在关联容器内以任何方式移动并导致所包含的值被修改是完全可以接受的(在底层容器的限制内)。
习惯上,迭代器是用一个构造器创建的,这个构造器将迭代器附加到一个容器对象上,迭代器在它的生命周期中不会附加到不同的容器上。
注意迭代器通常又小又便宜,所以你很容易再做一个。
使用迭代器,您可以遍历堆栈中的元素而不弹出它们,就像指针可以在数组的元素中移动一样。然而,迭代器知道栈的底层结构以及如何遍历元素,所以即使你通过假装“增加一个指针”来遍历它们,底层发生的事情更复杂。这是迭代器的关键:它将从一个容器元素移动到下一个容器元素的复杂过程抽象成看起来像指针的东西。目标是让程序中的每个迭代器都有相同的接口,这样任何使用迭代器的代码都不会关心它指向什么;它只知道可以用同样的方式重新定位所有迭代器,所以迭代器指向的容器并不重要。通过这种方式,您可以编写更通用的代码。标准 C++ 库中的所有容器和算法都是基于迭代器的原理。
为了有助于使事情更加通用,最好能够说“每个容器都有一个名为iterator
的相关类”,但是这通常会导致命名问题。解决方案是向每个容器添加一个嵌套的iterator
类(注意,在本例中,“iterator
”以小写字母开头,以便符合标准 C++ 库的样式)。清单 16-20 显示了带有嵌套iterator
的IterIntStack.cpp
。
清单 16-20 。展示了容器中迭代器的嵌套
//: C16:NestedIterator.cpp
// Nesting an iterator inside the container
//{L} fibonacci
#include "fibonacci.h"
#include "../require.h"
#include <iostream>
#include <string>
using namespace std;
class IntStack {
enum { ssize = 100 };
int stack[ssize];
int top;
public:
IntStack() : top(0) {}
void push(int i) {
require(top < ssize, "Too many push()es");
stack[top++] = i;
}
int pop() {
require(top > 0, "Too many pop()s");
return stack[--top];
}
class iterator;
friend class iterator;
class iterator {
IntStack& s;
int index;
public:
iterator(IntStack& is) : s(is), index(0) {}
// To create the "end sentinel" iterator:
iterator(IntStack& is, bool)
: s(is), index(s.top) {}
int current() const { return s.stack[index]; }
int operator++() { // Prefix
require(index < s.top,
"iterator moved out of range");
return s.stack[++index];
}
int operator++(int) { // Postfix
require(index < s.top,
"iterator moved out of range");
return s.stack[index++];
}
// Jump an iterator forward
iterator& operator+=(int amount) {
require(index + amount < s.top,
"IntStack::iterator::operator+=() "
"tried to move out of bounds");
index += amount;
return *this;
}
// To see if you're at the end:
bool operator==(const iterator& rv) const {
return index == rv.index;
}
bool operator!=(const iterator& rv) const {
return index != rv.index;
}
friend ostream&
operator <<(ostream& os, const iterator& it) {
return os << it.current();
}
};
iterator begin() { return iterator(*this); }
// Create the "end sentinel":
iterator end() { return iterator(*this, true);}
};
int main() {
IntStack is;
for(int i = 0; i < 20; i++)
is.push(fibonacci(i));
cout << "Traverse the whole IntStack\n";
IntStack::iterator it = is.begin();
while(it != is.end())
cout << it++ << endl;
cout << "Traverse a portion of the IntStack\n";
IntStack::iterator
start = is.begin(), end = is.begin();
start += 5, end += 15;
cout << "start = " << start << endl;
cout << "end = " << end << endl;
while(start != end)
cout << start++ << endl;
} ///:∼
在制作嵌套的friend
类时,必须经历先声明类名,再声明为friend
,再定义类的过程。否则,编译器会感到困惑。
迭代器中增加了一些新的变化。成员函数产生迭代器当前选择的容器中的元素。您可以使用operator+=
将迭代器向前“跳转”任意数量的元素。此外,您将看到两个重载操作符,==
和!=
,它们将一个迭代器与另一个迭代器进行比较。这些可以比较任意两个IntStack::iterator
,但是它们主要是用来测试迭代器是否像“真正的”标准 C++ 库迭代器一样位于序列的末尾。这个想法是两个迭代器定义一个范围,包括第一个迭代器指向的第一个元素,直到第二个迭代器指向的最后一个元素。所以如果你想在两个迭代器定义的范围内移动,你可以说类似于
while(start != end)
cout << start++ << endl;
其中start
和end
是范围内的两个迭代器。注意,end
迭代器,我们通常称之为结束标记,并没有被解引用,它只是告诉你已经到了序列的末尾。因此,它代表“一个过去的结束。”
大多数情况下,您希望遍历容器中的整个序列,因此容器需要某种方式来产生迭代器,以指示序列的开始和结束标记。在这里,和在标准 C++ 库中一样,这些迭代器由容器成员函数begin()
和end()
产生。begin()
使用第一个iterator
构造器,默认指向容器的开头(这是压入堆栈的第一个元素)。然而,end()
使用的第二个构造器是创建结束标记iterator
所必需的。“在末尾”意味着指向堆栈的顶部,因为top
总是指示堆栈上下一个可用但未使用的空间。这个iterator
构造器接受第二个bool
类型的参数,这是一个伪参数,用于区分两个构造器。
斐波那契数列再次用于填充main()
中的IntStack
,而iterator
s 用于移动整个IntStack
,并且也在序列的缩小范围内。
当然,下一步是通过将它的类型模板化来使代码通用化,这样你就可以保存任何类型,而不是被迫只保存int
s;参见清单 16-21 。
清单 16-21 。用嵌套迭代器演示了一个简单的堆栈模板
//: C16:IterStackTemplate.h
// Simple stack template with nested iterator
#ifndef ITERSTACKTEMPLATE_H
#define ITERSTACKTEMPLATE_H
#include "../require.h"
#include <iostream>
template<class T, int ssize = 100>
class StackTemplate {
T stack[ssize];
int top;
public:
StackTemplate() : top(0) {}
void push(const T& i) {
require(top < ssize, "Too many push()es");
stack[top++] = i;
}
T pop() {
require(top > 0, "Too many pop()s");
return stack[--top];
}
class iterator; // Declaration required
friend class iterator; // Make it a friend
class iterator { // Now define it
StackTemplate& s;
int index;
public:
iterator(StackTemplate& st): s(st),index(0){}
// To create the "end sentinel" iterator:
iterator(StackTemplate& st, bool)
: s(st), index(s.top) {}
T operator*() const { return s.stack[index];}
T operator++() { // Prefix form
require(index < s.top,
"iterator moved out of range");
return s.stack[++index];
}
T operator++(int) { // Postfix form
require(index < s.top,
"iterator moved out of range");
return s.stack[index++];
}
// Jump an iterator forward
iterator& operator+=(int amount) {
require(index + amount < s.top,
" StackTemplate::iterator::operator+=() "
"tried to move out of bounds");
index += amount;
return *this;
}
// To see if you're at the end:
bool operator==(const iterator& rv) const {
return index == rv.index;
}
bool operator!=(const iterator& rv) const {
return index != rv.index;
}
friend std::ostream& operator<<(
std::ostream& os, const iterator& it) {
return os << *it;
}
};
iterator begin() { return iterator(*this); }
// Create the "end sentinel":
iterator end() { return iterator(*this, true);}
};
#endif // ITERSTACKTEMPLATE_H ///:∼
您可以看到从常规类到template
的转换是相当透明的。这种先创建并调试一个普通类,然后将其制作成模板的方法通常被认为比从头开始创建模板更容易。
请注意,不只是说
friend iterator; // Make it a friend
这个代码说
friend class iterator; // Make it a friend
这很重要,因为名字“迭代器”已经在一个包含文件的作用域中。
代替current()
成员函数,iterator
有一个operator*
来选择当前元素,这使得iterator
看起来更像一个指针,这是一种常见的做法。
清单 16-22 显示了测试模板的修改后的例子。
清单 16-22 。测试清单 16-21 中的堆栈模板
//: C16:IterStackTemplateTest.cpp
//{L} fibonacci
#include "fibonacci.h"
#include "IterStackTemplate.h" // To be INCLUDED from above
#include <iostream>
#include <fstream>
#include <string>
using namespace std;
int main() {
StackTemplate<int> is;
for(int i = 0; i < 20; i++)
is.push(fibonacci(i));
// Traverse with an iterator:
cout << "Traverse the whole StackTemplate\n";
StackTemplate<int>::iterator it = is.begin();
while(it != is.end())
cout << it++ << endl;
cout << "Traverse a portion\n";
StackTemplate<int>::iterator
start = is.begin(), end = is.begin();
start += 5, end += 15;
cout << "start = " << start << endl;
cout << "end = " << end << endl;
while(start != end)
cout << start++ << endl;
ifstream in("IterStackTemplateTest.cpp");
assure(in, "IterStackTemplateTest.cpp");
string line;
StackTemplate<string> strings;
while(getline(in, line))
strings.push(line);
StackTemplate<string>::iterator
sb = strings.begin(), se = strings.end();
while(sb != se)
cout << sb++ << endl;
} ///:∼
迭代器的第一次使用只是从头到尾行进一次(并显示 end sentinel 工作正常)。在第二种用法中,您可以看到迭代器如何让您轻松地指定元素的范围(标准 C++ 库中的容器和迭代器几乎在任何地方都使用范围的概念)。重载的operator+=
将start
和end
迭代器移动到is
中元素范围的中间位置,这些元素被打印出来。请注意,在输出中,end sentinel 是包含在范围内的而不是,因此它可以是一个超出范围的结束标记,让您知道您已经通过了结束标记——但是您不能取消对 end sentinel 的引用,否则您可能会取消对空指针的引用。最后,为了验证StackTemplate
与类对象一起工作,为string
实例化了一个,并用源代码文件中的行填充,然后打印出来。
带迭代器的堆栈
您可以使用动态调整大小的Stack
类重复这个过程,该类在整本书中都被用作示例。清单 16-23 显示了Stack
类,其中嵌套了一个迭代器。
清单 16-23 。说明了带有嵌套迭代器的模板化堆栈
//: C16:TStack2.h
// Templatized Stack with nested iterator
#ifndef TSTACK2_H
#define TSTACK2_H
template<class T> class Stack {
struct Link {
T* data;
Link* next;
Link(T* dat, Link* nxt)
: data(dat), next(nxt) {}
}* head;
public:
Stack() : head(0) {}
∼Stack();
void push(T* dat) {
head = new Link(dat, head);
}
T* peek() const {
return head ? head->data : 0;
}
T* pop();
// Nested iterator class:
class iterator; // Declaration required
friend class iterator; // Make it a friend
class iterator { // Now define it
Stack::Link* p;
public:
iterator(const Stack<T>& tl) : p(tl.head) {}
// Copy-constructor:
iterator(const iterator& tl) : p(tl.p) {}
// The end sentinel iterator:
iterator() : p(0) {}
// operator++ returns boolean indicating end:
bool operator++() {
if(p->next)
p = p->next;
else p = 0; // Indicates end of list
return bool(p);
}
bool operator++(int) { return operator++(); }
T* current() const {
if(!p) return 0;
return p->data;
}
// Pointer dereference operator:
T* operator->() const {
require(p != 0,
"PStack::iterator::operator->returns 0");
return current();
}
T* operator*() const { return current(); }
// bool conversion for conditional test:
operator bool() const { return bool(p); }
// Comparison to test for end:
bool operator==(const iterator&) const {
return p == 0;
}
bool operator!=(const iterator&) const {
return p != 0;
}
};
iterator begin() const {
return iterator(*this);
}
iterator end() const { return iterator(); }
};
template<class T> Stack<T>::∼Stack() {
while(head)
delete pop();
}
template<class T> T* Stack<T>::pop() {
if(head == 0) return 0;
T* result = head->data;
Link* oldHead = head;
head = head->next;
delete oldHead;
return result;
}
#endif // TSTACK2_H ///:∼
你还会注意到这个类已经被修改为支持所有权,这现在可以工作了,因为这个类知道确切的类型(或者至少知道基本类型,,假设使用了虚拟析构函数,它将会工作)。默认情况下容器销毁它的对象,但是你要对你使用的任何指针负责。
迭代器很简单,物理上非常小——只有一个指针的大小。当你创建一个iterator
时,它被初始化到链表的头部,你只能在链表中向前递增。如果想从头开始,可以创建一个新的迭代器,如果想记住列表中的某个点,可以从指向该点的现有迭代器创建一个新的迭代器(使用迭代器的复制构造器)。
要为迭代器引用的对象调用函数,可以使用current()
函数、operator*
或指针解引用operator->
(迭代器中常见的情况)。后者有一个实现,看起来和current()
一样,因为它返回一个指向当前对象的指针,但是不同的是,指针解引用操作符执行额外级别的解引用(参考第十二章)。
iterator
类遵循你在清单 16-21 中看到的形式。class iterator
嵌套在容器类中,它包含构造器来创建指向容器中元素的迭代器和“end sentinel”迭代器,容器类有begin()
和end()
方法来产生这些迭代器。整个实现包含在头文件中,所以没有单独的cpp
文件。
清单 16-24 包含了一个测试迭代器的小测试。
清单 16-24 。测试清单 16-23 中的模板化堆栈
//: C16:TStack2Test.cpp
#include "TStack2.h" // To be INCLUDED from above
#include "../require.h"
#include <iostream>
#include <fstream>
#include <string>
using namespace std;
int main() {
ifstream file("TStack2Test.cpp");
assure(file, "TStack2Test.cpp");
Stack<string> textlines;
// Read file and store lines in the Stack:
string line;
while(getline(file, line))
textlines.push(new string(line));
int i = 0;
// Use iterator to print lines from the list:
Stack<string>::iterator it = textlines.begin();
Stack<string>::iterator* it2 = 0;
while(it != textlines.end()) {
cout << it->c_str() << endl;
it++;
if(++i == 10) // Remember 10th line
it2 = new Stack<string>::iterator(it);
}
cout << (*it2)->c_str() << endl;
delete it2;
} ///:∼
一个Stack
被实例化以保存string
对象,并用文件中的行填充。然后创建一个迭代器,用于遍历序列。通过从第一个迭代器复制构造第二个迭代器来记住第十行;稍后,这一行被打印出来,动态创建的迭代器被销毁。这里,动态对象创建用于控制对象的生存期。
带迭代器的 PStash
对于大多数容器类来说,拥有一个迭代器是有意义的。清单 16-25 展示了一个添加到PStash
类中的迭代器。
清单 16-25 。说明了带有嵌套迭代器的模板化 PStash
//: C16:TPStash2.h
// Templatized PStash with nested iterator
#ifndef TPSTASH2_H
#define TPSTASH2_H
#include "../require.h"
#include <cstdlib>
template<class T, int incr = 20>
class PStash {
int quantity;
int next;
T** storage;
void inflate(int increase = incr);
public:
PStash() : quantity(0), storage(0), next(0) {}
∼PStash();
int add(T* element);
T* operator[](int index) const;
T* remove(int index);
int count() const { return next; }
// Nested iterator class:
class iterator; // Declaration required
friend class iterator; // Make it a friend
class iterator { // Now define it
PStash& ps;
int index;
public:
iterator(PStash& pStash)
: ps(pStash), index(0) {}
// To create the end sentinel:
iterator(PStash& pStash, bool)
: ps(pStash), index(ps.next) {}
// Copy-constructor:
iterator(const iterator& rv)
: ps(rv.ps), index(rv.index) {}
iterator& operator=(const iterator& rv) {
ps = rv.ps;
index = rv.index;
return *this;
}
iterator& operator++() {
require(++index <= ps.next,
"PStash::iterator::operator++ "
"moves index out of bounds");
return *this;
}
iterator& operator++(int) {
return operator++();
}
iterator& operator--() {
require(--index >= 0,
"PStash::iterator::operator-- "
"moves index out of bounds");
return *this;
}
iterator& operator--(int) {
return operator--();
}
// Jump interator forward or backward:
iterator& operator+=(int amount) {
require(index + amount < ps.next &&
index + amount >= 0,
"PStash::iterator::operator+= "
"attempt to index out of bounds");
index += amount;
return *this;
}
iterator& operator-=(int amount) {
require(index - amount < ps.next &&
index - amount >= 0,
"PStash::iterator::operator-= "
"attempt to index out of bounds");
index -= amount;
return *this;
}
// Create a new iterator that's moved forward
iterator operator+(int amount) const {
iterator ret(*this);
ret += amount; // op+= does bounds check
return ret;
}
T* current() const {
return ps.storage[index];
}
T* operator*() const { return current(); }
T* operator->() const {
require(ps.storage[index] != 0,
"PStash::iterator::operator->returns 0");
return current();
}
// Remove the current element:
T* remove(){
return ps.remove(index);
}
// Comparison tests for end:
bool operator==(const iterator& rv) const {
return index == rv.index;
}
bool operator!=(const iterator& rv) const {
return index != rv.index;
}
};
iterator begin() { return iterator(*this); }
iterator end() { return iterator(*this, true);}
};
// Destruction of contained objects:
template<class T, int incr>
PStash<T, incr>::∼PStash() {
for(int i = 0; i < next; i++) {
delete storage[i]; // Null pointers OK
storage[i] = 0; // Just to be safe
}
delete []storage;
}
template<class T, int incr>
int PStash<T, incr>::add(T* element) {
if(next >= quantity)
inflate();
storage[next++] = element;
return(next - 1); // Index number
}
template<class T, int incr> inline
T* PStash<T, incr>::operator[](int index) const {
require(index >= 0,
"PStash::operator[] index negative");
if(index >= next)
return 0; // To indicate the end
require(storage[index] != 0,
"PStash::operator[] returned null pointer");
return storage[index];
}
template<class T, int incr>
T* PStash<T, incr>::remove(int index) {
// operator[] performs validity checks:
T* v = operator[](index);
// "Remove" the pointer:
storage[index] = 0;
return v;
}
template<class T, int incr>
void PStash<T, incr>::inflate(int increase) {
const int tsz = sizeof(T*);
T** st = new T*[quantity + increase];
memset(st, 0, (quantity + increase) * tsz);
memcpy(st, storage, quantity * tsz);
quantity += increase;
delete []storage; // Old storage
storage = st; // Point to new memory
}
#endif // TPSTASH2_H ///:∼
这个文件的大部分内容是将前面的PStash
和嵌套的iterator
直接翻译成一个模板。然而,这一次,操作符返回对当前迭代器的引用,这是更典型、更灵活的方法。
析构函数为所有包含的指针调用delete
,因为类型是由模板捕获的,所以会发生适当的析构。您应该知道,如果容器保存指向基类类型的指针,该类型应该有一个virtual
析构函数,以确保在将地址被向上转换的派生对象放入容器时,对它们进行适当的清理。
PStash::iterator
在其生命周期中遵循绑定到单个容器对象的迭代器模型。此外,copy-constructor 允许您创建一个新的迭代器,指向与创建它的现有迭代器相同的位置,有效地在容器中创建一个书签。operator+=
和operator-=
成员函数允许你移动迭代器很多点,同时尊重容器的边界。重载的递增和递减操作符将迭代器移动一个位置。operator+
产生一个新的迭代器,它向前移动了加数的数量。如清单 16-11 中的所示,指针解引用操作符用于对迭代器所引用的元素进行操作,remove()
通过调用容器的remove()
销毁当前对象。
与清单 16-11 中的相同类型的代码(就像标准 C++ 库容器一样)被用于创建结束标记:第二个构造器,容器的end()
成员函数,以及用于比较的operator==
和operator!=
。
清单 16-26 创建并测试了两种不同的Stash
对象,一种用于一个名为Int
的新类,它声明了它的构造和销毁,另一种用于保存标准库string
类的对象。
清单 16-26 。创建和测试两个不同的 Stash 对象
//: C16:TPStash2Test.cpp
#include "TPStash2.h" // To be INCLUDED from above
#include "../require.h"
#include <iostream>
#include <vector>
#include <string>
using namespace std;
class Int {
int i;
public:
Int(int ii = 0) : i(ii) {
cout << ">" << i << ' ';
}
∼Int() { cout << "∼" << i << ' '; }
operator int() const { return i; }
friend ostream&
operator <<(ostream& os, const Int& x) {
return os << "Int: " << x.i;
}
friend ostream&
operator <<(ostream& os, const Int* x) {
return os << "Int: " << x->i;
}
};
int main() {
{ // To force destructor call
PStash<Int> ints;
for(int i = 0; i < 30; i++)
ints.add(new Int(i));
cout << endl;
PStash<Int>::iterator it = ints.begin();
it += 5;
PStash<Int>::iterator it2 = it + 10;
for(; it != it2; it++)
delete it.remove(); // Default removal
cout << endl;
for(it = ints.begin();it != ints.end();it++)
if(*it) // Remove() causes "holes"
cout << *it << endl;
} // "ints" destructor called here
cout << "\n-------------------\n";
ifstream in("TPStash2Test.cpp");
assure(in, "TPStash2Test.cpp");
// Instantiate for String:
PStash<string> strings;
string line;
while(getline(in, line))
strings.add(new string(line));
PStash<string>::iterator sit = strings.begin();
for(; sit != strings.end(); sit++)
cout << **sit << endl;
sit = strings.begin();
int n = 26;
sit += n;
for(; sit != strings.end(); sit++)
cout << n++ << ": " << **sit << endl;
} ///:∼
为了方便起见,Int
对于Int&
和Int*
都有一个关联的ostream operator<<
。
main()
中的第一个代码块用大括号括起来,以强制销毁PStash<Int>
,从而由析构函数自动清除。手工移除和删除一系列元素,表明PStash
清理了剩余的元素。
对于PStash
的两个实例,一个迭代器被创建并用于遍历容器。注意使用这些结构产生的优雅;您不会被使用数组的实现细节所困扰。你告诉容器和迭代器对象做什么,而不是怎么做。这使得解决方案更容易概念化、构建和修改。
为什么是迭代器?
到目前为止,您已经看到了迭代器的机制,但是理解它们为什么如此重要需要一个更复杂的例子。
在真正的面向对象程序中,多态、动态对象创建和容器一起使用是很常见的。容器和动态对象创建解决了不知道需要多少或什么类型的对象的问题。如果容器被配置为保存指向基类对象的指针,则每次将派生类指针放入容器时都会发生向上转换(具有相关的代码组织和可扩展性好处)。作为本章的最后一段代码,清单 16-27 也将汇集到目前为止你所学的所有内容的各个方面。如果你能遵循这个例子,那么你就为接下来的章节做好了准备。
清单 16-27 。把这一切放在一起
//: C16:Shape.h
#ifndef SHAPE_H
#define SHAPE_H
#include <iostream>
#include <string>
class Shape {
public:
virtual void draw() = 0;
virtual void erase() = 0;
virtual ∼Shape() {}
};
class Circle : public Shape {
public:
Circle() {}
∼Circle() { std::cout << "Circle::∼Circle\n"; }
void draw() { std::cout << "Circle::draw\n";}
void erase() { std::cout << "Circle::erase\n";}
};
class Square : public Shape {
public:
Square() {}
∼Square() { std::cout << "Square::∼Square\n"; }
void draw() { std::cout << "Square::draw\n";}
void erase() { std::cout << "Square::erase\n";}
};
class Line : public Shape {
public:
Line() {}
∼Line() { std::cout << "Line::∼Line\n"; }
void draw() { std::cout << "Line::draw\n";}
void erase() { std::cout << "Line::erase\n";}
};
#endif // SHAPE_H ///:∼
假设您正在创建一个程序,允许用户编辑和生成不同种类的绘图。每个绘图都是一个对象,包含一组Shape
对象;见清单 16-27 。
这使用了基类中虚函数的经典结构,这些虚函数在派生类中被重写。注意,Shape
类包含了一个virtual
析构函数,你应该自动添加到任何带有virtual
函数的类中。如果一个容器保存了指向Shape
对象的指针或引用,那么当这些对象的virtual
析构函数被调用时,一切都将被正确地清理。
清单 16-28 中的每种不同类型的绘图都使用了不同种类的模板化容器类:本章已经定义的PStash
和Stack
,以及标准 C++ 库中的vector
类。容器的“使用”非常简单;一般来说,继承可能不是最好的方法(组合可能更有意义),但在这种情况下,继承是一种简单的方法,它不会偏离示例中的观点。
清单 16-28 。使用清单 16-27 中的头文件
//: C16:Drawing.cpp
#include <vector> // Uses Standard vector too!
#include "TPStash2.h"
#include "TStack2.h"
#include "Shape.h" // To be INCLUDED from above
using namespace std;
// A Drawing is primarily a container of Shapes:
class Drawing : public PStash<Shape> {
public:
∼Drawing() { cout << "∼Drawing" << endl; }
};
// A Plan is a different container of Shapes:
class Plan : public Stack<Shape> {
public:
∼Plan() { cout << "∼Plan" << endl; }
};
// A Schematic is a different container of Shapes:
class Schematic : public vector<Shape*> {
public:
∼Schematic() { cout << "∼Schematic" << endl; }
};
// A function template:
template<class Iter>
void drawAll(Iter start, Iter end) {
while(start != end) {
(*start)->draw();
start++;
}
}
int main() {
// Each type of container has
// a different interface:
Drawing d;
d.add(new Circle);
d.add(new Square);
d.add(new Line);
Plan p;
p.push(new Line);
p.push(new Square);
p.push(new Circle);
Schematic s;
s.push_back(new Square);
s.push_back(new Circle);
s.push_back(new Line);
Shape* sarray[] = {
new Circle, new Square, new Line
};
// The iterators and the template function
// allow them to be treated generically:
cout << "Drawing d:" << endl;
drawAll(d.begin(), d.end());
cout << "Plan p:" << endl;
drawAll(p.begin(), p.end());
cout << "Schematic s:" << endl;
drawAll(s.begin(), s.end());
cout << "Array sarray:" << endl;
// Even works with array pointers:
drawAll(sarray,
sarray + sizeof(sarray)/sizeof(*sarray));
cout << "End of main" << endl;
} ///:∼
不同类型的容器都包含指向Shape
的指针和指向从Shape
派生的类的向上转换对象的指针。然而,由于多态,当调用虚函数时,正确的行为仍然发生。
注意,Shape*
的数组sarray
,也可以认为是一个容器。
功能模板
在drawAll()
你会看到一些新的东西。到目前为止,本章中我们只使用了类模板,它基于一个或多个类型参数实例化新类。然而,您可以轻松地创建函数模板,它基于类型参数创建新函数。
创建函数模板的原因与创建类模板的原因是一样的:您试图创建泛型代码,并且通过延迟一个或多个类型的规范来实现这一点。你只是想说这些类型参数支持某些操作,而不是确切的说它们是什么类型。
函数模板drawAll()
可以被认为是一个算法(而这也是标准 C++ 库中大多数函数模板的叫法)。它只是说如何做一些给定的迭代器描述一系列元素,只要这些迭代器可以被解引用,增加,和比较。这些正是我们在本章中开发的迭代器,也是——并非巧合——由标准 C++ 库中的容器产生的迭代器,在本例中使用vector
就是证明。
你还想让drawAll()
成为一个通用算法,这样容器就可以是任何类型,你不必为每个不同类型的容器编写新版本的算法。这就是函数模板必不可少的地方,因为它们会自动为每种不同类型的容器生成特定的代码。
但是如果没有迭代器提供的额外间接性,这种泛型(程序的泛型属性或泛型属性)是不可能的。这就是迭代器重要的原因;它们允许您编写涉及容器的通用代码,而无需了解容器的底层结构。
注意在 C++ 中,迭代器和泛型算法需要函数模板才能工作。
你可以在main()
中看到这一点的证明,因为drawAll()
对于每一种不同类型的容器都是不变的。更有趣的是,drawAll()
还可以处理指向数组开头和结尾的指针sarray
。这种将数组视为容器的能力是标准 C++ 库设计的一部分,其算法看起来很像drawAll()
。
因为容器类模板很少受到你在“普通”类中看到的继承和向上转换的影响,所以你几乎不会在容器类中看到virtual
函数。容器类重用是用模板实现的,而不是用继承。
审查会议
- 容器类是面向对象编程的一个基本部分。它们是简化和隐藏程序细节的另一种方式,也是加速程序开发过程的另一种方式。
- 此外,通过取代 c 语言中原始的数组和相对粗糙的数据结构技术,它们提供了大量的安全性和灵活性
- 因为客户端程序员需要容器,所以容器必须易于使用。这就是模板的用武之地。
- 有了模板,源代码重用(与继承和组合提供的目标代码重用相反)的语法对新手用户来说变得足够简单。事实上,用模板复用代码明显比继承和组合容易。
- 与容器类设计相关的问题在本章中已经有所涉及,但是你可能已经知道它们可以更进一步。
- 事实上,复杂的容器类库可能涵盖所有种类的附加问题,包括多线程、持久性和垃圾收集。
十七、异常处理
改进错误恢复是提高代码健壮性的最有效的方法之一。
不幸的是,忽略错误条件几乎是公认的做法,就好像我们对错误持否定态度一样。毫无疑问,一个原因是检查许多错误的繁琐和代码膨胀。例如,printf()
返回成功打印的字符数,但是实际上没有人检查这个值。光是代码的激增就令人厌恶,更不用说阅读代码的难度了。
C 的错误处理方法的问题可以被认为是耦合——函数的用户必须将错误处理代码与该函数紧密地联系在一起,以至于它变得太笨拙而难以使用。
C++ 的主要特性之一是异常处理,这是思考和处理错误的一种更好的方式。异常处理有几个好处。
- 编写错误处理代码并不那么乏味,也不会与您的“正常”代码混淆。你写下你希望 ?? 发生的代码;稍后在一个单独的部分中,您将编写代码来处理这些问题。如果多次调用一个函数,就可以在一个地方一次性处理该函数的错误。
- 错误不容忽视。如果一个函数需要向该函数的调用者发送一个错误消息,它会将一个表示该错误的对象“抛出”该函数。如果调用者没有“捕获”错误并处理它,它将进入下一个封闭的动态范围,依此类推,直到错误被捕获或者程序因为没有处理程序来捕获这种类型的异常而终止。
本章考察了 C 语言的错误处理方法,讨论了它为什么不适用于 C 语言,并解释了它为什么不适用于 C++。本章还涵盖了支持异常处理的 C++ 关键字try
、throw
和catch
。
传统错误处理
在本书的大部分例子中,我按照预期使用了assert()
:在开发过程中使用代码进行调试,这些代码可以在产品发布时用#define NDEBUG
禁用。运行时错误检查使用在第九章的中开发的require.h
函数(assure()
和require()
)。这些函数是一种方便的表达方式,“这里有一个问题,你可能想用一些更复杂的代码来处理,但是在这个例子中你不需要被它分散注意力。”对于小程序来说,require.h
函数可能已经足够了,但是对于复杂的产品来说,您可能想要编写更复杂的错误处理代码。
当您确切地知道要做什么时,错误处理是非常简单的,因为您在该上下文中有所有必要的信息。您可以在这一点上处理错误。
当 you 在该上下文中没有足够的信息,并且需要将错误信息传递到存在该信息的不同上下文中时,问题就出现了。在 C 中,您可以使用三种方法来处理这种情况。
- 从函数返回错误信息,或者,如果返回值不能这样使用,则设置一个全局错误条件标志。(标准 C 提供了
errno
和perror()
来支持这个。)如上所述,程序员很可能会忽略错误信息,因为每个函数调用都必须进行冗长而令人困惑的错误检查。此外,从遇到异常情况的函数返回可能没有意义。 - 使用鲜为人知的标准 C 库信号处理系统,用
signal()
函数(确定事件发生时会发生什么)和raise()
(生成事件)实现。同样,这种方法涉及到高耦合,因为它要求生成信号的任何库的用户理解并安装适当的信号处理机制。在大型项目中,来自不同库的信号编号可能会冲突。 - 使用标准 C 库中的非本地
goto
函数:setjmp()
和longjmp()
。用setjmp()
你在程序中保存一个已知良好的状态,如果你遇到麻烦,longjmp()
将恢复那个状态。同样,在存储状态的位置和发生错误的位置之间存在高度耦合。
当考虑 C++ 的错误处理方案时,还有一个额外的关键问题:signals 和setjmp()
/ longjmp()
的 C 技术不调用析构函数,所以对象没有被适当地清理。(事实上,如果longjmp()
跳过了应该调用析构函数的作用域的末尾,程序的行为就是未定义的。)这使得从异常情况中有效恢复变得几乎不可能,因为你总是会留下那些没有被清理并且不能再被访问的对象。清单 17-1 用setjmp/longjmp
演示了这一点。
清单 17-1 。演示异常处理(用 C 的 setjmp() & longjmp())
//: C17:Nonlocal.cpp
// setjmp() & longjmp().
#include <iostream>
#include <csetjmp>
using namespace std;
class Rainbow {
public:
Rainbow() { cout << "Rainbow()" << endl; }
∼Rainbow() { cout << "∼Rainbow()" << endl; }
};
jmp_buf kansas;
void oz() {
Rainbow rb;
for(int i = 0; i< 3; i++)
cout << "there's no place like home" << endl;
longjmp(kansas, 47);
}
int main() {
if(setjmp(kansas) == 0) {
cout << "tornado, witch, munchkins..." << endl;
oz();
} else {
cout << "Auntie Em! "
<< "I had the strangest dream..."
<< endl;
}
} ///:∼
setjmp()
函数很奇怪,因为如果你直接调用它,它会将当前处理器状态的所有相关信息(比如指令指针和运行时堆栈指针的内容)存储在jmp_buf
中,并返回零。在这种情况下,它的行为就像一个普通的函数。然而,如果你用同一个jmp_buf
呼叫longjmp()
,就好像你又从setjmp()
回来了——你正好从setjmp()
的后端出来。这一次,返回值是longjmp()
的第二个参数,因此您可以发现您实际上是从longjmp()
返回的。你可以想象,有了许多不同的jmp_buf
,你可以在程序中的许多不同的地方出现。本地goto
(带标签)和非本地goto
的区别在于,你可以用setjmp()
/ longjmp()
返回到运行时栈中任何更高的预定位置(任何你调用了setjmp()
的地方)。
C++ 的问题是longjmp()
不尊重对象;特别是当它跳出一个作用域时,它不会调用析构函数。析构函数调用是必不可少的,所以这种方法不适用于 C++。事实上,C++ 标准规定,用goto
分支到一个作用域(有效地绕过构造器调用),或者用longjmp()
分支到一个作用域之外,其中堆栈上的一个对象有一个析构函数,组合未定义的行为。
抛出异常
如果您在代码中遇到异常情况——也就是说,如果您在当前上下文中没有足够的信息来决定做什么——您可以通过创建一个包含该信息的对象并将它“抛出”当前上下文,将有关错误的信息发送到一个更大的上下文中。这被称为抛出异常。清单 17-2 展示了它的样子。
清单 17-2 。引发异常
//: C17:MyError.cpp {RunByHand}
classMyError {
const char* const data;
public:
MyError(const char* const msg = 0) : data(msg) {}
};
void f() {
// Here we "throw" an exception object:
throw MyError("something bad happened");
}
int main() {
// As you’ll see shortly, we’ll want a "try block" here:
f();
} ///:∼
MyError
是一个普通的类,在这种情况下,它接受一个char*
作为构造器参数。抛出时可以使用任何类型(包括内置类型),但通常要为抛出异常创建特殊的类。
关键字throw
导致了许多相对神奇的事情发生。首先,它创建一个你抛出的对象的副本,实际上,从包含抛出表达式的函数中“返回”它,即使该对象类型通常不是该函数要返回的类型。考虑异常处理的一个天真的方法是作为一个替代的返回机制(尽管你会发现如果你把这个类比得太远,你会陷入麻烦)。您还可以通过引发异常来退出普通范围。在任何情况下,都会返回一个值,并且函数或作用域会退出。
与return
语句的任何相似之处都到此为止,因为返回的与普通函数调用返回的地方完全不同。
注意你会在代码的一个适当的部分结束——称为异常处理程序——它可能远离抛出异常的地方。
此外,在异常发生时创建的任何本地对象都将被销毁。这种本地对象的自动清理通常被称为栈展开。
此外,您可以投掷任意多种不同类型的对象。通常,您会为每一类错误抛出不同的类型。其思想是将信息存储在对象及其类的名称中,这样在调用上下文中的某个人就可以知道如何处理您的异常。
捕捉异常
如前所述,C++ 异常处理的优势之一是您可以在一个地方集中精力解决问题,然后在另一个地方处理代码中的错误。
try 块
如果你在一个函数中抛出了一个异常(或者一个被调用的函数抛出了一个异常),那么这个函数就会因为抛出的异常而退出。如果你不想让一个throw
离开一个函数,你可以在函数中设置一个特殊的块,在这里你可以尝试解决你实际的编程问题(并且可能产生异常)。这个块被称为 try 块,因为您在那里尝试各种函数调用。try 块是一个普通的作用域,前面有关键字try
,如:
try {
// Code that may generate exceptions
}
如果通过仔细检查所用函数的返回代码来检查错误,则需要用设置和测试代码包围每个函数调用,即使多次调用同一个函数也是如此。使用异常处理,您将所有内容放在一个try
块中,并在try
块之后处理异常。因此,您的代码更容易编写和阅读,因为代码的目标不会与错误处理混淆。
异常处理程序
当然,抛出的异常必须在某个地方结束。这个地方就是异常处理程序,你需要一个异常处理程序来处理你想要捕捉的每一种异常类型。然而,多态也适用于异常,因此一个异常处理程序可以处理一个异常类型和从该类型派生的类。
异常处理程序紧跟在try
块之后,由关键字catch
表示,如:
try {
// Code that may generate exceptions
} catch(type1 id1) {
// Handle exceptions of type1
} catch(type2 id2) {
// Handle exceptions of type2
} catch(type3 id3)
// Etc...
} catch(typeNidN)
// Handle exceptions of typeN
}
// Normal execution resumes here...
catch
子句的语法类似于接受单个参数的函数。标识符(id1
、id2
等等)可以在处理程序中使用,就像函数参数一样,尽管如果处理程序中不需要标识符,也可以省略它。异常类型通常给你足够的信息来处理它。
处理程序必须直接出现在try
块之后。如果抛出异常,异常处理机制会寻找第一个参数与异常类型匹配的处理程序。然后它进入那个catch
子句,异常被认为已经处理。(一旦找到了catch
子句,对处理程序的搜索就会停止。)只执行匹配的catch
子句;然后,控制在与该 try 块关联的最后一个处理程序之后继续。
注意,在try
块中,许多不同的函数调用可能会生成相同类型的异常,但是您只需要一个处理程序。
为了说明try
和catch
,清单 17-3 修改了Nonlocal.cpp
( 清单 17-1 ),用try
块替换了对setjmp()
的调用,用throw
语句替换了对longjmp()
的调用。
清单 17-3 。图示试&抓块
//: C17:Nonlocal2.cpp
// Illustrates exceptions.
#include <iostream>
using namespace std;
class Rainbow {
public:
Rainbow() { cout << "Rainbow()" << endl; }
∼Rainbow() { cout << "∼Rainbow()" << endl; }
};
void oz() {
Rainbow rb;
for(int i = 0; i < 3; i++)
cout << "there's no place like home" << endl;
throw 47;
}
int main() {
try {
cout << "tornado, witch, munchkins..." << endl;
oz();
} catch(int) {
cout << "Auntie Em! I had the strangest dream..."
<< endl;
}
} ///:∼
当oz()
中的throw
语句执行时,程序控制返回,直到找到带int
参数的catch
子句。继续执行那个catch
条款的主体。这个程序和Nonlocal.cpp
最重要的区别是当throw
语句导致执行离开函数oz()
时,对象rb
的析构函数被调用。
终止和恢复
异常处理理论中有两种基本模型:终止和恢复。在终止(这是 C++ 支持的)中,您假设错误非常严重,以至于没有办法从异常发生的地方自动恢复执行。换句话说,抛出异常的人决定没有办法挽回局面,他们不希望回来。
另一种错误处理模型被称为恢复,于 20 世纪 60 年代由 PL/I 语言首次引入。使用恢复语义意味着期望异常处理程序做一些事情来纠正这种情况,然后自动重试出错的代码,假设第二次成功。如果您想在 C++ 中恢复,您必须显式地将执行转移回发生错误的代码,通常是通过重复首先将您送到那里的函数调用。把你的try
块放在一个while
循环中是很常见的,这个循环不断地重新进入try
块,直到结果令人满意。
从历史上看,使用支持恢复性异常处理的操作系统的程序员最终会使用类似终止的代码并跳过恢复。虽然恢复听起来很吸引人,但在实践中似乎并不那么有用。一个原因可能是异常和它的处理程序之间的距离。终止于远处的处理程序是一回事,但是跳到那个处理程序然后再跳回来对于大型系统来说可能在概念上太困难了,因为在大型系统中,异常是从许多点生成的。
异常匹配
当抛出一个异常时,异常处理系统会按照它们在源代码中出现的顺序检查“最近的”处理程序。当它找到一个匹配项时,该异常被视为已处理,不再进行进一步的搜索。
匹配异常并不要求异常和它的处理程序之间有完美的关联。对 derivedclass 对象的对象或引用将匹配基类的处理程序。(但是,如果处理程序是针对对象而不是引用的,则异常对象在传递给处理程序时会被“切片”,即被截断为基类型。这不会造成损害,但是会丢失所有派生类型的信息。)出于这个原因,也为了避免制作异常对象的另一个副本,通过引用而不是通过值来捕捉异常总是更好的。如果抛出指针,通常的标准指针转换用于匹配异常。但是,在匹配过程中,不会使用自动类型转换来将一种异常类型转换为另一种异常类型。例如,参见清单 17-4 。
清单 17-4 。说明异常匹配
//: C17:Autoexcp.cpp
// No matching conversions.
#include <iostream>
using namespace std;
class Except1 {};
class Except2 {
public:
Except2(const Except1&) {}
};
void f() { throw Except1(); }
int main() {
try { f();
} catch(Except2&) {
cout << "inside catch(Except2)" << endl;
} catch(Except1&) {
cout << "inside catch(Except1)" << endl;
}
} ///:∼
尽管您可能认为可以通过使用转换构造器将一个Except1
对象转换成一个Except2
来匹配第一个处理程序,但是系统不会在异常处理期间执行这样的转换,您将在Except1
处理程序处结束。
清单 17-5 显示了一个基类处理程序如何捕捉一个派生类异常。
清单 17-5 。说明异常层次结构
//: C17:Basexcpt.cpp
// Exception hierarchies.
#include <iostream>
using namespace std;
class X {
public:
class Trouble {};
class Small : public Trouble {};
class Big : public Trouble {};
void f() { throw Big(); }
};
int main() {
X x;
try {
x.f();
} catch(X::Trouble&) {
cout << "caught Trouble" << endl;
// Hidden by previous handler:
} catch(X::Small&) {
cout << "caught Small Trouble" << endl;
} catch(X::Big&) {
cout << "caught Big Trouble" << endl;
}
} ///:∼
在这里,异常处理机制总是将一个Trouble
对象、或任何属于、Trouble
(通过公共继承、)的对象匹配到第一个处理程序。这意味着第二个和第三个处理程序永远不会被调用,因为第一个处理程序捕获了它们。更有意义的做法是首先捕获派生类型,然后将基类型放在最后来捕获任何不太具体的类型。
请注意,这些示例通过引用捕获异常,尽管对于这些类来说这并不重要,因为派生类中没有额外的成员,而且处理程序中也没有参数标识符。您通常希望在处理程序中使用引用参数而不是值参数,以避免切断信息。
捕捉任何异常
有时你想创建一个处理程序来捕捉任何类型的异常。使用参数列表中的省略号可以做到这一点,例如:
catch(...) {
cout << "an exception was thrown" << endl;
}
因为省略号会捕捉任何异常,所以您会希望将它放在处理程序列表的末尾以避免抢占它后面的任何异常。
省略号不能让你有一个参数,所以你不能知道任何关于异常或其类型的事情。这是一个无所不包的游戏;它通常用于清理一些资源,然后再抛出异常。
再次引发异常
当您有一些资源需要释放时,例如网络连接或需要释放的堆内存,您通常希望重新引发异常。
注详见本章后面的“资源管理”一节。
如果发生异常,您不必关心是什么错误导致了异常,您只需要关闭之前打开的连接。之后,您会希望让更接近用户的其他上下文(即,调用链中更高的位置)来处理异常。在这种情况下,省略号规范正是您想要的。您希望捕获任何异常,清理您的资源,然后重新抛出该异常以便在其他地方处理。您可以在处理程序中使用不带参数的throw
来重新抛出异常,比如:
catch(...) {
cout << "an exception was thrown" << endl;
// Deallocate your resource here, and then rethrow
throw;
}
同一个try
块的任何进一步的catch
子句仍然被忽略——throw
导致异常转到下一个更高上下文中的异常处理程序。此外,关于异常对象的一切都被保留下来,因此捕捉特定异常类型的更高上下文中的处理程序可以提取该对象可能包含的任何信息。
未捕获的异常
正如我在本章开始时解释的那样,异常处理被认为比传统的返回错误代码技术更好,因为异常不能被忽略,也因为错误处理逻辑与手头的问题是分离的。如果某个特定的try
块之后的异常处理程序都不匹配某个异常,那么这个异常就会转移到下一个更高的上下文中,也就是说,没有捕捉到该异常的try
块周围的函数或try
块。(这个try
块的位置乍一看并不总是很明显,因为它在调用链中的位置更高。)这个过程一直持续,直到在某个层次上,一个处理程序匹配到异常为止。此时,该异常被认为是“被捕获的”,不会进行进一步的搜索。
terminate()函数
如果任何级别的处理程序都没有捕捉到异常,则自动调用特殊库函数terminate()
(在<exception>
头文件中声明)。默认情况下,terminate()
调用标准的 C 库函数abort()
,它会突然退出程序。在 Unix 系统上,abort()
也会导致核心转储。当调用abort()
时,不会调用正常的程序终止函数,这意味着全局和静态对象的析构函数不会执行。如果局部对象的析构函数在堆栈展开时抛出异常(中断正在进行的异常),或者如果全局或静态对象的构造器或析构函数抛出异常,那么terminate()
函数也会执行。(一般情况下,不允许析构函数抛出异常。)
*函数的作用是
您可以使用标准的set_terminate()
函数安装您自己的terminate()
函数,该函数返回一个指向您正在替换的terminate()
函数的指针(这将是您第一次调用它时的默认库版本),因此如果您愿意,您可以稍后恢复它。您的定制terminate()
必须不带参数,并且有一个void
返回值。此外,您安装的任何terminate()
处理程序都不能返回或抛出异常,而是必须执行某种程序终止逻辑。如果调用terminate()
,问题不可恢复。
清单 17-6 展示了set_terminate()
的用法。在这里,返回值被保存和恢复,以便terminate()
函数可以用来帮助隔离发生未捕获异常的代码部分。
清单 17-6 。使用 set _ termin ate();此外,还演示了未捕获的异常
//: C17:Terminator.cpp
// Use of set_terminate(). Also shows uncaught exceptions.
#include <exception>
#include <iostream>
using namespace std;
void terminator() {
cout << "I'll be back!" << endl;
exit(0);
}
void (*old_terminate)() = set_terminate(terminator);
class Botch {
public:
class Fruit {};
void f() {
cout << "Botch::f()" << endl;
throw Fruit();
}
∼Botch() { throw 'c'; }
};
int main() {
try {
Botch b;
b.f();
} catch(...) {
cout << "inside catch(...)" << endl;
}
} ///:∼
起初,old_terminate
的定义看起来有点混乱:它不仅创建了一个指向函数的指针,还将该指针初始化为set_terminate()
的返回值。尽管您可能很熟悉在指向函数的声明后面看到分号,但这里它只是另一种变量,可以在定义时初始化。
类Botch
不仅在f()
内部抛出异常,还在其析构函数中抛出异常。这导致了对terminate()
的调用,正如你在main()
中看到的。即使异常处理程序说catch(...)
,这似乎捕捉了一切,没有理由调用terminate()
,但是terminate()
还是被调用了。在清理堆栈上的对象以处理一个异常的过程中,调用了Botch
析构函数,这会生成第二个异常,从而强制调用terminate()
。因此,抛出异常或导致异常被抛出的析构函数通常是糟糕的设计或草率编码的标志。
清理
异常处理的神奇之处在于,您可以从正常的程序流跳到适当的异常处理程序中。但是,如果在抛出异常时没有进行适当的清理,那么这样做是没有用的。C++ 异常处理保证当你离开一个作用域时,该作用域中所有构造器已经完成的对象都会被调用它们的析构函数。
清单 17-7 展示了没有完成的构造器不会调用相关的析构函数。它还显示了在创建对象数组的过程中抛出异常时会发生什么。
清单 17-7 。演示异常不会清理不完整的对象
//: C17:Cleanup.cpp
// Exceptions clean up complete objects only.
#include <iostream>
using namespace std;
class Trace {
static int counter;
int objid;
public:
Trace() {
objid = counter++;
cout << "constructing Trace #" << objid << endl;
if(objid == 3) throw 3;
}
∼Trace() {
cout << "destructing Trace #" << objid << endl;
}
};
int Trace::counter = 0;
int main() {
try {
Trace n1;
// Throws exception:
Trace array[5];
Trace n2; // Won't get here.
} catch(int i) {
cout << "caught " << i << endl;
}
} ///:∼
类Trace
跟踪对象,这样你就可以跟踪程序进度。它记录用数据成员counter
创建的对象的数量,并用objid
跟踪特定对象的数量。
主程序创建一个单独的对象,n1
( objid
0),然后尝试创建一个由五个Trace
对象组成的数组,但是在第四个对象(#3)完全创建之前抛出了一个异常。对象n2
从未被创建。你可以在程序的输出中看到结果:
constructing Trace #0
constructing Trace #1
constructing Trace #2
constructing Trace #3
destructing Trace #2
destructing Trace #1
destructing Trace #0
caught 3
成功创建了三个数组元素,但是在第四个元素的构造器中,引发了一个异常。因为main()
中对array[2]
的第四次构造永远不会完成,所以只调用对象array[1]
和array[0]
的析构函数。最后,对象n1
被销毁,但不是对象n2
,因为它从未被创建过。
资源管理
当编写带有异常的代码时,特别重要的是,您总是问:“如果发生异常,我的资源会被适当地清理吗?”大多数时候你是相当安全的,但是在构造器中有一个特殊的问题:如果一个异常在构造器完成之前被抛出,相关的析构函数将不会被调用。因此,在编写构造器时,你必须特别勤奋。
困难在于在构造器中分配资源。如果构造器中出现异常,析构函数就没有机会释放资源。这个问题最常发生在“裸指针上。我称它们为“裸体”指针是有道理的。他们的行为就像一个人脱下衣服开始洗澡,但洗完澡后不得不赤身裸体地出来,因为有人拿着他的衣服跑了。因此,在脱下他的衣服后,一个例外发生了,他的衣服被偷了,现在他不得不裸体出来,因为他对这种例外情况没有准备。代码示例见清单 17-8 。
清单 17-8 。演示了裸指针的情况
//: C17:Rawp.cpp
// Naked pointers.
#include <iostream>
#include <cstddef>
using namespace std;
class Cat {
public:
Cat() { cout << "Cat()" << endl; }
∼Cat() { cout << "∼Cat()" << endl; }
};
class Dog {
public:
void* operator new(size_tsz) {
cout << "allocating a Dog" << endl;
throw 47;
}
void operator delete(void* p) {
cout << "deallocating a Dog" << endl;
::operator delete(p);
}
};
class UseResources {
Cat* bp;
Dog* op;
public:
UseResources(int count = 1) {
cout << "UseResources()" << endl;
bp = new Cat[count];
op = new Dog;
}
∼UseResources() {
cout << "∼UseResources()" << endl;
delete [] bp; // Array delete
delete op;
}
};
int main() {
try {
UseResources ur(3);
} catch(int) {
cout << "inside handler" << endl;
}
} ///:∼
输出是
UseResources()
Cat()
Cat()
Cat()
allocating a Dog
inside handler
进入UseResources
构造器,三个数组对象的Cat
构造器成功完成。然而,在Dog::operator new()
内部,会抛出一个异常(模拟内存不足的错误)。突然,你在处理程序中结束,没有调用析构函数。这是正确的,因为UseResources
构造器无法完成,但这也意味着在堆上成功创建的Cat
对象从未被销毁。
让一切都成为物体
为了防止这种资源泄漏,您必须以两种方式中的一种来防止这些“原始”资源分配(出于与上面相同的原因,我将它们称为原始资源分配)。他们表现得像一个“原始”的人,有衣服(?? 资源),但没有做好充分准备,以应对洗澡时有人带着他的衣服(?? 资源)逃跑的特殊情况,最终不得不赤身裸体地出来。
- 您可以在构造器中捕获异常,然后释放资源。
- 可以将分配放在对象的构造器中,将释放放在对象的析构函数中。
使用后一种方法,由于是本地对象生命周期的一部分,每个分配都变成了原子的,如果它失败了,其他资源分配对象在栈展开期间被适当地清除。这种技术被称为资源获取是初始化(简称 RAII),因为它将资源控制等同于对象生存期。使用模板是修改清单 17-8 以获得清单 17-9 中所示代码的一个极好的方法。
清单 17-9 。使用 RAII 说明了安全原子指针&
//: C17:Wrapped.cpp
// Safe, atomic pointers.
#include <iostream>
#include <cstddef>
using namespace std;
// Simplified. Yours may have other arguments.
template<class T, int sz = 1> class PWrap {
T* ptr;
public:
class RangeError {}; // Exception class
PWrap() {
ptr = new T[sz];
cout << "PWrap constructor" << endl;
}
∼PWrap() {
delete[] ptr;
cout << "PWrap destructor" << endl;
}
T& operator[](int i) throw(RangeError) {
if(i >= 0 && i < sz) return ptr[i];
throw RangeError();
}
};
class Cat {
public:
Cat() { cout << "Cat()" << endl; }
∼Cat() { cout << "∼Cat()" << endl; }
void g() {}
};
class Dog {
public:
void* operator new[](size_t) {
cout << "Allocating a Dog" << endl;
throw 47;
}
void operator delete[](void* p) {
cout << "Deallocating a Dog" << endl;
::operator delete[](p);
}
};
class UseResources {
PWrap<Cat, 3> cats;
PWrap<Dog> dog;
public:
UseResources() { cout << "UseResources()" << endl; }
∼UseResources() { cout << "∼UseResources()" << endl; }
void f() { cats[1].g(); }
};
int main() {
try {
UseResources ur;
} catch(int) {
cout << "inside handler" << endl;
} catch(...) {
cout << "inside catch(...)" << endl;
}
} ///:∼
不同之处在于使用了模板来包装指针并使它们成为对象。这些对象的构造器在UseResources
构造器的主体之前被调用,并且在抛出异常之前完成的这些构造器中的任何一个都将在栈展开期间调用它们相关的析构函数。
PWrap
模板展示了异常的一个更典型的用法:如果参数超出范围,就会创建一个名为RangeError
的嵌套类在operator[ ]
中使用。因为operator[ ]
返回一个引用,所以不能返回零。
注意没有空引用。
这是一个真正的异常情况——您不知道在当前上下文中该做什么,并且您不能返回一个不可能的值。在清单 17-9 中,RangeError
[5]很简单,假设所有必要的信息都在类名中,但是如果有用的话,你可能还想添加一个包含索引值的成员。
现在输出是
Cat()
Cat()
Cat()
PWrap constructor
allocating a Dog
∼Cat()
∼Cat()
∼Cat()
PWrap destructor
inside handler
再次,Dog
的存储分配抛出了一个异常,但是这次Cat
对象的数组被正确地清理了,所以没有内存泄漏。
使用自动指针
由于动态内存是典型 C++ 程序中使用最频繁的资源,该标准为指向堆内存的指针提供了一个 RAII 包装器,可以自动释放内存。在<memory>
头中定义的auto_ptr
类模板有一个构造器,该构造器接受一个指向其泛型类型的指针(无论您在代码中使用什么)。auto_ptr
类模板还重载了指针操作符*
和->
,将这些操作转发给auto_ptr
对象持有的原始指针。因此您可以像使用原始指针一样使用auto_ptr
对象。清单 17-10 显示了它是如何工作的。
清单 17-10 。演示 auto_ptr 的 RAII 特性
//: C17:Auto_ptr.cpp
// Illustrates the RAII nature of auto_ptr.
#include <memory>
#include <iostream>
#include <cstddef>
using namespace std;
class TraceHeap {
int i;
public:
static void* operator new(size_t siz) {
void* p = ::operator new(siz);
cout << "Allocating TraceHeap object on the heap "
<< "at address " << p << endl;
return p;
}
static void operator delete(void* p) {
cout << "Deleting TraceHeap object at address "
<< p << endl;
::operator delete(p);
}
TraceHeap(int i) : i(i) {}
intgetVal() const { return i; }
};
int main() {
auto_ptr<TraceHeap> pMyObject(new TraceHeap(5));
cout << pMyObject->getVal() << endl; // Prints 5
} ///:∼
TraceHeap
类重载了operator new
和operator delete
,这样你就可以清楚地看到发生了什么。注意,像任何其他类模板一样,您要在模板参数中指定要使用的类型。你没有说TraceHeap*
,但是——auto_ptr
已经知道它将存储一个指向你的类型的指针。main()
的第二行验证了auto_ptr
的operator->()
函数对原始的底层指针应用了间接寻址。最重要的是,即使您没有显式删除原始指针,pMyObject
的析构函数也会在堆栈展开期间删除原始指针,如以下输出所示:
Allocating TraceHeap object on the heap at address 8930040
5
Deleting TraceHeap object at address 8930040
对于指针数据成员,类模板也很方便。因为由值包含的类对象总是被析构,auto_ptr
成员总是在包含对象被析构时删除它们包装的原始指针。
函数级 try 块
由于构造器通常会引发异常,所以您可能希望处理在初始化对象的成员或基子对象时发生的异常。为此,您可以将这些子对象的初始化放在一个函数级的 try 块中。与通常的语法不同,构造器初始化器的try
块是构造器体,相关的catch
块跟在构造器体后面,如清单 17-11 所示。
清单 17-11 。阐释如何处理子对象的异常
//: C17:InitExcept.cpp {-bor}
// Handles exceptions from subobjects.
#include <iostream>
using namespace std;
class Base {
int i;
public:
classBaseExcept {};
Base(int i) : i(i) { throw BaseExcept(); }
};
class Derived : public Base {
public:
class DerivedExcept {
const char* msg;
public:
DerivedExcept(const char* msg) : msg(msg) {}
const char* what() const { return msg; }
};
Derived(int j) try : Base(j) {
// Constructor body
cout << "This won't print" << endl;
} catch(BaseExcept&) {
throw DerivedExcept("Base subobject threw");;
}
};
int main() {
try {
Derived d(3);
} catch(Derived::DerivedExcept& d) {
cout << d.what() << endl; // "Base subobject threw"
}
} ///:∼
请注意,Derived
的构造器中的初始化列表位于try
关键字之后,构造器体之前。如果发生异常,所包含的对象不会被构造,因此返回到创建它的代码是没有意义的。出于这个原因,唯一明智的做法是在函数级catch
子句中抛出一个异常。
尽管不是特别有用,C++ 也允许函数级的块用于任何 ?? 函数,如清单 17-12 ?? 所示。
清单 17-12 。演示函数级 try 块
//: C17:FunctionTryBlock.cpp {-bor}
// Function-level try blocks.
// {RunByHand} (Don’t run automatically by the makefile)
#include <iostream>
using namespace std;
int main() try {
throw "main";
} catch(const char* msg) {
cout << msg << endl;
return 1;
} ///:∼
在这种情况下,catch
块可以以函数体正常返回的方式返回。使用这种类型的函数级try
块与在函数体内的代码周围插入一个try-catch
没有太大区别。
标准例外
标准 C++ 库使用的异常也可供您使用。一般来说,从一个标准的异常类开始比试图定义自己的异常类更容易、更快。如果标准类不能完全满足您的需求,您可以从它派生。
所有标准的异常类最终都是从头文件<exception>
中定义的类exception
中派生出来的。两个主要的派生类是logic_error
和runtime_error
,它们位于<stdexcept>
(它本身包括<exception>
)。类logic_error
表示编程逻辑中的错误,比如传递了一个无效的参数。运行时错误是由于硬件故障或内存耗尽等不可预见的因素导致的。runtime_error
和logic_error
都提供了一个接受std::string
参数的构造器,这样你就可以在异常对象中存储一条消息,然后用exception::what()
提取它,如清单 17-13 所示。
清单 17-13 。演示如何派生异常类
//: C17:StdExcept.cpp
// Derives an exception class from std::runtime_error.
#include <stdexcept>
#include <iostream>
using namespace std;
class MyError : public runtime_error {
public:
MyError(const string& msg = "") : runtime_error(msg) {}
};
int main() {
try {
throw MyError("my message");
} catch(MyError& x) {
cout << x.what() << endl;
}
} ///:∼
尽管runtime_error
构造器将消息插入到它的std::exception
子对象中,std::exception
没有提供接受std::string
参数的构造器。你通常想从runtime_error
或者logic_error
(或者它们的一个派生物)中派生出你的异常类,而不是从std::exception
中。
表 17-1 描述了标准异常类别。
表 17-1。标准异常类
| exception
| C++ 标准库引发的所有异常的基类。您可以询问 what()并检索初始化异常时使用的可选字符串。 |
| logic_error
| 源自exception
。报告程序逻辑错误,这些错误大概可以通过检查发现。 |
| runtime_error
| 源自exception
。报告运行时错误,这些错误可能只有在程序执行时才能被检测到。 |
iostream 异常类ios::failure
也是从exception
派生的,但是它没有进一步的子类。
您可以按原样使用下面两个表中的类,也可以将它们用作基类,从基类派生您自己的更具体类型的异常。参见表 17-2 和 17-3 。
表 17-2。从标准异常类派生的异常类 logic_error
从logic_error 派生的异常类 |
---|
domain_error |
invalid_argument |
length_error |
out_of_range |
bad_cast |
bad_typeid |
表 17-3。从标准异常类派生的异常类- runtime_error
从runtime_error 派生的异常类 |
---|
range_error |
overflow_error |
bad_alloc |
异常规格
你不需要通知使用你的函数的人你可能抛出什么异常。然而,不这样做可以被认为是不文明的,因为这意味着用户不能确定应该编写什么代码来捕捉所有潜在的异常。如果他们有你的源代码,他们可以搜索并寻找throw
语句,但是库通常没有源代码。好的文档可以帮助缓解这个问题,但是有多少软件项目是有良好文档记录的呢?C++ 提供了语法来告诉用户这个函数抛出的异常,这样用户就可以处理它们。这是可选的异常规范,它修饰一个函数的声明,出现在参数列表之后。
异常规范重用关键字throw
,后面是函数可能抛出的所有类型的潜在异常的括号列表。您的函数声明可能如下所示:
void f() throw(toobig, toosmall, divzero);
就异常而言,传统的函数声明
void f();
意味着任何类型的异常都可以从函数中抛出。如果你说
void f() throw();
这个函数不会抛出任何异常(所以你最好确保调用链中更靠下的函数不会让任何异常向上传播!).
为了良好的编码策略、良好的文档以及函数调用方的易用性,在编写抛出异常的函数时,可以考虑使用异常规范。
注本章稍后将讨论该指南的变体。
意外的()函数
如果你的异常规范声明你将抛出一组特定的异常,然后你抛出了不在那组中的东西,惩罚是什么?当您抛出异常规范中没有出现的内容时,会调用特殊函数unexpected()
。如果发生这种不幸的情况,默认的unexpected()
调用本章前面描述的terminate()
函数。
函数的作用是
和terminate()
一样,unexpected()
机制会安装您自己的函数来响应意外的异常。你可以用一个名为set_unexpected()
的函数来实现,这个函数和set_terminate()
一样,接受一个没有参数和void
返回值的函数的地址。此外,因为它返回unexpected()
指针的前一个值,所以您可以保存它并在以后恢复它。要使用set_unexpected()
,包括头文件<exception>
。清单 17-14 显示了到目前为止本节所讨论的特性的一个简单用法。
清单 17-14 。使用异常规范&的意外()机制
//: C17:Unexpected.cpp
// Exception specifications & unexpected(),
//{-msc} (Doesn’t terminate properly)
#include <exception>
#include <iostream>
using namespace std;
class Up {};
class Fit {};
void g();
void f(int i) throw(Up, Fit) {
switch(i) {
case 1: throw Up();
case 2: throw Fit();
}
g();
}
// void g() {} // Version 1
void g() { throw 47; } // Version 2
void my_unexpected() {
cout << "unexpected exception thrown" << endl;
exit(0);
}
int main() {
set_unexpected(my_unexpected); // (Ignores return value)
for(int i = 1; i <= 3; i++)
try {
f(i);
} catch(Up) {
cout << "Up caught" << endl;
} catch(Fit) {
cout << "Fit caught" << endl;
}
} ///:∼
创建类Up
和Fit
只是为了抛出异常。异常类通常很小,但是它们肯定可以保存额外的信息,以便处理程序可以查询这些信息。
f()
函数在其异常规范中承诺只抛出类型为Up
和Fit
的异常,从函数定义来看,这似乎是合理的。由f()
调用的g()
版本一不抛出任何异常,所以这是真的。但是如果有人更改了g()
,使其抛出不同类型的异常(如本例中的第二个版本,它抛出了一个int
),那么就违反了f()
的异常规范。
my_unexpected()
函数没有参数或返回值,遵循自定义unexpected()
函数的正确形式。它只是显示一条消息,这样您就可以看到它被调用了,然后退出程序(这里使用了exit(0)
,这样书的make
进程就不会中止)。您的新unexpected()
函数不应该有return
语句。
在main()
中,try
程序块在一个for
循环中,所以所有的可能性都被执行了。这样就可以达到复盘之类的东西。将try
模块嵌套在for
、while
、do
或if
中,并引发任何异常以尝试修复问题;然后再次尝试try
块。
只有Up
和Fit
异常被捕获,因为这些是f()
的程序员说会被抛出的唯一异常。g()
的版本二导致my_unexpected()
被调用,因为f()
随后抛出一个int
。
在对set_unexpected()
的调用中,返回值被忽略,但是它也可以保存在指向函数的指针中,以后再恢复,就像本章前面的set_terminate()
例子(清单 17-6 )一样。
典型的unexpected
处理程序记录错误并通过调用exit()
终止程序。然而,它可以抛出另一个异常(或者,重新抛出同一个异常)或者调用abort()
。如果它抛出了一个异常,该异常是最初违反了规范的函数所允许的类型,那么搜索将在具有该异常规范的函数的调用处重新开始。
注意这种行为是unexpected()
独有的。
如果从您的unexpected
处理程序抛出的异常不被原始函数的规范所允许,就会发生两个事件之一。
- 如果
std::bad_exception
(在<exception>
中定义)在函数的异常规范中,从意外处理程序抛出的异常将被替换为一个std::bad_exception
对象,搜索将像以前一样从函数中恢复。 - 如果原始函数的规范不包括
std::bad_exception
,则调用terminate()
。
清单 17-15 说明了这种行为。
清单 17-15 。列举了两个糟糕的例外情况
//: C17:BadException.cpp {-bor}
#include <exception> // For std::bad_exception
#include <iostream>
#include <cstdio>
using namespace std;
// Exception classes:
class A {};
class B {};
// terminate() handler
void my_thandler() {
cout << "terminate called" << endl;
exit(0);
}
// unexpected() handlers
void my_uhandler1() { throw A(); }
void my_uhandler2() { throw; }
// If we embed this throw statement in f or g,
// the compiler detects the violation and reports
// an error, so we put it in its own function.
void t() { throw B(); }
void f() throw(A) { t(); }
void g() throw(A, bad_exception) { t(); }
int main() {
set_terminate(my_thandler);
set_unexpected(my_uhandler1);
try {
f();
} catch(A&) {
cout << "caught an A from f" << endl;
}
set_unexpected(my_uhandler2);
try {
g();
} catch(bad_exception&) {
cout << "caught a bad_exception from g" << endl;
}
try {
f();
} catch(...) {
cout << "This will never print" << endl;
}
} ///:∼
my_uhandler1()
处理程序抛出一个可接受的异常(A
),所以在第一次捕捉成功时执行继续。my_uhandler2()
处理程序没有抛出有效的异常(B
,但是由于g
指定了bad_exception
,所以B
异常被一个bad_exception
对象替换,第二次捕捉也成功了。由于f
的规范中不包含bad_exception
,因此my_thandler()
被作为终止处理程序调用。以下是输出结果:
caught an A from f
caught a bad_exception from g
terminate called
更好的异常规范?
您可能会觉得现有的异常规范规则不太安全
void f();
应该是指这个函数没有抛出异常。如果程序员想抛出任何类型的异常,你可能会认为他或她应该说
void f() throw(...); // Not in C++
这肯定是一种改进,因为函数声明会更加明确。不幸的是,通过查看函数中的代码,您并不总能知道是否会抛出异常——例如,它可能是因为内存分配而发生的。更糟糕的是,在异常处理被引入到语言中之前编写的现有函数可能会发现自己无意中抛出了异常,因为它们调用的函数(可能会链接到新的抛出异常的版本中)。因此,在这种毫无信息的情况下
void f();
意思是,“也许我会抛出一个异常;也许我不会。”这种模糊性对于避免阻碍代码进化是必要的。如果您想指定f
不抛出异常,请使用空列表,如下所示:
void f() throw();
异常规范和继承
类中的每个公共函数本质上都与用户形成了一个契约;如果您向它传递某些参数,它将执行某些操作和/或返回一个结果。同样的约定在派生类中也必须成立;否则,预计的将会违反派生类和基类之间的关系。由于异常规范在逻辑上是函数声明的一部分,它们也必须在继承层次结构中保持一致。例如,如果基类中的成员函数说它将只抛出类型为A
的异常,那么派生类中该函数的重写不能将任何其他异常类型添加到规范列表中,因为这将破坏任何遵循基类接口的程序。然而,你可以指定更少的异常或者根本没有,因为这不需要用户做任何不同的事情。你也可以指定任何东西,在派生函数的规范中,用代替A
。清单 17-16 显示了一个例子。
清单 17-16 。说明协方差(异常规范&继承)
//: C17:Covariance.cpp {-xo}
// Should cause compile error. {-mwcc}{-msc}
#include <iostream>
using namespace std;
class Base {
public:
class BaseException {};
class DerivedException : public BaseException {};
virtual void f() throw(DerivedException) {
throw DerivedException();
}
virtual void g() throw(BaseException) {
throw BaseException();
}
};
class Derived : public Base {
public:
void f() throw(BaseException) {
throw BaseException();
}
virtual void g() throw(DerivedException) {
throw DerivedException();
}
}; ///:∼
编译器应该用一个错误(或至少一个警告)来标记Derived::f()
的覆盖,因为它以一种违反Base::f()
规范的方式改变了它的异常规范。Derived::g()
的规格是可以接受的,因为DerivedException
是-a BaseException
(而不是相反)。你可以把Base/Derived
和BaseException/DerivedException
想象成平行的类层次结构;当你在Derived
时,你可以用DerivedException
替换异常规范和返回值中对BaseException
的引用。这种行为被称为协方差(因为两组类一起沿着它们各自的层次向下变化)。
何时不使用异常规范
如果你仔细阅读整个标准 C++ 库的函数声明,你会发现没有一个异常规范出现在任何地方!虽然这看起来很奇怪,但这种不一致有一个很好的原因:库主要由模板组成,你永远不知道泛型类型或函数会做什么。例如,假设您正在开发一个通用的堆栈模板,并试图为您的 pop 函数附加一个异常规范,如下所示:
T pop() throw(logic_error);
因为您预期的唯一错误是堆栈下溢,所以您可能认为指定一个logic_error
或其他适当的异常类型是安全的。但是类型T
的复制构造器可能会抛出一个异常。然后unexpected()
会被调用,你的程序会终止。你不能做出无法支持的保证。如果您不知道可能会发生什么异常,就不要使用异常规范。这就是为什么组合标准 C++ 库主要部分的模板类不使用异常规范——它们在文档中指定它们知道的关于的异常,剩下的交给你。异常规范主要针对非模板类。
异常安全
标准 C++ 库包括了stack
容器。您会注意到的一件事是,pop()
成员函数的声明如下:
void pop();
你可能会觉得奇怪,因为pop()
没有返回值。相反,它只是移除堆栈顶部的元素。要检索上限值,在调用pop()
之前先调用top()
。这种行为有一个重要的原因,它与异常安全有关,这是库设计中的一个关键考虑因素。异常安全有不同的级别,但最重要的是——顾名思义——异常安全是关于面对异常时的正确语义。
假设你正在用一个动态数组实现一个堆栈(姑且称之为data
和计数器整数count
,你试着写pop()
让它返回值。这种pop()
的代码可能看起来像这样:
template<class T> T stack<T>::pop() {
if(count == 0)
throw logic_error("stack underflow");
else
return data[--count];
}
如果最后一行中为返回值调用的复制构造器在值返回时抛出异常,会发生什么?弹出的元素因为异常而没有返回,然而count
已经被递减,所以你想要的顶部元素永远丢失了!问题是这个函数试图同时做两件事:(1)返回值,和(2)改变堆栈的状态。最好将这两个动作分成两个独立的成员函数,这正是标准的stack
类所做的。(换句话说,遵循衔接的设计惯例——每个功能都要做好一件事。)异常安全代码使对象保持一致的状态,不会泄漏资源。
您还需要小心编写自定义赋值操作符。在第十二章中,你看到operator=
应该遵循以下模式。
- 确保您没有分配给 self。如果是,请转到步骤 6。(这是严格意义上的优化。)
- 分配指针数据成员所需的新内存。
- 将数据从旧内存复制到新内存。
- 删除旧的记忆。
- 通过将新的堆指针分配给指针数据成员来更新对象的状态。
- 返回
*this
。
重要的是,在所有新的部分都被安全地分配和初始化之前,不要改变对象的状态。一个好的技巧是将步骤 2 和 3 移到一个单独的函数中,通常称为clone()
。清单 17-17 为一个有两个指针成员theString
和theInts
的类这样做。
清单 17-17 。阐释异常安全运算符(=)
//: C17:SafeAssign.cpp
// An Exception-safe operator=.
#include <iostream>
#include <new> // For std::bad_alloc
#include <cstring>
#include <cstddef>
using namespace std;
// A class that has two pointer members using the heap
class HasPointers {
// A Handle class to hold the data
struct MyData {
const char* theString;
const int* theInts;
size_t numInts;
MyData(const char* pString, const int* pInts,
size_t nInts)
: theString(pString), theInts(pInts), numInts(nInts) {}
} *theData; // The handle
// Clone and cleanup functions:
static MyData* clone(const char* otherString,
const int* otherInts, size_t nInts) {
char* newChars = new char[strlen(otherString)+1];
int* newInts;
try {
newInts = new int[nInts];
} catch(bad_alloc&) {
delete [] newChars;
throw;
}
try {
// This example uses built-in types, so it won't
// throw, but for class types it could throw, so we
// use a try block for illustration. (This is the
// point of the example!)
strcpy(newChars, otherString);
for(size_t i = 0; i < nInts; ++i)
newInts[i] = otherInts[i];
} catch(...) {
delete [] newInts;
delete [] newChars;
throw;
}
return new MyData(newChars, newInts, nInts);
}
static MyData* clone(const MyData* otherData) {
return clone(otherData->theString, otherData->theInts,
otherData->numInts);
}
static void cleanup(const MyData* theData) {
delete [] theData->theString;
delete [] theData->theInts;
delete theData;
}
public:
HasPointers(const char* someString, constint* someInts,
size_t numInts) {
theData = clone(someString, someInts, numInts);
}
HasPointers(const HasPointers& source) {
theData = clone(source.theData);
}
HasPointers& operator=(const HasPointers& rhs) {
if(this != &rhs) {
MyData* newData = clone(rhs.theData->theString,
rhs.theData->theInts, rhs.theData->numInts);
cleanup(theData);
theData = newData;
}
return *this;
}
∼HasPointers() { cleanup(theData); }
friend ostream&
operator<<(ostream& os, const HasPointers& obj) {
os << obj.theData->theString << ": ";
for(size_t i = 0; i < obj.theData->numInts; ++i)
os << obj.theData->theInts[i] << ' ';
return os;
}
};
int main() {
int someNums[] = { 1, 2, 3, 4 };
size_t someCount = sizeof someNums / sizeof someNums[0];
int someMoreNums[] = { 5, 6, 7 };
size_t someMoreCount =
sizeof someMoreNums / sizeof someMoreNums[0];
HasPointers h1("Hello", someNums, someCount);
HasPointers h2("Goodbye", someMoreNums, someMoreCount);
cout << h1 << endl; // Hello: 1 2 3 4
h1 = h2;
cout << h1 << endl; // Goodbye: 5 6 7
} ///:∼
为了方便起见,HasPointers
使用MyData
类作为两个指针的句柄。每当需要分配更多内存时,无论是在构造还是赋值期间,最终都会调用第一个clone
函数来完成这项工作。如果第一次调用new
操作符时内存失败,就会自动抛出一个bad_alloc
异常。如果它发生在第二次分配时(对于theInts
,你必须为theString
清理内存——因此第一个try
块捕捉到一个bad_alloc
异常。第二个try
块在这里并不重要,因为你只是复制了int
和指针(所以不会发生异常),但是每当你复制对象时,它们的赋值操作符可能会导致异常,所以一切都需要清理。在两个异常处理程序中,请注意您重新抛出了异常和。那是因为你只是在这里管理资源;用户仍然需要知道出错了,所以您让异常沿着动态链向上传播。不默默吞下异常的软件库被称为异常中立。总是努力编写既异常安全又异常中立的库。
如果您仔细检查前面的代码,您会注意到没有一个delete
操作会抛出异常。这个代码取决于这个事实。回想一下,当您在一个对象上调用delete
时,该对象的析构函数被调用。事实证明,如果不假设析构函数不抛出异常,设计异常安全的代码几乎是不可能的。不要让析构函数抛出异常。
注意在本章结束之前,我们将再次提醒你这一点。
异常编程
对于大多数程序员,尤其是 C 程序员,异常在他们现有的语言中是不可用的,需要一些调整。以下是异常编程的指导原则。
何时避免例外
例外不是所有问题的答案;过度使用会带来麻烦。以下章节指出了未授权的例外情况。决定何时使用异常的最佳建议是,只有当函数不符合其规范时才抛出异常。
不适用于异步事件
标准 C signal()
系统和任何类似的系统都处理异步事件——发生在程序流程之外的事件,也就是程序无法预料的事件。您不能使用 C++ 异常来处理异步事件,因为异常及其处理程序在同一个调用堆栈上。也就是说,异常依赖于程序运行时堆栈上函数调用的动态链(它们有动态范围,而异步事件必须由完全独立的代码处理,这些代码不是正常程序流的一部分(通常是中断服务例程或事件循环)。不要从中断处理程序抛出异常。
这并不是说异步事件不能与异常相关联。但是中断处理程序应该尽可能快地完成它的工作,然后返回。处理这种情况的典型方法是在中断处理程序中设置一个标志,并在主线代码中同步检查它。
不适用于良性错误情况
如果您有足够的信息来处理一个错误,它就不是一个异常。在当前的上下文中处理它,而不是在更大的上下文中抛出一个异常。
此外,对于机器级别的事件,如被零除,不会引发 C++ 异常。我们假设一些其他的机制,比如操作系统或者硬件,来处理这些事件。通过这种方式,C++ 异常可以相当有效,并且它们的使用仅限于程序级的异常情况。
不用于控制流
异常看起来有点像替代返回机制,又有点像switch
语句,所以您可能会尝试使用异常来代替这些普通的语言机制。这是一个坏主意,部分原因是异常处理系统的效率明显低于正常的程序执行。异常是一种罕见的事件,所以正常的程序不应该支付它们。此外,除了错误条件之外的任何异常都很容易让类或函数的用户感到困惑。
您不必被迫使用异常
有些程序非常简单(例如,小工具)。您可能只需要接受输入并执行一些处理。在这些程序中,您可能会尝试分配内存但失败,尝试打开文件但失败,等等。在这些程序中,显示一条消息并退出程序是可以接受的,让系统来收拾残局,而不是自己努力捕捉所有异常并恢复所有资源。基本上,如果你不需要异常,你不会被迫使用它们。
新异常,旧代码
出现的另一种情况是修改不使用异常的现有程序。你可能会引入一个使用了异常的库,并且想知道你是否需要修改整个程序中的所有代码。假设您已经有了一个可接受的错误处理方案,最简单的方法就是将使用新库的最大的代码块包围起来(这可能是用一个try
代码块、后跟一个 catch(...)
和基本错误消息来包围main()
中的所有代码)。您可以通过添加更具体的处理程序来将它细化到任何必要的程度,但是,在任何情况下,您必须添加的代码都可以是最少的。更好的做法是将您的异常生成代码隔离在一个try
块中,并编写处理程序将异常转换成您现有的错误处理方案。
当你创建一个供其他人使用的库时,考虑异常是非常重要的,尤其是当你不知道他们需要如何响应关键的错误条件时。
注回想一下前面关于异常安全以及为什么标准 C++ 库中没有异常规范的讨论。
异常的典型用法
务必使用异常来执行以下操作:
- 请修复该问题,然后重试导致异常的函数。
- 修补东西并继续,不要重试该功能。
- 在当前上下文中尽你所能,将相同的异常重新抛出到更高的上下文中。
- 在当前上下文中做你能做的任何事情,并向更高的上下文抛出一个不同的异常。
- 终止程序。
- 包装使用普通错误方案的函数(尤其是 C 库函数),这样它们反而会产生异常。
- 简化。如果你的错误处理方案让事情变得更复杂,那么使用起来会很痛苦,很烦人。异常可以用来使错误处理更简单、更有效。
- 使您的库和程序更加安全。这是一项短期投资(用于调试),也是一项长期投资(用于应用程序健壮性)。
何时使用异常规范
异常规范就像一个函数原型:它告诉用户编写异常处理代码以及处理什么异常。它告诉编译器这个函数可能产生的异常,这样它就可以在运行时检测到违规。
您不能总是查看代码并预测特定函数会引发哪些异常。有时,它调用的函数会产生一个意外的异常,有时,一个没有抛出异常的旧函数会被一个抛出异常的新函数所替换,这样您就会得到一个对unexpected()
的调用。任何时候使用异常规范或调用使用异常规范的函数时,都要考虑创建自己的unexpected()
函数,记录一条消息,然后抛出异常或中止程序。
如前所述,您应该避免在模板类中使用异常规范,因为您无法预料模板参数类可能会抛出什么类型的异常。
从标准异常开始
在创建自己的异常之前,先检查一下标准的 C++ 库异常。如果一个标准的异常满足了你的需要,那么你的用户就很容易理解和处理它。
如果您想要的异常类型不是标准 C++ 库的一部分,请尝试从现有的标准异常中继承一个。如果你的用户总是能够编写他们的代码来期望在exception()
类接口中定义的what()
函数,那就太好了。
嵌套您自己的异常
如果您为您的特定类创建异常,最好将异常类嵌套在您的类中或包含您的类的命名空间中,以便向读者提供一个明确的消息,即该异常仅适用于您的类。此外,它还防止了全局名称空间的污染。即使您是从 C++ 标准异常派生的,您也可以嵌套您的异常。
使用异常层次结构
使用异常层次结构是对您的类或库可能遇到的严重错误类型进行分类的一种有价值的方法。这为用户提供了有用的信息,帮助他们组织代码,并为他们提供了忽略所有特定类型的异常并只捕捉基类类型的选项。此外,以后通过从同一基类继承而添加的任何异常都不会强制重写所有现有代码——基类处理程序将捕获新的异常。
标准 C++ 异常是异常层次结构的一个很好的例子。如果可能的话,在它的基础上构建您的异常。
多重继承(MI)
正如你将在第二十一章中读到的,MI 的唯一本质地方是如果你需要将一个对象指针向上转换到两个不同的基类——也就是说,如果你需要这两个基类的多态行为。原来,异常层次结构是多重继承的有用位置,因为多重继承异常类的任何根的基类处理程序都可以处理异常。
通过引用捕获,而不是通过值
正如您在“异常匹配”一节中看到的,您应该通过引用来捕捉异常,原因有两个:
- 以避免在将异常对象传递给处理程序时对其进行不必要的复制。
- 在捕获作为基类对象的派生异常时避免对象切片。
虽然您也可以抛出和捕捉指针,但这样做会引入更多的耦合——抛出者和捕捉者必须就如何分配和清理异常对象达成一致。这是一个问题,因为异常本身可能是由堆耗尽引起的。如果抛出异常对象,异常处理系统会处理所有存储。
在构造器中抛出异常
因为构造器没有返回值,所以之前你有两种方法在构造过程中报告错误:
- 设置一个非本地标志,并希望用户检查它。
- 返回一个创建不完整的对象,并希望用户检查它。
这个问题很严重,因为 C 程序员期望对象创建总是成功的,这在 C 中不是不合理的,因为类型是如此原始。但是在 C++ 程序中,构造失败后继续执行肯定是一场灾难,所以构造器是抛出异常的最重要的地方之一——现在您有了一种安全有效的方法来处理构造器错误。但是,您还必须注意对象内部的指针,以及在构造器内部引发异常时进行清理的方式。
不要在析构函数中引发异常
因为析构函数是在抛出其他异常的过程中被调用的,所以您绝不会想要在析构函数中抛出一个异常,或者通过在析构函数中执行的某些操作引发另一个异常。如果发生这种情况,在到达现有异常的 catch-clause 之前,可能会抛出一个新的异常,这将导致对terminate()
的调用。
如果在析构函数中调用任何可能抛出异常的函数,这些调用应该在析构函数的try
块中,并且析构函数必须自己处理所有异常。任何人都不能从析构函数中逃脱。
避免裸指针
参见清单 17-9 中的Wrapped.cpp
。如果为指针分配了资源,那么裸指针通常意味着构造器中存在漏洞。指针没有析构函数,所以如果在构造器中抛出异常,这些资源不会被释放。对引用堆内存的指针使用auto_ptr
或其他智能指针类型。
开销
当抛出异常时,会有相当大的运行时开销(但是这是很好的开销,因为对象是自动清理的!).出于这个原因,无论异常看起来多么诱人和聪明,您都不应该将它作为正常控制流的一部分。
异常应该很少发生,所以开销堆积在异常上,而不是正常执行的代码上。异常处理的一个重要设计目标是,当不使用时,它可以在不影响执行速度的情况下实现;也就是说,只要您不抛出异常,您的代码就会像没有异常处理时一样快。这是否正确取决于您使用的特定编译器实现。
注参见本节后面对“零成本模式”的描述。
您可以将一个throw
表达式想象成对一个特殊系统函数的调用,该函数将异常对象作为一个参数,并沿着执行链向上回溯。为此,编译器需要将额外的信息放到堆栈中,以帮助展开堆栈。要理解这一点,您需要了解运行时堆栈。
每当调用一个函数时,关于该函数的信息就被推送到运行时堆栈中的一个激活记录实例 ( 【阿里】 ) ,也称为堆栈帧。一个典型的堆栈帧包含调用函数的地址(这样执行可以返回给它),一个指向函数的静态父函数的 ARI 的指针(这个范围在词法上包含被调用的函数,所以可以访问函数的全局变量),一个指向调用它的函数(它的动态父函数)的指针。重复跟踪动态父链接的逻辑结果路径是本章前面提到的动态链或调用链。
这就是抛出异常时执行可以回溯的方式,这种机制使得在不了解彼此的情况下开发的组件可以在运行时交流错误。
为了启用异常处理的堆栈展开,需要为每个堆栈帧提供关于每个函数的额外异常相关信息。该信息描述了需要调用哪些析构函数(以便可以清理本地对象),指示当前函数是否有一个try
块,并列出相关联的 catch 子句可以处理哪些异常。
这些额外的信息会占用空间,所以支持异常处理的程序会比不支持异常处理的程序大一些。甚至使用异常处理的程序的编译时大小也更大,因为如何在运行时生成扩展堆栈帧的逻辑必须由编译器生成。
为了说明这一点,我在 Borland C++ Builder 和 Microsoft Visual C++ 中编译了有和没有异常处理支持的清单 17-18 中的程序。
清单 17-18 。说明有/没有异常处理支持的程序
//: C17:HasDestructor.cpp {O}
*/* shows that programs with exception-handling support are bigger than those without */*
class HasDestructor {
public:
∼HasDestructor() {}
};
void g(); // For all we know, g may throw.
void f() {
HasDestructor h;
g();
} ///:∼
如果启用了异常处理,编译器必须在运行时为f()
在 ARI 中保留关于∼HasDestructor()
的可用信息(这样当g()
抛出异常时,它可以正确地销毁h
)。
表 17-4 根据编译的大小总结了编译的结果。obj)文件(字节)。
表 17-4 。汇编流程结果(汇总)
编译器\模式 | 例外支持 | 无一例外的支持 |
---|---|---|
Borland |
Six hundred and sixteen | Two hundred and thirty-four |
Microsoft |
One thousand one hundred and sixty-two | Six hundred and eighty |
不要把两种模式的百分比差异看得太重。
请记住,异常(应该是)通常组合程序的一小部分,因此空间开销往往更小(通常在 5%到 15%之间)。
这种额外的内务处理会降低执行速度,但是聪明的编译器实现可以避免这种情况。由于关于异常处理代码和局部对象的偏移量的信息可以在编译时计算一次,所以这些信息可以保存在与每个函数相关联的单个位置,而不是保存在每个 ARI 中。
从本质上消除了每个 ARI 的异常开销,从而避免了将它们压入堆栈的额外时间。这种方法被称为异常处理的零成本模型,前面提到的优化存储被称为影子堆栈。
审查会议
- 错误恢复是你编写的每一个程序的基本关注点。在 C++ 中,当创建程序组件供他人使用时,这一点尤其重要。要创建一个健壮的系统,每个组件都必须健壮。
- C++ 中的异常处理的目标是使用比目前更少的代码来简化大型、可靠程序的创建,更确信你的应用程序没有未处理的错误。这是在很少或没有性能损失的情况下完成的,并且对现有代码的影响很小。
- 基本的例外是不太难学;尽快在你的程序中使用它们。
- 异常是那些为你的项目提供直接和显著利益的特性之一。*
十八、深入字符串
用字符数组处理字符串是 c 语言中最浪费时间的事情之一。字符数组要求程序员跟踪静态引用字符串和在堆栈和堆上创建的数组之间的区别,以及有时你传递一个 char*而有时你必须复制整个数组的事实。
尤其是因为字符串操作如此普遍,字符数组是误解和错误的主要来源。尽管如此,创建字符串类仍然是初学 C++ 程序员多年来的常见练习。标准的 C++ 库string
一劳永逸地解决了字符数组操作的问题,即使在赋值和复制构造期间也能跟踪内存。你根本不需要考虑它。
本章考察了标准的 C++ string
类,首先看看 C++ 字符串是由什么组成的,以及 C++ 版本与传统的 C 字符数组有何不同。您将学习使用string
对象的操作和操纵,您将看到 C++ string
如何适应字符集和字符串数据转换的变化。
处理文本是最古老的编程应用之一,所以 C++ string
大量借鉴了 C 和其他语言中长期使用的思想和术语也就不足为奇了。当你开始熟悉 C++ string
s 时,这个事实应该是令人放心的。无论您选择哪种编程习惯,您可能想用string
做三件常见的事情:
- 创建或修改存储在
string
中的字符序列。 - 检测
string
中是否存在元素。 - 在表示
string
字符的各种方案之间进行翻译。
您将看到这些工作是如何使用 C++ string
对象来完成的。
字符串中有什么?
在 C 语言中,字符串只是一个字符数组,它总是包含一个二进制零(通常称为零终止符)作为它的最终数组元素。C++ string
s 与其 C 祖先之间存在显著差异。首先,也是最重要的,C++ string
隐藏了它们包含的字符序列的物理表示。您不需要关心数组维数或空终止符。一个string
还包含一些关于其数据大小和存储位置的“内务”信息。具体来说,一个 C++ string
对象知道它在内存中的起始位置、它的内容、它的字符长度,以及在它必须调整其内部数据缓冲区之前它可以增长到的字符长度。因此,C++ 字符串大大降低了犯三种最常见和最具破坏性的 C 编程错误的可能性:覆盖数组边界、试图通过未初始化或值不正确的指针访问数组,以及在数组不再占用曾经分配给它的存储空间后让指针“悬空”。
C++ 标准没有定义 string 类的内存布局的确切实现。这“意在足够灵活以允许编译器供应商的不同实现,而保证用户的可预测行为。特别是,没有定义分配存储来保存字符串对象的数据的确切条件。字符串分配规则被公式化为允许但不要求引用计数实现,但是无论实现是否使用引用计数,语义必须是相同的。换句话说,在 C 语言中,每个char
数组都占据一个唯一的物理内存区域。在 C++ 中,单个的string
对象可能会也可能不会占用内存中唯一的物理区域,但是如果引用计数避免存储数据的重复副本,那么单个对象必须看起来和表现得好像它们独占了唯一的存储区域。例如,见清单 18-1 。
*清单 18-1 。说明字符串存储
//: C18:StringStorage.cpp
#include <string>
#include <iostream>
using namespace std;
int main() {
string s1("12345");
// Set the iterator indicate the first element
string::iterator it = s1.begin();
// This may copy the first to the second or
// use reference counting to simulate a copy
string s2 = s1;
// Either way, this statement may ONLY modify first
*it = '0';
cout << "s1 = " << s1 << endl;
cout << "s2 = " << s2 << endl;
} ///:∼
引用计数可能有助于提高实现的内存效率,但它对string
类的用户是透明的。
创建和初始化 C++ 字符串
创建和初始化字符串是一件简单的事情,而且相当灵活。在清单 18-2 的SmallString.cpp
中,声明了第一个string
、imBlank
,但不包含初始值。与 C char
数组不同,imBlank
数组包含有意义的信息,Cchar
数组在初始化之前包含随机且无意义的位模式。这个string
对象被初始化为保存“no characters ”,并且可以使用类成员函数正确地报告它的零长度和数据元素的缺失。
下一个字符串heyMom
,由文字参数“我的袜子在哪里?”这种形式的初始化使用带引号的字符数组作为string
构造器的参数。相比之下,standardReply
只是通过赋值来初始化。使用现有的 C++ string
对象初始化该组的最后一个字符串useThisOneAgain
。换句话说,清单 18-2 说明了string
对象让你做以下事情:
- 创建一个空的
string
,并推迟用字符数据初始化它。 - 通过将一个带引号的字符数组作为参数传递给构造器来初始化一个
string
。 - 使用等号(
=
)初始化 astring
。 - 使用一个
string
初始化另一个。
清单 18-2 。说明字符串特征
//: C18:SmallString.cpp
#include <string>
using namespace std;
int main() {
string imBlank;
string heyMom("Where are my socks?");
string standardReply = "Beamed into deep "
"space on wide angle dispersion?";
string useThisOneAgain(standardReply);
} ///:∼
这些是最简单的初始化形式,但是变化提供了更多的灵活性和控制。您可以执行以下操作:
- 使用 C
char
数组或 C++string
的一部分。 - 使用
operator+
组合不同来源的初始化数据。 - 使用
string
对象的substr()
成员函数创建一个子串。
清单 18-3 展示了这些特征。
清单 18-3 。说明更多字符串功能
//: C18:SmallString2.cpp
#include<string>
#include<iostream>
using namespace std;
int main() {
string s1("What is the sound of one clam napping?");
string s2("Anything worth doing is worth overdoing.");
string s3("I saw Elvis in a UFO");
// Copy the first 8 chars:
string s4(s1, 0, 8);
cout << s4 << endl;
// Copy 6 chars from the middle of the source:
string s5(s2, 15, 6);
cout << s5 << endl;
// Copy from middle to end:
string s6(s3, 6, 15);
cout << s6 << endl;
// Copy many different things:
string quoteMe = s4 + "that" +
// substr() copies 10 chars at element 20
s1.substr(20, 10) + s5 +
// substr() copies up to either 100 char
// or eos starting at element 5
"with" + s3.substr(5, 100) +
// OK to copy a single char this way
s1.substr(37, 1);
cout << quoteMe << endl;
} ///:∼
string
成员函数substr()
将起始位置作为第一个参数,将选择的字符数作为第二个参数。两个参数都有默认值。如果你用一个空的参数列表说substr()
,你产生了一个整个string
的副本,所以这是一个复制string
的方便方法。
下面是程序的输出:
What is
doing
Elvis in a UFO
What is that one clam doing with Elvis in a UFO?
注意清单 18-3 中的最后一行。C++ 允许在一条语句中混合使用初始化技术,这是一个灵活方便的特性。还要注意,最后一个初始化器只从源string
复制了一个字符。
另一个稍微微妙的初始化技术涉及到使用string
迭代器string::begin()
和string::end()
。这种技术将string
视为一个容器对象(您主要以vector
的形式看到它),它使用迭代器来指示一个字符序列的开始和结束。这样你可以给一个string
构造器传递两个迭代器,它从一个迭代器复制到另一个迭代器到新的string
,如清单 18-4 所示。
清单 18-4 。说明字符串迭代器
//: C18:StringIterators.cpp
#include <string>
#include <iostream>
#include <cassert>
using namespace std;
int main() {
string source("xxx");
string s(source.begin(), source.end());
assert(s == source);
} ///:∼
迭代器不限于begin()
和end()
;您可以增加、减少和添加整数偏移量,允许您从源string
中提取字符的子集。
C++ 字符串可能而不是用单个字符或 ASCII 或其他整数值初始化。但是,您可以用单个字符的多个副本来初始化一个字符串;参见清单 18-5 。
清单 18-5 。说明字符串的初始化
//: C18:UhOh.cpp
#include <string>
#include <cassert>
using namespace std;
int main() {
// Error: no single char inits
//! string nothingDoing1('a');
// Error: no integer inits
//! string nothingDoing2(0x37);
// The following is legal:
string okay(5, 'a');
assert(okay == string("aaaaa"));
} ///:∼
第一个参数指示第二个参数在字符串中的副本数。第二个参数只能是单个char
,不能是char
数组。
在字符串上操作
如果你用 C 语言编程,你会习惯于编写、搜索、修改和复制char
数组的函数。处理char
数组的标准 C 库函数有两个不幸的方面。首先,它们有两个组织松散的家族:“普通”组,以及要求您提供手头操作中要考虑的字符数的组。C char
数组库中的函数列表中有一长串神秘的、大多难以发音的名字,让不知情的用户大吃一惊。尽管函数的参数类型和数量有些一致,但要正确使用它们,您必须注意函数命名和参数传递的细节。
标准 C char
数组工具的第二个固有陷阱是,它们都明确依赖于字符数组包含一个空终止符的假设。如果由于疏忽或错误,空值被忽略或覆盖,C char
数组函数很难控制内存超出分配空间的限制,有时会导致灾难性的结果。
C++ 极大地提高了string
对象的便利性和安全性。对于实际的字符串处理操作来说,string
类中的不同成员函数名称的数量与 C 库中的函数数量大致相同,但是由于重载,功能要多得多。再加上合理的命名实践和对默认参数的明智使用,这些特性结合起来使得string
类比 C 库char
数组函数更容易使用。
追加、插入和连接字符串
C++ 字符串最有价值和最方便的方面之一是它们可以根据需要增长,而不需要程序员的干预。这不仅使字符串处理代码本身更值得信赖,而且几乎完全消除了繁琐的日常工作——跟踪字符串所在的存储范围。例如,如果创建一个 string 对象,用一个包含 50 个副本的字符串X对其进行初始化,然后在其中存储 50 个副本的“Zowie”,那么该对象本身将重新分配足够的存储空间来容纳数据的增长。也许没有什么地方比在代码中操作的字符串改变大小时更能体现这种特性,而您不知道这种改变有多大。字符串成员函数append()
和insert()
在字符串增长时透明地重新分配存储,如清单 18-6 中的所示。**
**清单 18-6 。示出了根据字符串大小的存储再分配
//: C18:StrSize.cpp
#include <string>
#include <iostream>
using namespace std;
int main() {
string bigNews("I saw Elvis in a UFO. ");
cout << bigNews << endl;
// How much data have we actually got?
cout << "Size = " << bigNews.size() << endl;
// How much can we store without reallocating?
cout << "Capacity = " << bigNews.capacity() << endl;
// Insert this string in bigNews immediately
// before bigNews[1]:
bigNews.insert(1, " thought I");
cout << bigNews << endl;
cout << "Size = " << bigNews.size() << endl;
cout << "Capacity = " << bigNews.capacity() << endl;
// Make sure that there will be this much space
bigNews.reserve(500);
// Add this to the end of the string:
bigNews.append("I've been working too hard.");
cout << bigNews << endl;
cout<< "Size = " << bigNews.size() << endl;
cout << "Capacity = " << bigNews.capacity() << endl;
} ///:∼
下面是一个特定编译器的输出:
I saw Elvis in a UFO.
Size = 22
Capacity = 31
I thought I saw Elvis in a UFO.
Size = 32
Capacity = 47
I thought I saw Elvis in a UFO. I've been
working too hard.
Size = 59
Capacity = 511
清单 18-6 展示了即使你可以安全地放弃分配和管理你的string
所占用的内存的大部分责任,C++ string
为你提供了几个工具来监控和管理它们的大小。请注意,更改分配给字符串的存储大小是多么容易。size()
函数返回当前存储在字符串中的字符数,与length()
成员函数相同。capacity()
函数返回当前底层分配的大小,即字符串在不请求更多存储空间的情况下可以容纳的字符数。reserve()
功能是一种优化机制,表明您打算指定一定量的存储空间以备将来使用;capacity()
总是返回一个至少与最近一次调用reserve()
一样大的值。如果新的大小大于当前的字符串大小,则resize()
函数会添加空格,否则会截断字符串。(resize()
的重载可以指定附加不同的字符。)
string
成员函数为数据分配空间的确切方式取决于库的实现。当测试来自清单 18-6 的代码的一个实现时,似乎在偶数字(即全整数)边界上发生了重新分配,保留了 1 个字节。string
类的设计者已经努力使混合使用 C char
数组和 C++ 字符串对象成为可能,因此StrSize.cpp
报告的容量数据很可能反映出,在这个特定的实现中,留出了一个字节以方便插入空终止符。
替换字符串字符
insert()
函数的 特别好,因为它免除了您确保在字符串中插入字符不会溢出存储空间或覆盖紧跟在插入点之后的字符的责任。空间变大了,现有的角色礼貌地移动以适应新的元素。有时候这可能不是你想要的。如果您希望字符串的大小保持不变,请使用replace()
函数来覆盖字符。有许多重载版本的replace()
,但是最简单的一个有三个参数:一个整数表示在字符串中从哪里开始,一个整数表示从原始字符串中删除多少个字符,以及替换字符串(可以是与删除数量不同的字符数)。一个简单的例子见清单 18-7 。
清单 18-7 。说明字符串字符的替换
//: C18:StringReplace.cpp
// Simple find-and-replace in strings.
#include <cassert>
#include <string>
using namespace std;
int main() {
string s("A piece of text");
string tag("$tag$");
s.insert(8, tag + ' ');
assert(s == "A piece $tag$ of text");
int start = s.find(tag);
assert(start == 8);
assert(tag.size() == 5);
s.replace(start, tag.size(), "hello there");
assert(s == "A piece hello there of text");
} ///:∼
首先将tag
插入到s
(注意插入发生在表示插入点的值之前的,并且在tag
之后增加了一个额外的空格),然后找到并替换它。
在执行replace()
之前,你应该检查一下是否有所发现。前面的例子用一个char*
替换,但是有一个重载版本用一个string
替换。清单 18-8 提供了对replace()
更完整的演示。
清单 18-8 。展示了更完整的 replace()演示
//: C18:Replace.cpp
#include <cassert>
#include <cstddef> // For size_t
#include <string>
using namespace std;
void replaceChars(string& modifyMe,
const string& findMe, const string& newChars) {
// Look in modifyMe for the "find string"
// starting at position 0:
size_t i = modifyMe.find(findMe, 0);
// Did we find the string to replace?
if(i != string::npos)
// Replace the find string with newChars:
modifyMe.replace(i, findMe.size(), newChars);
}
int main() {
string bigNews = "I thought I saw Elvis in a UFO. "
"I have been working too hard.";
string replacement("wig");
string findMe("UFO");
// Find "UFO" in bigNews and overwrite it:
replaceChars(bigNews, findMe, replacement);
assert(bigNews == "I thought I saw Elvis in a "
"wig. I have been working too hard.");
} ///:∼
如果replace
没有找到搜索字符串,它返回string::npos
。npos
数据成员是string
类的静态常量成员,表示不存在的字符位置。
与insert()
不同,replace()
不会增加string
的存储空间,如果你将新的字符复制到一个已存在的数组元素序列的中间。然而,如果需要的话,它会增加存储空间,例如,当你进行一次“替换”,将原来的字符串扩展到当前分配的末尾之外,如清单 18-9 中的所示。
清单 18-9 。说明字符串替换和增长
//: C18:ReplaceAndGrow.cpp
#include<cassert>
#include<string>
using namespace std;
int main() {
string bigNews("I have been working the grave.");
string replacement("yard shift.");
// The first argument says "replace chars
// beyond the end of the existing string":
bigNews.replace(bigNews.size() - 1,
replacement.size(), replacement);
assert(bigNews == "I have been working the "
"graveyard shift.");
} ///:∼
对replace()
的调用开始“替换”超出现有数组的末尾,这相当于一个追加操作。注意在清单 18-9 replace()
中相应地扩展了数组。
你可能已经在本章中尝试做一些相对简单的事情,比如用一个不同的字符替换一个字符的所有实例。在找到之前关于替换的材料时,您认为您找到了答案,但是随后您开始看到字符组、计数和其他看起来有点太复杂的东西。难道string
没有办法在任何地方用一个字符替换另一个字符吗?您可以使用find()
和replace()
成员函数轻松编写这样一个函数,如清单 18-10 中的所示。
清单 18-10 。说明 ReplaceAll
//: C18:ReplaceAll.h
#ifndef REPLACEALL_H
#define REPLACEALL_H
#include <string>
std::string& replaceAll(std::string& context,
const std::string& from, const std::string& to);
#endif // REPLACEALL_H ///:∼
//: C18:ReplaceAll.cpp {O}
#include <cstddef>
#include "ReplaceAll.h"// To be INCLUDED from Header FILE above
using namespace std;
string& replaceAll(string& context, const string& from,
const string& to) {
size_t lookHere = 0;
size_t foundHere;
while((foundHere = context.find(from, lookHere))
!= string::npos) {
context.replace(foundHere, from.size(), to);
lookHere = foundHere + to.size();
}
return context;
} ///:∼
这里使用的版本find()
将开始查找的位置作为第二个参数,如果没有找到,则返回string::npos
。将变量lookHere
中的位置提升到替换字符串之后是很重要的,在本例中from
是to
的子字符串。清单 18-11 测试replaceAll
功能。
清单 18-11 。在清单 18-10 中展示了 ReplaceAll 的测试
//: C18:ReplaceAllTest.cpp
//{L} ../C18/ReplaceAll
#include <cassert>
#include <iostream>
#include <string>
#include "ReplaceAll.h"
using namespace std;
int main() {
string text = "a man, a plan, a canal, Panama";
replaceAll(text, "an", "XXX");
assert(text == "a mXXX, a plXXX, a cXXXal, PXXXama");
} ///:∼
如您所见,string
类本身并不能解决所有可能的问题。许多解决方案都留给了标准 C++ 库中的算法,因为string
类看起来就像一个 STL 序列(依靠前面讨论的迭代器)。所有的通用算法都处理容器中“一系列”的元素。通常这个范围只是“从容器的开始到结束”一个string
对象看起来像一个字符容器:要获得范围的开始,使用string::begin()
,要获得范围的结束,使用string::end()
。
使用 STL replace()算法进行简单的字符替换
有没有更简单的方法,把一个字符到处换成另一个字符?是的,string
有它;清单 18-12 显示了使用replace()
算法将单个字符‘X’的所有实例替换为‘Y’。
清单 18-12 。说明字符串替换
//: C18:StringCharReplace.cpp
#include <algorithm>
#include <cassert>
#include <string>
using namespace std;
int main() {
string s("aaaXaaaXXaaXXXaXXXXaaa");
replace(s.begin(), s.end(), 'X', 'Y');
assert(s == "aaaYaaaYYaaYYYaYYYYaaa");
} ///:∼
注意这个replace()
是作为string
的成员函数调用的而不是。此外,与只执行一次替换的string::replace()
函数不同,replace()
算法将一个字符的所有实例替换为另一个字符。
replace()
算法仅适用于单个对象(在本例中为char
对象),不会替换引用的char
数组或string
对象。由于string
的行为类似于 STL 序列,许多其他算法可以应用于它,这可能会解决string
成员函数没有直接解决的其他问题。
使用非成员重载运算符的串联
等待 C 程序员学习 C++ string
处理的最令人愉快的发现之一是使用operator+
和operator+=
可以多么简单地组合和追加string
s。这些操作符使得组合string
在语法上类似于添加数字数据,如清单 18-13 所示。
清单 18-13 。说明字符串的添加
//: C18:AddStrings.cpp
#include <string>
#include <cassert>
using namespace std;
int main() {
string s1("This ");
string s2("That ");
string s3("The other ");
// operator+ concatenates strings
s1 = s1 + s2;
assert(s1 == "This That ");
// Another way to concatenates strings
s1 += s3;
assert(s1 == "This That The other ");
// You can index the string on the right
s1 += s3 + s3[4] + "ooh lama";
assert(s1 == "This That The other The other oooh lala");
} ///:∼
使用operator+
和operator+=
操作符是组合string
数据的一种灵活方便的方式。在语句的右侧,几乎可以使用任何计算结果为一个或多个字符组的类型。
在字符串中搜索
string
成员函数的find
系列定位给定字符串中的一个字符或一组字符。表 18-1 显示了find
家族的成员及其一般用法。
表 18-1 。通过查找字符串成员函数族进行搜索
字符串查找成员函数 | 它发现了什么/如何发现的 |
---|---|
find() |
在字符串中搜索指定的字符或一组字符,并返回找到的第一个匹配项的起始位置,如果没有找到匹配项,则返回npos 。 |
find_first_of() |
搜索目标字符串并返回指定组中第一个匹配的任意字符的位置。如果没有找到匹配,它返回npos 。 |
find_last_of() |
搜索目标字符串并返回指定组中任何字符的的最后一个匹配的位置。如果没有找到匹配,它返回npos 。 |
find_first_not_of() |
搜索目标字符串并返回指定组中第一个不匹配任何字符的元素的位置。如果没有找到这样的元素,它返回npos 。 |
find_last_not_of() |
搜索目标字符串,并返回指定组中与中的任何字符都不匹配的最大下标元素的位置。如果没有找到这样的元素,它返回npos 。 |
rfind() |
在字符串中从头到尾搜索指定的字符或字符组,如果找到匹配项,则返回匹配项的起始位置。如果没有找到匹配,它返回npos 。 |
find()
最简单的用法是在string
中搜索一个或多个字符。这个重载版本的find()
接受一个指定要搜索的字符的参数和一个告诉它从字符串中的什么地方开始搜索子字符串的可选参数。(开始搜索的默认位置是 0。)通过在一个循环中设置对find
的调用,您可以轻松地遍历一个字符串,重复搜索以查找字符串中给定字符或字符组的所有出现。
清单 18-14 使用厄拉多塞的筛子的方法寻找小于 50 的质数。该方法从数字 2 开始,将所有后续的 2 的倍数标记为非素数,并对下一个素数候选重复该过程。设置字符数组sieveChars
的初始大小,并将值‘P’写入其每个成员。
*清单 18-14 。图解厄拉多塞的筛子(求质数< 50)
//: C18:Sieve.cpp
#include <string>
#include <iostream>
using namespace std;
int main() {
// Create a 50 char string and set each
// element to 'P' for Prime
string sieveChars(50, 'P');
// By definition neither 0 nor 1 is prime.
// Change these elements to "N" for Not Prime
sieveChars.replace(0, 2, "NN");
// Walk through the array:
for(int i = 2;
i <= (sieveChars.size() / 2) - 1; i++)
// Find all the factors:
for(int factor = 2;
factor * i < sieveChars.size();factor++)
sieveChars[factor * i] = 'N';
cout << "Prime:" << endl;
// Return the index of the first 'P' element:
int j = sieveChars.find('P');
// While not at the end of the string:
while(j != sieveChars.npos) {
// If the element is P, the index is a prime
cout << j << " ";
// Move past the last prime
j++;
// Find the next prime
j = sieveChars.find('P', j);
}
cout << "\n Not prime:" << endl;
// Find the first element value not equal P:
j = sieveChars.find_first_not_of('P');
while(j != sieveChars.npos) {
cout << j << " ";
j++;
j = sieveChars.find_first_not_of('P', j);
}
} ///:∼
来自Sieve.cpp
的输出如下所示:
Prime:
2 3 5 7 11 13 17 19 23 29 31 37 41 43 47
Not prime:
0 1 4 6 8 9 10 12 14 15 16 18 20 21 22
24 25 26 27 28 30 32 33 34 35 36 38 39
40 42 44 45 46 48 49
find()
允许您在string
中前进,检测一个字符或一组字符的多次出现,而find_first_not_of()
允许您测试一个字符或一组字符的缺失。
find()
成员对于检测一个string
中一个字符序列的出现也很有用,如清单 18-15 所示。
清单 18-15 。使用 find()检测字符序列
//: C18:Find.cpp
// Find a group of characters in a string
#include <string>
#include <iostream>
using namespace std;
int main() {
string chooseOne("Eenie, meenie, miney, mo");
int i = chooseOne.find("een");
while(i != string::npos) {
cout << i << endl;
i++;
i = chooseOne.find("een", i);
}
} ///:∼
Find.cpp
产生单行输出:
8
这告诉我们,搜索组“een”的第一个“e”是在单词“meenie”中找到的,并且
这是字符串中的第八个元素。注意find
忽略了单词“Eenie”中的“Een”字符组。成员函数find
执行一个区分大小写的搜索。
在string
类中没有改变字符串大小写的函数,但是这些函数可以使用标准的 C 库函数toupper()
和tolower()
很容易地创建,它们一次改变一个字符的大小写。一些小的改变将使Find.cpp
执行不区分大小写的搜索,如清单 18-16 所示。
清单 18-16 。使用 find()进行不区分大小写的搜索
//: C18:NewFind.cpp
#include <string>
#include <iostream>
using namespace std;
// Make an uppercase copy of s:
string upperCase(string& s) {
char* buf = new char[s.length()];
s.copy(buf, s.length());
for(int i = 0; i < s.length(); i++)
buf[i] = toupper(buf[i]);
string r(buf, s.length());
delete buf;
return r;
}
// Make a lowercase copy of s:
string lowerCase(string& s) {
char* buf = new char[s.length()];
s.copy(buf, s.length());
for(int i = 0; i < s.length(); i++)
buf[i] = tolower(buf[i]);
string r(buf, s.length());
delete buf;
return r;
}
int main() {
string chooseOne("Eenie, meenie, miney, mo");
cout << chooseOne << endl;
cout << upperCase(chooseOne) << endl;
cout << lowerCase(chooseOne) << endl;
// Case sensitive search
int i = chooseOne.find("een");
while(i != string::npos) {
cout << i << endl;
i++;
i = chooseOne.find("een", i);
}
// Search lowercase:
string lcase = lowerCase(chooseOne);
cout << lcase << endl;
i = lcase.find("een");
while(i != lcase.npos) {
cout << i << endl;
i++;
i = lcase.find("een", i);
}
// Search uppercase:
string ucase = upperCase(chooseOne);
cout << ucase << endl;
i = ucase.find("EEN");
while(i != ucase.npos) {
cout << i << endl;
i++;
i = ucase.find("EEN", i);
}
} ///:∼
upperCase()
和lowerCase()
函数遵循相同的形式:它们分配存储来保存参数string
中的数据,复制数据,并改变大小写。然后他们用新数据创建一个新的string
,释放缓冲区,并返回结果string
。
因为c_str()
返回一个指向const
的指针,所以c_str()
函数不能用来产生一个指针来直接操作string
中的数据。也就是说,你不能用指针操作string
数据,只能用成员函数。如果你需要使用更原始的char
数组操作,你应该使用上面显示的技术(参见清单 18-16 )。
输出如下所示:
Eenie, meenie, miney, mo
EENIE, MEENIE, MINEY, MO
eenie, meenie, miney, mo
8
eenie, meenie, miney, mo
0
8
EENIE, MEENIE, MINEY, MO
0
8
不区分大小写的搜索在“een”组中找到了这两个事件。
Find.cpp
和NewFind.cpp
不是区分大小写问题的最佳解决方案,所以我们将在“字符串和字符特征”一节中再次讨论这个问题。
反向查找
如果你需要从头到尾搜索一个string
(以后进先出(LIFO)的顺序查找数据),你可以使用字符串成员函数rfind()
,如清单 18-17 所示。
清单 18-17 。使用 rfind()反向查找
//: C18:Rparse.cpp
// Reverse the order of words in a string
#include <string>
#include <iostream>
#include <vector>
using namespace std;
int main() {
// The ';' characters will be delimiters
string s("now.;sense;make;to;going;is;This");
cout << s << endl;
// To store the words:
vector<string> strings;
// The last element of the string:
int last = s.size();
// The beginning of the current word:
int current = s.rfind(';');
// Walk backward through the string:
while(current != string::npos){
// Push each word into the vector.
// Current is incremented before copying to
// avoid copying the delimiter.
strings.push_back(
s.substr(++current,last - current));
// Back over the delimiter we just found,
// and set last to the end of the next word
current -= 2;
last = current;
// Find the next delimiter
current = s.rfind(';', current);
}
// Pick up the first word - it's not
// preceded by a delimiter
strings.push_back(s.substr(0, last - current));
// Print them in the new order:
for(int j = 0; j < strings.size(); j++)
cout << strings[j] << " ";
} ///:∼
下面是清单 18-17 中的输出:
now.;sense;make;to;going;is;This
This is going to make sense now.
rfind()
返回字符串寻找记号,报告匹配字符的数组索引,如果不成功则返回string::npos
。
查找一组字符的第一个/最后一个
可以方便地将find_first_of()
和find_last_of()
成员函数 用于创建一个小实用程序,去除字符串两端的空白字符。注意,它并不接触原始字符串,而是返回一个新字符串,如清单 18-18 所示。
清单 18-18 。去除空白,也就是修剪字符串
//: C18:trim.h
#ifndef TRIM_H
#define TRIM_H
#include <string>
// General tool to strip spaces from both ends:
inline std::string trim(const std::string& s) {
if(s.length() == 0)
return s;
int b = s.find_first_not_of(" \t");
int e = s.find_last_not_of(" \t");
if(b == -1) // No non-spaces
return "";
return std::string(s, b, e - b + 1);
}
#endif // TRIM_H ///:∼
第一个测试检查空的string
;在这种情况下,不进行测试,并返回一个副本。
注意,一旦找到了端点,string
构造器用于从旧的构建新的string
,给出起始计数和长度。返回值也是“优化的”
测试这样一个通用工具需要彻底,正如你在清单 18-19 中看到的。
清单 18-19 。测试清单 18-18 中的“trim.h”
//: C18:TrimTest.cpp
#include "trim.h" // To be INCLUDED from Header FILE above
#include <iostream>
using namespace std;
string s[] = {
" \t abcdefghijklmnop \t ",
"abcdefghijklmnop \t ",
" \t abcdefghijklmnop",
"a", "ab", "abc", "a b c",
" \t a b c \t ", " \t a \t b \t c \t ",
"", // Must also test the empty string
};
void test(string s) {
cout << "[" << trim(s) << "]" << endl;
}
int main() {
for(int i = 0; i < sizeof s / sizeof *s; i++)
test(s[i]);
} ///:∼
在string s
的数组中,可以看到字符数组被自动转换为string
对象。这个数组提供了检查从两端删除空格和制表符的情况,以及确保空格和制表符不会从string
的中间删除。
从字符串中删除字符
使用erase()
成员函数删除字符简单而高效,该函数有两个参数:从哪里开始删除字符(默认为0
)以及删除多少字符(默认为string::npos
)。如果您指定的字符多于字符串中剩余的字符,剩余的字符将被删除(因此不带任何参数调用erase()
将删除字符串中的所有字符)。有时,获取一个 HTML 文件并去掉其标签和特殊字符是很有用的,这样您就可以得到一些类似于 web 浏览器中显示的文本的内容,只是作为一个纯文本文件。清单 18-20 使用erase()
来完成这项工作。
清单 18-20 。使用 erase()演示 HTML 剥离器
//: C18:HTMLStripper.cpp {RunByHand}
//{L} ../C18/ReplaceAll
// Filter to remove html tags and markers.
#include <cassert>
#include <cmath>
#include <cstddef>
#include <fstream>
#include <iostream>
#include <string>
#include "ReplaceAll.h" // SEE Above
#include "../require.h" // To be INCLUDED from *Chapter 9*
using namespace std;
string& stripHTMLTags(string& s) {
static bool inTag = false;
bool done = false;
while(!done) {
if(inTag) {
// The previous line started an HTML tag
// but didn't finish. Must search for '>'.
size_t rightPos = s.find('>');
if(rightPos != string::npos) {
inTag = false;
s.erase(0, rightPos + 1);
}
else {
done = true;
s.erase();
}
}
else {
// Look for start of tag:
size_t leftPos = s.find('<');
if(leftPos != string::npos) {
// See if tag close is in this line:
size_t rightPos = s.find('>');
if(rightPos == string::npos) {
inTag = done = true;
s.erase(leftPos);
}
else
s.erase(leftPos, rightPos - leftPos + 1);
}
else
done = true;
}
}
// Remove all special HTML characters
replaceAll(s, "<", "<");
replaceAll(s, ">", ">");
replaceAll(s, "&", "&");
replaceAll(s, " ", " ");
// Etc...
return s;
}
int main(int argc, char* argv[]) {
requireArgs(argc, 1,
"usage: HTMLStripper InputFile");
ifstream in(argv[1]);
assure(in, argv[1]);
string s;
while(getline(in, s))
if(!stripHTMLTags(s).empty())
cout << s << endl;
} ///:∼
这段代码甚至会去掉跨多行的 HTML 标签。这是通过静态标志inTag
来实现的,每当找到一个标签的开始,但是在同一行中没有找到伴随的标签结束时,该标志为true
。erase()
的所有形式都出现在stripHTMLTags()
函数中。这里使用的getline()
版本是一个在<string>
头文件中声明的(全局)函数,因为它在其string
参数中存储了一个任意长的行,所以非常方便。你不需要像使用istream::getline()
那样担心字符数组的维数。注意清单 18-20 中的使用了本章前面的replaceAll()
函数。在下一章中,您将使用字符串流创建一个更优雅的解决方案。
比较字符串
比较字符串本质上不同于比较数字。数字有恒定的、普遍有意义的值。要评估两个字符串的大小之间的关系,必须进行一个词法比较。词法比较意味着当您测试一个字符以查看它是“大于”还是“小于”另一个字符时,您实际上是在比较那些字符的数字表示,正如所使用的字符集的排序序列中所指定的那样。最常见的是 ASCII 排序序列,它为 32 到 127 十进制范围内的英语数字分配可打印字符。在 ASCII 排序序列中,列表中的第一个“字符”是空格,后面是几个常见的标点符号,然后是大写和小写字母。就字母表而言,这意味着靠近前面的字母比靠近末尾的字母具有更低的 ASCII 值。记住这些细节,就更容易记住当一个词汇比较报告s1
大于s2
时,它仅仅意味着当两者被比较时,s1
中第一个不同的字符在字母表中比s2
中相同位置的字符更晚。
C++ 提供了几种比较字符串的方法,每种方法都有优点。最容易使用的是非成员、重载的操作符函数:operator ==
、operator != operator >
、operator <
、operator >=
和operator <=
。参见清单 18-21 中的示例。
清单 18-21 。说明字符串的比较
//: C18:CompStr.cpp
#include <string>
#include <iostream>
using namespace std;
int main() {
// Strings to compare
string s1("This ");
string s2("That ");
for(int i = 0; i < s1.size() &&
i < s2.size(); i++)
// See if the string elements are the same:
if(s1[i] == s2[i])
cout << s1[i] << " " << i << endl;
// Use the string inequality operators
if(s1 != s2) {
cout << "Strings aren't the same:" << " ";
if(s1 > s2)
cout << "s1 is > s2" << endl;
else
cout << "s2 is > s1" << endl;
}
} ///:∼
下面是来自CompStr.cpp
的输出:
T 0
h 1
4
Strings aren't the same: s1 is > s2
重载比较运算符对于比较完整字符串和单个字符串字符元素都很有用。
注意在清单 18-22 中,比较运算符左右两边的参数类型都很灵活。为了提高效率,string
类提供了重载操作符,用于直接比较字符串对象、引用文字和指向 C 风格字符串的指针,而不必创建临时的string
对象。
清单 18-22 。说明字符串比较中的等价性
//: C18:Equivalence.cpp
#include <iostream>
#include <string>
using namespace std;
int main() {
string s2("That"), s1("This");
// The lvalue is a quoted literal
// and the rvalue is a string:
if("That" == s2)
cout << "A match" << endl;
// The left operand is a string and the right is
// a pointer to a C-style null terminated string:
if(s1 != s2.c_str())
cout << "No match" << endl;
} ///:∼
c_str()
函数返回一个const char*
,它指向一个 C 风格的空终止字符串,等价于string
对象的内容。当你想把一个字符串传递给一个标准的 C 函数,比如atoi()
或者任何在<cstring>
头中定义的函数时,这就很方便了。使用c_str()
返回的值作为任何函数的非const
参数都是错误的。
你不会在字符串的运算符中找到逻辑 not ( !
)或逻辑比较运算符(&&
和||
)。(你也找不到重载版本的逐位 C 操作符&
、|
、^
或∼
。)string 类的重载非成员比较运算符仅限于对单个字符或字符组有明确应用的子集。
与非成员操作符集相比,compare()
成员函数提供了更加复杂和精确的比较。它提供了重载版本进行比较
- 两个完整的字符串
- 任一字符串的一部分转换为完整的字符串
- 两个字符串的子集
清单 18-23 比较完整的字符串。
清单 18-23 。比较完整的字符串
//: C18:Compare.cpp
// Demonstrates compare() and swap().
#include <cassert>
#include <string>
using namespace std;
int main() {
string first("This");
string second("That");
assert(first.compare(first) == 0);
assert(second.compare(second) == 0);
// Which is lexically greater?
assert(first.compare(second) > 0);
assert(second.compare(first) < 0);
first.swap(second);
assert(first.compare(second) < 0);
assert(second.compare(first) > 0);
}
///:∼
清单 18-23 中的swap()
函数如其名所示:它交换其对象和参数的内容。要比较一个或两个字符串中的字符子集,可以添加参数来定义从哪里开始比较以及要考虑多少个字符。例如,您可以使用下面的compare()
重载版本:
s1.compare(s1StartPos, s1NumberChars, s2, s2StartPos,s2NumberChars);
参见清单 18-24 中的示例。
清单 18-24 。比较一个或两个字符串中的字符子集
//: C18:Compare2.cpp
// Illustrate overloaded compare().
#include <cassert>
#include <string>
using namespace std;
int main() {
string first("This is a day that will live in infamy");
string second("I don't believe that this is what "
"I signed up for");
// Compare "his is" in both strings:
assert(first.compare(1, 7, second, 22, 7) == 0);
// Compare "his is a" to "his is w":
assert(first.compare(1, 9, second, 22, 9) < 0);
} ///:∼
Indexing with [] vs. at()
在迄今为止的例子中,我使用了 C 风格的数组索引语法来引用字符串中的单个字符。C++ 字符串为s[n]
符号提供了另一种选择:成员at()
。如果一切顺利,这两种索引机制在 C++ 中产生相同的结果;参见清单 18-25 。
清单 18-25 。演示使用[]和 at()的字符串索引之间的相似性
//: C18:StringIndexing.cpp
#include <cassert>
#include <string>
using namespace std;
int main() {
string s("1234");
assert(s[1] == '2');
assert(s.at(1) == '2');
} ///:∼
然而,在[]
和at()
之间有一个重要的区别。当你试图引用一个越界的数组元素时,at()
会帮你抛出一个异常,而普通的[]
下标语法会让你自行处理,如清单 18-26 所示。
清单 18-26 。演示使用[]和 at()进行字符串索引的区别
//: C18:BadStringIndexing.cpp
#include <exception>
#include <iostream>
#include <string>
using namespace std;
int main() {
string s("1234");
// at() saves you by throwing an exception:
try {
s.at(5);
} catch(exception& e) {
cerr << e.what() << endl;
}
} ///:∼
负责任的程序员不会使用错误的索引,但是如果你想要自动索引检查的好处,使用at()
代替[]
将会给你一个机会从对不存在的数组元素的引用中优雅地恢复。在我们的一个测试编译器上执行清单 18-26 给出了以下输出:
invalid string position
成员at()
抛出了一个类out_of_range
的对象,这个类(最终)是从std::exception
派生出来的。通过在异常处理程序中捕获该对象,您可以采取适当的补救措施,如重新计算违规的下标或增大数组。使用string::operator[]()
无法提供这样的保护,并且与 c # 中的char
数组处理一样危险
字符串和字符特征
本章前面的程序Find.cpp
和NewFind.cpp
(分别为清单 18-15 和清单 18-16 )引导我提出一个明显的问题:为什么不区分大小写的比较不是标准string
类的一部分?答案提供了关于 C++ 字符串对象的真实性质的有趣背景。
考虑一下一个角色拥有“格”意味着什么书面希伯来语、波斯语和日本汉字不使用大写和小写的概念,所以对于这些语言来说,这个概念没有任何意义。似乎如果有一种方法可以将一些语言指定为“全大写”或“全小写”,我们就可以设计一个通用的解决方案。然而,一些使用“case”概念的语言也用变音符号改变特定字符的含义,例如西班牙语中的变音符号、法语中的抑扬符号和德语中的变音符号。由于这个原因,任何试图变得全面的区分大小写的排序方案使用起来都非常复杂。
虽然我们通常会把 C++ string
当做一个类来对待,但事实真的不是这样。string
类型是一个更一般的成分的专门化,即basic_string< >
模板。观察string
是如何在标准 C++ 头文件中声明的:
typedef basic_string<char> string;
要理解 string 类的本质,请看basic_string< >
模板:
template<class charT, class traits = char_traits<charT>,
class allocator = allocator<charT>> class basic_string;
现在,只需注意当用char
实例化basic_string
模板时,就创建了string
类型。在basic_string< >
模板声明里面,行
class traits = char_traits<charT>,
告诉你从basic_string< >
模板生成的类的行为是由基于模板char_traits< >
的类指定的。因此,basic_string< >
模板产生面向字符串的类,这些类操纵除了char
以外的类型(例如宽字符)。为此,char_traits< >
模板使用字符比较函数eq()
(等于)、ne()
(不等于)和lt()
(小于)来控制各种字符集的内容和排序行为。basic_string< >
字符串比较函数依赖于这些。
这就是为什么 string 类不包含不区分大小写的成员函数:这不在它的工作描述中。要改变字符串类处理字符比较的方式,您必须提供一个不同的char_traits< >
模板,因为它定义了单个字符比较成员函数的行为。
您可以使用这些信息创建一个新类型的忽略大小写的string
类。首先,您将定义一个新的不区分大小写的char_traits< >
模板,它继承自现有的模板。接下来,您将只覆盖需要更改的成员,以使逐字符比较不区分大小写。(除了前面提到的三个词汇字符比较成员、之外,您还将为char_traits
函数find()
、和、compare()
提供一个新的实现)。最后,您将typedef
一个基于basic_string
的新类,但是使用不区分大小写的ichar_traits
模板作为它的第二个参数,如清单 18-27 所示。
清单 18-27 。发展 ichar_traits
//: C18:ichar_traits.h
// Creating your own character traits.
#ifndef ICHAR_TRAITS_H
#define ICHAR_TRAITS_H
#include <cassert>
#include <cctype>
#include <cmath>
#include <cstddef>
#include <ostream>
#include <string>
using std::allocator;
using std::basic_string;
using std::char_traits;
using std::ostream;
using std::size_t;
using std::string;
using std::toupper;
using std::tolower;
struct ichar_traits : char_traits<char> {
// We'll only change character-by-
// character comparison functions
static bool eq(char c1st, char c2nd) {
return toupper(c1st) == toupper(c2nd);
}
static bool ne(char c1st, char c2nd) {
return !eq(c1st, c2nd);
}
static bool lt(char c1st, char c2nd) {
return toupper(c1st) < toupper(c2nd);
}
static int
compare(const char* str1, const char* str2, size_t n) {
for(size_t i = 0; i < n; ++i) {
if(str1 == 0)
return -1;
else if(str2 == 0)
return 1;
else if(tolower(*str1) < tolower(*str2))
return -1;
else if(tolower(*str1) > tolower(*str2))
return 1;
assert(tolower(*str1) == tolower(*str2));
++str1; ++str2; // Compare the other chars
}
return 0;
}
static const char*
find(const char* s1, size_t n, char c) {
while(n-- > 0)
if(toupper(*s1) == toupper(c))
return s1;
else
++s1;
return 0;
}
};
typedef basic_string<char, ichar_traits> istring;
inline ostream& operator<<(ostream& os, const istring& s) {
return os << string(s.c_str(), s.length());
}
#endif // ICHAR_TRAITS_H ///:∼
您提供了一个名为istring
的typedef
,这样您的类在各方面都像一个普通的string
,除了它会进行所有不考虑大小写的比较。为了方便起见,你还提供了一个重载的operator<<()
,这样你就可以打印istring
s。
清单 18-28 。实现清单 18-27 中的头文件
//: C18:ICompare.cpp
#include <cassert>
#include <iostream>
#include "ichar_traits.h" // To be INCLUDED from Header FILE
// above
using namespace std;
int main() {
// The same letters except for case:
istring first = "tHis";
istring second = "ThIS";
cout << first << endl;
cout << second << endl;
assert(first.compare(second) == 0);
assert(first.find('h') == 1);
assert(first.find('I') == 2);
assert(first.find('x') == string::npos);
} ///:∼
这只是一个玩具的例子。为了使istring
完全等同于string
,你必须创建其他必要的函数来支持新的istring
类型。
<string>
头通过下面的typedef
提供了一个宽字符串类:
typedef basic_string<wchar_t> wstring;
宽字符串支持也在宽流(wostream
代替ostream
,也在<iostream>
中定义)和头<cwctype>
(?? 的宽字符版本)中显示出来。这与标准 C++ 库中的char_traits
的wchar_t
特殊化一起,允许你做ichar_traits
的宽字符版本,如清单 18-29 所示。
清单 18-29 。开发 ichar_traits 的宽字符版本
//: C18:iwchar_traits.h {-g++}
// Creating your own wide-character traits.
#ifndef IWCHAR_TRAITS_H
#define IWCHAR_TRAITS_H
#include <cassert>
#include <cmath>
#include <cstddef>
#include <cwctype>
#include <ostream>
#include <string>
using std::allocator;
using std::basic_string;
using std::char_traits;
using std::size_t;
using std::towlower;
using std::towupper;
using std::wostream;
using std::wstring;
struct iwchar_traits : char_traits<wchar_t> {
// We'll only change character-by-
// character comparison functions
static bool eq(wchar_t c1st, wchar_t c2nd) {
return towupper(c1st) == towupper(c2nd);
}
static bool ne(wchar_t c1st, wchar_t c2nd) {
return towupper(c1st) != towupper(c2nd);
}
static bool lt(wchar_t c1st, wchar_t c2nd) {
return towupper(c1st) < towupper(c2nd);
}
static int compare(
const wchar_t* str1, const wchar_t* str2, size_t n) {
for(size_t i = 0; i < n; i++) {
if(str1 == 0)
return -1;
else if(str2 == 0)
return 1;
else if(towlower(*str1) < towlower(*str2))
return -1;
else if(towlower(*str1) > towlower(*str2))
return 1;
assert(towlower(*str1) == towlower(*str2));
++str1; ++str2; // Compare the other wchar_ts
}
return 0;
}
static const wchar_t*
find(const wchar_t* s1, size_t n, wchar_t c) {
while(n-- > 0)
if(towupper(*s1) == towupper(c))
return s1;
else
++s1;
return 0;
}
};
typedef basic_string<wchar_t, iwchar_traits> iwstring;
inline wostream& operator<<(wostream& os,
const iwstring& s) {
return os << wstring(s.c_str(), s.length());
}
#endif // IWCHAR_TRAITS_H ///:∼
如您所见,这主要是在源代码的适当位置放置一个“w”的练习。清单 18-30 包含了测试程序。
清单 18-30 。测试清单 18-29 中开发的头文件
//: C18:IWCompare.cpp {-g++}
#include <cassert>
#include <iostream>
#include "iwchar_traits.h" // To be INCLUDED from Header FILE
// above
using namespace std;
int main() {
// The same letters except for case:
iwstring wfirst = L"tHis";
iwstring wsecond = L"ThIS";
wcout << wfirst << endl;
wcout << wsecond << endl;
assert(wfirst.compare(wsecond) == 0);
assert(wfirst.find('h') == 1);
assert(wfirst.find('I') == 2);
assert(wfirst.find('x') == wstring::npos);
} ///:∼
不幸的是,一些编译器仍然不提供对宽字符的强大支持。
字符串应用程序
如果您仔细阅读了本书中的示例代码,您会注意到注释中的某些标记围绕着代码。Python 程序使用它们将代码提取到文件中,并为构建代码建立 makefiles。例如,行首的双斜杠后跟冒号表示源文件的第一行。该行的其余部分包含描述文件的名称和位置的信息,以及是否应该只编译而不是完全构建到可执行文件中。例如,清单 18-30 中的第一行包含字符串C18:IWCompare.cpp
,表示文件IWCompare.cpp
应该被提取到目录C18
中。
源文件的最后一行包含一个三重斜杠,后跟一个冒号和一个波浪号。如果第一行在冒号后有一个感叹号,则源代码的第一行和最后一行不会输出到文件中(这是针对纯数据文件的)。
注意如果你想知道为什么我避免向你展示这些标记,那是因为我不想破坏应用于书的文本的代码提取器
Python 程序不仅仅是提取代码。如果标记{O}
跟在文件名后面,那么它的 makefile 条目将只用于编译文件,而不用于链接到可执行文件。为了将这样的文件与另一个源示例链接起来,目标可执行文件的源文件将包含一个{L}
指令,如
//{L} ../TestSuite/Test
本节将展示清单 18-31 中的一个程序,提取所有代码,以便你可以手动编译和检查。您可以使用该程序通过将文档文件保存为文本文件(姑且称之为MFCTC++.txt
)并在 shell 命令行上执行如下内容来提取本书中的所有代码:
C:> extractCode MFCTC++.txt /TheCode
该命令读取文本文件MFCTC2.txt
,并将所有源代码文件写入顶层目录/TheCode
下的子目录中。目录树将如下所示:
TheCode/
C0B/
C01/
C02/
C18/
C04/
C05/
C06/
C07/
C08/
C09/
C10/
C11/
TestSuite/
包含每章示例的源文件将位于相应的目录中。
清单 18-31 。说明书中所有源代码的提取
//: C18:ExtractCode.cpp {-edg} {RunByHand}
// Extracts code from text.
#include <cassert>
#include <cstddef>
#include <cstdio>
#include <cstdlib>
#include <fstream>
#include <iostream>
#include <string>
using namespace std;
// Legacy non-standard C header for mkdir()
#if defined(__GNUC__) || defined(__MWERKS__)
#include <sys/stat.h>
#elif defined(__BORLANDC__) || defined(_MSC_VER) \
|| defined(__DMC__)
#include <direct.h>
#else
#error Compiler not supported
#endif
// Check to see if directory exists
// by attempting to open a new file
// for output within it.
bool exists(string fname) {
size_t len = fname.length();
if(fname[len-1] != '/' && fname[len-1] != '\\')
fname.append("/");
fname.append("000.tmp");
ofstream outf(fname.c_str());
bool existFlag = outf;
if(outf) {
outf.close();
remove(fname.c_str());
}
return existFlag;
}
int main(int argc, char* argv[]) {
// See if input file name provided
if(argc == 1) {
cerr << "usage: extractCode file [dir]" << endl;
exit(EXIT_FAILURE);
}
// See if input file exists
ifstream inf(argv[1]);
if(!inf) {
cerr << "error opening file: " << argv[1] << endl;
exit(EXIT_FAILURE);
}
// Check for optional output directory
string root("./"); // current is default
if(argc == 3) {
// See if output directory exists
root = argv[2];
if(!exists(root)) {
cerr << "no such directory: " << root << endl;
exit(EXIT_FAILURE);
}
size_t rootLen = root.length();
if(root[rootLen-1] != '/' && root[rootLen-1] != '\\')
root.append("/");
}
// Read input file line by line
// checking for code delimiters
string line;
bool inCode = false;
bool printDelims = true;
ofstream outf;
while(getline(inf, line)) {
size_t findDelim = line.find("//" "/:∼");
if(findDelim != string::npos) {
// Output last line and close file
if(!inCode) {
cerr << "Lines out of order" << endl;
exit(EXIT_FAILURE);
}
assert(outf);
if(printDelims)
outf << line << endl;
outf.close();
inCode = false;
printDelims = true;
} else {
findDelim = line.find("//" ":");
if(findDelim == 0) {
// Check for '!' directive
if(line[3] == '!') {
printDelims = false;
++findDelim; // To skip '!' for next search
}
// Extract subdirectory name, if any
size_t startOfSubdir =
line.find_first_not_of(" \t", findDelim+3);
findDelim = line.find(':', startOfSubdir);
if(findDelim == string::npos) {
cerr << "missing filename information\n" << endl;
exit(EXIT_FAILURE);
}
string subdir;
if(findDelim > startOfSubdir)
subdir = line.substr(startOfSubdir,
findDelim - startOfSubdir);
// Extract file name (better be one!)
size_t startOfFile = findDelim + 1;
size_t endOfFile =
line.find_first_of(" \t", startOfFile);
if(endOfFile == startOfFile) {
cerr << "missing filename" << endl;
exit(EXIT_FAILURE);
}
// We have all the pieces; build fullPath name
string fullPath(root);
if(subdir.length() > 0)
fullPath.append(subdir).append("/");
assert(fullPath[fullPath.length()-1] == '/');
if(!exists(fullPath))
#if defined(__GNUC__) || defined(__MWERKS__)
mkdir(fullPath.c_str(), 0); // Create subdir
#else
mkdir(fullPath.c_str()); // Create subdir
#endif
fullPath.append(line.substr(startOfFile,
endOfFile - startOfFile));
outf.open(fullPath.c_str());
if(!outf) {
cerr << "error opening " << fullPath
<< " for output" << endl;
exit(EXIT_FAILURE);
}
inCode = true;
cout << "Processing " << fullPath << endl;
if(printDelims)
outf << line << endl;
}
else if(inCode) {
assert(outf);
outf << line << endl; // Output middle code line
}
}
}
exit(EXIT_SUCCESS);
} ///:∼
首先,您会注意到一些条件编译指令。在文件系统中创建目录的mkdir()
函数是由 POSIX 标准在头文件<sys/stat.h>
中定义的。不幸的是,许多编译器仍然使用不同的头,<direct.h>
。mkdir()
各自的签名也不同:POSIX 指定了两个参数,旧版本只有一个。由于这个原因,在程序的后面有更多的条件编译来选择对mkdir()
的正确调用。在本书的例子中,我通常不使用条件编译,但是这个特殊的程序非常有用,不能不做一些额外的工作,因为你可以用它来提取所有的代码。
清单 18-31 中ExtractCode.cpp
的exists()
函数通过打开一个临时文件来测试一个目录是否存在。如果打开失败,则该目录不存在。你可以通过将文件名作为char*
发送给std::remove()
来删除一个文件。
主程序验证命令行参数,然后一次读取输入文件的一行,寻找特殊的源代码分隔符。布尔标志inCode
表示程序在源文件的中间,所以应该输出几行。如果开始标记后面没有感叹号,printDelims
标志将为真;否则不写第一行和最后一行。首先检查结束分隔符是很重要的,因为开始标记是一个子集,首先搜索开始标记在两种情况下都会返回成功的查找结果。如果您遇到结束标记,您将验证您正在处理一个源文件;否则,文本文件中分隔符的布局方式就有问题。如果inCode
为真,则一切正常,您(可选)写下最后一行并关闭文件。找到开始标记后,解析目录和文件名部分并打开文件。本例中使用了以下与string
相关的函数:length()
、append()
、getline()
、find()
( 两个版本)、find_first_not_of()
、substr()
、find_first_of()
、c_str()
,当然还有operator<<()
。
审查会议
- C++ 字符串对象为开发人员提供了比他们的 C 同行更多的优势。在很大程度上,字符串类使得用字符指针引用字符串的变得没有必要。这个消除了一整类软件缺陷,这些缺陷是由于使用未初始化的和不正确赋值的指针而产生的。
- C++ 字符串动态透明地增加其内部数据存储空间,以适应字符串数据大小的增加。当字符串中的数据增长超过最初分配给它的内存限制时,字符串对象将进行内存管理调用,从堆中获取空间并返回空间。
- 一致的分配方案可以防止内存泄漏,并且有可能比“滚动自己的”内存管理更加高效。
- string 类成员函数为创建、修改和搜索字符串提供了一套相当全面的工具。
- 字符串比较总是区分大小写的,但是您可以通过将字符串数据复制到 C 风格的空终止字符串并使用不区分大小写的字符串比较函数,将字符串对象中保存的数据临时转换为单个大小写,或者通过创建不区分大小写的字符串类来覆盖用于创建
basic_string object
的字符特征,从而解决这个问题。****
十九、iostreams
对于一般的 I/O 问题,你可以做更多的事情,而不仅仅是把标准的 I/O 变成一个类。
如果您可以让所有常见的“容器”——标准 I/O、文件甚至内存块——看起来都一样,以便您只需要记住一个接口,这不是很好吗?这就是iostreams
背后的想法。它们比标准 C 库stdio
中的各种函数更容易、更安全,有时甚至更高效。
iostreams
类通常是新 C++ 程序员学习使用的 C++ 库的第一部分。这一章讨论了iostreams
是如何改进 C 的stdio
功能的,并探讨了除标准控制台流之外的文件和字符串流的行为。
为什么是 iostreams?
您可能想知道优秀的旧 C 库有什么问题。为什么不把 C 库“包装”在一个类中就完事了呢?有时这是一个很好的解决方案。例如,假设您想确保由一个stdio FILE
指针表示的文件总是安全地打开和正确地关闭,而不必依赖用户记住调用close()
函数。清单 19-1 展示了这样一个尝试。
清单 19-1 。包装 stdio 文件类
//: C19:FileClass.h
// stdio files wrapped.
#ifndef FILECLASS_H
#define FILECLASS_H
#include <cstdio>
#include <stdexcept>
class FileClass {
std::FILE* f;
public:
struct FileClassError : std::runtime_error {
FileClassError(const char* msg)
: std::runtime_error(msg) {}
};
FileClass(const char* fname, const char* mode = "r");
∼FileClass();
std::FILE* fp();
};
#endif // FILECLASS_H ///:∼
当您在 C 中执行文件 I/O 时,您使用的是一个指向文件struct
的裸指针,但是这个类包装了指针,并保证使用构造器和析构函数对其进行正确的初始化和清理。第二个构造器参数是文件模式,默认为“r”表示“读取”
要获取在文件 I/O 函数中使用的指针值,可以使用fp()
访问函数。清单 19-2 包含了成员函数定义。
清单 19-2 。实现清单 19-1 中的头文件
//: C19:FileClass.cpp {O}
// FileClass Implementation.
#include "FileClass.h" // To be INCLUDED from Header FILE above
#include <cstdlib>
#include <cstdio>
using namespace std;
FileClass::FileClass(const char* fname, const char* mode) {
if((f = fopen(fname, mode)) == 0)
throw FileClassError("Error opening file");
}
FileClass::∼FileClass() { fclose(f); }
FILE* FileClass::fp() { return f; } ///:∼
构造器像平常一样调用fopen()
,但是它也确保结果不为零,这表示打开文件失败。如果文件没有按预期打开,将引发异常。
析构函数关闭文件,访问函数fp()
返回f
。关于使用FileClass
的简单例子,见清单 19-3 。
清单 19-3 。测试清单 19-2 中的实现
//: C19:FileClassTest.cpp
//{L} FileClass
#include <cstdlib>
#include <iostream>
#include "FileClass.h"
using namespace std;
int main() {
try {
FileClass f("FileClassTest.cpp");
const int BSIZE = 100;
char buf[BSIZE];
while(fgets(buf, BSIZE, f.fp()))
fputs(buf, stdout);
} catch(FileClass::FileClassError& e) {
cout << e.what() << endl;
return EXIT_FAILURE;
}
return EXIT_SUCCESS;
} // File automatically closed by destructor
///:∼
您创建了FileClass
对象,并通过调用fp()
在普通的 C 文件 I/O 函数调用中使用它。当你用完后,就把它忘掉;文件在其作用域的末尾被析构函数关闭。
尽管FILE
指针是私有的,但它并不特别安全,因为fp()
会检索它。既然唯一的效果似乎是保证初始化和清理,为什么不将其公开或者使用一个struct
来代替呢?请注意,虽然您可以使用fp()
获得f
的副本,但是您不能将其分配给f
——这完全由类控制。在捕获了fp()
返回的指针后,客户端程序员仍然可以给结构元素赋值,甚至关闭它,所以安全性在于保证一个有效的FILE
指针,而不是结构的正确内容。
如果你想要完全安全,你必须阻止用户直接访问FILE
指针,如清单 19-4 所示。所有普通文件 I/O 函数的某个版本必须作为类成员出现,这样你用 C 方法做的一切都可以在 C++ 类中得到。
清单 19-4 。在 C++ 中隐藏文件 I/O
//: C19:Fullwrap.h
// Completely hidden file I/O.
#ifndef FULLWRAP_H
#define FULLWRAP_H
#include <cstddef>
#include <cstdio>
#undef getc
#undef putc
#undef ungetc
using std::size_t;
using std::fpos_t;
class File {
std::FILE* f;
std::FILE* F(); // Produces checked pointer to f
public:
File(); // Create object but don't open file
File(const char* path, const char* mode = "r");
∼File();
int open(const char* path, const char* mode = "r");
int reopen(const char* path, const char* mode);
int getc();
int ungetc(int c);
int putc(int c);
int puts(const char* s);
char* gets(char* s, int n);
int printf(const char* format, ...);
size_t read(void* ptr, size_t size, size_t n);
size_t write(const void* ptr, size_t size, size_t n);
int eof();
int close();
int flush();
int seek(long offset, int whence);
int getpos(fpos_t* pos);
int setpos(const fpos_t* pos);
long tell();
void rewind();
void setbuf(char* buf);
int setvbuf(char* buf, int type, size_t sz);
int error();
void clearErr();
};
#endif // FULLWRAP_H ///:∼
这个类包含了几乎所有来自<cstdio>
的文件 I/O 函数。注意vfprintf()
不见了;它实现了printf()
成员功能。
File
具有与清单 19-3 中的相同的构造器,并且它也有一个默认的构造器。如果你想创建一个File
对象的数组或者使用一个File
对象作为另一个类的成员,默认的构造器是很重要的,因为初始化不是在构造器中发生的,而是在封闭对象被创建之后的某个时候。
默认构造器将私有FILE
指针f
设置为零。但是现在,在引用f
之前,必须检查它的值以确保它不为零。这是通过F()
完成的,它是private
,因为它只供其他成员函数使用。(您不想让用户直接访问这个类中的底层FILE
结构。)
这种方法无论如何都不是一个糟糕的解决方案。它非常实用,您可以想象为标准(控制台)I/O 和内核格式化(读/写一块内存而不是文件或控制台)创建类似的类。
绊脚石是用于变量参数列表函数的运行时解释器。这是在运行时解析你的格式字符串并从变量参数列表中获取和解释参数的代码。这是一个问题,原因有四。
- 即使你只使用了解释器的一小部分功能,全部内容都会被加载到你的可执行文件中。所以如果你说
printf("%c", 'x');
,你会得到整个包,包括打印浮点数和字符串的部分。没有减少程序占用空间的标准选项。 - 因为解释是在运行时进行的,所以无法消除性能开销。这很令人沮丧,因为所有的信息在编译时都在格式字符串中,但是直到运行时才会被计算。然而,如果您可以在编译时解析格式字符串中的参数,您就可以直接调用函数,这有可能比运行时解释器快得多(尽管
printf()
函数族通常已经过很好的优化)。 - 因为直到运行时才计算格式字符串,所以不可能有编译时错误检查。如果您曾试图寻找因在
printf()
语句中使用错误的参数数量或类型而导致的错误,您可能对这个问题很熟悉。C++ 非常重视编译时错误检查,以便尽早发现错误,让您的生活更轻松。抛弃 I/O 库的类型安全似乎是一种耻辱,尤其是在 I/O 被大量使用的情况下。 - 对于 C++,最关键的问题是
printf()
函数族不是特别具有可扩展性。它们实际上被设计成只处理 C 语言中的基本数据类型(char
、int
、float
、double
、wchar_t
、char*
、wchar_t*
和void*)
以及它们的变体。您可能认为每次添加一个新类时,您都可以添加重载的printf()
和scanf()
函数(以及它们的文件和字符串变体),但是请记住,重载函数必须在它们的参数列表中有不同的类型,并且printf()
系列在格式字符串和变量参数列表中隐藏了它的类型信息。对于像 C++ 这样的语言来说,其目标是能够容易地添加新的数据类型,这是一个不可接受的限制。
拯救之流
这些问题清楚地表明 I/O 是标准 C++ 类库的首要任务之一。因为“Hello,World”是几乎每个人都用新语言编写的第一个程序,并且因为 I/O 是几乎每个程序的一部分,所以 C++ 中的 I/O 库必须特别容易使用。它还面临着更大的挑战,即它必须适应任何新的类别。因此,它的约束要求这个基础类库是一个真正有灵感的设计。除了在处理 I/O 和格式化时获得大量的杠杆作用和清晰性,你还将在本章中看到一个真正强大的 C++ 库是如何工作的。
此时,我们在清单 19-5 的中引入了一些日期类文件。
清单 19-5 。日期类文件
//: C19:Date.h
*#ifndef DATE_H*
*#define DATE_H*
*#include <string>*
*#include <stdexcept>*
*#include <iosfwd>*
class Date {
int year, month, day;
int compare(const Date&) const;
static int daysInPrevMonth(int year, int mon);
public:
*// A class for date calculations*
struct Duration {
int years, months, days;
Duration(int y, int m, int d)
: years(y), months(m) ,days(d) {}
};
*// An exception class*
struct DateError : public std::logic_error {
DateError(const std::string& msg = "")
: std::logic_error(msg) {}
};
Date();
Date(int, int, int) throw(DateError);
Date(const std::string&) throw(DateError);
int getYear() const;
int getMonth() const;
int getDay() const;
std::string toString() const;
friend Duration duration(const Date&, const Date&);
friend bool operator<(const Date&, const Date&);
friend bool operator<=(const Date&, const Date&);
friend bool operator>(const Date&, const Date&);
friend bool operator>=(const Date&, const Date&);
friend bool operator==(const Date&, const Date&);
friend bool operator!=(const Date&, const Date&);
friend std::ostream& operator<<(std::ostream&,
const Date&);
friend std::istream& operator>>(std::istream&, Date&);
};
#endif *// DATE_H ///:∼*
*//:*
C19:Date.cpp {O}
*#include "Date.h" // To be INCLUDED from Header FILE above*
*#include <iostream>*
*#include <sstream>*
*#include <cstdlib>*
*#include <string>*
*#include <algorithm> // For swap()*
*#include <ctime>*
*#include <cassert>*
*#include <iomanip>*
*using namespace std;*
namespace {
const int daysInMonth[][13] = {
{ 0, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31 },
{ 0, 31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31 }
};
inline bool isleap(int y) {
return y%4 == 0 && y%100 != 0 || y%400 == 0;
}
}
Date::Date() {
*// Get current date*
time_t tval = time(0);
struct tm *now = localtime(&tval);
year = now->tm_year + 1900;
month = now->tm_mon + 1;
day = now->tm_mday;
}
Date::Date(int yr,int mon,int dy) throw(Date::DateError) {
if(!(1 <= mon && mon <= 12))
throw DateError("Bad month in Date ");
if(!(1 <= dy && dy <= daysInMonth[isleap(year)][mon]))
throw DateError("Bad day in Date ");
year = yr;
month = mon;
day = dy;
}
Date::Date(const std::string& s) throw(Date::DateError) {
*// Assume YYYYMMDD format*
if(!(s.size() == 8))
throw DateError("Bad string in Date ");
for(int n = 8; --n >= 0;)
if(!isdigit(s[n]))
throw DateError("Bad string in Date ");
string buf = s.substr(0, 4);
year = atoi(buf.c_str());
buf = s.substr(4, 2);
month = atoi(buf.c_str());
buf = s.substr(6, 2);
day = atoi(buf.c_str());
if(!(1 <= month && month <= 12))
throw DateError("Bad month in Date ");
if(!(1 <= day && day <=
daysInMonth[isleap(year)][month]))
throw DateError("Bad day in Date ");
}
int Date::getYear() const { return year; }
int Date::getMonth() const { return month; }
int Date::getDay() const { return day; }
string Date::toString() const {
ostringstream os;
os.fill('0');
os << setw(4) << year
<< setw(2) << month
<< setw(2) << day;
return os.str();
}
int Date::compare(const Date& d2) const {
int result = year - d2.year;
if(result == 0) {
result = month - d2.month;
if(result == 0)
result = day - d2.day;
}
return result;
}
int Date::daysInPrevMonth(int year, int month) {
if(month == 1) {
--year;
month = 12;
}
else
--month;
return daysInMonth[isleap(year)][month];
}
bool operator<(const Date& d1, const Date& d2) {
return d1.compare(d2) < 0;
}
bool operator<=(const Date& d1, const Date& d2) {
return d1 < d2 || d1 == d2;
}
bool operator>(const Date& d1, const Date& d2) {
return !(d1 < d2) && !(d1 == d2);
}
bool operator>=(const Date& d1, const Date& d2) {
return !(d1 < d2);
}
bool operator==(const Date& d1, const Date& d2) {
return d1.compare(d2) == 0;
}
bool operator!=(const Date& d1, const Date& d2) {
return !(d1 == d2);
}
Date::Duration
duration(const Date& date1, const Date& date2) {
int y1 = date1.year;
int y2 = date2.year;
int m1 = date1.month;
int m2 = date2.month;
int d1 = date1.day;
int d2 = date2.day;
*// Compute the compare*
int order = date1.compare(date2);
if(order == 0)
return Date::Duration(0,0,0);
else if(order > 0) {
*// Make date1 precede date2 locally*
using std::swap;
swap(y1, y2);
swap(m1, m2);
swap(d1, d2);
}
int years = y2 - y1;
int months = m2 - m1;
int days = d2 - d1;
assert(years > 0 ||
years == 0 && months > 0 ||
years == 0 && months == 0 && days > 0);
*// Do the obvious corrections (must adjust days before months!)*
*// This is a loop in case the previous month is February, and days < -28.*
int lastMonth = m2;
int lastYear = y2;
while(days < 0) {
*// Borrow from month*
assert(months > 0);
days += Date::daysInPrevMonth(
lastYear, lastMonth--);
--months;
}
if(months < 0) {
*// Borrow from year*
assert(years > 0);
months += 12;
--years;
}
return Date::Duration(years, months, days);
}
ostream& operator<<(ostream& os, const Date& d) {
char fillc = os.fill('0');
os << setw(2) << d.getMonth() << '-'
<< setw(2) << d.getDay() << '-'
<< setw(4) << setfill(fillc) << d.getYear();
return os;
}
istream& operator>>(istream& is, Date& d) {
is >> d.month;
char dash;
is >> dash;
if(dash != '-')
is.setstate(ios::failbit);
is >> d.day;
is >> dash;
if(dash != '-')
is.setstate(ios::failbit);
is >> d.year;
return is;
} *///:∼*
插入器和提取器
流是传输和格式化固定宽度字符的对象。你可以拥有一个输入流(通过istream
类的后代),一个输出流(带有ostream
对象),或者同时拥有两者的流(带有从iostream
派生的对象)。iostreams
库提供了不同类型的类:用于文件的ifstream
、ofstream
和fstream
,以及用于与标准 C++ string
类接口的istringstream
、ostringstream
和stringstream
。所有这些流类都有几乎相同的接口,因此您可以以统一的方式使用流,无论您是处理文件、标准 I/O、内存区域还是string
对象。您学习的单一接口也适用于为支持新类而添加的扩展。有些函数实现您的格式化命令,有些函数在没有格式化的情况下读写字符。
前面提到的流类实际上是模板专门化,很像标准的string
类是basic_string
模板的专门化。iostreams
继承层次中的基本类如图图 19-1 所示。
图 19-1 。显示 IOSTREAM 继承层次结构中的基本类
ios_base
类声明所有流共有的一切,与流处理的字符类型无关。这些声明大部分是常量和管理它们的函数,其中一些你会在本章看到。其余的类是以底层字符类型作为参数的模板。例如,istream
类定义如下:
typedef basic_istream<char> istream;
前面提到的所有类都是通过类似的类型定义来定义的。还有使用wchar_t
(宽字符类型)代替char
的所有流类的类型定义。你会在本章末尾看到这些。basic_ios
模板定义了输入和输出通用的函数,但这取决于底层的字符类型(你不会经常用到)。模板basic_istream
为输入定义了通用函数,basic_ostream
为输出做了同样的事情。稍后介绍的文件和字符串流类为它们特定的流类型添加了功能。
在iostreams
库中,两个操作符被重载以简化 iostreams 的使用。操作员<<
通常被称为 iostreams 的插入器,操作员>>
通常被称为提取器。
提取器根据目的对象的类型解析它所期望的信息。要查看这方面的示例,您可以使用cin
对象,它是 C 语言中stdin
的 iostream 等价物,即可重定向的标准输入。每当包含<iostream>
标题时,这个对象都是预定义的,比如:
int i;
cin >> i;
float f;
cin >> f;
char c;
cin >> c;
char buf[100];
cin >> buf;
每个内置数据类型都有一个重载的operator>>
。你也可以重载你自己的,稍后你会看到。
为了找出你在各种变量中有什么,你可以使用cout
对象(对应标准输出;还有一个cerr
对象对应于标准误差)和插入器<<
,如:
cout << "i = ";
cout << i;
cout << "\n";
cout << "f = ";
cout << f;
cout << "\n";
cout << "c = ";
cout << c;
cout << "\n";
cout << "buf = ";
cout << buf;
cout << "\n";
尽管类型检查有所改进,但这很乏味,看起来并不比printf()
有多大改进。幸运的是,重载的插入器和提取器被设计成链接成一个更复杂的表达式,更容易写 ( 读):
cout << "i = " << i << endl;
cout << "f = " << f << endl;
cout << "c = " << c << endl;
cout << "buf = " << buf << endl;
为你自己的类定义插入器和提取器只是重载相关的操作符来做正确的事情,即:
- 使第一个参数成为流的非
const
引用(istream
表示输入,ostream
表示输出)。 - 通过向/从流中插入/提取数据来执行操作(通过处理对象的组件)。
- 返回对流的引用。
流应该是非const
的,因为处理流数据会改变流的状态。通过返回流,可以在一条语句中链接流操作,如前所示。
例如,考虑如何以 MM-DD-YYYY 格式输出一个Date
对象的表示。以下插入器完成工作:
ostream& operator<<(ostream& os, const Date& d) {
char fillc = os.fill('0');
os << setw(2) << d.getMonth() << '-'
<< setw(2) << d.getDay() << '-'
<< setw(4) << setfill(fillc) << d.getYear();
return os;
}
此函数不能是Date
类的成员,因为<<
运算符的左操作数必须是输出流。ostream
的fill()
成员函数改变由操纵器 setw()
决定的输出字段宽度大于数据所需宽度时使用的填充字符。您使用“0”字符,这样十月之前的月份将显示前导零,例如“09”表示九月。fill()
函数还返回前一个填充字符(默认为一个空格),以便您稍后可以使用操纵器setfill()
恢复它。我将在本章后面深入讨论操纵器。
提取器需要多一点小心,因为输入数据可能会出错。通知流错误的方法是设置流的失败位,如下所示:
istream& operator>>(istream& is, Date& d) {
is >> d.month;
char dash;
is >> dash;
if(dash != '-')
is.setstate(ios::failbit);
is >> d.day;
is >> dash;
if(dash != '-')
is.setstate(ios::failbit);
is >> d.year;
return is;
}
当流中的错误位被置位时,所有进一步的流操作都被忽略,直到流恢复到良好状态(稍后解释)。这就是为什么即使ios::failbit
被设置,上面的代码也继续提取。这个实现有些宽容,因为它允许日期字符串中的数字和破折号之间有空白(因为在读取内置类型时,>>
操作符默认跳过空白)。
以下是此提取器的有效日期字符串:
"08-10-2003"
"8-10-2003"
"08 - 10 - 2003"
但这些不是:
"A-10-2003" // No alpha characters allowed
"08%10/2003" // Only dashes allowed as a delimiter
我将在本章后面的“处理流错误”一节中更深入地讨论流状态。
常见用法
正如Date
提取器所示,您必须警惕错误的输入。如果输入产生了一个意外的值,那么这个过程就是偏斜的,并且很难恢复。此外,格式化输入默认为空白分隔符。考虑一下当你把本章前面的代码片段收集到一个程序中会发生什么,如清单 19-6 所示。
清单 19-6 。阐释 iostream 示例
//: C19:Iosexamp.cpp {RunByHand}
// iostream examples.
#include <iostream>
using namespace std;
int main() {
int i;
cin >> i;
float f;
cin >> f;
char c;
cin >> c;
char buf[100];
cin >> buf;
cout << "i = " << i << endl;
cout << "f = " << f << endl;
cout << "c = " << c << endl;
cout << "buf = " << buf << endl;
cout << flush;
cout << hex << "0x" << i << endl;
} ///:∼
让我们给它以下输入:
12 1.4 c this is a test
我们期待同样的输出,就好像我们给了它一样
12
1.4
c
this is a test
有点出乎意料的是,
i = 12
f = 1.4
c = c
buf = this
0xc
注意,buf
只得到第一个单词,因为输入例程寻找一个空格来分隔输入,它在“this”之后看到了此外,如果连续的输入字符串比分配给buf
的存储空间长,就会使缓冲区溢出。
实际上,您通常希望从交互式程序中以字符序列的形式一次一行地获取输入,扫描它们,然后在它们安全地进入缓冲区时执行转换。这样你就不需要担心输入程序会被意外的数据阻塞。
另一个考虑是命令行界面的整体概念。在过去,当控制台比玻璃打字机好不了多少时,这是有意义的,但世界正在迅速改变,图形用户界面(GUI)占据主导地位。在这样的世界里,控制台 I/O 的意义是什么?除了简单的例子或测试,完全忽略cin
更有意义,并采取以下方法。
- 如果你的程序需要输入,从一个文件中读取输入——你很快就会发现用
iostreams
使用文件非常容易。iostreams
对于文件来说,在 GUI 中仍然可以很好地工作。 - 正如刚才建议的那样,读取输入,不要试图转换它。当输入在转换过程中不会出错时,您可以安全地扫描它。
- 产量不一样。如果您使用 GUI,
cout
不一定有效,您必须将它发送到一个文件(这与发送到cout
是一样的)或使用 GUI 工具来显示数据。否则,把它送给cout
通常是有意义的。在这两种情况下,iostreams 的输出格式化功能都非常有用。
另一种常见的做法是节省大型项目的编译时间。例如,考虑如何在头文件中声明本章前面介绍的Date
流操作符。您只需要包含函数的原型,所以没有必要在Date.h
中包含整个<iostream>
头。标准的做法是只声明类,就像这样:
class ostream;
这是一种将接口与实现分离的古老技术,通常被称为前向声明(此时ostream
将被视为不完整类型,因为编译器尚未看到类定义)。
然而,由于两个原因,这样做是行不通的。
- 流类在
std
名称空间中定义。 - 它们是模板。
正确的声明应该是
namespace std {
template<class charT, class traits = char_traits<charT>>
class basic_ostream;
typedef basic_ostream<char> ostream;
}
(如您所见,与string
类一样,streams 类也使用字符特征类)。因为为您想要引用的每个流类键入所有这些内容非常繁琐,所以标准提供了一个头文件来为您完成这项工作:<iosfwd>
。Date
头(见上)看起来会像这样:
// Date.h
#include <iosfwd>
class Date {
friend std::ostream& operator<<(std::ostream&, const Date&);
friend std::istream& operator>>(std::istream&, Date&);
// Etc.
面向行的输入
要一次抓取一行输入,您有三种选择。
- 成员函数
get()
- 成员函数
getline()
- 在
<string>
标题中定义的全局函数getline()
前两个函数有三个参数。
- 指向存储结果的字符缓冲区的指针。
- 缓冲区大小(因此不会溢出)。
- 终止字符,知道何时停止读取输入。
终止字符有一个默认值'\n'
,这是您通常会使用的。当两个函数在输入中遇到终止字符时,它们在结果缓冲区中存储一个零。
那有什么区别呢?微妙但重要的是:get()
在看到输入流中的分隔符时停止,但它没有从输入流中提取它。因此,如果您使用相同的分隔符执行另一个get()
,它将立即返回,并且不获取输入。(大概,你要么在下一个get()
语句中使用不同的分隔符,要么使用不同的输入函数。)另一方面,getline()
函数从输入流中提取分隔符,但仍然不将它存储在结果缓冲区中。
<string>
中定义的getline()
函数很方便。它不是一个成员函数,而是一个在名称空间std
中声明的独立函数。它只需要两个非默认参数,输入流和string
对象来填充。就像它的名字一样,它读取字符,直到遇到第一个出现的分隔符(默认情况下是'\n'
)并消耗和丢弃该分隔符。这个函数的优点是它读入一个string
对象,所以你不需要担心缓冲区的大小。
一般来说,当你在处理一个文本文件时,你一次读一行,你会想要使用一个getline()
函数。
get_)的重写版本
get()
函数还有另外三个重载版本:一个没有参数,使用int
返回值返回下一个字符;使用引用将字符填充到其char
参数中的函数;一个直接存储到另一个iostream
对象的底层缓冲区结构中。
注后者将在本章后面探讨。
读取原始字节
如果您确切地知道您正在处理什么,并且想要将字节直接移动到内存中的变量、数组或结构中,您可以使用无格式的 I/O 函数read()
。这个函数的第一个参数是指向目标内存的指针,第二个参数是要读取的字节数。如果您之前已经将信息存储到了一个文件中,例如,使用互补的write()
成员函数将信息以二进制形式存储到了一个输出流中(当然,使用的是同一个编译器),那么这将非常有用。稍后您将看到所有这些函数的示例。
处理流错误
前面显示的Date
提取器在特定条件下设置流的失败位。用户如何知道这样的故障何时发生?您可以通过调用某些流成员函数来检测流错误,以查看是否发生了错误状态,或者如果您不关心特定的错误是什么,您可以只在布尔上下文中评估流。这两种技术都源自流的错误位的状态。
流状态
从ios
派生的ios_base
类定义了四个标志,如表 19-1 所示,你可以用它们来测试一个流的状态。
表 19-1 。测试流状态的四个标志
旗 | 意义 |
---|---|
Badbit |
发生了一些致命的(可能是物理的)错误。该流应被视为不可用。 |
Eofbit |
输入结束已发生(通过遇到文件流的物理结尾或通过用户终止控制台流,如使用 Ctrl-Z 或 Ctrl-D)。 |
Failbit |
I/O 操作失败,很可能是因为无效数据(例如,在尝试读取数字时发现字母)。该流仍然可用。当输入结束时,failbit 标志也会置位。 |
Goodbit |
一切都好;没有错误。输入结束尚未发生。 |
您可以通过调用相应的成员函数来测试这些条件是否已经发生,这些函数返回一个布尔值来指示这些条件是否已经设置。如果其他三个位都没有设置,则good()
流成员函数返回 true。如果eofbit
被设置,函数eof()
返回 true,这发生在试图从没有更多数据的流(通常是文件)中读取时。因为在 C++ 中,当试图读取物理介质的末尾时会发生输入结束,所以failbit
也被设置为指示“预期的”数据没有被成功读取。如果failbit
或badbit
被置位,则fail()
函数返回真,如果badbit
被置位,则bad()
仅返回真。**
*一旦流状态中的任何错误位被设置,它们就保持设置状态,这并不总是您想要的。读取文件时,您可能希望在文件结束之前重新定位到文件中更早的位置。仅仅移动文件指针不会自动复位eofbit
或failbit
;你必须自己用clear()
函数来做,就像这样:
myStream.clear(); // Clears all error bits
调用clear()
后,如果立即调用,good()
将返回true
。正如您在前面的Date
提取器中看到的,setstate()
函数设置您传递给它的位。结果是setstate()
不会影响任何其他位——如果它们已经被设置,它们将保持设置。如果您想要设置某些位,但同时重置所有其他位,您可以调用重载版本的clear()
,向其传递表示您想要设置的位的按位表达式,如:
myStream.clear(ios::failbit | ios::eofbit);
大多数时候,您不会对单独检查流状态位感兴趣。通常你只是想知道是否一切都好。从头到尾读一个文件就是这种情况;您只想知道输入数据何时用完。您可以使用为void*
定义的转换函数,当布尔表达式中出现流时会自动调用该函数。使用这个习惯用法读取一个流,直到输入结束,如下所示:
int i;
while(myStream >> i)
cout << i << endl;
记住operator>>()
返回它的流参数,所以上面的while
语句将流作为布尔表达式进行测试。这个特殊的例子假设输入流myStream
包含由空格分隔的整数。函数ios_base::operator void*()
简单地在它的流上调用good()
并返回结果。因为大多数流操作返回它们的流,所以使用这个习惯用法很方便。
流和异常
早在异常出现之前,它就已经作为 C++ 的一部分存在了,所以手动检查流状态只是一种方式。为了向后兼容,这仍然是现状,但是现代的iostreams
可以抛出异常来代替。exceptions()
流成员函数接受一个参数,该参数表示您希望抛出异常的状态位。每当流遇到这样的状态,它抛出一个类型为std::ios_base::failure
的异常,该异常继承自std::exception
。
尽管您可以为四种流状态中的任何一种触发失败异常,但是为所有这些状态启用异常不一定是个好主意。正如第十七章所解释的那样,对于真正异常的情况使用异常,但是文件结束不仅是而不是异常——它是预期的!出于这个原因,您可能希望仅对由badbit
表示的错误启用异常,您可以这样做:
myStream.exceptions(ios::badbit);
您可以逐流启用异常,因为exceptions()
是流的成员函数。exceptions()
函数返回一个位掩码(类型为iostate
,这是一种依赖于编译器的类型,可转换为int
),指示哪些流状态会导致异常。如果已经设置了这些状态,则会立即引发异常。当然,如果您使用与流相关的异常,您最好准备好捕捉它们,这意味着您需要用一个具有ios::failure
处理程序的try
块包装所有的流处理。许多程序员觉得这很乏味,只是手动检查他们预计会发生错误的状态(因为,例如,他们不希望bad()
在大多数情况下返回true
)。这是让流抛出异常是可选的而不是默认的另一个原因。在任何情况下,您都可以选择想要如何处理流错误。出于我建议在其他上下文中使用异常进行错误处理的同样原因,我在这里也这样做。
文件流
使用iostreams
操作文件比在 c 语言中使用stdio
要容易和安全得多。你所要做的就是创建一个对象——由构造器来完成这项工作。你不需要显式关闭一个文件(尽管你可以使用close()
成员函数),因为当对象超出范围时析构函数会关闭它。要创建默认为输入的文件,创建一个ifstream
对象。要创建一个默认为输出的对象,创建一个ofstream
对象。一个fstream
对象既可以做输入也可以做输出。
文件流类归入iostreams
类,如图 19-2 中的所示。
图 19-2 。展示了文件流类如何适应 iostream 继承层次结构
和以前一样,您实际使用的类是由类型定义定义的模板专门化。例如,ifstream
处理char
的文件,定义为
typedef basic_ifstream<char> ifstream;
文件处理示例
清单 19-7 显示了迄今为止讨论过的许多特性。注意包含了<fstream>
来声明文件 I/O 类。尽管在许多平台上这也会自动包含<iostream>
,但编译器并不需要这样做。如果你想要可移植的代码,总是包括两个头。
清单 19-7 。用文件说明流 I/O
//: C19:Strfile.cpp
// Stream I/O with files;
// The difference between get() & getline().
#include <fstream>
#include <iostream>
#include "../require.h" // To be INCLUDED from *Chapter 9*
using namespace std;
int main() {
const int SZ = 100; // Buffer size;
char buf[SZ];
{
ifstream in("Strfile.cpp"); // Read
assure(in, "Strfile.cpp"); // Verify open
ofstream out("Strfile.out"); // Write
assure(out, "Strfile.out");
int i = 1; // Line counter
// A less-convenient approach for line input:
while(in.get(buf, SZ)) { // Leaves \n in input
in.get(); // Throw away next character (\n)
cout << buf << endl; // Must add \n
// File output just like standard I/O:
out << i++ << ": " << buf << endl;
}
} // Destructors close in & out
ifstream in("Strfile.out");
assure(in, "Strfile.out");
// More convenient line input:
while(in.getline(buf, SZ)) { // Removes \n
char* cp = buf;
while(*cp != ':')
++cp;
cp += 2; // Past ": "
cout << cp << endl;
// Must still add \n
}
} ///:∼
在创建了ifstream
和ofstream
之后,会有一个assure()
来保证文件被成功打开。这里,在编译器期望布尔结果的情况下使用的对象产生一个指示成功或失败的值。
第一个while
循环演示了两种形式的get()
函数的使用。第一个将字符放入缓冲区,并在读取了SZ-1
字符或遇到第三个参数(默认为'\n'
)时将零终止符放入缓冲区。get()
函数在输入流中留下了终止符,所以这个终止符必须通过in.get()
使用不带参数的get()
的形式丢弃,它获取一个字节并作为int
返回。您也可以使用ignore()
成员函数,它有两个默认参数。第一个参数是要丢弃的字符数,默认为 1。第二个参数是ignore()
函数退出(提取后)的字符,默认为EOF
。
接下来,您会看到两条看起来相似的输出语句:一条是针对cout
的,一条是针对文件out
的。注意这里的便利性——你不需要担心对象类型,因为格式化语句对所有的ostream
对象都是一样的。第一个将该行回显到标准输出中,第二个将该行写出到新文件中,并包含一个行号。
为了演示getline()
,打开您刚刚创建的文件,去掉行号。要确保在打开文件进行读取之前将其正确关闭,您有两种选择。您可以用大括号将程序的第一部分括起来,迫使out
对象超出作用域,从而调用析构函数并关闭文件,这里就完成了。您也可以调用close()
来获取这两个文件;如果这样做,您甚至可以通过调用open()
成员函数来重用in
对象。
第二个while
循环展示了当遇到终止符时getline()
如何从输入流中移除终止符(它的第三个参数,默认为'\n'
)。虽然getline()
和get()
一样,在缓冲区中放了一个零,但还是没有插入终止符。
这个例子,以及本章中的大多数例子,假设对任何重载getline()
的每个调用都会遇到一个换行符。如果不是这样,流的eofbit
状态将被设置,对getline()
的调用将返回false
,导致程序丢失最后一行输入。
打开模式
通过重写构造器的默认参数,可以控制文件的打开方式。表 19-2 显示了控制文件模式的标志。
表 19-2 。控制文件模式的标志
旗 | 功能 |
---|---|
ios::in |
打开一个输入文件。将此用作ofstream 的打开模式,以防止截断现有文件。 |
ios::out |
打开输出文件。用于不带ios::app 、ios::ate 或ios::in 的ofstream 时,隐含ios::trunc 。 |
ios::app |
打开仅用于追加的输出文件。 |
ios::ate |
打开一个现有文件(输入或输出)并查找到结尾。 |
ios::trunc |
如果旧文件已经存在,则截断旧文件。 |
ios::binary |
以二进制模式打开文件。默认为文本模式。 |
您可以使用按位或操作来组合这些标志。
尽管二进制标志是可移植的,但它只对某些非 UNIX 系统有效,例如从 MS-DOS 派生的操作系统,这些系统有存储行尾分隔符的特殊约定。例如,在文本模式(这是默认模式)的 MS-DOS 系统上,每次输出一个换行符('\n'
),文件系统实际上输出两个字符,一个回车/换行符对(CRLF),也就是一对 ASCII 字符0x0D
和0x0A
。相反,当您在文本模式下将这样一个文件读回内存时,这对字节的每一次出现都会导致一个'\n'
被发送到程序的相应位置。如果想绕过这种特殊处理,可以用二进制模式打开文件。二进制模式与你能否将原始字节写入文件没有任何关系——你总能做到(通过调用write()
)。然而,当您使用read()
或write()
时,您应该以二进制模式打开文件,因为这些函数需要一个字节计数参数。在这些情况下,拥有额外的'\r'
字符会影响字节数。如果你要使用本章后面讨论的流定位命令,你也应该以二进制模式打开一个文件。
您可以通过声明一个fstream
对象来打开一个输入和输出文件。当声明一个fstream
对象时,您必须使用前面提到的足够多的开放模式标志来让文件系统知道您是想要输入、输出还是两者都要。要从输出切换到输入,您需要刷新流或更改文件位置。要从输入更改为输出,请更改文件位置。要通过一个fstream
对象创建一个文件,在构造器调用中使用ios::trunc
open mode 标志进行输入和输出。
iostream 缓冲
良好的设计实践表明,无论何时创建一个新的类,都应该尽可能对类的用户隐藏底层实现的细节。你只给他们看他们需要知道的,其余的则让他们看以免混淆。当使用插入器和提取器时,您通常不知道或不关心字节在哪里产生或消耗,也不知道或不关心您处理的是标准 I/O、文件、内存还是一些新创建的类或设备。
然而,有时与产生和消耗字节的iostream
部分进行通信是很重要的。为了给这个部分提供一个公共接口,同时仍然隐藏它的底层实现,标准库将它抽象成自己的类,称为streambuf
。每个iostream
对象都包含一个指向某种streambuf
的指针。
注意类型取决于它是否处理标准 I/O、文件、内存等等。
可以直接访问streambuf
;例如,您可以将原始字节移入和移出streambuf
,而无需通过封闭的iostream
对它们进行格式化。这是通过调用streambuf
对象的成员函数来完成的。
目前,您需要知道的最重要的事情是,每个iostream
对象都包含一个指向streambuf
对象的指针,并且streambuf
对象有一些您可以在必要时调用的成员函数。对于文件和字符串流,有专门类型的流缓冲区,如图 19-3 所示。
图 19-3 。显示文件和字符串的专用流缓冲区
为了允许你访问streambuf
,每个iostream
对象都有一个名为rdbuf()
的成员函数,它返回指向对象streambuf
的指针。这样你可以调用底层streambuf
的任何成员函数。然而,您可以用streambuf
指针做的最有趣的事情之一是使用<<
操作符将它连接到另一个iostream
对象。这将把你的对象中的所有字符都排到<<
左侧的对象中。如果你想把所有的字符从一个iostream
移动到另一个iostream
,你不需要经历一次读一个字符或一行的繁琐(和潜在的编码错误)。这是一种更优雅的方法。清单 19-8 打开一个文件并将内容发送到标准输出(类似于前面的例子)。
清单 19-8 。将文件键入标准输出
//: C19:Stype.cpp
// Type a file to standard output.
#include <fstream>
#include <iostream>
#include "../require.h"
using namespace std;
int main() {
ifstream in("Stype.cpp");
assure(in, "Stype.cpp");
cout << in.rdbuf(); // Outputs entire file
} ///:∼
使用该程序的源代码文件作为参数创建一个ifstream
。如果文件无法打开,函数assure()
会报告失败。所有的工作都真实地发生在声明中
cout << in.rdbuf();
它将文件的全部内容发送到cout
。这不仅更简洁,而且通常比一次移动一个字节更有效。
一种形式的get()
直接写入另一个对象的streambuf
。第一个参数是对目的地streambuf
的引用,第二个参数是终止字符(默认为'\n'
,它停止了get()
功能。所以还有另一种方法将文件打印成标准输出,如清单 19-9 所示。
清单 19-9 。将文件复制到标准输出
//: C19:Sbufget.cpp
// Copies a file to standard output.
#include <fstream>
#include <iostream>
#include "../require.h"
using namespace std;
int main() {
ifstream in("Sbufget.cpp");
assure(in);
streambuf& sb = *cout.rdbuf();
while(!in.get(sb).eof()) {
if(in.fail()) // Found blank line
in.clear();
cout << char(in.get()); // Process '\n'
}
} ///:∼
rdbuf()
函数返回一个指针,所以它必须被解引用以满足函数查看对象的需要。流缓冲区不应该被复制(它们没有复制构造器),所以您将sb
定义为对cout
的流缓冲区的引用。您需要调用fail()
和clear()
,以防输入文件有空行(这个就是)。当这个特定的重载版本的get()
在一行中看到两个新行(空行的证据)时,它设置输入流的 fail 位,因此您必须调用clear()
来重置它,以便可以继续读取流。对get()
的第二次调用提取并回显每个换行符。(记住,get()
函数不像getline()
和那样提取它的分隔符。)
您可能不需要经常使用这样的技术,但是知道它的存在是很好的。
在 iostreams 中搜索
每种类型的iostream
都有一个概念,即它的“下一个”角色将来自哪里(如果是istream
) 或 go(如果是ostream
)。在某些情况下,您可能想要移动这个流位置。您可以使用两种模型来实现这一点:一种使用流中的绝对位置,称为streampos
;第二种工作方式类似于文件的标准 C 库函数fseek()
,从文件的开头、结尾或当前位置移动给定数量的字节。
streampos
方法要求您首先调用一个“tell”函数:ostream
的tellp()
或istream
的tellg()
。(“p”指的是放指针,“g”指的是取指针。)这个函数返回一个streampos
,当您想要返回到流中的那个位置时,您可以稍后在对ostream
的seekp()
或对istream
的seekg()
的调用中使用这个函数。
第二种方法是相对查找,使用重载版本的seekp()
和seekg()
。第一个参数是要移动的字符数:它可以是正数,也可以是负数。第二个参数是寻道地点/位置,如表 19-3 所示。
表 19-3 。任何 C++ 流中的三个查找位置
ios::beg | 从流的开始 |
---|---|
ios::cur |
流中的当前位置 |
ios::end |
从流的末尾 |
清单 19-10 显示了在文件中的移动,但是记住,你并不局限于在文件中查找,就像你使用 C 的stdio
一样。使用 C++,您可以在任何类型的 iostream 中进行查找(尽管标准流对象,如cin
和cout
,明确不允许这样做)。
清单 19-10 。在 iostreams 中寻找的演示
//: C19:Seeking.cpp
// Seeking in iostreams.
#include <cassert>
#include <cstddef>
#include <cstring>
#include <fstream>
#include "../require.h"
using namespace std;
int main() {
const int STR_NUM = 5, STR_LEN = 30;
char origData[STR_NUM][STR_LEN] = {
"Hickory dickory dus. . .",
"Are you tired of C++?",
"Well, if you have,",
"That's just too bad,",
"There's plenty more for us!"
};
char readData[STR_NUM][STR_LEN] = {{ 0 }};
ofstream out("Poem.bin", ios::out | ios::binary);
assure(out, "Poem.bin");
for(int i = 0; i < STR_NUM; i++)
out.write(origData[i], STR_LEN);
out.close();
ifstream in("Poem.bin", ios::in | ios::binary);
assure(in, "Poem.bin");
in.read(readData[0], STR_LEN);
assert(strcmp(readData[0], "Hickory dickory dus. . .")
== 0);
// Seek -STR_LEN bytes from the end of file
in.seekg(-STR_LEN, ios::end);
in.read(readData[1], STR_LEN);
assert(strcmp(readData[1], "There's plenty more for us!")
== 0);
// Absolute seek (like using operator[] with a file)
in.seekg(3 * STR_LEN);
in.read(readData[2], STR_LEN);
assert(strcmp(readData[2], "That's just too bad,") == 0);
// Seek backwards from current position
in.seekg(-STR_LEN * 2, ios::cur);
in.read(readData[3], STR_LEN);
assert(strcmp(readData[3], "Well, if you have,") == 0);
// Seek from the beginning of the file
in.seekg(1 * STR_LEN, ios::beg);
in.read(readData[4], STR_LEN);
assert(strcmp(readData[4], "Are you tired of C++?")
== 0);
} ///:∼
这个程序使用二进制输出流将一首诗写到一个文件中。因为您将它作为ifstream
重新打开,所以您使用seekg()
来定位 get 指针。如您所见,您可以从文件的开头或结尾或者从当前文件位置开始查找。显然,您必须提供一个正数来从文件的开始处移动,提供一个负数来从文件的结尾处向后移动。
现在你已经知道了streambuf
和如何查找,你可以理解一个替代的方法(除了使用一个fstream
对象)来创建一个读和写文件的流对象。下面的代码首先创建一个带有标志的ifstream
,标志表示它既是输入文件又是输出文件。您不能写入一个ifstream
,因此您需要创建一个带有底层流缓冲区的ostream
,如下所示:
ifstream in("filename", ios::in | ios::out);
ostream out(in.rdbuf());
你可能想知道当你写这些对象时会发生什么。清单 19-11 包含了一个例子。
清单 19-11 。读写一个文件的演示
//: C19:Iofile.cpp
// Reading & writing one file.
#include <fstream>
#include <iostream>
#include "../require.h"
using namespace std;
int main() {
ifstream in("Iofile.cpp");
assure(in, "Iofile.cpp");
ofstream out("Iofile.out");
assure(out, "Iofile.out");
out << in.rdbuf();
// Copy file
in.close();
out.close();
// Open for reading and writing:
ifstream in2("Iofile.out", ios::in | ios::out);
assure(in2, "Iofile.out");
ostream out2(in2.rdbuf());
cout << in2.rdbuf(); // Print whole file
out2 << "Where does this end up?";
out2.seekp(0, ios::beg);
out2 << "And what about this?";
in2.seekg(0, ios::beg);
cout << in2.rdbuf();
} ///:∼
前五行将这个程序的源代码复制到一个名为iofile.out
的文件中,然后关闭这些文件。这给了你一个安全的文本文件。然后,使用上述技术创建两个对象,这两个对象读写同一个文件。在cout << in2.rdbuf()
中,你可以看到 get 指针被初始化到文件的开头。然而,put 指针被设置到文件的末尾,因为“它在哪里结束?”显示为附加到文件中。然而,如果 put 指针移动到带有seekp()
的开头,所有插入的文本将覆盖现有的文本。当使用seekg()
将get
指针移回到开头时,可以看到两次写入,并显示文件。当out2
超出范围并调用其析构函数时,文件自动保存并关闭。
字符串 iostreams
字符串流直接作用于内存,而不是文件或标准输出。它使用与cin
和cout
相同的读取和格式化功能来操作内存中的字节。在旧电脑上,内存被称为内核,因此这种功能通常被称为内核格式化。
字符串流的类名与文件流的类名相呼应。如果您想创建一个字符串流来从中提取字符,您可以创建一个istringstream
。如果您想将字符放入字符串流,您可以创建一个ostringstream
。字符串流的所有声明都在标准头文件<sstream>
中。通常,有适合iostreams
层次的类模板,如图图 19-4 所示。
图 19-4 。iostream 继承层次结构中的字符串流模板
输入字符串流
要使用流操作从字符串中读取数据,需要创建一个用字符串初始化的istringstream
对象。清单 19-12 展示了如何使用一个istringstream
对象。
清单 19-12 。演示输入字符串流
//: C19:Istring.cpp
// Input string streams.
#include <cassert>
#include <cmath> // For fabs()
#include <iostream>
#include <limits> // For epsilon()
#include <sstream>
#include <string>
using namespace std;
int main() {
istringstream s("47 1.414 This is a test");
int i;
double f;
s >> i >> f; // Whitespace-delimited input
assert(i == 47);
double relerr = (fabs(f) - 1.414) / 1.414;
assert(relerr <= numeric_limits<double>::epsilon());
string buf2;
s >> buf2;
assert(buf2 == "This");
cout << s.rdbuf(); // " is a test"
} ///:∼
您可以看到,与标准的 C 库函数(如atof()
或atoi()
)相比,这是一种更灵活、更通用的将字符串转换为类型值的方法,尽管后者对于单次转换可能更有效。
在表达式s >> i >> f
中,第一个数字被提取到i
,第二个被提取到f
。这不是第一个空格分隔的字符集,因为它取决于要提取的数据类型。例如,如果字符串改为“1.414 47 This is a test
”,那么i
将得到值 1,因为输入例程将在小数点处停止。那么f
就会得到0.414
。如果您想将一个浮点数分解成一个整数和一个小数部分,这可能会很有用。否则,这似乎是一个错误。第二个assert()
计算你读到的和你期望的相对误差;这样做总比比较浮点数是否相等要好。由epsilon()
返回的常量,在<limits>
中定义,代表双精度数的机器ε,这是你可以期望double
s 的比较满足的最佳公差。
正如您可能已经猜到的那样,buf2
没有得到字符串的其余部分,只是下一个空格分隔的单词。一般来说,当您知道输入流中数据的确切序列,并且您正在转换成某种类型而不是字符串时,最好使用iostreams
中的提取器。然而,如果您想一次提取字符串的剩余部分,并将其发送给另一个iostream
,您可以使用如图所示的rdbuf()
。
为了测试本章开头的Date
提取器,清单 19-13 展示了一个输入字符串流的使用。
清单 19-13 。测试日期提取器
//: C19:DateIOTest.cpp
//{L} ../C19/Date
#include <iostream>
#include <sstream>
#include "../Date.h"
using namespace std;
void testDate(const string& s) {
istringstream os(s);
Date d;
os >> d;
if(os)
cout << d << endl;
else
cout << "input error with \"" << s << "\"" << endl;
}
int main() {
testDate("08-10-2003");
testDate("8-10-2003");
testDate("08 - 10 - 2003");
testDate("A-10-2003");
testDate("08%10/2003");
} ///:∼
main()
中的每个字符串文字都通过引用传递给testDate()
,后者又将它包装在一个istringstream
中,这样就可以测试Date
对象的流提取器。功能testDate()
也开始测试插入器operator<<()
。
输出字符串流
要创建输出字符串流,您只需创建一个ostringstream
对象,它管理一个动态大小的字符缓冲区来保存您插入的任何内容。为了获得作为string
对象的格式化结果,您调用str()
成员函数,如清单 19-14 中的所示。
清单 19-14 。说明 ostringstream 的用法
//: C19:Ostring.cpp {RunByHand}
// Illustrates ostringstream.
#include <iostream>
#include <sstream>
#include <string>
using namespace std;
int main() {
cout << "type an int, a float and a string: ";
int i;
float f;
cin >> i >> f;
cin >> ws; // Throw away white space
string stuff;
getline(cin, stuff); // Get rest of the line
ostringstream os;
os << "integer = " << i << endl;
os << "float = " << f << endl;
os << "string = " << stuff << endl;
string result = os.str();
cout << result << endl;
} ///:∼
这类似于清单 19-12 中的Istring.cpp
例子,它获取了一个int
和一个float
。下面是一个执行示例(键盘输入以斜体显示)。
type an int, a float and a string: *10 20.5 the end*
integer = 10
float = 20.5
string = the end
您可以看到,像其他输出流一样,您可以使用普通的格式化工具,比如<<
操作符和endl
,向ostringstream
发送字节。每次您调用str()
函数时,它都会返回一个新的string
对象,因此字符串流拥有的底层stringbuf
对象不受干扰。
第十八章包含了一个程序,清单 18-20 中的HTMLStripper.cpp
,它从一个文本文件中移除了所有的 HTML 标签和特殊代码。正如所承诺的,这里有一个使用字符串流的更优雅的版本;参见清单 19-15 。
清单 19-15 。展示了一个使用字符串流的更优雅的 HTML 剥离器(前一个,你在清单 18-20 :参考第十八章)
//: C19:HTMLStripper2.cpp {RunByHand}
//{L} ../C19/ReplaceAll
// Filter to remove html tags and markers.
#include <cstddef>
#include <cstdlib>
#include <fstream>
#include <iostream>
#include <sstream>
#include <stdexcept>
#include <string>
#include "../ReplaceAll.h" // To be INCLUDED from Chapter 18
#include "../require.h"
using namespace std;
string& stripHTMLTags(string& s) throw(runtime_error) {
size_t leftPos;
while((leftPos = s.find('<')) != string::npos) {
size_t rightPos = s.find('>', leftPos+1);
if(rightPos == string::npos) {
ostringstream msg;
msg << "Incomplete HTML tag starting in position "
<< leftPos;
throw runtime_error(msg.str());
}
s.erase(leftPos, rightPos - leftPos + 1);
}
// Remove all special HTML characters
replaceAll(s, "<", "<");
replaceAll(s, ">", ">");
replaceAll(s, "&", "&");
replaceAll(s, " ", " ");
// Etc...
return s;
}
int main(int argc, char* argv[]) {
requireArgs(argc, 1,
"usage: HTMLStripper2 InputFile");
ifstream in(argv[1]);
assure(in, argv[1]);
// Read entire file into string; then strip
ostringstream ss;
ss << in.rdbuf();
try {
string s = ss.str();
cout << stripHTMLTags(s) << endl;
return EXIT_SUCCESS;
} catch(runtime_error& x) {
cout << x.what() << endl;
return EXIT_FAILURE;
}
} ///:∼
在这个程序中,通过在一个ostringstream
中插入一个对文件流的rdbuf()
调用,将整个文件读入一个字符串。现在,搜索 HTML 分隔符对并删除它们变得很容易。清单 19-16 展示了如何使用双向(即读/写)字符串流。
清单 19-16 。读取和写入字符串流
//: C19:StringSeeking.cpp {-bor}{-dmc}
// Reads and writes a string stream.
#include <cassert>
#include <sstream>
#include <string>
using namespace std;
int main() {
string text = "We will hook no fish";
stringstream ss(text);
ss.seekp(0, ios::end);
ss << " before its time.";
assert(ss.str() ==
"We will hook no fish before its time.");
// Change "hook" to "ship"
ss.seekg(8, ios::beg);
string word;
ss >> word;
assert(word == "hook");
ss.seekp(8, ios::beg);
ss << "ship";
// Change "fish" to "code"
ss.seekg(16, ios::beg);
ss >> word;
assert(word == "fish");
ss.seekp(16, ios::beg);
ss << "code";
assert(ss.str() ==
"We will ship no code before its time.");
ss.str("A horse of a different color.");
assert(ss.str() == "A horse of a different color.");
} ///:∼
和往常一样,要移动 put 指针,调用seekp()
,要重新定位 get 指针,调用seekg()
。尽管我没有在这个例子中展示,但是字符串流比文件流更宽容一些,因为您可以随时从读取切换到写入,反之亦然。您不需要重新定位 get 或 put 指针或刷新流。这个程序还演示了用一个新字符串替换流的底层stringbuf
的str()
重载。
输出流格式
iostreams
设计的目标是允许你轻松地移动和/或格式化字符。如果你不能完成 C 的printf()
系列函数所提供的大部分格式,那么它肯定是没有用的。在这一节中,您将学习所有可用于iostreams
的输出格式化函数,这样您就可以按照您想要的方式格式化您的字节。
起初,iostreams
中的格式化函数可能有些混乱,因为通常有不止一种方法来控制格式化:通过成员函数和操纵器。更令人困惑的是,泛型成员函数设置状态标志来控制格式,例如左对齐或右对齐,使用大写字母表示十六进制符号,始终使用小数点表示浮点值,等等。另一方面,单独的成员函数设置和读取填充字符、字段宽度和精度的值。
为了澄清这一切,让我们首先检查一个iostream
的内部格式化数据,以及可以修改该数据的成员函数。(如果需要,一切都可以通过成员函数来控制。)机械手将单独介绍。
格式标志
类ios
包含数据成员来存储所有与流相关的格式化信息。有些数据有一个取值范围,并存储在变量中:浮点精度、输出字段宽度和用于填充输出的字符(通常是一个空格)。其余的格式由标志决定,这些标志通常组合在一起以节省空间,统称为格式标志。您可以用ios::flags()
成员函数找出格式标志的值,该函数不带参数,返回一个包含当前格式标志的fmtflags
(通常是long
的同义词)类型的对象。所有其余的函数对格式标志进行修改,并返回格式标志的前一个值,如:
fmtflags ios::flags(fmtflags newflags);
fmtflags ios::setf(fmtflags ored_flag);
fmtflags ios::unsetf(fmtflags clear_flag);
fmtflags ios::setf(fmtflags bits, fmtflags field);
第一个函数强制所有的标志改变,这有时是你想要的。更常见的情况是,使用剩下的三个函数一次更改一个标志。
使用setf()
似乎有些令人困惑。要知道使用哪个重载版本,您必须知道您要更改的标志的类型。有两种类型的标志:简单地打开或关闭的标志,以及与其他标志一起工作的标志。开/关标志最容易理解,因为你用setf(fmtflags)
打开它们,用unsetf(fmtflags)
关闭它们。这些标志如表 19-4 所示。
表 19-4 。开/关标志和效果
开/关标志 | 影响 |
---|---|
ios::skipws | 跳过空白。(供输入;这是默认值。) |
ios::showbase | 显示打印整数值时的数字基数(例如通过dec 、oct 或hex 设置)。当showbase 开启时,输入流也会识别基本前缀。 |
ios::showpoint | 显示浮点值的小数点和尾随零。 |
ios::uppercase | 十六进制值显示大写A-F ,科学值显示大写E 。 |
ios::showpos | 显示正值的加号(+)。 |
IOs::unibuf | 单位缓冲;每次插入后都会刷新流。 |
例如,为了表示cout
的加号,你说cout.setf(ios::showpos)
。要停止显示加号,你说cout.unsetf(ios::showpos)
。
unitbuf
标志控制单元缓冲,这意味着每个插入都被立即刷新到其输出流。这对于错误跟踪很方便,这样在程序崩溃的情况下,您的数据仍然会被写入日志文件。清单 19-17 展示了单元缓冲。
清单 19-17 。说明单元缓冲的使用
//: C19:Unitbuf.cpp {RunByHand}
#include <cstdlib> // For abort()
#include <fstream>
using namespace std;
int main() {
ofstream out("log.txt");
out.setf(ios::unitbuf);
out << "one" << endl;
out << "two" << endl;
abort();
} ///:∼
在对流进行任何插入之前,有必要打开单元缓冲。当我注释掉对setf()
的调用时,一个特定的编译器只将字母“o”写到文件log.txt
中。有了单元缓冲,没有数据丢失。
默认情况下,标准错误输出流cerr
打开了单元缓冲。单元缓冲是有成本的,所以如果输出流被大量使用,除非效率不是考虑因素,否则不要启用单元缓冲。
格式化字段
第二种类型的格式化标志在组中工作。一次只能设置其中一个标志,就像老式汽车收音机上的按钮一样——你按下一个,其余的就会弹出来。不幸的是,这不会自动发生,你必须注意你设置了什么标志,这样你就不会不小心调用了错误的setf()
函数。例如,每种基数都有一个标志:十六进制、十进制和八进制。这些标志统称为ios::basefield
。如果设置了ios::dec
标志,并且您调用了setf(ios::hex)
,您将设置ios::hex
标志,但是您不会清除ios::dec
位,从而导致未定义的行为。而是这样调用setf()
的第二种形式:setf(ios::hex, ios::basefield)
。该功能首先清除ios::basefield
和中的所有位,然后设置ios::hex
。因此,这种形式的setf()
确保了组中的其他标志在您设置一个标志时“弹出”。操作器自动地为你做所有这些,所以你不需要关心这个类实现的内部细节,甚至不需要关心它是一组二进制标志。稍后你会看到在所有你会用到setf()
的地方都有操纵器提供同等的功能。标志组及其作用见表 19-5 。
表 19-5 。三个标志组和效果
ios::basefield | 影响 |
---|---|
ios::dec | 以 10 为基数(十进制)格式化整数值(默认基数—前缀不可见)。 |
ios::hex | 以 16 进制(十六进制)格式化整数值。 |
ios::oct | 以 8 进制(八进制)格式化整数值。 |
ios::floatfield |
影响 |
ios::科学 | 以科学格式显示浮点数。精度字段表示小数点后的位数。 |
ios::已修复 | 以固定格式显示浮点数。精度字段表示小数点后的位数。 |
"automatic" (Neither bit is set.) |
精度字段指示有效数字的总数。 |
ios::adjustfield |
影响 |
ios::左 | 左对齐值;用填充字符填充右侧。 |
ios::右 | 右对齐值。用填充字符填充左侧。这是默认对齐方式。 |
ios::内部 | 在任何前导符号或基准指示器之后、值之前添加填充字符。(换句话说,如果打印,符号是左对齐的,而数字是右对齐的。) |
宽度、填充和精度
控制输出字段宽度的内部变量、用于填充输出字段的填充字符以及打印浮点数的精度由同名的成员函数读写。参见表 19-6 。
表 19-6 。宽度、填充和精度功能以及效果
功能 | 影响 |
---|---|
int ios::width() | 返回当前宽度。默认值为 0。用于插入和提取。 |
int ios::width(int n) | 设置宽度并返回以前的宽度。 |
int ios::son() | 返回当前填充字符。默认为空格。 |
int ios::son(int n) | 设置填充字符并返回上一个填充字符。 |
int ios::precision() | 返回当前浮点精度。默认值为 6。 |
int ios::precision(int n) | 设置浮点精度并返回以前的精度。“精度”的含义见表 19-5 中的ios::floatfield |
fill
和precision
的值相当简单,但是width
需要一些解释。当width
为零时,插入一个值会产生表示该值所需的最少字符数。正的width
意味着插入一个值将产生至少与宽度一样多的字符;如果值少于宽度字符,则填充字符填充字段。然而,该值永远不会被截断,所以如果您试图用 2 的width
打印 123,您仍然会得到 123。字段width
指定最小字符数;没有办法指定最大数量。
width
也明显不同,因为它被每个插入器或提取器重置为零,这可能受其值的影响。它实际上不是一个状态变量,而是插入器和提取器的隐式参数。如果您想要一个恒定的宽度,在每次插入或提取后调用width()
。
详尽的例子
为了确保你知道如何调用前面讨论的所有函数,清单 19-18 调用了它们。
清单 19-18 。说明所有的格式化功能
//: C19:Format.cpp
// Formatting Functions.
#include <fstream>
#include <iostream>
#include "../require.h"
using namespace std;
#define D(A) T << #A << endl; A
int main() {
ofstream T("format.out");
assure(T);
D(int i = 47;)
D(float f = 2300114.414159;)
const char* s = "Is there any more?";
D(T.setf(ios::unitbuf);)
D(T.setf(ios::showbase);)
D(T.setf(ios::uppercase | ios::showpos);)
D(T << i << endl;) // Default is dec
D(T.setf(ios::hex, ios::basefield);)
D(T << i << endl;)
D(T.setf(ios::oct, ios::basefield);)
D(T << i << endl;)
D(T.unsetf(ios::showbase);)
D(T.setf(ios::dec, ios::basefield);)
D(T.setf(ios::left, ios::adjustfield);)
D(T.fill('0');)
D(T << "fill char: " << T.fill() << endl;)
D(T.width(10);)
T << i << endl;
D(T.setf(ios::right, ios::adjustfield);)
D(T.width(10);)
T << i << endl;
D(T.setf(ios::internal, ios::adjustfield);)
D(T.width(10);)
T << i << endl;
D(T << i << endl;) // Without width(10)
D(T.unsetf(ios::showpos);)
D(T.setf(ios::showpoint);)
D(T << "prec = " << T.precision() << endl;)
D(T.setf(ios::scientific, ios::floatfield);)
D(T << endl << f << endl;)
D(T.unsetf(ios::uppercase);)
D(T << endl << f << endl;)
D(T.setf(ios::fixed, ios::floatfield);)
D(T << f << endl;)
D(T.precision(20);)
D(T << "prec = " << T.precision() << endl;)
D(T << endl << f << endl;)
D(T.setf(ios::scientific, ios::floatfield);)
D(T << endl << f << endl;)
D(T.setf(ios::fixed, ios::floatfield);)
D(T << f << endl;)
D(T.width(10);)
T << s << endl;
D(T.width(40);)
T << s << endl;
D(T.setf(ios::left, ios::adjustfield);)
D(T.width(40);)
T << s << endl;
} ///:∼
/这个例子使用了一个技巧来创建一个跟踪文件,这样您就可以监视发生了什么。宏D(a)
使用预处理器字符串化将a
转换成要显示的字符串。然后它重申a
所以语句被执行。宏将所有信息发送到一个名为T
的文件中,这个文件就是跟踪文件。/
输出是
int i = 47;
float f = 2300114.414159;
T.setf(ios::unitbuf);
T.setf(ios::showbase);
T.setf(ios::uppercase | ios::showpos);
T << i << endl;
+47
T.setf(ios::hex, ios::basefield);
T << i << endl;
0X2F
T.setf(ios::oct, ios::basefield);
T << i << endl;
057
T.unsetf(ios::showbase);
T.setf(ios::dec, ios::basefield);
T.setf(ios::left, ios::adjustfield);
T.fill('0');
T << "fill char: " << T.fill() << endl;
fill char: 0
T.width(10);
+470000000
T.setf(ios::right, ios::adjustfield);
T.width(10);
0000000+47
T.setf(ios::internal, ios::adjustfield);
T.width(10);
+000000047
T << i << endl;
+47
T.unsetf(ios::showpos);
T.setf(ios::showpoint);
T << "prec = " << T.precision() << endl;
prec = 6
T.setf(ios::scientific, ios::floatfield);
T << endl << f << endl;
2.300114E+06
T.unsetf(ios::uppercase);
T << endl << f << endl;
2.300114e+06
T.setf(ios::fixed, ios::floatfield);
T << f << endl;
2300114.500000
T.precision(20);
T << "prec = " << T.precision() << endl;
prec = 20
T << endl << f << endl;
2300114.50000000000000000000
T.setf(ios::scientific, ios::floatfield);
T << endl << f << endl;
2.30011450000000000000e+06
T.setf(ios::fixed, ios::floatfield);
T << f << endl;
2300114.50000000000000000000
T.width(10);
Is there any more?
T.width(40);
0000000000000000000000Is there any more?
T.setf(ios::left, ios::adjustfield);
T.width(40);
Is there any more?0000000000000000000000
研究这个输出应该会澄清您对iostream
格式化成员函数的理解。
操作者
正如您从清单 19-18 中看到的,调用成员函数进行流格式化操作会变得有点乏味。为了使事情更容易读写,提供了一组操纵器来复制成员函数提供的动作。操纵器很方便,因为您可以在包含表达式中插入它们以获得效果;您不需要创建单独的函数调用语句。
操纵器改变流的状态,而不是处理数据。例如,当您在输出表达式中插入endl
时,它不仅会插入一个换行符,而且还会刷新流(也就是说,输出所有已经存储在内部流缓冲区中但尚未输出的待定字符)。你也可以像这样冲洗水流
cout << flush;
这导致对成员函数flush()
的调用,如
cout.flush();
作为副作用(没有任何东西被插入到流中)。其他基本操纵器会将基数更改为oct
(八进制)、dec
(十进制)或hex
(十六进制),如下所示:
cout << hex << "0x" << i << endl;
在这种情况下,数字输出将以十六进制模式继续,直到您通过在输出流中插入dec
或oct
来改变它。
还有一个用于提取的操纵器,它“吃掉”空白:
cin >> ws;
<iostream>
中提供了不带参数的操纵器。这些包括dec
、oct
和hex
,它们分别执行与setf(ios::dec, ios::basefield)
、setf(ios::oct, ios::basefield)
和setf(ios::hex, ios::basefield)
相同的动作,尽管更加简洁。<iostream>
集管还包括ws
、endl
、flush
以及表 19-7 所示的附加集。
表 19-7 。在< iostream >中定义的操纵器(附加)
操作者 | 影响 |
---|---|
showbase | |
我们的 showbase | 显示打印整数值时的数字基数(dec 、oct 或hex )。 |
鼻道 | 显示正值的加号(+)。 |
upper casenoupper case | 显示大写的 A-F 表示十六进制值,显示 E 表示科学值。 |
展示点 | |
无展示点 | 显示浮点值的小数点和尾随零。 |
skipws | |
noskipws | 跳过输入中的空白。 |
左 | |
右 | |
内部 | 左对齐,右边填充。 |
右对齐,左边填充。 | |
在前导符号或基本指示符与值之间填充。 | |
科学 | |
固定 | 指示浮点输出的显示首选项(科学记数法与定点十进制)。 |
带参数的操纵器
有六个标准操纵器,比如setw()
,它们接受参数。这些在头文件< iomanip>
中定义,并在表 19-8 中总结。
表 19-8 。在< iomanip >中定义了参数的操纵器
操作者 | 影响 |
---|---|
setosflags(fmtfflags)-setosflags(setosflags)-setosflags(setosflags)(setosflags)(setosflags)(setosflags)(setosflags(setosflags)(setosflags)(setosflags)(setosflags(setosflags)) | 相当于调用了setf(n) 。该设置保持有效,直到下一次改变,如ios::setf() 。 |
重置标志(fmtflags) | 仅清除由n 指定的格式标志。该设置保持有效,直到下一次改变,如ios::unsetf() 。 |
setbase(基数 n) | 将基数改为n ,其中n 为 10、8 或 16。(其他任何结果都是 0。)如果n 是零,输出基数是 10,但是输入使用 C 惯例:10 是 10,010 是 8,0xf 是 15。不妨用dec 、oct 、hex 输出。 |
setfill(char n) | 将填充字符改为n ,如ios::fill() 。 |
setprecision(int n) | 将精度改为n ,如ios::precision() 。 |
setw(内部 n) | 将字段宽度更改为n ,如ios::width() 。 |
如果您正在进行大量的格式化,您可以看到使用操纵器而不是调用流成员函数是如何清理代码的。作为一个例子,清单 19-19 包含了前一节中重写的程序,以使用操纵器。(D()
宏被删除,以便于阅读。)
清单 19-19 。说明操纵器的使用
//: C19:Manips.cpp
// Format.cpp using manipulators.
#include <fstream>
#include <iomanip>
#include <iostream>
using namespace std;
int main() {
ofstream trc("trace.out");
int i = 47;
float f = 2300114.414159;
char* s = "Is there any more?";
trc << setiosflags(ios::unitbuf
| ios::showbase | ios::uppercase
| ios::showpos);
trc << i << endl;
trc << hex << i << endl
<< oct << i << endl;
trc.setf(ios::left, ios::adjustfield);
trc << resetiosflags(ios::showbase)
<< dec << setfill('0');
trc << "fill char: " << trc.fill() << endl;
trc << setw(10) << i << endl;
trc.setf(ios::right, ios::adjustfield);
trc << setw(10) << i << endl;
trc.setf(ios::internal, ios::adjustfield);
trc << setw(10) << i << endl;
trc << i << endl; // Without setw(10)
trc << resetiosflags(ios::showpos)
<< setiosflags(ios::showpoint)
<< "prec = " << trc.precision() << endl;
trc.setf(ios::scientific, ios::floatfield);
trc << f << resetiosflags(ios::uppercase) << endl;
trc.setf(ios::fixed, ios::floatfield);
trc << f << endl;
trc << f << endl;
trc << setprecision(20);
trc << "prec = " << trc.precision() << endl;
trc << f << endl;
trc.setf(ios::scientific, ios::floatfield);
trc << f << endl;
trc.setf(ios::fixed, ios::floatfield);
trc << f << endl;
trc << f << endl;
trc << setw(10) << s << endl;
trc << setw(40) << s << endl;
trc.setf(ios::left, ios::adjustfield);
trc << setw(40) << s << endl;
} ///:∼
您可以看到许多多重语句被压缩到一个链式插入中。注意对setiosflags()
的调用,其中传递了标志的按位“或”。这也可以用前面的例子中的setf()
和unsetf()
来完成(清单 19-18 )。
当对输出流使用setw()
时,输出表达式被格式化为一个临时字符串,如果需要的话,用当前填充字符填充,这是通过将格式化结果的长度与setw()
的参数进行比较来确定的。换句话说,setw()
影响格式化输出操作的结果字符串。同样,在读取字符串时,只对输入流使用setw()
是有意义的,如清单 19-20 所示。
清单 19-20 。用输入说明 setw 的局限性
//: C19:InputWidth.cpp
// Shows limitations of setw with input.
#include <cassert>
#include <cmath>
#include <iomanip>
#include <limits>
#include <sstream>
#include <string>
using namespace std;
int main() {
istringstream is("one 2.34 five");
string temp;
is >> setw(2) >> temp;
assert(temp == "on");
is >> setw(2) >> temp;
assert(temp == "e");
double x;
is >> setw(2) >> x;
double relerr = fabs(x - 2.34) / x;
assert(relerr <= numeric_limits<double>::epsilon());
} ///:∼
如果你试图读取一个字符串,setw()
会很好地控制提取的字符数。。。在某种程度上。第一次提取得到两个字符,但是第二次只得到一个,即使您要求两个。这是因为operator>>()
使用空格作为分隔符(除非您关闭了skipws
标志)。然而,当试图读取一个数字时,例如x
,您不能使用setw()
来限制读取的字符。对于输入流,仅使用setw()
提取字符串。
创建操纵器
有时你想创造你自己的操纵器,结果非常简单。像endl
这样的零参数操纵器只是一个函数,它将一个ostream
引用作为其参数,并返回一个ostream
引用。endl
的申报是
ostream& endl(ostream&);
现在,当你说
cout << "howdy" << endl;
endl
产生该函数的地址。所以编译器会问,“这里有没有可以应用的函数,把函数的地址作为参数?”<iostream>
中的预定义函数会这样做;他们被称为应用器(因为他们将功能应用于流)。应用程序调用它的函数参数,将ostream
对象作为它的参数传递给它。你不需要知道喷涂机如何创造你自己的机械手;你只需要知道他们的存在。以下是ostream
敷贴器的(简化)代码:
ostream& ostream::operator<<(ostream& (*pf)(ostream&)) {
return pf(*this);
}
实际的定义稍微复杂一些,因为它涉及到模板,但是这段代码演示了这种技术。当一个像*pf
(接受流参数并返回流引用)这样的函数被插入到流中时,这个 applicator 函数被调用,它反过来执行pf
指向的函数。标准 C++ 库中预定义了ios_base
、basic_ios
、basic_ostream
和basic_istream
的应用程序。
为了说明这个过程,清单 19-21 是一个简单的例子,它创建了一个名为nl
的操纵器,相当于在一个流中插入一个新行(也就是说,不会像endl
那样刷新流)。
清单 19-21 。说明了操纵器的创建
//: C19:nl.cpp
// Creating a manipulator.
#include <iostream>
using namespace std;
ostream& nl(ostream& os) {
return os << '\n';
}
int main() {
cout << "newlines" << nl << "between" << nl
<< "each" << nl << "word" << nl;
} ///:∼
当您将nl
插入到一个输出流中时,比如cout
,下面的调用序列随之发生:
cout.operator<<(nl) è nl(cout)
表情
os << '\n';
在nl()
内部调用ostream::operator(char)
,它返回流,这是最终从nl()
返回的内容。
效应物
如你所见,零参数操纵器很容易创建。但是如果你想创建一个接受参数的操纵器呢?如果您检查<iomanip>
头,您会看到一个名为smanip
的类型,这是带有参数的操纵器返回的内容。你可能会试图用这种类型来定义你自己的操纵器,但是不要这样做。smanip
类型依赖于实现,因此不可移植。幸运的是,基于一种叫做效应器的技术,你可以直接定义这种操纵器,而不需要任何特殊的机械。效应器是一个简单的类,它的构造器格式化一个表示所需操作的字符串,以及一个重载的operator<<
来将该字符串插入到流中。清单 19-22 是一个有两个效应器的例子。第一个输出截断的字符串,第二个输出二进制数。
清单 19-22 。演示了两个效应器的使用(前者输出一个截断的字符串,而后者以二进制格式输出一个数字)
//: C19:Effector.cpp
#include <cassert>
#include <limits> // For max()
#include <sstream>
#include <string>
using namespace std;
// Put out a prefix of a string:
class Fixw {
string str;
public:
Fixw(const string& s, int width) : str(s, 0, width) {}
friend ostream& operator<<(ostream& os, const Fixw& fw) {
return os << fw.str;
}
};
// Print a number in binary:
typedef unsigned long ulong;
class Bin {
ulong n;
public:
Bin(ulong nn) { n = nn; }
friend ostream& operator<<(ostream& os, const Bin& b) {
const ulong ULMAX = numeric_limits<ulong>::max();
ulong bit = ∼(ULMAX >> 1); // Top bit set
while(bit) {
os << (b.n & bit ? '1' : '0');
bit >>= 1;
}
return os;
}
};
int main() {
string words = "Things that make us happy, make us wise";
for(int i = words.size(); --i >= 0;) {
ostringstream s;
s << Fixw(words, i);
assert(s.str() == words.substr(0, i));
}
ostringstream xs, ys;
xs << Bin(0xCAFEBABEUL);
assert(xs.str() ==
"1100""1010""1111""1110""1011""1010""1011""1110");
ys << Bin(0x76543210UL);
assert(ys.str() ==
"0111""0110""0101""0100""0011""0010""0001""0000");
} ///:∼
Fixw
的构造器为它的char*
参数创建一个缩短的副本,析构函数释放为这个副本创建的内存。重载的operator<<
获取其第二个参数Fixw
对象的内容,将其插入到第一个参数ostream
中,然后返回ostream
,以便在链式表达式中使用。当你在这样的表达式中使用Fixw
时
cout << Fixw(string, i) << endl;
通过调用Fixw
构造器创建一个临时对象,并将该临时对象传递给operator<<
。其效果就像一个带有参数的操纵器。临时的Fixw
对象持续到语句结束。
Bin
效应器依赖于这样一个事实:将一个无符号数向右移动会将零移动到高位。我使用numeric_limits<unsigned long>::max()
(最大的unsigned long
值,来自标准报头<limits>
)来产生一个高位设置的值,并且这个值在所讨论的数字中移动(通过将其向右移动),依次屏蔽每个位。为了可读性,我在代码中并置了字符串文字;编译器将单独的字符串连接成一个字符串。
从历史上看,这种技术的问题在于,一旦你为char*
创建了一个名为Fixw
的类,或者为unsigned long
创建了一个名为Bin
的类,其他人就无法为他们的类型创建不同的Fixw
或Bin
类。但是,有了名称空间,这个问题就解决了。效应器和操纵器并不等同,尽管它们通常可以用来解决相同的问题。如果你发现一个效应器是不够的,你将需要征服操纵器的复杂性。
iostream 示例
在这一节中,你将看到运用你在本章中学到的知识的例子。尽管有许多工具可以操作字节(来自 Unix 的流编辑器如sed
和awk
可能是最著名的,但是文本编辑器也属于这一类),但它们通常有一些限制。sed
和awk
都很慢,只能按顺序处理行,文本编辑器通常需要人工交互,或者至少学习一门专有的宏语言。你用iostreams
编写的程序没有这些限制:它们快速、可移植、灵活。
维护类库源代码
通常,当你创建一个类时,你用库的术语来考虑:你为类声明创建一个头文件Name.h
,然后创建一个名为Name.cpp
的文件,在那里实现成员函数。这些文件有一定的要求:特定的编码标准(这里显示的程序使用本书的编码格式),头文件中代码周围的预处理语句,以防止类的多重声明。(多个声明让编译器很困惑——它不知道你想用哪一个。它们可能是不同的,所以它抛出双手,给出一个错误信息。)
清单 19-23 创建一个新的文件头/实现对,或者修改一个现有的对。如果文件已经存在,它会检查并可能修改文件,但是如果文件不存在,它会使用正确的格式创建文件。
清单 19-23 。测试文件的一致性
//: C19:Cppcheck.cpp
// Configures .h & .cpp files to conform to style
// standard. Tests existing files for conformance.
#include <fstream>
#include <sstream>
#include <string>
#include <cstddef>
#include "../require.h"
using namespace std;
bool startsWith(const string& base, const string& key) {
return base.compare(0, key.size(), key) == 0;
}
void cppCheck(string fileName) {
enum bufs { BASE, HEADER, IMPLEMENT, HLINE1, GUARD1,
GUARD2, GUARD3, CPPLINE1, INCLUDE, BUFNUM };
string part[BUFNUM];
part[BASE] = fileName;
// Find any '.' in the string:
size_t loc = part[BASE].find('.');
if(loc != string::npos)
part[BASE].erase(loc); // Strip extension
// Force to upper case:
for(size_t i = 0; i < part[BASE].size(); i++)
part[BASE][i] = toupper(part[BASE][i]);
// Create file names and internal lines:
part[HEADER] = part[BASE] + ".h";
part[IMPLEMENT] = part[BASE] + ".cpp";
part[HLINE1] = "//" ": " + part[HEADER];
part[GUARD1] = "#ifndef " + part[BASE] + "_H";
part[GUARD2] = "#define " + part[BASE] + "_H";
part[GUARD3] = "#endif // " + part[BASE] +"_H";
part[CPPLINE1] = string("//") + ": " + part[IMPLEMENT];
part[INCLUDE] = "#include \"" + part[HEADER] + "\"";
// First, try to open existing files:
ifstream existh(part[HEADER].c_str()),
existcpp(part[IMPLEMENT].c_str());
if(!existh) { // Doesn't exist; create it
ofstream newheader(part[HEADER].c_str());
assure(newheader, part[HEADER].c_str());
newheader << part[HLINE1] << endl
<< part[GUARD1] << endl
<< part[GUARD2] << endl << endl
<< part[GUARD3] << endl;
} else { // Already exists; verify it
stringstream hfile; // Write & read
ostringstream newheader; // Write
hfile << existh.rdbuf();
// Check that first three lines conform:
bool changed = false;
string s;
hfile.seekg(0);
getline(hfile, s);
bool lineUsed = false;
// The call to good() is for Microsoft (later too):
for(int line = HLINE1; hfile.good() && line <= GUARD2;
++line) {
if(startsWith(s, part[line])) {
newheader << s << endl;
lineUsed = true;
if(getline(hfile, s))
lineUsed = false;
} else {
newheader << part[line] << endl;
changed = true;
lineUsed = false;
}
}
// Copy rest of file
if(!lineUsed)
newheader << s << endl;
newheader << hfile.rdbuf();
// Check for GUARD3
string head = hfile.str();
if(head.find(part[GUARD3]) == string::npos) {
newheader << part[GUARD3] << endl;
changed = true;
}
// If there were changes, overwrite file:
if(changed) {
existh.close();
ofstream newH(part[HEADER].c_str());
assure(newH, part[HEADER].c_str());
newH << "//@//\n" // Change marker
<< newheader.str();
}
}
if(!existcpp) { // Create cpp file
ofstream newcpp(part[IMPLEMENT].c_str());
assure(newcpp, part[IMPLEMENT].c_str());
newcpp << part[CPPLINE1] << endl
<< part[INCLUDE] << endl;
} else { // Already exists; verify it
stringstream cppfile;
ostringstream newcpp;
cppfile << existcpp.rdbuf();
// Check that first two lines conform:
bool changed = false;
string s;
cppfile.seekg(0);
getline(cppfile, s);
bool lineUsed = false;
for(int line = CPPLINE1;
cppfile.good() && line <= INCLUDE; ++line) {
if(startsWith(s, part[line])) {
newcpp << s << endl;
lineUsed = true;
if(getline(cppfile, s))
lineUsed = false;
} else {
newcpp << part[line] << endl;
changed = true;
lineUsed = false;
}
}
// Copy rest of file
if(!lineUsed)
newcpp << s << endl;
newcpp << cppfile.rdbuf();
// If there were changes, overwrite file:
if(changed) {
existcpp.close();
ofstream newCPP(part[IMPLEMENT].c_str());
assure(newCPP, part[IMPLEMENT].c_str());
newCPP << "//@//\n" // Change marker
<< newcpp.str();
}
}
}
int main(int argc, char* argv[]) {
if(argc > 1)
cppCheck(argv[1]);
else
cppCheck("cppCheckTest.h");
} ///:∼
首先,注意有用的函数startsWith()
,正如它的名字所说,如果第一个字符串参数以第二个参数开始,它将返回true
。这在查找预期的注释和与包含相关的语句时使用。有了字符串数组,part
,就可以在源代码中的一系列预期语句中轻松循环。如果源文件不存在,只需将语句写入一个给定名称的新文件中。如果文件确实存在,您可以一次搜索一行,验证预期的行是否出现。如果不存在,则插入它们。必须特别注意确保不要删除现有的行(参见布尔变量lineUsed
的位置)。请注意对现有文件使用了一个stringstream
,因此您可以首先将文件内容写入其中,然后读取和搜索它。
枚举中的名称是BASE
,大写的不带扩展名的基本文件名;HEADER
,头文件名称;IMPLEMENT
,实现文件(cpp
)名称;HLINE1
,头文件的骨架第一行;GUARD1
、GUARD2
和GUARD3
,头文件中的“保护”行(防止多重包含);CPPLINE1
,cpp
文件的骨架第一行;以及INCLUDE
,包含头文件的cpp
文件中的行。
如果不带任何参数运行该程序,将创建以下两个文件:
// CPPCHECKTEST.h
#ifndef CPPCHECKTEST_H
#define CPPCHECKTEST_H
#endif // CPPCHECKTEST_H
// CPPCHECKTEST.cpp
#include "CPPCHECKTEST.h" // To be INCLUDED from above
注意为了不混淆本书的代码提取器,我删除了第一行注释中双斜杠后的冒号。它会出现在cppCheck
产生的实际输出中。
您可以通过从这些文件中删除选定的行并重新运行程序来进行试验。每次你都会看到正确的行被添加回来。当文件被修改时,字符串“//@//
”被放置在文件的第一行,以引起您的注意。在再次处理文件之前,您需要删除这一行(否则cppCheck
将认为初始注释行丢失)。
检测编译器错误
本书中的所有代码都被设计成可以如所示编译而不会出错。应该生成编译时错误的代码行可以用特殊的注释序列“//!”注释掉。清单 19-24 将删除这些特殊注释,并在该行后面添加一个编号注释。当您运行您的编译器时,它应该会生成错误消息,并且当您编译所有文件时,您会看到所有的数字出现。这个程序还将修改后的行附加到一个特殊的文件中,这样您就可以很容易地找到任何不会产生错误的行。
清单 19-24 。取消注释错误生成器
//: C19:Showerr.cpp {RunByHand}
// Uncomment error generators.
#include <cstddef>
#include <cstdlib>
#include <cstdio>
#include <fstream>
#include <iostream>
#include <sstream>
#include <string>
#include "../require.h"
using namespace std;
const string USAGE =
"usage: showerr filename chapnum\n"
"where filename is a C++ source file\n"
"and chapnum is the chapter name it's in.\n"
"Finds lines commented with //! and removes\n"
"the comment, appending //(#) where # is unique\n"
"across all files, so you can determine\n"
"if your compiler finds the error.\n"
"showerr /r\n"
"resets the unique counter.";
class Showerr {
const int CHAP;
const string MARKER, FNAME;
// File containing error number counter:
const string ERRNUM;
// File containing error lines:
const string ERRFILE;
stringstream edited; // Edited file
int counter;
public:
Showerr(const string& f, const string& en,
const string& ef, int c)
: CHAP(c), MARKER("//!"), FNAME(f), ERRNUM(en),
ERRFILE(ef), counter(0) {}
void replaceErrors() {
ifstream infile(FNAME.c_str());
assure(infile, FNAME.c_str());
ifstream count(ERRNUM.c_str());
if(count) count >> counter;
int linecount = 1;
string buf;
ofstream errlines(ERRFILE.c_str(), ios::app);
assure(errlines, ERRFILE.c_str());
while(getline(infile, buf)) {
// Find marker at start of line:
size_t pos = buf.find(MARKER);
if(pos != string::npos) {
// Erase marker:
buf.erase(pos, MARKER.size() + 1);
// Append counter & error info:
ostringstream out;
out << buf << " // (" << ++counter << ") "
<< "Chapter " << CHAP
<< " File: " << FNAME
<< " Line " << linecount << endl;
edited << out.str();
errlines << out.str(); // Append error file
}
else
edited << buf << "\n"; // Just copy
++linecount;
}
}
void saveFiles() {
ofstream outfile(FNAME.c_str()); // Overwrites
assure(outfile, FNAME.c_str());
outfile << edited.rdbuf();
ofstream count(ERRNUM.c_str()); // Overwrites
assure(count, ERRNUM.c_str());
count << counter; // Save new counter
}
};
int main(int argc, char* argv[]) {
const string ERRCOUNT("../errnum.txt"),
ERRFILE("../errlines.txt");
requireMinArgs(argc, 1, USAGE.c_str());
if(argv[1][0] == '/' || argv[1][0] == '-') {
// Allow for other switches:
switch(argv[1][1]) {
case 'r': case 'R':
cout << "reset counter" << endl;
remove(ERRCOUNT.c_str()); // Delete files
remove(ERRFILE.c_str());
return EXIT_SUCCESS;
default:
cerr << USAGE << endl;
return EXIT_FAILURE;
}
}
if(argc == 3) {
Showerr s(argv[1], ERRCOUNT, ERRFILE, atoi(argv[2]));
s.replaceErrors();
s.saveFiles();
}
} ///:∼
你可以用你选择的一个来替换这个标记。
每个文件一次读取一行,并在每行中搜索出现在行首的标记;该行被修改并放入错误行列表和字符串流edited
。当整个文件被处理时,它被关闭(通过到达一个作用域的末尾),它作为一个输出文件被重新打开,并且edited
被注入文件。还要注意,计数器保存在外部文件中。下一次调用这个程序时,它继续增加计数器。
一个简单的数据记录器
清单 19-25 展示了一种方法,你可以把数据记录到磁盘上,然后再取出来进行处理。它的目的是制作海洋不同点的温度-深度剖面图。DataPoint
类保存数据。
清单 19-25 。展示了一个简单的数据记录器记录布局
//: C19:DataLogger.h
// Datalogger record layout.
#ifndef DATALOG_H
#define DATALOG_H
#include <ctime>
#include <iosfwd>
#include <string>
using std::ostream;
struct Coord {
int deg, min, sec;
Coord(int d = 0, int m = 0, int s = 0)
: deg(d), min(m), sec(s) {}
std::string toString() const;
};
ostream& operator<<(ostream&, const Coord&);
class DataPoint {
std::time_t timestamp; // Time & day
Coord latitude, longitude;
double depth, temperature;
public:
DataPoint(std::time_t ts, const Coord& lat,
const Coord& lon, double dep, double temp)
: timestamp(ts), latitude(lat), longitude(lon),
depth(dep), temperature(temp) {}
DataPoint() : timestamp(0), depth(0), temperature(0) {}
friend ostream& operator<<(ostream&, const DataPoint&);
};
#endif // DATALOG_H ///:∼
一个DataPoint
由一个时间戳、经度和纬度坐标以及深度和温度值组成,时间戳存储为一个在<ctime>
中定义的time_t
值。请注意,使用插入符是为了方便格式化。清单 19-26 包含了实现文件。
清单 19-26 。实现清单 19-25 中的头文件(DataLogger.h)
//: C19:DataLogger.cpp {O}
// Datapoint implementations.
#include "DataLogger.h"// To be INCLUDED from Header FILE above
#include <iomanip>
#include <iostream>
#include <sstream>
#include <string>
using namespace std;
ostream& operator<<(ostream& os, const Coord& c) {
return os << c.deg << '*' << c.min << '\''
<< c.sec << '"';
}
string Coord::toString() const {
ostringstream os;
os << *this;
return os.str();
}
ostream& operator<<(ostream& os, const DataPoint& d) {
os.setf(ios::fixed, ios::floatfield);
char fillc = os.fill('0'); // Pad on left with '0'
tm* tdata = localtime(&d.timestamp);
os << setw(2) << tdata->tm_mon + 1 << '\\'
<< setw(2) << tdata->tm_mday << '\\'
<< setw(2) << tdata->tm_year+1900 << ' '
<< setw(2) << tdata->tm_hour << ':'
<< setw(2) << tdata->tm_min << ':'
<< setw(2) << tdata->tm_sec;
os.fill(' '); // Pad on left with ' '
streamsize prec = os.precision(4);
os << " Lat:" << setw(9) << d.latitude.toString()
<< ", Long:" << setw(9) << d.longitude.toString()
<< ", depth:" << setw(9) << d.depth
<< ", temp:" << setw(9) << d.temperature;
os.fill(fillc);
os.precision(prec);
return os;
} ///:∼
因为DataPoint
插入器在打印纬度和经度之前调用setw()
,所以Coord::toString()
功能是必要的。如果您使用Coord
的流插入器,宽度将仅适用于第一次插入(即Coord::deg
),因为宽度变化总是立即重置。对setf()
的调用导致浮点输出为固定精度,并且precision()
将小数位数设置为 4。注意如何将填充字符和精度恢复到调用插入器之前的状态。
为了从存储在DataPoint::timestamp
中的时间编码中获取值,您调用函数std::localtime()
,该函数返回一个指向tm
对象的静态指针。tmstruct
的布局如下:
struct tm {
int tm_sec; // 0-59 seconds
int tm_min; // 0-59 minutes
int tm_hour; // 0-23 hours
int tm_mday; // Day of month
int tm_mon; // 0-11 months
int tm_year; // Years since 1900
int tm_wday; // Sunday == 0, etc.
int tm_yday; // 0-365 day of year
int tm_isdst; // Daylight savings?
};
生成测试数据
清单 19-27 使用write()
创建一个二进制形式的测试数据文件,并使用DataPoint
插入器创建第二个 ASCII 形式的文件。您也可以将它打印到屏幕上,但以文件形式查看更容易。
清单 19-27 。说明测试数据的生成(使用 write()和数据点插入器)
//: C19:Datagen.cpp
// Test data generator.
//{L} DataLogger
#include <cstdlib>
#include <ctime>
#include <cstring>
#include <fstream>
#include "DataLogger.h"
#include "../require.h"
using namespace std;
int main() {
time_t timer;
srand(time(&timer)); // Seed the random number generator
ofstream data("data.txt");
assure(data, "data.txt");
ofstream bindata("data.bin", ios::binary);
assure(bindata, "data.bin");
for(int i = 0; i < 100; i++, timer += 55) {
// Zero to 199 meters:
double newdepth = rand() % 200;
double fraction = rand() % 100 + 1;
newdepth += 1.0 / fraction;
double newtemp = 150 + rand() % 200; // Kelvin
fraction = rand() % 100 + 1;
newtemp += 1.0 / fraction;
const DataPoint d(timer, Coord(45,20,31),
Coord(22,34,18), newdepth,
newtemp);
data << d << endl;
bindata.write(reinterpret_cast<const char*>(&d),
sizeof(d));
}
} ///:∼
文件data.txt
以普通方式创建为 ASCII 文件,但是data.bin
有一个标志ios::binary
来告诉构造器将其设置为二进制文件。为了说明文本文件使用的格式,下面是第一行data.txt
(该行换行是因为它的长度超过了该页面允许的长度):
07\28\2003 12:54:40 Lat:45*20'31", Long:22*34'18", depth: 16.0164, temp: 242.0122
标准 C 库函数time()
用当前时间的编码更新其参数指向的time_t
值,在大多数平台上,当前时间是自 1970 年 1 月 1 日格林威治时间 00:00:00(宝瓶座时代的开始)以来经过的秒数?)。当前时间也是用标准 C 库函数srand()
生成随机数生成器的一种便捷方式,就像这里所做的一样。
在此之后,timer
增加 55 秒,以给出该模拟中读数之间的有趣间隔。
所用的纬度和经度是固定值,用来表示单个位置的一组读数。深度和温度都由标准 C 库rand()
函数生成,该函数返回一个介于零和平台相关常量RAND_MAX
之间的伪随机数,该常量在<cstdlib>
中定义(通常是平台最大无符号整数的值)。要将它放入期望的范围,使用余数运算符%
和范围的上限。这些数字是整数;要添加一个小数部分,第二次调用rand()
,值在加 1 后反转(防止被零除的错误)。
实际上,data.bin
文件被用作程序中数据的容器,尽管这个容器存在于磁盘上,而不在 ram 中。write()
以二进制形式将数据发送到磁盘。第一个参数是源块的起始地址——注意它必须被转换成一个char*
,因为这是write()
对窄流的期望。第二个参数是要写入的字符数,在本例中是DataPoint
对象的大小(同样,因为使用了窄流)。因为DataPoint
中没有包含指针,所以将对象写入磁盘没有问题。如果对象更复杂,您必须实现一个序列化的方案,它写入指针引用的数据,并在以后读回时定义新的指针。
验证和查看数据
为了检查以二进制格式存储的数据的有效性,您可以使用输入流的read()
成员函数将它读入内存,并与之前由Datagen.cpp
创建的文本文件进行比较。清单 19-28 只是将格式化的结果写到cout
,但是你可以将它重定向到一个文件,然后使用一个文件比较工具来验证它是否与原始文件相同。
清单 19-28 。扫描并验证二进制数据(对照清单 19-27 中由 Datagen.cpp 创建的文本文件)
//: C19:Datascan.cpp
//{L} DataLogger
#include <fstream>
#include <iostream>
#include "DataLogger.h"
#include "../require.h"
using namespace std;
int main() {
ifstream bindata("data.bin", ios::binary);
assure(bindata, "data.bin");
DataPoint d;
while(bindata.read(reinterpret_cast<char*>(&d),
sizeof d))
cout << d << endl;
} ///:∼
国际化
软件业现在是一个健康的全球经济市场,对可以在各种语言和文化中运行的应用程序有需求。早在 20 世纪 80 年代末,C 标准委员会就通过他们的地区机制增加了对非美国格式约定的支持。区域设置是显示某些实体(如日期和货币数量)的一组首选项。在 20 世纪 90 年代,C 标准委员会批准了标准 C 的附录,指定了处理宽字符(由类型 wchar_t
表示)的函数,允许支持除 ASCII 及其常用的西欧扩展以外的字符集。尽管没有指定宽字符的大小,但一些平台将它们实现为 32 位量,因此它们可以保存 Unicode Consortium 指定的编码,以及到亚洲标准团体定义的多字节字符集的映射。C++ 在 iostreams 库中集成了对宽字符和地区的支持。
宽流
宽流是处理宽字符的流类 。到目前为止,大多数例子都使用了保存char
实例的窄流。因为不管底层字符类型如何,流操作本质上都是相同的,所以它们一般被封装为模板。例如,所有输入流都连接到basic_istream
类模板,如:
template<class charT, class traits = char_traits<charT>>
class basic_istream {...};
事实上,根据下面的类型定义 : ,所有的输入流类型都是这个模板的专门化
typedef basic_istream<char> istream;
typedef basic_istream<wchar_t> wistream;
typedef basic_ifstream<char> ifstream;
typedef basic_ifstream<wchar_t> wifstream;
typedef basic_istringstream<char> istringstream;
typedef basic_istringstream<wchar_t> wistringstream;
所有其他流类型都以类似的方式定义。
在一个完美的世界中,这是你创建不同字符类型的流所需要的。但是事情没那么简单。原因是为char
和wchar_t
提供的字符处理函数没有相同的名称。例如,要比较两个窄字符串,可以使用strcmp()
函数。对于宽字符,该函数被命名为wcscmp()
。(记住这些起源于 C,它没有函数重载,因此需要唯一的名字。)因此,通用流不能只调用strcmp()
来响应比较操作符。需要有一种方法来自动调用正确的低级函数。
解决方案是将差异分解成一个新的抽象。你可以对角色执行的操作已经被抽象到了char_traits
模板中,该模板已经为char
和wchar_t
预定义了专门化,正如上一章末尾所讨论的。为了比较两个字符串,然后,basic_string
只是调用traits::compare()
(记住traits
是第二个模板参数),后者又调用strcmp()
或wcscmp()
,这取决于使用的是哪个专门化(对basic_string
透明)。
你只需要关心char_traits
如果你访问低级的字符处理函数;大多数时候你都不在乎。但是,考虑一下,通过将插入器和提取器定义为模板来使它们更加健壮,以防有人想在更宽的流上使用它们。
为了说明这一点,再次回忆一下本章开头的Date
类插入器。它最初被宣布为
ostream& operator<<(ostream&, const Date&);
这只能容纳狭窄的溪流。为了使它通用,您只需使它成为基于basic_ostream
的模板,如:
template<class charT, class traits>
std::basic_ostream<charT, traits>&
operator<<(std::basic_ostream<charT, traits>& os,
const Date& d) {
charT fillc = os.fill(os.widen('0'));
charT dash = os.widen('-');
os << setw(2) << d.month << dash<< setw(2) << d.day
<< dash<< setw(4) << d.year;
os.fill(fillc);
return os;
}
注意,在fillc
的声明中,您还必须用模板参数charT
替换char
,因为它可以是char
或wchar_t
,这取决于所使用的模板实例化。
因为您不知道在编写模板时您拥有哪种类型的流,所以您需要一种方法来自动将字符文字转换成流的正确大小。这是widen()
成员函数的工作。例如,如果流是宽流,则表达式widen('-')
将其参数转换为L'-'
(字面语法等同于转换wchar_t('-')
),否则不进行处理。还有一个narrow()
功能,如果需要可以转换成char
。
您可以使用widen()
编写本章前面提到的nl
操纵器的通用版本,如:
template<class charT, class traits>
basic_ostream<charT,traits>&
nl(basic_ostream<charT,traits>& os) {
return os << charT(os.widen('\n'));
}
处所
也许国与国之间典型的数字计算机输出最显著的区别是用来分隔实数的整数和小数部分的标点符号。在印度,句点表示小数点,但在世界上的大多数地方,逗号应该被替换。为依赖于区域设置的显示自行设置格式是很不方便的。再一次,创建一个处理这些差异的抽象解决了这个问题。
这个抽象就是场所。所有流都有一个关联的 locale 对象,用于指导如何为不同的文化环境显示特定的数量。区域设置管理依赖于文化的显示规则的类别,这些规则在表 19-9 中定义。
表 19-9 。由与流相关联的区域设置管理的依赖于区域性的显示规则的类别
种类 | 影响 |
---|---|
核对 | 允许根据不同的、受支持的排序序列比较字符串。 |
C 型 | 抽象出 |
货币的 | 支持货币数量的不同显示。 |
数字的 | 支持实数的不同显示格式,包括基数(小数点)和分组(千位)分隔符。 |
时间 | 支持各种国际格式的日期和时间显示。 |
信息 | 搭建来实现上下文相关的消息目录(比如不同语言的错误消息)。 |
清单 19-29 展示了基本的区域行为。
清单 19-29 。说明地区的影响
//: C19:Locale.cpp {-g++}{-bor}{-edg} {RunByHand}
// Illustrates effects of locales.
#include <iostream>
#include <locale>
using namespace std;
int main() {
locale def;
cout << def.name() << endl;
locale current = cout.getloc();
cout << current.name() << endl;
float val = 1234.56;
cout << val << endl;
// Change to French/France
cout.imbue(locale("french"));
current = cout.getloc();
cout << current.name() << endl;
cout << val << endl;
cout << "Enter the literal 7890,12: ";
cin.imbue(cout.getloc());
cin >> val;
cout << val << endl;
cout.imbue(def);
cout << val << endl;
} ///:∼
以下是输出结果:
C
C
1234.56
French_France.1252
1234,56
Enter the literal 7890,12: 7890,12
7890,12
7890.12
默认的区域设置是“C”区域设置,这是 C 和 C++ 程序员这些年来已经习惯的(基本上就是英语和美国文化)。所有流最初都被“灌输”了“C”语言环境。
成员函数改变了流使用的语言环境。请注意,显示了“法语”地区的完整 ISO 名称(即,在法国使用的法语与在其他国家使用的法语)。此示例显示该区域设置在数字显示中使用逗号作为基点。如果你想根据这个地区的规则进行输入,你必须将cin
改变到相同的地区。
每一个地区类别都被分成若干个方面,这些方面是封装了与该类别相关的功能的类。例如,time
类别有面time_put
和time_get
,它们分别包含处理时间和日期的函数input
和output
。monetary
类别有刻面money_get
、money_put
和moneypunct
。(后一方面决定了货币符号。)
清单 19-30 展示了moneypunct
方面。(time
方面需要迭代器的复杂使用,这超出了本章的范围。 )
清单 19-30 。说明“moneypunct”方面
//: C19:Facets.cpp {-bor}{-g++}{-mwcc}{-edg}
#include <iostream>
#include <locale>
#include <string>
using namespace std;
int main() {
// Change to French/France
locale loc("french");
cout.imbue(loc);
string currency =
use_facet<moneypunct<char>>(loc).curr_symbol();
char point =
use_facet<moneypunct<char>>(loc).decimal_point();
cout << "I made " << currency << 12.34 << " today!"
<< endl;
} ///:∼
输出显示了法国货币符号和小数点分隔符:
I made Ç12,34 today!
您还可以定义自己的方面来构建定制的语言环境。请注意,区域设置的开销相当大。事实上,一些库供应商提供了不同“风格”的标准 C++ 库,以适应空间有限的环境。
审查会议
- 本章已经给了你一个关于
iostream
类库的相当全面的介绍。 - 您在这里看到的可能是您使用
iostreams
创建程序所需的全部内容。 - 然而,要知道
iostreams
中的一些附加特性并不经常使用,但是你可以通过查看iostream
头文件和来发现它们,方法是在iostreams
上阅读你的编译器文档。*
二十、运行时类型识别(RTTI)
当您只有指向基类型的指针或引用时,运行时类型标识(RTTI)允许您查找对象的动态类型。
这可以被认为是 C++ 中的一个“次要”特性,当你陷入罕见的困境时,实用主义可以帮助你。通常,您会有意忽略对象的确切类型,让虚函数机制实现该类型的正确行为。然而,有时候,知道一个只有一个基指针的对象的确切的运行时(也就是最派生的)类型是有用的。有了这些信息,您可以更有效地执行特殊情况操作,或者防止基类接口变得笨拙。大多数类库都包含虚函数来产生运行时类型信息,这种情况时有发生。当异常处理被添加到 C++ 中时,该特性需要关于对象的运行时类型的信息,因此下一步构建对该信息的访问变得很容易。本章解释了 RTTI 的用途以及如何使用它。
运行时强制转换
通过指针或引用确定对象的运行时类型的一种方法是使用运行时转换,它验证尝试的转换是有效的。当您需要将基类指针强制转换为派生类型时,这很有用。由于继承层次结构通常用派生类之上的基类来描述,这样的转换被称为向下转换。考虑图 20-1 中的等级结构。
图 20-1 。一个投资阶层的等级体系
在清单 20-1 中,Investment
类有一个其他类没有的额外操作,所以在运行时知道Security
指针是否指向Investment
对象是很重要的。为了实现检查的运行时强制转换,每个类都保留一个整数标识符,以区别于层次结构中的其他类。
清单 20-1 。运行时检查强制转换
//: C20:CheckedCast.cpp
// Checks casts at runtime.
#include <iostream>
#include <vector>
#include "../purge.h" // SEE ahead in this Section
using namespace std;
class Security {
protected:
enum { BASEID = 0 };
public:
virtual ∼Security() {}
virtual bool isA(int id) { return (id == BASEID); }
};
class Stock : public Security {
typedef Security Super;
protected:
enum { OFFSET = 1, TYPEID = BASEID + OFFSET };
public:
bool isA(int id) {
return id == TYPEID || Super::isA(id);
}
static Stock* dynacast(Security* s) {
return (s->isA(TYPEID)) ? static_cast<Stock*>(s) : 0;
}
};
class Bond : public Security {
typedef Security Super;
protected:
enum { OFFSET = 2, TYPEID = BASEID + OFFSET };
public:
bool isA(int id) {
return id == TYPEID || Super::isA(id);
}
static Bond* dynacast(Security* s) {
return (s->isA(TYPEID)) ? static_cast<Bond*>(s) : 0;
}
};
class Investment : public Security {
typedef Security Super;
protected:
enum { OFFSET = 3, TYPEID = BASEID + OFFSET };
public:
bool isA(int id) {
return id == TYPEID || Super::isA(id);
}
static Investment* dynacast(Security* s) {
return (s->isA(TYPEID)) ?
static_cast<Investment*>(s) : 0;
}
void special() {
cout << "special Investment function" << endl;
}
};
class Metal : public Investment {
typedef Investment Super;
protected:
enum { OFFSET = 4, TYPEID = BASEID + OFFSET };
public:
bool isA(int id) {
return id == TYPEID || Super::isA(id);
}
static Metal* dynacast(Security* s) {
return (s->isA(TYPEID)) ? static_cast<Metal*>(s) : 0;
}
};
int main() {
vector<Security*> portfolio;
portfolio.push_back(new Metal);
portfolio.push_back(new Investment);
portfolio.push_back(new Bond);
portfolio.push_back(new Stock);
for(vector<Security*>::iterator it = portfolio.begin();
it != portfolio.end(); ++it) {
Investment* cm = Investment::dynacast(*it);
if(cm)
cm->special();
else
cout << "not an Investment" << endl;
}
cout << "cast from intermediate pointer:" << endl;
Security* sp = new Metal;
Investment* cp = Investment::dynacast(sp);
if(cp) cout << " it's an Investment" << endl;
Metal* mp = Metal::dynacast(sp);
if(mp) cout << " it's a Metal too!" << endl;
purge(portfolio);
} ///:∼
//: :purge.h
// Delete pointers in an STL sequence container
#ifndef PURGE_H
#define PURGE_H
#include <algorithm>
template<class Seq> void purge(Seq& c) {
typename Seq::iterator i;
for(i = c.begin(); i != c.end(); i++) {
delete *i;
*i = 0;
}
}
// Iterator version:
template<class InpIt>
void purge(InpIt begin, InpIt end) {
while(begin != end) {
delete *begin;
*begin = 0;
begin++;
}
}
#endif // PURGE_H ///:∼
多态函数isA()
检查它的参数是否与其类型参数(id
)兼容,这意味着要么id
与对象的typeID
完全匹配,要么它与对象的祖先之一匹配(在这种情况下,因此调用Super::isA()
)。在每个类中都是静态的dynacast()
函数调用isA()
作为其指针参数,以检查强制转换是否有效。如果isA()
返回true
,则转换有效,并返回一个适当转换的指针。否则,将返回空指针,这告诉调用方强制转换无效,意味着原始指针没有指向与所需类型兼容(可转换为)的对象。所有这些机制都是检查中间类型转换所必需的,比如从指向Metal
对象的Security
指针到清单 20-1 中的Investment
指针。
对于大多数程序来说,向下转换是不必要的,实际上也是不鼓励的,因为日常的多态解决了面向对象应用程序中的大多数问题。但是,对于调试器、类浏览器和数据库之类的实用程序来说,检查向更派生类型的强制转换的能力非常重要。C++ 用dynamic_cast
操作符提供了这种检查转换。清单 20-2 是使用dynamic_cast
的前一个例子的重写。
清单 20-2 。使用 dynamic_cast 修改清单 20-1
//: C20:Security.h
#ifndef SECURITY_H
#define SECURITY_H
#include <iostream>
class Security {
public:
virtual ∼Security() {}
};
class Stock : public Security {};
class Bond : public Security {};
class Investment : public Security {
public:
void special() {
std::cout << "special Investment function” << std::endl;
}
};
class Metal : public Investment {};
#endif // SECURITY_H ///:∼
//: C20:CheckedCast2.cpp
// Uses RTTI's dynamic_cast.
#include <vector>
#include "../purge.h"
#include "Security.h" // To be INCLUDED from Header FILE above
using namespace std;
int main() {
vector<Security*> portfolio;
portfolio.push_back(new Metal);
portfolio.push_back(new Investment);
portfolio.push_back(new Bond);
portfolio.push_back(new Stock);
for(vector<Security*>::iterator it =
portfolio.begin();
it != portfolio.end(); ++it) {
Investment* cm = dynamic_cast<Investment*>(*it);
if(cm)
cm->special();
else
cout << "not a Investment" << endl;
}
cout << "cast from intermediate pointer:” << endl;
Security* sp = new Metal;
Investment* cp = dynamic_cast<Investment*>(sp);
if(cp) cout << " it's an Investment” << endl;
Metal* mp = dynamic_cast<Metal*>(sp);
if(mp) cout << " it's a Metal too!” << endl;
purge(portfolio);
} ///:∼
这个例子要短得多,因为原始例子中的大部分代码只是检查类型转换的开销。dynamic_cast
的目标类型放在尖括号中,就像其他新式 C++ 强制转换一样(static_cast
等等),要强制转换的对象作为操作数出现。如果你想要安全的向下转换,dynamic_cast
要求你使用的类型是多态的。这反过来要求该类必须至少有一个虚函数。幸运的是,Security
基类有一个虚拟析构函数,所以我们不必发明一个额外的函数来完成这项工作。因为dynamic_cast
使用虚拟表在运行时工作,所以它比其他新类型的类型转换更昂贵。
您也可以将dynamic_cast
与引用一起使用,而不是指针,但是因为没有空引用这种东西,所以您需要另一种方法来知道强制转换是否失败。那个“其他办法”就是抓一个bad_cast
异常 ,如清单 20-3 所示。
清单 20-3 。捕获 bad_cast 异常
//: C20:CatchBadCast.cpp
#include <typeinfo>
#include "Security.h"
using namespace std;
int main() {
Metal m;
Security& s = m;
try {
Investment& c = dynamic_cast<Investment&>(s);
cout << "It's an Investment" << endl;
} catch(bad_cast&) {
cout << "s is not an Investment type" << endl;
}
try {
Bond& b = dynamic_cast<Bond&>(s);
cout << "It's a Bond" << endl;
} catch(bad_cast&) {
cout << "It's not a Bond type" << endl;
}
} ///:∼
bad_cast
类在<typeinfo>
头文件中定义,并且像大多数标准 C++ 库一样,在std
名称空间中声明。
typeid 运算符
获取对象运行时信息的另一种方法是通过typeid
操作符。这个操作符返回一个类为type_info
的对象,它产生关于它所应用的对象类型的信息。如果类型是多态的,它给出了关于最适用的派生类型(动态类型)的信息;否则,它会产生静态类型信息。typeid
操作符的一个用途是获取一个对象的动态类型名作为一个const char*
,正如你在清单 20-4 中看到的。
清单 20-4 。阐释 typeid 运算符的用法
//: C20:TypeInfo.cpp
// Illustrates the typeid operator.
#include <iostream>
#include <typeinfo>
using namespace std;
struct PolyBase { virtual ∼PolyBase() {} };
struct PolyDer : PolyBase { PolyDer() {} };
struct NonPolyBase {};
struct NonPolyDer : NonPolyBase { NonPolyDer(int) {} };
int main() {
// Test polymorphic Types
const PolyDerpd;
const PolyBase* ppb = &pd;
cout << typeid(ppb).name() << endl;
cout << typeid(*ppb).name() << endl;
cout << boolalpha << (typeid(*ppb) == typeid(pd))
<< endl;
cout << (typeid(PolyDer) == typeid(const PolyDer))
<< endl;
// Test non-polymorphic Types
const NonPolyDernpd(1);
const NonPolyBase* nppb = &npd;
cout << typeid(nppb).name() << endl;
cout << typeid(*nppb).name() << endl;
cout << (typeid(*nppb) == typeid(npd)) << endl;
// Test a built-in type
int i;
cout << typeid(i).name() << endl;
} ///:∼
这个程序使用一个特定的编译器的输出是
struct PolyBase const *
struct PolyDer
true
true
struct NonPolyBase const *
struct NonPolyBase
false
int
第一个输出行只是回显了ppb
的静态类型,因为它是指针。要让 RTTI 开始工作,您需要查看指针或引用目标对象,如第二行所示。注意,RTTI 忽略了顶级的const
和volatile
限定符。对于非多态类型,您只能获得静态类型(指针本身的类型)。如您所见,内置类型也受支持。
原来你不能在一个type_info
对象中存储一个typeid
操作的结果,因为没有可访问的构造器,赋值是不允许的。你必须像我们展示的那样使用它。此外,type_info::name()
返回的实际字符串是依赖于编译器的。例如,对于一个名为C
的类,一些编译器返回“C 类”而不仅仅是“C”,将typeid
应用到一个解引用空指针的表达式将导致抛出bad_typeid
异常(也在<typeinfo>
中定义)。
清单 20-5 显示了type_info::name()
返回的类名是完全限定的。
清单 20-5 。说明了 RTTI 和嵌套和
//: C20:RTTIandNesting.cpp
#include <iostream>
#include <typeinfo>
using namespace std;
class One {
class Nested {};
Nested* n;
public:
One() : n(new Nested) {}
∼One() { delete n; }
Nested* nested() { return n; }
};
int main() {
One o;
cout << typeid(*o.nested()).name() << endl;
} ///:∼
因为Nested
是One
类的成员类型,所以结果是One::Nested
。
您还可以使用before(type_info&)
询问一个type_info
对象是否在实现定义的“排序序列”(文本的本地排序规则)中位于另一个type_info
对象之前,这将返回true
或false
。当你说,
if(typeid(me).before(typeid(you))) // ...
您在询问在当前的排序序列中,me
是否出现在you
之前。如果你使用type_info
对象作为关键点,这很有用。
铸造到中级水平
正如您在使用了Security
类的层次结构的清单 20-2 中所看到的,dynamic_cast
可以检测精确类型,并且在具有多个层次的继承层次结构中,可以检测中间类型。清单 20-6 是另一个例子。
清单 20-6 。说明了中间铸造
//: C20:IntermediateCast.cpp
#include <cassert>
#include <typeinfo>
using namespace std;
class B1 {
public:
virtual ∼B1() {}
};
class B2 {
public:
virtual ∼B2() {}
};
class MI : public B1, public B2 {};
class Mi2 : public MI {};
int main() {
B2* b2 = new Mi2;
Mi2* mi2 = dynamic_cast<Mi2*>(b2);
MI* mi = dynamic_cast<MI*>(b2);
B1* b1 = dynamic_cast<B1*>(b2);
assert(typeid(b2) != typeid(Mi2*));
assert(typeid(b2) == typeid(B2*));
delete b2;
} ///:∼
注意下面三行代码
Mi2* mi2 = dynamic_cast<Mi2*>(b2);
MI* mi = dynamic_cast<MI*>(b2);
B1* b1 = dynamic_cast<B1*>(b2);
可能会导致编译器(如 XCode)发出“未使用的变量”警告,但此示例的目的只是为了说明 dynamic_cast 既可以检测精确类型,也可以在多级继承层次结构中检测中间类型。
这个例子有额外的复杂性多重继承(你将在本章后面了解更多关于多重继承的知识)。如果您创建一个Mi2
并将其向上转换到根(在这种情况下,选择两个可能的根中的一个),那么dynamic_cast
返回到派生级别MI
或Mi2
是成功的。
您甚至可以从一个根转换到另一个根,如:
B1* b1 = dynamic_cast<B1*>(b2);
这是成功的,因为B2
实际上是指向一个Mi2
对象,它包含一个B1
类型的子对象。
铸造到中级带来了一个有趣的差异dynamic_cast
和typeid
。typeid
操作符总是产生一个静态type_info
对象的引用,描述对象的动态类型。因此,它不能给你中级水平的信息。在下面的表达式(也就是true
)中,typeid
不像dynamic_cast
那样将b2
视为指向派生类型的指针:
typeid(b2) != typeid(Mi2*)
b2 的类型就是指针的确切类型,如:
typeid(b2) == typeid(B2*)
虚空指针
RTTI 只适用于完整的类型,这意味着当使用typeid
时,所有的类信息都必须可用。特别是,它不能与void
指针一起工作,正如你在清单 20-7 中看到的。
清单 20-7 。阐释了 RTTI 指针和空指针
//: C20:VoidRTTI.cpp
// RTTI & void pointers.
//!#include <iostream>
#include <typeinfo>
using namespace std;
classStimpy {
public:
virtual void happy() {}
virtual void joy() {}
virtual ∼Stimpy() {}
};
int main() {
void* v = new Stimpy;
// Error:
//! Stimpy* s = dynamic_cast<Stimpy*>(v);
// Error:
//! cout<<typeid(*v).name() <<endl;
} ///:∼
真正的意思是“没有类型信息”
使用带有模板的 RTTI
类模板与 RTTI 配合得很好,因为它们所做的只是生成类。像往常一样,RTTI 提供了一种便捷的方式来获取您所在的类的名称。清单 20-8 打印构造器和析构函数调用的顺序。
清单 20-8 。打印构造器/析构函数调用的顺序
//: C20:ConstructorOrder.cpp
// Order of constructor calls.
#include <iostream>
#include <typeinfo>
using namespace std;
template<int id> class Announce {
public:
Announce() {
cout << typeid(*this).name() << " constructor" << endl;
}
∼Announce() {
cout << typeid(*this).name() << " destructor" << endl;
}
};
class X : public Announce<0> {
Announce<1> m1;
Announce<2> m2;
public:
X() { cout << "X::X()" << endl; }
∼X() { cout << "X::∼X()" << endl; }
};
int main() { X x; } ///:∼
这个模板使用一个常量int
来区分不同的类,但是类型参数也可以。在构造器和析构函数中,RTTI 信息产生要打印的类名。类X
使用继承和组合来创建一个类,这个类有一个有趣的构造器和析构函数调用顺序。
输出是
Announce<0> constructor
Announce<1> constructor
Announce<2> constructor
X::X()
X::∼X()
Announce<2> destructor
Announce<1> destructor
Announce<0> destructor
当然,根据编译器如何表示其name()
信息,您可能会得到不同的输出。
多重继承
RTTI 机制必须与多重继承的所有复杂性一起正常工作,包括virtual
基类,如清单 20-9 所示(在下一章将深入讨论——你可能想在阅读完第二十一章后回到这里)。
清单 20-9 。说明了 RTTI 和多重继承
//: C20:RTTIandMultipleInheritance.cpp
#include <iostream>
#include <typeinfo>
using namespace std;
class BB {
public:
virtual void f() {}
virtual ∼BB() {}
};
class B1 : virtual public BB {};
class B2 : virtual public BB {};
class MI : public B1, public B2 {};
int main() {
BB* bbp = new MI; // Upcast
// Proper name detection:
cout << typeid(*bbp).name() << endl;
// Dynamic_cast works properly:
MI* mip = dynamic_cast<MI*>(bbp);
// Can't force old-style cast:
//! MI* mip2 = (MI*)bbp; // Compile error
} ///:∼
typeid()
操作符正确地检测实际对象的名称,甚至通过virtual
基类指针。dynamic_cast
也能正常工作。但是编译器甚至不允许你尝试用老方法强制转换,比如:
MI* mip = (MI*)bbp; // Compile-time error
编译器知道这从来都不是正确的做法,所以它要求您使用dynamic_cast
。
RTTI 的合理使用
因为您可以从匿名多态指针中发现类型信息,所以 RTTI 很容易被新手误用,因为 RTTI 可能比虚函数更有意义。对于许多来自程序背景的人来说,很难不将程序组织成一组switch
语句。他们可以用 RTTI 来实现这一点,从而失去了多态在代码开发和维护中的重要价值。C++ 的意图是在代码中使用虚函数,并且只在必要时使用 RTTI。
然而,使用虚函数需要您控制基类定义,因为在程序扩展的某个时候,您可能会发现基类不包含您需要的虚函数。如果基类来自一个库或者被其他人控制,这个问题的一个解决方案是 RTTI;你可以派生一个新的类型并添加额外的成员函数。在代码的其他地方,您可以检测您的特定类型并调用该成员函数。这不会破坏程序的多态和可扩展性,因为添加一个新类型不需要您寻找 switch 语句。然而,当您在主体中添加需要您的新特性的新代码时,您必须检测您的特定类型。
将一个特性放在基类中可能意味着,为了一个特定类的利益,从该基类派生的所有其他类都需要一些无意义的存根来实现一个纯虚函数。这使得界面不太清晰,并且惹恼了那些必须在从基类派生纯虚函数时重写它们的人。
最后,RTTI 有时会解决效率问题。如果您的代码以一种很好的方式使用了多态,但结果是您的一个对象以一种非常低效的方式对这个通用代码做出反应,您可以使用 RTTI 挑选出那个类型,并编写特定于案例的代码来提高效率。
一个垃圾回收商
为了进一步说明 RTTI 、、的实际使用,清单 20-10 模拟了一个垃圾回收器。不同种类的“垃圾”被放入一个容器中,然后根据它们的动态类型进行分类。
清单 20-10 。模拟垃圾回收器
//: C20:Trash.h
// Describing trash.
#ifndef TRASH_H
#define TRASH_H
#include <iostream>
class Trash {
float _weight;
public:
Trash(float wt) : _weight(wt) {}
virtual float value() const = 0;
float weight() const { return _weight; }
virtual ∼Trash() {
std::cout << "∼Trash()" << std::endl;
}
};
class Aluminum : public Trash {
static float val;
public:
Aluminum(float wt) : Trash(wt) {}
float value() const { return val; }
static void value(float newval) {
val = newval;
}
};
class Paper : public Trash {
static float val;
public:
Paper(float wt) : Trash(wt) {}
float value() const { return val; }
static void value(float newval) {
val = newval;
}
};
class Glass : public Trash {
static float val;
public:
Glass(float wt) : Trash(wt) {}
float value() const { return val; }
static void value(float newval) {
val = newval;
}
};
#endif // TRASH_H ///:∼
代表垃圾类型的单位价格的static
值在实现文件中定义(清单 20-11 )。
清单 20-11 。实现清单 20-10 (Trash.h)中的头文件
//: C20:Trash.cpp {O}
// A Trash Recycler.
#include "Trash.h" // To be INCLUDED from Header FILE above
float Aluminum::val = 1.67;
float Paper::val = 0.10;
float Glass::val = 0.23;
///:∼
sumValue()
模板遍历一个容器,显示和计算结果,如清单 20-12 所示。
清单 20-12 。使用 sumValue()模板演示回收
//: C20:Recycle.cpp
//{L} Trash
// A Trash Recycler.
#include <cstdlib>
#include <ctime>
#include <iostream>
#include <typeinfo>
#include <vector>
#include "Trash.h"
#include "../purge.h"
using namespace std;
// Sums up the value of the Trash in a bin:
template<class Container>
void sumValue(Container& bin, ostream&os) {
typename Container::iterator tally = bin.begin();
floatval = 0;
while(tally != bin.end()) {
val += (*tally)->weight() * (*tally)->value();
os << "weight of " << typeid(**tally).name()
<< " = " << (*tally)->weight() << endl;
++tally;
}
os << "Total value = " << val << endl;
}
int main() {
srand(time(0)); // Seed the random number generator
vector<Trash*> bin;
// Fill up the Trash bin:
for(int i = 0; i < 30; i++)
switch(rand() % 3) {
case 0 :
bin.push_back(new Aluminum((rand() % 1000)/10.0));
break;
case 1 :
bin.push_back(new Paper((rand() % 1000)/10.0));
break;
case 2 :
bin.push_back(new Glass((rand() % 1000)/10.0));
break;
}
// Note: bins hold exact type of object, not base type:
vector<Glass*> glassBin;
vector<Paper*> paperBin;
vector<Aluminum*> alumBin;
vector<Trash*>::iterator sorter = bin.begin();
// Sort the Trash:
while(sorter != bin.end()) {
Aluminum* ap = dynamic_cast<Aluminum*>(*sorter);
Paper* pp = dynamic_cast<Paper*>(*sorter);
Glass* gp = dynamic_cast<Glass*>(*sorter);
if(ap) alumBin.push_back(ap);
else if(pp) paperBin.push_back(pp);
else if(gp) glassBin.push_back(gp);
++sorter;
}
sumValue(alumBin, cout);
sumValue(paperBin, cout);
sumValue(glassBin, cout);
sumValue(bin, cout);
purge(bin);
} ///:∼
垃圾被不加分类地扔进一个垃圾箱,因此具体的类型信息“丢失”但是后来必须恢复特定的类型信息来正确地对垃圾进行分类,因此使用了 RTTI。
您可以通过使用将指向type_info
对象的指针与Trash
指针的vector
相关联的map
来改进这个解决方案。因为一个map
需要一个排序谓词,所以您提供一个名为TInfoLess
的谓词来调用type_info::before()
。当您将Trash
指针插入地图时,它们会自动与它们的type_info
键相关联。注意sumValue()
在清单 20-13 中必须有不同的定义。
清单 20-13 。使用地图说明回收
//: C20:Recycle2.cpp
//{L} Trash
// Recyling with a map.
#include <cstdlib>
#include <ctime>
#include <iostream>
#include <map>
#include <typeinfo>
#include <utility>
#include <vector>
#include "Trash.h"
#include "../purge.h"
using namespace std;
// Comparator for type_info pointers
struct TInfoLess {
bool operator()(const type_info* t1, const type_info* t2)
const { return t1->before(*t2); }
};
typedef map<const type_info*, vector<Trash*>, TInfoLess>
TrashMap;
// Sums up the value of the Trash in a bin:
void sumValue(const TrashMap::value_type& p, ostream& os) {
vector<Trash*>::const_iterator tally = p.second.begin();
float val = 0;
while(tally != p.second.end()) {
val += (*tally)->weight() * (*tally)->value();
os << "weight of "
<< p.first->name() // type_info::name()
<< " = " << (*tally)->weight() << endl;
++tally;
}
os << "Total value = " << val << endl;
}
int main() {
srand(time(0)); // Seed the random number generator
TrashMap bin;
// Fill up the Trash bin:
for(int i = 0; i < 30; i++) {
Trash* tp;
switch(rand() % 3) {
case 0 :
tp = new Aluminum((rand() % 1000)/10.0);
break;
case 1 :
tp = new Paper((rand() % 1000)/10.0);
break;
case 2 :
tp = new Glass((rand() % 1000)/10.0);
break;
}
bin[&typeid(*tp)].push_back(tp);
}
// Print sorted results
for(TrashMap::iterator p = bin.begin();
p != bin.end(); ++p) {
sumValue(*p, cout);
purge(p->second);
}
} ///:∼
您已经修改了sumValue()
来直接调用type_info::name()
,因为type_info
对象现在可以作为TrashMap::value_type
对的第一个成员。这避免了额外调用typeid
来获取正在处理的Trash
类型的名称,这在清单 20-12 中是必要的。
RTTI 的机制和开销
通常,RTTI 是通过在类的虚函数表中放置一个额外的指针来实现的。这个指针指向那个特定类型的type_info
结构。
一个typeid()
表达式的效果非常简单:虚函数表指针获取type_info
指针,并产生一个对结果type_info
结构的引用。因为这只是一个双指针解引用操作,所以它是一个常量时间操作。
对于一个dynamic_cast<destination*>(source_pointer)
,大多数情况非常简单:source_pointer
的 RTTI 信息被检索,类型destination*
的 RTTI 信息被获取。
然后,一个库例程确定source_pointer
的类型是属于类型destination*
还是属于destination*
的基类。如果基类不是派生类的第一个基类,它返回的指针可能会因为多重继承而被调整。多重继承的情况更复杂,因为一个基类可能在继承层次结构中出现不止一次,并且使用了虚拟基类。
因为用于dynamic_cast
的库例程必须检查一个基类列表,所以dynamic_cast
的开销可能比typeid()
高(但是你会得到不同的信息,这些信息对你的解决方案可能是必不可少的),而且发现一个基类可能比发现一个派生类花费更多的时间。
此外,dynamic_cast
将任何类型与任何其他类型进行比较;您并不局限于比较同一层次结构中的类型。这给dynamic_cast
使用的库例程增加了额外的开销。
审查会议
- 尽管通常情况下你会将一个指针向上指向一个基类,然后使用该基类的通用接口(通过虚函数),但有时你会陷入一个困境,如果你知道一个基类指针所指向的对象的动态类型,事情会变得更有效,这就是 RTTI 所提供的。
- 最常见的误用可能来自不懂虚函数的程序员,用 RTTI 来做类型检查编码,而不是。
- C++ 的哲学似乎是为你提供强大的工具,保护类型违反和完整性,但是如果你想故意误用或避开语言特性,没有什么可以阻止你。在这种情况下,值得一提的是,有时轻微烧伤是获得有用经验的最快方式。
二十一、多重继承
多重继承(MI)的基本概念听起来很简单:通过从多个基类继承来创建一个新类型。语法正是您所期望的,只要继承图简单,MI 也可以很简单。
然而,MI 可以引入许多歧义和奇怪的情况,这将在本章中讨论。但首先,对这个问题有一些看法是有帮助的。
远景
在 C++ 之前,最成功的面向对象语言是 Smalltalk。Smalltalk 是作为一种面向对象的语言从头开始创建的。它通常被称为纯语言,而 C++ 被称为混合语言,因为它支持多种编程范例,而不仅仅是面向对象的范例。Smalltalk 的一个设计决策是所有的类都在一个单一的层次结构中派生,以一个单一的基类为根(称为Object
——这是基于对象的层次结构的模型)。在 Smalltalk 中,如果不从现有的类派生,就无法创建新的类,这就是为什么在 Smalltalk 中需要一定的时间才能变得高效:在开始创建新的类之前,您必须学习类库。因此,Smalltalk 类层次结构是一个单一的整体树。
Smalltalk 中的类通常有许多共同点,它们总是有一些的共同点(Object
的特征和行为),所以你不会经常遇到需要从多个基类继承的情况。然而,使用 C++ 你可以创建任意多的不同继承树 。所以为了逻辑完整性,语言必须能够一次组合多个类——因此需要多重继承。
然而,程序员需要多重继承这一点并不明显,对于它在 C++ 中是否必不可少还存在很多争议。MI 于 1989 年被添加到美国电话电报公司 2.0 版本中,是该语言相对于 1.0 版本的第一个重大变化。从那以后,标准 C++ 中加入了许多其他特性(尤其是模板),这些特性改变了我们对编程的看法,并将 MI 置于一个不那么重要的位置。您可以将 MI 视为一个“次要”的语言特性,很少涉及到您的日常设计决策。
MI 最迫切的论点之一涉及容器 。假设您想要创建一个每个人都可以轻松使用的容器。一种方法是使用void*
作为容器内部的类型。然而,Smalltalk 的方法是创建一个保存Object
的容器,因为Object
是 Smalltalk 层次结构的基本类型。因为 Smalltalk 中的一切最终都是从Object
中派生出来的,一个容纳Object
s 的容器可以容纳任何东西。
现在考虑一下 C++ 中的情况。假设供应商A
创建了一个基于对象的层次结构,其中包含一组有用的容器,包括您想要使用的名为Holder
的容器。接下来,您会遇到 vendor B
的类层次结构,其中包含一些对您来说很重要的其他类,例如保存图形图像的BitImage
类。制作BitImage
s 的Holder
的唯一方法是从两个Object
派生一个新类,这样它就可以保存在Holder
和BitImage
中,如图图 21-1 所示。
图 21-1 。一个说明 MI 需要创建一个对象容器的例子。要成为位图像的持有者,您需要 MI
这被认为是 MI 的一个重要原因,许多类库都是基于这个模型构建的。您可能需要 MI 的另一个原因与设计有关。您可以有意地使用 MI 来使设计更加灵活或有用(或者至少看起来如此)。这样的例子在最初的iostream
库设计 中图 21-2 (在今天的模板设计中依然坚持)。
图 21-2 。有意使用 MI 使 iostream 设计更加灵活和有用
istream
和ostream
本身都是有用的类,但是它们也可以由一个结合了它们的特征和行为的类同时派生出来。类ios
提供了所有流类共有的东西,因此在这种情况下,MI 是一个代码分解机制 。
不管你使用 MI ,的动机是什么,它比看起来更难使用。
接口继承
多重继承的一个没有争议的用途与接口继承有关。在 C++ 中,所有的继承都是实现继承,因为基类中的一切,接口和实现,都变成了派生类的一部分。不可能只继承类的一部分(比如说,只继承接口)。正如第十四章所解释的,private
和protected
继承 使得当被一个派生类对象的客户使用时,限制对从基类继承的成员的访问成为可能,但是这并不影响派生类;它仍然包含所有基类数据,并且可以访问所有非private
基类成员。
另一方面,接口继承只是将成员函数声明添加到一个派生类接口中,在 C++ 中不被直接支持。在 C++ 中模拟接口继承的常用技术是从一个接口类 派生而来,这个类只包含声明(没有数据或函数体)。这些声明将是纯虚函数,除了析构函数。清单 21-1 包含了一个例子。
清单 21-1 。阐释多接口继承
//: C21:Interfaces.cpp
// Multiple interface inheritance.
#include <iostream>
#include <sstream>
#include <string>
using namespace std;
class Printable {
public:
virtual ∼Printable() {}
virtual void print(ostream&) const = 0;
};
class Intable {
public:
virtual ∼Intable() {}
virtual int toInt() const = 0;
};
class Stringable {
public:
virtual ∼Stringable() {}
virtual string toString() const = 0;
};
class Able : public Printable, public Intable,
public Stringable {
int myData;
public:
Able(int x) { myData = x; }
void print(ostream& os) const { os << myData; }
int toInt() const { return myData; }
string toString() const {
ostringstream os;
os << myData;
return os.str();
}
};
void testPrintable(const Printable& p) {
p.print(cout);
cout << endl;
}
void testIntable(const Intable& n) {
cout << n.toInt() + 1 << endl;
}
void testStringable(const Stringable& s) {
cout << s.toString() + "th" << endl;
}
int main() {
Able a(7);
testPrintable(a);
testIntable(a);
testStringable(a);
} ///:∼
类Able
“实现”接口Printable
、Intable
和Stringable
,因为它为它们声明的函数提供了实现。因为Able
从所有三个类派生而来,Able
对象有多个 is-a 关系。例如,对象a
可以作为一个Printable
对象,因为它的类Able
公开地从Printable
派生,并为print()
提供了一个实现。测试函数不需要知道它们的参数的最派生类型;他们只需要一个可以替代其参数类型的对象。
通常,模板解决方案更简洁;参见清单 21-2 。
清单 21-2 。说明隐式接口继承(使用模板)
//: C21:Interfaces2.cpp
// Implicit interface inheritance via templates.
#include <iostream>
#include <sstream>
#include <string>
using namespace std;
class Able {
int myData;
public:
Able(int x) { myData = x; }
void print(ostream& os) const { os << myData; }
int toInt() const { return myData; }
string toString() const {
ostringstream os;
os << myData;
return os.str();
}
};
template<class Printable>
void testPrintable(const Printable& p) {
p.print(cout);
cout << endl;
}
template<class Intable>
void testIntable(const Intable& n) {
cout << n.toInt() + 1 << endl;
}
template<class Stringable>
void testStringable(const Stringable& s) {
cout << s.toString() + "th" << endl;
}
int main() {
Able a(7);
testPrintable(a);
testIntable(a);
testStringable(a);
} ///:∼
名字Printable
、Intable
和Stringable
现在只是模板参数,假设在它们各自的上下文中指示的操作的存在。换句话说,测试函数可以接受任何类型的参数,只要这些参数能够为成员函数定义提供正确的签名和返回类型;不必从公共基类派生。有些人更喜欢第一个版本,因为类型名通过继承保证了预期接口的实现。其他人满足于这样一个事实,即如果测试函数所要求的操作不能被它们的模板类型参数所满足,错误仍然会在编译时被捕获。后一种方法在技术上是一种比前一种(继承)方法“更弱”的类型检查形式,但是对程序员(和程序)的影响是相同的。这是当今许多 C++ 程序员可以接受的弱类型的一种形式。
实现继承
如前所述,C++ 只提供实现继承,这意味着你总是从你的基类继承所有的东西。这可能很好,因为它使您不必实现派生类中的所有内容,如前面的接口继承示例所示。多重继承的一个常见用法是使用 mixin 类 ,这些类通过继承向其他类添加功能。Mixin 类不打算自己实例化。
举个例子,假设你是一个支持数据库访问的类的客户。在这种情况下,您只有一个头文件可用——部分原因是您无法访问实现的源代码。为了便于说明,假设清单 21-3 中所示的Database
类 的实现。
清单 21-3 。实现数据库类
//: C21:Database.h
// A prototypical resource class.
#ifndef DATABASE_H
#define DATABASE_H
#include <iostream>
#include <stdexcept>
#include <string>
struct DatabaseError : std::runtime_error {
DatabaseError(const std::string& msg)
: std::runtime_error(msg) {}
};
class Database {
std::string dbid;
public:
Database(const std::string& dbStr) : dbid(dbStr) {}
virtual ∼Database() {}
void open() throw(DatabaseError) {
std::cout << "Connected to " << dbid << std::endl;
}
void close() {
std::cout << dbid << " closed" << std::endl;
}
// Other database functions...
};
#endif // DATABASE_H ///:∼
/我们省略了实际的数据库功能(存储、检索等等),但这在这里并不重要。使用这个类需要一个数据库连接字符串,调用Database::open()
来连接,调用Database::close()
来断开:/
//: C21:UseDatabase.cpp
#include "Database.h" // To be INCLUDED from Header FILE
// above
int main() {
Database db("MyDatabase");
db.open();
// Use other db functions...
db.close();
}
/* Output:
connected to MyDatabase
MyDatabase closed
*/ ///:∼
在典型的客户机-服务器情况下,一个客户机将有多个对象共享一个到数据库的连接。重要的是,数据库最终要关闭,但只有在不再需要访问它之后。通常通过一个类来封装这种行为,该类跟踪使用数据库连接的客户端实体的数量,并在该数量变为零时自动终止连接。要将引用计数添加到Database
类中,可以使用多重继承将名为Countable
的类混合到Database
类中,以创建一个新类DBConnection
。清单 21-4 包含了Countable
mixin 类。
清单 21-4 。说明可数的“mixin”类
//: C21:Countable.h
// A "mixin" class.
#ifndef COUNTABLE_H
#define COUNTABLE_H
#include <cassert>
class Countable {
long count;
protected:
Countable() { count = 0; }
virtual ∼Countable() { assert(count == 0); }
public:
long attach() { return ++count; }
long detach() {
return (--count > 0) ? count : (delete this, 0);
}
long refCount() const { return count; }
};
#endif // COUNTABLE_H ///:∼
很明显,这不是一个独立的类,因为它的构造器是protected
;它需要一个朋友或一个派生类来使用。析构函数必须是虚拟的,这一点很重要,因为它只能从detach()
中的delete this
语句中调用,并且您希望派生的对象被正确地销毁。
DBConnection
类继承了Database
和Countable
,并提供了一个静态的create()
函数来初始化它的Countable
子对象;参见清单 21-5 。
清单 21-5 。使用可数的“mixin”类
//: C21:DBConnection.h
// Uses a "mixin" class.
#ifndef DBCONNECTION_H
#define DBCONNECTION_H
#include <cassert>
#include <string>
#include "Countable.h" // To be INCLUDED from Header FILE
// above
#include "Database.h"
using std::string;
class DBConnection : public Database, public Countable {
DBConnection(const DBConnection&); // Disallow copy
DBConnection& operator=(const DBConnection&);
protected:
DBConnection(const string& dbStr) throw(DatabaseError)
: Database(dbStr) { open(); }
∼DBConnection() { close(); }
public:
static DBConnection*
create(const string& dbStr) throw(DatabaseError) {
DBConnection* con = new DBConnection(dbStr);
con->attach();
assert(con->refCount() == 1);
return con;
}
// Other added functionality as desired...
};
#endif // DBCONNECTION_H ///:∼
您现在有了一个引用计数的数据库连接,而无需修改Database
类,并且您可以放心地假设它不会被秘密终止。打开和关闭是通过DBConnection
构造器和析构函数使用资源获取初始化(RAII)习惯用法来完成的。这使得DBConnection
易于使用,如清单 21-6 中的所示。
清单 21-6 。测试出可数的“mixin”类
//: C21:UseDatabase2.cpp
// Tests the Countable "mixin" class.
#include <cassert>
#include "DBConnection.h" // To be INCLUDED from Header FILE
// above
class DBClient {
DBConnection* db;
public:
DBClient(DBConnection* dbCon) {
db = dbCon;
db->attach();
}
∼DBClient() { db->detach(); }
// Other database requests using db...
};
int main() {
DBConnection* db = DBConnection::create("MyDatabase");
assert(db->refCount() == 1);
DBClient c1(db);
assert(db->refCount() == 2);
DBClient c2(db);
assert(db->refCount() == 3);
// Use database, then release attach from original create
db->detach();
assert(db->refCount() == 2);
} ///:∼
对DBConnection::create()
的调用调用attach()
,所以当你完成时,你必须显式地调用detach()
来释放最初对连接的保持。注意,DBClient
类也使用 RAII 来管理它对连接的使用。当程序终止时,两个DBClient
对象的析构函数将减少引用计数(通过调用detach()
,它从Countable
继承而来),当对象c1
被销毁后计数达到零时,数据库连接将被关闭(因为Countable
的虚拟析构函数)。
模板方法通常用于 mixin 继承,允许用户在编译时指定想要哪种风格的 mixin。这样,您可以使用不同的引用计数方法,而不用显式地定义两次DBConnection
。清单 21-7 展示了它是如何完成的。
清单 21-7 。说明一个参数化的“mixin”类(使用模板)
//: C21:DBConnection2.h
// A parameterized mixin.
#ifndef DBCONNECTION2_H
#define DBCONNECTION2_H
#include <cassert>
#include <string>
#include "Database.h"
using std::string;
template<class Counter>
class DBConnection : public Database, public Counter {
DBConnection(const DBConnection&); // Disallow copy
DBConnection& operator=(const DBConnection&);
protected:
DBConnection(const string& dbStr) throw(DatabaseError)
: Database(dbStr) { open(); }
∼DBConnection() { close(); }
public:
static DBConnection* create(const string& dbStr)
throw(DatabaseError) {
DBConnection* con = new DBConnection(dbStr);
con->attach();
assert(con->refCount() == 1);
return con;
}
// Other added functionality as desired...
};
#endif // DBCONNECTION2_H ///:∼
这里唯一的变化是类定义的模板前缀(为了清楚起见,将Countable
重命名为Counter
)。您还可以将数据库类作为模板参数(如果您有多个数据库访问类可供选择),但它不是 mixin,因为它是一个独立的类。清单 21-8 使用最初的Countable
作为Counter
mixin 类型,但是你可以使用任何实现适当接口的类型(attach()
、detach()
等等)。
清单 21-8 。测试参数化的“mixin”类
//: C21:UseDatabase3.cpp
// Tests a parameterized "mixin" class.
#include <cassert>
#include "Countable.h"
#include "DBConnection2.h" // To be INCLUDED from Header FILE
// above
class DBClient {
DBConnection<Countable>* db;
public:
DBClient(DBConnection<Countable>* dbCon) {
db = dbCon;
db->attach();
}
∼DBClient() { db->detach(); }
};
int main() {
DBConnection<Countable>* db =
DBConnection<Countable>::create("MyDatabase");
assert(db->refCount() == 1);
DBClient c1(db);
assert(db->refCount() == 2);
DBClient c2(db);
assert(db->refCount() == 3);
db->detach();
assert(db->refCount() == 2);
} ///:∼
多参数混合的一般模式很简单。
template<class Mixin1, class Mixin2, ... , class MixinK>
class Subject : public Mixin1,
public Mixin2,
...
publicMixinK {...};
重复子对象
当从基类继承时,您会在派生类中获得该基类的所有数据成员的副本。清单 21-9 显示了如何在内存中布置多个基础子对象。
清单 21-9 。使用 MI 演示子对象的布局
//: C21:Offset.cpp
// Illustrates layout of subobjects with MI.
#include <iostream>
using namespace std;
class A { int x; };
class B { int y; };
class C : public A, public B { int z; };
int main() {
cout << "sizeof(A) == " << sizeof(A) << endl;
cout << "sizeof(B) == " << sizeof(B) << endl;
cout << "sizeof(C) == " << sizeof(C) << endl;
C c;
cout << "&c == " << &c << endl;
A* ap = &c;
B* bp = &c;
cout << "ap == " << static_cast<void*>(ap) << endl;
cout << "bp == " << static_cast<void*>(bp) << endl;
C* cp = static_cast<C*>(bp);
cout << "cp == " << static_cast<void*>(cp) << endl;
cout << "bp == cp? " << boolalpha << (bp == cp) << endl;
cp = 0;
bp = cp;
cout << bp << endl;
} ///:∼
/*
输出:
sizeof(A) == 4
sizeof(B) == 4
sizeof(C) == 12
&c == 1245052
ap == 1245052
bp == 1245056
cp == 1245052
bp == cp? true
0
*/
正如你所看到的,对象C
的B
部分从整个对象的开始处偏移了 4 个字节,这暗示了图 21-3 中的布局。
图 21-3 。输出数据的布局
对象C
从它的A
子对象开始,然后是B
部分,最后是来自完整类型C
本身的数据。因为一个C
是-an A
而是-a B
,所以可以向上造型为任何一种基类。当向上转换到一个A
时,产生的指针指向A
部分,恰好在C
对象的开头,所以地址ap
与表达式&c
相同。然而,当向上转换到B
时,结果指针必须指向B
子对象实际驻留的位置,因为类B
对类C
(或类A
)一无所知。换句话说,bp
指向的对象必须能够表现为一个独立的B
对象(除了任何必需的多态行为)。
当将bp
造型回C*
时,由于原始对象首先是一个C
,因此B
子对象所在的位置是已知的,因此指针被调整回完整对象的原始地址。如果bp
一开始就指向一个独立的B
对象,而不是一个C
对象,那么强制转换就是非法的。此外,在比较bp == cp
中,cp
被隐式转换为B*
,因为这是使比较有意义的唯一方式(也就是说,总是允许向上转换),因此产生了true
结果。因此,当在子对象和完整类型之间来回转换时,会应用适当的偏移量。
显然,空指针需要特殊处理,因为如果指针从零开始,在转换到B
子对象或从B
子对象转换时盲目减去偏移量将导致无效地址。由于这个原因,当转换到一个B*
或从一个B*
转换时,编译器生成逻辑首先检查指针是否为零。如果不是,它应用偏移量;否则,它将其保留为零。
用你目前看到的语法,如果你有多个基类,并且这些基类又有一个公共基类,你将有两个顶级基类的副本,如你在清单 21-10 中看到的。
清单 21-10 。演示重复的子对象
//: C21:Duplicate.cpp
// Shows duplicate subobjects.
#include <iostream>
using namespace std;
class Top {
int x;
public:
Top(int n) { x = n; }
};
class Left : public Top {
int y;
public:
Left(int m, int n) : Top(m) { y = n; }
};
class Right : public Top {
int z;
public:
Right(int m, int n) : Top(m) { z = n; }
};
class Bottom : public Left, public Right {
int w;
public:
Bottom(int i, int j, int k, int m)
: Left(i, k), Right(j, k) { w = m; }
};
int main() {
Bottom b(1, 2, 3, 4);
cout << sizeof b << endl; // 20
} ///:∼
由于b
的大小是 20 字节,所以在一个完整的Bottom
对象中总共有五个整数。这个场景的典型类图 ?? 如图图 21-4 所示。
图 21-4 。菱形继承场景的类图
这就是所谓的“钻石继承”,但在这种情况下,它会更好地呈现为图 21-5 。
图 21-5 。相同场景下更好的类图
这种设计的笨拙表现在前面代码中的Bottom
类的构造器中。用户认为只需要四个整数,但是Left
和Right
需要的两个参数应该传递哪些实参呢?尽管这种设计本质上并不是“错误的”,,但它通常不是应用程序所需要的。当试图将指向Bottom
对象的指针转换成指向Top
的指针时,也会出现问题。如前所示,地址可能需要调整,这取决于子对象在完整对象中的位置,但这里有两个Top
子对象可供选择。编译器不知道选择哪个,所以这样的向上转换是不明确的,也是不允许的。同样的推理解释了为什么一个Bottom
对象不能调用一个只在Top
中定义的函数。如果这样的函数Top::f()
存在,调用b.f()
将需要引用一个Top
子对象作为执行上下文,有两个可供选择。
*虚拟基础类
在这种情况下,你通常想要的是真钻石继承 ,其中一个单独的Top
对象由一个完整的Bottom
对象中的Left
和Right
子对象共享,这就是第一个类图所描述的。这是通过使Top
成为Left
和Right
的虚拟基类来实现的,如清单 21-11 所示。
清单 21-11 。展示真正的钻石继承
//: C21:VirtualBase.cpp
// Shows a shared subobject via a virtual base.
#include <iostream>
using namespace std;
class Top {
protected:
int x;
public:
Top(int n) { x = n; }
virtual ∼Top() {}
friend ostream&
operator<<(ostream& os, const Top& t) {
return os << t.x;
}
};
class Left : virtual public Top {
protected:
int y;
public:
Left(int m, int n) : Top(m) { y = n; }
}
class Right : virtual public Top {
protected:
int z;
public:
Right(int m, int n) : Top(m) { z = n; }
};
class Bottom : public Left, public Right {
int w;
public:
Bottom(int i, int j, int k, int m)
: Top(i), Left(0, j), Right(0, k) { w = m; }
friend ostream&
operator<<(ostream& os, const Bottom& b) {
return os << b.x << ',' << b.y << ',' << b.z
<< ',' << b.w;
}
};
int main() {
Bottom b(1, 2, 3, 4);
cout << sizeof b << endl;
cout << b << endl;
cout << static_cast<void*>(&b) << endl;
Top* p = static_cast<Top*>(&b);
cout << *p << endl;
cout << static_cast<void*>(p) << endl;
cout << dynamic_cast<void*>(p) << endl;
} ///:∼
给定类型的每个虚拟基引用同一个对象,不管它出现在层次结构中的什么位置。这意味着当一个Bottom
对象被实例化时,对象布局 可能看起来像图 21-6 。
图 21-6 。对象布局
Left
和Right
子对象每个都有一个指向共享的Top
子对象的指针(或一些概念上的等价物),并且在Left
和Right
成员函数中对该子对象的所有引用都将通过这些指针。这里,当从一个Bottom
向上转换到一个Top
对象时,没有歧义,因为只有一个Top
对象要转换。
清单 21-11 中程序的输出如下:
36
1,2,3,4
1245032
1
1245060
1245032
打印的地址表明这个特定的实现确实在完整对象的末尾存储了Top
子对象(尽管它放在哪里并不重要)。dynamic_cast
到void*
的结果总是解析为完整对象的地址。
尽管这样做在技术上是非法的,但是如果您删除虚拟析构函数(和dynamic_cast
语句,这样程序将会编译),那么Bottom
的大小将会减少到 24 个字节。这似乎是相当于三个指针大小的减少。为什么?
重要的是不要把这些数字看得太重。当添加虚拟构造器时,其他编译器只能将大小增加 4 个字节。不是编译器作者,我不能告诉你他们的秘密。但是,我可以告诉你,有了多重继承,一个派生的对象必须表现得好像它有多个 VPTRs,每个 VPTRs 对应于它的一个直接基类,这些基类也有虚函数。就这么简单。编译器进行作者发明的任何优化,但是行为必须是相同的。
清单 21-11 中最奇怪的事情是Bottom
构造器中Top
的初始化器。通常人们不担心初始化直接基类之外的子对象,因为所有的类都负责初始化它们自己的基类。然而,从Bottom
到Top
有多条路径,因此依靠中间类Left
和Right
来传递必要的初始化数据会导致一种不确定性——谁负责执行初始化?由于这个原因,大多数派生类必须初始化一个虚拟基 。但是同样初始化Top
的Left
和Right
构造器中的表达式呢?在创建独立的Left
或Right
对象时,它们当然是必要的,但是在创建Bottom
对象时,它们必须被忽略(因此在Bottom
构造器中,它们的初始值为零——当Left
和Right
构造器在Bottom
对象的上下文中执行时,这些槽中的任何值都被忽略)。编译器会为您处理所有这些,但是理解责任在哪里是很重要的。始终确保多重继承层次结构中的所有具体(非抽象)类知道任何虚拟基,并适当地初始化它们。
这些责任规则不仅适用于初始化,还适用于跨越类层次结构的所有操作。考虑清单 21-11 中的流插入器。我们使数据受到保护,这样我们就可以“欺骗”并访问operator<<(ostream&, const Bottom&)
中的继承数据。将打印每个子对象的工作分配给相应的类并让派生类根据需要调用其基类函数通常更有意义。如果我们像清单 21-12 所示的那样用operator<<()
尝试会发生什么?
清单 21-12 。演示了一个错误的方法来实现运算符< < ()
//: C21:VirtualBase2.cpp
// How NOT to implement operator<<.
#include <iostream>
using namespace std;
class Top {
int x;
public:
Top(int n) { x = n; }
virtual ∼Top() {}
friend ostream& operator<<(ostream& os, const Top& t) {
return os << t.x;
}
};
class Left : virtual public Top {
int y;
public:
Left(int m, int n) : Top(m) { y = n; }
friend ostream& operator<<(ostream& os, const Left& l) {
return os << static_cast<const Top&>(l) << ',' << l.y;
}
};
class Right : virtual public Top {
int z;
public:
Right(int m, int n) : Top(m) { z = n; }
friend ostream& operator<<(ostream& os, const Right& r) {
return os << static_cast<const Top&>(r) << ',' << r.z;
}
};
class Bottom : public Left, public Right {
int w;
public:
Bottom(int i, int j, int k, int m)
: Top(i), Left(0, j), Right(0, k) { w = m; }
friend ostream& operator<<(ostream& os, const Bottom& b){
return os << static_cast<const Left&>(b)
<< ',' << static_cast<const Right&>(b)
<< ',' << b.w;
}
};
int main() {
Bottom b(1, 2, 3, 4);
cout << b << endl; // 1,2,1,3,4
} ///:∼
你不能像通常那样盲目地向上分担责任,因为Left
和Right
流插入器都调用Top
插入器,同样会有数据重复。相反,你需要模仿编译器在初始化时做的事情。一个解决方案是在知道虚拟基类的类中提供特殊的函数,这些函数在打印时忽略虚拟基类(把工作留给最派生的类),如清单 21-13 所示。
清单 21-13 。演示正确的流插入器
//: C21:VirtualBase3.cpp
// A correct stream inserter.
#include <iostream>
using namespace std;
class Top {
int x;
public:
Top(int n) { x = n; }
virtual ∼Top() {}
friend ostream& operator<<(ostream& os, const Top& t) {
return os << t.x;
}
};
class Left : virtual public Top {
int y;
protected:
void specialPrint(ostream& os) const {
// Only print Left's part
os << ',' << y;
}
public:
Left(int m, int n) : Top(m) { y = n; }
friend ostream& operator<<(ostream& os, const Left& l) {
return os << static_cast<const Top&>(l) << ',' << l.y;
}
};
class Right : virtual public Top {
int z;
protected:
void specialPrint(ostream& os) const {
// Only print Right's part
os << ',' << z;
}
public:
Right(int m, int n) : Top(m) { z = n; }
friend ostream& operator<<(ostream& os, const Right& r) {
return os << static_cast<const Top&>(r) << ',' << r.z;
}
};
class Bottom : public Left, public Right {
int w;
public:
Bottom(int i, int j, int k, int m)
: Top(i), Left(0, j), Right(0, k) { w = m; }
friend ostream& operator<<(ostream& os, const Bottom& b){
os << static_cast<const Top&>(b);
b.Left::specialPrint(os);
b.Right::specialPrint(os);
return os << ',' << b.w;
}
};
int main() {
Bottom b(1, 2, 3, 4);
cout << b << endl; // 1,2,3,4
} ///:∼
specialPrint()
函数是protected
,因为它们只会被Bottom
调用。它们只打印自己的数据,忽略它们的Top
子对象,因为在调用这些函数时Bottom
插入器处于控制中。Bottom
插入器必须知道虚拟基,就像Bottom
构造器需要知道的一样。同样的推理也适用于具有虚拟基的层次结构中的赋值操作符,以及任何想要在层次结构中的所有类之间共享工作的函数,无论是否是成员。
讨论了虚拟基类之后,现在让我们来说明对象初始化 的“完整故事”。因为虚拟基产生共享子对象,所以在共享发生之前它们应该是可用的是有意义的。所以子对象的初始化顺序递归地遵循这些规则。
- 所有虚拟基类子对象根据它们在类定义中出现的位置,以自上而下、从左到右的顺序初始化。
- 然后,非虚拟基类按通常的顺序初始化。
- 所有成员对象都按声明顺序初始化。
- 完整对象的构造器执行。
清单 21-14 展示了这种行为。
清单 21-14 。用虚拟基类阐释初始化顺序
//: C21:VirtInit.cpp
// Illustrates initialization order with virtual bases.
#include <iostream>
#include <string>
using namespace std;
class M {
public:
M(const string& s) { cout << "M " << s << endl; }
};
class A {
M m;
public:
A(const string& s) : m("in A") {
cout << "A " << s << endl;
}
virtual ∼A() {}
};
class B {
M m;
public:
B(const string& s) : m("in B") {
cout << "B " << s << endl;
}
virtual ∼B() {}
};
class C {
M m;
public:
C(const string& s) : m("in C") {
cout << "C " << s << endl;
}
virtual ∼C() {}
};
class D {
M m;
public:
D(const string& s) : m("in D") {
cout << "D " << s << endl;
}
virtual ∼D() {}
};
class E : public A, virtual public B, virtual public C {
M m;
public:
E(const string& s) : A("from E"), B("from E"),
C("from E"), m("in E") {
cout << "E " << s << endl;
}
};
class F : virtual public B, virtual public C, public D {
M m;
public:
F(const string& s) : B("from F"), C("from F"),
D("from F"), m("in F") {
cout << "F " << s << endl;
}
};
class G : public E, public F {
M m;
public:
G(const string& s) : B("from G"), C("from G"),
E("from G"), F("from G"), m("in G") {
cout << "G " << s << endl;
}
};
int main() {
G g("from main");
} ///:∼
这个程序的输出是
M in B
B from G
M in C
C from G
M in A
A from E
M in E
E from G
M in D
D from F
M in F
F from G
M in G
G from main
图 21-7 。显示各种类别
这段代码中的类可以用图 21-7 来表示。
每个类都有一个类型为M
的嵌入成员。注意只有四个派生是虚拟的:B
和C
的E
,以及B
和C
的F
。
G
的初始化要求它的E
和F
部分首先被初始化,但是B
和C
子对象首先被初始化,因为它们是虚拟基,并且是从G
的初始化器初始化的,G
是最衍生的类。类B
没有基类,所以根据规则 3,它的成员对象M
被初始化,然后它的构造器从G
打印出B
,对于E
的C
主题也是如此。E
子对象需要A
、B
和C
子对象。由于B
和C
已经初始化,接下来初始化E
子对象的A
子对象,然后初始化E
子对象本身。对G
的F
子对象重复相同的场景,但是不重复虚拟基础的初始化。
姓名查询问题
用子对象说明的歧义适用于任何名称,包括函数名。如果一个类有多个共享同名成员函数的直接基类,而你调用了其中一个成员函数,编译器不知道选择哪个。清单 21-15 中的程序会报告这样一个错误。
清单 21-15 。说明不明确的函数名
//: C21:AmbiguousName.cpp {-xo}
class Top {
public:
virtual ∼Top() {}
};
class Left : virtual public Top {
public:
void f() {}
};
class Right : virtual public Top {
public:
void f() {}
};
class Bottom : public Left, public Right {};
int main() {
Bottom b;
b.f(); // Error here
} ///:∼
类Bottom
继承了两个同名的函数(签名是不相关的,因为名称查找发生在重载解析之前),没有办法在它们之间进行选择。消除调用歧义的常用技术是用基类名称限定函数调用;参见清单 21-16 。
清单 21-16 。解决清单 21-15 中的歧义
//: C21:BreakTie.cpp
class Top {
public:
virtual ∼Top() {}
};
class Left : virtual public Top {
public:
void f() {}
};
class Right : virtual public Top {
public:
void f() {}
};
class Bottom : public Left, public Right {
public:
using Left::f;
};
int main() {
Bottom b;
b.f(); // Calls Left::f()
} ///:∼
Left::f
这个名字现在在Bottom
的范围内,所以Right::f
这个名字甚至不在考虑范围内。为了引入超出Left::f()
所提供的额外功能,您实现了一个调用Left::f()
的Bottom::f()
函数。
在层次结构的不同分支中出现的同名函数经常会发生冲突。清单 21-17 中的层次结构没有这样的问题。
清单 21-17 。说明了解决类层次结构中函数名歧义的优势原则
//: C21:Dominance.cpp
class Top {
public:
virtual ∼Top() {}
virtual void f() {}
};
class Left : virtual public Top {
public:
void f() {}
};
class Right : virtual public Top {};
class Bottom : public Left, public Right {};
int main() {
Bottom b;
b.f(); // Calls Left::f()
} ///:∼
这里没有明确的Right::f()
。由于Left::f()
是最衍生的,所以选择它。为什么呢?假设Right
不存在,给出单继承层次结构Top <= Left <= Bottom
。由于正常的作用域规则:派生类被认为是基类的嵌套作用域,你当然会期望Left::f()
是由表达式b.f()
调用的函数。一般来说,如果A
直接或间接地从B
派生,或者换句话说,如果A
在层次结构中比B
更“派生”,则名称A::f
支配名称B::f
。因此,在两个同名函数之间进行选择时,编译器会选择占优势的那个。如果没有主导名,就有歧义。
清单 21-18 进一步说明了优势原则。
清单 21-18 。说明优势原则(再次)来解决更多的歧义
//: C21:Dominance2.cpp
#include <iostream>
using namespace std;
class A {
public:
virtual ∼A() {}
virtual void f() { cout << "A::f\n"; }
};
class B : virtual public A {
public:
void f() { cout << "B::f\n"; }
};
class C : public B {};
class D : public C, virtual public A {};
int main() {
B* p = new D;
p->f(); // Calls B::f()
delete p;
} ///:∼
该层次的类图如图 21-8 所示。
图 21-8 。类图
类A
是B
的基类(在本例中是直接基类),因此名字B::f
支配着A::f
。
避糜
当是否使用多重继承的问题出现时,至少要问两个问题。
- 你需要通过你的新类型显示这两个类的公共接口吗?(相反,看看一个类是否可以包含在另一个类中,只在新的类中暴露其部分接口。)
- 您需要向上转换到两个基类吗?(当您有两个以上的基类时,这也适用。)
如果你能对任何一个问题回答“不”,你就可以避免使用 MI,并且很可能应该这样做。
注意一个类只需要作为函数参数向上转换的情况。在这种情况下,可以嵌入该类,并在新类中提供自动类型转换功能,以产生对嵌入对象的引用。每当您使用新类的对象作为需要嵌入对象的函数的参数时,都会使用类型转换函数。但是,类型转换不能用于正常的多态成员函数选择;这需要继承。比起继承,更喜欢组合是一个好的总体设计准则。
扩展一个接口
多重继承的一个最好的用途涉及到不受你控制的代码。假设您已经获得了一个包含头文件和已编译成员函数的库,但是没有成员函数的源代码。这个库是一个带有虚函数的类层次结构,它包含一些全局函数,这些函数将指针指向库的基类;也就是说,它多态地使用库对象。现在,假设您围绕这个库构建了一个应用程序,并编写了自己的代码,以多种形式使用基类。
*在项目开发的后期或维护期间,您发现供应商提供的基类接口没有提供您需要的东西:函数可能是非虚拟的,而您需要它是虚拟的,或者接口中完全没有虚拟函数,但它对解决您的问题是必不可少的。多重继承可能是解决方案。例如,清单 21-19 包含了你获得的一个库的头文件。
清单 21-19 。说明供应商提供的类头
//: C21:Vendor.h
// Vendor-supplied class header
// You only get this & the compiled Vendor.obj.
#ifndef VENDOR_H
#define VENDOR_H
class Vendor {
public:
virtual void v() const;
void f() const; // Might want this to be virtual...
∼Vendor(); // Oops! Not virtual!
};
class Vendor1 : public Vendor {
public:
void v() const;
void f() const;
∼Vendor1();
};
void A(const Vendor&);
void B(const Vendor&);
// Etc.
#endif // VENDOR_H ///:∼
假设库要大得多,有更多的派生类和更大的接口。注意,它还包括函数A()
和B()
,这两个函数接受一个基本引用,并对其进行多态处理。清单 21-20 包含了库的实现文件。
清单 21-20 。实现清单 21-19 (Vendor.h) 中的头文件
//: C21:Vendor.cpp {O}
// Assume this is compiled and unavailable to you.
#include "Vendor.h" // To be INCLUDED from Header FILE
// above
#include <iostream>
using namespace std;
void Vendor::v() const { cout << "Vendor::v()" << endl; }
void Vendor::f() const { cout << "Vendor::f()" << endl; }
Vendor::∼Vendor() { cout << "∼Vendor()" << endl; }
void Vendor1::v() const { cout << "Vendor1::v()" << endl; }
void Vendor1::f() const { cout << "Vendor1::f()" << endl; }
Vendor1::∼Vendor1() { cout << "∼Vendor1()" << endl; }
void A(const Vendor& v) {
// ...
v.v();
v.f();
// ...
}
void B(const Vendor& v) {
// ...
v.v();
v.f();
// ...
} ///:∼
在您的项目中,该源代码对您不可用。相反,您会得到一个编译后的文件,名为Vendor.obj
或Vendor.lib
(或者,带有适用于您的系统的等效文件后缀)。
问题出现在这个库的使用上。首先,析构函数不是虚拟的。此外,f()
未造虚;你假设库的创建者决定不需要它。您还发现基类的接口缺少一个对解决问题至关重要的功能。还假设你已经使用现有的接口编写了相当多的代码(更不用说功能A()
和B()
,它们已经超出了你的控制范围),并且你不想改变它。
为了修复这个问题,你创建你自己的类接口,并从你的接口和现有的类中多重继承一组新的派生类,如清单 21-21 所示。
清单 21-21 。说明了如何使用 MI 修复清单 21-20 中的混乱
//: C21:Paste.cpp
//{L} Vendor
// Fixing a mess with MI.
#include <iostream>
#include "Vendor.h"
using namespace std;
class MyBase { // Repair Vendor interface
public:
virtual void v() const = 0;
virtual void f() const = 0;
// New interface function:
virtual void g() const = 0;
virtual ∼MyBase() { cout << "∼MyBase()" << endl; }
};
class Paste1 : public MyBase, public Vendor1 {
public:
void v() const {
cout << "Paste1::v()" << endl;
Vendor1::v();
}
void f() const {
cout << "Paste1::f()" << endl;
Vendor1::f();
}
void g() const { cout << "Paste1::g()" << endl; }
∼Paste1() { cout << "∼Paste1()" << endl; }
};
int main() {
Paste1& p1p = *new Paste1;
MyBase& mp = p1p; // Upcast
cout << "calling f()" << endl;
mp.f(); // Right behavior
cout << "calling g()" << endl;
mp.g(); // New behavior
cout << "calling A(p1p)" << endl;
A(p1p); // Same old behavior
cout << "calling B(p1p)" << endl;
B(p1p); // Same old behavior
cout << "delete mp" << endl;
// Deleting a reference to a heap object:
delete ∓ // Right behavior
} ///:∼
在MyBase
(其中没有使用 MI)中,f()
和析构函数现在都是虚拟的,一个新的虚函数g()
被添加到接口中。现在必须重新创建原始库中的每个派生类,在新接口中混合 MI。函数Paste1::v()
和Paste1::f()
只需要调用它们函数的原始基类版本。但是现在,如果你像在main()
中一样向上投射到MyBase
MyBase* mp = p1p; // Upcast
任何通过mp
进行的函数调用都将是多态的,包括delete
。另外,新的接口函数g()
可以通过mp
调用。下面是程序的输出:
calling f()
Paste1::f()
Vendor1::f()
calling g()
Paste1::g()
calling A(p1p)
Paste1::v()
Vendor1::v()
Vendor::f()
calling B(p1p)
Paste1::v()
Vendor1::v()
Vendor::f()
delete mp
∼Paste1()
∼Vendor1()
∼Vendor()
∼MyBase()
原来的库函数A()
和B()
仍然工作相同(假设新的v()
调用它的基类版本)。析构函数现在是virtual
,并显示出的正确行为。
虽然这是一个混乱的例子,但它确实在实践中发生了,并且很好地演示了多重继承显然是必要的:您必须能够向上转换到两个基类。
审查会议
- C++ 中存在 MI ?? 的一个原因是,它是一种 ?? 混合语言,不能像 Smalltalk 和 Java 那样实现单一的类层次结构。
- 相反,C++ 允许形成许多继承树,所以有时你可能需要将两个或更多树的接口组合成一个新类。
- 如果没有“钻石”出现在你的类层次结构中,那么 MI 相当简单(尽管基类中相同的函数签名仍然必须被解析)。如果出现菱形,您可能希望通过引入虚拟基类来消除重复的子对象。这不仅增加了混乱,而且底层表示变得更加复杂和低效。
- 多重继承被称为 90 年代的“goto”。“这看起来很合适,因为像 goto 一样, MI 最好在普通编程中避免,但偶尔会非常有用。这是 C++ 的一个“次要”但更高级的特性,旨在解决特殊情况下出现的问题。
- 如果你发现自己经常使用它,你可能想看看你的推理。问问你自己,“我必须将向上转换为所有的基类吗?”如果没有,如果将所有不需要的类的实例嵌入到中,你的生活会更容易。**