第一章 对象的简介
汇编语言对底层机器进行了很小程度的抽象描述。在它之后出现的许多“指令式"(imperative)编程语言(例如Fortran,BASIC和C)则 是对汇编语言的抽象描述。与汇编语言相比,这些语言有巨大的进步;然而它们所提供的抽象依然要求程序员从计算机,而不是要解决的问题的角度来进行思考。
面向对象语言(OOP)允许程序员从问题而不是计算机的角度来思考。
访问控制(access control)
实施访问控制的第一个理由:确保客户程序员无法看到它们不应该看到的东西。
实施访问控制的第二个理由:允许库的设计者可以放心修改类的内部工作机制,而不用担心会影响客户程序员。
代码重用是OOP所提供的巨大优势之一。
在组合(composition)与继承(inheritance)之间,应该首先考虑组合,因为它相比起来更简单,也更灵活。
pure substitution & substitution principle
即派生类不在基类的基础上增加任何新的接口,两者的接口保持完全一致。
后期绑定(late binding)
编译器负责确认被调用的函数确实存在,而且完成必要的参数和返回值类型检查,然而编译器并不在意要执行的确切代码。
upcasting:将派生类对象视为基类对象
对于在堆上创建的实体,编译器并不了解其生存期;而在栈上创建的实体,编译器很清楚它将在很是被创建和销毁。
对于大多数错误处理机制,存在的一个主要问题是,整个机制是基于程序员的严谨而不是由语言强制要求的。
需要注意的是,异常处理机制并不是OOP特性。在OOP出现之前,异常处理就已经存在了。
极限编程(Extreme Programming)
极限编程中最重要和最突出的两个贡献,分别是“write test first"和"pari programming“。
XP彻底的改变了测试的概念,它把测试视为与代码同等甚至更为重要的部分。
第二章 创建和使用对象
用户定义类型,或者说类,是C++与传统的面向过程语言的区别所在。
静态类型检查(static type checking)
在编译期间执行的类型检查被称为静态类型检查。
某些动态语言执行某些运行时类型检查(动态类型检查)。动态类型检查与静态类型检查相结合,比单独使用静态类型检查具备更强大的功能,然而也增加了程序执行时的开销。
在构造大型项目时,separate compilation具有特别重要的意义。
第三章 C++中的C语言元素
C的函数定义中要求每个参数都有名字,而在C++中函数定义的参数列表中可以存在未命名的参数。
C和C++中关于函数参数的另一个区别在于参数列表为空时的意义,在C++中,这和显式使用void具有相同的意思即该函数是无参函数;而在C中,其表示的意思是:该函数的参数未确定。
C++中必须明确指定函数的返回类型,而在C中,若省略了返回类型,编译器会默认它的返回类型为int。
范围(scope)被定义为最近的两个尖括号之间的部分。
局部变量只存在于范围之内,位于某个函数的局部。局部变量经常又被称为自动变量,因为它们在程序进入某个范围时自动被创建,而在离开某个范围时自动消失。
reintepret_cast操作暗含着如下意味:使用它所得到的结果是如此的与初值不相符,以至于决不能再按最初类型来使用,除非再显式的转换回去。
利用宏的调试技巧
#define PR(x) cout << #x " = " << x << "/n";
table-driven-code:函数指针数组的一种应用
第四章 数据抽象
The only way to get massive increases in productivity is to leverage off other people’s code. That is, to use libraries
每个独立的C语言实现文件(后缀名.c)是一个转换单元。也就是说,编译器分别处理每个转换单元,在处理时对其它单元是无任何了解的。
使用C语言的函数库时的最大障碍之一,就是名字冲突的问题。
尽止步于将函数和数据打包这种程度的程序语言,被称为基于对象(object-based),而不是面向对象(object-oriented)。
C++中允许创建不含任何数据成员的结构,而这在C语言中是非法的(注:在C99中是合法的)。
对象的基本特性之一,就是不同对象具有不同的地址,因此不含数据成员的结构的大小总是非零的。
与头文件相关的第一个问题是应该在头文件中放置什么内容。基本原则是只包含声明,也就是说,只向编译器提供信息而不包含任何会导致空间分配的语句。
C++编译器将结构和类的重复定义视为错误。
实际上,你在头文件中应该永远不会看到using语句。一句话,绝不要在头文件中使用using语句。
第五章 隐藏实现
友元
可以将一个全局函数定义为类的友元,也可以将另一个类的成员函数定义为该类的友元,甚至可以将另一个类定义为该类的友元。
对于友元函数,在声明其与当前类的友元关系的同时,也完成了对该函数原型 的声明。
Nested friends
Making a structure nested doesn’t automatically give it access to private members. To accomplish this, you must follow a particular form: first, declare (without defining) the nested structure, then declare it as a friend, and finally define the structure. The structure definition must be separate from the friend declaration, otherwise it would be seen by the compiler as a non-member.
第六章 初始化与清除
编译器在对象创建的时候自动调用构造函数,以保证客户在能够操作对象之前对象已经完成了初始化。
C++中,定义和初始化是一个统一的概念。
空间分配
尽管编译器可能会选择在进入一个范围(scope)时,为这个范围内的所有对象分配空间,但是对象的构造函数只有在程序执行到这个对象被定义的那个序列点时才会被自动调用。
Aggregate Initialization
when there are fewer initializers than array size: atomically initialized to 0
第七章 函数重载 &默认参数
默认参数只在函数声明中出现(通常包括在头文件中)。编译器必须在调用这样的函数前已获取默认参数的值。
当未命名参数和默认参数这两者结合起来时,就出现了一个有意思的技术:占位符参数。通常库程序员会使用这种技巧,以便于在不改变外部接口的情况下修改函数的功能。
第八章 const
C++中引入const的最初动机是要消除使用预处理指令#define以进行常值替换的必要性。
C++中的const默认具有内部链接属性,也就是说,用const定义的对象只在定义该对象的文件内可见,在链接时对于其它转换单元 (translation unit)是不可见的。对于使用const的定义,必须总是同时为其初始化,除非显式的用extern关键字进行声明。
通常,C++编译器不会为const对象分配存储空间,而是在符号表中记录下其定义,以用于值替换。
当extern与const一同使用时,程序员强迫编译器为其分配空间(在其它情况下也会发生,例如取const对象的地址)。
对于复杂结构,编译器试图避免为const对象分配空间的目的同样会无法实现。
对于内置类型的const,编译器总是执行常量替换(前提是该对象为compiler-time const)。
compiler-time const & run-time const
对于run-time const,编译器会为其分配空间,并不在符号表中记录相关信息(这里变得和C一样),因此这种情况下编译器不会进行值替换。
const与聚合类型
const可以用来修饰聚合类型(如数组)。然而,你应该理所当然的认为,没有那个编译器会复杂到为聚合类型在符号表中保存相关信息,因此对于const 聚合类型,编译器和为其分配空间;在这种情况下,const应该被理解为readonly:"一块无法被改写的内存区域“。
然而,聚合类型的值不像之前所提到的内置类型const变量一样具有compile-time const属性,因为编译器不知道也未被要求知道存储区域在编译时的值。
C++与C中const的区别
C99中的const是从C++中引入的,然而在C中const意味着“一个不可改写的普通变量”。
在C中,编译器总是为const分配存储,而且变量的名字是全局的(外部链接)。C编译器无法将const变量视为compile-time常量。
在C++中,在所有函数外部定义的const具备文件范围(即在文件外是不可见的),也就是说,默认具备internal linkage属性。这和C++中所有其它默认为external linkage的标识符都大大不同。
为了给一个const对象加上external linkage,以便可以在其它文件中引用它,需要在对象定义时显式的加上extern关键字
例如 extern const int x=1;
注意,上述定义中,由于extern和初始化的存在,一方面是x具备了external linkage属性,另一方面,也强迫编译器为变量分配了存储空间;当然,编译器依然为变量x保留着值替换的特性。
函数参数与返回值
传递const value:C++提供给函数创建者而不是调用者的工具
返回const value:
对于内置类型,函数返回值是否用const修饰是无所谓的。
而对于用户定义类型(class),函数返回值是否用const修饰则变得很重要,即在有const的情况下,函数返回的对象不能调用non-const的成员函数
const返回值对于内置类型无影响的原因在于编译器总是禁止将返回值作为左值(因为它总是个值,而不是变量)。
临时对象:
临时对象的一个特性是它们自动被编译器赋予了const属性
传递和返回地址
事实上,在编写函数时,若某个参数为对象地址,应该尽可能的将参数类型加上const属性。
一个接受const指针的函数,较之不接受const指针的函数,更具有通用性。
C++中参数传递的首选方式是引用,确切的说的带有const的引用。
类中的const
在类中,const的语义蜕化为C中const的语义。编译器会为class中的const成员分配空间,并在其初始化后保证值不被修改。换言之,类中的const意味着:“这个成员的值在对象的声明期保持不变”;然而,不同对象中对应的成员可能有不同的值。
因此,在类中定义一个普通的、非static的const成员时,不允许在定义时完成初始化;初始化工作必须由构造函数来完成,然而是在一个特殊的位置进行,即
initializer list。
类中的compile-time const
类中的static const的内置类型成员变量可以被视为compile-time const。
static const必须在定义时初始化,其它所有成员变量则必须在构造函数中完成初始化。
const object & const member function
要理解const成员函数的概念,必须首先理解const对象的概念。
当在类中定义了一个const成员函数时,实际上是告知编译器,该函数可以被const对象调用。默认情况下(未显式声明const)的成员函数是不允许被const对象调用的。
注意,const关键字在成员函数的声明和定义中都要求出现。
另外,构造和析构函数不允许声明为const,因为从它们所完成的功能来看通常总是对对象执行修改操作的。
mutable关键字
bitwise const VS logical const
bitwise:意味着整个对象的每个bit都是不允许进行修改的。
logical wise:意味着对象在概念上是不允许修改的,但是可以存在以成员为单位的可写性。
volatile关键字
volatile关键字用于告知编译器不要对数据的持久性作任何假设,尤其是在代码优化时。
可以创建const volatile对象,意思是这个对象不会被客户程序修改,然而可能会被某些外部agency修改。
和const一样,volatile可用于成员变量、成员函数和对象;volatile对象只能调用volatile函数。
第九章 内联函数
C++中宏的两个大问题
1.宏看起来像是函数,然而并不具备函数的特性。
2.预处理器无权访问类成员变量,没有办法在宏定义中表示出类范围的概念。
普通函数具备的任何特性,都可以从内联函数中得到,只有一点不同即内联函数在被调用处,如同宏一般,函数体被展开并替换函数调用。
通常将内联函数的定义放置在头文件中。
内联函数需要在函数定义处显式使用inline关键字,仅在函数声明处使用inline是不够的。
在类中定义的成员函数自动具备了内联函数的特性。
以下划线开头的标识符属于被保留的,程序员最好不要定义这种形式的标识符。
内联函数与编译器
当编译器发现一个内联函数时,会在符号表中添加这个函数的类型信息(即函数原型,包括函数明、参数类型和个数、以及函数返回类型)以及函数体代码。之后当 编译器发现对这个函数的调用时,会和对待普通函数调用一般的执行类型检验,在函数调用合法的情况下用函数体代码替换函数调用。
内联函数的限制
1.编译器无法将复杂函数作为内联函数处理。通常来说,任何类型的循环都被认为过于复杂而不能内联展开。
2.当代码中有显式的取函数地址的操作时,编译器无法执行内联展开,而是为函数体代码分配存储空间。
重要的是要理解inline关键字只是对编译器的建议和请求,而不是强制性的命令。
第十章 名字控制
C中的static关键字在人们发明"重载"这个概念之前实际上已经被重载了,而C++则为static增添了新的含义。
在C和C++中,static都具备两个基本语意,不幸的是,两者经常混杂在一起。
1.在固定地址空间分配内存,而不是每次函数调用时在栈上分配空间。这里static的意味是静态存储(static storage)。
2.只对一个特定的转换单元(translation unit)可见。这里static的意味是内部链接(internal linkage)。
函数中的static变量
若程序员不为static局部变量提供初始化值,编译器会保证该对象被初始化为0(对于内置类型)。
函数中的static对象
默认初始化为0只对内置类型有意义,对于static类对象,必须由构造函数完成初始化。
静态对象的析构
静态对象(不仅限于局部静态变量)在程序从main()函数推出时被自动调用析构函数。
如果调用标准库函数abort()结束程序的话,静态对象的析构函数是不会被自动调用的。
需要理解的是,只有已经被创建的对象才被析构。具体的说,一个包含局部静态对象的函数若从未被调用过的话,该类对象的析构函数自然不会在推出main()时被调用,因为该对象根本还不曾存在过!
链接控制
使用内部链接(internal linkage)的优点之一在于可以在将标识符的定义放置在头文件中,而不用担心链接时会出现名字冲突的问题。
C++中通常放置于头文件中的名字定义,如const,默认具有internal linkage属性。
当用于局部变量时,static不再具有可见性的含义,而只是改变变量的存储方式。
名字空间
namespace关键字的唯一目的是创建名字空间
创建名字空间时需要注意的:
1。名字空间的定义只能出现在全局范围,或者是在另一个名字空间的内部
2。名字空间定义的结尾处不需要加分号。
3。一个名字空间的定义可在多个文件中完成。
4。一个名字空间可以定义为另一个名字空间的alias。
匿名名字空间
匿名名字空间中的名字在其所在的转换单元中自动可见,不用加限定符。
编译器保证每个转换单元(translation unit)中最多只有一个匿名名字空间。
匿名名字空间中的名字虽然对于外部TU是不可见的,但其链接属性仍是external linkage。
C++废弃了使用static来设置internal linkage的方法,而推荐使用匿名名字空间。
友元与名字空间
若在类定义中声明友元的话,该友元自动被插入类所属的名字空间。
例如:
namespace Me {
class Us {
//...
friend void you();
};
}
现在函数you()是名字空间Me的一员了。
使用名字空间
可以通过三种方式来使用某个名字空间内的名字
1。显式使用范围限定符::。
2。使用using directive,将某个名字空间内的名字全部引入当前名字空间。
3。使用using declaration,一次引入一个名字。
using和namespace关键字一起使用时被称为using directive。
using declaration被视为在当前范围内的声明,而using directive被视为对当前范围全局性的声明;因此,using declaration可能会覆盖using directive引入的名字。
using declaration只是引入了名字,并没有包含类型信息;
using directive的有效性只到那个文件的编译器为止。
实际上,using directive 通常出现在.cpp文件而不是在头文件中,因为这样做会污染全局名字空间。
C++中的静态成员
类中的静态成员必须在类定义之外被定义(但是在类定义中同样要进行声明),并且只允许被定义一次,因此,这样的静态成员的定义一般放在类的实现部分(.cpp),而不是定义部分(.h)。
初始化:对于static const的整型变量可以在类中进行定义,对所有其它情况,必须在类外完成定义。
局部类不允许拥有静态成员变量。
静态成员函数:静态成员函数只能访问静态成员变量,无法访问非静态成员变量,也不发调用非静态成员函数。这是因为静态成员函数在调用时没有this指针被传递。
第十一章 引用&拷贝构造函数
C++和C中的指针基本保持一致,同时增强了类型检查。在C中可以在void *指针与其它类型指针之间任意赋值,而在C++中只允许将其它类型指针赋值给void *,反过来的赋值需要显式的类型转换。
C++中的引用是从Algol中借鉴而来的。
引用
引用在概念上可以视为由编译器自动解析的常量指针。
使用引用时需要遵循的规则:
1.引用在创建时必须初始化。
2.引用一旦初始化完毕,就不能更改指向的目标。
3.不允许空引用(类似于NULL 指针),必须保证某个引用指向一处合法的存储区。
函数中的引用
如果确信函数会维持传入参数的const特性,那么将参数类型设为const引用,则会令函数具有最广泛的可用性。
将参数类型设为const引用在函数有可能被传入临时对象时具有特殊的重要的作用,因为临时对象总是具有const性,因此若函数参数不是const引用的话,是无法通过编译的。
参数传递原则:尽可能使用const引用的参数传递方式,这不仅是考虑到效率问题,更重要的是与将对象按值传递时会导致拷贝构造函数的调用有关。
拷贝构造函数是C++语言中最令人迷惑的特性之一。
函数返回值
对于无法通过寄存器返回的复杂结构和对象,试图通过将要返回的对象放置在栈上的策略是不可取的,因为C/C++是支持中断的概念的,若函数将代返回对象放 置在栈上(只能位于返回地址之下)后返回,然后被其它函数中断,则ISR在执行过程中可能会覆盖原本的返回内容。
要解决该问题,则需要函数的调用者在调用函数前在栈上额外分配空间以存放函数的返回值。然而,C/C++采取了一种更有效率的解决方案。
具体的说,就是在调用函数前,将最终要容纳函数返回值的对象地址作为隐藏参数传递给函数,函数在执行过程中直接将返回值写入目标地址。
临时对象
考虑这样的情形:函数要返回某个对象,而调用者在调用时选择了忽略返回值,这时前面所提到的指针指向何处?
事实上编译器在这种情况下会在栈上创建一个临时对象来容纳返回值。临时对象的生存期总是尽可能短,以避免过多的临时对象存在而占用宝贵的资源。
一个简单的技巧
可以在类定义中使用一种简单的技术,来保证类对象无法以值传递的方式用于参数传递或函数返回。
方法如下:在类定义中声明(甚至不用定义)一个private的拷贝构造函数。
第十二章 运算符重载
运算符重载的唯一理由是可以令代码的书写和阅读更容易。
前缀和后缀增量运算符的重载
当编译器发现前缀操作如++a时,会调用operator ++(a)
而编译器发现后缀操作如a++时,会调用operator(a,int)
这样就可以在运算符重载时将前缀和后缀操作区分开来。
与赋值相关的运算符的重载
所有被重载的运算符函数,在其内都应该检查是否要执行的操作属于"自我赋值",这是基本原则;如果忽略这一检查的话,代码中很可能会引入难以察觉的bug。
重载时的参数和返回值
1。参数默认情况下以const引用的方式进行传递;当操作符是类成员时,函数需声明为const成员函数。
2。返回值类型取决于运算符所表示的意义。
3。所有赋值运算符都修改左值,因此通常情况下返回类型应为非const引用。
4。对于前缀运算符,可以以引用方式简单的返回*this,而对于后缀操作,应该以值传递的方式返回。
返回值优化(return value optimization)
假设Interger是用户定义的类,考虑在运算符重载中以下两种返回结果的方式会有何差别?
方式一: return Integer(left.i+right.i);
方式二: Integer temp(left.i+right.i);
return temp;
在方式二中,三个操作陆续发生:
首先,temp对象被创建,并自动调用构造函数
其次,拷贝构造函数被调用,将temp对象的内容复制到外部容纳返回值的地址。
最后,析构函数被调用,以销毁temp对象。
而在方式一中,编译器在看到这样的代码时会明白,程序员的要求只是返回一个对象;因此编译器会利用这一点,直接在外部容纳返回值的存储区域出构建这个对象——而这仅需要调用构造函数而已,既不需要调用拷贝构造函数,也不需要调用析构函数。
两种方式相比,第二种方式更富有效率。以上的特性常被称为返回值优化(ROV)。
非常见运算符的重载
运算符[]:
必须是成员函数
运算符->
如果你希望一个对象看起来像是一个指针,那么一般会需要重载运算符->。这样的对象比起典型的指针具有更多智能特性,因此经常被称为智能指针。
该操作符必须是成员函数。
不允许重载的运算符
某些运算符禁止重载的主要原因是考虑到安全。
成员选择运算符.和.*不允许重载。
另外,重载不会改变运算符的优先级和结合性,也不能改变运算符的参数个数。
赋值运算符的重载
一定要明白,编译器什么时候调用赋值运算符,什么时候调用拷贝构造函数。
例如 MType a;
MType b=a;
b=a;
代码的第二行中,从一个已有的对象a中创建一个新的对象b,编译器此时的唯一选择就是调用拷贝构造函数。尽管从表达式上看来,这个操作涉及到了运算符=,但是编译器是不会调用其对应函数的。
代码的第三行,运算符=的两侧都是之前被创建的对象,显然,此处编译器会调用=运算符函数。
简而言之,对象尚未创建时,需要初始化;否则则执行=操作。
从可读性的角度来看,应该尽量避免使用=的形式进行初始化,而最好显式使用构造函数的形式。
运算符=必须是成员函数。
构造转换(constructor conversion)
如果为类定义了一个单参数(其它类型)的构造的函数的话,则允许编译器完成从参数类型到该类类型的自动类型转换。
由构造函数引起的自动类型转换有时可能会引起问题,可以使用explicit关键字显式的禁止自动转换。
运算符转换(operatro conversion)
可以为类创建一个成员函数,完成从当前类型到指定目标类型的转换。
函数的一般形式: operater ret-type () { return ret-value;}
对比:对于构造转换,是由目标类型完成的;而对于运算符转换,是由源类型完成的。
实际上,创建一个单参数构造函数总是定义了一种类型自动转换。
第十三章 动态创建对象
C++针对
动态创建对象
提供的
的
解决方案是将其纳入语言的核心;而在C中,malloc()和free()是以库函数的形式提供,不在编译器的控制之中。
在栈上的空间分配有内置的处理器指令进行支持,因此非常高效。
new操作府
默认的new操作符在将内存地址返回前检查并确认内存分配操作是成功的,因此C++程序员不必显式的判断new操作是否成功。
delete操作符
如果传递给delete操作符的指针为0的话,不会执行任何操作。
值得注意的是,如果程序员的代码中出现 delete pv(其中pv的类型为void *)这样的代码,几乎可以肯定这将成为程序的一个bug,除非pv指向的对象非常简单——不具备析构函数。
内存不足
当new操作符分配内存失败时,会导致一个称为new-hander的特殊函数被调用;或者说,(编译器)会检查某个函数指针,如果该函数指针非0的话,则调用对应的函数。
new操作符在失败时的默认行为是抛出一个bad_alloc异常。
程序员可以通过包含头文件<new>并调用set_new_handler()来设定new-handler,参数是用户定义的函数地址。
注意,当new被重载时,原有的new-handler不再会被默认调用。
重载new&delete
当程序员使用new语句时,首先是编译器调用operator new 分配空间,然后编译器调用构造函数完成对象的初始化。
当程序员使用delete语句时,首先编译器调用对象的析构函数,然后调用operator delete释放内存空间。
以上操作中对构造函数和析构函数的调用是程序员永远无法进行控制的(由编译器负责),然而程序员可以定制operator new和operator delete。
一定要清楚,new和delete的重载,所改变的仅是空间分配的方式。
全局性的重载new和delete
重载的new需要一个类型为size_t的参数;该参数是由编译器产生并传递给new的,其值为要创建的对象的大小。
再次重申,new所完成的工作只是空间分配,而不是对象创建;对象在构造函数被调用前是不存在的——这是由编译器控制和保证的,你,程序员,是无法插手其中的。
重载的delete需要一个类型为void *的指针;类型是void *是因为delete操作符在析构函数被调用后才得到该参数,而析构函数已经消除了存储区域的对象特性。
还需要注意的是,在重载new时,不能在函数体中使用iostream,因为iostream对象(若全局性的cin,cout,cerr)在创建时,会调用new来分配空间。
在类中重载new和delete
当为类重载new和delete操作符时,无须显式的使用static,实际上是为类创建了static成员函数。
重载面向数组的new 和delete
其形式与普通的new和delete类似,只是语法形式改为 new [] 和delete[]
记住,从接口的角度看,重载new所要求完成的唯一任务就是返回一个指向一块足够大存储空间的指针。
构造函数的调用
考虑下面的代码: MyType * p=new MyType;
在一切正常的情况下,编译器会调用new来分配空间,然后调用类MyType的构造函数来对存储空间完成初始化,从而创建对象。
那么,在new操作失败的情况下,会发生什么?事实是在这种情况下,构造函数不会被调用。
C++标准要求new的实现在失败的情况下抛出bad_alloc异常。
placement new &delete
存在两种不常使用的重载new的用途:
1.某些情况下需要将某个对象放在确定的地址。
2.在调用new时希望能有多种内存分配方式进行选择。
这两种需要都可以通过在重载new时传入不止一个参数来实现。
如前所述,new的第一个参数总是对象的大小,并且是由编译器隐式计算和传递的;然而new还可以被传入用户希望的任何参数
一般形式 MyType * p= new (arg-list) MyType;
显式调用析构函数
显式的调用析构函数是可以通过编译的,而显式的调用构造函数则不能(很符合逻辑,对象都已创建了,再次构造显然是个错误)。
需要注意的是,如果对在stack分配的对象显式调用析构函数,则对象在生存期结束时析构函数会被编译器再次调用——重复析构一个对象很可能是bug之源。另一方面,如果对在heap上分配的对象显式调用析构函数,对象会被销毁,但其占用的空间不会被释放。
第十四章 继承
C++中基于类机制,在不污染现有代码的前提下实现代码重用。基本方式有两种:组合和继承。
组合
常见做法是将嵌入的对象声明为private成员,这样就些嵌入的对象就变成了底层实现而彻底与接口无关了。
继承
默认情况下继承是以private方式进行的,而通常情形是采取public方式继承的。
在与继承相关的构造函数的一个基本概念就是,在进入新类的构造函数的函数体时,所有基类的构造函数都已经调用完毕。
继承中的名字隐藏(Name hiding)
情况一:redefining(non virtual function)or overriding(virtual function)
即派生类提供了与基类具有signature和返回类型的接口函数。
通常,如果派生类中重定义了基类中的某个被重载的函数,那么基类中的所有同名函数在新类中都被隐藏了。
情况二:
派生类修改了基类中某个接口函数的signature和/或返回类型,这种情形通常来说违背了继承的本意,因为继承的最终目标是实现多提阿,即在维持单一接口的前提下,完成不同操作。
非自动继承的成员函数
并不是所有的基类成员函数都自动继承给派生类的。
构造析构、析构函数、拷贝构造函数、赋值运算符这四个类成员函数不会被派生类自动继承。
若派生类中未定义上述函数,则编译器会为派生类合成之。所合成的构造函数按照memberwise进行初始化,所合成的运算符=按照memberwise进行赋值操作。
值得一提的时,基类中若存在转换函数,则被派生类继承。
另外,编译器只为同类型对象之间的赋值操作自动生成函数。
在组合&继承中选择
通常在需要某个已存在的类提供的功能而不是接口时,会采用组合的方式。也就是说,程序员将对象嵌入自定义的类中以实现新类的某些功能,然而这个新类的用 户是通过新类的接口而不是原有类的接口来执行操作的。通常在组合方式中会将嵌入的对象声明为新类的private成员。当然,有时需要允许用户访问原有类 的接口,从而使代码更清晰、更具可读性,这是可以将嵌入的对象声明为public成员。
概括的说,继承表达的是"is -a “关系,而组合表达的是"has-a“关系
Subtyping
类对象的自动转换只发生在函数调用的参数中,而不是成员选择的过程中。
私有继承
对于私有继承,派生类的对象无法像在public继承时,被视为基类的一个实例。
私有继承中的publicizing
在private inheritage中,可以在派生类中显式的使用访问限定符,从而使基类的某些成员可见和可用。
protected访问控制
在理想世界中,private成员永远都具备不可有任何折扣的private属性;然而,在实际工程中,有些时候是希望能够使得成员对于外部而言是不可见而对于派生类的成员是允许访问的。
protected关键字实际上表示:“该成员对于类的用户而言,具有private性质,但是对于所有派生类的成员都是可访问的。“
最佳的解决方案是将所有的成员变量都设置为private,而将这些成员变量的访问接口定义为protected,这样派生类就可以在底层实现中使用基类提供的这些接口。
protected inheritage
类似于private继承,protected继承很少被使用,C++中引入更多的是出于保持语言完成性的考虑。
继承与运算符重载
除了赋值运算符=之外,其它运算符函数被派生类自动继承。
多重继承
一言以蔽之,非大牛勿碰。
Upcasting
继承所提供的最重要的特性并不是为派生类提供了基类的成员函数,而是通过继承所表现出的基类与新类之间的关系。
简单的概括起来就是,新类可以视为基类。
upcasting永远都是安全的——由更特殊的类向更通用的类转化时,对于类接口唯一可能发生的事情是丢失成员函数,而不是新增成员函数。这也是为何编译器总是允许upcasting。
upcasting和拷贝构造函数
在编写派生类的拷贝构造函数时,一定要显式的调用基类的拷贝构造函数。
组合与继承
要判断究竟使用组合还是派生,最清晰的方法之一就是看是否需要进行upcast。是的话,则用继承,不是,则用组合。
第十五章 多态和虚函数
多态从另一个纬度提供接口与实现的分离,即将能够做什么和怎么做分离(decouple what from how)
只有从设计的角度才能理解虚函数的意义所在。
函数绑定
将函数调用和函数体联系起来的操作称为绑定。
在程序运行之前完成的绑定(由编译器和链接器完成),称为早期绑定。
虚函数
在类中创建一个虚函数,只需在函数声明中使用virtual关键字即可,函数定义中不需要。
虚函数的特性可以描述为:“用户只需要向对象发送消息,完成什么工作以及如何完成都由对象操心。“
C++里后期绑定的实现
VTABLE:编译器为每个包含虚函数的类创建一个称为VTABLE表格,表格中存放类中每个虚函数的地址。
VPTR:编译器在每个对象中都插入一个称为VPTR的指针(前提是类中包含虚函数),指向对象所属类的VTABLE。
创建VTABLE,为每个对象初始化VPTR,都是由编译器在幕后完成的。
存储类型信息
要实现后期绑定,就必然要求在对象中存储某些类型信息。
一旦对象中的VPTR被正确初始化后,对象实际上已经"了解"自己的类型。
基类和派生类中的VPTR在对象中都处于相同的位置,通常是放置在对象的开始处。
VPTR的初始化
对于虚函数的实现,VPTR指针的正确初始化是非常重要的。在VPTR被初始化前,是无法调用虚函数的。
VPTR的初始化是在构造函数中完成的,编译器会自动的在构造函数(无论是默认还是用户定义的)中插入初始化VPTR的代码。
纯虚函数和抽象基类
当创建一个纯虚函数时,编译器会在VTABLE中为该函数保留一个slot,但是并不在slot中填写适当的函数地址(通常情况下也不存在适当的地址)。这样,即使类中只有一个纯虚函数,VTABLE也是不完整的。
在一个类的VTABLE是不完整的情况下,创建该类的一个对象是不安全的,因此编译器不会为含有纯虚函数的类创建对象。
纯虚函数的存在,可以禁止抽象基类以值传递的方式用于函数参数;另外,也避免了object slicing这种现象的出现。
纯虚函数的定义(函数体)
即使是纯虚函数,程序员也可以选择为其提供定义(函数体)。
在这种情况下,编译器依然不会为类创建对象;纯虚函数在类的VTABLE中的对应slot仍然为空,但是与普通纯虚函数的区别在于,在派生类中会存在一个可以调用的函数。
另外的优点在于设计者可以在不破坏现有代码的前提下轻松的将一个纯虚函数改变为普通的虚函数。
继承与VTABLE
当继承以及虚函数重载发生时,编译器会为新类创建一个新的VTABLE;基类中未被重载的函数的地址被插入新类的VTABLE中。
Object Slicing
纯虚函数的最重要应用可能就是通过编译错误来组织object slicing的发生。
重载VS覆盖(overloading & overriding)
在第十四章中已经看到,派生类中对基类中某个重载函数的重定义会隐藏基类中所有版本的函数。
然而,对于基类中的虚函数,情形稍有不同:编译器不允许派生类在进行覆盖时修改虚函数的返回类型,而这在函数为非虚函数时是允许的。即对于虚函数,在覆盖时不允许修改返回类型。
虚函数与构造函数
构造函数不能是虚函数,这从逻辑上很容易理解:任何虚函数调用在对象的VPTR被正确初始化后才能正常工作,而负责初始化工作的正是构造函数,如果构造函数也是虚函数的话,会出现逻辑上的循环依赖关系。
当一个包含虚函数的类对象被创建时,必须保证其VPTR指针正确指向对应的VTABLE;这一工作必须在任何虚函数被调用前完成。构造函数不仅负责对象的创建,同时也负责VPTR的正确初始化。编译器会在构造函数的开始处插入初始化VPTR的代码。
以上的事实隐含说明了以下的事实
首先是效率问题;类的构造函数所完成的工作很可能远比代码中体现的要多,
第二个方面是构造函数被调用的顺序:按照类层次结构被调用。
第三个方面是构造函数中虚函数被调用的方式。如果在构造函数中调用虚函数,则调用虚函数的当前版本,也就是说,虚函数机制在构造函数内部失效。
第三点特性是合理的,这可以
从两方面解释。
首先,在构造函数中,只能确认基类对象已被初始化,然而并不知道那个类会从当前类派生。而虚函数调用机制,会发生继承体系的越界(向下)的清况;假使在构 造函数中允许虚函数机制的话,可能会调用一个函数,而这个函数对尚未初始化的成员进行操作,而这显然可能导致灾难性的后果。
其次,这可以从技术上解释清楚。对象的VPTR总是初始化指向最近一个被调用的构造函数对应的VTABLE,即VPTR的状态又最近被调用的构造函数决 定,这也是为什么构造函数按照从基类到最后被派生的顺序被调用。因此在当前构造函数在执行时,对象的VPTR被设置指向当前类的VTABLE,如果在构造 函数中调用虚函数的话,只会根据当前类的VTABLE完成函数调用。因此,最终结果只能是调用虚函数的当前版本。
虚函数与析构函数
构造函数不允许是虚函数,与之对应的是,析构函数可以可以而且经常被定义为虚函数。
在析构函数中可以安全的调用基类的成员函数。
每个析构函数都了解自己是基于哪个类而派生而来的,然而却不知道哪个类会基于自己而派生出来。
应该在脑子中明白,构造函数和析构函数是仅有的编译器要求必须按照结构关系的顺序依次被调用的两个特例。
忘记将析构函数设为virtual可能导致一个隐藏很深的bug,因为这通常并不直接影响程序的执行,然而却会悄无声息的引入内存泄露。
纯虚析构函数
尽管纯虚析构函数在C++中是合法定义,然而在使用时却有一个额外的限制:程序员必须为纯虚虚构函数提供函数定义。
这看起来似乎违背直觉:一个虚函数在声明为纯虚的前提下却必须提供函数体。
然而你需要再次记住,析构函数是特殊函数,在类结构中的每个类的析构函数都要被调用,如果纯虚析构函数的函数体不存在的话,在析构函数中将面临无函数可调用的窘境。因此,由编译器和链接器保证纯虚析构函数函数体的存在是绝对必要的。
令人感到疑惑的事实是,当继承一个包含纯虚析构函数的类时,与普通情况不同,派生类中不需要为纯虚析构函数提供定义。
简单的说,析构函数定义为虚函数是很必要的,而是否是纯虚函数并不是十分重要。
析构函数中的虚函数
类似于构造函数中的情形,析构函数中调用虚函数时执行虚函数的当前版本,虚函数机制被忽略。
在构造函数中类型信息是不可知的,而在析构函数中类型信息可知,但确实不可靠的。
single-rooted hierarchy
事实上,除C++之外的所有其它OOP语言都采取了single-rooted hierarchy。
运算符重载
运算符类似其它成员函数,也可以设定为虚函数。
Downcasting
dynamic_cast是一个类型安全的downcast操作。
当使用dynamic_cast进行downcast时,只有在要执行的转换是合法的情况下操作才会返回指向一个目标类型的指针。
dynamic_cast必须应用于一个多态层次结构,因为dyanmic_cast需要使用VTABLE中的信息来确认对象的实际类型。
频繁的执行dynamic_cast可能会导致性能问题。
多态和后期绑定是不可分离的。
第十六章 模板简介
通过使用继承和组合可以重用目标代码,而通过使用模板可以重用源代码。
存在三种源代码重用的方案:C方案、Smalltalk方案和C++方案(模板)
C方式:在复制源代码的基础上手动进行修改。
Smalltalk方案:要重用代码,就使用继承;该方案的可行性是基于Smalltalk的single-rooted的语言特性。由于C++支持多个相互独立的类层次结构,Smalltalk方案在C++中不会有预期中的效果。
头文件和模板
对于模板类,通常情况下其所有的声明和实现都放入头文件中;这看起来违背了头文件使用的基本原则——不要在头文件放入任何会导致编译器分配空间的代码,以此来避免在链接时出现多重定义的错误。
实际上,对于模板,这种“违背原则"的做法并不会导致链接时错误的出现。对于任何有template <...>前置的代码块,编译器在第一次看到这个代码块时并不会为代码块分配存储空间,而是等到它发现一个模板实例时才会为代码块分配存储空 间;此外,编译器和链接器中存在着移除同一模板的多重定义的机制。
从另一个角度来看,将模板的实现部分放在头文件中使得其它人可能偷取并修改你的代码。
模板隐式的为其支持的类型定义好了接口。
换句话说,虽然C++属于强类型语言,模板为C++提供一种弱类型机制。
(注:smalltalk和Python中的所有方法都具有弱类型性,这些语言并不需要模板机制)。
在实现容器时所面临的基本困难是,容器的清理工作需要了解容器所容纳对象的类型,而容器的创建工作却要求不对其所容纳对象的类型有特定要求。
通过值方式容纳对象的容器,不需要担心对象所有权的问题,因为该容器是对象毫无疑问的所有者。
而对于通过引用或指针方式容纳对象的容器,在设计和实现时,必须仔细考虑对象所有权的问题。
迭代器是这样的一个对象,它在一个容纳其它对象的容器中移动,并每次选择其中一个对象,并且不提供对容器实现的直接访问。
从很多角度来看,迭代器就是一个智能指针;实际上,你会发现迭代器通常模仿大多数指针操作。
迭代器设计的目标是使得客户程序员的程序中所有用到的迭代器都拥有一致的接口。
nested iterator
为了创建一个nested friend class(iterator),通常要遵循以下的步骤:
首先声明类的名称,其次将类声明为友元,最后是类定义。
end sentinel
从一个普通类到模板的转换是很清晰的。首先创建并调试一个普通类,然后将其转为模板,这种开发方式比起从零开始创建模板要更容易。
容器类的重用是通过模板而不是继承实现的。
实际上,通过模板实现代码重用,比起通过继承和组合,要简单很多。