简介
不能只把C++看作是语言要素的一个集合,因为有些要素单独使用是没有意义的。如果我们不只是用C++语言编写代码,而是用它思考“设计”问题,那么必须综合使用这些要素。而且,为了以这种方法理解C++,我们必须了解使用C的问题和一般的编程问题。
我将始终坚持一种观点:读者应当在头脑中建立一个模型,以便逐步理解这种语言,直到炉火纯青的程度。如果读者遇到难题,可以将问题纳入这个模型,推导出答案。
第一章 对象导言
所有的程序设计语言都提供抽象。可以说,人们能解决的问题的复杂性直接与抽象的类型和质量有关。这里的类型指的是:要抽象的东西
程序员必须在机器模型(在”解空间“,即建模该问题的空间中,例如在计算机上)和实际上要解决的问题的模型(在“问题空间”,即问题存在的空间中)之间建立联系。
由于类描述了一组有相同特性(数据元素)和相同行为(功能)的对象,因此类实际上就是数据类型,例如浮点数也有一组特性和行为。
区别在于:程序员定义类是为了与具体问题相适应,而不是被迫使用已存在的数据类型,而设计这些已经存在的数据类型的动机是为了标识机器中的存储单元。程序员可以通过增添专门针对自己需要的新数据类型来扩展程序设计语言。
面向对象程序设计的难题之一,是在问题空间中的元素和解空间的对象之间建立一对一的映射。
把程序员化为为类创建者(创建新数据类型的人)和客户程序员(在应用程序中使用数据类型的类的用户)
- 客户程序员的目标是去收集一个装满类的工具箱,用于快速应用开发
- 类创建者的目标是去建造类,这个类只暴露对于客户程序员是必需的东西,其他的都隐藏起来。
为什么呢?因为如果是隐藏的东西,客户程序员就不能使用它,这意味着这个类的创建者可以改变隐藏的部分,而不用担心会影响到其他人
访问控制的第一个理由是为了防止客户程序员插手不应当杰出的部分;第二个理由是允许库设计者去改变这个类的内部工作方式,而不必担心这样做会影响到客户程序员
代码重用是面向对象程序设计语言的最大优点之一
重用一个类最简单的方法就是:直接使用这个类的对象,并且还可以将这个类的对象放到一个新类的里面,称之为“创建一个成员对象”。
可以用任何数量和类型的其他对象组成新类,通过组合得到新类所希望的功能。因为这是由已经存在的类组成新类,所以称为组合(composition),或者更通常地称为聚合(aggregation)
当创建新类时,程序员应当首先考虑组合,因为它更简单和更灵活。
对象的思想本身是一种很方便的工具。我们可以将数据和功能通过概念封装在一起,使得我们能描述合适的问题空间思想,而不是被强制使用底层机器的用语。通过使用class关键字,这些概念被表示为程序设计语言中的基本单元。
继承表示了在基类型和派生类型之间的这种相似性。
一个基类型具有所有由它派生出来的类型所共有的特性和行为。程序员创建一个基类型以描述关于系统中的一些对象的思想核心。由这个基类型,我们可以派生出其他类型来表述实现该核心的不同途径。
**早捆绑(early binding)**:编译器会对特定的函数名产生调用,而连接器将这个调用解析为要执行代码的绝对地址。在OOP中,直到程序运行时,编译器才能确定执行代码的地址,所以,当消息被发送给一般对象时,需要采用其他的方案
为了解决这个问题,面向对象语言采用**晚捆绑(late binding)**的思想:当给对象发送消息时,在程序运行时才去确定被调用的代码。编译器保证这个被调用的函数存在,并执行参数和返回值的类型检查(其中不采用这中处理方式的语言称为弱类型(weakly typed)语言),但是它并不知道将执行的确切代码。
我们可以用关键字
virtual
声明它希望某个函数有晚捆绑的灵活性在C++中,必须记住添加
virtual
关键字,因为根据规定,默认情况下成员函数不能动态绑定。virtual
函数(虚函数)可用来表示出在相同家族中的类具有不同的行为。这些不同是产生多态行为的原因。我们把处理派生类型就如同处理其基类型的过程称为向上类型转换(upcasting)
cast
一次来自铸造领域,up
一词来自于继承图的典型排列方式,基类型置于顶层,派生类向下层展开。这样,类型向基类型的转换是沿继承图向上移动,即“向上类型转换”。
从技术角度,OOP的论域就是抽象数据类型,继承和多态性。但是,其他一些问题也是重要的
- 特别重要的是对象创建和销毁的方法。对象的数据存放在何处?如何控制对象的生命期?不同的程序设计语言有不同的行事之道。C++采取的方法是把效率控制作为最重要的问题,所以它为程序员提供了一个选择。
- 为了最大化运行速度,通常将对象存放在栈中或静态存储区域中,存储和生命期可以在编写程序时确定。
- 栈是内存中的一个区域,可以直接由微处理器在程序执行期间存放数据。在栈中的变量有时称为自动变量(automatic variable)或局部变量(scoped variable)
- 静态存储区域简单说是内存的一个固定块,在程序开始执行以前分配。
- 使用栈或静态存储区,可以快速分配和释放,有时这是有价值的,然而,这也导致牺牲了灵活性。因为程序员必须在写程序时知道对象的准确数量,生命期和类型。
- 第二种方法是在称为堆(heap)的区域动态创建对象。这些决定是在程序运行之中作出的。如果需要新的对象,直接使用
new
关键字让它在堆上生成,当使用结束时,用关键字delete
释放。 - 因为这种存储是在运行时动态管理的,所以在堆上分配存储所需要的时间比在栈上创建存储的时间长的多(在栈上创建存储常常只是一条向下移动栈指针的微处理器指令,另外一条是移回指令)
- 另一个问题是对象的生命期
- 如果在栈上或在静态存储上创建一个对象,编译器决定这个对象持续多长时间并能自动销毁它。然而,如果在堆上创建它,编译器则不知道它的生命期。在C++中,程序员必须编程决定何时销毁此对象。然后使用
delete
关键字执行这个销毁任务。 - 作为一个替换,运行环境可以提供一个称为**垃圾收集器(garbage collector)**的功能,当一个对象不再被使用时此功能可以自动发现并销毁这个对象。当然,使用垃圾收集器编写程序是非常方便的,但是它需要所有应用软件能忍受垃圾收集器的存在及垃圾收集的系统开销。这并不符合C++语言的设计需要,因此C++没有包括它,尽管存在用于C++的第三方垃圾收集器。
- 如果在栈上或在静态存储上创建一个对象,编译器决定这个对象持续多长时间并能自动销毁它。然而,如果在堆上创建它,编译器则不知道它的生命期。在C++中,程序员必须编程决定何时销毁此对象。然后使用
- 特别重要的是对象创建和销毁的方法。对象的数据存放在何处?如何控制对象的生命期?不同的程序设计语言有不同的行事之道。C++采取的方法是把效率控制作为最重要的问题,所以它为程序员提供了一个选择。
异常处理(exception handling)将错误处理直接与程序设计语言甚至有时是操作系统联系起来。
异常是一个对象,它在出错的地方被抛出,并且被一段用以处理特定类型错误的异常处理代码(exception handler)所接收
方法(method),通常称为方法论(methodology),是一系列的过程和探索,用以降低程序设计问题的复杂性。
考虑采用一个方法之前,理解它试图要解决什么问题是重要的。
如果我们正在考虑的是一个包含丰富细节而且需要许多步骤和文档的方法学,将很难判断什么时候停止,应当牢记我们正在努力寻找的是什么:
- 什么是对象?(如何将项目分成多个组成部分?)
- 它们的接口是什么?(需要向每个对象发送什么信息?)
C++中引入了异常处理机制来支持复杂的出错处理,从而避免大量的出错处理逻辑干扰程序的代码。
**使用断言(assertion)来表示和强化程序中的不变量(invariant)**,是经验丰富的软件工程师的确切标志
第二章 对象的创建与使用
任何一种计算机语言都要从某种人们容易理解的形式(源代码)转化成计算机能执行的形式(机器指令)。通常,翻译其分为两类:解释器(interpreter)和编译器(compiler)
- 解释器将源代码转换成一些动作(它可由多组机器指令组成)并立即执行这些动作。
- 编译器直接把源代码转换成汇编语言或机器指令。最终的结果是一个或多个机器代码的文件。
程序可由多个文件构成,一个文件中的函数很可能要访问另一些文件中的函数和数据。编译一个文件时,C或C++编译器必须知道在另一些文件中的函数和数据,特别是它的名字和基本用法。
编译器就是要确保函数和数据被正确地使用。“告知编译器”外部函数和数据的名称及它们的模样,这一过程就是声明(declaration)。一旦声明了一个函数或变量,编译器知道怎么样检查对它们的引用,以确保引用正确。
声明(declaration)和定义(definition)
- 声明,是向编译器介绍名字–标识符。它告诉编译器:这个函数或这个变量在某处可以找到,它的模样像什么?
- 定义,为名字分配存储空间。它说:在这里建立变量或者在这里建立函数。无论定义的是函数还是变量,编译器都要为它们在定义点分配存储空间。
在C和C++中,可以在不同的地方声明相同的变量和函数,但是只能有一个定义(有时这称为ODR(one-definition rule, 单一定义规则))
函数声明的语法
- C/C++的函数声明就是给函数取名,指定函数的参数类型和返回值。
- 说明:对于带空参数表的函数,C和C++有很大的不同
- 在C语言中,声明
int func();
表示:一个可带任意参数(任意数目,任意类型)的函数。这就妨碍了类型检查 - 在C++语言中它就意味着:不带参数的函数
- 在C语言中,声明
变量声明的语法
- 变量声明告知编译器变量的外表特征
- 函数声明包括函数类型(即返回值类型),函数名,参数列表和一个分号。这些信息使的编译器足以认出它是一个函数声明并可识别出这个函数的外部特征。
- 由此推断,变量声明应该是类型标识后面跟一个标识符,例如:
int a;
- 可以声明变量a是一个整数,这符合上面的逻辑。但是这就产生了一个矛盾:这段代码有足够的信息让编译器为整数a分配空间,而且编译器也确实给整数a分配了空间。要解决这个矛盾,对于C/C++需要一个关键字来说明:这只是一个声明,它的定义在别的地方。
- 这个关键字就是
extern
,它标识变量是在文件以外定义的,或者在文件后面部分才定义的。 extern
也可用于函数声明。这种声明方式和普通的声明方式一样。因为没有函数体,编译器必定把它作为声明而不是函数定义。extern
关键字对函数来说是多余的,可选的。
标准
C++ include
语句格式- 从C继承下来的带有传统
.h
扩展名的库仍然可用,也可以使用更现代的C++风格使用它们,即**在文件名前面加一个字母c
,例如:#include <stdio.h>
变为#include <cstdio>
- 对所有的标准C头文件都一样。这就提供了一个区分标志,说明所使用的是C还是C++库
- 新的包含格式和老的效果是不一样的:
- 使用
.h
的文件是老的,非模板化的版本 - 而没有
.h
的文件是新的模板化版本。 - 如果在同一个程序中混用这两种形式,会遇到某些问题
- 使用
- 从C继承下来的带有传统
名字空间
- 标准的C++有预防名字冲突的机制:
namespace
关键字。库或程序中的每一个C++定义集被封装在一个名字空间中,如果其他的定义中有相同的名字,但它们在不同的名字空间,就不会产生冲突。 - 名字空间是十分方便和有用的工具,但是名字空间的出现意味着在写程序之前,必须知道它们。确切的说,如果仅仅只包含头文件,编译器无法找到任何有关函数和对象的声明。其所代表的含义即:虽然包含了头文件,但是所有的声明都在一个名字空间中,而没有告诉编译其我们想要用这个名字空间中的声明
- 可以使用一个关键来声明:我要使用这个名字空间中的声明和(或)定义–
using
- 标准的C++有预防名字冲突的机制:
Vector
- 人们经常会把标准C++库的“容器”与“算法”和被称为STL的东西混淆
- STL(标准模板类库,Standard Template Library)是1994年春天Alex Stepanov在加州San Diego的会议上把他的C++库提交给C++标准委员会时使用的名称。这个名称一直沿用下来
- 同时,C++标准委员会对STL作了大量的修改,将它整合进标准C++类库。SGI公司不断对STL进行改进。SGI的STL与标准的C++库在许多细节上是不同的。
- 虽然人们经常产生误解,但实际上C++标准是不“包括”STL的。
- 人们经常会把标准C++库的“容器”与“算法”和被称为STL的东西混淆
第三章C++中的C
标准C和C++有一个特征叫做函数原型(function prototyping)。
- 用函数原型,在声明和定义一个函数时,必须使用参数类型描述。这种描述就是“原型”
- 调用函数时,编译器使用原型确保正确传递参数并且正确地处理返回值。如果调用函数时程序员出错了,编译器就会捕获这个错误
用C++编程时,当前C函数库中的所有函数都可以使用。在定义自己的函数之前,应该仔细地看一下函数库,可能有人已经解决了我们的问题,而且进行了更多的思考和调试。
do-while
语句与while
语句的区别在于:即时表达式第一次计值就等于假,前面的语句也会至少执行一次。在一般的while
语句中,如果条件第一次为假,语句一次也不会执行。递归
- 递归是十分有趣的,有时也是非常有用的编程技巧,凭借递归可以调用我们所在的函数。
- 当然,如果这是所做的全部,那么会一直调用下去,直到内存用完,所以一定要有一种确定“到达底点”递归调用的方法。
运算符
- 我们可以把运算符看作是一种特殊的函数(C++的运算符重载正是以这种方式对待运算符)。
- 一个运算符带一个或更多的参数并产生一个新值。运算符参数和普通的函数调用参数相比在形式上不同,但是作用是一样的。
数据类型
- 在编写程序中,数据类型(data type)定义使用存储空间(内存)的方式。
- 通过定义数据类型,告诉编译器怎样创建一片特定的存储空间,以及怎样操纵这片存储空间。
- 数据类型可以是内部的或者是抽象的。
- 内部数据类型是编译器本来能理解的数据类型,直接与编译器关联。C和C++中的内部数据类型几乎是一样的。
- 用户定义的数据类型是我们和别的程序员创建的类型,作为一个类。它们一般被称为抽象数据类型。
- 编译器启动时,知道怎样处理内部数据类型;编译器再通过读包含类声明的头文件认识怎么处理抽象数据类型。
基本内部类型
- 标准C的内部类型(由C++继承)规范不说明每一个内部类型必须有多少位。规范只规定内部类型必须能存储的最大值和最小值
- C和C++中有4个基本的内部数据类型
char
是用于存储字符的,使用最小的8位(一个字节)的存储,尽管它可能占用更大的空间int
存储整数值,使用最小两个字节的存储空间float
和double
类型存储浮点数,一般使用IEEE的浮点格式。
说明符(specifier)
- 说明符用于改变基本内部类型的含义并把它们扩展成一个更大的集合。有四个说明符:
long, short, signed, unsigned
long
和short
修改数据类型具有的最大值和最小值signed
和unsigned
修饰符告诉编译器怎样使用整数类型和字符的符号位(浮点数总含有一个符号)unsigned
数不保存符号,因此有一个多余的位可用,所以它能存储比signed
数大两倍的正数。signed
是默认的,只有char
才一定要使用signed
。char
可以默认为signed
,也可以不默认为signed
。通过规定signed char
,可以强制使用符号位。
- 说明符用于改变基本内部类型的含义并把它们扩展成一个更大的集合。有四个说明符:
指针简介
- 不管什么时候运行一个程序,都是首先把它装入(一般从磁盘装入)计算机内存。因此,程序中所有元素都驻留在内存的某处。
- 内存一般被布置成一系列连续的内存位置;我们通常把这些位置看作是8位字节,但实际上每一个空间的大小取决于具体机器的结构,一般那称为机器的字长(word size)。每一个空间可按它的地址与其他空间区分。
连接
- 在一个执行程序中,标识符代表存放变量或被编译过的函数体的存储空间。
- 连接用连接器所见的方式描述存储空间。连接的方式有两种:内部连接(internal linkage)和外部连接(external linkage)
- 内部连接意味着只对正被编译的文件创建存储空间。用内部连接,别的文件可以使用相同的标识符或全局变量,连接器不会发现冲突–也就是为每一个标识符创建单独的存储空间。在C和C++中,内部连接是由关键字
static
指定的。 - 外部连接意味着为所有被编译过的文件创建一片单独的存储空间。一旦创建存储空间,连接器必须解决所有对这片存储空间的引用。全局变量和函数名有外部连接。通过用关键字
extern
声明,可以从其他文件访问这些变量和函数。函数之外定义的所有变量(在C++中除了const
)和函数定义默认为外部连接。可以使用关键字static
特地强制它们具有内部连接,也可以在定义时使用关键字extern
显式指定标识符具有外部连接。在C中,不必使用extern
定义变量或函数,但是在C++中对于const
有时必须使用 - 调用函数时,自动(局部)变量只是临时存储在堆栈中。连接器不知道自动变量,所以这些变量没有连接。
- 内部连接意味着只对正被编译的文件创建存储空间。用内部连接,别的文件可以使用相同的标识符或全局变量,连接器不会发现冲突–也就是为每一个标识符创建单独的存储空间。在C和C++中,内部连接是由关键字
第四章 数据抽象
什么是对象?
- 把函数放进结构中是从C到C++中的根本改变,这引起我们将结构作为新概念去思考。
- 在C中,struct是数据的凝聚,它将数据捆绑在一起,使的我们可以将它们看作一个包,但这除了能使编程方便之外,别无其他。对这些结构进行操作的函数可以在别处。
- 然而将函数也放进这个包内,结构就变成了新的创造物了,它即能描写属性(就像C struct能做的一样),又能描述行为,这就形成了对象的概念。对象是一个独立的捆绑的实体,有自己的记忆和活动。
- 在C++中,对象就是变量,它的最纯正的定义是“一块存储区”(更明确的说法是:对象必须有唯一的标识,在C++中是一个唯一的地址)。它是一块空间,在这里能够存放数据,而且还隐含着对这些数据进行处理的操作。
抽象数据类型
- 将数据连同函数捆绑在一起的能力可以用于创建新的数据类型。这常常被称为封装(encapsulation)。
预处理指示
#define #ifdef #endif
- 预处理指示
#define
可以用来创建编译时标记。 - 一般有两种选择:可以简单地告诉预处理器这个标记被定义,但不指定特定的值;或者给它一个值(这是典型的定义常数的C方法)
- 无论哪种情况,预处理器都能测试该标记,检查它是否已经被定义,例如
#ifdef FLAG
,这将得到一个真值,#ifdef
后面的代码将包含在发送给编译器的包中。当预处理器遇到语句#endif
或者#endif // FLAG
时,包含终止 #define
的反意是#undef
(un-define
的简写),它将使得使用相同变量的#ifdef
语句得到假值。#undef
还引起预处理器停止使用宏。#ifdef
的反意是ifndef
,如果标记还没有定义,它得到真值
- 预处理指示
第四章小结
- C++的基本方法,在结构的内部放入函数。结构的这种新类型称为抽象数据类型(abstract data type),用这种结构创建的变量称为这个类型的对象(object)或实例(instance)。调用对象的成员函数称为向这个对象发消息(sending a message)。在面向对象的程序设计中的主要动作就是向对象发消息。
第五章 隐藏实现
- 访问控制通常是指实现细节的隐藏(
implementation hiding
)。将函数包含到一个结构内(常称为封装)来产生一种带数据和操作的数据类型,由访问控制在该数据类型之内确定边界,这样做的原因有两个- 首先是决定那些客户程序员可以用,那些客户程序员不能用。我们可以建立结构内部的机制,而不必担心客户程序员会把内部的数据机制当作它们可使用的接口的一部分来访问
- 将具体实现与接口分离开来。如果该结构被用在一系列程序中,而客户程序员只能对
public
的接口发送消息,这样就可以改变所有声明为private
的成员而不必去修改客户程序员的代码。