Cpp 学习笔记

这里是我学习《Thinking in C++》时记录整理的笔记,时间比较久远,不保证时效性。


第1章 对象导言

本章将介绍面向对象程序设计(OOP)的基本概念,包括OOP开发方法的概述。

所有的程序语言都提供抽象。可以说,人们能解决的问题的复杂性直接与抽象的类型和质量有关。汇编语言时对底层机器的小幅度抽象。其后的许多所谓“命令式”语言(例如Fortran、Basic和C)都是对汇编语言的抽象。OPP允许程序员用问题本市的术语来描述问题,而不是用要运行解决方案的计算机的术语来描述问题。每个对象看上去像一台小计算机,它有状态,有可执行的运算。这似乎是现实世界中对象的很好类比,它们都有特性和行为。

面向对象语言的五个基本特性:

  1. 万物皆对象。
  2. 程序就是一组对象,对象之间通过发送消息互相通知做什么。
  3. 每个对象都有它子集的由其他对象构成的存储区。
  4. 每个对象都有一个类型。
  5. 一个特定类型的所有对象都能接收相同的消息。

创建抽象数据类型是面向对象程序设计的基本思想。抽象数据类型几乎能完全像内部类型一样工作。

类描述了一组有相同特性(数据元素)和相同行为(功能)的对象,因此类实际上就是数据类型。面向对象程序设计的难题之一,是在问题空间中的元素和解空间中的对象之间建立一对一的映射。

必须有一种方法能向对象作出请求,使得它能做某些事情。可以向对象发出的请求是由它的接口(interface)定义的,而接口由类型确定。接口规定我们能向特定的对象发出什么请求。然后,必须有代码满足这种请求,再加上隐藏的数据,就组成了实现(implementation)

C++语言使用了三个明确的关键字来设置类中的边界:publicprivateprotectedpublic意味着随后的定义对所有人都可用。相反,private关键字则意味着,除了该类型的创建者和该类型的内部成员函数之外,任何人都不能访问这些定义。继承的类可以访问protected成员,但不能访问private成员。

代码重用是面向对象程序设计语言的最大优点之一。可以用任何数量和类型的其他对象组成新类,通过组合得到新类所希望的功能。因为这是由已经存在的类组成新类,所以称为组合(composition)【或者更通常称为聚合(aggregation)】。组合常常被称为has-a(有)关系。

当创建新类时,程序员应当首先考虑组合,因为它更简单和更灵活。如果采用组合的方法,设计将变得清晰。

克服许多困难去创造一个类,并随后强制性地创造一个有类似功能地全新地类,似乎很愚蠢。如果能选取已存在地类、克隆它,然后对这个克隆增加和修改,则是再好不过地事。这是继承(inheritance)带来地好处,缺点是,如果原来的类(称为基类、超类或父类)被修改,则这个修改过的“克隆”(称为派生类、继承类或子类)也会表现出这些改变。

当我们从已经存在的类型来继承时,我们就创造了一个新类型。这个新类性不仅包含那个已经存在的类型的所有成员,还复制了这个基类的接口,这意味着这个派生类与这个基类是相同类型的。

有两种方法能使新派生类区别于原始基类。第一种相当直接,简单地向派生类添加全新的函数。这些新函数不是基类接口的一部分。着意味着,这个基类不能做我们希望它做的事情,所以必须添加函数。

虽然继承有时意味着向接口添加新函数,但这未必真的需要。是新类有别于基类的第二个和更重要的方法是,改变已经存在的基类函数的行为,这称为重载(overriding)这个函数。为了重载函数,可以简单地再派生类中创建新定义。相当于说:“我正再使用同一个接口函数,但是我希望它为我做不同的事情。”

只重载基类(并且不添加基类中没有的新成员函数)的继承意味着派生类和基类是完全相同的类型,因为它们有相同的接口。结果是,我们可以用派生类的对象代替基类的对象。因此这被认为是纯代替(pure substitution),常常被称为代替原则(substitution principle)。这种情况下,我们常把基类和派生类直接的关系看作是一个is-a(是)关系。

有时需要向一个派生类型添加新的接口元素,这样就扩展了接口并创建了新类型。这个新类型仍然可以代替这个基类,但这个代替不是完美的,因为这些新函数不能从基类访问,这可以描述为“is-like-a(像)”关系。

如果试图把派生类型的对象看做是比它们自身更一般的基本类型(圆形看做形体,自行车看做车辆),这里就有一个问题:如果一个函数告诉一个一般的形体去绘制它子集,或者告诉一个一般的车辆去行驶,则编译器再编译时就不能确切地知道应当执行哪段代码。同样地问题是,消息发送时,程序员并不想知道将执行哪段代码。编译器不能确切地知道执行哪段代码,那么它应该怎么办呢?

在面向对象的程序设计中,答案非常新奇:编译器并不做传统意义上的函数调用。非OOP编译器产生的函数调用会导致与被调用代码的早捆绑(early binding),其意思是:编译器会对特定的函数名产生调用,而连接器将这个调用解析为要执行代码的绝对地址。

在OOP中,知道程序运行时,编译器才能确定执行代码的地址,所以,当消息被发送给一般对象时,需要采用其他的方案。为了解决这一问题,面向对象语言采用晚捆绑(late binding)的思想。当给对象发送消息时,在程序运行时才去确定被调用的代码。编译器保证这个被调用的函数存在,并执行参数和返回值的类型检查【其中不采用这种处理方式的语言称为弱类型(weakly typed)语言】,但是它并不知道将执行的确切代码。

为了执行晚捆绑,C++编译器在真正调用的地方插入一段特殊的二进制代码。通过使用存放在对象自身中的信息,这段代码在运行时计算被调用函数函数体的地址(详见第15章)。这样每个对象就能根据这段二进制代码的内容有不同的行为。当一个对象接收到消息时,它根据这个消息判断应当做什么。

我们可以用关键字virtual声明他希望某个函数有晚捆绑的灵活性。在C++中,必须记住添加virtual关键字,因为根据规定,默认情况下成员函数不能动态捆绑。virtual函数(虚函数)可用来表示出在相同家族中的类具有不同的行为。这些不同是产生多态行为的原因。
我们把处理派生类型就如同处理其基类型的过程称为向上类型转换(upcasting)。编译器和运行系统可以处理这些细节,我们只需要知道它会这样做和知道如何用它设计程序就行了。如果一个成员函数是virtual的,则当我们给一个对象发送消息时,这个对象将做正确的事情,即使是在有向上类型转换的情况下。

对象的数据存放在何处?如何控制对象的生命期?不同的设计语言有不同的处理方式。C++才去的方法是把效率控制作为最重要的问题,所以它为程序员提供了一个选择。为了最大化运行速度,通过将对象存放在栈中或静态存储区域中,存储和生命期可以在编写程序时确定。栈是内存中的一个区域,可以直接由微处理器在程序执行期间存放数据。在栈中的变量有时称自动变量(automatic variable)局部变量(scoped variable)。静态存储区简单说是内存的一个固定块,在程序开始执行以前分配。使用栈或静态存储区,可以快速分配和释放,但是我们因此也牺牲了灵活性。

第二种方法是在称为堆(heap)的区域动态创建对象。用这种方法,可以直到运行时还不知道需要多少个对象,它们的生命期是什么和他们的准确数据类型是什么。这些决定是在程序运行之中作出的。如果需要心的对象,直接使用new关键字让它在堆上生成。当使用结束时,用关键字delete释放。

另一个问题是对象的生命期。如果在栈上或在静态存储上创建一个对象,编译器决定这个对象持续多长时间并能自动销毁它。然后,如果在堆上创建它,编译器则不知道它的生命期。在C++中,程序员必须编程决定何时销毁此对象。作为一个替换,运行环境可以提供一个称为垃圾收集器(garbage collector)的功能。当然,使用垃圾收集器编写程序是非常方便的,但是它需要所有应用软件能承受垃圾收集器的存在及垃圾收集的系统开销。

从程序设计语言出现开始,错误处理就是最重要的问题之一。因为设计一个好的错误处理方案非常困难,许多语言忽略这个问题,将这个问题转交给库的设计者,而库的设计者往往采取不彻底的措施,即可以在许多情况下起作用,但很容易被绕考,通常是被忽略。

异常处理(exception handling)将错误处理直接与程序设计语言甚至有时是操作系统联系起来。异常是一个对象,它在出错的地方被抛出,并且被一段用以处理特定类型错误的异常处理代码(exception handler)所接收。异常处理似乎是另一个并行的执行路径,在出错的时候被调用。由于它使用一个单独的执行路径,它并不需要干涉正常的执行代码。因为不需经常检查错误,代码可以很简洁。另外,异常并不同于一个由函数返回的错误值或标记,后两者可以被忽略,而异常不能被忽略,必须保证它们在某些点上进行处理。最后,异常提供了一个从错误状态中进行可靠恢复的方法。除了从这个程序中退出以外,我们常常还可以作出正确的设置,并且回复程序执行,这有助于产生更健壮的系统。

方法(method)[通常称为方法论(methodology)]是一系列的过程和探索,用以降低程序设计问题的复杂性。

经历开发过程时,最重要的问题是:不要迷路。如果不定因素不止一个,在没有创建一个能工作的原型之前,不要计划它将用多长时间和花费多少,这里的自由度太大了。

应当牢记我们正在努力寻找的是什么:

  • 什么是对象(如何将项目分成多个组成部分?)
  • 它们的接口是什么?(需要向每个对象发送什么信息?)
  • 整个过程可以分5个阶段完成,阶段0只是使用一些结构的初始约定。

我们必须首先决定在此过程中应当有哪些步骤。无论建造什么系统,不管如何复杂,都有其基本的目的,有其要处理的业务,有所满足的基本需要。通过各种观察,我们将最终找出它的核心,通常简单而又直接。

这个相当重要,因为它设定了项目的基调,这是一种任务陈述。我们不必一开始就让它正确,但是要不停地努力使其越来越正确。

这一阶段我们有必要把注意力始终放在核心问提上:确定这个系统要做什么。为此,最有价值的工具是一组所谓的用例(use case)。用例之明了系统中的关键特性,它们将展现我们使用的一些节本的类。它们实际上是对类似于下列问题的描述性回答:

  • “谁将使用这个系统?”
  • “执行者用这个系统做什么?”
  • “执行者如何用这个系统工作?”
  • “如果其他人也做这件事,或者同一个执行者有不同的目标,该怎么办?(揭示变化)”
  • “当使用这个系统时,会发生什么问题?(揭示异常)”

只要符合用户的使用感受,系统实际上如何实现并不重要。

在这一阶段,我们必须作出设计,描述这些类和它们如何交互。确定类和交互的出色技术就是:类职责协同(Class-Responsibility-Collaboration,CRC)卡片

这个技术非常简单:只要有一组小空白卡片,在上面书写。每张卡片描述一个类,所写的内容有:

  • 类的名字。体现类行为的本质,一目了然的作用。
  • 类的职责。它应当做什么。通常,它可以仅由成员函数的名字陈述。
  • 类的协同:它与其他类有哪些交互?如果一张小卡片上放不下类所需要的信息,那么这个类就太复杂了(或者是考虑过细了,或者应当创建多个类)。理想的类应该一目了然。

对象开发准则:

  • 让特定问题生成一个类,然后在解决其他问题期间让这个类生长和成熟。
  • 记住,发现所需要的类(和它们的接口),是设计系统的主要内容。如果已经有了那些类,这个项目就不困难了。
  • 不要强迫自己在一开始就知道每一件事情,应当不断学习。
  • 开始编程,让一部分能够运行,这样就可以证明或否定已生成的设计。不要害怕过程型大杂烩式的代码──类的隔离性可以控制它们。坏的类不会破坏好的类。
  • 尽量保持简单。具有明显用途的不太清楚的对象比很复杂的接口好。从小的和简单的类开始,当我们对它有了较好的理解时再拓展这个类接口,但是很难从一个类中删去元素。

这是从粗线条设计向便宜和执行可执行代码体的最初转换阶段,特别是,它将证明或者否定我们的体系结构。这不是一遍的过程,而是反复地建立系统的一系列步骤的开始。

一旦代码框架运行起来,我们增加的每一组特征本身就是一个小项目。在一次迭代(iteration)期间,我们增加一组特征,一次迭代是一个相当短的开发时期。

理想情况下,每次迭代为一到三个星期,在这个期间的最后,我们得到一个集成的、测试过的、比前一周期有更多功能的系统。

通过这些过程,我们可以更早地揭露和解决严重问题,客户有足够的机会改变它们的意见,程序员会更满意,能更精确地掌握项目。

这是开发周期中,传统上称为“维护”的一个阶段。我们不可能第一次就使软件正确,所以应当为学习、返工和修改留有余地。

“使软件正确”的意思不只是使程序按照要求和用例工作,还意味着我们理解代码的内部结构,并且认识到它能很好地协同工作,没有拙笨的语法和过大的对象,也没有难看的暴露的代码。

提出一个漂亮的方案感觉上更接近于艺术,而不是技术。精致总是有回报的,这不是一种虚浮的追求。它不仅给除了一个容易建造和调试的程序,而且容易理解和维护,这就是其经济价值的体现。

XP既是程序设计工作的哲学,又是做程序设计的一组原则。有两个原则最重要:“先写测试”和“结对编程”。

先写测试有两个及其重要的作用:

  • 它强制类的接口有清楚的定义。
  • 能在每次编连软件时运行这些测试。

结对编程(pair programming)反对深植于我们心中的个人主义,一个人编写代码时另一个人在思考。思考者的头脑中保持总体概念,不仅是手头问题这一段,而且还有XP指导方针。这种结对方式,使事情顺畅、有章可循。

第2章 对象的创建与使用

语言的翻译过程

任何一种计算机语言都要从某种人们理解的形式(源代码)转化成计算机能执行的形式(机器指令)。通常,翻译器分为两类:解释器(interpreter)编译器(compiler)

解释器(interpreter)

将源代码转化成一些动作并立即执行这些动作。使用解释器有许多好处。从写代码到执行代码的转换几乎能立即完成,并且源代码总是显存,所以一旦出现错误,解释器能很容易地指出。另外的优点是较好的交互性和适于快速程序开发。

做大项目时候就有某些局限性。要求一次输入整个源代码,一旦出现错误,就很难调试。

编译器(compiler)

编译器直接把源代码转化成汇编语言或机器指令。某些语言可以分别编译各段程序,最后使用连接器(linker)把各段程序连接成一个完整的可执行程序。这个过程称为分段编译(separate compilation)

某些语言(特别是C/C++)编译时,首先要对源代码执行预处理。预处理器(preprocessor)是一个简单的程序,用程序员(利用预处理器指令)定义好的模式代替源代码中的模式。

编译一般分两遍进行。首先,对预处理过的代码进行语法分析。编译器把源代码分解成小的单元并把它们按树形结构组织起来。有时候会在编译的第一遍和第二遍之间使用全局优化器(global optimizer)来生成更短、更快的代码。

编译的第二遍由代码生成器(code generator)遍历语法分析树,把树的每个节点转化成汇编语言或机器代码。

类型检查(type checking)是编译器在第一遍中完成的。类型检查是检查函数参数是否正确使用,以防止许多程序设计错误。由于类型检查是在编译阶段而不是程序运行阶段进行的,所以称之为静态类型检查(static type checking)。在C++里可以不使用静态类型检查。我们可以自己做动态类型检查──这只需要写一些代码。

第3章 C++中的C

全局变量是在所有函数体的外部定义的,程序的所有部分(甚至其他文件中的代码)都可以使用。全局变量不受作用域的影响,总是可用的(也就是说,全局变量的生命期一直到程序的结束)。如果在一个文件中存在全局变量,那么这个文件可以使用这个数据。

局部变量出现在一个作用域内,它们是局限于一个函数的。局部变量经常被称为自动变量(automatic variable),因为它们在进入作用域时自动生成,离开作用域时自动消失。
寄存器变量是一种局部变量,最好避免使用关键字register

关键字static有一些独特的意义。通常,函数中定义的局部变量在函数作用域结束时小时。当再次调用这个函数时,会重新创建该变量的存储空间,其值会被重新初始化。如果想使局部变量的值在程序的整个生命期里仍然存在,就可以定义函数的局部变量为static,并给它一个初始值。初始化只在函数第一次调用时执行,函数调用之间变量的值保持不变。用这种方式,函数可以“记住”函数调用之间的一些信息片段。

static变量的优点是在函数范围之外它是不可用的,所以它不可能被轻易地改变。这会使错误局部化。

static的第二层意思和前面的含义相关,即“在某个作用域外不可访问”。当应用static于函数名和所有函数外部的变量时,它的意思是“在文件的外部不可以使用这个名字”。函数名或变量是局部于文件的;我们说它具有文件作用域(file scope)。即使在另一个文件用extern声明,连接器也不会找到它。

extern关键字告诉编译器存在着一个变量和函数,即使编译器在当前编译的文件中没有看到它,这个变量或函数可能在另一个文件中或者在当前文件的后面定义。

连接(linkage)

连接用连接器所见的方式描述存储空间。连结方式有两种:内部连接(internal linkage)外部连接(external linkage)。内部连接意味着只对正被编译的文件创建存储空间。用内部连接,别的文件可以使用相同的标识符或全局变量,连接器不会发现冲突──也就是为每一个标识符创建单独的存储空间。在C和C++中,内部连接是由关键字static指定的。

外部连接意味着所有被编译过的文件创建一片单独的存储空间。一旦创建存储空间,连接器必须解决所有对这片存储空间的引用。

所有的运算符都会从它们的操作数中产生一个值。除了赋值、自增、自减运算符之外,运算符所产生的值不会修改操作数。修改操作数被称为副作用(side effect)。一般使用修改操作数的运算就是为了产生这种副作用。

可以作为一个运算符用于分隔表达式。在这种情况下,它只产生最后一个表达式的值。在逗号分隔的列表中,其余的表达式的计算只完成它们的副作用。

通常,除了作为一个分隔符,逗号最好不作他用,因为人们不习惯把它看作是运算符。

第4章 数据抽象

库只是他人已经写好的一些代码,按照某种方式包装在一起。通常,最小的包是带有拓展名(如lib)的文件和向编译器声明库中有什么的一个或多个头文件。在跨越多种体系结构的平台(例如Linux和Unix)上,通常,提供库的最明智的方法是使用源代码,这样它就能在心的目标机上被重新配置和编译。

所以,库大概是改进生产效率的最重要的方法。C++的主要设计目标之一就是使库使用起来更加容易。

在C++中,对象就是变量,它的最纯正的定义是“一块存储区”(更明确的说法是,“对象必须有惟一的标识”,在C++中是一个惟一的地址)。它是一块空间,在这里能存放数据,而且还隐含着对这些数据进行处理的操作。

将数据连同函数捆绑在一起的能力可以用于创建新的数据类型。这常常被称为封装(encapsulation)。称为抽象数据类型(abstract data type),也许这是因为它能允许从问题空间抽象概念到解空间。对抽象数据类型[有时称为用户定义类型(user-defined type)]的类型检查就像对内建类型的类型检查一样严格。

头文件是我们和我们的库的用户之间的合约。这份合约描述了我们的数据结构,为函数调用贵点了参数和返回值。

通过要求我们在使用结构和函数之前声明所有这些结构和函数,在定义成员函数之前声明这些成员函数,编译器强制履行这个合约。

放到头文件中的基本原则是“只限于声明”,即只限于对编译器的信息,不涉及通过生成代码或创建变量而分配存储的任何信息。

对于包含结构的每个头文件,应当首先检查这个头文件是否已经包含在特定的cpp文件中,如:

#ifndef HEADER_FLAG
#define HEADER_FLAG
// Type declaration here...
#endif // HEADER_FLAG

防止多次包含的这些预处理器语句常常称为包含守卫(include guard)。

第5章 隐藏实现

在任何关系中,设立相关各方面都遵从的边界是很重要的。需要控制对结构成员的访问有两个理由:一是让客户程序员远离一些它们不需要使用的工具,这些工具对数据类型内部的处理来说是必需的,但对客户程序员解决特定问题的接口却不是必须的。另一个理由是允许库的设计者改变struct的内部实现,而不必担心会对客户程序员产生影响。

引进了三个访问说明符(access specifier)publicprivateprotected。无论什么时候使用访问说明符,后面必须加一个冒号。

  • public意味着在其后声明的所有成员可以被所有的人访问。
  • private关键字则意味着,除了该类型的创建者和类的内部成员函数之外,任何人都不能访问。
  • protectedprivate基本相似,只有一点不同:继承的结构可以访问protected成员,但不能访问private成员。

如果想允许显示地不属于当前结构的一个成员函数访问当前结构中的数据,可以在该结构内部声明这个函数为friend(友元)。注意,一个friend必须在一个结构内声明,这一点很重要。

嵌套的结构并不能自动获得访问private成员的权限。要获得访问私有成员的权限,必须存手特定的规则:首先声明(而不定义)一个嵌套的结构,然后声明它是全局范围使用的一个friend,最后定义这个结构。结构的定义必须与friend声明分开,否则编译器将不把它看做成员。

C++不是完全的面向对象语言,而只是一个混合产品。增加friend关键字就是为了用来解决一些实际问题。这也说明了这种语言是不纯的。毕竟C++语言的设计目的是使用,而不是追求理想的抽象。

访问说明符是结构的一部分,它们并不影响从这个结构创建的对象。程序开始运行之前,所有的访问说明信息都消失了。访问说明信息通常是在编译期间消失的。

一般说来,在程序员编写程序时,依赖特定实现的任何东西都是不合适的。如确有必要,这些特定实现部分应封装在一个结构之内,这样当环境改变时,只需修改一个地方就行了。

访问控制通常是指实现细节的隐藏(implementation hiding)。将函数包含到一个结构内(常称为封装)来产生一种带数据和操作的数据类型,由访问控制在该数据类型之内确定边界。

然后在C++中的class逐渐变成了一个非必要的关键字。它和struct的每个方面都是一样的,除了class中的成员默认为private,而struct中的成员默认为public

C++中的访问控制允许将实现部分与接口部分分开,但实现部分的隐藏是不完全的。编译器仍然必须知道一个对象所有部分的声明。但C++要尽可能多地在编译期间作静态类型检查。这意味着尽早捕获错误,也意味着程序具有更高的效率。然后包含似有实现部分会带来两个影响:一是既是客户程序员不能轻易地访问私有实现部分,但可以看到它;二是造成一些不必要的重复编译。

有些项目不可让最终客户程序员看到其实现部分,就有必要把一个变一号的实际结构放在实现文件中,而不是让其暴露在头文件中。

在我们的编译环境中,当一个文件被修改,或它所依赖的头文件被修改时,项目管理员需要重复编译该文件。这意味着程序员无论何时修改了一个类,无论修改的是公共的接口部分,还是私有成员的声明部分,他都必须再次编译包含头文件的所有文件。这就是通常所说的易碎的基类问题(fragile base-class problem)。对于一个大的项目而言,在开发初期这可能非常难以处理,因为内部实现部分可能需要经常改动。如果这个项目非常大,用于编译的时间过多可能妨碍项目的快速转型。

解决这个问题的技术有时称为句柄类(handle class)或称为Cheshire cat。有关实现的任何东西都消失了,只剩一个单指针“smile”。该指针指向一个结构,该结构的定义与其所有的成员函数的定义一同出现在实现文件中。这样,只要接口部分不改变,头文件就不需变动,而实现部分可以按需要任意更改,完成后只需要对实现文件进行重新编译,然后重新连接到项目中。

第6章 初始化与清除

C++中,初始化和清楚的概念是简化库的使用的关键所在,并可以减少那些在客户程序员忘记去完成这些操作时会引起的细微错误。

类的设计者可以通过提供一个叫做构造函数(constructor)的特殊函数来保证每个对象都被初始化。如果一个类有构造函数,编译器在创建对象时就自动调用这一函数。构造函数的名字与类的名字一样。这样的函数在初始化时会被自动调用。

构造函数和析构函数是两个非常特殊的函数:它们没有返回值。

在程序中创建和消除一个对象的行为非常特殊,就像出生和死亡,而且总是由编译器来调用这些函数以确保它们被执行。如果它们有返回值,要么编译器必须知道如何处理返回值,要么就只能由客户程序员子集来显式的调用构造函数与析构函数,这样一来,安全性就被破坏了。

在一个库中,对于一个曾经用过的对象,如果不做处理,对象就永远不会消失。在C++中,清除就像初始化一样重要,它通过析构函数来保证清除的执行。

析构函数的语言与构造函数一样,用类的名字作为函数名。然而析构函数前面加上一个代字号(~),以和构造函数区别。

当对象超出它的作用域时,编译器将自动调用析构函数。

在C++中,应该在尽可能靠近变量的使用点处定义变量,并在定义时就初始化。这是出于安全性的考虑,通过减少变量在块中的生命周期,就可以减少该变量在块的其他地方被误用的机会。

集合(aggregate)就是多个事物聚集在一起。这个定义包括混合类型的集合。

默认构造函数(default constructor)就是不带任何参数的构造函数。一旦有了一个构造函数,编译器就会确保不关在什么情况下它总是会被调用。

尽管编译器会创建一个默认的构造函数,但是编译器合成的构造函数的行为很少是我们期望的。我们应该把这个特征看成是一个安全网,但尽量少用它。一般说来,应该明确地定义子集的构造函数,而不让编译器来完成。

第7章 函数重载与默认参数

能使名字方便使用,是任何程序设计语言的一个重要特征。

尽管函数重载对构造函数来说是必须的,但是它仍然是一个通用的方便手段,并可以与任意函数一起使用。另外,函数重载意味着,我们有两个库,它们都有同名的函数,只要它们的参数列表不同就不会发生冲突。

可以对不同的函数用同样的名字,只要求函数的参数不同,编译器会修饰这些名字、范围和参数来产生内部名以供它和连接器使用。

仅仅依靠返回值来重载函数实在过于微妙,所以在C++中禁止这样做。

对名字修饰还可以带来一个额外的好处。在C中,如果用户错误地声明了一个函数,或者更糟糕地,一个函数还没声明就调用了,而编译器则按照函数被调用的方式去推断函数的声明。若这样的推断不正确,那么就会变成一个很难发现的错误。

在C++中,所有的恶函数在被使用前都必须事先声明,因此出现上述情况的机会大大减少了。名字修饰会给我们提供一个安全网,这也就是人们常说的类型安全连接(type-safe linkage)

默认参数(default argument)是在函数声明时就已给定的一个值,如果在调用函数时没有指定这一参数的值,编译器就会自动地插上这个值。

在使用默认参数时必须记住两条规则。第一,只有参数列表的后部参数才是可默认的,也就是说,不可以在一个默认参数后面又跟一个非默认的参数。第二,一旦在一个函数调用中开始使用默认参数,那么这个参数后面的所有参数都必须是默认的。

默认参数只能放在函数声明中,通常在一个头文件中。编译器必须在使用该函数之前知道默认值。有时人们为了阅读方便在函数定义处放上你一些默认的注释值。

第8章 常量

常量概念(由关键字const表示)是为了使程序员能够在变和不变之间画一条界线。这在C++程序设计项目中提供了安全性和可控性。

可以使指针成为const。当处理const指针时,编译器仍将努力避免存储分配并进行常量折叠。如果程序员以后想在程序代码中改变const这种指针的使用,编译器将给出通知。这大大增加了安全性。

当使用带有指针的const时,有两种选择:const修饰指针正指向的对象,或者const修饰在指针里存储的地址。

定义指针的技巧是在标识符的开始处读它并从里向外读。const修饰“最靠近”它的那个。如:const int* u; 可以读成:u是一个指针,它指向一个const int

使指针本身称为一个const指针,必须把const标明的部分放在的右边。如:`int d = 1; int const w = &d;可以读成w是一个const指针指向一个int`。

因为指针本事现在是const指针,编译器要求给它一个初始值,这个值在指针生命期内不变。然而要干煸它所指向的值是可以的:*w = 2;

当然也可以把一个const指针指向一个const对象。

如果可能的话,一行只定义一个指针,并尽可能在定义时初始化。

可以把一个非const对象的地址赋给一个const指针,因为也许有时不想改变某些可以改变的东西。然后不能把一个const对象的地址赋给一个非const指针,因为这样做可能通过被赋值的指针改变这个对象的值。

如果函数是按值传递,则可用指定参数是const的。这里参数不能被改变。所以它其实是函数创建者的工具,而不是函数调用者的工具。

为了不使调用者很小,在函数内部用const限定参数优于在参数表里用const限定参数。可以用一个指针来实现,但更好的语法形式是“引用”。简而言之,引用相一个被自动间接引用的常量指针,它的作用是成为对象的别名。为建立一个引用,在定义里使用&

如果一个函数的返回值是一个常量(const),这就约定了函数框架里的原变量不会被修改。另外,因为这是按值返回的,所以这个变量被制成副本,舍得初值不会被返回值所修改。

对于内部类型来说,按值返回的是否是一个const,是无关紧要的,所以按值返回一个内部类型时,应该去掉const,从而不使客户程序员混淆。

当处理用户定义的类型时,按值返回常量是很重要的。如果一个函数按值返回一个类对象为const时,那么这个函数的返回值不能是一个左值(不能被赋值或修改)。

如果传递或返回一个地址(一个指针或一个引用),客户程序员去取地址并修改其初值是可能的。如果使这个指针或者引用成为const,就会阻止这类事的发生,这是非常重要的事情,事实上,无论什么时候传递一个地址给一个函数,都应该尽可能用const修饰它。如果不这样做,就不能以const指针参数的方式使用这个函数。

常数表达式使用常量的地方之一是在类里。典型的例子是在一个类里建立一个数组,并用const代替#define设置数组大小。数组大小一直隐藏在类里,这样,如果用size表示数组大小,就可以把size这个名字用在另一个类里而不发生冲突。

读者可能认为合乎逻辑的选择是把一个const放在类里。但这样不会产生预期的效果。在一个类里,const又部分恢复到它在C语言中的含义。它在每个类对象里分配存储并代表一个值,这个值一旦被初始化以后就不能改变。在一个类里使用const意味着“在这个对象生命期内,它是一个常量”。然而,对这个常量来讲,每个不同的对象可以含有一个不同的值。

这样,在一个类里建立一个普通的(非static的)const时,不能给它初值。这个初始化工作比须在构造函数里进行,当然,要在构造函数的某个特别的地方进行。因为const必须在建立它的地方被初始化,所以在构造函数的主体里,const必定已被初始化了。否则,就只有等待,直到在构造函数主体以后的某个地方给它初始化,这意味着过一会儿才给const初始化。当然,无法防止在构造函数主体的不同地方改变const的值。

构造函数初始化列表

在构造函数里有个专门初始化的地方,这就是构造函数初始化列表(constructor initializer list),起初用在机成立。构造函数初始化列表是一个出现在函数参数表和冒号后,但在构造函数主体开头的花括号前的“函数调用列表”。这提醒人们,表里的初始化发生在构造函数的任何代码执行之前。这是初始化所有const的地方,若sizeFred类的一个const成员的话,其正确形式是:

Fred::Fred(int sz) : size(sz){}

把一个内部类型风装载一个类里以保证用构造函数初始化,这是很有用的。

若要让类有编译期间的常量成员,就要求使用另外一个关键字static。在这种情况下,关键字static意味着“不管类的对象被创建多少次,都只有一个实例”。因此,一个内部类型的static const可以看作一个编译期间的常量。

必须在static const定义的地方对它进行初始化。

如果声明一个成员函数为const(修饰符const放在函数参数表的后面),则等于告诉编译器该成员函数可以为一个const对象所调用。一个没有被明确声明为const的成员函数被堪称是将要修改数据成员的函数,而且编译器不允许它为一个const对象所调用。

关键字const必须同样的方式重复出现在定义里,否则编译器把它看成一个不同的函数!
一个const成员函数调用const和非const对象是安全的,因此,可以把它看做成员函数的最一般形式。不修改数据成员的任何函数都应该把它们声明为const,这样它可以和const对象一起使用。

volatile的语法与const是一样的,但是volatile的意思是“在编译器认识的范围外,这个数据可以被改变”。不知何故,环境正在改变数据(可能通过多任务、多线程或者中断处理),所以,volatile告诉编译器不要擅自作出有关该数据的任何假定,优化期间尤其如此。

第9章 内联函数

C++从C中集成的一个重要特征是效率。加入C++的效率显著地低于C的效率,那么就会有很大一批程序员不去使用它。

为了既保持预处理器宏的效率又增加安全性、而且还能像一般成员函数一样可以在类里访问自如,C++引入了内联函数(inline function)

预处理器宏存在问题的关键是我们可能认为预处理器的行为和编译器的行为一样。

在解决C++中宏访问private类成员的问题过程中,所有和预处理器宏有关的问题也随之排除了。这是通过使宏被编译器控制来实现的。在C++中,宏的概念是作为内联函数(inline function)来实现的,而内联函数无论从哪一方面上说都是真正的函数。

任何在类中定义的函数自动成为内联函数,但也可以在非类的函数前面加上inline关键字使之称为内联函数。但为了使之有效,必须使函数体和声明结合在一起,否则,编译器将它作为普通函数对待。

一般应该把内联定义放在头文件里。当编译器看到这个定义时,它把函数类型(函数名+返回值)和函数体放到符号表里。当使用函数时,编译器检查以确保调用是正确的且返回值被正确使用,然后将函数调用替换为函数体,因而消除了开销。内联代码的确占用空间,但假如函数较小,这实际上比为了一个普通函数调用而产生的代码(参数压栈和执行CALL)占用的空间还小。

类内部的内联函数节省了在外部定义成员函数的额外步骤,所以我们一定想在类声明内每一处都使用内联函数。但应记住,使用内联函数的目的是减少函数调用的开销。但是,假如函数较大,由于需要在调用函数的每一处重复复制代码,这样将使代码膨胀,在速度方面获得的好处就会减少。

在类中内联函数的最重要的使用之一是用做访问函数(access function)。这是一个小函数,它容许读或修改对象状态──即一个或几个内部变量。即最通常所说的setget方法(修改器和访问器)。

对于函数,编译器在它的符号表里放入函数类型(即包括名字和参数类型的函数原型及函数的返回类型)。另外,当编译器看到内联函数和对内联函数体的分析没有发现错误时,就将对应于函数体的代码也放入符号表。代码是以源程序形式存放还是以编译过的汇编指令形式存放取决于编译器。

当调用一个内联函数时,编译器首先确保调用正确,即所有的参数类型必须满足:要么与函数参数表中的参数类型一样,要么编译器能够将其转换为正确类型,并且返回值在目标表达式里应该是正确类型或可改变为正确类型。

有两种编译器不能执行内联的情况。在这些情况下,它就像对非内联函数一样,根据内联函数定义和为函数建立存储空间,简单地将其转换为函数的普通形式。

加入函数太复杂,编译器将不能执行内联。这取决于特定的编译器,但对大多数编译器这时都回放弃内联方式,因为这时内联可能不能提高任何效率。一般地,任何种类的循环都被认为太复杂。

内联仅是编译器的一个建议,编译器不会被强迫内联任何代码。一个好的编译器将会内联小的、简单的函数,同时明智地忽略那些太复杂的内联。这将给我们想要的结果──具有宏效率的函数调用的真正语义学。

当一个内联函数在类中向前引用一个还没有声明的函数时,是可以正常工作的,因为C++语言规定:只有在类声明结束后,其中的内联函数才会被计算。

第10章 名字控制

创建名字是程序设计过程中一项最基本的活动,当一个项目很大时,它会不可避免地包含大量的名字。

关于static的所有使用最基本的概念是指“位置不变的某个东西”,不管这里是指在内存中的物理位置还是指在文件中的可见性。

在C和C++中,static都有两种基本的含义,并且这两种含义经常是相互冲突的:

  • 在固定的地址上进行存储分配,也就是说对象是在一个特殊的静态数据区(static data area)上创建的,而不是每次函数调用时在堆栈上产生的。这也是静态存储的概念。
  • 对一个特定的编译单位来说是局部的。这样,static控制名字的可见性(visibility),所以这个名字在这个单元或类外是不可见的。这也描述了连接的概念,它决定连接器将看到哪些名字。

通常,在函数体内定义一个局部变量时,编译器在每次函数调用时使堆栈的指针下移到一个适当的位置,为这些局部变量非配内存。如果这个变量又一个初始化表达式,那么每当程序运行到此处,初始化就被执行。

然而,有时想在两次函数调用之间保留一个变量的值,可以通过定义一个全局变量来实现,但这样一来,这个变量就不仅仅只受到这个函数的控制。C和C++都允许在函数内部定义一个static对象,这个对象将存储在静态数据区中,而不是在堆栈中。这个对象只在第一次调用是初始化一次,以后它将在两次函数调用之间保持它的值。

一般情况下,在文件作用域(file scope)内的所有名字(即不嵌套在类或函数中的名字)对程序中的所有翻译单元来说都是可见的。这就是所谓的外部连接(external linkage),因为在连接时这个名字对连接器来说是可见的,对单独的翻译单元来说,它是外部的。全局变量和普通函数都有外部连接。

在文件作用域内,一个被明确声明为static的对象或函数的名字对翻译单元来说是局部于该单元的。这些名字有内部连接(internal linkage)

内部连接的一个好处是这个名字可以放在一个头文件中

第11章 引用和拷贝构造函数

C不允许随便地把一个类型的指针赋给另一个类型,但允许通过void*来实现。由于C的这种功能允许把任何一种类型看做别的类型处理,这就在类型系统中流下了一个大的漏洞。C++不允许这样做,如果真想把某种类型当作别的类型处理,则必须显示地使用类型转换。

引用(reference)(&)就像能自动地被编译器间接引用的常量型指针。它常用于函数的参数表中和函数的返回值,但也可以独立使用。

使用引用时有一定的规则:

  • 当引用被创建时,它必须被初始化(指针则可以在任何时候被初始化)。
  • 一旦一个引用被初始化为指向一个对象,它就不能改变为另一个对象的引用(指针则可以在任何时候指向另一个对象)。
  • 不可能有NULL引用。必须确保引用是和一块合法的存储单元关联。

最经常看见引用的地方是在函数参数和返回值中。当引用被用做函数参数时,在函数内任何对引用的更改将对函数外的参数产生改变。当然,可以通过传递一个指针来做相同的事情,但引用具有更清晰的语法。

如果从函数中返回一个引用,必须像从函数中返回一个指针来一样对待。当函数返回时,无论引用关连的是什么都应该存在,否则,将不知道指向哪一个内存。

若想要改变指针本身而不是它所指向的内容,函数参数变成指针的引用,用不着取得指针的地址。

#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;
}

当给函数传递参数时,人们习惯上是通过常量引用来传递。这种简单习惯可以大大提高效率:传值方式需要调用构造函数和析构函数,然而,如果不想改变参数,则可以通过常量引用传递,它仅需要将地址压栈。

拷贝构造函数是一个更令人混淆的概念,常被称为X(X&)(“X引用的X”),在函数调用时,这个构造函数是控制通过传值方式传递和返回用户定义类型的根本所在。这是很重要的。

在C和C++中,参数是从右向左进栈的,然后调用函数,调用代码负责清理栈中的参数。但是要注意,通过按值传递方式传递参数时,编译器简单地将参数拷贝压栈──编译器知道拷贝有多大,并知道如何对参数压栈,对它们正确拷贝。

当编译器为函数调用产生代码时,它首先把所有的参数压栈,然后调用函数。在函数内部,产生代码,向下移动栈指针为函数局部变量提供存储单元。

第13章 动态对象创建

有时我们能知道程序中对象的确切数量、类型和生命期。但情况不总是这样。为了解决这个普遍的编程问题,在运行时可以创建和销毁对象是最基本的要求。C提供了动态内存分配(dynamic memory allocation)函数malloc()free(),这些函数在运行时从堆(也称自由内存)中分配存储单元。

然而,在C++中这些函数将不能很好的运行。因为构造函数不允许我们向它传递内存地址来进行初始化。

C++是如何保证正确的初始化和清理,有允许我们在堆上动态创建对象呢?

答案是,使动态对象称为语言的核心。malloc和free是库函数,因此不在编译器控制范围之内。然而,如果我们有一个完成动态内存分配及初始化组合动作的运算符和另一个完成清理及释放内存组合动作的运算符,编译器仍可以保证所有对象的构造函数和析构函数会被调用。

当创建一个C++对象,会发生两件事:

  • 为对象分配内存。
  • 调用构造函数来初始化那个内存。

到目前为止,因该确保步骤2一定发生。C++强迫这样做是因为未初始化的对象是程序出错的主要原因。对象在那里和如何创建无关紧要──构造函数总是需要被调用。

然而,步骤1可以用几种方式或在可选择的时间发生:

  • 在静态存储区域,存储空间在程序开始之前就可以分配。这个存储空间在整个程序运行期间都存在。
  • 无论何时到达一个特殊的执行点(左大括号)时,存储单元都可以在栈上被创建。除了执行点(右大括号),这个存储单元自动被释放。这些栈分配运算内置于处理器的指令集中,非常有效。然而,在写程序时,必须知道需要多少个存储单元,以便编译器生成正确的指令。

存储单元也可以从一块称为堆(也被称为自由存储单元)的地方分配。这被称为动态内存分配。在运行时调用程序分配这些内存。这意味着可以在任何时候决定分配内存及分配多少内存。当然也需负责决定何时释放内存。这块内存的生存期由我们选择决定──而不受范围决定。

为了在运行时动态分配内存,如malloc()free()。这些函数是有效的但较原始的,需要编程人员理解和小心使用。例如,必须对分配的空间进行显式地类型转换,还需要自行调用初始化的函数(构造函数并不能被显式地调用)。这很容易出错。所以,C程序设计者常常在静态内存区域使用虚拟内存机制分配很大的变量数组以避免使用动态内存分配。为了在C++中使得一般的程序员可以安全使用库函数而不费力,所以C的动态内存方法是不可接受的。

C++中的解决方案是把船舰一个对象所需的所有动作都结合在一个称为new的运算符里。当用new(new的表达式)创建一个对象时,它就在堆里为对象分配内存并为这块内存调用构造函数。等价于调用malloc()函数并调用构造函数。返回一个指向该对象的this指针。

MyType *fp = new MyType;

默认的new还进行检查以确信在传递地址给构造函数之前内存分配是成功的,所以不必显式地确定调用是否成功。

我们可以看到,在堆里创建对象的过程变得简单了──只是一个简单的表达式,它带有内置的长度计算、类型转换和安全检查。这样在堆里创建一个对象何在栈里创建一个对象一样容易。

delete表达式首先调用析构函数,然后释放内存(常调用free())。正如new表达式返回一个指向对象的指针一样,delete表达式需要一个对象的地址。

delete fp;

delete只用于删除由new创建的对象。

当在堆栈里自动创建对象时,对象的大小和它们的生存期被准确地内置在生成的代码里,这是因为编译器知道确切的类型、数量和范围。而在堆里创建的对象还包括另外的时间和空间的开销。以下是一个典型情况:

调用malloc(),即从堆里搜索一块足够大的内存来满足请求,可以通过检查按某种方式排列的映射或目录来实现,这样的映射或目录用以显示内存的使用情况。这个过程很快但可能要试探几次,所以它可能是不确定的──即每次运行malloc()并不是花费了相同的时间。

在栈或堆上创建一个对象数组是同样容易的。但这里有一个限制条件:由于不带参数的构造函数必须被每一个对象调用,所以除了在栈上整体初始化外还必须有一个默认的构造函数。

MyType *fp = new MyType[100];
MyType *fp2 = new MyType;

我们知道其实fp和fp2是数组的起始地址。

delete fp2; // OK
delete fp; // Not the desired effect

对于fp来说,另外99个析构函数没有调用,正确应该这样:

delete []fp;

空的方括号告诉编译器产生代码,该代码的任务是将从数组创建时存放在某处的对象数量取回,并为数组的所有对象调用析构函数。

上面定义的fp可以被修改指向任何类型,但这对于一个数组的起始地址来说没有什么意义。一般来讲,把它定义为常量会更好些,因为这样任何修改指针的企图都会被认为出错。

使得指针指向的int不能修改(指针可以修改):

int const* q = new int[10];   or
const int* q = new int[10];

使得指针不能被修改(数组可以修改):

int * const q = new int[10];

operator new()找不到组够大的连续内存块来安排对象时,一个称为new-handler的特殊函数将会被调用。首先,检查指向函数的指针,如果指针非0,那么它指向的函数将被调用。

new-handler的默认动作是产生一个异常(throw an exception)

第14章 继承和组合

C++中最重要的特征之一是代码重用。但是如果希望更进一步,就不能仅仅用拷贝代码和修改代码的方法,而是要做更多的工作。

关键技巧是使用这些类,但不修改已存在的代码。第一种方法很直接:我们简单地在心类中创建已存在类的对象。因为新类是由已存在的类的对象组合而成,所以这种方法称为组合(composition)

第二种方法要复杂些。我们创建一个新类作为一个已存在类的类型。我们不修改已存在的类,而是采取这个已存在类的形式,并将代码加入其中。这种巧妙方法称为继承(inheritance),其中大量的工作是由编译器完成。继承是面向对象程序设计的基石。

在语法上和行为上,组合和继承大部分是相似的。

组合语法 直接把子对象放入新对象的组成中即可。

访问嵌入对象(称为子对象)的成员的成员函数只需再一次的成员选择。更常见的是把嵌入的对象设为私有,因此它们将称为内部实现的一部分(这意味着如果我们原因,可以改变这个实现)。新类的公有接口函数包括了对嵌入对象的使用,但没有必要模仿这个对象的接口。

当继承时,我们会发现“这个新类很像原来的类”。我们规定,在代码中和原来一样给出该类的名字,但在类的左括号的前面,加一个冒号和基类的名字(对于多重继承,要给出多个基类名,它们之间用逗号分开)。当昨晚这些时,将会自动地得到基类中的所用数据成员和成员函数。

class Y:public X{//......}

我们可以看到YX进行了继承,这意味着Y将包含X中的所有数据成员和成员函数。所有X中的私有成员在Y中仍然是私有的,因为YX进行了继承并不意味着Y可以不遵守保护机制。

这里基类前面是public。由于在继承时,基类中所有的成员都是被预设为私有的,所以如果基类的前面没有public,这意味着基类的所有公有成员将在派生类中变为私有的。这显然不是所希望的,我们希望基类中的所有公有成员在派生类中仍是公有的,这可以在继承时通过使用关键字public来实现。

倘若XY类中均有一个set()函数,那么将会使用Y中重新定义的版本。这也就是说,如果不想使用某个继承而来的函数,我们可以改变它的内容。然而,当我们重新定义了一个函数之后,仍可能想调用基类的函数,为了调用基类Xset()函数,必须使用作用域运算符来显示地表明基类名。

在C++中保证正确的初始化是多么重要,这一点在组合和继承中也是一样。当创建一个对象时,编译器确保调用了所有子对象的构造函数。

但是,如果子对象没有默认构造函数或如果想改变构造函数的某个默认参数,就会出现问题,因为这个新类的构造函数没有权利访问这个子对象的私有数据成员,所以不能直接对它们初始化。

解决的方法很简单:对于子函数调用构造函数,C++为此提供了专门的语法,即构造函数的初始化表达式表。构造函数的初始化表达式的形式模仿继承活动。

对于继承,我们把基类至于冒号和这个类体的左括号之间。而在构造函数的初始化表达式中,可以将对子对象构造函数的调用语句放在构造函数参数表和冒号之后,在函数体的左括号之前。对于从Bar继承来的类MyType,如果Bar的构造函数只有一个int型参数,则可以表示为:

MyType::MyType(int i) : Bar(i) { // ...

对于组合,也可以对成员对象使用同样语法,只是所给出的不是类名,而是对象的名字。如果在初始化表达式表中有多个构造函数的调用,应当用逗号加以隔开:

MyType2::MyType(int i) : Bar(i), m(i+1) { // ...

这是类MyType2构造函数的开头,该类是从Bar继承来的,并且包含一个称为m的成员对象。请注意,虽然可以在这个构造函数的初始化表达式表中看到基类的类型,但只能看到成员对象的标识符。

构造函数的初始化表达式表允许我们显式地调用成员对象的构造函数。它的主要思想是,在进入新类的构造函数体之前调用所有其他的构造函数。这样,对于子对象的成员函数所做的任何调用都总是转到了这个被初始化的对象中。即使编译器可以隐藏地调用默认的构造函数,但在没有对所有的成员对象和基类对象的构造函数进行调用之前,就没有办法进入该构造函数体。这是C++的一个强化的机制,它确保了,如果没有调用对象的构造函数,就别想向下进行。

对于哪些没有构造函数的内部类型嵌入对象,这一切会怎么样?

为了使语法一致,可以把内部类型看做这样一种类型,它只有一个取单个参数的构造函数,而这个参数与正在初始化的变量类型相同。于是可以这么写:

class X {
    int i;
    float f;
    char c;
    char* s;
public:
    X() : i(7), f(1.4), c(‘x’), s(“howdy”) {}
};

这些“伪构造函数调用”操作可以进行简单的赋值。这种方法很方便,并且具有良好的编码风格。甚至在类之外创建内部类型的变量是,也可以使用伪构造函数语法。

这使得内部类型的操作有点类似于对象,但是这些并不是真正的构造函数。特别地,如果没有显式的进行伪构造函数调用,初始化是不会执行的。

还可以把组合和继承放在一起使用。

自动析构函数调用

虽然常常需要在初始化表达式表中显式构造函数调用,但并不需要做显式的析构函数调用,因为对于任何类型只有一个析构函数,并且它并不取任何参数。然而,编译器仍要保证所有的析构函数被调用,这意味着,在整个层次中的所有析构函数中,从派生类最底层的析构函数开始调用,一直到根层。

构造是从类层次的最根处开始,而在每一层,首先会调用基类构造函数,然后调用成员对象构造函数。调用析构函数则严格按照构造函数相反的次序──这是很重要的,因为要考虑潜在的相关性(对于派生类中的构造函数和析构函数,必须假设基类子对象仍然可供使用并且已经被构造了──或者还未被消除)。

另一个有趣现象是,对于成员对象,构造函数调用的次序完全不受构造函数的初始化表达式表中的次序影响。该次序是由成员对象在类中声明的次序所决定的。

如果继承一个类并且对它的成员函数重新进行定义,可能会出现两种情况:

  • 第一种是正如在基类中所进行的定义一样,在派生类的定义中明确地定义操作和返回类型。这称之为对普通成员函数的重定义(redefining),而如果基类的成员函数是虚函数的情况,又可称之为重写(overriding)。
  • 任何时候重新定义了基类中的一个重载函数,在新类之中所有其他版本则被自动地隐藏了。

如果通过修改基类中一个成员函数的操作与/或返回类型来改变了基类的接口,我们就没有使用继承通常所提供的功能,而是按另一种方式来重用了该类。这并不一定意味着做错了,只是由于继承的最终目标是为了实现多态性(polymorphism)

不是所有的函数都能自动地从基类继承到派生类中的。构造函数和析构函数用来处理对象的创建和析构操作,但它们只知道对它们的特定层次上的的对象做些什么。所以,在该类以下各个层次中的所有构造函数和析构函数都必须被调用,也就是说,构造函数和析构函数不能被继承,必须为每一个特定的派生类分别创建。

另外,operator=也不能被继承,因为它完成类似于构造函数的活动。

静态(static)成员函数与非静态成员函数的共同点:

  • 它们均可被继承到派生类中。
  • 如果我们重新定义了一个静态成员,所有在基类中的其他重载函数会被隐藏。
  • 如果我们改变了基类中一个函数的特征,所有使用该函数名字的基类版本都将会被隐藏。然而,静态(static)成员函数不可以是虚函数(virtual)

组合通常是在希望新类内部具有已存在类的功能时使用,而不是希望已存在类作为它的接口。这就是说,嵌入一个对象用以实现新类的功能,而新类的用户看到的是新定义的接口而不是来自老类的接口。为此,在新类的内部嵌入已存在的private对象。

有时,又希望允许类用户直接访问新类的组成,这就让成员对象是public。由于成员对象使用自己的访问控制,所以是安全的,而当用户了结了我们所做的组装工作时,会更容易理解接口。

is-a关系用继承表达,has-a关系用组合表达。

如果由一个已存在的类创建一个新类,并且希望这个类的每件东西都进来,就称为子类型化(subtyping)。这个新类与已存在的类有着严格相同的接口(希望增加任何我们想要加入的其他成员函数),所以能在已经用过这个已存在的类的任何地方使用这个新类,这就是必须使用继承的地方。

通过在基类表中去掉public或通过显式地声明private,可以私有地继承基类。当私有继承时,我们是“照此实现”;也就是说,创建的新类具有基类的所有数据和功能,但这些功能是隐藏的,所以它只是部分的内部实现。该类的用户访问不到这些内部功能,并且一个对象不能被看做是这个基类的实例。

为了完整性,private继承被包含在该语言中。但是通常希望使用组合而不是private继承。

私有继承时,基类的所有public成员都变成了private。如果希望其中的任何一个是可视的,只要用派生类的public部分声明它们的名字即可

using Pet::eat;

其中Pet是私有继承的类,这样就可以使用Pet类中的eat成员函数。

这样,如果想要隐藏基类的部分功能,则private继承是有用的。注意给出一个重载函数的名字将使基类中的所有它的重载版本公有化。

在使用private继承取代组合之前,应当仔细考虑,当与运行时类型标识相连时,私有继承特别复杂。

实际项目中,有时希望某些东西隐藏起来,但仍允许其派生类的成员访问,此时可用protected。它的意思是:“就这个类的用户而言,它是private的,但它可被从这个类继承来的任何类使用”。

最好让数据成员是private,因为我们应该保留改变内部实现的权利。然后才能通过protected成员函数控制对该类的继承者的访问。

保护继承的派生类意味着对其他类来说是“照此实现”,但它是对于派生类和友元是is-a。它是不常用的,它的存在只是为了语言的完备性。

除了赋值运算符以外,其余的运算符可以自动地继承到派生类中。

直到我们已经很好地学会程序设计并完全理解这个语言时,我们才能试着去用多重继承。不管我们如何认为我们必须用多重继承,我们总是能通过单继承完成。

多重继承引起很多含糊的可能性。

继承和组合的优点之一是它支持渐增式开发(incremental development),它允许在已存在的代码中引进代码,而不会给原来的代码带来错误。

认识到程序开发就像人的学习过程一样,是一个渐增的过程,这是很重要的。我们能做尽可能多的分析,但当开始一个项目时,我们仍不可能知道所有的答案。

记住,继承首先是表示一种关系,即“新类属于老类的类型(a type of)”。我们的程序不应当关心怎样怎样摆布位,而应当关心如何创建和处理各类型的对象,以便用问题空间的术语表示模型。

继承最重要的方面不是它为新类提供了成员函数,而是它是基类与新类之间的关系,这种关系可被描述为:“新类属于原有类的类型”。

这个描述不仅仅是一种想象的解释继承的方法──它直接由编译器支持。将新类的引用或指针转变成基类的引用或指针的活动被称为向上类型转换(upcasting)

为什么要“向上类型转换” 这个术语的引入是有其历史原因的,而且它也与类继承图的传统画法有关:在顶部是根,向下生长。

向上类型转换总是安全的。因为是从更专门的类型到更一般的类型──对于这个类接口可能出现的唯一事情是它失去成员函数,而不是获得它们。这就是编译器允许向上类型转换而不需要显式地说明或做其他标记的原因。

必须记住无论何时我们在创建了子集的拷贝构造函数时,都要正确地调用基类拷贝构造函数(正如编译器所作的)。

第15章 多态性和虚函数

多态性(在C++中通过虚函数来实现)是面向对象程序设计语言中数据抽象和继承之外的第三个基本特性。

多态性(polymorphism)提供了接口与具体实现之间的另一层隔离,从而将whathow分离开来。多态性改善了代码的组织性和可读性,同时也使创建的程序具有可拓展性。

封装(encapsulation)通过组合特性和行为来生成心的数据类型。访问控制通过使细节数据设为private,将接口从具体实现中分离开来。

C程序员可以用三步演变为C++程序员。

  1. 第一步:简单地把C++作为一个“更好的C”。
  2. 第二步:进入“基于对象”的C++。
  3. 第三步:了解和使用虚函数,这是理解面向对象程序设计的转折点。不用虚函数,就等于还不懂得面向对象程序设计(OOP),虚函数增强了类型概念,而不只是在结构内部隐蔽地封装代码。

取一个对象的地址(指针或引用),并将其作为基类的地址来处理,这被称为向上类型转换(upcasting),因为继承树的绘制方式是以基类为顶点的。

把函数体与函数调用相联系称为捆绑(binding)。当捆绑在程序运行之前(由编译器和连接器)完成时,这称为早捆绑(early binding)。C编译只有一种函数调用方式,就是早捆绑。晚捆绑(late binding)意味着捆绑根据对象的类型,发生在运行时。晚捆绑又称为动态捆绑(dynamic binding)或运行时捆绑(runtime binding)。对于一种编译语言,编译器并不知道实际的对象类型,但它插入能找到和调用正确函数体的代码。

对于特定的函数,为了引起晚捆绑,C++要求在基类中声明这个函数时使用virtual关键字。晚捆绑只对virtual函数起作用,而且只在使用含有virtual函数的基类的地址时发生,尽管它们也可以在更早的基类中定义。

为了创建一个像virtual这样的成员函数,可以简单地在声明这个函数时使用virtual关键字。仅仅在声明的时候需要使用关键字virtual,定义时并不需要。如果一个函数在基类中被声明为virtual,那么在所有的派生类中它都是virtual的。在派生类中virtual函数的重定义通常称为重写(overriding)

注意,仅需要在基类中声明一个函数为virtual。调用所有匹配基类声明行为的派生类函数都将使用虚机制。

在一个设计风格良好的OOP程序中,大多数甚至所有的函数都可以沿用基类的某个模型,只需与基类接口通信。这样的程序是可拓展的(extensible),因为可以通过从公共基类继承新数据类型而增加新功能。操作基类接口的函数完全不需要改变就可以适合于这些新类。

关键字virtual告诉编译器它不应当执行早捆绑,相反,它应当自动安装对于实现晚捆绑必需的所有机制。

为了达到这个目的,典型的编译器对每个包含虚函数的类创建一个表(VTABLE)。在VTABLE中,编译器放置特定类的虚函数的地址。在每个带有虚函数的类中,编译器秘密地防止一个指针,称为vpointer(缩写为VPTR),指向这个对象VTABLE。当通过基类指针做虚函数调用时(也就是做多台调用时),编译器静态地插入能取得这个VPTR并在VTABLE表中查找函数地址的代码,这样就能调用正确的函数并引起晚捆绑的发生。

为每个类设置VTABLE,初始化VPTR、为虚函数调用插入代码,所有这些都是自动发生的。利用虚函数,即使在编译器还不知道这个对象的特定类型的情况下,也能调用这个对象中正确的函数。

如果有一个或多个虚函数,编译器都只在这个结构中插入一个单个指针(VPTR),指向一个存放函数地址的表。我们只需要一个表,因为所有虚函数地址都包含在这个但个表里。

C++并不是对于绝对地址的一个简单的CALL,而是为设置虚函数调用需要两条以上的复杂的汇编指令。这既需要代码空间,又需要执行时间。

一些面向对象的语言已经接受了这种途径,即晚捆绑对于面向对象程序设计是性质所固有的,所以应当总是出现,它不应当是可选的,而且用户并不一定需要知道它。这是在创造语言的设计时决定的,而这种特殊的方法对于许多语言是适合的(smalltalk、Java和Python)。

virtual关键字可以改变程序的效率。当设计类时,我们不应当为效率问题担心。如果使用多态,就处处使用虚函数。当试图加速代码时,只需寻找可以不使用虚函数的函数。

有些证据表明,C++中的规模和速度改进效果是在C的规模和速度的10%之内,并且常常更接近。能够得到更小的规模和更高速度的原因是C++可以有比C更快的方法设计程序,而且设计的程序更小。

在设计时,常常希望基类仅仅作为其派生类的一个接口。这就是说,仅想对基类进行向上类型转换,使用它的接口,而不希望用户实际地创建一个基类对象。就可以在基类中加入至少一个纯虚函数(pure virtual function),来使基类成为抽象(abstract)类。纯虚函数使用关键字virtual,并且在其后面加上= 0。如果试着生成一个抽象类的对象,编译器会制止他。

当继承一个抽象类时,必须实现所有的纯虚函数,否则继承出的类也将是一个抽象类。创建一个纯叙述函数允许在接口中放置成员函数,而不一定要提供一段可能对这个函数毫无意义的代码。同时纯虚函数要求继承出的类对它提供一个定义。

建立公共接口的唯一原因是它能对于每个不同的子类有不同的表示。它建立一个基本的格式,用来确定什么是对于所有派生类是公共的──除此之外,别无用途。当仅希望通过一个公共接口来操纵一组类,且这个公共接口不需要实现(或者不需要完全实现)时,可以创建一个抽象类。语法为:

virtual void f() = 0;

这样做,等于告诉编译器在VTABLE中为函数保留一个位置,但在这个特定位置中不放地址。只要有一个函数在类中被声明为纯虚函数,则VTABLE就是不完全的。

如果一个类的VTABLE是不完全的,当试图创建这个类的对象时,编译器就发出一个出错信息。这样,编译器就保证了抽象类的纯洁性,就不会被误用了。

一个类若全是纯虚函数,就称为纯抽象类(pure abstract class)。纯序函数是非常有用的,因为它们使得类有明显的抽象性,并告诉用户和编译器打算如何使用。

注意,纯虚函数禁止对抽象类的函数以传值方式调用。这也是防止对象切片(object slicing)的一种方法。通过抽象类,可以保证在向上类型转换期间总是使用指针或引用。

当实现继承和重新定义一些虚函数时,编译器对新类创建一个新的VTABLE表,并且插入新函数的地址,对于没有重新定义的虚函数使用基类函数的地址。无论如何,对于可被创建的每个对象(即它的类不含有纯虚函数),在VTABLE中纵有一个函数地址的全集,所以绝对不能对不在其中的地址进行调用(否则结果将是灾难性的)。

若在派生(derived)类中继承或增加新的虚函数,那么通过基类的指针是无法进行调用的。

如果知道保存在一般容器中的所有对象的确切类型,会使我们的设计工作在最佳状态(或者没有选择)。这就是运行时类型辨认(Run-Time Type Identification,RTTI)问题。RTTI是有关向下类型转换基类指针到派生类指针的问题。向上类型转换是自动发生的,不需强制,因为它是绝对安全的。向下类型转换是不安全的,因为这里没有关于实际类型的编译时信息,所以必须准确地知道这个类实际是什么类型。如果把它转换称错误的类型,就会出现麻烦。

当多态地处理对象时,传地址与传值有明显的不同。如果对一个对象进行向上类型转换,而不使用地址或引用,这个对象将会被“切片”,直到剩下来的是适合于目的的子对象。确切地来说,派生类对象会被切片成一个基类对象。

对象切片实际上是当它拷贝到一个新的对象时,去掉原来对象的一部分,而不是像使用指针或引用那样简单地改变地址和内容。因此,不常使用对象向上类型转换,事实上,通常要提防或防止这种操作。

重新定义一个基类中的重载函数将会隐藏所有该函数的其他基类版本。而当对虚函数进行这些操作的时候,情况会有点不同。

编译器不允许我们改变重新定义过的虚函数的返回值(如果不是虚函数,则是允许的)。这是一个非常重要的限制,因为编译器必须保证我们能够多态地通过基类调用函数,若返回值不同,那么就会产生问题。

如果重新定义了基类中的一个重载成员函数,则在派生类中其他的重载函数将会被隐藏。例如,基类中有f(int)和f(string)这样的重载函数,如果我在派生类中重新定义了f(int),那么对于这个派生类来说,f(string)就被隐藏了,不可用了。

通常,我们不能在重新定义过程中修改虚函数的返回类型,但是也有特例,如果返回一个指向基类的指针或引用,则该函数的重新定义版本可以从基类返回的内容中返回一个指向派生类的指针或引用。

当创建一个报含有虚函数的对象时,必须初始化它的VPTR以指向相应的VTABLE。这必须在对虚函数进行任何调用之前完成。编译器在构造函数开头部分秘密地插入能初始化VPTR的代码。

当寻找效率漏洞时,我们必须明白,编译器正在插入隐藏代码到我们构造函数中。这些隐藏代码不仅必须初始化VPTR,而且还必须检查this的值(以免operator new返回零)和调用基类构造函数。放在一起,这些代码可以影响我们认为是一个小内联函数的调用。特别是,构造函数的规模会抵消函数调用代价的减少。如果做大量的内联构造函数调用,代码长度就会增长,而在速度上没有任何好处。

当然,也许并不会立即把所有这些小构造函数都变成非内联,因为它们更容易写为内联构造函数。但是,当我们正在调整我们的代码时,务必去掉这些内联构造函数。

所有基类构造函数总是在继承类构造函数中被调用。派生类只访问它自己的成员,而不访问基类的成员。只有基类构造函数能正确地初始化它自己的成员。如果不在构造函数初始化表达式表中显式地调用基类构造函数,他就调用默认构造函数。如果没有默认构造函数,编译器将报告错误。

构造函数调用的顺序是重要的。当继承时,必须知道基类的全部成员并能访问基类的任何publicprotected成员。在通常的成员函数中,构造已经发生,所以这个对象的所有部分的成员都已经建立。然而,在构造函数中,必须想办法保证所有成员都已经建立。保证它的惟一方法是让基类构造函数首先被调用。

只要可能,我们应当在构造函数初始化表达式表中初始化所有的成员对象。只要遵从这个做法,我们就能保证初始化所有基类成员和当前对象的成员对象。

对于在构造函数中调用一个虚函数的情况,被调用的只是这个函数的本地版本。也就是说,虚机制在构造函数中不工作。

构造函数的工作是生成一个对象。在任何构造函数中,可能只是部分形成对象──我们只能知道基类已被初始化,但并不能知道哪个类是从这个基类继承来的。然而,虚函数在继承层次上是“向前”和“向外”进行调用。它可以调用在派生类中的函数。如果我们在构造函数中也这样做,那么我们所调用的函数可能操作还没有被初始化的成员。这将导致灾难的发生。

当一个构造函数被调用时,它做的首要事情之一就是初始化它的VPTR。然而,它只能知道它属于“当前”类──即构造函数所在类。于是它完全忽视这个对象是否是基于其他类的。当编译器为这个构造函数产生代码时,它是为这个类的构造函数产生代码──既不是为基类,也不是为它的派生类。VPTR的状态是由被最后调用的构造函数确定的。

当这一系列构造函数调用正发生时,每个构造函数都已经设置VPTR指向子集的VTABLE。如果函数调用使用虚机制,它将只产生通过它自己的VTABLE的调用,而不是最后派生的VTABLE

总之,在构造函数中调用虚函数都不能得到预期的结果。

构造函数是不能为虚函数的。但析构函数能够且常常必须是虚的。

构造函数有一项特殊工作,即一块一块地组合成一个对象。它首先调用基类构造函数,然后调用在继承顺序中的更晚派生的构造函数。类似地,析构函数也有一项特殊工作,即它必须拆卸属于某层次类的对象。析构函数自最晚派生的类开始,并向上到基类。

如果通过指向某个对象基类的指针操纵这个对象(也就是通过它的一般接口操纵这对象),当我们想在delete在栈中已经用new创建的对象的指针时,就会出现这个问题。如果这个指针是指向基类的,在delete期间,编译器只能知道调用这个析构函数的基类版本,幸运的是,析构函数可以是虚函数。

不把析构函数设为虚函数是一个隐匿的错误,因为它常常不会对程序有直接的影响,但是会不知不觉引入存储器泄露(关闭程序时内存未释放)。同样,这样的析构操作还有可能掩盖发生的问题。

尽管纯虚析构函数在标准C++中是合法的,但在使用的时候有一个额外的限制:必须为纯虚析构函数提供一个函数体。纯虚析构函数和非纯虚虚构函数之间的唯一不同之处在于纯虚析构函数使得基类是抽象类,所以不能创建一个基类的对象(如果基类的任何其他函数是纯虚函数,也是具有同样的效果)。

当从某个含有虚析构函数的类中继承出一个类,情况变得有点复杂。不像其他的纯虚函数,我们不要求在派生类中提供纯虚函数的定义。

一般来说,如果在派生类中基类的纯虚函数(和所有其他纯虚函数)没有重新定义,则派生类将会成为抽象类。但是这里编译器将会自动地为每个类生成一个析构函数定义,基类的析构函数被重写(重新定义),因此编译器会提供定义并且派生类实际上不会成为抽象类。

当我们的类仅含有一个纯虚函数时,就会发现这个唯一的差别:析构函数。这里析构函数的纯虚性的唯一效果是阻止基类的实例化。如果有其他的纯虚函数,则它们会阻止基类的实例化。

作为一个准则,任何时候我们的类中都要有一个虚函数,我们应当立即增加一个虚析构函数(即使它什么也不做)。这样,我们保证在后面不会出现问题。

在析构期间,有一些我们可能不希望马上发生的情况。如果正在一个普通的成员函数中,并且调用一个虚函数,则会使用晚捆绑机制来调用这个函数。而对于析构函数,这样不行,不论是虚的还是非虚的。在析构函数中,只有成员函数的“本地”版本被调用;虚机制被忽略。

负责动态对象创建(使用new)的对象进行delete调用的称之为“所有者”。在使用容器时的问题是,它们需要足够的灵活性用来接收不同类型的对象。为了做到这一点,容器使用void指针,因此它们并不知道所包容对象的类型。删除一个void指针并不调用析构函数,所以容器并不负责清除它的对象。

一种方法要求我们要为想在容器中容纳的每一种类型都派生出新类。

问题是我们希望容器可以容纳更多的类型,但我们不想使用void指针。另外一种解决方法是使用多态性,它通过强制容器内的所有对象从同一个基类继承而来。也就是说,容器容纳了具有同一基类的对象,并随后调用虚函数──特别地,我们可以调用虚析构函数来解决所有权问题。

这种解决方法使用单根继承(singly-rooted hierarchy)基于对象的继承(object-based hierarchy)。事实上,除了C++,每种面向对象的语言都强制使用这样的体系──当创建一个类时,都会直接或间接地从一个公共基类中继承出它,这个基类是由该语言的创建者生成的。C++中认为,强制地使用这个公共基类会引起太多的开销,所有便没有使用它。

就像对成员函数那样,我们可以使用virtual运算符。然而,因为我们可能对两个不知道类型的对象进行操作,所以实现virtual运算符通常会很复杂。这通常用于处理数学部分。

C++提供了一个特殊的称为dynamic_cast显式类型转换(explicit cast),它就是一种安全类型向下类型转换(type-safe downcast)的操作。当使用dynamic_cast来试着向下类型转换一个特定的类型,仅当类型转换是正确的并且是成功的时,返回值会是一个指向所需类型的指针,否则它将会返回0来表示这并不是正确的类型。
当使用dynamic_cast时,必须对一个真正多态的层次进行操作──它含有虚函数──这因为dynamic_cast使用了存储在VTABLE中的信息来判断实际的类型,所以运行时需要一点额外的开销。

第16章 模板介绍

继承和组合提供了重用对象代码的方法,而C++的模板特征提供了重用源代码的方法。

在一般程序设计问题中,程序员在编写程序时并不知道将来需要创建多少个对象。C++中有更好的解决方法:用new创建所需要的对象,将其指针放入容器中,待实际实用时将其取出并进行处理。用这种方法,所创建的只是确实需要的对象。通常,在启动程序时没有可用的初始化条件。new允许等待,直到在环境中相关事件发生后,再实际地创建这个对象。

有三种源代码重用的方法:

  • C方法:应该摒弃,由于它表现繁琐、易发生错误、缺乏美感,是非常低效的技术。
  • Smalltalk方法:通过继承来实现代码重用,既简单又直观。每个容器类包含通用的基类Object的项目。这是一种单纯的技巧,因为Smalltalk类层次上的任何类都源于Object的派生,任何容器可容纳任何类(包括容器本身)。这种基于通用的基类(常称为Object,在Java中也有类似情况)的单树形层次类型称为“基于对象的层次结构”。

尽管具有多重继承的基于对象的层次结构在概念上是直观的,但是在实践上较为困难。

模板对源代码进行重用,而不是通过继承和组合重用目标代码。容器不再存放称为Object的通用基类,而是存放一个未指明的参数。当用户使用模板时,参数由编译器来替换。

C++中,模板实现了参数化类型(parameterized type)的概念。模板方法的另一个优点是,使对继承不熟悉、不适应的新程序员也能正确地使用密封的容器类。

template这个关键字会告诉编译器,随后的类定义将操作一个或更多未指明的类型。当由这个模板产生实际类代码时,必须指定这些类型以使编译器能够替换它们。

template<class T>
class Array{
    enum{ size = 100 };
    T A[size];
public:
    T& operator[](int index){....}
};
int main(){
    Array<int> ia;
    Array<double> da;
    ...
}

这里T是替换参数,它代表一个类型名称。在容器类中,它将出现在那些原本由某以特定类型出现的地方。

有时我们希望有非内联成员函数的定义。这时编译器需要在成员函数定义之前看到template声明。

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) {...}

注意在引用模板的类名的地方,必须伴游该模板的参数列表。可以想象,在内部,使用模板参数列表中的参数修饰类名,以便为每一个模板实例产生唯一的类名标识符。

在template<…>之后的任何东西都意味着编译器在当时不为它分配存储空间,而是一直处于等待状态直到被一个模板示例告知。在编译器和连接器中有机制能去掉同一模板的多重定义。所以为了使用方便,几乎总是在头文件中放置全部的模板声明和定义。

可以认为模板为C++提供了一种弱类型(weak typing)机制,C++通常是强类型语言。

模板参数并不局限于类定义的类型,可以使用编译器内置类型。这些参数值在编译期间编程模板的特定示例的常量。我们甚至可以对这些参数使用默认值。

template<class T, int size = 100>
class Array{
    T array[size];
public:
    int length() const { return size; }
};

这里的size决不存放在类中,但对它的使用就如同是成员函数中的数据成员。

如果某个类有一个指向Array的指针,而不是指向类型Array的嵌入对象。该指针在构造函数中不被初始化,而是推迟到第一次访问时。这称为懒惰初始化(lazy initialization)。如果创造大量的对象,但不访问每一个对象,为了节省存储,可以使用懒惰初始化技术。

以值包含对象的容器通常无需担心所有权问题,因为它们清晰地拥有它们所包含的对象。但是,如果容器内包含指向对象的指针(这种情况在C++中相当普遍,有其在多态的情况下),而这些指针很可能用于程序的其他地方,那么删除该指针指向的对象会导致在程序的其他地方的指针对已销毁的对象进行引用。为了避免上述情况,在设计和使用容器时必须考虑所有权问题。

处理所有权问题的最好方法是由客户程序员来选择。这常常通过构造函数的一个参数来完成,它默认地指明所有权。

如果我们没有模板,那么在一个一般的容器内创建对象的一个拷贝是一个复杂的问题。使用模板,事情噢那个就相对简单了,只要说我们存放对象而不是指针就行了。

迭代器(iterator)是一个对象,它在其他对象的容器上遍历,每此选择它们中的一个,不需要提供对这个容器的实现的直接访问。迭代器提供了一种访问元素的标准方法,无论容器是否提供了直接访问元素的方法。迭代器常常与容器类联合使用,而且迭代器在标准C++容器的设计和使用中是一个基本概念。迭代器也是一种设计模式(design pattern)

迭代器通常模仿大多数指针的运算。然而,不同的是,迭代器的设计更安全,所以数组越界的可能性更小。

习惯上,用构造函数来创建迭代器,并把它与一个容器对象联系,并且在它的生命期中,不把它与不同的容器联系。

第19章 深入理解模板

C++模板应用的便利性远远超出了它只是一种T类型容器”(containers of T)的范畴。尽管其最初的设计动机是为了能产生类型安全的通用容器,但在现在C++中,模板也用来生成自定义代码,这些代码通过编译时的程序设计构造来优化程序的执行。

模板有两类:函数模板和类模板。二者都是由它们的参数来完全地描绘模板的特性。每个模板参数描述了下述内容之一:

  • 类型(或者是系统固有类型或者是用户自定义类型)。
  • 编译时常数值(例如,整数、指针和某些静态实体的引用,通常是作为无类型参数的引用)。

其他模板。

一个无类型模板参数必须是一个编译时所知的整数值。

template<class T, size_t N>

在类模板中,可以为模板参数提供默认(缺省)参数,但是在函数模板中却不行。作为默认的模板参数,它们只能被定义一次,编译器会知道第一次的模板声明或定义。一旦引入了一个默认参数,所有它之后的模板参数也必须具有默认值。

C++ 技巧

变量定义指定了变量的类型和标识符,也可以为对象提供初始值。定义时制定了初始值的对象被成为是已初始化的(initialized)。C++支持两种初始化变量的形式:复制初始化(copy-initialization)直接初始化(direct-initialization)。复制初始化语法用等号,直接初始化则是把初始化式放在括号中

int ival(1024); //direct-initialization
int ival = 1024;    //copy-initialization

C++中初始化和赋值是两种不同的操作,请注意。并且直接初始化语法更灵活而且效率更高。


定义如何进行初始化的成员函数称为构造函数(constructor)。和其他函数一样,构造函数能接受多个参数。一个类可以定义几个构造函数,每个构造函数必须接受不同数目或者不同类型的参数。


内置类型(如int)变量是否自动初始化取决于变量定义的位置。在函数体外定义的变量都初始化为0,在函数体里定义的内置类型变量不进行自动初始化。除了用作赋值操作符的左操作数,未初始化变量用作任何其他用途都是没有定义的。未初始化变量引起的错误难以发现,永远不要依赖未定义行为。


建议每个内置类型的对象都要初始化。虽然这样做并不总是必需的,但是会更加容易和安全,除非你确定忽略初始化式不会带来风险。


如果定义某个类的变量时没有提供初始化式,这个类也可以定义初始化时的操作。它是通过定义一个特殊的构造函数即默认构造函数(default constructor)来实现的。如果没有提供初始化式,那么就会使用默认构造函数。不管变量在哪里定义,默认构造函数都会被使用。


变量的定义(definition)用于为变量分配存储空间,还可以为变量指定初始值。在一个程序中,变量有且仅有一个定义。声明(declaration)用于项程序表明变量的类型和名字。定义也是声明;当定义变量时我们声明了它的类型和名字。可以通过使用extern关键字声明变量而不定义它。不定义变量的声明包括对象名。对象类型和对象类型前的关键字extern


在C++语言中,变量必须且仅能定义一次,而且在使用变量之前必须定义或者声明变量。


用来区分名字的不同意义的上下文称为作用域(scope)。作用域是程序的一段区域。一个名称可以和不同作用域中的不同实体相关联。C++语言中,大多数作用域是用花括号来界定的。一般来说,名字从其声明点开始直到其声明所在的作用域结束处都是可见的。


定义在所有函数外部的名字具有全局作用域(global scope),可以在程序中的任何地方访问。定义在main函数的作用域,则在整个main函数中可以使用,具有局部作用域(local scope)。而在某个语句中定义的(例如for语句中),则只能在语句中使用,具有语句作用域(statement scope)


C++中作用域可嵌套,若先定义了全局变量s1,而又在main中定义了局部变量s1,那么,局部变量s1就会屏蔽全局变量s1。要注意的是,像上面这样的定义方法很可能让他人大惑不解,同名总是不好的,建议局部变量最好使用不同的名字。


一般来说,变量的定义或声明可以放在程序中能摆放语句的任何位置。变量在使用前必须先声明或定义。通常把一个对象定义在它首次使用的地方是一个很好的办法。


定义一个变量代表某一常数的方法仍然有一个严重的问题。此变量是可以被修改的。const限定符提供了一个解决办法,它把一个对象转换成一个常量。如下:

const int bufsize = 512;

此时变量bufsize是不可修改的,任何修改bufsize的尝试都会导致编译错误。因为常量在定义后就不能被修改,所以定义时必须初始化。


const对象默认为文件的局部变量,此变量只存在于那个文件中,不能被其他文件访问。非const变量默认为extern。要使const变量能够在其他文件中访问,必须显式地指定它为extern


引用(reference)就是对象的另一个名字。在实际程序中,引用主要用作函数的形式参数。引用式一种复合类型(compound type),通过在变量名前添加&符号来定义。复合类型是指用其他类型定义的类型。在引用的情况下,每一种引用类型都“关联到”某一其他类型。不能定义引用类型的引用,但可以定义其他任何类型的引用。引用必须用与该引用同类型的对象初始化:

int ival = 1024;
int &refVal = ival; //ok: refVal refers to ival
int &refVal2;       //error: a reference must be initialized
int &refVal3 = 10;  //error: initializer must be an object

引用只是它绑定的对象的另一个名字,作用在引用上的所有操作事实上都是作用在该引用绑定的对象上。当引用初始化后,只要该引用存在,它就保持绑定到初始化时指向的对象。不可能将引用绑定到另一个对象。


const引用是指向const对象的引用

const int ival = 1024;
const int &refVal = ival;   //ok: both reference and object are const
int &ref2 = ival;       //error: nonconst reference of a const object

可以读取但是不能修改refVal,任何对refVal的赋值都是不合法的。同理,用ival初始化ref2也是不合法的:ref2是普通的非const引用(nonconst reference)。


const引用可以初始化为不同类型的对象或者初始化为右值,如字面值常量:

int i = 42;
// legal for const references only
const &r = 42;
const &r2 = r + i;

同样的初始化对于非const引用却是不合法的,而且会导致编译时错误。这里稍微解释一下

double dval = 3.14;
const int &ri = dval;

编译器会把这些代码转换成如以下形式的编码:

int temp = dval;     // create temporary int from the double
const int &ri = temp;   // bind ri to that temporary

如果ri不是const,那么可以给ri赋一个新值。这样做不会修改dval,而是修改了temp。期望对ri的赋值会修改dval的程序员会发现dval并没有被修改。仅允许const引用绑定到需要临时使用的值完全避免了这个问题,因为const引用是只读的。


typedef可以用来定义类型的同义词:

typedef double wages;   // wages is a synonym for double
typedef int exam_score; // exam_score is a synonym for int

typedef名字可以用作类型说明符:

wages hourly,  weekly;  // double hourly, weekly;

typedef通常被用于以下三种目的:为了隐藏特定类型的实现,强调使用类型的目的;简化复杂的类型定义,使其更易理解;允许一种类型用于多个目的,同时使得每次使用该类型的目的明确。


枚举的定义包括关键字 enum,其后是一个可选的枚举类型名,和一个用花括号括起来、用逗号分开的枚举成员(enumerator)列表。

// input is 0, output is 1, and append is 2
enum open_mode {input, output, append};

默认地,第一个枚举成员赋值为0,后面地每个枚举成员赋的值比前面的大1。

//shape is 1, sphere is 2, cylinder is 3, polygon is 4
enum Forms {shape = 1, sphere, cylinder, polygon};

枚举成员值可以是不唯一的。每个enum都定义一种唯一的类型。


每个类都定义了一个接口(interface)和一个实现(implementation)。接口由使用该类的代码需要执行的操作组成。实现一般包括该类所需要的数据。实现还包括定义该类需要的但又不供一般性使用的函数。定义类时,通常先定义该类的接口,即该类所提供的操作,可以决定该类完成其功能所需要的数据,以及是否需要定义一些函数来支持该类的实现。


类定义以关键字class开始,其后是该类的名字标识符。类体位于花括号里面。花括号后面必须要跟一个分号。类体可以为空,类体定义了组成该类型的数据和操作。这些操作和数据是类的一部分,也称为类的成员(member)。操作称为成员函数,而数据则称为数据成员(data member)。


用class和struct关键字定义类的唯一差别在于默认访问级别:默认情况下,struct的成员为public,而class的成员为private。


因为头文件包含在多个源文件中,所以不应该含有变量或函数的定义。如果const变量不是用常量表达式初始化,那么它就不应该在头文件定义。相反,和其他的变量一样,该const变量应该在一个源文件中定义并初始化。应在头文件中为它添加extern声明,以使其能被多个文件共享。


使得头文件安全的通用做法,是使用预处理器定义头文件保护符(header guard)。

#ifndef someheader.h
#define someheader.h
#endif

头文件应该含有保护符,即使这些头文件不会被其他头文件包含。编写头文件保护符并不困难,而且如果头文件被包含多次,它可以避免难以理解的编译错误。


C++提供了更简洁的方式来使用命名空间成员。这里介绍一种最安全的机制:using声明。形式为:using namespace::name;

#include <string>
#include <iostream>
using std::cin;
using std::cout;
int main()
{
.......
}

一个using声明一次只能作用于一个命名空间成员。每个名字都需要一个using声明。


标准库string类型支持长度可变的字符串。一般的声明格式如下:

#include <string>
using std::string;

string标准库支持几个构造函数,如下:

string s1;              默认构造函数,s1为空串
string s2(s1);          将s2初始化为s1的一个副本
string s3(“value”);     将s3初始化为一个字符串字面值副本
string s4(n,‘c’);       将s4初始化为字符‘c’的n个副本

因为历史原因以及为了与C语言兼容,字符串字面值与标准库string类型不是同一种类型,编程时一定要注意区别。


string类型的输入操作符:读取并忽略开头所有的空白字符(如空格,换行符,制表符);读取字符直至再次遇到空白字符,读取中止。


读入未知数目的string对象

while(cin >> word)
    cout << word << endl;

用getline读取整行文本,该函数接受两个参数:一个输入流对象和一个string对象。getline函数从输入流的下一行读取,并保存读取的内容到string中,但不包括换行符。

string line;
while (getline(cin, line))
    cout << line << endl;

因为line不含换行符,如果需要逐行输出则需要自行添加。


VC 6和Xcode中对于getline的使用都存在bug。具体的表现是VC6里要输入两次回车才能输出,而Xcode的出错则是释放了未分配的指针。具体的解决方案如下

VC6:

X:\Program Files\Microsoft Visual Studio\VC98\Include\string(注意是string文件,不是string.h)找到165行,下面的代码从163行开始

else if (_Tr::eq((_E)_C, _D)) //163行
{_Chg = true;
//  _I.rdbuf()->snextc(); // 把这一行注释掉,添加下一行.
_I.rdbuf()->sbumpc(); //添加
break;}

Xcode:有两种方案,其实差不多,第一种是在代码最前面加上

#define _GLIBCXX_FULLY_DYNAMIC_STRING 1 
#undef _GLIBCXX_DEBUG 
#undef _GLIBCXX_DEBUG_PEDANTIC

第二种如下:

The solution is to double-click on the target to open its Info window, go to the Build tab, and scroll down to the “GCC 4.2 - Preprocessing” section. In this section is a setting named “Preprocessor Macros” that by default has two entries, “_GLIBCXX_DEBUG=1” and “_GLIBCXX_DEBUG_PEDANTIC=1”. Remove these entries.


string的size和empty操作。可以通过size操作获取。

int main(){
    string st(“The expense of spirit\n”);
    cout <<  “The size of ” <<  st <<  “is ” <<  st.size() << endl;
    return 0;}

empty成员函数将返回bool值,如果string对象为空则返回ture,否则返回false。


size操作返回的是string::size_type类型的值。string类类型和许多其他库类型都定义了一些配套类型(companion type)。通过这些配套类型,库类型的使用就能与机器无关(machine-independent)。size_type就是这些配套类型的一种。定义为与unsigned型具有相同的含义,而且可以保证足够大能够存储任意string对象的长度。为了使用类型定义的size_type类型,程序员必须加上作用域操作符来说明所使用的size_type类型是由string类定义的。即std::size_type类型。不要把size的返回值赋给int变量!


对string对象来说,可以把一个string对象赋值给另一个string对象。string对象的加法被定义为(concatenation)。如下

string s1(“hello, ”);
string s2(“world\n”);
string s3 = s1 + s2;        // s3 is hello, world\n

如果要把s2直接追加到s1的末尾,就用+=

s1 += s2;

当进行string对象和字符串字面值混合连接操作时,+操作符的左右操作数必须至少有一个是string类型的:

string s4 = “hello” + “, ”;     // error: no string operand
string s5 = s1 + “, ” + “world ”;   // ok: each + has string operand
string s6 = “hello” + “,” + s2; // error: can’t add string literals

顺序是从左到右的,所以s5中的s1先和第二个加,还是string类型,然后和第二个加;而s6中的第一个和第二个相加就不满足条件了。


string类型通过下标操作符( [] )来访问string对象中的单个字符。下标操作符需要取一个size_type类型的值,来标明要访问字符的位置。着下标中的值通常被称为“下标”或“索引(index)”。string对象的下标从0开始而s[s.size()-1]就是最后一个字符。


vector是同一种类型的对象的集合,每个对象都有一个对应的整数索引值。我们把vector称为容器,是 因为它可以包含其他对象。一个容器中的所有对象都必须是同一种类型的。在使用vector之前,必须包含相应的头文件。声明如下

#include <vector>
using std::vector;

vector是一个类模板(class template)。使用模板可以编写一个类定义或函数定义,而用于多个不同的数据类型。声明从类模板生产的某种类型的对象,需要提供附加信息,信息的种类取决于模板。以vector为例,必须说明vector保存何种对象的类型,通过将类型放在类模板名称后面的尖括号中来指定类型:

vector<int> ivec;               // ivec holds objects of type int
vector<Sales_item> Sales_vec        // holds Sale_itmes

和其他变量定义一样,定义vector对象要指定类型和一个变量的列表。上面的第一个定义,类型是vector,该类型即是含有若干int类型对象的vector,变量名为ivec。


vector不是一种数据类型,而只是一个类模板,可用来定义多种数据类型。vector类型的每一种都指定了其保存元素的类型。因此,vector和vector都是数据类型。


vector对象的初始化方式

vector<T>  v1;          vector保存类型为T的对象,默认构造函数v1为空
vector<T>  v2;          v2是v1的一个副本
vector<T>  v3(n,i);     v3包含n个值为i的元素
vector<T>  v4(n);       v4含有值初始化的元素的n个副本

vector对象(以及其他标准库容器对象)的重要属性就在于可以在运行时高效地添加元素。因为vector增长地效率高,在元素值已知的情况下,最好是动态地添加元素。


vector对象的size

empty和size操作类似于string类型的相关操作。成员函数size返回相应vector类定义的size_type值。使用size_type类型时,必须指出该类型时在哪里定义的。vector类型总是包括vector的元素类型:

vector<int>::size_type      // ok
vector::size_type           // error

向vector添加元素。push_back()操作接受一个元素值,并将它作为一个新的元素添加到vector对象的后面。

// read words from the standard input and store them as elements in vector
string word;
vector<string> text;                // empty vector
while(cin >> word){
    text.push_back(word);       // append word to text
}

vector中的对象是没有命名的壳以按vector中对象的位置来访问它们。通常使用下标操作符来获取元素。vector元素的位置从0开始。


必须是已存在的元素才能用下标操作符进行索引。通过下标操作进行赋值时,不会添加任何元素。如果想要插入新元素,写法如下:

for (vector<int>::size_type ix = 0; ix != 10; ++ix)  //这样的话就保证了索引和实际一致
    ivec.push_back(ix);

除了使用下标来访问vector对象的元素外,标准库还提供了另外一种访问元素的方式:使用迭代器(iterator),迭代器时一种检查容器内元素并遍历元素的数据类型。标准库为每一种标准容器(包括vector)定义了一种迭代器类型。迭代器类型提供了比下标操作更通用化的方法:所有的标准库容器都定义了相应的迭代器类型,而只有少数的容器支持下标操作。因此,现代C++程序更倾向于使用迭代器而不是下标来访问容器元素。


容器的iterator类型,定义如下(以vector为例)

vector<int>::iterator iter;

每种容器都定义了一对名为begin和end的函数,用于返回迭代器。如果容器中有元素的话,由begin返回的迭代器指向第一个元素:

vector<int>::iterator iter = ivec.begin();

上述语句把iter初始化为ivec[0]。由end操作返回的迭代器指向vector的“末端元素的下一个”。通常称为超出末端迭代器(off-the -end iterator),只是起一个哨兵(sentinel)的作用,表示我们已经处理完了vector中的所有元素。


迭代器可以使用解引用操作符(*操作符)来访问迭代器所指向的元素

*iter = 0;  //即把iter当前指向的元素赋值为0

迭代器使用自增操作符向前移动迭代器指向容器中的下一个元素。

由于end操作返回的迭代器不指向任何元素,因此不能对它进行解引用或自增操作。


用 == 或者 != 操作符来比较两个迭代器,如果两个迭代器对象指向同一个元素,则它们相等,否则就不相等。


应用迭代器来编写的初始化为0的循环

for(vector<int>::iterator iter = ivec.begin(); iter != ivec.end(); ++iter )
    *iter = 0;

若定义为const_iterator类型,就只能用于读取容器内元素,但不能改变其值。而如果时const的iterator对象,那么这个迭代器就不能改变,这样基本就无用的。下面是一个const_iterator的例子:

for(vector<string>::const_iterator iter = text.begin(); iter != text.end(); ++iter)
    cout <<  *iter << endl;

迭代器的算术操作(iterator arithmetic):iter + n与 iter - n。iter1 - iter2 用来计算两个迭代器对象的距离,该距离时名为difference_type的signed类型的值。例如求最靠近正中的元素,可用以下代码:

vector<int>::iterator mid = vi.begin() + vi.size()/2;

任何改变vector长度的操作都会使已存在的迭代器失效。例如,在调用push_back后,就不能再信赖指向vector的迭代器的值了。


标准库bitset类型可以用来处理二进制位的有序集,可以使用bitset处理,声明如下:

#include <bitset>
using std::bitset;

类似于vector,bitset类是一种类模板;而与vector不一样的是bitset类型对象的区别仅在其长度而不在其类型。定义bitset时,要明确bitset含有多少位,要在尖括号内给出它的长度值:

bitset<32> bitvec;  // 32 bits, all zero

长度值必须定义为整型字面值常量或者是已用常量值初始化的整型的const对象。bitset中的位是没有命名的,程序员只能按位置访问。位集合的位置编号从0开始,以0位开始的位串是低阶位(low-order bit),以31位结束的位串是高阶位(high-order bit)。


用unsigned值初始化bitset对象时,该值将转化为二进制的位模式。而bitset对象中的位集作为这种位模式的副本。如果bitset类型长度大于unsigned long值的二进制位数,则其余的高阶位将置为0;如果小于,则只使用unsigned值中的低阶位,其余的被丢弃。


用string对象初始化bitset对象时,string对象直接表示为位模式。从string对象读入位集的顺序时从右向左:

string strval(“111000”);
bitset<32> bitvec4(strval);

那么这时bitvec4的表示为:0000000000···000111(共32位)

string str(“1111111000000011001101”);
bitset<32> bitvec5(str, 5, 4);      //从str[5]开始的4个位。即1100
bitset<32> bitvec6(str,str.size()-4)    //取最后的四位。即1101

现代C++程序应尽量使用vector和迭代器类型,而避免使用低级的数组和指针。设计良好的程序只有在强调速度时才在类实现的内部使用数组和指针。指针和数组容易产生不可预料的错误。其中一部分是概念上的问题:指针用于低级操作,容易产生与繁琐细节相关的(bookkeeping)错误。其他错误则源于使用指针的语法规则,特别是声明指针的语法。许多有用的程序都可不使用数组或指针实现,现代C++采用vector类型和迭代器取代一般的数组、采用string类型取代C风格字符串。


如果可能的话,除非所指向的对象已经存在,否则不要先定义指针,这样可以避免定义一个未初始化的指针。如果必须分开定义指针和其所指向的对象,则将指针初始化未0。因为编译器可检测出0值的指针,程序可判断该指针并未指向一个对象。


C++提供了一种特殊的指针类型 void*,它可以保存任何类型对象的地址。void*表明改指针与一地址值相关,但不清楚存储在此地址上的对象的类型。void*指针只支持几种有限的操作:与另一个指针进行比较;向函数传递void*指针或从函数返回void*指针;给另一个void*指针赋值。不允许使用void*指针操纵它所指向的对象。


如果对左操作数进行解引用,则修改的是指针所指对象的值;如果没有使用解引用操作,则修改的是指针本身的值。


如果指针指向const对象,则不允许用指针来改变其所指的const值。为了保证这个特性,C++语言强制要求指向const对象的指针也必须具有const特性:

const double *cptr ;        // cptr may point to a double that is const

这里cptr是一个指向double类型const对象的指针,const限定了cptr指针所指向的对象类型,而并非cptr本身。也就是说cptr本身并不是const(这里推荐从右向左读以上语句,就可以理解为cptr指向一个const的double类型)。在定义的时候不需要初始化,也可以对其重新赋值,但不能通过cptr修改其所指向对象的值;把你个const对象的地址赋给一个普通的、非const对象的指针也会导致编译时的错误;不能使用void*指针保存const对象的地址,而必须使用const void*类型的指针保存const对象的地址;允许把const对象的地址赋给指向const对象的指针。


不能使用指向const对象的指针修改基础对象,然后如果该指针指向的是一个非const对象,可用其他方法修改其所指的对象。

const double *cptr;
dval = 3.14159;     // dval is not const
*cptr = 3.14159;        // error: cptr is a pointer to const
double *ptr = &dval;        // ok: ptr points at non-const double
*ptr = 2.72;            // ok: ptr is plain pointer
cout << *cptr;          // ok: prints 2.72

从本质上说,由于没有方法分辨cptr所指的对象是否为const,系统会把它所有对象都视为const。如果指向const的指针所指的对象并非const,则可直接给该对象赋值或间接地利用普通地非const指针修改其值:毕竟这个值不是const。就是说不能保证指向const地指针所指对象的值一定不能修改。


C++还提供了const指针——本身的值不能修改。任何企图给const指针赋值的行为(即使是赋同样的值)都会导致编译时的错误。定义方式如下:

const double pi = 3.14159;
const double *const pi_ptr = & pi;
// pi_ptr is const and points to a const object

C风格字符串(C-style character string)是以空字符null结束的字符数组。尽管C++支持C风格字符串,但不应该在C++程序中使用这个类型。C风格字符串常常带来许多错误,是导致大量安全问题的根源。


可以这么样利用循环测试C风格字符串:

const char *cp = “some value”;
while (*cp){
++cp;//注意一定是C风格字符串,因为这样才能保证结尾是null,否则不能结束
}

用关系操作符(> < ==)来比较C风格字符串时,比较的时指针上存放的地址值,而不是它们所指向的字符串。


调用者必须确保目标字符串具有足够的大小,但是却有潜在的严重错误。如果必须使用C风格字符串,strncat和strncpy比strcat和strcpy函数更安全。诀窍就是可以适当地控制复制字符地个数。特别是在复制和串联字符串时,一定要时刻记住算上结束符null。所以尽可能使用标准库类型string,不但安全性增强了,效率也提高了。


数组类型的变量有三个重要的限制:数组长度固定不变,在编译时必须直到其长度,数组只有在定义它的块语句内存在。每一个程序在执行时都占用一块可用的内存空间,用于存放动态分配的对象,此内存空间称为程序的自由存储区(free store)或堆(heap)。C语言程序使用一对标准库函数malloc和free在自由存储区中分配存储空间,而C++语言则使用new和delete表达式实现相同的功能。


允许动态分配空数组(编译的时候并不知道数组的长度)。可以用以下代码实现

size_t n = get_size()   // get_size returns of elements needed
int* p = new int[n];
for(int* q = p; q !=p+n; ++q)
.........(可见ex3.17-3.21)

有趣的是,即使get_size返回的是0,代码依然可以正确执行。C++虽然不允许定义长度为0的数组变量,但明确指出,调用new动态创建长度为0的数组是合法的,返回有效的非零指针。


动态分配的内存最后必须进行释放。C++为指针提供了delete []表达式释放指针所指向的数组空间。如 delete [ ] pia;就回收了pia指向的数组。如果遗漏了空方括号对,就无法告诉编译器该指针指向的是数组,将导致程序在运行时出错。


使用数组初始化vector对象,必须指出用于初始化式的第一个元素以及数组最后一个元素的下一位置的地址:

const size_t arr_size = 6;
int int_arr[arr_size] = {0,1,2,3,4,5};
// ivec has 6 elements: each a copy of the corresponding element in int_arr
vector<int> ivec(int_arr, int_arr + arr_size);

传递给ivec的两个指针标出了vector初值的范围。第二个指针指向被复制的最后一个元素之后的地址空间。


用typedef简化指向多维数组的指针

typedef int int_array[4];
int_array *ip = ia;
for (int_array *p = ia; p != ia + 3; ++p)
    for(int *q = *p; q != *p + 4; ++q)
        cout << *q << endl;

逻辑与和逻辑或操作符总是先计算其左操作数,然后再计算其右操作数。只有仅靠左操作数的值无法确定该逻辑表达式的结果时,才会求解其右操作数。我们称这种求值策略为“短路求值(short-circuit evaluation)”。这么就引出了一个很有价值的用法:如果某边界条件使expr2的计算变得危险,那么显然expr1的计算结果为false。(expr1 &&(||)expr2)


不应该串接使用关系操作符,虽然是左结合,但是由于其返回bool类型的结果,如果多个关系操作符串接起来使用,结果往往出乎意料。


val本身是bool类型,或者val具有可转换为bool类型的数据类型。如果val是bool类型,那么if(val == true )等价于 if(val)。

若val不是bool值,val和true的比较等效于 if (val == 1)


位操作符使用整型的操作数。位操作符将其整型操作数视为二进制位的集合,为每一位提供检验和设置的功能(也可用于bitset类型)。位操作符操纵的整数的类型可以是有符号的也可以是没有符号的。如果操作数为负数,具体的处理情况就要依照机器的情况来判定,所以保险起见,用unsigned整型操作数。


一般而言,标准库提供的bitset操作更直接,更容易阅读和书写、正确使用的可能性更高。而且,bitset对象的大小不受unsigned数的位数限制。通常来说,bitset优于整形术句的低级直接位操作。


在赋值操作上加圆括号是必需的,因为赋值操作符的优先级低于不等操作符。


对于for循环来说,例如:for(语句1;条件;增殖)这样的,先执行语句1,再判断是否满足条件,满足的话执行完循环体,再进行增殖,这里使用++i与i++的效果是一样的。但是只有再必要时才使用后置操作符(i++),因为前置操作需要做的工作更少。只需加1后返回加1后的结果即可。而后置操作符则必须先保存操作数原来的值,以便返回未加1之前的值作为操作的结果。对于int型对象和指针,编译器可优化掉这项额外工作。但是对于更多的复杂迭代器类型,这种额外工作可能花费更大的代价。因此,养成使用前置操作这个好习惯,就不必担心性能差异的问题。


sizeof操作符的作用是返回一个对象或类型名的长度,返回值的类型为size_t,长度的单位是字节。sizeof表达式的结果是编译时常量。


对char类型或值为char类型的表达式做sizeof操作保证得1。

对引用类型做sizeof操作将返回存放此引用类型对象所需的内存空间大小。

对指针做sizeof操作将返回存放指针所需的内存大小;注意,如果要获取该指针所指向的对象的大小,则必须对该指针进行解引用。

对数组做sizeof操作等效于将对其元素类型做sizeof的结果乘上数组元素的个数。所以用sizeof数组的结果除以sizeof其元素类型的结果,即可求得数组元素的个数。


逗号表达式是一组由逗号分隔的表达式,这些表达式从左向右计算。逗号表达式的结果是其最右边表达式的值。


含有两个或更多操作符的表达式称为复合表达式(compound expression)。在复合表达式中,操作数和操作符的结合方式决定了整个表达式的值。表达式的结果会因为操作符和操作数的分组结合方式的不同而不同。操作数的分组结合方式决定了整个表达式的值。表达式的结果会因为操作符和操作数的分组结合方式的不同而不同。优先级规定的是操作数的结合方式,但并没有说明操作数的计算顺序。在大多数情况下,操作数一般以最方便的次序求解。


以下两个指导原则有助于处理复合表达式:

(1)如果有怀疑,则在表达式上按程序逻辑要求使用圆括号强制操作数的组合。

(2)如果要修改才做数的值,则不要在同一个语句的其他地方使用该操作数。如果必须使用改变的值,则把该表达式分割成两个独立语句:在一个语句中改变操作数的值,再在下一个语句使用它。

一个表达式里,不要在两个或更多的子表达式中对同一对象做自增或自减操作。


定义变量时,必须指定其数据类型和名字。而动态创建对象时,只需指定其数据类型,而不必为该对象命名。取而代之的是,new表达式返回指向新创建对象的指针,我们通过该指针来访问此对象:

int *pi = new int;  //pi points to dynamically allocated, unnamed, uninitialized int

这个new表达式在自由存储区中分配创建了一个整型对象,并返回此对象的地址,并用该地值初始化指针pi。


动态创建的对象可用初始化变量的方式实现初始化:

int *pi = new int(1024);        // object to which pi points is 1024
string *ps = new string(10, ‘9’);   // *ps is “9999999999”

正如我们(几乎)总是要初始化定义为变量的对象一样,在动态创建对象时,(几乎)总是对它做初始化也是一个好办法。


动态创建的对象用完后,程序员必须显式地将该对象占用地内存返回给自由存储区。可以使用delete表达式释放指针所指向地地址空间。如:

delete pi;该命令释放pi指向的int型对象占用的内存空间。

若指针指向不是用new分配的内存地址,则在该指针上使用delete是不合法的。C++没有明确定义如何释放指向不是用new分配的内存地址的指针。


执行语句 delete p;之后,p变成没有定义。在很多机器上,尽管p没有定义,但仍然存放了它之前所指向的地址,然而p所指向的内存已经被释放,因此p不再有效。删除指针后,该指针变成悬垂指针(dangling pointer)。悬垂指针指向曾经存放对象的内存,但该对象已经不再存在了。悬垂指针往往导致程序错误,而且很难检测出来。

一旦删除了指针所指向的对象,立即将指针置为0,这样就非常清楚地表明指针不再指向任何对象。


const对象地动态分配和回收

const int *pic = new const int(1024);

动态创建的const对象必须在创建时初始化,并且一经初始化,其值就不能修改。

delete pic;// ok: deletes a const object

将enum对象或枚举成员提升为什么类型由机器定义,并且依赖于枚举成员的最大值。无论其最大值时什么,enum对象或枚举成员至少提升为int型。


当使用非const对象初始化const对象的引用时,系统将非const对象转化为const对象。此外,还可以将非const对象的地址(或非const指针)转换为const类型的指针。


显式转换也称为强制类型转换(cast),包括以下名字命名的强制类型转换操作符:static_cast、dynamic_cast、const_cast和reinterpret_cast。

虽然有时候确实需要强制类型转换,但是它们本质上是非常危险的。


因为要覆盖通常的标准转换,所以需要显式使用强制类型转换

double dval;
int ival;   //这里要先将ival转换称double型,然后再把double型的结果
ival *= dval;   //截取为int型,再赋值给ival。

为了去掉这个不必要的转换,可以强制将ival转换为int型
ival *= static_cast(dval);

显式使用强制类型转换的另一个原因是可能存在多种转换,需要选择一种特定的类型转换。


const_cast,将转换掉表达式的const性质。dynamic_cast支持运行时识别指针或引用所指向的对象。reinterpret_cast通常为操作数的位模式提供较低层次的重新解释。

reinterpret_cast本质上依赖于机器。为了安全地使用reinterpret_cast,要求程序员完全理解所设计地数据类型,以及编译器实现强制类型转换的细节。


编译器隐式执行的任何类型转换都可以由static_cast显式完成

double d = 97.0;
char ch = static_cast<char>(d);

当需要将一个较大的算术类型赋值给较小的类型时,使用强制转换非常有用。此时强制类型转换告诉程序的读者和编译器:我知道并且不关心潜在的精度损失。这样警告信息就会消失。如果编译器不提供自动转换,使用static_cast来执行类型转换也是很有用的。例如下面的程序使用static_cast找回存放在void*指针中的值:

void *p = &d;
double *dp = static_cast<double*>p;

强制类型转换关闭或挂起了正常的类型检查。强烈建议程序员避免使用强制类型转换,不依赖强制类型转换也能写出很好的C++程序。这个建议再如何看待reinterpret_cast的使用时非常重要。此类强制转换总是非常危险的。相似地,使用const_cast也总是预示着设计缺陷。设计合理的系统不需要使用强制类型转换抛弃const特性。如果非强制转换不可,则应限制强制转换值的作用域,并且记录所有假定涉及的类型,这样能减少错误发生的机会。


如果在程序的某个地方,语法上需要一个语句,但逻辑上并不需要,此时应该使用空余句。这种用法常见于在循环条件判断部分就能完成全部循环工作的情况。

使用空语句时应该加上注释,以便任何读这段代码的人都知道该语句是有意义的。


在条件表达式中定义的变量必须初始化,该条件检验的就是初始化对象的值。这种变量的作用域限制在语句体内。通常,语句体本身就是一个块语句,其中也可能包含了其他的块。一个在控制结构里引入的名字是该语句的局部变量,其作用域限在语句内部。


很多编辑器和开发环境都提供工具自动根据语句结构缩排源代码。有效地利用这些工具将是一种很好的编程方法。


所有语言的if语句普遍存在着潜在的二义性。这种情况往往称为悬垂else(dangling-else)问题,C++中悬垂else问题带来的二义性,通过将else匹配给最后出现的尚未匹配的if子句来解决。


尽管没有严格要求在switch结构的最后一个标号之后指定break语句,但是,为了安全起见,最好在每个标号后面提供一个break语句,即使是最后一个标号也一样。如果以后在switch结构的末尾又需要添加一个新的case标号,则不用再前面添加break语句了。

故意省略case后面的break语句是很罕见的,因此应该提供一些注释说明其逻辑。


default标号(default label)提供了相当于else子句的功能。如果所有的case标号与switch表达式的值都不匹配,并且default标号存在,则执行default标号后面的语句。哪怕没有语句要在default标号下执行,定义default标号仍然是有用的。定义default标号是为了告诉它的读者,表明这种情况已经考虑到了,只是没有什么要执行的。


再循环条件中定义的变量再每次循环里都要经历创建和撤销的过程。


do while循环保证循环体至少执行一次,并且总是以分号结束。


goto语句提供了函数内部的无条件跳转,实现从goto语句跳转到同一函数内某个带标号的语句。语法规则位: goto label; 其中label是用于标识带标号的语句的标识符。再任何语句前提供一个标识符和冒号,即得带标号得语句(labeled statement):

end: return;    // labeled statement, may be target of a goto

goto语句不能跨越变量得定义语句向前跳转:

//  ...
goto end;
int ix = 10;        // error: goto bypasses declaration statement

end:
// error: code here could use ix but the goto bypassed its declaration
ix =  42;

如果确实需要再goto和其跳转对应得标号之间定义变量,则定义必须放在一个块语句中:

// ...
goto end;
{
    int ix = 10;
    // ...code using ix
}
end: // ix no longer visible here

向后跳过已经执行得变量定义语句是合法的。

再涉及各种软件系统的过程中,处理程序中的错误和其他反常行为是最困难的部分之一。异常就是运行时出现的不正常,例如运行时耗尽了内存或遇到意外的非法输入。异常存在于程序的正常功能之外,并要求程序立即处理。在设计良好的系统中,异常是程序错误处理的一部分。当程序代码检查到无法处理的问题时,异常处理就特别有用。在这些情况下,检测出问题的那部分程序需要一种方法把控制权转到可以处理这个问题的那部分程序。错误检测程序还必须指出具体出现了什么问题,并且可能需要提供一些附加信息。


异常机制提供程序中错误检测与错误处理部分之间的通信。C++的异常处理中包括:

throw表达式(throw expression),错误检测部分使用这种表达式来说明遇到了不可处理的错误。可以说,throw引发(raise)了异常条件。

try块(try block),错误处理部分使用它来处理异常。try语句块以try关键字开始,并以一个或多个catch子句(catch clause)结束。在try块中执行的代码所抛出(throw)的异常,通常会被其中一个catch子句处理。由于它们“处理”异常,catch子句也称为处理代码(handler)

由标准库定义的一组异常类(exception class),用来在throw和相应的catch之间传递有关的错误信息。


系统通过throw表达式抛出异常。throw表达式由关键字throw以及尾随的表达式组成,通常以分号结束,这样它就称为了表达式语句。throw表达式的类型决定了所抛出异常的类型。下面是用throw抛出异常来改写检测代码(判断是否是同一本书,如果不是就输出信息并退出):

// first check that data is for the same item
if(!item1.same_isbn(item2))
    throw runtime_error(“Data must refer to same ISBN”);
// ok, if we’re still here the ISBNs are the same
std::cout << item1 + item2 << std::endl;

throw语句使用了一个表达式。这里是用的是runtime_error类型的对象,此类型是标准库异常类中的一种,在stdexcept头文件中定义,这样就可以提供更多相关信息。


try块的通用语法形式是:

try{
    program-statements
} catch (exception-specifier){
    handler-statements
} catch (exception-specifier){
    handler-statements
}   //......

try块以关键字try开始,后面是用花括号括起来的语句序列块。try块后面是一个或多个catch子句。每个catch子句包括三部分:关键字catch,圆括号内单个类型或者单个对象的声明,称为异常说明符(exception specifier),以及通常用花括号括起来的语句块。如果选择了一个catch子句来处理异常,则执行相关的块语句。一旦catch子句执行结束,程序流程立即继续执行紧随着最后一个catch子句的语句。try语句内的program-statement形成程序的正常逻辑。这里面可以包含任意C++语句,包括变量声明。与其他语句一样,try块引入局部作用域,在try块中声明的变量,包括catch子句中声明的变量,不能在try外面引用。


对于111中抛出的错误,与用户交互的部分可能会包括以下代码:

while(cin >> item1 >> item2 ){
try{
// execute code that will add the two Sales_items
// if the addition fails, the code throws a runtime_error exception
} catch(runtime_error err){
// remind the user that ISBN must match and prompt for another pair
cout << err.what()
<< “\nTry Again? Enter y or n” << endl;
char c;
cin >> c;
if(cin && c == ‘n’)
break; //break out of the while loop
}
}

通过输出err.what()的返回值提示用户。这里what返回的C风格字符串,是用于初始化runtime_error的string对象的副本。


在复杂的系统中,程序的执行路径也许在遇到抛出异常的代码之前,就已经经过了多个try块。例如一个try块可能调用了包含另一try块的函数,它的try块又调用了含有try块的另一函数,如此类推。

寻找处理代码的过程与函数调用链刚好相反。抛出一个异常时,首先要搜索的是抛出异常的函数。如果没有找到匹配的catch,则终止这个函数的执行,并在调用这个函数的函数中寻找相配的catch。如果仍然没有找到相应的处理代码,该函数同样要终止,搜索调用它的函数。如此类推,继续按执行路径回退,直到找到适当类型的catch为止。

如果不存在处理该异常的catch子句,程序的运行就要跳转到名为terminate的标准库函数,该函数在exception头文件中定义。通常情况下,其执行将导致程序非正常退出。

抛出异常的语句要在try中···不然会挂掉的。


C++标准库定义了一组类,用于报告在标准库中函数遇到的问题。程序员可在自己编写的程序中使用这些标准异常类。exception头文件定义了最常见的异常类,类名是exception。这个类只通知异常的产生,不会提供更多的信息。stdexcept头文件定义了几种常见的异常类。new头文件定义了bad_alloc异常类型,提供因无法分配内存而由new抛出的异常。type_info头文件定义了bad_cast异常类型。

标准库异常类只提供很少的操作,包括创建、赋值异常类型对象以及异常类型对象的赋值。exception、bad_alloc以及bad_cast类型只定义了默认构造函数,无法在创建这些类型的对象时为它们提供初值。其他的异常类则只定义了一个使用string初始化式的构造函数,用于为所发生的错误提供更多的信息。

异常类型只定义了一个名为what的操作。这个函数不需要任何参数,并且返回const char*类型的值。它返回的指针指向一个C风格字符串,用来提供对异常的更详细的文字描述。


C++程序员有时候也会使用预处理技术来有条件地执行用于调试的代码。这种想法是:程序所包含的调试代码仅在开发过程中执行,当应用程序已经完成,并且准备提交时,就会将调试代码关闭。可使用NDEBUG预处理变量实现有条件的调试代码。

int main(){
#ifndef NDEBUG
cerr << “starting main” << endl;
#endif
// .......

如果NDEBUG未定义,那么程序就会将信息写到cerr中。如果NDEBUG已经定义了,那么程序执行时将会跳过#ifndef和#endif之间的代码。


预处理器还定义了其余四在调试时非常有用的常量:

_ _FILE_ _ 文件名              _ _LINE_ _ 当前行号
_ _TIME_ _ 文件被编译的时间     _ _DATE_ _ 文件被编译的日期

另一个常见的调试技术是使用NDEBUG预处理变量以及assert(断言)预处理宏(preprocessor macro)。assert宏是在cassert头文件中定义的。预处理宏有点像函数调用。assert宏需要一个表达式作为它的条件: assert(expr)
只要NDEBUG未定义,assert宏就求解表达式expr,如果结果为false,assert输出信息并且终止程序的执行。如果该表达式有一个非零,则assert不做任何操作。在成品代码中,assert语句不做任何工作,因此也没有任何运行时的代价。当然,也不会引起任何运行时的检查。assert仅用于检查确实不可能的条件,这只对程序的测试有帮助,但不能用来代替运行时的逻辑检查,也不能代替对程序可能产生的错误检测。


函数可以看作程序员定义的操作。与内置操作符相同的是,每个函数都会实现一系列的计算。但与操作符不同的是,函数是有自己的函数名,而且操作数没有数量限制。与操作符一样,函数可以重载,这意味着同样的函数名可以对应多个不同的函数。


函数不能返回另一个函数或者内置数组类型,但可以返回指向函数的指针,或者指向数组元素的指针的指针。C++是一种静态强类型语言,对于每一次的函数调用,编译时都会检查其实参。


每次调用函数时,都会重新创建该函数所有的形参,此时所传递的实参将会初始化对应的形参。型材的初始化与变量的初始化一样:如果形参具有非引用类型,则赋值实参的值,如果形参为引用类型,则它只是实参的别名。


普通的非引用类型的参数通过复制对应的实参实现初始化。当用实参副本初始化形参时,函数并没有访问调用所传递的实参本身,因此不会修改实参的值。非引用形参表示对应实参的局部副本。对这类形参的修改仅仅改变了局部副本的值。一旦函数执行结束,这些局部变量的值也就没有了。


指针形参是指向const类型还是非const类型,将影响函数调用所使用的实参。在调用函数时,如果该函数使用非引用的非const形参,则既可给该函数传递const实参,也可传递非const的实参。


复制实参并不是在所有的情况下都适合,不适合复制实参的情况包括:
当需要在函数中修改实参的值时。

当需要以大型对象作为实参传递时。对实际的应用而言,复制对象所付出的时间和存储空间代价往往过大。

当没有办法实现对象的复制时。

对于上述几种情况,有效的解决办法是将形参定义为引用或指针类型。


如果想要在函数中交换实参的值,需要将形参定义为引用类型:

void swap(int &v1,int &v2){
int temp = v2; v2 = v1; v1 =  temp;
}

与所有引用一样,引用形参直接关联到其所绑定的对象,而非这些对象的副本。定义引用时,必须用与该引用绑定的对象初始化该引用。引用形参完全以相同的方式工作。每次调用函数,引用形参被创建并与相应实参关联。


使用引用形参返回额外的信息。例如,定义一个find_val函数,在一个整型vector对象的元素中搜索某个特定值。如果找到满足要求的元素,则返回指向该元素的迭代器;否则返回一个迭代器,执行该vector对象的end操作返回的元素。此外,如果该值出现了不止一次,我们还希望函数可以返回其出现的次数。在这种情况下,返回的迭代器应该指向具有要寻找的值的第一个元素。

我们可以定义一种包含一个迭代器和一个计数器的新类型。而更简便的解决方案给find_val传递一个额外的引用实参,用于返回出现册数的统计结果。

// returns an iterator that refers to the first occurrence of value
// the reference parameter occurs contains a second return value
vector<int>::const_iterator find_val(
    vector<int>::const_iterator beg,        // first element
    vector<int>::const_iterator end,        // one past last element
    int value,                  // the value we want
    vector<int>::size_type &occurs)     // number of times it occurs
{
    // res_iter will hold first occurrence, if any
    vector<int>::const_iterator res_iter = end;
    occurs = 0;                 // set occurrence count parameter
    for( ; beg != end; ++beg)
        if(*beg == value) {
            // remember first occurrence of value
            if(res_iter == end)
            res_iter = beg;
            ++occurs;               // increment occurrence count
    }
    return res_iter;                // count returned implicitly in occurs
}

调用find_val时,需传递四个实参:一对标识vector对象中要搜索的元素范围的迭代器,所查找的值,以及用于存储出现次数的size_type类型对象。假设ivec是vecter类型的对象,it是一个适当类型的迭代器,而ctr则是size_type类型的变量,则可如此调用该函数:

it = find_val(ivec.begin(), ivec.end(), 42, ctr);

调用后,ctr的值将是42出现的次数,如果42在ivec中出现了,则it将指向其第一次出现的位置;否则it的值为ivec.end(),而ctr则为0。


在向函数传递大型对象时,需要使用引用形参,对于大部分的类类型或者大型数组,复制实参的效率就太低了,此时就可以利用const引用直接访问实参对象,无须复制。

如果使用引用形参的唯一目的是避免复制实参,则应将形参定义为const引用。


应该将不需要修改的引用形参定义为const引用。普通的非const引用形参在使用时不太灵活。这样的形参既不能用const对象初始化,也不能用字面值或产生右值的表达式实参初始化。


通常,函数不应该有vector或其他标准库容器类型的形参。。调用含有普通的非引用vector形参的函数将会复制vector的每一个元素。从避免复制vector的角度出发,应考虑将形参声明为引用类型。事实上,C++程序员倾向于通过传递指向容器中需要处理的元素的迭代器来传递容器。

// pass iterators to the first and one past the last element to print
void print(vector<int>::const_iterator beg,
    vector<int>::const_iterator end){
    while (beg != end){
        cout << *beg++;
        if (beg != end)   cout << “ ”;  // no space after last element
    }
cout << endl;
}

通常,将数组形参直接定义为指针要比使用数组语法定义更好。这样就明确地表示,函数操纵的是指向数组元素的指针,而不是数组本身。由于护绿了数组长度,形参定义中如果包含了数组长度则特别容易引起误解。当编译器检查数组形参关联的实参时,它只会检查实参是不是指针、指针的类型和数组元素的类型是否匹配,而不会检查数组的长度。


若形参是数组的引用,编译器不会将数组实参抓化为指针,而是传递数组的引用本身。在这种情况下,数组大小称为形参和实参类型的一部分。编译器检查数组实参的大小与形参大小是否相配。


和其他数组一样,多为数组以指向0号元素的指针方式传递。多维数组的元素本身就是数组。除了第一维以外的所有维德长度都是元素类型的一部分,必须明确指定:

// first parameter is an array whose elements are arrays of 10 ints
void printValues(int (matrix*)[10], int rowSize);

除了第一维以外的所有维德长度都是元素类型的一部分,必须明确指定。我们也可以用数组语法定义多维数组。与一维数组一样,编译器忽略第一维的长度,所以最好不要把它包括在形参表内。


非引用数组形参的类型检查只是确保实参是和数组元素具有同样类型的指针,而不会检查实参实际上是否指向指定大小的数组。任何处理数组的程序都要确保程序停留在数组的边界内。


有三种常见的编程技巧确保函数的操作部超出数组实参的边界。第一种方法是在数组本身放置一个标记来检测数组的结束。C风格字符串就是采用这种方法的一个例子。第二种方法是传递指向数组第一个和最后一个元素的下一个位置的指针,这样就可以确定一个元素范围,程序就会安全。点钟方法是将第二个形参定义为表示数组的大小,即显示传递表示数组大小的形参。


return语句用于结束当前正在执行的函数,并将控制权返回给调用此函数的函数。可以返回值,也可以不返回。不带返回值的return语句只能用于返回类型为void的函数。在返回类型为void的函数中,return返回语句不是必需的,隐式的return发生在函数的最后一个语句完成时。一般情况下,返回类型是void的函数使用return语句是为了引起函数的强制结束。在含有return语句的循环后没有提供return语句是很危险的,因为大部分的编译器不能检测出这个漏洞,运行时会出现什么问题是不确定的。


返回类型不是void的函数必需返回一个值,但此规则有一个例外情况:允许主函数main没有返回值就可结束。


返回非引用类型的时候,return都会在调用该函数的时候复制返回的对象。而返回引用类型的时候则是不复制的。


理解返回引用至关重要的是:千万不能返回局部变量的引用。当函数执行完毕时,将释放分配给局部对象的存储空间。此时,对局部对象的引用就会指向不确定的内存。

确保返回引用安全的一个好方法是:请自问,这个引用指向哪个在此之前存在的对象?


函数定义就是写明具体的执行过程,声明就是告诉编译器要使用这个函数。函数声明由函数返回类型、函数名和形参列表组成。形参列表必须包括形参类型,但是不必对形参命名。这三个元素被称为函数原型(function prototype),函数原型描述了函数的接口。


函数也应当在头文件中声明,并在源文件中定义。定义函数的源文件应包含声明该函数的头文件。


因为char是整形,因此把一个char值传递给int型形参是合法的,反之亦然。


在C++语言中,每个名字都有作用域,而每个对象都有生命期(lifetime)。要弄清楚函数是怎么运行的,理解这两个概念十分重要。名字的作用域指的是知道该名字的程序文本区。对象的生命期则是在程序执行过程中对象存在的时间。


默认情况下,局部变量的生命期局限于所在函数的每次执行期间。只有当定义它的函数被调用时才存在的对象称为自动对象(automatic object)。自动对象在每次调用函数时创建和撤销。在函数结束后,自动对象和形参的值都不能再访问了。


一个变量如果位于函数的作用域内,但是生命期却跨域了这个函数的多次调用,这种变量往往很有用。则应该将这样的对象定义为static(静态的)。static局部对象(static local object)确保不迟于在程序执行流程第一经过该对象的定义语句时进行初始化。这种对象一旦被创建,在程序结束前都不会被撤销。当定义静态局部对象的函数结束时,静态对象不会被撤销。

size_t count_calls(){
    static size_t ctr = 0;  // value will persist across calls
    return ++ctr;
}
int main(){
    for(size_t i = 0; i != 10; ++i)
        cout << cout_calls() << endl;
    return 0;
}

依次输出1到10(包含10)的整数。


内联函数避免函数调用的开销,相当于在调用函数的时候用函数体替换,就可以加快速度。在函数返回类型前加上关键字inline就可以指定为内联函数。内联说明(inline specification)对于编译器来说只是一个建议,编译器可以选择忽略这个建议。一般来说,内联机制适用于优化小的、只有几行而且经常被调用的函数。大多数编译器都不支持递归函数的内联。


内联函数应该在头文件中定义,这一点不同于其他函数。在头文件中加入或修改内联函数时,使用了该头文件的所有泊文件都必须得重新编译。


成员函数的定义与普通函数的定义类似。和任何函数一样,成员函数也包含下面四个部分:函数返回类型、函数名、用逗号隔开的形参表(也可能是空的)、包含在一对花括号里面的函数体。函数原型必须在类中定义。但是,函数体则既可以在类中也可以在类外定义。类的所有成员都必须在类定义的花括号里声明,此后,就不能再为类增加任何成员。类的成员函数必须如声明的一般定义。类的成员函数既可以在类的定义内也可以在类的定义外定义。编译器隐式地将在类内定义的成员函数当作内联函数。类的成员函数可以访问该类的private成员。


每个成员函数(除static成员函数外)都有一个额外的、隐含的形参this。在调用成员函数时,形参this初始化为调用函数的对象的地址。


const对象、指向const对象的指针或引用只能调用其const成员函数,如果尝试用它们来调用非const成员函数,则是错误的。


在成员函数中,不必显式地使用this指针来访问被调用函数所属对象的成员。对这个类的成员的任何没有前缀的引用,都被假定为通过指针this实现的引用。


在类外定义成员函数就必须指明它们是类的成员

double Sales_item::avg_price() const{
if (units_sold)
    return revenue/units_sold;
else
    return 0;
}

使用作用域操作符指明函数avg_price是在类Sales_item的作用域范围内定义的。


构造函数(constructor)是特殊的成员函数,与其他成员函数不同,构造函数和类同名,而且没有返回类型。而与其他成员函数相同的是,构造函数也有形参表(可能为空)和函数体。一个类可以有多个构造函数,每个构造函数必须有与其他构造函数不同数目或类型的形参。构造函数也必须在类中声明,但是可以在类中或类外定义。构造函数放在类的public部分。


如果没有为一个类显式定义任何构造函数,编译器将自动为这个类生成默认构造函数。合成的默认构造函数一般适用于仅包含类类型成员的类。而对于含有内置类型或复合类型成员的类。则通常应该定义他们自己的默认构造函数初始化这些成员。


出现在相同作用域中的两个函数,如果具有相同的名字而形参表不同,则成为重载函数(overloaded function)。如果两个函数声明的返回类型和形参表完全匹配,则将第二个函数声明视为第一个的重复声明。如果两个函数的形参表完全相同,但返回类型不同,则第二个声明是错误的。


一般作用域规则同样适用于重载函数名。如果局部地声明一个函数,则该函数将屏蔽而不是重载在外层作用域中声明的同名函数。所以,每一个版本的重载函数都应在同一个作用域中声明。一般来说,局部地声明函数时一种不明智的选择。函数的声明应放在头文件中。在C++中,名字查找发生在类型检查之前。


函数重载确定(overload resolution,即函数匹配function matching)是将函数调用与重载函数集合中的一个函数相关联的过程。通过自动提取函数调用中实际使用的实参与重载集合中各个函数提供的形参做比较,编译器实现该调用与函数的匹配。


为了确定最佳匹配,编译器将实参类型到相应形参类型的转换划分等级。转换等级以降序排列如下:精确匹配(exact match),实参与形参类型相同。通过类型提升(promotion)实现的匹配。通过标准转换(standard conversion)实现的匹配。通过类类型转换(class-type conversion)实现的匹配。

内置类型的提升和转换可能会使函数匹配产生意想不到的结果。但幸运的是,设计良好的系统很少会包含形参类型相当接近的函数。


类型提升或转换适用于实参类型可通过某种标准转换提升或转换为适当的形参类型的情况。通过类型提升实现的转换优于其他标准的转换。

void ff(int);
void ff(short);
ff(‘a’);            // char promotes to int, so matches ff(int)

枚举类型enum的对象只能用同一枚举类型的另一个对象或一个枚举成员(enumerator)进行初始化。整数对象即使具有与枚举元素相同的值也不能用于调用期望获得枚举类型实参的函数。虽然无法将整型值传递给枚举类型的形参,但可以将枚举值传递给整数形参。此时,枚举值被提升为int型或更大的整形。具体的提升类型取决于枚举成员的值。


仅当形参是引用或指针时,形参是否为const才有影响。


函数指针是指指向函数而非指向对象的指针。像其他指针一样,函数指针也指向某个特定的类型。函数类型由其返回类型以及形参表确定,而与函数名无关。

// pf points to function returning bool that takes two const string references
bool (*pf)(const string &, const string &);

其中*pf两侧的括号是必需的。或者是使用typedef为指针类型定义同义词,可将函数指针的使用大大简化。

typedef bool (*cmpFcn) (const string &, const string &);

以后使用则直接用cmpFcn即可。


在引用函数名但又没有调用该函数时,函数名将被自动解释为指向函数的指针。函数指针只能通过同类型的函数或函数指针或0值常量表达式进行初始化或赋值。


指向函数的指针可用于调用它所指向的函数。可以不需要使用解引用操作符,直接通过指针调用函数。函数的形参可以是指向函数的指针。


endl操纵符用于输出一个换行符并刷新缓冲区。而flush,用于刷新流,但不在输出中添加任何字符。还有一个比较少用的ends,这个操作符在缓冲区插入空字符null,然后刷新它。如果需要刷新所有输出,最好使用unitbuf操作符。这个操作符在每次执行完写操作后都刷新流:

cout << unitbuf << “first” << “ second” << nounitbuf;   

等价于:

cout << “first” << flush << “second” << flush;

nounitbuf操纵符将流恢复为使用正常的、由系统管理的缓冲区刷新方式。


如果程序不正常结束,输出缓冲区将不会刷新。在尝试调试已崩溃的程序时,通常会根据最后的输出找出程序发生错误的区域。如果崩溃出现在某个特定的输出语句后面,则可知是在程序的这个位置之后出错。

调试程序时,必须保证期待写入的每个输出都确实被刷新了。如果需要使用最后的输出给程序错误定位,则必须确定所有要输出地都已经输出。为了确保用户看到程序实际上处理的所有输出,最好的方法是保证所有的输出操作都显式地调用了flush或endl。如果仅因为缓冲区没有刷新,程序员将浪费大量的时间跟踪调试并没有执行的代码。基于这个原因,输出时应多使用endl而非‘\n’。


当输入流与输出流绑在一起时,任何读输入流的尝试都将首先刷新其输出流关联的缓冲区。交互式系统通常应确保它们的输入和输出流失绑在一起的。这样做意味着可以保证任何输出,包括给用户的提示,都在试图读之前输出。


打开文件后,通常要检验打开是否成功,这是一个好习惯。

// check that the open succeeded
if (!infile){
    cerr << “error: unable to open input file : ”
        << ifile << endl;
    return -1;
}

如果程序员需要重用文件流读写多个文件,必须在读另一个文件之前调用clear清楚该流的状态。


C++提供了使用抽象进行高效率编程的方式。标准库就是一个很好的例子:标准库定义了许多容器类以及一系列泛型算法,使程序员可以更简洁、抽象和有效地编写程序。这样可以让标准库操心那些繁琐的细节,特别是内存管理,我们的程序只需关注要解决的实际问题就行了。泛型算法中,所谓“泛型(generic)”指的是两个方面:这些算法可作用于各种不同的容器类型,而这些容器又可以容纳多种不同类型的元素。

为容器类型提供通用接口是设计库的目的。容器提供的操作和算法是一致定义的,这使得学习标准库更容易:只需理解一个操作如何工作,就能将该操作应用于其他的容器。更重要的是,接口的一致性使程序变得更灵活。


标准库定义了三种顺序容器类型:vector(支持快速随机访问)、list(支持快速插入、删除)和deque(是双端队列“double-ended queue”的简写,发音为 “deck”)。它们的差别在于访问元素的方式,以及添加或删除元素相关操作的运行代价。标准库还提供了三种容器适配器(adaptor)。实际上,适配器是根据原始的容器类型所提供的操作,通过定义新的操作接口,来适应基础的容器类型。顺序容器适配器包括stack(后进先出LIFO栈)、queue(先进先出FIFO)和priority_queue类型(有优先级管理的队列)。容器只定义了少量操作。大多数额外操作则由算法库提供。标准库为由容器类型定义的操作加强了公共的接口。


为了定义一个容器类型的对象,必须先包含相关的头文件所有的容器都是类模板。要定义某种特殊的容器,必须在容器名后加一对尖括号,尖括号里面提供容器中存放的元素的类型。所有的容器类型都定义了默认构造函数,用于创建指定类型的空容器对象。为了使程序更清晰、简短,容器类型最常用的构造函数是默认构造函数。在大多数的程序中,使用默认构造函数能达到最佳运行时性能,并且使容器更容易使用。


将一个容器复制给另一个容器时,类型必须匹配:容器类型和元素类型都必须相同。尽管不能将一种容器内的元素复制给另一种容器,但系统允许通过传递一对迭代器间接实现该功能。使用迭代器时,不要求容器类型相同。容器内的元素类型也可以不相同,只要他们相互兼容,能够将要复制的元素转换为所构建的新容器的元素类型,即可实现复制。


创建顺序容器时,可显式指定容器大小和一个(可选的)元素初始化式。容器大小可以使常量或非常量表达式,元素初始化式则必须是可用于初始化其元素类型的对象的值。接受容器大小做形参的构造函数只适用于顺序容器,而关联容器不支持这种初始化。


C++语言中,大多数类型都可用作容器的元素类型。容器元素类型必须满足以下两个约束:元素类型必须支持赋值运算;元素类型的对象必须可以复制。


因为容器受容器元素类型的约束,所以可定义元素是容器类型的容器。例如

vector< vector<string> > lines;     // vector of vectors

必须用空格隔开两个相邻的 > 符号,以示这是两个分开的符号,否则,系统会认为>>是单个符号,为右移操作符,并结果导致编译时错误。


在整个标准库中,经常使用形参为一对迭代器的构造函数。关系操作符只适用于vector和deque容器,这是因为只有这两种容器为其元素提供快速、随机的访问。它们确保可根据元素位置直接有效地访问指定的容器元素。


迭代器范围这个概念是标准库的基础。C++语言使用一对迭代器标记迭代器范围(iterator range),这两个迭代器分别指向同一个容器中的两个元素或超出末端的下一位置,通常将它们命名为first和last,或beg和end,用于标记容器中的一段元素范围。称为左闭合区间(left-inclusive interval),其标准方式为:

// to be read as: includes first and each element up to but not including last
[ first, last )

当first与last相等时,迭代器范围为空;

当first与last不相等时,迭代器范围内至少有一个元素,而且first指向该区间中的第一个元素。


修改容器的内在状态或移动容器内的元素等操作使所有指向呗移动的元素的迭代器时效,也可能同时使其他迭代器失效。使用无效迭代器时没有定义的,可能会导致与悬垂指针相同的问题。使用迭代器编写程序时,必须留意那些操作会使迭代器失效。使用无效迭代器将会导致严重的运行时错误。


使用迭代器时,通常可以编写程序使得要求迭代器有效地代码发内相对较短。然后,在该范围内,严格检查每一条语句,判断是否有元素添加或删除,从而相应地调整迭代器的值。


除了push_back运算,list和deque容器类型还提供了类似的操作:push_front。这个操作实现再容器首部插入新元素的功能。


再容器中添加元素时,系统是将元素值复制到容器里。类似地,使用一段元素初始化新容器时,新荣期存放的是原始元素的副本。被复制的原始值与新荣期中的元素各不相关,此后,容器内元素值发生变化时,被复制的原值不会收到影响,反之亦然。


insert曹走提供了一组更通用的插入方法,实现在容器的任意指定位置插入新元素。

c.insert(p,t) 在迭代器p所指向的元素前面插入值为t的新元素。返回指向新添加元素的迭代器

c.insert(p,n,t) 在迭代器p所指向的元素前面插入n个值为t的新元素。返回void类型

c.insert(p,b,e) 在迭代器p所指向的元素前面插入由迭代器b和e标记的范围内的元素。返回void类型


任何insert或push操作都可能导致迭代器失效。当编写循环将元素插入到vector或deque容器中时,程序必须确保迭代器在每次循环后都得到更新。


在vector或deque容器中添加元素时,可能会导致某些或全部迭代器失效。假设所有迭代器失效是最安全的做法。这个建议特别适用于由end操作返回的迭代器。在容器的任何位置插入任何元素都会使该迭代器失效。为了避免存储end迭代器,可以在每次做完插入运算后重新计算。

// safer:recalculate end on each trip whenever the loop adds/erases elements
while (first != v.end()){
      // do some processing
      first = v.insert(first, 42); // insert new value
      ++first; // advance first just past the element we added
}

所有的容器类型都支持用关系操作符来实现两个容器的比较。比较的容器必须具有相同的容器类型,而且其元素类型也必须相同。

如果两个容器具有相同的长度而且所有元素都相等,那么这两个容器就相等;否则,它们就不相等。

如果两个容器的长度不相同,但较短的容器中所有元素都等于较长容器中对应的元素,则称较短的容器小于另一个容器。

如果两个容器都不是对方的初始子序列,则它们的比较结果取决于所比较的第一个不相等的元素。


顺序容器大小的操作

c.max_size()      返回容器c可容纳的最多元素个数,返回类型为c::size_type
c.resize(n)          调整容器c的长度大小,使其能容纳n个元素,如果n<c.size(),则删除多出来的元素;否则,添加采用值初始化的新元素
c.resize(n,t)        调整容器c的大小,使其能容纳n个元素。所有新添加的元素值都为t

resize操作可能会使迭代器失效。在vector或deque容器上做resize操作有可能会使所有的迭代器都失效。对于所有的容器类型,如果resize操作压缩了容器,则指向已删除的元素的迭代器失效。


如果容器非空,那么容器类型的front和back成员将返回容器内第一个或最后一个元素的引用。使用越界的下标,或调用空容器的front或back函数,都会导致程序出现严重的错误。使用下标运算的另一个可选方案是at成员函数。这个函数的行为和下标运算相似,但是如果给出的下标无效,at函数将会抛出out_of_range异常。


容器类型提供了通用的insert操作在容器的任何位置插入元素,并支持特定的push_front和push_back操作在容器首部或尾部插入新元素。类似地,容器类型提供了通用的erase操作和特定的pop_front和pop_back操作来删除容器内的元素。

c.erase( k )            

删除迭代器 k 所指向的元素。返回一个迭代器,它指向被删除元素后面的元素,若 k 指向容器容器内的最后一个元素,则返回的迭代器指向容器的超出末端的下一位置。如果 k 本身就是指向超出末端的下一位置的迭代器,则该函数未定义。

c.erase( b,e )         

删除迭代器 b 和 e 所标记的范围内所有的元素。返回一个迭代器,它指向被删除元素段后面的元素。如果 e 本身就是指向超出末端的下一位置的迭代器,那么返回的迭代器也指向容器超出末端的下一位置。

c.clear()                 

删除容器 c 内的所有元素。返回void

c.pop_back()         

删除容器 c 的最后一个元素。返回void。如果 c 为空容器,则该函数未定义。

c.pop_front()          

删除容器 c 的第一个元素。返回void。如果 c 为空容器,则该函数未定义。

以上两个只能用于list或deque容器


pop_front操作通常与front操作配套使用,实现以桟的方式处理容器:

while (!ilist.empty()) {
    process(ilist.front());      // do something with the current top of ilist
    ilist.pop_front();       // done;remove first element
}

这个循环非常简单:使用front操作获取要处理的元素,然后调用pop_front函数从容器list中删除该元素。pop_front和pop_back函数的返回值并不是删除的元素的值,而是void。要获取删除的元素值,则必须在删除元素之前调用front或back函数。


删除一个或一段元素更通用的方法是erase操作。如同其他操作一样,erase操作也不会检查它的参数。必须确保迭代器是有效的。


赋值和 assign 操作使得作操作数容器的所有迭代器失效。swap 操作则不会使迭代器失效。完成 swap 后,尽管被交换的元素已经存放在另一容器中,但迭代器仍然指向相同的元素。


顺序容器的赋值操作

c1 = c2            删除容器 c1 的所有元素,然后将 c2 的元素复制给 c1。c1 和 c2 的类型(包括容器类型和元素类型)必须相同。
c1.swap(c2)      交换内容:调用完该函数后,c1 中存放的是 c2 原来的元素,c2中存放的则是 c1原来的元素。c1 和 c2 的类型必须相同。该函数的执行速度通常要比将 c2 的元素复制到 c1 的操作快。
c.assign(b,e)     重新设置 c 中的元素:将迭代器 b 和 e 标记的范围内所有的元素复制到 c 中。b 和 e 必须不是指向 c 中元素的迭代器。
c.assign(n,t)      将容器 c 重新设置为存储 n 各值为 t 的元素

是用 swap 操作以节省删除元素的成本,并且迭代器不会失效,原来指向哪里,现在还是指向哪里。


在容器对象中 insert 或压入一个元素时,该对象的大小增加 1。类似地,如果 resize 容器以扩充其容量,则必须在容器中添加额外的元素。比起 list 和 deque 容器,vector 的增长效率通常会更高。会为 vector 预留额外的存储区。


capacity 操作获取在容器需要分配更多的存储空间之前能够存储的元素总数,而 reserve 操作则告诉 vector 容器应该预留多少个元素的存储空间。size 指容器当前拥有的元素个数。每当 vector 容器不得不分配新的存储空间时,以加倍当前容量分配策略实现重新分配。vector 的每种实现都可自由地选择自己的内存分配策略。然后,它们都必须提供 reserve 和 capacity 函数,而且必须时到必要时才分配新的内存空间。分配多少内存取决于其实现方式。不同的库采用不同的策略实现。此外,每种实现都要求遵循以下原则:确保 push_back 操作高效地在 vector 中添加元素。从技术上说,在原来为空的 vector 容器上 n 次调用 push_back 函数,从而创建拥有 n 个元素的 vector 容器,其执行时间永远不能超过 n 的常量倍。

捧个钱场?