《高质量程序设计指南--C/C++语言》学习笔记

《高质量程序设计指南--C/C++语言》学习笔记

  • 高质量软件开发之道
  • 程序设计入门
    • 内部名称
    • 连接规范
    • 变量及其初始化
    • 浮点变量与零值比较
  • C++/C常量
    • 正确定义符号常量
    • 类中的常量
    • 实际应用中如何定义常量
      • 在C程序中定义多个编译单元或模块公用的常量
      • 在C程序中定义仅供一个编译单元使用的常量
      • 在C++程序中定义多个编译单元或模块公用的常量
      • 在C++程序中定义仅供一个编译单元使用的常量
  • C++/C函数设计基础
    • 连接类型
    • 使用断言
  • C++/C指针、数组和字符串
    • 字符数组、字符指针和字符串
    • 函数指针
  • C++/C高级数据类型
    • 成员对齐
    • Union
    • 枚举 Enum
  • C++/C编译预处理
    • 文件包含
    • 头文件包含的合理顺序
    • 宏定义
    • 条件编译
    • #error
    • #pragam
    • 预定义符号常量
  • C++/C文件结构和程序版式
    • 程序文件的目录结构
    • 文件的结构
      • 头文件的用途和结构
    • 源文件结构
  • C++/C应用程序命名规则
  • C++面向对象程序设计方法概述
    • 对象的内存映像
  • 对象的初始化、拷贝和析构
  • C++函数的高级特性
    • 函数重载
    • 摆脱隐藏
    • 参数的默认值
    • 运算符重载
    • 内联函数
    • 类型转换函数
    • const成员函数
  • C++异常处理机制和RTTI
    • C++异常处理
    • 异常类型和异常对象
    • 异常的类型匹配规则
    • 异常说明及其冲突
    • 当异常抛出时局部对象如何释放
    • 如何使用好异常处理技术
    • C++的标准异常
  • RTTI
    • typeid运算符
    • dynamic_cast<>运算符
  • 内存管理
    • 有了malloc/free为什么还要new/delete
    • new有3种使用方式
      • plain new/delete
      • nothrow new/delete
      • palcement new/delete
  • 学习和使用STL
    • 容器设计原理
    • 迭代器
    • 适配器
    • 泛型算法
    • STL使用心得
  • 经典C/C++试题

高质量软件开发之道

一般地,软件设计应该将“设备相关程序”与“设备无关程序”分开,将“功能模块”与“用户界面”分开,这样可以提高可移植性。

尽可能复用你所能复用的东西。

修改错误代码时的注意事项:

  • 发现错误时不要急于修改,先思考一下修改此代码会不会引发其他问题。
  • 考虑是否还有同类型的其他错误。
  • 不论原有程序是否绝对正确,只要对此程序做过改动(哪怕是微不足道的),都要进行回归测试。

对于以源代码形式提供的库,必须使用当前的编译器对其重新编译;如果是二进制级的库,除非它的开发商保证该库的实现与IDE的缺省库是二进制兼容的,否则不能使用。

开发环境泛指支持软件开发的一切工具,例如操作系统、代码编辑器、编译器、连接器、调试器等等。**集成开发环境(IDE)**则是把编辑器、编译器、连接器及调试器等各种工具集成到了一个工作空间中。如果没有IDE,就得手动编辑编译连接的命令行或者makefile,手工编辑它们的参数设置。

设计上应该追求简单低耦合

程序设计入门

内部名称

在C语言中,所有函数不是局部于编译单元(文件作用域)的static函数,就是具有extern连接类型和global作用域的全局函数,因此除了两个分别位于不同编译单元的static函数可以同名外,全局函数是不能同名的;全局变量也是同样的道理。其原因是C语言采用了一种极其简单的函数名称区分规则:仅在所有函数名的前面添加前缀"_",从唯一识别函数的作用上来说,与不添加前缀没有什么不同。

但是C++语言允许用户在不同的作用域中定义同名的函数、类型和变量等,这些作用域不仅仅限于编译单元,还包括classstructunionnamespace等;甚至在同一个作用域中也可以定义同名的函数,即重载函数。为了避免连接二义性,会对这些函数进行重命名,在C++中,称为**”Name-Mangling"(名字修饰或名字改编)**。例如在它们的前面分别添加所属各级作用域的名称(classnamespace等)及重载函数的经过编码的参数信息(参数类型和个数等)作为前缀或后缀。另外,C++标注的不同实现会采取不同的Name-Mangling方案(标准没有强制规定)。

连接规范

在使用不同编程语言进行联合软件开发的时候,需要统一全局函数、全局变量、全局常量、数据类型等的链接规范,特别是在不同模块之间共享的接口定义部分。因为连接规范关系到编译器采用什么样的Name-Mangling方案来重命名这些标识符的名称,而如果同一个标识符在不同的编译单元或模块中具有不一致的连接规范,就会产生不一致的内部名称,这肯定会导致程序连接失败。

同样道理,在开发程序库的时候,明确连接规范也是必须要遵循的一条规则。通用的连接规范则属C连接规范:extern C

变量及其初始化

初始化和赋值的不同:前者发生在对象(变量)创建的同时,而后者是在对象创建后进行的。

注意:在一个编译单元中定义的全局变量的初始值不要依赖定义于另一个编译单元中的全局变量的初始值。这是因为:虽然编译器和连接器可以决定同一个编译单元中定义的全局变量的初始化顺序保持与它们定义的先后顺序一致,但是却无法决定当两个编译单元连接在一起时哪一个的全局变量的初始化先于另一个编译单元的全局变量的初始化。也就是说,这一次编译连接和下一次编译连接很可能使不同编译单元之间的全局变量的初始化顺序发生改变。例如下面的做法是不当的:

//file.c
int g_x = 100;
//file2.c
extern int g_x;
double g_d = g_x + 10;

如果g_x初始化被排在g_d的前面,那么g_d就会被初始化为110;但是如果反过来,那么g_d的初始值就无法预料了。

浮点变量与零值比较

计算机表示浮点数(float和double类型)都有一个精度限制。对于超出了精度限制的浮点数,计算机会把它们的精度之外的小数部分截断。因此本来不相等的两个浮点数在计算机中可能就变成相等的了。

如果两个同符号浮点数之差的绝对值小于或等于一个可接受的误差(即精度),就认为它们是相等的,否则就是不相等的。精度根据具体应用要求而定,不要直接用==!=对两个浮点数进行比较,虽然C/C++语言支持直接对浮点数进行==!=的比较操作,但是由于它们采用的精度往往比我们实际应用中要求的精度高,所以可能导致不符合实际需求的结果甚至错误。

#define EPSILON 1e-6 //精度
if(abs(x-y) <= EPSILON) //x等于y
if(abs(x-y) > EPSILON) //x不等于y

if(abs(x)<=EPSILON) //x等于)
if(abs(x) > EPSILON) //x不等于0

C++/C常量

常用的常量可以分为:字面常量、符号常量、契约性常量、布尔常量和枚举常量等。

由于字面常量只能引用,不能修改,所以语言实现一般把它保存在程序的符号表里面而不是一般的数据区中。

存在两种符号常量:用#define定义的宏常量和用const定义的常量。由于#define是预编译伪指令,它定义的宏常量在进入编译阶段前就已经被替换为所代表的字面常量了,因此宏常量在本质上是字面常量。

在标准C语言中,const符号常量默认是外连接的(分配存储),也就是说你不能在两个(或两个以上)编译单元总同时定义一个同名的const符号常量(重复定义错误),或者把一个const符号常量定义放在一个头文件中而在多个编译单元中同时包含该头文件。但是在标准C++中,const符号常量默认是内连接的,因此可以定义在头文件中。当在不同的编译单元中同时包含该头文件时,编译器认为它们是不同的符号常量,因此每个编译单元独立编译时会分别为它们分配存储空间,而在连接时进行常量合并。

正确定义符号常量

在C++需要对外公开的常量放在头文件中,不需要对外公开的常量放在定义文件的头部。为便于管理,可以把不同模块的常量集中存放在一个公用的头文件中。

尽量使用const而不是#define来定义符号常量,包括字符串常量。

类中的常量

非静态const数据成员是属于每一个对象的成员,只在某个对象的生存期限内是常量,而对于整个类来说它是可变的,除非是static const

不能在类声明中初始化非静态const数据成员。例如下面的代码是错误的,因为在类的对象被创建前,编译器无法知道SIZE的值是多少。

class A
{
	...
	const int SIZE = 100; //错误:企图在类声明中初始化非静态const数据成员,应该通过参数化列表的方式进行初始化
	int array[SIZE]; //错误:未知的SIZE
};

想要建立在整个类中都恒定的常量,需要通过类中的枚举常量或者static const来完成。

class A
{
	...
	enum
	{
		SIZE1 = 100, //枚举常量
		SIZE2 = 200;
	};
	
	int array1[SIZE1];
	int array2[SIZE2];
};
class A
{
public:
	static const int SIZE1 = 100; //静态常量成员
	static const int SIZE2 = 200;

private:	
	int array1[SIZE1];
	int array2[SIZE2];
};

实际应用中如何定义常量

在C程序中const符号常量定义的默认连接类型是extern的,即外连接(extern linkage),就像全局变量一样。因此,如果要在头文件中定义,必须使用static关键字,这样每一个包含该头文件的编译单元就会分别拥有该常量的一份独立定义实体(如同直接在每一个源文件中分别定义一次),否则会导致“redefinition"的编译器诊断信息;如果在源文件中定义,除非明确改变它的连接类型为static(实际上是存储类型为static,连接类型为内连接)的,否则其他编译单元就可以通过extern声明来访问它。

但是在C++程序中const符号常量定义的默认连接类型却是static的,即内连接,就像class的定义一样,这就是在头文件中定义而不需要static关键字的原因。

在C程序中定义多个编译单元或模块公用的常量

方法一:
在某个公用头文件中将符号常量定义为static并初始化,例如:

//CommonDef.h
static const int MAX_LENGTH = 1024;

然后每一个使用它的编译单元#include该头文件即可;

方法二:
在某个公用的头文件中将符号常量声明为extern的,例如:

//CommonDef.h
extern const int MAX_LENGTH;

并且在某个源文件中定义一次:

const int MAX_LENGTH = 1024;

然后每个使用它的编译单元#include该头文件即可.

方法三
如果是整型常量,在某个公用头文件中定义enum类型,然后每一个使用它的编译单元#include该头文件即可。

在C程序中定义仅供一个编译单元使用的常量

直接于该编译单元(源文件)开头位置将符号常量定义为static并初始化,例如:

//foo.c
static const int MAX_LENGTH = 1024;

在C++程序中定义多个编译单元或模块公用的常量

方法一
在某个公用的头文件中直接在某个名字空间中或者全局名字空间中定义符号常量并初始化(有无static无所谓),例如:

//CommonDef.h
const int MAX_LENGTH = 1024;

然后每一个使用它的编译单元#include该头文件即可。

方法二

在某个公用的头文件中并且在某个名字空间中或者全局名字空间中将符号常量声明为extern的,例如:

//CommonDef.h
extern const int MAX_LENGTH;

并且在某个源文件中定义一次并初始化:

const int MAX_LENGTH = 1024;

然后每个使用它的编译单元#include该头文件即可。

方法三
如果是整形常量,在某个公用头文件中定义enum类型,然后每一个使用它的编译单元#include该头文件即可。

方法四
定义为某一个公用类的static const数据成员并初始化,或者定义为类内的枚举类型,例如:

//Utility.h
class Utility{
public:
	static const int MAX_LENGTH;
	enum{
		TIME_OUT = 10;
	};
};
//Utility.cpp
const int Utility::MAX_LENGTH = 1024;

每一个使用它的编译单元#include该类的定义即可。

方法二和方法四的优点:

  • 节省存储,每一个编译单元访问的都是这个唯一的定义
  • 修改初值后只需重新编译定义所在编译单元即可,影响面很小

方法二和方法四的缺点:

  • 如果要改变初值,需修改源文件

方法一和方法三的优点:

  • 维护方便

方法一和方法三的缺点:

  • 如果修改常量初值,则将影响多个编译单元,所有受影响的编译单元必须重新编译;
  • 每一个符号常量在每一个包含它们的编译单元内都存在一份独立的拷贝内容,每个编译单元访问的就是各自的拷贝内容,因此浪费存储空间

在C++程序中定义仅供一个编译单元使用的常量

直接于该编译单元(源文件)开头位置将符号常量定义为常量并初始化(有无static无所谓),例如:

//foo.cpp
const int MAX_LENGTH = 1024;

C++/C函数设计基础

不论是函数的原型还是定义,都要明确写出每个参数的类型和名字,不要贪图省事只写参数的类型而忽略参数名字。如果函数没有参数,那么使用void而不要空着,这是因为标准C把空的参数列表解释为可以接收任何类型和个数的参数;而标准C++则把空的参数列表解释为不可以接收任何参数。在移植C++/C程序时尤其要注意这方面的不同。

不要将正常值和错误标志混在一起返回。建议正常值用输出参数获得,而错误标志用return语句返回。

函数的功能要单一,即一个函数只完成一件事情,不要设计多用途的函数。函数规模尽量控制在50行代码以内。

不仅要检查输入参数的有效性(例如通过assert),还要检查通过其他途径进入函数体内的变量的有效性,例如全局变量、文件句柄等。

连接类型

连接类型分为外连接内连接无连接 3种。连接类型表明了一个标识符的可见性,容易与作用域混淆。

如果一个标识符能够在其他编译单元中或者在定义它的编译单元中的其他范围内被调用,那么它就是外连接的。外连接的标识符需要分配运行时的存储空间。

void f(bool flag) {...} //函数定义是外连接的
int g_int; //全局变量g_int是外连接的
extern const int MAX_LENGTH = 1024; //MAX_LENGTH变成外连接的
namespace NS_H
{
	long count; //NS_H::count是外连接的
	bool g(); //NS_H::g是外连接的,但原型是内连接的
}

如果一个标识符能在定义它的编译单元中的其他范围内被调用,但是不能在其他编译单元中被调用,那么它就是内连接的。

static void f2() {...} //f2为内连接的
union  //匿名联合的成员是内连接的
{
	long count;
	char *p;
}

class Me{...}; //Me是内连接的

const int MAX_LENGTH = 1024; //常量是内连接的
typedef long Integer; //typedef为内连接的

一个仅能在声明它的范围内被调用的名字是无连接的。

void f()
{
	int a; //a是无连接的
	class B{...}; //局部类是无连接的,具有程序块作用域
}

《高质量程序设计指南--C/C++语言》学习笔记_第1张图片
《高质量程序设计指南--C/C++语言》学习笔记_第2张图片

使用断言

C++/C的宏assert(expression):当表达式为假时,调用库函数abort()终止程序。程序一般分为DebugRelease版本,assert只在Debug版本内有效。

在函数的入口处,建议使用断言来检查参数的有效性(合法性)。

请给assert语句加注释,告诉人们assert语句究竟要干什么。

使用断言的目的是捕捉在运行时不应该发生的非法情况。不要混淆非法情况与错误情况之间的区别,后者是程序运行过程中自然存在的并且是一定要主动做出处理的。例如动态内存申请失败不是非法情况,而是错误情况。应该使用if语句捕捉错误情况并给出错误处理代码,而不应该适用assert(否则在Release版本失效)。

C++/C指针、数组和字符串

不管指针变量是全局的还是局部的,静态的还是非静态的,应当在声明它的同时初始化它,要么赋予它一个有效的地址,要么赋予它NULL

数组名字本身就是一个指针,是一个指针常量,例如对于int a[10]a等价于int *const a,因此不能试图修改数组名的值。数组名的值就是数组第一个元素的内存首地址,即a == &a[0]

虽然数组自己知道它有多少个元素,但是由于可以使用整形变量及其表达式作为数组的下标,而变量的值在编译时是无法确定的,所以语言无法执行静态检查。

数组与指针间存在如下的等价关系:
(1) 一维数组等价于元素的指针,例如:

int a[10]  <=> int *const a;

(2) 二维数组等价于指向一维数组的指针,例如:

int b[3][4] <=> int (* const b)[4]

(3) 三维数组等价于指向二维数组的指针,例如:

int c[3][4][5]  <=> int (* const c)[4][5] 

对于多维数组,C++/C并不像一维数组那样可以简单地转换为同类型指针,而是转换为与其等价的数组指针。例如int a[m][n]就转换为int (*a)[n],就是说a是一个指向一维数组的指针,而该一维数组具有n个元素,a是指向原来数组的第一行的指针,a+1就是指向第二行的指针,依次类推。下面的几种表达方式是等价的:

  • a[i][j]
  • *(a[i] + j)
  • (*(a+i))[j]
  • *(*(a+i)+j)

注意,上述的4个表达式中的a是指向一维数组的指针,而不是单纯的指向int类型元素的指针,因此(*(a+i)+j)的值实际上是((a+i*sizeof(int)*n)+j*sizeof(int))

数组传递在C++/C中默认就是地址传递。如果你想要按值来传递数组,可以将数组封装起来,例如放到structclass里面作为一个成员,因为结构和类对象默认都是按值传递的。

动态创建和删除多维数组:你不能简单的使用一个元素类型的指针来接收动态创建的多维数组的返回地址,这是因为一个多维数组在语义上并不等价于一个指向其元素类型的指针,相反它等价于一个“指向数组的指针”。

char *p1 = new char[5][3]; //错误! 语义不等价
int *p2 = new int[4][6]; //错误! 语义不等价

char (*p3)[4]  = new char[5][4];//正确,退化第一维,语义等价
char (*p4)[5][7] = new char[20][5][7]; //正确,退化第一维,语义等价

delete[]p3; //删除p3
delete[]p4; //删除p4

字符数组、字符指针和字符串

字符数组就是元素为字符变量的数组,而字符串则是以\0(ASCII码值为0x00)为结束字符的字符数组。可见,字符数组不一定是字符串

由于字符串的连续性,编译器没必要通过它的长度信息来提取整个字符串,仅通过一个指向其开头字符的字符指针就能实现对整个字符串的引用。

如果用一个字符串字面常量来初始化一个字符数组,数组的长度至少要比字符串字面常量的长度大1,因为还要保存结束符\0

char array[] = "hello";
//数组array的元素为{'h','e','l','l','o', '\0'}
char arrChar_1[] = {'a', 'b', '\0', 'd', 'e'};
char arrChar_2[] = "hello";
char *p  = "hello";

cout << sizeof(arrChar_1) << endl; //5, 表示该数组占5个字节
cout << strlen(arrChar_1) << endl; //2, 表示字符串长度为2
cout << sizeof(arrChar_2) << endl; //6, 表示该数组占6个字节
cout << strlen(arrChar_2) << endl; //5, 表示字符串长度为5
cout << sizeof(p) << endl; //4,表示指针p占4个字节
cout << strlen(p) << endl; //5,表示字符串长度为5

字符串的拷贝请使用库函数strcpystrncpy而不是用=(使用=就成了字符指针的赋值)。同理不要使用==!=>=等符号直接比较两个字符串,应使用strcmpstrncpm等库函数。

对字符串进行拷贝时,要保证函数结束后目标字符串的结尾有\0结束标志。某些字符串函数并不会自动在目标字符串结尾追加\0,例如strncpystrncat,除非你指定的n值比源串的长度大1,stcpystrcat会把源串的结束符一并拷贝到目标串中。

函数指针

可以通过函数指针数组实现同类型函数的批量调用。在C++动态决议的虚拟机制中使用的vtable就是一个用来保存虚成员函数地址的函数指针数组。

double _cdecl (* fp[5])(double) = { sqrt, fabs, cos, sin, exp};
for(int k=0; k<5; k++)
{
	cout << "Result:" << fp[k](10.25) << endl; 
}
class CTest{
public:
	void f(void) {cout << "CTest::f()" <<endl;} //普通成员函数
	static void g(void) {cout << "CTest::g()"<< endl;} //静态成员函数
	virtual void h(void) {cout << "CTest::h()" << endl;} //虚成员函数

	//...

};

void main()
{
	typedef void (*GFPtr)(void); //定义一个全局函数指针类型
	GFPtr fp = CTest::g; //取静态成员函数地址的方法和取一个全局函数的地址相似
	fp(); //通过函数指针调用类静态成员函数

	typedef void (CTest::*MemFuncPtr)(void); //声明类成员函数指针类型
	MemFuncPtr mfp_1 = &CTest::f; //声明成员函数指针变量并初始化
	MemFuncPtr mfp_2 = &CTest::h; //注意获取成员函数地址的方法

	CTest theObj;
	(theObj.*mfp_1)(); //使用对象和成员函数指针调用成员函数
	(theObj.*mfp_2)();

	CTest *pTest = &theObj;
	(pTest->*mfp_1)(); //使用对象指针和成员函数指针调用成员函数
	(pTest->*mfp_2)();
}

输出如下:
《高质量程序设计指南--C/C++语言》学习笔记_第3张图片

C++/C高级数据类型

成员对齐

对于复合类型(一般指结构或类)的对象,如果它的起始地址能够满足其中**要求最严格(或最高)**的那个数据成员的自然对齐要求,那么它就是自然对齐的。如果那个数据成员又是一个复合类型的对象,则依次类推,直到最后都是基本类型的数据成员。

自然对齐要求最严格:例如double变量的地址要能被8整除,int变量的地址只需要能被4整除,bool变量的地址只需要能被1整除。在C++/C的基本数据类型中,如果不考虑enum可能的最大值所需的内存字节数,double就是对齐要求最严格的类型,其次是intfloat,然后是shortboolchar

typedef unsigned char BYTE;
enum Color {RED = 0x01, BLUE, GREEN, YELLOW, BLACK};

struct Sedan //私家车
{
	bool m_hasSkylight; //是否有天窗
	Color m_color; //颜色
	bool m_isAutoShift; //是否是自动挡
	double m_price; //价格
	BYTE m_seatNum; //座位数量
};

对于上面的Sedan类,显然double成员m_price的对其要求更严格,因此Sedan对象的地址应该能被8整除。其他成员的其实地址也需要满足各自的自然对齐要求。
《高质量程序设计指南--C/C++语言》学习笔记_第4张图片
下面是一些例子:

struct X
{
	char m_ch;
	char *m_pStr;
};

sizeof(X) = 8,按4字节对齐

struct Y
{
	char m_ch;
	int m_count;
};

sizeof(Y) = 8,按4字节对齐

struct Z
{
	bool m_ok;
	char m_name[6];
};

sizeof(Z) = 7,按1字节对齐

struct R
{
	char m_ch;
	double m_width;
	char m_name[6];
};

sizeof(R) = 24,按8字节对齐

struct T
{
	int m_no;
	R m_r;
};

sizeof(T) = 32,按8字节对齐

struct U
{
	bool m_ok;
	T m_t;
};

sizeof(T) = 40,按8字节对齐

《高质量程序设计指南--C/C++语言》学习笔记_第5张图片
为了节省内存空间,显然要设法减少对象中的空洞,宁愿让末尾留下空洞也不要让中间留下空洞,尽量使所有成员连续存放,并且减少末尾的填充字节。方法很简单:按照从大到小的顺序从前到后声明每一个数据成员,并且尽量使用较小的成员对齐方式

对齐方式的指定还关系到模块之间接口的语义一致性和对象的二进制兼容性,不一致的对齐方式极有可能导致程序运行时产生错误的结果甚至崩溃。能够100%保证一致的方法就是直接在代码中使用编译器提供的方法指定每一个接口数据类型的对齐方式,而不是依赖于命令行参数设置或者其他途径。

Union

Union提供了一种使不同类型数据成员之间共享存储空间的方法,同时可以实现不同类型数据成员之间的自动类型转换。Union在同一时间只能存储一个成员的值(即只有一个数据是活跃的)。Union的大小取决于其中字节数最多的成员。在定义Union时可以指定初始值,但是只能指定一个初始值,而且该初始值的类型必须和Union的第一个成员的类型匹配。

枚举 Enum

C++/C枚举类型允许我们定义特定用途的一组符号常量,它表明这种类型的变量可以取值的范围。当你定义一个枚举类型的时候,如果不特别指定其中标识符的值,则第一个标识符的值将为0,后面的标识符将比前面的标识符依次大1;如果你指定了其中某一个标识符的值,那么它后面的标识符自动在前面的标识符值的基础上依次加1,除非你也同时指定了它们的值。

在标准C中,枚举类型的内存大小等于sizeof(int)。但是在标准C++中,枚举类型的底层表示并非必须是一个int–它可能更大或者更小(与该枚举类型的实际取值范围有关)。

枚举类型可以是匿名的。匿名的枚举类型就相当于直接定义的const符号常量,可以作为全局枚举,也可以放在任何类定义和名字空间中

C++/C编译预处理

C++/C的编译预处理器对预编译伪指令进行处理后生成中间文件作为编译器的输入,因此所有的预编译指令都不会进入编译阶段。预编译指令一般以#开头。

文件包含

#include <头文件名称>一般用来包含开发环境提供的库头文件,它指示编译预处理器在开发环境设定的搜索路径中查找所需要的头文件。#include "头文件名称"一般用来包含自己编写的头文件,它指示编译器首先在当前工作目录下搜索头文件,如果找不到的话再到开发环境设定的路径中去找。

使用该伪指令前时,头文件前面可以加相对路径或决定路径(此处的\并不解释为转义字符)。例如:

#include ".\myinclude\abc.h"
#include "C:\myproject\test1\source\include\abc.inl"

头文件包含的合理顺序

无论是在头文件还是源文件中,在文件开始部分包含其他的头文件时需要遵循一定的顺序。如果包含顺序不当,有可能出现包含顺序依赖问题,甚至引起编译时错误。推荐的顺序如下:
在头文件中
(1) 包含当前工程中所需要的自定义头文件(顺序自定)
(2) 包含第三方程序库的头文件
(3) 包含标准头文件

在源文件中:
(1) 包含该源文件对应的头文件(如果存在)
(2) 包含当前工程中所需要的自定义头文件
(3) 包含第三方程序库的头文件
(4) 包含标准头文件

宏定义

宏定义具有文件作用域,不论宏定义出现在文件中的哪个地方,例如函数体内、类型定义内部、名字空间内部等,在它后面的任何地方都可以引用宏。

宏定义不是C++/C语句,因此不需要使用语句结束符;

不要使用宏来定义新类型名,应该使用typedef,否则容易造成错误。

给宏添加注释时请使用块注释(/* */),而不要使用行注释。因为有些编译器可能会把宏后面的行注释理解为宏体的一部分。

尽量使用const取代宏来定义符号常量。

对于较长的使用频率较高的重复代码片段,建议使用函数或模板而不要使用带参数的宏定义;而对于较短的重复代码片段,可以使用带参数的宏定义,这不仅是出于类型安全的考虑,而且也是优化与折衷的体现。

条件编译

使用条件编译可以控制预处理器选择不同的代码段作为编译器的输入,从而使得源程序在不同的编译条件下产生不同的目标代码。条件编译为程序的移植和调试带来了极大方便,可以用它来暂时或永久地阻止一段代码的编译。条件编译指令主要包括#if#ifdef#ifndef#elif#else#endifdefine。每一个条件编译块都必须以#if开始,以#endif结束,#if必须与它下面的某一个#endif配对;define必须结合#if或者#elif使用,而不能单独使用。条件编译块可以出现在程序代码的任何地方。

通常我们想放弃编译一段代码时,会使用块注释。但是如果这段代码本身就有块注释时,那么双重注释很麻烦。可以通过下面的条件编译伪指令来屏蔽这段代码。如果要这段代码生效,只需要把0改为任何一个非0的值(例如1)记得。

#if 0
.../*...*/ //希望禁止编译的代码段
.../*...*/ //希望禁止编译的代码段
#endif
#define FLAG_DOS 2
#define FLAG_UNIX 1
#define FLAG_WIN 0

#define OS 1

#if OS == FLAG_DOS
	cout << "DOS platform" << endl;
#elif OS == FLAG_UNIX
	cout << "UNIX platform" << endl;
#elif OS == FLAG_WIN
	cout << "Windows platform" << endl;
#else 
    cout << "Unknow platform" << endl;
#endif

预编译伪指令#ifdef XYZ等价于#if define(XYZ),此处XYZ称为调试宏。如果前面曾经用#define定义过宏XYZ,那么#ifdef XYZ表示条件为真,否则条件为假。

#define XYZ
...
#ifdef XYZ
DoSomething();
#endif

预编译伪指令#ifndef XYZ等价于#if !define(XYZ)

#ifndef GRAPHICS_H //防止graphics.h被重复利用
#define GRAPHICS_H 

#include "myheader.h"
#include 
...

#endif

#error

编译伪指令#error用于输出与平台、环境等有关的信息。

#if !define(WIN32)
 #error ERROR: Only Win32 platform supported!
#endif

#ifndef _cplusplus
 #error MFC requires C++ compilation (use a .cpp suffix)
#endif 

当预处理器发现应用程序中没有定义宏WIN32或者_cplusplus时,把#error后面的字符序列输出到屏幕后即终止,程序不会进入编译阶段。

#pragam

编译伪指令#pragam用于执行语言实现所定义的动作,具体参考所使用的编译器帮助文档。

#pragam pack(push, 8) /* 对象成员对齐字节数 */
#pragam pack(pop)

#pragam warning(disable:4069) /*不要产生第C4069号编译错误*/
#pragam comment(lib, "kernel32.lib")

预定义符号常量

C++继承了ANSI C的预定义符号常量,预处理器在处理代码时将它们替换为确定的字面常量。这些符号不能用#define重新定义,也不能用#undef取消。

符号常量 解释
_LINE_ 引用该符号的语句的代码行号
_FILE_ 引用该符号的语句的源文件名称
_DATE_ 引用该符号的语句所在源文件被编译的日期(字符串)
_TIME_ 引用该符号的语句所在源文件被编译的时间(字符串)
_TIMESTAMP_ 引用该符号的语句所在源文件被编译的日期和时间(字符串)
_STDC_ 标准C语言环境都会定义该宏以标识当前环境

上表中的预定义符号常量可以被直接引用,常用来输出调试信息和定位异常发生的文件及代码行。

double * const pDouble = new(nothrow) double[10000];
if(pDouble == NULL){
	cerr << "allocate memory failed on line" << (_LINE_-2)
	    << "in file " << _FILE_ << endl;
}

C++/C文件结构和程序版式

版式虽然不会影响程序的功能,但是会影响清晰性。程序的版式追求清晰、美观,是程序风格的重要因素。

程序文件的目录结构

《高质量程序设计指南--C/C++语言》学习笔记_第6张图片
可以参照上图的目录结构来组织文件:
(1) Include目录存放应用程序的头文件(.h),还可以再细分子目录。
(2) Source目录存放应用程序的源文件(.c.cpp),还可以再细分子目录。
(3) Shared目录存放一些共享的文件。
(4) Resource目录存放应用程序所用的各种资源文件,包括图片、视频、图标、光标、对话框等,可以继续细分子目录。
(5) Debug目录存放应用程序调试版本生成的中间文件。
(6) Release目录存放应用程序发行版本生成的中间文件。
(7) Bin目录存放程序员自己创建的lib文件和dll文件。

注意:分清楚编译时相对路径和运行时相对路径的不同,这在编写操作DLL文件、INI文件及数据文件等外部文件的代码时很重要,因为它们的”参照物“不同。例如#include "..\include\abc.h是相对于当前工程所在目录的路径,或者是相对于当前文件所在目录的路径,在编译选项的设置中也有这样的路径。而OpenFile("..\abc.ini");则是相对于运行时可执行文件所在目录的路径,或者是相对于你为当前程序设置的工作目录的路径。

文件的结构

头文件的用途和结构

头文件用途:

  • 通过头文件来调用库功能,在很多场合,源代码不便向用户公布,只要向用户提供头文件和二进制的库即可。
  • 头文件能加强类型安全检查。
  • 头文件可以提高程序的可读性(清晰性)。

头文件中的元素比较多,一般有如下元素:

  • 头文件注释(包括文件说明、功能描述、版权声明等)(必须有)
  • 内部包含卫哨开始(#ifndef XXX / #define XXX)(必须有)
  • #include其他头文件(如果需要)
  • 外部变量和全局函数声明(如果需要)
  • 常量和宏定义(如果需要)
  • 类型前置声明和定义(如果需要)
  • 全局函数原型和内联函数的定义(如果需要)
  • 内部包含卫哨结束:#endif //XXX(必须有)
  • 文件版本及修订说明

如果程序中需要内联函数,那么内联函数的定义应当放在头文件中,因为内联函数调用语句最终被拓展开来而不是采用真正的函数调用机制。

源文件结构

源文件的结构一般如下:

  • 源文件注释(包括文件说明、功能描述、版权声明等)(必须有)
  • 预处理指令(如果需要)
  • 常量和宏定义(如果需要)
  • 外部变量声明和全局变量定义及初始化(如果需要)
  • 成员函数和全局函数的定义(如果需要)
  • 文件修改记录

C++/C应用程序命名规则

标识符的名字应当直观且可以拼读,可望文知意,不必进行”解码“。

不要仅靠大小写来区分相似标识符。

不要使程序中出现局部变量和全局变量同名的现象。

变量的名字应该使用”名词“或者”形容词加名词“的格式来命名。例如:

float value;
float oldValue;
float newValue;

全局函数的名字应当是使用"动词”或者“动词加名词”。

DrawBox(); //全局函数
box.Draw();//类的成员函数

建议:类型名和函数名均以大写字母开头的单词组合而成。

class Node;
void Draw(void);

建议:变量名和参数名采用第一个单词首字母小写而后面的单词首字母大写的单词组合

bool flag;
int drawMode;

建议:符号常量和宏名用全大写的单词组合而成,并在单词之间用单下划线分割,注意首尾最好不要使用下划线。

const int MAX_LENGTH =100;

建议:给静态变量加前缀s_(表示static

void Init()
{
	static int s_initValue;//静态变量
	...
}

建议:如果不得已需要全局变量,这时全局变量加前缀g_(表示global

int g_howManyPeople;
int g_howMuchMoney;

建议:类的数据成员加前缀m_(表示member),这样可以避免数据成员与成员函数的参数同名

void Object::SetValue(int width, int height)
{
	m_width = width;
	m_height = height;
}

建议:为了防止某一软件库中的一些标识符和其他软件库中的冲突,可以统一为各种标识符加上能反应软件性质的前缀。更好的办法是使用名字空间。

C++面向对象程序设计方法概述

不要在数组中直接存放多态对象,而是换之以基类指针或者基类的智能指针。

对象的内存映像

**构成对象本身的只有数据,任何成员函数都不隶属于任何一个对象,非静态成员函数与对象的关系就是绑定,绑定的中介就是this指针。**成员函数为该类所有对象共享,不仅是出于简化语言设计、节省存储的目的,而且是为了使同类对象具有一致的行为。虽然同类对象的行为一致,但是操作不同对象的数据成员,就会使各个对象具有不同的状态。

class Shape{
public:
	Shape(): m_color(0) {}
	virtual ~Shape() {}
	float GetColor() const {return m_color;}
	void SetColor(float color) {m_color = color;}
	virtual void Draw() = 0;

private:
	float m_color;
};

class Rectangle: public Shape{
public:
	......
private:
	......
};

《高质量程序设计指南--C/C++语言》学习笔记_第7张图片

  • 派生类继承基类的非静态数据成员,并作为自己对象的专用数据成员。
  • 派生类继承基类的非静态成员函数并可以像自己的成员函数一样访问。
  • 为每一个多态类创建一个虚函数指针数组vtable,该类的所有虚函数(继承自基类的或者新增的)的地址都保存在这张表中。
  • 多态类的每一个对象(如果有)中安插一个指针成员vptr,其类型为指向函数指针的指针,它总是指向所属类的vtable,也就是说:vptr当前所在的对象是什么类型的,那么它就指向这个类型的vtablevptr是C++对象的隐含数据成员之一(实际上它被安插在多态类的定义中);
  • 如果基类已经插入了vptr,则派生类将继承和重用该vptr
  • 如果派生类是从多个基类继承或者有多个继承分支(从所有根类开始算起),而其中若干个继承分支上出现了多态类,则派生类将从这些分支中的每个分支上继承一个vptr,编译器也将为它生成多个vtable,有几个vptr就生成几个vtable(每个vptr分别指向其中一个),分别与它的多态基类对应。
  • vptr在派生类对象中的相对位置不会随着继承层次的逐渐加深而改变,并且现在的编译器一般都将vptr放在所有数据成员的最前面;
  • 为了支持RTTI,为每一个多态类创建一个type_info对象,并把其地址保存在vtable中的固定位置(一般为第一个位置)(这一条取决于具体编译器的实现技术,标准并未规定)。

对象的初始化、拷贝和析构

不要在构造函数内做与初始化对象无关的工作,不要在析构函数内做与销毁一个对象无关的工作。也就是说,构造函数和析构函数应该做能够满足正确初始化和销毁一个对象的最少工作量,否则会降低效率,甚至会让人误解。比如对于一个用于消息发送和接收的类来说,不应该在构造函数内打开一个socket连接,同样不应该在析构函数内断开一个socket连接,应该将打开和断开socket连接放到另外的成员函数内来完成。

初始化就是在对象创建的同时使用初值直接填充对象的内存单元,因此不会有数据类型转换等中间过程,也就不会产生临时对象。而赋值则是在对象创建好之后任何时候都可以调用的而且可以多次调用的函数,由于它调用的是=运算符,因此可能需要进行类型转换,即会产生临时对象。

构造函数的作用是:当对象的内存分配好后把它从原始状态变为良好的可用的状态。

最好为每个类显式的定义构造函数和析构函数,即使它们暂时空着,尤其是当类含有指针成员或者引用成员的时候。

当使用成员初始化列表来初始化数据成员时,这些成员真正的初始化顺序并不一定与你在初始化列表中为它们安排的顺序一致,编译器总是按照它们在类中声明的次序来初始化的。因此最好是按照它们声明的顺序来书写成员初始化列表

不能同时定义一个无参数的构造函数和一个参数全部有默认值的构造函数,否则会造成二义性。

一般来说,重载的构造函数的行为都差不多,因此必然存在重复代码片段。当我们为类定义多个构造函数时,设法把其中相同任务的代码片段抽取出来并定义一个非public的成员函数,然后在每一个构造函数中适当的地方调用它。

注意不要将检查自赋值的if语句

if(this != &other) //地址相等才认为是一个对象

错写成

if(*this != other) //值相等不能作为自赋值的判断依据

在编写派生类的赋值函数时,注意不要忘记对基类的数据成员重新赋值,这可通过调用基类的赋值函数来实现。

class Base{
public:
	//...
	Base& operator=(const Base& other);

private:
	int m_i, m_j, m_k;
};

class Derived: public Base{
public:
	//...
	Derived& operator=(const Derived& other);

private:
	int m_x, m_y, m_z;
};

Derived& Derived::operator=(const Derived& other){
	//(1) 自赋值检查
	if(this != &other){
		// (2) 对基类的数据成员重新赋值
		Base::operator=(other); //因为不能直接操作基类的私有数据成员

		//(3) 对派生类的数据成员赋值
		m_x = other.m_x;
		m_y = other.m_y;
		m_z = other.m_z;
	}
	//(4) 返回本对象的引用
	return *this;
}

C++函数的高级特性

函数重载

只能靠参数列表而不能仅靠返回值类型的不同来区分重载函数。编译器根据参数列表为每个重载函数产生不同的内部标识符。

不同的编译器会产生不同风格的内部标识符,这就是不同厂商的编译器和连接器不能兼容的一个主要原因。如果C++程序要调用已经被编译的C函数,由于编译后的名字不同,C++程序不能直接调用编译后的C函数。C++提供了一个C连接交换指示符extern "C"来解决这个问题。

#ifdef __cplusplus
extern "C"{
#endif
void __cdecl foo(int x, int y);
//其他C函数
#ifdef __cplusplus
}
#endif
#ifdef __cplusplus
extern "C"{
#endif
#include "myheader.h"
//其他C头文件
#ifdef __cplusplus
}
#endif

C++编译器开发商已经对C标准库的头文件做了extern "C"处理,所以我们可以直接用#include引用这些头文件。

摆脱隐藏

class Base{
public:
	void f(int x);
};

class Derived: public Base{
public:
	void f(char *str);
};

void Test(void)
{
	Derived *pd = new Derived;
	pd->f(10); //错误:Base::f(int)被隐藏了
}

如果pd->f(10)确实想调用Base::f(int),那么有两种办法:其一就是使用using声明;其二就是通过调用转移。

class Derived: public Base{
public:
	using Base::f; //使用`using`声明
	void f(char *str);
};
class Derived: public Base{
public:
	void f(char *str);
	void f(int x) {Base::f(x);} //调用传递
};

参数的默认值

参数的默认值放在函数的声明中,而不要放在定义体中。

如果函数有多个参数,参数只能从后向前默认,否则将导致函数调用语句怪模怪样。

运算符重载

如果运算符被重载为全局函数,那么只有一个参数的运算符叫做一元运算符,有两个参数的运算符叫做二元运算符。

如果运算符被重载为类的成员函数,那么一元运算符没有参数(但是++和–的后置版本除外),二元运算符只有一个右侧参数,因为对象自己成了左侧参数。

运算符 规则
所有的一元运算符 建议重载为非静态成员函数
=()[]->* 只能重载为非静态成员函数
+=-=/=*=&=|=~=%=>>=<<= 建议重载为非静态成员函数
所有其他运算符 建议重载为全局函数

当为一个类型重载++--的前置版本时,不需要参数;当为一个类型重载++--的后置版本时,需要一个int类型的参数作为标志(即哑元,非具名参数)。

尽量选择前置版本来使用,可以减少临时对象的创建。

内联函数

C++的函数内联机制既具备宏代码的效率,又增加了安全性,而且可以自由操作类的数据成员。所以C++程序中应该尽量使用内联函数来取代宏代码。

内联函数的另一个优点是:函数被内联后,编译器就可以通过上下文相关的优化技术对结果代码执行更深入的优化。

**注意:关键字inline必须与函数定义体放在一起才能使函数真正内联,仅把inline放在函数声明的前面不起任何作用。**即inline是一种用于实现的关键字,而不是用于声明的关键字。

定义在类声明中的成员函数将自动内联。

以下情况不适合使用内联:

  • 函数体内代码较长,使用内联会导致代码膨胀。
  • 函数体内出现循环或者其他复杂的控制结构,那么执行函数体内的代码的时间将比函数调用的开销大得多,内联意义不大。

类型转换函数

类型转换的本质是创建新的目标对象,并以源对象的值来初始化,所以源对象没有丝毫改变。不要把类型转换理解为”将源对象的类型转换为目标类型“。

在C++程序中尽量不要再使用C风格的类型转换,除非源对象和目标类型都是基本类型的对象或指针,否则很不安全。

const成员函数

任何不会修改数据成员的成员函数都应该声明为const类型。如果在编写const成员函数时不慎写下了试图修改数据成员的代码,或者调用了其他非const成员函数,编译器将指出错误。

static成员函数不能定义为const的,因为static成员函数只是全局函数的一个形式上的封装,而全局函数不存在const一说;何况static成员函数不能访问类的非静态成员(没有this指针),修改非静态数据成员又从何说起呢?

C++异常处理机制和RTTI

C++异常处理

C++保证:如果一个异常在抛出点没有得到处理,那么它将一直被抛向上层调用者,直至main()函数,直至找到一个类型匹配的异常处理器,否则调用terminate()结束程序。

异常处理机制的本质:在真正导致错误的语句即将执行之前,并且异常发生的条件已经具备时,使用我们自定义的软件异常(异常对象)来替代它,从而阻止它。因此,当异常抛出时,真正的错误实际上并未发生。

class DevideByZero {};

double Devide(double a, double b)
{
	if(abs(a) < std::numeric_limits<double>::epsilon())
		throw DevideByZero();//提前检测异常发生条件并抛出自定义异常
	return a/b;
}

void test()
{
	double x = 100, y = 20.5;
	try{
		cout << Devide(x,y) << endl; //可能抛出异常DevideByZero
	}
	catch(DevideByZero&){
		cerr << "Devided by zero!" << endl;
	}
}

异常类型和异常对象

任何一种类型都可以当作异常类型,因此任何一个对象都可以当作异常对象,包括基本数据类型的变量、常量、任何类型的指针、引用、结构等,甚至空结构或空类的对象。这是因为异常仅仅通过类型而不是通过值来匹配的。

class DevideByZero {
public:
	DevideByZero(const char *p);
	const char* description();
	//...
private:
	char *desp;
};

double Devide(double a, double b)
{
	if(abs(a) < std::numeric_limits<double>::epsilon())
		throw DevideByZero("The divisor is 0.");//提前检测异常发生条件并抛出自定义异常
	return a/b;
}

void test()
{
	double x = 100, y = 20.5;
	try{
		cout << Devide(x,y) << endl; //可能抛出异常DevideByZero
	}
	catch(DevideByZero& ex){
		cerr << ex.description() << endl;
	}
}

异常抛出点可能深埋在底层软件模块内,而异常捕获点常常在高层组件中。

在一个函数内尽量不要出现多个并列的try块,也不要使用嵌套的try块,否则不仅会导致程序结构复杂化,增加运行时的开销,而且容易出现逻辑错误。

每一个try块后必须至少跟一个catch块。当异常抛出时,C++异常处理机制将从碰到的第一个catch块开始匹配,直到找到一个类型符合的catch块为止,紧接着执行该catch块内的代码。当异常处理完毕后,将跳过后面一系列catch块,接着执行后面的正常代码。

由于异常处理机制采用类型匹配而不是值判断,因此catch块的参数可以没有参数名称,只需要参数类型,除非确实要使用那个异常对象。

异常的类型匹配规则

C++规定,当一个异常对象和catch子句的参数类型复合下列条件时,匹配成功:

  • 如果catch子句参数的类型就是异常对象的类型或其引用;
  • 如果catch子句参数类型时异常对象所属类型的public基类或其引用。
  • 如果catch子句参数类型为public基类指针,而异常对象为派生类指针。
  • catch子句参数类型为void *,而异常对象为任何类型指针。
  • catch子句为catch-all,即catch(...)

异常说明及其冲突

在使用了C++异常处理机制的环境中,应当使用函数异常说明,以告诉函数的调用者该函数可能抛出哪些类型的异常,以便用户能够编写合适的异常处理器。

函数异常说明示例如下:

double Devide(double x, double y) throw(DevidedByZero); //(1)只可能抛出一种异常
bool func(const char *) throw(T1, T2, T3) //(2) 可能抛出3种异常
void g() throw() //(2) 不抛出任何异常
void k(); //(4)可能抛出任何异常,也可能不抛出任何异常

当异常抛出时局部对象如何释放

当异常抛出时,异常处理机制保证:所有从trythrow语句之间构造起来的局部对象的析构函数将被调用(以与构造相反的顺序),然后清退堆栈(就像函数正常退出那样)。

如何使用好异常处理技术

如果不使用异常处理机制就能够安全而高效地消除错误,那么就不要使用异常处理。

catch块的参数应当采用引用传递而不是值传递。原因之一:异常对象可能会在调用链中上溯好几个层次才能遇到匹配的处理块,显然引用传递比值传递的效率高得多;原因二:这样可以利用异常对象的多态性,因为异常处理类型可能是多态类,你可以抛出一个异常对象的地址,那么catch块中的参数就应该是异常类型的指针。

在异常组合中,要合理安排异常处理的层次:一定要把派生类的异常捕获放在基类异常捕获的前面,否则派生类异常匹配永远也不会执行到。

如果实在无法判断到底会有什么异常抛出,那就使用”一网打尽“策略:catch(void *)catch(...)。但是要记住:catch(void *)catch(...)必须放在异常组合的最后面,并且catch(void *)放在catch(...)的前面。

C++的标准异常

头文件 异常类型
exception, bad_exception
bad_alloc
bad_cast, bad_typeid
logic_error, runtime_error, domain_error, invalid_argument, length_error, out_of_range, range_error,overflow_error,underflow_error

RTTI

RTTI(Run-time Type Identification)

RTTI和虚函数并非一回事!实际上虚函数的动态绑定并没有使用对象的type_info信息。

有了RTTI之后,就能够在运行时查询一个多态指针或引用指向的具体对象的类型了。为了能够在运行时获得对象的类型信息type_info,C++增加了两个运算符:typeiddynamic_cast<>

typeid运算符

typeid运算符和sizeof一样是C++语言直接支持的,它以一个对象或者类型名作为参数,返回一个匹配的const type_info对象,表明该对象的确切类型。

如果试图用typeid来检索NULL指针所指对象的类型信息

typeid(*p); //p==NULL

将抛出std::bad_typeid异常。

dynamic_cast<>运算符

可以看出,typeid()不具备可拓展性,因为它返回一个对象的确切类型而不是基类型。一个派生类对象在语义上也应该是其基类型的对象(如果是public继承),然而typeid()不具备这种能力。

dynamic_cast<dest_type>(src);

其中,dest_type就是转换的目标类型,而src则是被转换的目标(注意dynamic_cast<>可以用来转换指针和引用,但是不能转换对象)。如果运行时srcdest_type确实存在is-a关系,则转换可进行;否则转换失败。

当目标类型是某种类型的指针(包括void*)时,如果转换成功则返回目标类型的指针,否则返回NULL;当目标类型为某种类型的引用时,如果成功则返回目标类型的引用,否则抛出std::bad_cast异常。

dynamic_cast<>只能用于多态类型对象(拥有虚函数或虚拟继承),否则将导致编译时错误。

dynamic_cast<>可以实现两个方向的转换:upcastdowncast

  • upcast:把派生类型的指针、引用转换为基类型的指针或引用(实际上这可以隐式的进行,不必显式地转换)。
  • downcast:把基类型的指针或引用转换称为派生类型的指针或引用。如果这个基类型的指针或引用确实指向一个这种派生类的对象,那么转换就会成功;否则转换就会失败。

内存管理

有了malloc/free为什么还要new/delete

由于malloc()/free()是库函数而不是运算符,不在编译器控制权限之内,不能把调用构造函数和析构函数得任务强加给它们。因此,C++语言需要一个能够完成动态内存分配和初始化工作的运算符new,以及一个能够完成清理和释放内存工作的运算符delete

class Obj{
public:
	Obj() {cout << "constructor" << endl;}
	~Obj() {cout << "destroy" << endl;}
	void Initialize(void) {cout << "initialize" << endl;}
	void Destroy(void) {cout << "destroy" << endl;}	
};

void usermallocfree(void)
{
	Obj *a = (Obj*)malloc(sizeof(Obj)); //申请动态内存
	a->Initialize(); //初始化
	//...
	a->Destroy(); //清除工作
	free(a); //释放内存
}

void UseNewDelete(void)
{
	Obj *a = new Obj; //申请动态内存并调用构造函数来初始化
	//...
	delete a; //调用析构函数并且释放内存
}

new有3种使用方式

plain newnothrow newplacement new

plain new/delete

字面意思,就是普通的new,也就是我们最常用的那种new,没有任何附加成分。它们在中是这样定义的:

void * operator new(std::size_t) throw(std::bad_alloc);
void operator delete(void *) throw();

标准C++修改了new的语义,plain new在失败后抛出标准异常std::bad_alloc而不是返回NULL

char *GetMemory(unsigned long size)
{
	char *p = new char[size];
	return p;
}

void main(void)
{
	try{
		char *p = GetMemory(1000000); //可能抛出bad_alloc异常
		//...
		delete p;
	}
	catch(const std::bad_alloc& ex){
		cout << ex.what() << endl;
	}
}

nothrow new/delete

顾名思义,nothrow new就是不抛出异常的运算符new形式,在失败时返回NULL

void func(unsigned long length)
{
	unsigned char *p = new(nothrow) unsigned char[length];
	if(p==NULL) cout << "allocate failed!" << endl;
	//...
	delete []p;
}

palcement new/delete

placement意为“放置”,这种new形式允许在一块已经分配成功的内存上重新构造对象或者对象数组。显然placement new不用担心内存分配失败,因为它根本就不会分配内存,它所做的唯一一件事情就是调用对象的构造函数。语法如下:

type-name *q = new(p) type-name;

其中p就是已经分配成功的内存区的首地址,它被转换为目标类型的指针q,因此pq相等。

#include 
#include 

void main(void)
{
	using namespace std;
	char *p = new(nothrow) char[4]; //nothrow new
	if(p == NULL){
		cout << "allocate failed!" << endl;
		exit(-1);
	}
	//...
	long *q = new(p) long(1000); //placement new
	//...
	delete []p;//释放内存 
}

placement new的主要用途就是:反复使用一块较大的动态分配成功的内存来构造不同类型的对象或者它们的数组。

char *p = new(nothrow) char[100]; //nothrow new
if(p == NULL){
	cout << "allocate failed!" << endl;
	exit(-1)
}

//...
long *q1 = new(p) long(88); //placement new; 不必担心失败
//...
int *q2 = new(p) int[100/sizeof(int)]; //placement new 数组
delete []p;

由于使用placement new构造起来的对象或其数组的大小并不一定等于原来分配的内存大小,因此在结束使用时需要注意防止内存泄漏,对于复合类型需要显式调用析构函数。

char *p = new(nothrow) char[sizeof(ADT)+2]; //nothrow new
if(p == NULL){
	cout << "allocate failed!" << endl;
	exit(-1)
}

ADT *q = new(p) ADT; //placement new
//...
//delete q; //错误!不能在此处调用delete q;
q->ADT::~ADT(); //显式调用析构函数
delete []p;
new/delete类型 plain nothrow placement
对象 new type-name;delete p; new(nothrow) type-name; delete p; new(p) type-name; delete p;
对象数组 new type-name[x]; delete []p; new(nothrow) type-name[x]; delete []p; new(p) typename[x]; delete[] p;

学习和使用STL

STL主要包括如下组件:I/O流、string类、容器类(Container)、迭代器(Iterator)、存储分配器(Allocator)、适配器(Adapter)、函数对象(Functor)、泛型算法(Algorithm)、数值运算、国际化和本地化支持,以及标准异常类等。

《高质量程序设计指南--C/C++语言》学习笔记_第8张图片

头文件 内容
元素类型为T的向量,包括了特化vector
元素类型为T的双向链表
元素类型为T的双端队列
元素类型为T的普通队列,包括了priority_queue
元素类型为T的堆栈
元素类型为T的映射
元素类型为T的集合
布尔值的集合(实际上不是真正意义上的集合)
元素类型为Thash映射
元素类型为Thash集合

注意:stackqueuepriority_queue在概念上和接口上都不支持随机访问和遍历,这是由它们的语义决定的,而不是由底层存储方式决定的,因此没有迭代器(所以它们才被叫做容器适配器而不是归类为容器类)。

泛型算法定义在头文件中。

迭代器定义在头文件中。

STL有一些专门为数学运算设计的类和算法,定义在下表所示头文件中:
|头文件|内容|
|| 复数及其相关操作|
||数值向量及其相关操作|
||通用数学运算|
||常用数值类型的极限值和精度等|

容器设计原理

由于红黑树(平衡二叉搜索树的一种)在元素定位上的优异性能(O(log2N)),STL通常用它来实现关联式容器。

《高质量程序设计指南--C/C++语言》学习笔记_第9张图片

迭代器

迭代器是为了降低容器和泛型算法之间的耦合性而设计的,泛型算法的参数不是容器,而是迭代器。

迭代器屏蔽了底层存储空间的不连续性,在上层使容器元素维持一种“逻辑连续”的假象。

指针代表真正的内存地址,即对象在内存中的存储位置;而迭代器则代表元素在容器中的相对位置(当遍历容器的时候,关联式容器的元素也就具有了“相对位置”)。

泛型算法可以根据不同类别的迭代器所具有的不同能力来实现不同性能的版本,使得能力大的迭代器用于这些算法时具有更高的效率。

适配器

适配器往往是利用一种已有的比较通用的数据结构(通过组合而非继承)来实现更加具体的、更加贴近实际应用的数据结构。

容器适配器stackqueuepriority_queue

迭代器适配器

  • 插入式迭代器包括back_insert_iteratorfront_insert_iteratorinsert_iterator。对一个back_insert_iterator执行赋值操作(operator=)就相当于对其绑定的容器执行push_back()操作。对一个front_insert_iterator执行赋值操作(operator=)就相当于对其绑定的容器执行push_front()操作。而对一个insert_iterator执行赋值操作(operator=)就相当于对其绑定的容器执行insert()操作。
  • 输出流迭代器(ostream_iterator)则通过绑定一个ostream对象来完成批量输出功能,即内部维护一个ostream对象,并将赋值操作(operator =)转换为该ostream对象的运算符operator <<的调用。
  • 输入流迭代器(istream_iterator)则通过绑定一个istream对象来完成批量输入功能,并将前进操作(++)转换为 istream 对象的运算符operator >>的调用。
  • 反向迭代器(Reverse Iterator)用于将一个指定的迭代器的迭代行为反转(前进变为后退,后退变前进)。容器具有的rbegin()rend()方法返回的就是这种类型的迭代器。
list<int> li;
for(int k = 0; k < 10; k++){
	li.push_back(k);
}
copy(li.first(), li.end(), ostream_iterator<int>(cout, " "));

泛型算法

STL定义了一套丰富的泛型算法,可施行于容器或其他序列上,它们不依赖具体容器的类型和元素的数据类型。

迭代器就像算法和容器的中间人。作为算法,它并不关心所操作的数据对象在容器中的什么位置,也不必知道容器的类型甚至数据对象的类型,它所要做的工作就是“改变迭代器,并按照用户指定的方式(即函数对象或谓词,可以没有),逐个地对迭代器指向的对象进行定制地操作。

STL提供的泛型算法主要有如下几种:

  • 查找算法:如find()search()binary_search()find_if()等。
  • 排序算法:如sort()merge()等。
  • 数学计算:如accumulate()inner_product()partial_sum()等。
  • 集合运算:如set_union()set_intersection()includes()等。
  • 容器管理:如copy()replace()transform()remove()for_each()等。
  • 统计运算:如max()min()count()max_element()等。
  • 堆管理:如make_heap()push_heap()pop_heap()sort_heap()
  • 比较运算:如equal()等。

很多泛型算法都假定容器的元素类型定义了operator =()operator ==()operator !=()operator <()operator >()等函数,因此你有义务为你的容器元素定义它们,否则泛型算法将采用元素类型的默认语义或者报错。

STL提供了最常用的算法,但是有时候可能需要编写自己的算法,应该尽可能与STL框架无缝结合。例如下面是基于STL框架实现的”折半“查找算法。

template<typename RandomAccessIterator, typename T>
RandomAccessIterator binary_search(RandomAccessIterator first, 
								   RandomAccessIterator last,
								   const T& value)
{
	RandomAccessIterator mid, not_found = last;
	while(first != last){
		mid = first + (last - first)/2;
		if(!(value<*mid) && !(*mid<value))
			return mid;
		if(value < *mid)
			last = mid;
		else
			first = mid + 1;
	}
	return not_found;
}

算法内部对迭代器类别(Category)的识别是通过Iterator Traits技术从传入的迭代器对象中萃取出来的。

当容器的方法和泛型算法都可以完成一项工作时,选择容器本身的方法。

STL使用心得

当元素的有序比搜索速度更重要时,应选用setmultisetmapmultimap。否则选用hash_sethash_maphash_multimap

往容器中插入元素时,若元素在容器中的顺序无关紧要,请尽量加在最后面。若经常需要在序列容器的开头和中间增加或删除元素时,应该选用list

对关联式容器而言,尽量不要使用C风格的字符串(即字符指针)作为键值。如果非用不可,应显式的定义字符串比较运算符,即operator<operator==operator<=等。

经典C/C++试题

1、计算sizeof表达式和strlen表达式的值。

char s1[] = ""; 
char s2[] = "Hello World"; 
char *p = s2; 
char *q = NULL; 
void *r = malloc(100); 

《高质量程序设计指南--C/C++语言》学习笔记_第10张图片

char s1[10] = {'m', 'o', 'b', 'i', 'l'};
char s2[20] = {'A', 'N', 'S', 'I', '\0', 'C', '+', '+'};
char s3[6] = {'I', 'S', 'O', 'C', '+', '+'};

cout << "strlen(s1) = " << strlen(s1) << endl; //5
cout << "strlen(s2) = " << strlen(s2) << endl; //4
cout << "strlen(s3) = " << strlen(s3) << endl; //不确定

上面的strlen是统计到\0截至,对于s1,数组后面的位置元素自动初始化为\0。对于s3,输出的strlen(s3)与存储位置后面何时出现\0有关。

void Func(char str[100])
{
	cout << "sizeof(str) = " << sizeof(str) << endl; //4
}

2、写出boolfloat、指针变量与“零值”比较的if语句。

if(flag)
if(!flag)

//精度要求根据应用要求而定
const float EPSILON = 1e-6;
if( (x >= -EPSILON) && (x <= EPSILON))

if(p == nullptr)
if(p != nullptr)

3、在C++程序中调用C编译器编译后的函数,为什么要加extern C

C++语言支持函数重载,C语言支持函数重载。函数被C++编译器编译和被C编译器编译后生成的内部名字是不同的。C++提供了C连接交换指定符号extern C来解决名字匹配问题(即二进制兼容问题)。

4、下面两个输出语句的结果是否相同?

double d = 100.25;
int x = d;
int *pInt = (int *)&d;

cout << "x = " << x << endl;
cout << "*pInt = " << *pInt << endl;

两个输出结果不相同。第一个结果为100,xd的整数部分;第二个结果不是100,*pInt等于d的前4个字节的数值,而不是d的整数部分。

5、已知strcpy的原型为char *strcpy(char *strDest, char *strSrc);,请编写函数strcpy,并解释为何要返回char *

{
	assert((strDest != nullptr) && (strSrc != nullptr));
	char *address = strDest;
	while((*strDest++ = *strSrc++) != '\0')
		NULL;
	return address;
}

返回char *的返回值是为了实现链式表达式。

int length = strlen(strcpy(strDest, "hello world"));

6、编写String类的构造函数,析构函数和赋值函数

String::String(const char *str){
	if(str == nullptr){
		m_data = new char[1];
		*m_data = '\0';
	}else{
		int length = strlen(str);
		m_data = new char[length+1];
		strcpy(m_data, str);
	}
}

String::~String(){
	delete[] m_data;
}

String::String(const String &other){
	int length = strlen(other.m_data);
	m_data = new char[length+1];
	strcpy(m_data, other.m_data);
}

String & String::operator =(const String& other){
	//检查自赋值
	if(this != &other){
		char *temp = new char[strlen(other.m_data)+1];
		strcpy(temp, other.m_data);
		//释放原有资源
		delete[] m_data;
		m_data = temp;
	}

	return *this;
}

你可能感兴趣的:(C++)