文章目录
全文约 12566 字,预计阅读时长: 36分钟
一 版权声明
上海贝尔网络应用-林锐
二 做题打分
三 博士的前言总结
- 知错就改;
- 经常温故而知新;
- 坚持学习,天天向上。
第一二章:
- 每个C++/C程序通常分为两个文件。一个文件用于保存程序的声明(declaration),称为头文件。另一个文件用于保存程序的实现(implementation),称为定义(definition)文件。
- C++/C 程序的头文件以
“.h”
为后缀,C程序的定义文件以“.c”
为后缀,C++程序的定义文件通常以“.cpp”
为后缀(也有一些系统以“.cc”或“.cxx”为后缀)。
防止头文件被重复包含
# ifndef famer_h
# define famer_h
//..
# endif
<>
头文件,编译器将引用标准库的头文件,从标准目录开始搜索、“”
头文件,将从用户的工作目录开始搜索,用户自己创建的头文件- 头文件只存放声明,不存放定义
- 不建议使用全局变量
#ifndef GRAPHICS_H // 防止 graphics.h 被重复引用
#define GRAPHICS_H
#include <math.h> // 引用标准库的头文件
#include “myheader.h” // 引用非标准库的头文件
void Function1(⋯); // 全局函数声明
class Box //类结构声明
{
⋯
};
#endif
目录结构
- 如果一个软件的头文件数目比较多(如超过十个),通常应将头文件和定义文件分
- 别保存于不同的目录,以便于维护。
- 例如可将头文件保存于 include 目录,将定义文件保存于 source 目录(可以是多级目录)。
- 如果某些头文件是私有的,它不会被用户的程序直接引用,则没有必要公开其“声明”。为了加强信息隐藏,这些私有的头文件可以和定义文件存放于同一个目录
程序的版式风格
清晰、美观,是程序风格的重要构成因素
- 空行起着分隔程序段落的作用。
- 空行不会浪费内存
- 【规则 2-1-1】在每个类声明之后、每个函数定义结束之后都要加空行。
- 【规则 2-1-2】在一个函数体内,逻揖上密切相关的语句之间不加空行,其它地方应加空行分隔。
- 示例 2-1(a) 函数之间的空行:
// 空行
void Function1(⋯)
{
⋯
}
// 空行
void Function2(⋯)
{
⋯
}
// 空行
void Function3(⋯)
{
⋯
}
- 示例 2-1(b) 函数内部的空行:
// 空行
while (condition)
{
statement1;
// 空行
if (condition)
{
statement2;
}
else
{
statement3;
}
// 空行
statement4;
}
代码行
一行代码只做一件事情,如只定义一个变量,或只写一条语句。这样的代码容易阅读,并且方便于写注释。
if、for、while、do
等语句自占一行,执行语句不得紧跟其后。- 不论执行语句有多少都要加
{}
。这样可以防止书写失误。 - 关键字之后要留空格。像
const、virtual、inline、case
等关键字之后至少要留一个空格,否则无法辨析关键字。象if、for、while
等关键字之后应留一个空格再跟左括号‘(’
,以突出关键字。 - 【规则 2-3-4】
‘,’
之后要留空格,如Function(x, y, z)
。如果‘;’
不是一行的结束符号,其后要留空格,如for(initialization; condition; update)
。 - 【规则 2-3-5】赋值操作符、比较操作符、算术操作符、逻辑操作符、位域操作符,如
“=”、“+=” “>=”、“<=”、“+”、“*”、“%”、“&&”、“||”、“<<”,“^”
等二元操作符的前后应当加空格。*
对齐
- 【规则 2-4-1】程序的分界符
‘{’
和‘}’
应独占一行并且位于同一列,同时与引用它们的语句左对齐。- 【规则 2-4-2】
{ }
之内的代码块在‘{’
右边数格处左对齐
- 【规则 2-4-2】
长行拆分
- 【规则 2-5-1】代码行最大长度宜控制在 70 至 80 个字符以内。代码行不要过长,否则眼睛看不过来,也不便于打印。
- 【规则 2-5-2】长表达式要在低优先级操作符处拆分成新行,操作符放在新行之首(以便突出操作符)。拆分出的新行要进行适当的缩进,使排版整齐,语句可读。
- 【规则 2-6-1】应当将修饰符
* 和 &
紧靠变量名。 - 【规则 2-7-2】如果代码本来就是清楚的,则不必加注释。否则多此一举,令人厌烦。注释的花样要少
if ((very_longer_variable1 >= very_longer_variable12)
&& (very_longer_variable3 <= very_longer_variable14)
&& (very_longer_variable5 <= very_longer_variable16))
{
dosomething();
}
//..
virtual CMatrix CMultiplyMatrix (CMatrix leftMatrix,
CMatrix rightMatrix);
//..
for (very_longer_initialization;
very_longer_condition;
very_longer_update)
{
dosomething();
}
- 【规则2-6-1】应当将修饰符
*
和&
紧靠变量名。例如:
char *name;
int *x, y; // 此处y 不会被误解为指针
类的版式
- 类可以将数据和函数封装在一起,其中函数表示了类的行为(或称服务)。
- 类提供关键字
public、protected 和 private
,分别用于声明哪些数据和函数是公有的、受保护的或者是私有的。- 这样可以达到信息隐藏的目的,即让类仅仅公开必须要让外界知道的内容,而隐藏其它一切内容。我们不可以滥用类的封装功能,不要把它当成火锅,什么东西都往里扔。
- 将 private 类型的数据写在前面,而将 public 类型的函数写在后面,“以数据为中心”,重点关注类的内部结构。
- 将 public 类型的函数写在前面,而将 private 类型的数据写在后面,“以行为为中心”,重点关注的是类应该提供什么样的接口(或服务)。
- 建议“以行为为中心”的书写方式.
第三章:命名规则
- 【规则 3-1-1】标识符应当直观且可以拼读,可望文知意,不必进行“解码”。标识符最好采用英文单词或其组合,便于记忆和阅读。
- 【规则 3-1-2】标识符的长度应当符合
“min-length && max-information”
原则。 - 【规则 3-1-3】命名规则尽量与所采用的操作系统或开发工具的风格保持一致
Windows 简单的命名规则
- Windows 应用程序的标识符通常采用 “大小写” 混排的方式,如
AddChild
。而 Unix 应用程序的标识符通常采用 “小写加下划线” 的方式,如add_child
。 - 【规则 3-1-6】变量的名字应当使用 “名词” 或者 “形容词+名词”。
float value;
float oldValue;
float newValue;
- 【规则 3-1-7】全局函数的名字应当使用 “动词” 或者 “动词+名词”(动宾词组)。
DrawBox(); // 全局函数
box->Draw(); // 类的成员函数
- 【规则 3-1-8】用正确的反义词组命名具有互斥意义的变量或相反动作的函数等。
//例如:
int minValue;
int maxValue;
int SetValue(…);
int GetValue(…)
- 【规则 3-2-1】类名和函数名用大写字母开头的单词组合而成。
class Node; //类名
class LeafNode; //类名
void Draw(void); //函数名
void SetValue(int value); // 函数名
-
【规则 3-2-3】常量全用大写的字母,用下划线分割单词。
-
【规则 3-2-4】静态变量加前缀
s_
(表示 static)。 -
【规则 3-2-5】如果不得已需要全局变量,则使全局变量加前缀
g_
(表示 global)。
-
【规则 3-2-6】类的数据成员加前缀
m_
(表示 member),这样可以避免 数据成员 与 _成员函数的参数同名。
void Object::SetValue(int width, int height)
{
m_width = width;
m_height = height;
}
- 【规则 3-2-7】为了防止某一软件库中的一些标识符和其它软件库中的冲突,可以为各种标识符加上能反映软件性质的前缀。例如三维图形标准 OpenGL 的所有库函数均以 gl 开头,所有常量(或宏定义)均以 GL 开头。
简单的 Unix 应用程序命名规则
- 这是个迷
第 4 章 表达式和基本语句
运算符的优先级
-
C++/C 语言的运算符有数十个,运算符的优先级与结合律如表 4-1 所示。注意一元运算符
+ - *
的优先级高于对应的二元运算符。
-
【规则 4-1-1】如果代码行中的运算符比较多,用括号确定表达式的操作顺序,避免使用默认的优先级。
//例如:
word = (high << 8) | low
if ((a | b) && (a & c))
复合表达式
-
如 a = b = c = 0 这样的表达式称为复合表达式。允许复合表达式存在的理由是:(1)书写简洁;(2)可以提高编译效率。但要防止滥用复合表达式。
-
【规则 4-2-1】不要编写太复杂的复合表达式。例如:
i = a >= b && c < d && c + f <= g + h
; …复合表达式过于复杂。 -
【规则 4-2-2】不要有多用途的复合表达式。例如:
d = (a = b + c) + r ;
该表达式既求 a 值又求 d 值。应该拆分为两个独立的语句:
a = b + c;
d = a + r;
- 【规则 4-2-3】不要把程序中的复合表达式与“真正的数学表达式”混淆。 例如:
if (a < b < c) // a < b < c
是数学表达式而不是程序表达式- 并不表示
if ((a<b) && (b<c))
;而是成了令人费解的if ( (a<b)<c )
。
if语句
_本节以“与零值比较”为例,展开讨论。
布尔变量与零值比较
- 【规则 4-3-1】不可将布尔变量直接与 TRUE、FALSE 或者 1、0 进行比较。
- 根据布尔类型的语义,零值为“假”(记为 FALSE),任何非零值都是“真”(记为TRUE)。
- TRUE 的值究竟是什么并没有统一的标准。例如 Visual C++ 将 TRUE 定义为1,而 Visual Basic 则将 TRUE 定义为-1。
假设布尔变量名字为 flag,它与零值比较的标准 if 语句如下:
if (flag) //表示 flag 为真
if (!flag) //表示 flag 为假
整型变量与零值比较
- 【规则 4-3-2】应当将整型变量用“==”或“!=”直接与 0 比较。假设整型变量的名字为 value,它与零值比较的标准 if 语句如下:
if (value == 0)
if (value != 0)
浮点变量与零值比较
- 【规则 4-3-3】不可将浮点变量用“==”或“!=”与任何数字比较。
- 千万要留意,无论是 float 还是 double 类型的变量,都有精度限制。
- 所以一定要避免将浮点变量用“==”或“!=”与数字比较,应该设法转化成“>=”或“<=”形式。
- 假设浮点变量的名字为 x,应当将 if (x == 0.0) 隐含错误的比较
//转化为
if ((x>=-EPSINON) && (x<=EPSINON))
//其中 EPSINON 是允许的误差(即精度)。
指针变量与零值比较
- 【规则 4-3-4】应当将指针变量用“==”或“!=”与 NULL 比较。指针变量的零值是“空”(记为 NULL
- 尽管 NULL 的值与 0 相同但是两者意义不同。假设指针变量的名字为 p,它与零值比较的标准 if 语句如下:
if (p == NULL) // p 与 NULL 显式比较,强调 p 是指针变量
if (p != NULL)
4.4 循环语句的效率
本节重点论述循环体的效率。提高循环体效率的基本办法是降低循环体的复杂性。
- C++/C 循环语句中,for 语句使用频率最高,while 语句其次,do 语句很少用。
- 【建议 4-4-1】在多重循环中,如果有可能,应当将最长的循环放在最内层,最短的循环放在最外层,以减少 CPU 跨切循环层的次数。
for (col=0; col<5; col++ )
{
for (row=0; row<100; row++)
{
sum = sum + a[row][col];
}
}
- 【建议 4-4-2】如果循环体内存在逻辑判断,并且循环次数很大,宜将逻辑判断移到循环体的外面。
- 示例 C 的程序比示例 D 多执行了 N-1 次逻辑判断。并且由于前者老要进行逻辑判断,打断了循环“流水线”作业,使得编译器不能对循环进行优化处理,降低了效率。
//C
for (i=0; i<N; i++)
{
if (condition)
DoSomething();
else
DoOtherthing();
}
//D
if (condition)
{
for (i=0; i<N; i++)
DoSomething();
}
else
{
for (i=0; i<N; i++)
DoOtherthing();
}
4.5 for 语句的循环控制变量
- 【规则 4-5-1】不可在 for 循环体内修改循环变量,防止 for 循环失去控制。
4.6 switch 语句
- switch 是多分支选择语句,而 if 语句只有两个分支可供选择。
- 虽然可以用嵌套的if 语句来实现多分支选择,但那样的程序冗长难读。这是 switch 语句存在的理由。
switch (variable)
{
case value1:
break;
case value2:
break;
default:
break;
}
- 【规则 4-6-1】每个 case 语句的结尾不要忘了加 break,否则将导致多个分支重叠(除非有意使多个分支重叠)。
- 【规则 4-6-2】不要忘记最后那个 default 分支。即使程序真的不需要 default 处理,也应该保留语句 default : break; 这样做并非多此一举,而是为了防止别人误以为你忘了 default 处理。
4.7 goto 语句
- 自从提倡结构化设计以来,goto就成了有争议的语句。首先,由于goto语句可以灵活跳转,如果不加限制,它的确会破坏结构化设计风格。其次,goto语句经常带来错误或隐患。它可能跳过了某些对象的构造、变量的初始化、重要的计算等语句,例如:
goto state;
String s1, s2; // 被 goto 跳过
int sum = 0; // 被 goto 跳过
state:
- 如果编译器不能发觉此类错误,每用一次goto语句都可能留下隐患。
很多人建议废除C++/C 的 goto 语句,以绝后患。但实事求是地说,错误是程序员自己造成的,不是 goto 的过错。goto 语句至少有一处可显神通,它能从多重循环体中咻地一下子跳到外面,用不着写很多次的 break 语句; 例如:
{ ⋯
{ ⋯
{⋯
goto error;
}
}
}
error:
就象楼房着火了,来不及从楼梯一级一级往下走,可从窗口跳出火坑。所以我们主张少用、慎用goto语句,而不是禁用。
第五章 常量
5.1 为什么需要常量
- 如果不使用常量,直接在程序中填写数字或字符串,将会有什么麻烦?
- 程序的可读性(可理解性)变差。程序员自己会忘记那些数字或字符什么意思,用户则更加不知它们从何处来、表示什么。
- 在程序的很多地方输入同样的数字或字符串,难保不发生书写错误。
- 如果要修改数字或字符串,则会在很多地方改动,既麻烦又容易出错。
5.2 const 与 #define 的比较
- const 常量有数据类型,而宏常量没有数据类型。编译器可以对前者进行类型安全检查。而对后者只进行字符替换,没有类型安全检查,并且在字符替换可能会产生意料不到的错误(边际效应)。
- 有些集成化的调试工具可以对 const 常量进行调试,但是不能对宏常量进行调试。
- 【规则 5-2-1】在 C++ 程序中只使用 const 常量而不使用宏常量,即 const 常量完全取代宏常量。
5.3 常量定义规则
- 【规则 5-3-1】需要对外公开的常量放在头文件中,不需要对外公开的常量在定义文件的头部。为便于管理,可以把不同模块的常量集中存放在一个公共的头文件中。
- 【规则 5-3-2】如果某一常量与其它常量密切相关,应在定义中包含这种关系,而不应给出一些孤立的值。例如:
const float RADIUS = 100;
const float DIAMETER = RADIUS * 2;
5.4 类中的常量
- 枚举常量不会占用对象的存储空间,它们在编译时被全部求值。枚举常量的缺点是:它的隐含数据类型是整数,其最大值有限,且不能表示浮点数(如 PI=3.14159)。
第六章 函数设计
- 这里有一个很细的讲解:【C语言从青铜到王者】第二篇·详解函数
- C 语言中,函数的参数和返回值的传递方式有两种
- 值传递(pass by value)
- 指针传递(pass by pointer)
- C++ 语言中多了引用传递(pass by reference)。由于引用传递的性质像指针传递,而使用方式却像值传递。
参数的规则
- 【规则 6-1-1】参数的书写要完整,不要贪图省事只写参数的类型而省略参数名字。如果函数没有参数,则用 void 填充
- 【规则 6-1-2】参数命名要恰当,顺序要合理。
void StringCopy( char *strDestination,char *strSource)
;
- 【规则 6-1-3】如果参数是指针,且仅作输入用,则应在类型前加 const,以防止该指针在函数体内被意外修改。
void StringCopy(char *strDestination,const char *strSource);
- 【规则 6-1-4】如果输入参数以值传递的方式传递对象,则宜改用“
const &”
方式来传递,这样可以省去临时对象的构造和析构过程,从而提高效率。
6.2 返回值的规则
- 【规则 6-2-1】不要省略返回值的类型
- C 语言中,凡不加类型说明的函数,一律自动按整型处理。这样做不会有什么好处,却容易被误解为 void 类型
- C++语言有很严格的类型安全检查,不允许上述情况发生。由于C++程序可以调用C 函数,为了避免混乱,规定任何 C++/ C 函数都必须有类型。
- 如果函数没有返回值,那么应声明为 void 类型
- 如果 getchar 碰到文件结束标志或发生读错误,它必须返回一个标志 EOF。为了区别于正常的字符,只好将 EOF 定义为负数(通常为负 1)。因此函数 getchar 就成了 int 类型。
6.3 函数内部实现的规则
-
函数的出口入口处对参数的有效性进行检查
-
编译器直接把临时对象创建并初始化在外部存储单元中,省去了拷贝和析构的化费,提高了效率。
6.4 其他建议
- 【建议 6-4-1】函数的功能要单一,不要设计多用途的函数。
- 【建议 6-4-2】函数体的规模要小,尽量控制在 50 行代码之内。
- 建议 6-4-3】尽量避免函数带有“记忆”功能。如:static
6.5 使用断言
-
程序一般分为 Debug 版本和 Release 版本,Debug 版本用于内部调试,Release 版本发行给用户使用。
-
断言 assert 是仅在 Debug 版本起作用的宏,它用于检查“不应该”发生的情况。
-
assert 不是一个仓促拼凑起来的宏。为了不在程序的Debug版本和Release版本引起差别,assert 不应该产生任何副作用。所以assert不是函数,而是宏。程序员可以把 assert 看成一个在任何系统状态下都可以安全使用的无害测试手段。
-
【建议6-5-1】在编写函数时,要进行反复的考查,并且自问:“我打算做哪些假定?”一旦确定了的假定,就要使用断言对假定进行检查。
6.6 引用与指针的比较
- 引用是C++中的概念,初学者容易把引用和指针混淆一起。以下程序中,n 是 m 的一个引用(reference),m 是被引用物(referent)。
int m;
int &n = m;
- n 相当于 m 的别名(绰号),对 n的任何操作就是对m的操作。例如有人名叫王小毛,他的绰号是“三毛”。说“三毛”怎么怎么的,其实就是对王小毛说三道四。所以 n 既不是 m 的拷贝,也不是指向 m 的指针,其实 n 就是 m 它自己。
引用的一些规则如下:
(1)引用被创建的同时必须被初始化(指针则可以在任何时候被初始化)。
(2)不能有NULL引用,引用必须与合法的存储单元关联(指针则可以是NULL)。
(3)一旦引用被初始化,就不能改变引用的关系(指针则可以随时改变所指的对象)
以下示例程序中,k 被初始化为i的引用。语句 k = j 并不能将k修改成为j的引用,只是把 k 的值改变成为6。由于 k 是 i 的引用,所以 i 的值也变成了6。
int i = 5;
int j = 6;
int &k = i;
k = j; // k 和 i 的值都变成了6;
上面的程序看起来象在玩文字游戏,没有体现出引用的价值。引用的主要功能是传递函数的参数和返回值。C++语言中,函数的参数和返回值的传递方式有三种:值传递、指针传递和引用传递。
- 以下是“值传递”的示例程序。由于 Func1 函数体内的 x 是外部变量 n 的一份拷贝,改变 x 的值不会影响 n , 所以 n 的值仍然是0。
void Func1(int x)
{
x = x + 10;
}
int n = 0;
Func1(n);
cout << “n = ” << n << endl; // n = 0
- 以下是“指针传递”的示例程序。由于 Func2 函数体内的 x 是指向外部变量 n 的指针,改变该指针的内容将导致 n 的值改变,所以 n 的值成为10。
void Func2(int *x)
{
(* x) = (* x) + 10;
}
int n = 0;
Func2(&n);
cout << “n = ” << n << endl; // n = 10
- 以下是“引用传递”的示例程序。由于 Func3 函数体内的 x 是外部变量 n 的引用,x 和 n 是同一个东西,改变 x 等于改变 n ,所以 n 的值成为10。
void Func3(int &x)
{
x = x + 10;
}
int n = 0;
Func3(n);
cout << “n = ” << n << endl; // n = 10
- 对比上述三个示例程序,会发现“引用传递”的性质象“指针传递”,而书写方式象“值传递”。实际上“引用”可以做的任何事情“指针”也都能够做,为什么还要“引用”这东西?
- 答案是“用适当的工具做恰如其分的工作”。
- 指针能够毫无约束地操作内存中的如何东西,尽管指针功能强大,但是非常危险。就象一把刀,它可以用来砍树、裁纸、修指甲、理发等等,谁敢这样用?
- 如果的确只需要借用一下某个对象的“别名”,那么就用“引用”,而不要用“指针”,以免发生意外。比如说,某人需要一份证明,本来在文件上盖上公章的印子就行了,如果把取公章的钥匙交给他,那么他就获得了不该有的权利。
第 7 章 内存管理
- 640K ought to be enough for everybody — Bill Gates 1981
内存分配方式有三种:
- 从静态存储区域分配。内存在程序编译的时候就已经分配好,这块内存在程序的整个运行期间都存在。例如全局变量,static 变量。
- 在栈上创建。在执行函数时,函数内局部变量的存储单元都可以在栈上创建,函数执行结束时这些存储单元自动被释放。栈内存分配运算内置于处理器的指令集中,效率很高,但是分配的内存容量有限。
- 从堆上分配,亦称动态内存分配。程序在运行的时候用 malloc 或 new 申请任意多少的内存,程序员自己负责在何时用 free 或 delete 释放内存。动态内存的生存期由我们决定,使用非常灵活,但问题也最多
要对堆区开辟的内存判断是否为 NULL
- 开辟的内存要进行初始化
- 注意初始化成功时不要越界
- 动态内存的申请与释放必须配对,程序中 malloc 与 free 的使用次数一定要相同,
- 注意不要返回指向“栈内存”的“指针”
- 将指针设置为 NULL
7.3 指针与数组的对比
-
数组要么在静态存储区被创建(如全局数组),要么在栈上被创建。数组名对应着(而不是指向)一块内存,其地址与容量在生命期内保持不变,只有数组的内容可以改变。
-
指针可以随时指向任意类型的内存块,它的特征是“可变”,所以我们常用指针来操作动态内存。指针远比数组灵活,但也更危险。
-
指针 p 指向常量字符串“world”(位于静态存储区,内容为 world\0),
常量字符串的内容是不可以被修改的。从语法上看,编译器并不觉得语句 p[0]= ‘X’有什么不妥,但是该语句企图修改常量字符串的内容而导致运行错误
-
数组之间比较用 strcmp ,赋值用strcpy。
-
sizeof(字符串) ,'\0’也算字符串大小。
7.4 指针参数是如何传递内存的?
- 用“指向指针的指针”
- 用函数返回值来传递动态内存。这种方法更加简单
- 函数 Test5 运行虽然不会出错,但是函数 GetString2 的设计概念却是错误的。 因为 GetString2 内的“hello world”是常量字符串,位于静态存储区,它在程序生命期内恒定不变。无论什么时候调用 GetString2,它返回的始终是同一个“只读”的内存块。
- “栈指针”
7.5 free 和 delete 把指针怎么啦
-
别看 free 和 delete 的名字恶狠狠的(尤其是 delete),它们只是把指针所指的内存给释放掉,但并没有把指针本身干掉。
-
切记要初始化指针,释放指针后要置成空指针
7.6 动态内存会被自动释放吗?
void Func(void)
{
char *p = (char *) malloc(100); // 动态内存会自动释放吗?
}
函数体内的局部变量在函数结束时自动消亡。很多人误以为示例是正确的。理由是 p 是局部的指针变量,它消亡的时候会让它所指的动态内存一起完蛋。这是错觉!
- 我们发现指针有一些“似是而非”的特征:
- 指针消亡了,并不表示它所指的内存会被自动释放。
- 内存被释放了,并不表示指针会消亡或者成了NULL指针。
这表明释放内存并不是一件可以草率对待的事。也许有人不服气,一定要找出可以草率行事的理由:
- 如果程序终止了运行,一切指针都会消亡,动态内存会被操作系统回收。既然如此,在程序临终前,就可以不必释放内存、不必将指针设置为 NULL 了。终于可以偷懒而不会发生错误了吧?
- 想得美。如果别人把那段程序取出来用到其它地方怎么办?
7.7 杜绝“野指针”
- 要么将指针设置为 NULL,要么让它指向合法的内存。例如
char *p = NULL; *
char *str = (char *) malloc(100);
- 不要越界访问
7.8 有了 malloc/free 为什么还要 new/delete
- malloc 与 free 是 C++/C 语言的标准库函数,new/delete 是 C++ 的运算符。它们都可用于申请动态内存和释放内存。
- 对于非内部数据类型的对象而言,光用 maloc/free 无法满足动态对象的要求。对象在创建的同时要自动执行构造函数,对象在消亡之前要自动执行析构函数。由于 malloc/free 是库函数而不是运算符,不在编译器控制权限之内,不能够把执行构造函数和析构函数的任务强加于 malloc/free。
- 因此 C++ 语言需要一个能完成动态内存分配和初始化工作的运算符 new ,以及一个能完成清理与释放内存工作的运算符 delete 。注意new/delete不是库函数。
既然 new/delete 的功能完全覆盖了 malloc/free,为什么 C++ 不把 malloc/free 淘汰出局呢?这是因为 C++ 程序经常要调用 C 函数,而 C 程序只能用 malloc/free 管理动态内存。
- 如果用 free 释放 “new 创建的动态对象”,那么该对象因无法执行析构函数而可能导致程序出错。如果用 delete 释放 “malloc 申请的动态内存”,理论上讲程序不会出错,但是该程序的可读性很差。所以 new/delete 必须配对使用,malloc/free 也一样。
下面是来自《字节豆包》的解析:
在 C++中,虽然 C 语言风格的malloc
和free
可以用于动态内存管理,但new
和delete
的存在有以下重要原因:
1. 类型安全性
new
和delete
:new
在分配内存时会根据对象的类型自动计算所需的内存大小,并且会调用对象的构造函数进行初始化。例如,new int[10]
不仅分配了能容纳 10 个int
类型的内存空间,还确保这些int
值被正确初始化(对于基本类型,可能是未定义值,但对于类类型,会调用默认构造函数)。delete
在释放内存时,也会根据对象类型调用相应的析构函数来进行清理工作。例如,对于通过new
创建的包含动态分配资源的类对象数组,delete []
会正确地调用每个对象的析构函数来释放资源。
malloc
和free
:malloc
只负责分配指定字节数的内存,它不知道所分配的内存将用于存储什么类型的对象。例如,malloc(10 * sizeof(int))
仅仅分配了 10 个int
类型大小的内存空间,但不会对这些内存进行任何初始化操作。free
只是简单地释放由malloc
分配的内存,它不执行任何与对象析构相关的操作。如果使用不当,很容易导致内存泄漏或者访问已释放的内存等错误。
2. 对象初始化和清理的便利性
new
和delete
:- 对于单个对象,
new
可以直接在分配内存后调用构造函数进行初始化。例如,new MyClass(parameter1, parameter2)
会在为MyClass
对象分配内存后,使用给定的参数调用构造函数来初始化对象。 - 对于对象数组,
new []
能确保数组中的每个对象都被正确构造。同样,delete []
会在释放内存之前调用数组中每个对象的析构函数。
- 对于单个对象,
malloc
和free
:- 使用
malloc
分配内存后,需要手动进行对象的初始化工作,这涉及到复杂的指针操作和对对象内部结构的了解。例如,在为一个包含多个成员变量的结构体分配内存后,需要逐个成员地进行赋值初始化。 free
无法自动处理对象内部资源的释放。如果对象在其生命周期内分配了其他动态内存或者打开了文件等资源,使用free
释放内存时,这些资源不会被自动释放,必须在调用free
之前手动处理。
- 使用
3. 与 C++语言特性的集成
new
和delete
:- 在 C++中,当涉及到继承和多态等特性时,
new
和delete
能够正确地处理对象的动态分配和释放。例如,通过基类指针BaseClass* ptr = new DerivedClass;
分配派生类对象的内存,在使用delete ptr
时,会根据对象的实际类型(即派生类类型)调用析构函数,然后释放内存。 - 与 C++的异常处理机制相配合,如果在
new
操作过程中发生异常(例如内存不足),C++的异常处理机制会确保已经分配的内存被正确释放,避免内存泄漏。
- 在 C++中,当涉及到继承和多态等特性时,
malloc
和free
:malloc
和free
是 C 语言的内存管理函数,它们没有与 C++的面向对象特性(如继承、多态)进行集成。在 C++程序中使用malloc
和free
来管理包含类对象的内存时,容易出现与类的构造函数、析构函数以及继承体系不兼容的问题。- 当使用
malloc
分配内存后,如果在初始化对象或者后续操作中发生异常,需要手动编写复杂的错误处理代码来确保内存被正确释放,否则会导致内存泄漏。
7.9 内存耗尽怎么办?
- 如果在申请动态内存时找不到足够大的内存块,malloc 和 new 将返回 NULL 指针,宣告内存申请失败。通常有三种方式处理“内存耗尽”问题。
(1)判断指针是否为 NULL,如果是则马上用 return 语句终止本函数。例如:
void Func(void)
{
A *a = new A;
if(a == NULL)
{
return;
}
//…
}
(2)判断指针是否为 NULL,如果是则马上用 exit(1)终止整个程序的运行。例如:
void Func(void)
{
A *a = new A; *
if(a == NULL)
{
cout << “Memory Exhausted” << endl;
exit(1);
}
// …
}
(3)为 new 和 malloc 设置异常处理函数。例如 Visual C++可以用_set_new_hander
函_数为 new 设置用户自己定义的异常处理函数,也可以让 malloc 享用与 new 相同的异常处理函数。详细内容请参考 C++使用手册
- 上述(1)(2)方式使用最普遍。如果一个函数内有多处需要申请动态内存,那么方式(1)就显得力不从心(释放内存很麻烦),应该用方式(2)来处理。很多人不忍心用 exit(1),问:“不编写出错处理程序,让操作系统自己解决行不行?”
- 不行。如果发生“内存耗尽”这样的事情,一般说来应用程序已经无药可救。
- 如果不用 exit(1) 把坏程序杀死,它可能会害死操作系统。道理如同:如果不把歹徒击毙,歹徒在老死之前会犯下更多的罪.
有一个很重要的现象要告诉大家。对于 32 位以上的应用程序而言,无论怎样使用 malloc 与 new,几乎不可能导致“内存耗尽”。我在 Windows 98 下用 Visual C++编写了测试程序,见示例 7-9。这个程序会无休止地运行下去,根本不会终止。因为 32 位操作系统支持“虚存”,内存用完了,自动用硬盘空间顶替。我只听到硬盘嘎吱嘎吱地响,Window 98 已经累得对键盘、鼠标毫无反应。
---//试图耗尽操作系统的内存
void main(void)
{
float *p = NULL;
while(TRUE)
{
p = new float[1000000];
cout << “eat memory” << endl;
if(p==NULL)
exit(1);
}
}
7.10 malloc/free 的使用要点
- 函数 malloc 的原型如下:
void * malloc(size_t size);
用 malloc 申请一块长度为 length 的整数类型的内存,程序如下:
int *p = (int *) malloc(sizeof(int) * length);
7.11 new/delete 的使用要点
下面是来自《字节豆包》的解析:
在 C++中,new
和delete
的使用要点如下:
- 配对使用:
new
用于动态分配内存,delete
用于释放new
分配的内存。每一个new
操作必须有一个对应的delete
操作,否则会导致内存泄漏。例如:
int* ptr = new int; // 使用 ptr delete ptr;
- 对于使用
new[]
分配的数组,必须使用delete[]
来释放,以确保数组中每个元素的析构函数都被正确调用。如果使用delete
来释放new[]
分配的内存,或者使用delete[]
释放new
分配的单个对象的内存,都会导致未定义行为。例如:
int* arr = new int[10]; // 使用 arr delete[] arr;
- 初始化和清理:
new
在分配内存的同时会根据对象的类型调用相应的构造函数进行初始化。如果对象有自定义的构造函数,可以通过new
的参数来传递相应的初始化值。例如:
class MyClass { public: MyClass(int num) : data(num) {} // 其他成员函数和变量 private: int data; }; MyClass* obj = new MyClass(10);
delete
在释放内存之前会自动调用对象的析构函数,完成对象的清理工作。当类中包含动态分配的资源(如指针成员指向的堆内存)时,需要在析构函数中正确地释放这些资源,以防止内存泄漏。例如:
class ResourceHolder { public: ResourceHolder() { data = new int[10]; } ~ResourceHolder() { delete[] data; } private: int* data; };
- 异常处理:
- 当
new
无法分配足够的内存时,会抛出std::bad_alloc
类型的异常。在使用new
进行内存分配时,应该考虑到可能的内存不足情况,并进行适当的异常处理。例如:
try { int* ptr = new int[10000000000]; } catch (const std::bad_alloc& e) { std::cerr << "Memory allocation failed: " << e.what() << std::endl; }
- 当
- 避免内存覆盖和重复释放:
- 不要对同一块内存多次释放,重复释放已释放的内存会导致运行时错误。在释放内存后,应将指针设置为
nullptr
,以防止意外的重复释放和悬空指针的访问。例如:
int* ptr = new int; delete ptr; ptr = nullptr; // 后续代码中如果不小心再次使用 delete ptr,不会导致严重错误
- 在使用
new
分配内存后,不要在未释放之前覆盖该指针,否则会导致内存泄漏。确保在使用新的内存分配之前,先释放旧的内存。
- 不要对同一块内存多次释放,重复释放已释放的内存会导致运行时错误。在释放内存后,应将指针设置为
- 与类的成员函数结合使用:
- 当类的成员变量包含指针类型,并且在构造函数中使用
new
为指针分配了内存,那么在类的析构函数中必须使用delete
来释放该内存,以保证对象生命周期结束时正确地回收资源。例如:
class MyClass { public: MyClass() { ptr = new int; } ~MyClass() { delete ptr; } private: int* ptr; };
- 如果类中定义了复制构造函数和赋值运算符重载函数,也需要正确处理动态分配的内存,以防止浅拷贝导致的内存问题。例如,在复制构造函数中,应该为新对象分配独立的内存,并复制原对象的数据;在赋值运算符重载函数中,需要先释放原对象的内存,再为其分配新的内存并复制数据。
- 当类的成员变量包含指针类型,并且在构造函数中使用
- 注意内存分配的位置:
new
和delete
操作的内存是在堆上分配和释放的,而局部变量等是在栈上分配内存。堆上的内存需要手动管理,而栈上的内存会在函数执行结束等情况下自动释放。不要混淆这两种内存的使用方式和管理方法。
- 使用
new
的变体形式时要正确操作:- 定位
new
(placementnew
)可以在指定的内存地址上构造对象。使用定位new
时,需要注意在合适的时机调用对象的析构函数来释放资源,因为delete
操作并不适用于定位new
创建的对象。
- 定位
7.12 一些心得体会
- (1)越是怕指针,就越要使用指针。不会正确使用指针,肯定算不上是合格的程序员。
- (2)必须养成“使用调试器逐步跟踪程序”的习惯,只有这样才能发现问题的本质
第 8 章 C++函数的高级特性
- 对比于 C 语言的函数,C++增加了重载(overloaded)、内联(inline)、const 和 virtual 四种新机制。
- 其中重载和内联机制既可用于全局函数也可用于类的成员函数,const 与virtual 机制仅用于类的成员函数
8.1 函数重载的概念
- 重载的起源:自然语言中,一个词可以有许多不同的含义,即该词被重载了。
- 人们可以通过上下文来判断该词到底是哪种含义。
- “词的重载”可以使语言更加简练。
- 例如“吃饭”的含义十分广泛,人们没有必要每次非得说清楚具体吃什么不可。别迂腐得象孔已己,说茴香豆的茴字有四种写法。
- 在 C++程序中,可以将语义、功能相似的几个函数用同一个名字表示,即函数重载。
- 这样便于记忆,提高了函数的易用性,这是 C++语言采用重载机制的一个理由。
- 例如示例 8-1-1 中的函数
EatBeef,EatFish,EatChicken
可以用同一个函数名 Eat 表示,用不同类型的参数加以区别。
void EatBeef(…); //可以改为: void Eat(Beef …);
void EatFish(…); //可以改为: void Eat(Fish …);
void EatChicken(…); // 可以改为: void Eat(Chicken …);
- 重载是如何实现的?
- 几个同名的重载函数仍然是不同的函数,它们是如何区分的呢?我们自然想到函数
- 接口的两个要素:参数与返回值。
- 如果同名函数的参数不同(包括类型、顺序不同),那么容易区别出它们是不同的函数。
- 所以只能靠参数而不能靠返回值类型的不同来区分重载函数。
- 编译器根据参数为每个重载函数产生不同的内部标识符。
- 例如编译器为示例 8-1-1 中的三个 Eat 函数产生象
_eat_beef、_eat_fish、_eat_chicken
之类的内部标识符(不同的编译器可能产生不同风格的内部标识符)。
如果 C++程序要调用已经被编译后的 C 函数,该怎么办?
- 假设某个 C 函数的声明如下:void foo(int x, int y);
- 该函数被 C 编译器编译后在库中的名字为_foo,而 C++编译器则会产生像
_foo_int_int
之类的名字用来支持函数重载和类型安全连接。由于编译后的名字不同,C++程序不能直接调用 C 函数。 - C++提供了一个 C 连接交换指定符号 extern“C”来解决这个问题。例如:
extern “C”
{
void foo(int x, int y);
//… 其它函数
}
//或者写成
extern “C”
{
#include “myheader.h”
//… 其它 C 头文件
}
-
这就告诉 C++编译译器,函数 foo 是个 C 连接,应该到库中找名字_foo 而不是找_foo_int_int。C++编译器开发商已经对 C 标准库的头文件作了 extern“C”处理,所以我们可以用#include 直接引用这些头文件。
-
注意并不是两个函数的名字相同就能构成重载。全局函数和类的成员函数同名不算重载,因为函数的作用域不同。例如:
void Print(…); 全局函数
class A
{//…
void Print(…); // 成员函数
}
- 不论两个 Print 函数的参数是否不同,如果类的某个成员函数要调用全局函数 Print,为了与成员函数 Print 区别,全局函数被调用时应加‘::’标志。如
::Print(…);
// 表示 Print 是全局函数而非成员函数 - 当心隐式类型转换导致重载函数产生二义性
- 示例 8-1-3 中,第一个 output 函数的参数是 int 类型,第二个 output 函数的参数是 float 类型。由于数字本身没有类型,将数字当作参数时将自动进行类型转换(称为隐式类型转换)。
- 语句 output(0.5)将产生编译错误,因为编译器不知道该将 0.5 转换成int 还是 float 类型的参数。隐式类型转换在很多地方可以简化程序的书写,但是也可能留下隐患。
- 示例 8-1-3 隐式类型转换导致重载函数产生二义性 :
#include <iostream.h>
void output( int x); // 函数声明
void output( float x); // 函数声明
void output( int x)
{
cout << " output int " << x << endl ;
}
void output( float x)
{
cout << " output float " << x << endl ;
}
void main(void)
{
int x = 1;
float y = 1.0;
output(x); //output int 1
output(y); //output float 1
output(1); //output int 1
//output(0.5); //error! ambiguous call, 因为自动类型转换
output(int(0.5)); //output int 0
output(float(0.5)); //output float 0.5
}
8.2 成员函数的重载、覆盖与隐藏
下面是来自《字节豆包》的解析:
以下是成员函数的重载、覆盖与隐藏各自的特征和区别:
函数重载(Function Overloading)
特征
- 发生范围:在同一个类内部。
- 函数名:函数名相同。
- 参数列表:参数的个数、类型或顺序不同。
- 返回值类型:返回值类型可以相同也可以不同,但仅返回值类型不同不能构成重载。
- 静态联编:在编译阶段根据函数调用时实参的类型和个数来确定要调用的函数版本,属于静态联编。
示例
class Calculator {
public:
int add(int a, int b);
double add(double a, double b);
int add(int a, int b, int c);
};
函数覆盖(Function Overriding)
特征
- 发生范围:存在于派生类与基类之间。
- 函数名:函数名相同。
- 参数列表:参数列表必须完全相同。
- 返回值类型:返回值类型必须相同(协变返回类型除外,即派生类覆盖函数的返回值类型可以是基类对应函数返回值类型的派生类)。
- 虚函数要求:基类中的函数必须是虚函数(使用
virtual
关键字声明),这样才能实现多态性。 - 动态联编:在运行阶段根据对象的实际类型来确定调用的函数版本,属于动态联编。
示例
class Shape {
public:
virtual double area() = 0;
};
class Circle : public Shape {
public:
double area() override;
};
class Rectangle : public Shape {
public:
double area() override;
};
函数隐藏(Function Hiding)
特征
- 发生范围:在派生类与基类之间。
- 函数名:函数名相同。
- 参数列表:
- 与基类函数同名的派生类函数,无论参数列表是否相同都会隐藏基类函数。
- 如果派生类函数与基类虚函数同名但参数列表不同,此时也会发生函数隐藏。
- 调用方式:若要在派生类中调用被隐藏的基类函数,需通过基类名和作用域解析运算符
::
来显式调用。
示例
class Base {
public:
void display();
virtual void show(int x);
};
class Derived : public Base {
public:
void display();
void show(double x);
};
区别总结
- 重载与覆盖:
- 重载在同一类中,覆盖在派生类与基类之间。
- 重载的函数参数列表不同,覆盖的函数参数列表必须相同。
- 重载在编译时确定调用的函数,覆盖在运行时根据对象类型确定调用的函数。
- 覆盖与隐藏:
- 覆盖要求基类函数是虚函数且参数列表相同,隐藏对基类函数是否是虚函数没有要求且参数列表可以不同。
- 覆盖体现了动态多态性,隐藏主要是在派生类中重新定义了基类的同名函数(包括参数不同的虚函数),使得基类函数在派生类对象中被隐藏起来。
- 重载与隐藏:
- 重载在同一类中,而隐藏是在派生类与基类之间。
- 重载只看参数列表的差异,而隐藏主要是同名函数在派生类与基类之间的遮蔽现象。
如果想要在派生类中避免基类函数隐藏想要调用基类函数,则:
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); // error
}
//right:
class Derived : public Base
{
public:
void f(char *str);
void f(int x)
{
Base::f(x);
}
};
8.3 参数的缺省值
下面是来自《字节豆包》的解析:以下是关于 C++参数缺省值的介绍:
1. 定义
- 参数的缺省值(也称为默认值)是指在函数声明或定义时为函数参数指定的一个默认的数值。当调用函数时,如果没有为该参数提供实际的值,编译器会自动使用这个默认值。
2. 使用方法
-
可以在函数声明或者函数定义中指定参数的缺省值,但通常建议在函数声明中指定,这样可以使函数的接口更加清晰。例如:
// 函数声明中指定缺省值 void printMessage(const std::string& message = "Hello World"); // 函数定义 void printMessage(const std::string& message) { std::cout << message << std::endl; }
-
当调用
printMessage
函数时,如果不提供参数,如printMessage();
,则会输出Hello World
;如果提供了参数,如printMessage("Goodbye");
,则会输出Goodbye
。
3. 缺省参数的规则
-
缺省参数必须从右向左连续定义,不能间隔定义。例如:
// 正确的定义方式 void func(int a, int b = 0, int c = 1); // 错误的定义方式,中间的参数没有默认值 // void func(int a, int b, int c = 1);
-
一旦某个参数开始使用缺省值,那么它右边的所有参数都必须有缺省值。
4. 函数重载与缺省参数
- 当函数存在重载和缺省参数时,需要注意避免二义性。例如:
void func(int a); void func(int a, int b = 0);
- 当调用
func(1)
时,编译器无法确定应该调用哪个函数,会产生二义性错误。
- 当调用
8.4 运算符重载
下面是来自《字节豆包》的解析:以下是关于C++运算符重载的介绍:
1. 概念
- 运算符重载是C++中的一种特性,它允许程序员为自定义类型(类或结构体)重新定义运算符的操作方式,使得这些运算符可以像操作基本数据类型一样操作自定义类型的对象。
2. 定义方式
- 运算符重载是通过定义特殊的成员函数或非成员函数(通常是友元函数)来实现的。这些函数的名字由关键字
operator
后接要重载的运算符组成。
3. 重载规则
- 不能创造新运算符:只能重载C++中已有的运算符,不能创造新的运算符。例如,不能定义
operator@
这样的新运算符。 - 保持运算符原有含义:重载后的运算符应该尽可能保持其原有的语义和优先级。例如,
+
运算符通常表示加法或连接操作,重载后也应该符合这种常规的理解。 - 操作数数量不变:不能改变运算符操作数的数量。例如,
+
是二元运算符,重载后仍然需要两个操作数。
4. 成员函数重载与非成员函数重载
-
成员函数重载
- 当运算符重载为成员函数时,左操作数必须是该类的对象。例如:
class Complex { public: Complex operator+(const Complex& other); };
- 对于
Complex c1, c2; c1 + c2;
,c1
是调用operator+
的对象,c2
是参数。
-
非成员函数重载(通常是友元函数)
- 当运算符需要对第一个操作数进行类型转换或者运算符的操作数不是同一类对象时,通常需要使用非成员函数进行重载,而且常将其声明为友元函数。例如:
class Complex { public: friend Complex operator+(const Complex& c1, const Complex& c2); };
5. 常见运算符重载示例
-
算术运算符重载
class Vector { public: Vector operator+(const Vector& other) const; Vector operator-(const Vector& other) const; };
-
关系运算符重载
class Person { public: bool operator==(const Person& other) const; bool operator!=(const Person& other) const; };
-
输入/输出运算符重载
class Date { public: friend std::ostream& operator<<(std::ostream& os, const Date& date); friend std::istream& operator>>(std::istream& is, Date& date); };
6. 注意事项
-
重载赋值运算符(
=
)时,需要注意避免自我赋值等问题导致的错误。 -
重载下标运算符(
[]
)时,要确保不会越界访问数组元素。 -
我们要以平常心来对待运算符重载:
(1)不要过分担心自己不会用,它的本质仍然是程序员们熟悉的函数。
(2)不要过分热心地使用,如果它不能使代码变得更加易读易写,那就别用,否则会
自找麻烦。 -
如果运算符被重载为类的成员函数,那么一元运算符没有参数,二元运算符只有一个右侧参数,因为对象自己成了左侧参数。
-
从语法上讲,运算符既可以定义为全局函数,也可以定义为成员函数。文献[Murray , p44-p47]对此问题作了较多的阐述,并总结了如下的规则:
运算符 | 规则 |
---|---|
所有的一元运算符 | 建议重载为成员函数 |
= () [] -> | 只能重载为成员函数 |
+= -= /= *= &= | 建议重载为成员函数 |
所有其它运算符 | 建议重载为全局函数 |
在C++重载赋值运算符(=)时,怎样避免自我赋值等问题导致的错误?
在 C++中重载赋值运算符(=)时,可以采取以下方法来避免自我赋值等问题导致的错误:
一、使用临时变量和“证同测试”
- 引入临时变量
- 首先创建一个临时对象来保存当前对象的值。例如:
class MyClass { public: // 假设存在一些数据成员 int* data; MyClass& operator=(const MyClass& other) { // 创建临时对象保存当前状态 int* tempData = data; // 根据 other 的状态进行赋值操作 if (this!= &other) { data = new int(*other.data); } // 如果发生错误,恢复到原始状态 return *this; } };
- 进行“证同测试”
- 在进行赋值操作之前,先检查当前对象和要赋值的对象是否为同一个对象。如果是同一个对象,直接返回当前对象,避免不必要的操作。例如在上面的代码中,通过
if (this!= &other)
进行判断,如果是自我赋值,则不进行实际的赋值操作。
- 在进行赋值操作之前,先检查当前对象和要赋值的对象是否为同一个对象。如果是同一个对象,直接返回当前对象,避免不必要的操作。例如在上面的代码中,通过
二、采用“复制并交换”技术
- 创建拷贝构造函数和交换函数
- 首先,为你的类提供一个拷贝构造函数,用于创建一个对象的副本。然后,创建一个私有成员函数用于交换两个对象的状态。例如:
class MyClass { public: // 假设存在一些数据成员 int* data; MyClass(const MyClass& other) : data(new int(*other.data)) {} void swap(MyClass& other) { std::swap(data, other.data); } MyClass& operator=(MyClass other) { swap(other); return *this; } };
- 实现赋值运算符函数
- 在赋值运算符函数中,接受一个值传递的参数,然后通过调用交换函数来实现赋值操作。这样,即使在赋值过程中发生异常,也不会影响原始对象的状态。
通过以上方法,可以有效地避免在重载赋值运算符时可能出现的自我赋值等问题导致的错误,确保程序的稳定性和正确性。
8.5 函数内联
8.5.1 用内联取代宏代码
- C++ 语言支持函数内联,其目的是为了提高函数的执行效率(速度)。
- 在C程序中,可以用宏代码提高执行效率。宏代码本身不是函数,但使用起来象函数。预处理器用复制宏代码的方式代替函数调用,省去了参数压栈、生成汇编语言的CALL调用、返回参数、执行 return 等过程,从而提高了速度。使用宏代码最大的缺点是容易出错,预处理器在复制宏代码时常常产生意想不到的边际效应。例如
#define MAX(a, b) (a) > (b) ? (a) : (b)
result = MAX(i, j) + 2 ;
- 将被预处理器解释为
result = (i) > (j) ? (i) : (j) + 2 ;
,由于运算符‘+’比运算符‘:’的优先级高,所以上述语句并不等价于期望的:result = ( (i) > (j) ? (i) : (j) ) + 2 ;
。 - 如果把宏代码改写为
#define MAX(a, b) ( (a) > (b) ? (a) : (b) )
,则可以解决由优先级引起的错误。但是即使使用修改后的宏代码也不是万无一失的,例如语句result = MAX(i++, j);
将被预处理器解释为result = (i++) > (j) ? (i++) : (j);
- 对于C++ 而言,使用宏代码还有另一种缺点:无法操作类的私有数据成员。
让我们看看C++ 的“函数内联”是如何工作的。
- 对于任何内联函数,编译器在符号表里放入函数的声明(包括名字、参数类型、返回值类型)。如果编译器没有发现内联函数存在错误,那么该函数的代码也被放入符号表里。
- 在调用一个内联函数时,编译器首先检查调用是否正确(进行类型安全检查,或者进行自动类型转换,当然对所有的函数都一样)。如果正确,内联函数的代码就会直接替换函数调用,于是省去了函数调用的开销。
- 这个过程与预处理有显著的不同,因为预处理器不能进行类型安全检查,或者进行自动类型转换。假如内联函数是成员函数,对象的地址(this)会被放在合适的地方,这也是预处理器办不到的。
…C++ 语言的函数内联机制既具备宏代码的效率,又增加了安全性,而且可以自由操作类的数据成员。所以在C++ 程序中,应该用内联函数取代所有宏代码,“断言assert”恐怕是唯一的例外。assert是仅在Debug版本起作用的宏,它用于检查“不应该”发生的情况。为了不在程序的Debug版本和Release版本引起差别,assert不应该产生任何副作用。如果assert是函数,由于函数调用会引起内存、代码的变动,那么将导致Debug版本与Release版本存在差异。所以assert不是函数,而是宏。(参见6.5节“使用断言”)
8.5.2 内联函数的编程风格
- 关键字inline必须与函数定义体放在一起才能使函数成为内联,仅将inline放在函数声明前面不起任何作用。如下风格的函数Foo不能成为内联函数:
inline void Foo(int x, int y); // inline 仅与函数声明放在一起
void Foo(int x, int y)
{
⋯
}
- 而如下风格的函数Foo则成为内联函数:
void Foo(int x, int y);
inline void Foo(int x, int y) // inline 与函数定义体放在一起
{
⋯
}
- 所以说,inline是一种“用于实现的关键字”,而不是一种“用于声明的关键字”。
- 一般地,用户可以阅读函数的声明,但是看不到函数的定义。尽管在大多数教科书中内联函数的声明、定义体前面都加了inline关键字,但我认为inline不应该出现在函数的声明中。
- 这个细节虽然不会影响函数的功能,但是体现了高质量C++/C程序设计风格的一个基本原则:声明与定义不可混为一谈,用户没有必要、也不应该知道函数是否需要内联。
将成员函数的定义体放在类声明之中虽然能带来书写上的方便,但不是一种良好的编程风格,上例应该改成:
// 头文件
class A
{
public:
void Foo(int x, int y);
}
// 定义文件
inline void A::Foo(int x, int y)
{
⋯
}
8.5.3 慎用内联
- 内联能提高函数的执行效率,为什么不把所有的函数都定义成内联函数?
- 如果所有的函数都是内联函数,还用得着“内联”这个关键字吗?
- 内联是以代码膨胀(复制)为代价,仅仅省去了函数调用的开销,从而提高函数的执行效率。如果执行函数体内代码的时间,相比于函数调用的开销较大,那么效率的收获会很少。另一方面,每一处内联函数的调用都要复制代码,将使程序的总代码量增大,消耗更多的内存空间。以下情况不宜使用内联:
(1)如果函数体内的代码比较长,使用内联将导致内存消耗代价较高。
(2)如果函数体内出现循环,那么执行函数体内代码的时间要比函数调用的开销大。
类的构造函数和析构函数容易让人误解成使用内联更有效。要当心构造函数和析构函数可能会隐藏一些行为,如“偷偷地”执行了基类或成员对象的构造函数和析构函数。所以不要随便地将构造函数和析构函数的定义体放在类声明中。 一个好的编译器将会根据函数的定义体,自动地取消不值得的内联(这进一步说明了inline 不应该出现在函数的声明中)。
8.6 一些心得体会
- C++ 语言中的重载、内联、缺省参数、隐式转换等机制展现了很多优点,但是这些优点的背后都隐藏着一些隐患。正如人们的饮食,少食和暴食都不可取,应当恰到好处。
- 我们要辨证地看待 C++的新机制,应该恰如其分地使用它们。虽然这会使我们编程时多费一些心思,少了一些痛快,但这才是编程的艺术。
第 9 章 类的构造函数、析构函数与赋值函数
- 构造函数、析构函数与赋值函数是每个类最基本的函数。它们太普通以致让人容易麻痹大意,其实这些貌似简单的函数就象没有顶盖的下水道那样危险。
- 每个类只有一个析构函数和一个赋值函数,但可以有多个构造函数(包含一个拷贝构造函数,其它的称为普通构造函数)。对于任意一个类 A,如果不想编写上述函数,C++编译器将自动为A产生四个缺省的函数,如
A(void); // 缺省的无参数构造函数
A(const A &a); // 缺省的拷贝构造函数
~A(void); // 缺省的析构函数
A & operate =(const A &a); // 缺省的赋值函数
这不禁让人疑惑,既然能自动生成函数,为什么还要程序员编写?
原因如下:
(1)如果使用“缺省的无参数构造函数”和“缺省的析构函数”,等于放弃了自主“初始化”和“清除”的机会,C++发明人Stroustrup的好心好意白费了。
(2)“缺省的拷贝构造函数”和“缺省的赋值函数”均采用“位拷贝”而非“值拷贝”的方式来实现,倘若类中含有指针变量,这两个函数注定将出错。
- 对于那些没有吃够苦头的C++程序员,如果他说编写构造函数、析构函数与赋值函数很容易,可以不用动脑筋,表明他的认识还比较肤浅,水平有待于提高。 本章以类String 的设计与实现为例,深入阐述被很多教科书忽视了的道理。String的结构如下:
class String
{
public:
String(const char *str = NULL); // 普通构造函数
String(const String &other); // 拷贝构造函数
~ String(void); // 析构函数
String & operate =(const String &other); // 赋值函数
private:
char *m_data; // 用于保存字符串
};
9.1 构造函数与析构函数的起源
- 作为比C更先进的语言,C++提供了更好的机制来增强程序的安全性。C++编译器具有严格的类型安全检查功能,它几乎能找出程序中所有的语法问题,这的确帮了程序员的大忙。但是程序通过了编译检查并不表示错误已经不存在了,在“错误”的大家庭里,“语法错误”的地位只能算是小弟弟。级别高的错误通常隐藏得很深,就象狡猾的罪犯,想逮住他可不容易。
- 根据经验,不少难以察觉的程序错误是由于变量没有被正确初始化或清除造成的,而初始化和清除工作很容易被人遗忘。Stroustrup在设计C++语言时充分考虑了这个问题并很好地予以解决:
- 把对象的初始化工作放在构造函数中,把清除工作放在析构函数中。当对象被创建时,构造函数被自动执行。当对象消亡时,析构函数被自动执行。这下就不用担心忘了对象的初始化和清除工作。
- 构造函数与析构函数的名字不能随便起,必须让编译器认得出才可以被自动执行。Stroustrup 的命名方法既简单又合理:让构造函数、析构函数与类同名,由于析构函数的目的与构造函数的相反,就加前缀‘~’以示区别。
- 除了名字外,构造函数与析构函数的另一个特别之处是没有返回值类型,这与返回值类型为void的函数不同。构造函数与析构函数的使命非常明确,就象出生与死亡,光溜溜地来光溜溜地去。如果它们有返回值类型,那么编译器将不知所措。为了防止节外生枝,干脆规定没有返回值类型。(以上典故参考了文献[Eekel, p55-p56])
9.2 构造函数的初始化表
- 构造函数有个特殊的初始化方式叫“初始化表达式表”(简称初始化表)。初始化表位于函数参数表之后,却在函数体 {} 之前。这说明该表里的初始化工作发生在函数体内的任何代码被执行之前。
构造函数初始化表的使用规则:
- 如果类存在继承关系,派生类必须在其初始化表里调用基类的构造函数。
class A
{
…
A(int x); // A 的构造函数
};
class B : public A
{
B(int x, int y);// B 的构造函数
};
B::B(int x, int y)
: A(x) // 在初始化表里调用A的构造函数
{
...
}
- 类的const 常量只能在初始化表里被初始化,因为它不能在函数体内用赋值的方式来初始化(参见5.4节)。
- 类的数据成员的初始化可以采用初始化表或函数体内赋值两种方式,这两种方式的效率不完全相同。 非内部数据类型的成员对象应当采用第一种方式初始化,以获取更高的效率。
class A
{
A(void); //无参构造函数
A(const A &other); //拷贝构造函数
A & operate =( const A &other); //赋值函数
};
class B
{
public:
B(const A &A); // B 的构造函数
private:
A m_a; //成员对象
};
- 示例 9-2(a)中,类 B 的构造函数在其初始化表里调用了类A 的拷贝构造函数,从而将成员对象 m_a 初始化。
- 示例 9-2 (b) 中,类 B 的构造函数在函数体内用赋值的方式将成员对象 m_a 初始化。我们看到的只是一条赋值语句,但实际上 B 的构造函数干了两件事:先暗地里创建 m_a 对象(调用了 A 的无参数构造函数),再调用类 A 的赋值函数,将参数a赋给 m_a。
对于内部数据类型的数据成员而言,两种初始化方式的效率几乎没有区别,但后者的程序版式似乎更清晰些。若类 F 的声明如下:
class F
{
public:
F(int x, int y); // 构造函数
private:
int m_x, m_y;
int m_i, m_j;
}
示例 9-2© 中 F 的构造函数采用了第一种初始化方式,示例 9-2(d) 中 F 的构造函数采用了第二种初始化方式。
第 10 章 类的继承与组合
待定
第 11 章 其它编程经验
使用 const 提高函数的健壮性
- const 更大的魅力是它可以修饰函数的参数、返回值,甚至函数的定义体。const 是 constant 的缩写,“恒定不变”的意思。
- 被 const 修饰的东西都受到强制保护,可以预防意外的变动,能提高程序的健壮性。所以很多 C++程序设计书籍建议:“Use const whenever you need”。
- 用 const 修饰函数的参数;输出参数不能用 const
- const 只能修饰输入参数。
- 如果输入参数采用“指针传递”,那么加 const 修饰可以防止意外地改动该指针,起到保护作用。
- 如果输入参数采用“值传递”,由于函数将自动产生临时变量用于复制该参数,该输入参数本来就无需保护,所以不要加 const 修饰。
- 总结:
用 const 修饰函数的返回值
- 如果给以“指针传递”方式的函数返回值加 const 修饰,那么函数返回值(即指针)的内容不能被修改,该返回值只能被赋给加 const 修饰的同类型指针。
- 例如函数
const char * GetString(void); *
//如下语句将出现编译错误:
char *str = GetString(); *
//正确的用法是
const char *str = GetString();*
- 如果函数返回值采用“值传递方式”,由于函数会把返回值复制到外部临时的存储单元中,加 const 修饰没有任何价值。 例如不要把函数 int GetInt(void) 写成 const int GetInt(void)。
提高程序的效率
程序的时间效率是指运行速度,空间效率是指程序占用内存或者外存的状况。
全局效率是指站在整个系统的角度上考虑的效率,局部效率是指站在模块或函数角度上考虑的效率。
- 【规则 11-2-1】不要一味地追求程序的效率,应当在满足正确性、可靠性、健壮性、可读性等质量因素的前提下,设法提高程序的效率。
- 【规则 11-2-2】以提高程序的全局效率为主,提高局部效率为辅。
- 【规则 11-2-3】在优化程序的效率时,应当先找出限制效率的“瓶颈”,不要在无关紧要之处优化。
- 【规则 11-2-4】先优化数据结构和算法,再优化执行代码。
- 【规则 11-2-5】有时候时间效率和空间效率可能对立,此时应当分析那个更重要,作出适当的折衷。例如多花费一些内存来提高性能。
- 【规则 11-2-6】不要追求紧凑的代码,因为紧凑的代码并不能产生高效的机器码。
一些有益的建议
-
当心那些视觉上不易分辨的操作符发生书写错误。我们经常会把“==”误写成“=”,象“||”、“&&”、“<=”、“>=”这类符号也很容易发生“丢 1”失误。然而编译器却不一定能自动指出这类错误。
-
变量(指针、数组)被创建之后应当及时把它们初始化,以防止把未被初始化的变量当成右值使用。
-
当心变量的初值、缺省值错误,或者精度不够
-
当心数据类型转换发生错误。尽量使用显式的数据类型转换(让人们知道发生了什么事),避免让编译器轻悄悄地进行隐式的数据类型转换。
-
当心变量发生上溢或下溢,数组的下标越界。
-
当心忘记编写错误处理程序,当心错误处理程序本身有误。
-
当心文件 I/O 有错误。
-
避免编写技巧性很高代码。
-
不要设计面面俱到、非常灵活的数据结构。
-
尽量使用标准库函数,不要“发明”已经存在的库函数。
-
尽量不要使用与具体硬件或软件环境关系密切的变量。
-
把编译器的选择项设置为最严格状态。
-
如果可能的话,使用 PC-Lint、LogiScope 等工具进行代码审查。