文章目录
全文约 45519 字,预计阅读时长: 131分钟
一 版权声明
上海贝尔网络应用-林锐
二 做题打分
三 博士的前言总结
- 知错就改;
- 经常温故而知新;
- 坚持学习,天天向上。
第一二章:
- 每个C++/C程序通常分为两个文件。一个文件用于保存程序的声明(declaration),称为头文件。另一个文件用于保存程序的实现(implementation),称为定义(definition)文件。
- C++/C 程序的头文件以
“.h”
为后缀,C程序的定义文件以“.c”
为后缀,C++程序的定义文件通常以“.cpp”
为后缀(也有一些系统以“.cc”或“.cxx”为后缀)。
1.2- 1.4 防止头文件被重复包含
# 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
1.5 目录结构
- 如果一个软件的头文件数目比较多(如超过十个),通常应将头文件和定义文件分
- 别保存于不同的目录,以便于维护。
- 例如可将头文件保存于 include 目录,将定义文件保存于 source 目录(可以是多级目录)。
- 如果某些头文件是私有的,它不会被用户的程序直接引用,则没有必要公开其“声明”。为了加强信息隐藏,这些私有的头文件可以和定义文件存放于同一个目录
2.0 程序的版式风格
清晰、美观,是程序风格的重要构成因素
- 空行起着分隔程序段落的作用。
- 空行不会浪费内存
- 【规则 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;
}
2.1 代码行
一行代码只做一件事情,如只定义一个变量,或只写一条语句。这样的代码容易阅读,并且方便于写注释。
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 对齐
- 【规则 2-4-1】程序的分界符
‘{’
和‘}’
应独占一行并且位于同一列,同时与引用它们的语句左对齐。- 【规则 2-4-2】
{ }
之内的代码块在‘{’
右边数格处左对齐
- 【规则 2-4-2】
2.5 长行拆分
- 【规则 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 不会被误解为指针
2.8 类的版式
- 类可以将数据和函数封装在一起,其中函数表示了类的行为(或称服务)。
- 类提供关键字
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-2】变量和参数用小写字母开头的单词组合而成。例如:
BOOL flag; int drawMode;
- 【规则 3-2-3】常量全用大写的字母,用下划线分割单词。例如:
const int MAX = 100; const int MAX_LENGTH = 100;
- 【规则 3-2-4】静态变量加前缀
s_
(表示 static)。
void Init(…)
{
static int s_initValue; // 静态变量
//…
}
- 【规则 3-2-5】如果不得已需要全局变量,则使全局变量加前缀
g_
(表示 global)。int g_howManyPeople; int g_howMuchMoney
;
- 【规则 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.1 运算符的优先级
-
C++/C 语言的运算符有数十个,运算符的优先级与结合律如表 4-1 所示。注意一元运算符
+ - *
的优先级高于对应的二元运算符。
-
【规则 4-1-1】如果代码行中的运算符比较多,用括号确定表达式的操作顺序,避免使用默认的优先级。
//例如:
word = (high << 8) | low
if ((a | b) && (a & c))
4.2 复合表达式
-
如 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 )
。
4.3 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-5-1】建议 for 语句的循环控制变量的取值采用“半开半闭区间”写法。
- 示例 4-5(a)中的 x 值属于半开半闭区间“0 =< x < N”,起点到终点的间隔为 N,循环次数为 N。
- 示例 4-5(b)中的 x 值属于闭区间“0 =< x <= N-1”,起点到终点的间隔为 N-1,循环次数为 N。
- 相比之下,示例 4-5(a)的写法更加直观,尽管两者的功能是相同的。
//示例 4-5(a) 循环变量属于半开半闭区间
for (int x=0; x<N; x++)
{
//…
}
//示例 4-5(b) 循环变量属于闭区间
for (int x=0; x<=N-1; x++)
{
//…
}
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语句,而不是禁用。
第五章 常量
- 常量是一种标识符,它的值在运行期间恒定不变。C 语言用 #define 来定义常量(称为宏常量)。C++ 语言除了 #define 外还可以用 const 来定义常量(称为 const 常量)。
5.1 为什么需要常量
- 如果不使用常量,直接在程序中填写数字或字符串,将会有什么麻烦?
- 程序的可读性(可理解性)变差。程序员自己会忘记那些数字或字符什么意思,用户则更加不知它们从何处来、表示什么。
- 在程序的很多地方输入同样的数字或字符串,难保不发生书写错误。
- 如果要修改数字或字符串,则会在很多地方改动,既麻烦又容易出错。 例如:
#define MAX 100 /* C 语言的宏常量 */
const int MAX = 100; // C++ 语言的 const 常量
const float PI = 3.14159; // C++ 语言的 const 常量
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 类中的常量
- 有时我们希望某些常量只在类中有效。由于#define 定义的宏常量是全局的,不能达到目的,于是想当然地觉得应该用 const 修饰数据成员来实现。
- const 数据成员的确是存在的,但其含义却不是我们所期望的。
- const 数据成员只在某个对象生存期内是常量,而对于整个类而言却是可变的,因为类可以创建多个对象,不同的对象其 const 数据成员的值可以不同。
- 不能在类声明中初始化 const 数据成员。以下用法是错误的,因为类的对象未被创建时,编译器不知道 SIZE 的值是什么。
class A
{
//…
const int SIZE = 100; // 错误,企图在类声明中初始化 const 数据成员
int array[SIZE]; // 错误,未知的 SIZE
};
//const 数据成员的初始化只能在类构造函数的初始化表中进行,例如 :
class A
{
//…
A(int size); // 构造函数
const int SIZE ;
};
A::A(int size) : SIZE(size) // 构造函数的初始化表
{
//…
}
A a(100); // 对象 a 的 SIZE 值为 100
A b(200); // 对象 b 的 SIZE 值为 200
- 怎样才能建立在整个类中都恒定的常量呢?别指望 const 数据成员了,应该用类中的枚举常量来实现。例如:
class A
{
//…
enum { SIZE1 = 100, SIZE2 = 200}; // 枚举常量
int array1[SIZE1];
int array2[SIZE2];
};
- 枚举常量不会占用对象的存储空间,它们在编译时被全部求值。枚举常量的缺点是:它的隐含数据类型是整数,其最大值有限,且不能表示浮点数(如 PI=3.14159)。
第六章 函数设计
- 这里有一个很细的讲解:【C语言从青铜到王者】第二篇·详解函数
- C 语言中,函数的参数和返回值的传递方式有两种
- 值传递(pass by value)
- 指针传递(pass by pointer)
- C++ 语言中多了引用传递(pass by reference)。由于引用传递的性质像指针传递,而使用方式却像值传递。
6.1 参数的规则
- 【规则 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 类型
- 【规则 6-2-2】函数名字与返回值类型在语义上不可冲突。违反这条规则的典型代表是 C 标准库函数 getchar。
char c;
c = getchar();
if (c == EOF)
//…
- 按照 getchar 名字的意思,将变量 c 声明为 char 类型是很自然的事情。但不幸的是getchar 的确不是 char 类型,而是 int 类型,其原型如下:
int getchar(void);
- 由于 c 是 char 类型,取值范围是[-128,127],如果宏 EOF 的值在 char 的取值范围之外,那么 if 语句将总是失败,这种“危险”人们一般哪里料得到!导致本例错误的责任并不在用户,是函数 getchar 误导了使用者。
- 【规则 6-2-3】不要将正常值和错误标志混在一起返回。正常值用输出参数获得,而错误标志用 return 语句返回。
- 回顾上例,C 标准库函数的设计者为什么要将 getchar 声明为令人迷糊的 int 类型呢?他会那么傻吗?
- 在正常情况下,getchar 的确返回单个字符。但如果 getchar 碰到文件结束标志或发生读错误,它必须返回一个标志 EOF。为了区别于正常的字符,只好将 EOF 定义为负数(通常为负 1)。因此函数 getchar 就成了 int 类型。
- 我们在实际工作中,经常会碰到上述令人为难的问题。为了避免出现误解,我们应该将正常值和错误标志分开。即:正常值用输出参数获得,而错误标志用 return 语句返回。
- 函数 getchar 可以改写成
BOOL GetChar(char *c);
- 虽然 gechar 比 GetChar 灵活,例如
putchar(getchar());
但是如果 getchar 用错了,它的灵活性又有什么用呢?
- 函数 getchar 可以改写成
建议
- 【建议 6-2-1】有时候函数原本不需要返回值,但为了增加灵活性如支持链式表达,可以附加返回值。
char *strcpy(char *strDest,const char *strSrc);
char str[20];
//灵活性
int length = strlen( strcpy(str, “Hello World”) );
- 【建议 6-2-2】如果函数的返回值是一个对象,有些场合用“ 引用传递 ”替换“ 值传递 ”可以提高效率。而有些场合只能用“值传递”而不能用“引用传递”,否则会出错。
class String
{
//…
// 赋值函数
String & operate=(const String &other);
// 相加函数,如果没有 friend 修饰则只许有一个右侧参数
friend String operate+( const String &s1, const String &s2);
private:
char *m_data;
}
//String 的赋值函数 operate = 的实现如下:
String & String::operate=(const String &other)
{
if (this == &other)
return *this;
delete m_data;
m_data = new char[strlen(other.data)+1];
strcpy(m_data, other.data);
return *this; // 返回的是 *this 的引用,无需拷贝过程
}
- 对于赋值函数,应当用“引用传递”的方式返回 String 对象。如果用“值传递”的方式,虽然功能仍然正确,但由于 return 语句要把
*this
拷贝到保存返回值的外部存储单元之中,增加了不必要的开销,降低了赋值函数的效率。例如:
String a,b,c;
//…
a = b; // 如果用“值传递”,将产生一次 *this 拷贝
a = b = c; // 如果用“值传递”,将产生两次 *this 拷贝
- String 的相加函数 operate + 的实现如下:
- 对于相加函数,应当用“值传递”的方式返回 String 对象。
- 如果改用“引用传递”,那么函数返回值是一个指向局部对象 temp 的“引用”。由于 temp 在函数结束时被自动销毁,将导致返回的“引用”无效。
- 例如: c = a + b; 此时 a + b 并不返回期望值,c 什么也得不到,流下了隐患。
String operate+(const String &s1, const String &s2)
{
String temp;
delete temp.data; // temp.data 是仅含‘\0’的字符串
temp.data = new char[strlen(s1.data) + strlen(s2.data) +1];
strcpy(temp.data, s1.data);
strcat(temp.data, s2.data);
return temp;
}
6.3 函数内部实现的规则
- 不同功能的函数其内部实现各不相同,看起来似乎无法就“内部实现”达成一致的观点。但根据经验,我们可以在函数体的“入口处”和“出口处”从严把关,从而提高函数的质量。
- 【规则 6-3-1】在函数体的“入口处”,对参数的有效性进行检查。很多程序错误是由非法参数引起的,我们应该充分理解并正确使用“断言”(assert)来防止此类错误。详见 6.5 节“使用断言”。
- 【规则 6-3-2】在函数体的“出口处”,对 return 语句的正确性和效率进行检查。
- 如果函数有返回值,那么函数的“出口处”是 return 语句。
- 我们不要轻视 return语句。如果 return 语句写得不好,函数要么出错,要么效率低下。
注意事项:
(1)return 语句不可返回指向“栈内存”的“指针”或者“引用”,因为该内存在函数体结束时被自动销毁。例如:
char * Func(void)
{
char str[] = “hello world”; // str 的内存位于栈上
// …
return str; // 将导致错误
}
(2)要搞清楚返回的究竟是“值”、“指针”还是“引用”。
(3)如果函数返回值是一个对象,要考虑 return 语句的效率。例如:return String(s1 + s2);
,这是临时对象的语法,表示“创建一个临时对象并返回它”。不要以为它与“先创建一个局部对象 temp 并返回它的结果”是等价的,如:String temp(s1 + s2);
,return temp;
- 实质不然,上述代码将发生三件事。
- 首先,temp 对象被创建,同时完成初始化;
- 然后拷贝构造函数把 temp 拷贝到保存返回值的外部存储单元中;
- 最后,temp 在函数结束时被销毁(调用析构函数)。
- 然而“创建一个临时对象并返回它”的过程是不同的,编译器直接把临时对象创建并初始化在外部存储单元中,省去了拷贝和析构的化费,提高了效率。
6.4 其他建议
- 【建议 6-4-1】函数的功能要单一,不要设计多用途的函数。
- 【建议 6-4-2】函数体的规模要小,尽量控制在 50 行代码之内。
- 【建议 6-4-3】尽量避免函数带有“记忆”功能。如:static,带有“记忆”功能的函数,其行为可能是不可预测的,因为它的行为可能取决于某种“记忆状态”。这样的函数既不易理解又不利于测试和维护。在 C/C++语言中,函数的 static 局部变量是函数的“记忆”存储器。建议尽量少用static 局部变量,除非必需。
- 【建议 6-4-4】不仅要检查输入参数的有效性,还要检查通过其它途径进入函数体内的变量的有效性,例如全局变量、文件句柄等。
- 【建议 6-4-5】用于出错处理的返回值一定要清楚,让使用者不容易忽视或误解错误情况。
6.5 使用断言
- 程序一般分为 Debug 版本和 Release 版本,Debug 版本用于内部调试,Release 版本发行给用户使用。
- 断言 assert 是仅在 Debug 版本起作用的宏,它用于检查“不应该”发生的情况。
- assert 不是一个仓促拼凑起来的宏。为了不在程序的Debug版本和Release版本引起差别,assert 不应该产生任何副作用。所以assert不是函数,而是宏。程序员可以把 assert 看成一个在任何系统状态下都可以安全使用的无害测试手段。
- 复制不重叠的内存块:
void *memcpy(void *pvTo, const void *pvFrom, size_t size)
{
assert((pvTo != NULL) && (pvFrom != NULL)); // 使用断言
byte *pbTo = (byte *) pvTo; // 防止改变 pvTo 的地址
byte *pbFrom = (byte *) pvFrom; // 防止改变 pvFrom 的地址
while(size -- > 0 )
*pbTo ++ = *pbFrom ++ ;
return pvTo;
}
assert
- assert 不是一个仓促拼凑起来的宏。为了不在程序的 Debug 版本和 Release 版本引起差别,assert 不应该产生任何副作用。所以 assert 不是函数,而是宏。
- 程序员可以把assert 看成一个在任何系统状态下都可以安全使用的无害测试手段。如果程序在 assert处终止了,并不是说含有该 assert 的函数有错误,而是调用者出了差错,assert 可以帮助我们找到发生错误的原因。
- 很少有比跟踪到程序的断言,却不知道该断言的作用更让人沮丧的事了。你化了很多时间,不是为了排除错误,而只是为了弄清楚这个错误到底是什么。
- 有的时候,程序员偶尔还会设计出有错误的断言。所以如果搞不清楚断言检查的是什么,就很难判断错误是出现在程序中,还是出现在断言中。
- 幸运的是这个问题很好解决,只要加上清晰的注释即可。这本是显而易见的事情,可是很少有程序员这样做。
- 这好比一个人在森林里,看到树上钉着一块“危险”的大牌子。但危险到底是什么?树要倒?有废井?有野兽?除非告诉人们“危险”是什么,否则这个警告牌难以起到积极有效的作用。难以理解的断言常常被程序员忽略,甚至被删除。[Maguire, p8-p30]
- 【规则 6-5-1】使用断言捕捉不应该发生的非法情况。不要混淆非法情况与错误情况之间的区别,后者是必然存在的并且是一定要作出处理的。
- 【规则 6-5-2】在函数的入口处,使用断言检查参数的有效性(合法性)。
- 【建议 6-5-1】在编写函数时,要进行反复的考查,并且自问:“我打算做哪些假定?”一旦确定了的假定,就要使用断言对假定进行检查
- 【建议 6-5-2】一般教科书都鼓励程序员们进行防错设计,但要记住这种编程风格可能会隐瞒错误。当进行防错设计时,如果“不可能发生”的事情的确发生了,则要使用断言进行报警。
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’有什么不妥,但是该语句企图修改常量字符串的内容而导致运行错误。
//错误演示:
char a[] = “hello”;
a[0] = ‘X’;
cout << a << endl;
char *p = “world”; // 注意 p 指向常量字符串
p[0] = ‘X’; // 编译器不能发现该错误
cout << p << endl;
- 字符串数组之间比较用 strcmp ,赋值用strcpy。
// 数组…
char a[] = "hello";
char b[10];
strcpy(b, a); // 不能用 b = a;
if(strcmp(b, a) == 0) // 不能用 if (b == a)
//...
// 指针…
int len = strlen(a);
char *p = (char *)malloc(sizeof(char)*(len+1));
strcpy(p,a); // 不要用 p = a;
if(strcmp(p, a) == 0) // 不要用 if (p == a)
//...
7.3.3 计算内存容量
- 用运算符 sizeof 可以计算出数组的容量(字节数)。
- 示例(a)中,sizeof(a)的值是 12(注意别忘了’\0’)。指针 p 指向 a,但是 sizeof§的值却是 4。
- 这是因为sizeof§得到的是一个指针变量的字节数,相当于 sizeof(char*),而不是 p 所指的内存容量。
- C++/C 语言没有办法知道指针所指的内存容量,除非在申请内存时记住它。
- 注意当数组作为函数的参数进行传递时,该数组自动退化为同类型的指针。
- 示例(b)中,不论数组 a 的容量是多少,sizeof(a)始终等于 sizeof(char *)。
//a
char a[] = "hello world";
char *p = a;
cout<< sizeof(a) << endl; // 12 字节
cout<< sizeof(p) << endl; // 4 字节
//b
void Func(char a[100])
{
cout<< sizeof(a) << endl; // 4 字节而不是 100 字节
}
7.4 指针参数是如何传递内存的?
用“指向指针的指针”
如果函数的参数是一个指针,不要指望用该指针去申请动态内存。示例中,Test 函数的语句 GetMemory(str, 200) 并没有使 str 获得期望的内存,str 依旧是 NULL,为什么?❌错误演示:
void GetMemory(char *p, int num)
{
p = (char *)malloc(sizeof(char) * num);
}
void Test(void)
{
char *str = NULL;
GetMemory(str, 100); // str 仍然为 NULL
strcpy(str, "hello"); // 运行错误
}
- 毛病出在函数 GetMemory 中。编译器总是要为函数的每个参数制作临时副本,指针参数 p 的副本是 _p,编译器使 _p = p。如果函数体内的程序修改了_p 的内容,就导致参数 p 的内容作相应的修改。这就是指针可以用作输出参数的原因。
- 在示例中,_p 申请了新的内存,只是把_p 所指的内存地址改变了,但是 p 丝毫未变。
- 所以函数 GetMemory并不能输出任何东西。事实上,每执行一次 GetMemory 就会泄露一块内存,因为没有用free 释放内存。
- 如果非得要用指针参数去申请内存,那么应该改用“指向指针的指针”,见示例:
void GetMemory2(char **p, int num)
{
*p = (char *)malloc(sizeof(char) * num);
}
void Test2(void)
{
char *str = NULL;
GetMemory2(&str, 100); // 注意参数是 &str,而不是 str
strcpy(str, "hello");
cout<< str << endl;
free(str);
}
用函数返回值来传递动态内存。这种方法更加简单
char *GetMemory3(int num)
{
char *p = (char *)malloc(sizeof(char) * num);
return p;
}
void Test3(void)
{
char *str = NULL;
str = GetMemory3(100);
strcpy(str, "hello");
cout<< str << endl;
free(str);
}
- 用函数返回值来传递动态内存这种方法虽然好用,但是常常有人把 return 语句用错了。
- 这里强调不要用 return 语句返回指向“栈内存”的指针,因为该内存在函数结束时自动消亡。
- ❌错误演示:
char *GetString(void)
{
char p[] = "hello world";
return p; // 编译器将提出警告
}
void Test4(void)
{
char *str = NULL;
str = GetString(); // str 的内容是垃圾
cout<< str << endl;
}
- 用调试器逐步跟踪 Test4,发现执行 str = GetString 语句后 str 不再是 NULL 指针,但是 str 的内容不是 “hello world” 而是垃圾。如果改写成下面的示例会怎么样?
- return 语句返回常量字符串:
char *GetString2(void)
{
char *p = "hello world";
return p;
}
void Test5(void)
{
char *str = NULL;
str = GetString2();
cout<< str << endl;
}
- 函数 Test5 运行虽然不会出错,但是函数 GetString2 的设计概念却是错误的。
- 因为 GetString2 内的“hello world”是常量字符串,位于静态存储区,它在程序生命期内恒定不变。
- 无论什么时候调用 GetString2,它返回的始终是同一个“只读”的内存块。
7.5 free 和 delete 把指针怎么啦
- 别看 free 和 delete 的名字恶狠狠的(尤其是 delete),它们只是把指针所指的内存给释放掉,但并没有把指针本身干掉。
char *p = (char *) malloc(100);
strcpy(p, “hello”);
free(p); // p 所指的内存被释放,但是 p 所指的地址仍然不变
//…
if(p != NULL) // 没有起到防错作用
{
strcpy(p, “world”); // 出错
}
- 用调试器跟踪示例 ,发现指针 p 被 free 以后其地址仍然不变(非 NULL),只是该地址对应的内存是垃圾,p 成了“野指针”。如果此时不把 p 设置为 NULL,会让人误以为 p 是个合法的指针。
- 如果程序比较长,我们有时记不住 p 所指的内存是否已经被释放,在继续使用 p 之前,通常会用语句 if (p != NULL)进行防错处理。很遗憾,此时 if 语句起不到防错作用,因为即便 p 不是 NULL 指针,它也不指向合法的内存块。
- 所以切记:要初始化指针,释放指针后要置成空指针。
7.6 动态内存会被自动释放吗?
void Func(void)
{
char *p = (char *) malloc(100); // 动态内存会自动释放吗?
}
函数体内的局部变量在函数结束时自动消亡。很多人误以为示例是正确的。理由是 p 是局部的指针变量,它消亡的时候会让它所指的动态内存一起完蛋。这是错觉!
- 我们发现指针有一些“似是而非”的特征:
- 指针消亡了,并不表示它所指的内存会被自动释放。
- 内存被释放了,并不表示指针会消亡或者成了NULL指针。
- 这表明释放内存并不是一件可以草率对待的事。也许有人不服气,一定要找出可以草率行事的理由:
- 如果程序终止了运行,一切指针都会消亡,动态内存会被操作系统回收。既然如此,在程序临终前,就可以不必释放内存、不必将指针设置为 NULL 了。终于可以偷懒而不会发生错误了吧?
- 想得美。如果别人把那段程序取出来用到其它地方怎么办?
7.7 杜绝“野指针”
- “野指针”不是 NULL 指针,是指向“垃圾”内存的指针。人们一般不会错用 NULL指针,因为用 if 语句很容易判断。但是“ 野指针 ” 是很危险的,if 语句对它不起作用。
“野指针”的成因主要有两种:
(1)指针变量没有被初始化。任何指针变量刚被创建时不会自动成为 NULL 指针,它的缺省值是随机的,它会乱指一气。***所以,指针变量在创建的同时应当被初始化,要么将指针设置为 NULL,要么让它指向合法的内存。***例如:
char *p = NULL;
char *str = (char *) malloc(100);
(2)指针 p 被 free 或者 delete 之后,没有置为 NULL,让人误以为 p 是个合法的指针。参见 7.5 节。
(3)指针操作超越了变量的作用范围。这种情况让人防不胜防,示例程序如下:
class A
{
public:
void Func(void){ cout << “Func of class A” << endl; }
};
void Test(void)
{
A *p;
{
A a;
p = &a; // 注意 a 的生命期
}
p->Func(); // p 是“野指针”
}
- 函数 Test 在执行语句 p->Func()时,对象 a 已经消失,而 p 是指向 a 的,所以 p 就成了“野指针”。但奇怪的是我运行这个程序时居然没有出错,这可能与编译器有关。
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 的整数类型的内存,程序如下:
- 我们应当把注意力集中在两个要素上: “ 类型转换 ” 和 “ sizeof ”。
int *p = (int *) malloc(sizeof(int) * length);
- malloc 返回值的类型是
void *
,所以在调用 malloc 时要显式地进行类型转换,将void *
转换成所需要的指针类型。 - malloc 函数本身并不识别要申请的内存是什么类型,它只关心内存的总字节数。
- 我们通常记不住 int, float 等数据类型的变量的确切字节数。
- 例如 int 变量在 16 位系统下是 2 个字节,在 32 位下是 4 个字节;
- 而 float 变量在 16 位系统下是 4 个字节,在32 位下也是 4 个字节。
- 最好用以下程序作一次测试:
cout << sizeof(char) << endl;
cout << sizeof(int) << endl;
cout << sizeof(unsigned int) << endl;
cout << sizeof(long) << endl;
cout << sizeof(unsigned long) << endl;
cout << sizeof(float) << endl;
cout << sizeof(double) << endl;
cout << sizeof(void *) << endl;
//提示:在 malloc 的“()”中使用 sizeof 运算符是良好的风格。
//但要当心有时我们会昏了头,写出 p = malloc(sizeof(p))这样的程序来。
函数 free 的原型如下:void free( void * memblock );
- 为什么 free 函数不象 malloc 函数那样复杂呢?
- 这是因为指针 p 的类型以及它所指的内存的容量事先都是知道的,语句
free (p)
能正确地释放内存。 - 如果 p 是 NULL 指针,那么 free 对 p 无论操作多少次都不会出问题。
- 如果 p 不是 NULL 指针,那么 free 对 p连续操作两次就会导致程序运行错误。
- 这是因为指针 p 的类型以及它所指的内存的容量事先都是知道的,语句
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 …);
重载是如何实现的?
- 几个同名的重载函数仍然是不同的函数,它们是如何区分的呢?我们自然想到函数接口的两个要素:参数与返回值。
- 如果同名函数的参数不同(包括类型、顺序不同),那么容易区别出它们是不同的函数。
- 如果同名函数仅仅是返回值类型不同,有时可以区分,有时却不能。例如:
void Function(void); int Function (void);
- 上述两个函数,第一个没有返回值,第二个的返回值是 int 类型。如果这样调用函数:
int x = Function ();
则可以判断出 Function 是第二个函数。问题是在 C++/C 程序中,我们可以忽略函数的返回值。在这种情况下,编译器和程序员都不知道哪个 Function 函数被调用。
- 上述两个函数,第一个没有返回值,第二个的返回值是 int 类型。如果这样调用函数:
所以只能靠参数而不能靠返回值类型的不同来区分重载函数。
- 编译器根据参数为每个重载函数产生不同的内部标识符。例如编译器为示例 中的三个 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 是全局函数而非成员函数
当心隐式类型转换导致重载函数产生二义性
- 示例中,第一个 output 函数的参数是 int 类型,第二个 output 函数的参数是 float 类型。由于数字本身没有类型,将数字当作参数时将自动进行类型转换(称为隐式类型转换)。
- 语句 output(0.5)将产生编译错误,因为编译器不知道该将 0.5 转换成int 还是 float 类型的参数。隐式类型转换在很多地方可以简化程序的书写,但是也可能留下隐患。
- 示例隐式类型转换导致重载函数产生二义性 :
#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::B(const A &a)
: m_a(a)
{
//..
}
//示例 9-2(b) 成员对象在函数体内被初始化
B::B(const A &a)
{
m_a = 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 的构造函数采用了第二种初始化方式。
//示例 9-2(c) 数据成员在初始化表中被初始化
F::F(int x, int y)
: m_x(x), m_y(y)
{
m_i = 0;
m_j = 0;
}
//示例 9-2(d) 数据成员在函数体内被初始化
F::F(int x, int y)
{
m_x = x;
m_y = y;
m_i = 0;
m_j = 0;
}
9.3 构造和析构的次序
- 构造从类层次的最根处开始,在每一层中,首先调用基类的构造函数,然后调用成员对象的构造函数。析构则严格按照与构造相反的次序执行,该次序是唯一的,否则编译器将无法自动执行析构过程。
一个有趣的现象是,成员对象初始化的次序完全不受它们在初始化表中次序的影响,只由成员对象在类中声明的次序决定。这是因为类的声明是唯一的,而类的构造函数可以有多个,因此会有多个不同次序的初始化表。如果成员对象按照初始化表的次序进行构造,这将导致析构函数无法得到唯一的逆序。[Eckel, p260-261]
9.4 示例:类String 的构造函数与析构函数
String::String(const char *str)
{
if(str==NULL)
{
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(void)
{
delete [] m_data;
// 由于m_data是内部数据类型,也可以写成 delete m_data;
}
9.5 不要轻视拷贝构造函数与赋值函数
由于并非所有的对象都会使用拷贝构造函数和赋值函数,程序员可能对这两个函数有些轻视。
请先记住以下的警告,在阅读正文时就会多心:
- 本章开头讲过,如果不主动编写拷贝构造函数和赋值函数,编译器将以“位拷贝”的方式自动生成缺省的函数。倘若类中含有指针变量,那么这两个缺省的函数就隐含了错误。以类String的两个对象 a, b 为例,假设 a.m_data 的内容为 “hello” ,b.m_data 的内容为 “world” 。
- 现将a赋给b,缺省赋值函数的“位拷贝”意味着执行 b.m_data = a.m_data。这将造成三个错误:
- 一是 b.m_data 原有的内存没被释放,造成内存泄露;
- 二是 b.m_data 和 a.m_data 指向同一块内存,a 或 b 任何一方变动都会影响另一方;
- 三是在对象被析构时,m_data被释放了两次。
- 拷贝构造函数和赋值函数非常容易混淆,常导致错写、错用。拷贝构造函数是在对象被创建时调用的,而赋值函数只能被已经存在了的对象调用。以下程序中,第三个语句和第四个语句很相似,你分得清楚哪个调用了拷贝构造函数,哪个调用了赋值函数吗?
String a(“hello”);
String b(“world”);
String c = a; // 调用了拷贝构造函数,最好写成 c(a);
c = b; // 调用了赋值函数
本例中第三个语句的风格较差,宜改写成 String c(a) 以区别于第四个语句。
9.6 示例:类String 的拷贝构造函数与赋值函数
拷贝构造函数
String::String(const String &other)
{
// 允许操作 other 的私有成员 m_data
int length = strlen(other.m_data);
m_data = new char[length+1];
strcpy(m_data, other.m_data);
}
赋值函数
String & String::operate =(const String &other)
{
// (1) 检查自赋值
if(this == &other)
return *this;
// (2) 释放原有的内存资源
delete [] m_data;
// (3)分配新的内存资源,并复制内容
int length = strlen(other.m_data);
m_data = new char[length+1];
strcpy(m_data, other.m_data);
// (4)返回本对象的引用
return *this;
}
两者的区别
- 类 String 拷贝构造函数与普通构造函数(参见 9.4 节)的区别是:在函数入口处无需与 NULL 进行比较,这是因为“引用”不可能是 NULL,而“指针”可以为 NULL。
- 类 String 的赋值函数比构造函数复杂得多,分四步实现:
- 第一步,检查自赋值。你可能会认为多此一举,难道有人会愚蠢到写出 a = a 这样的自赋值语句!的确不会。但是间接的自赋值仍有可能出现。
- 也许有人会说:“即使出现自赋值,我也可以不理睬,大不了化点时间让对象复制自己而已,反正不会出错!”
- 他真的说错了。看看第二步的 delete,自杀后还能复制自己吗?所以,如果发现自赋值,应该马上终止函数。注意不要将检查自赋值的 if 语句
if(this == &other)
错写成为if( *this == other)
- 第二步,用 delete 释放原有的内存资源。如果现在不释放,以后就没机会了,将造成内存泄露。
- 第三步,分配新的内存资源,并复制字符串。注意函数 strlen 返回的是有效字符串长度,不包含结束符 ‘\0’ 。函数 strcpy 则连 ‘\0’ 一起复制。
- 第四步,返回本对象的引用,目的是为了实现象 a = b = c 这样的链式表达。注意不要将 return *this 错写成 return this 。那么能否写成 return other 呢?效果不是一样吗? 另外因为C++ 的赋值操作符 (=) 的结合顺序是从右向左。
- 不可以!因为我们不知道参数 other 的生命期。有可能 other 是个临时对象,在赋值结束后它马上消失,那么 return other 返回的将是垃圾。
- 第一步,检查自赋值。你可能会认为多此一举,难道有人会愚蠢到写出 a = a 这样的自赋值语句!的确不会。但是间接的自赋值仍有可能出现。
9.7 偷懒的办法处理拷贝构造函数与赋值函数
- 如果我们实在不想编写拷贝构造函数和赋值函数,又不允许别人使用编译器生成的缺省函数,怎么办?
- 偷懒的办法是:只需将拷贝构造函数和赋值函数声明为私有函数,不用编写代码。例如:
class A
{
//...
private:
A(const A &a); // 私有的拷贝构造函数
A & operate =(const A &a); // 私有的赋值函数
};
如果有人试图调用这两个私有函数,编译器将指出错误,因为外界不可以操作 A 的私有函数。
9.8 如何在派生类中实现类的基本函数
- 基类的构造函数、析构函数、赋值函数都不能被派生类继承。如果类之间存在继承关系,在编写上述基本函数时应注意以下事项:
- 派生类的构造函数应在其初始化表里调用基类的构造函数。
- 基类与派生类的析构函数应该为虚(即加 virtual 关键字)。例如:
#include <iostream.h>
class Base
{
public:
virtual ~Base() { cout<< "~Base" << endl ; }
};
class Derived : public Base
{
public:
virtual ~Derived() { cout<< "~Derived" << endl ; }
};
void main(void)
{
Base * pB = new Derived; // upcast
delete pB;
}
- 输出结果:
~Derived ~Base
,如果析构函数不为虚,那么输出结果为:~Base
。 - 在 C++ 中,当一个派生类对象被销毁时,析构函数的调用顺序是先调用派生类的析构函数,然后再自动调用基类的析构函数,这个顺序是固定的,确保在销毁对象时先清理派生类部分的资源,再清理基类部分的资源。
在编写派生类的赋值函数时,注意不要忘记对基类的数据成员重新赋值。例如:
class Base
{
public:
…
Base & operate =(const Base &other); // 类 Base 的赋值函数
private:
int m_i, m_j, m_k;
};
class Derived : public Base
{
public:
…
Derived & operate =(const Derived &other); // 类 Derived 的赋值函数
private:
int m_x, m_y, m_z;
};
Derived & Derived::operate =(const Derived &other)
{
//(1)检查自赋值
if(this == &other)
return *this;
//(2)对基类的数据成员重新赋值
Base::operate =(other); // 因为不能直接操作私有数据成员
//(3)对派生类的数据成员赋值
m_x = other.m_x;
m_y = other.m_y;
m_z = other.m_z;
//(4)返回本对象的引用
return *this;
}
9.9 一些心得体会
- 有些 C++程序设计书籍称构造函数、析构函数和赋值函数是类的 “Big-Three” ,它们的确是任何类最重要的函数,不容轻视。
- 也许你认为本章的内容已经够多了,学会了就能平安无事,我不能作这个保证。如果你希望吃透 “Big-Three” ,请好好阅读参考文献[Cline] [Meyers] [Murry] 。
第 10 章 类的继承与组合
- 对象(Object)是类(Class)的一个实例(Instance)。如果将对象比作房子,那么类就是房子的设计图纸。所以面向对象设计的重点是类的设计,而不是对象的设计。
- 对于 C++程序而言,设计孤立的类是比较容易的,难的是正确设计基类及其派生类。本章仅仅论述 “继承”(Inheritance)和 “组合”(Composition)的概念。
- 注意,当前面向对象技术的应用热点是 COM 和 CORBA,这些内容超出了 C++教材的范畴,请阅读 COM 和 CORBA 相关论著。
- COM(Component Object Model,组件对象模型)和 CORBA(Common Object Request Broker Architecture,公共对象请求代理体系结构)是面向对象技术在分布式系统中的重要应用。
- COM 是微软开发的一种组件技术,用于在不同的应用程序之间实现二进制级别的代码重用和交互。它定义了一套标准的接口和规范,使得不同的软件组件可以以统一的方式进行交互和通信。
- CORBA 是由 OMG(Object Management Group)制定的一种分布式对象技术标准。它提供了一种在不同的编程语言和操作系统之间实现对象通信和互操作的机制。
- COM(Component Object Model,组件对象模型)和 CORBA(Common Object Request Broker Architecture,公共对象请求代理体系结构)是面向对象技术在分布式系统中的重要应用。
总之,COM 和 CORBA 都是面向对象技术在分布式系统中的重要应用,它们提供了强大的组件化和分布式对象通信机制,使得不同的软件系统可以更加灵活地进行集成和协作。对于深入学习面向对象技术和分布式系统开发的人来说,阅读相关的论著是非常有必要的。
10.1 继承
- 如果 A 是基类,B 是 A 的派生类,那么 B 将继承 A 的数据和函数。例如:
class A
{
public:
void Func1(void);
void Func2(void);
};
class B : public A
{
public:
void Func3(void);
void Func4(void);
};
main()
{
B b;
b.Func1(); // B 从 A 继承了函数 Func1
b.Func2(); // B 从 A 继承了函数 Func2
b.Func3();
b.Func4();
}
这个简单的示例程序说明了一个事实:C++的“继承”特性可以提高程序的可复用性。正因为“继承”太有用、太容易用,才要防止乱用“继承”。我们应当给“继承”立一些使用规则。
【规则 10-1-1】如果类 A 和类 B 毫不相关,不可以为了使 B 的功能更多些而让 B 继承 A 的功能和属性。不要觉得“白吃白不吃”,让一个好端端的健壮青年无缘无故地吃人参补身体。
【规则 10-1-2】若在逻辑上 B 是 A 的“一种”( a kind of ),则允许 B 继承 A 的功能和属性。例如男人(Man)是人(Human)的一种,男孩(Boy)是男人的一种。那么类 Man 可以从类 Human 派生,类 Boy 可以从类 Man 派生。
class Human
{
…
};
class Man : public Human
{
…
};
class Boy : public Man
{
…
};
注意事项
【规则 10-1-2】看起来很简单,但是实际应用时可能会有意外,继承的概念在程序世界与现实世界并不完全相同。
例如从生物学角度讲,鸵鸟(Ostrich)是鸟(Bird)的一种,按理说类 Ostrich 应该可以从类 Bird 派生。但是鸵鸟不能飞,那么 Ostrich::Fly 是什么东西?
class Bird
{
public:
virtual void Fly(void);
…
};
class Ostrich : public Bird
{
…
};
- 例如从数学角度讲,圆(Circle)是一种特殊的椭圆(Ellipse),按理说类 Circle 应该可以从类 Ellipse 派生。但是椭圆有长轴和短轴,如果圆继承了椭圆的长轴和短轴,岂非画蛇添足?
- 所以更加严格的继承规则应当是:若在逻辑上 B 是 A 的“一种”,并且 A 的所有功能和属性对 B 而言都有意义,则允许 B 继承 A 的功能和属性。
10.2 组合
【规则 10-2-1】若在逻辑上 A 是 B 的“一部分”( a part of ),则不允许 B 从 A 派生,而是要用 A 和其它东西组合出 B。
例如眼(Eye)、鼻(Nose)、口(Mouth)、耳(Ear)是头(Head)的一部分,所以类 Head 应该由类 Eye、Nose、Mouth、Ear 组合而成,不是派生而成。如示例所示:Head 由 Eye、Nose、Mouth、Ear 组合而成。
class Eye
{
public:
void Look(void);
};
class Nose
{
public:
void Smell(void);
};
class Mouth
{
public:
void Eat(void);
};
class Ear
{
public:
void Listen(void);
};
// 正确的设计,虽然代码冗长。
class Head
{
public:
void Look(void) { m_eye.Look(); }
void Smell(void) { m_nose.Smell(); }
void Eat(void) { m_mouth.Eat(); }
void Listen(void) { m_ear.Listen(); }
private:
Eye m_eye;
Nose m_nose;
Mouth m_mouth;
Ear m_ear;
- 如果允许 Head 从 Eye、Nose、Mouth、Ear 派生而成,那么 Head 将自动具有 Look、Smell、Eat、Listen 这些功能。示例十分简短并且运行正确,但是这种设计方法却是不对的。
// 功能正确并且代码简洁,但是设计方法不对。
class Head : public Eye, public Nose, public Mouth, public Ear
{
};
- 一只公鸡使劲地追打一只刚下了蛋的母鸡,你知道为什么吗?因为母鸡下了鸭蛋。很多程序员经不起“继承”的诱惑而犯下设计错误。“运行正确”的程序不见得是高质量的程序,此处就是一个例证。
第 11 章 其它编程经验
11.1 使用 const 提高函数的健壮性
- const 更大的魅力是它可以修饰函数的参数、返回值,甚至函数的定义体。const 是 constant 的缩写,“恒定不变”的意思。
- 被 const 修饰的东西都受到强制保护,可以预防意外的变动,能提高程序的健壮性。所以很多 C++程序设计书籍建议:“Use const whenever you need”。
11.1.1 用 const 修饰函数的参数
- 用 const 修饰函数的参数;输出参数不能用 const. 。例如 StringCopy 函数:
void StringCopy(char *strDestination, const char *strSource);
其中 strSource 是输入参数,strDestination 是输出参数。给 strSource 加上 const修饰后,如果函数体内的语句试图改动 strSource 的内容,编译器将指出错误。
- 如果输入参数采用“值传递”,由于函数将自动产生临时变量用于复制该参数,该输入参数本来就无需保护,所以不要加 const 修饰。
- 例如不要将函数
void Func1(int x)
写成void Func1(const int x)
。同理不要将函数void Func2(A a)
写成void Func2(const A a)
。其中 A 为用户自定义的数据类型。
- 例如不要将函数
- 对于非内部数据类型的参数而言,象
void Func(A a)
这样声明的函数注定效率比较底。因为函数体内将产生 A 类型的临时对象用于复制参数 a,而临时对象的构造、复制、析构过程都将消耗时间。- 为了提高效率,可以将函数声明改为
void Func(A &a)
,因为“ 引用传递 ” 仅借用一下参数的别名而已,不需要产生临时对象。但是函数 void Func(A &a) 存在一个缺点:“引用传递”有可能改变参数 a,这是我们不期望的。解决这个问题很容易,加 const 修饰即可,因此函数最终成为void Func(const A &a)
。
- 为了提高效率,可以将函数声明改为
- 以此类推,是否应将 void Func(int x) 改写为 void Func(const int &x),以便提高效率?完全没有必要,因为内部数据类型的参数不存在构造、析构的过程,而复制也非常快,“值传递”和“引用传递”的效率几乎相当。
问题是如此的缠绵,我只好将“const &”修饰输入参数的用法总结一下:
对于非内部数据类型的输入参数,应该将“值传递”的方式改为“const 引用传递”,目的是提高效率。例如将 void Func(A a) 改为 void Func(const A &a)。 | |
---|---|
对于内部数据类型的输入参数,不要将“值传递”的方式改为“const 引用传递”。否则既达不到提高效率的目的,又降低了函数的可理解性。例如 void Func(int x) 不应该改为 void Func(const int &x)。 |
11.1.2 用 const 修饰函数的返回值
- 如果给以“指针传递”方式的函数返回值加 const 修饰,那么函数返回值(即指针)的内容不能被修改,该返回值只能被赋给加 const 修饰的同类型指针。 例如函数 :
const char * GetString(void); *
//如下语句将出现编译错误:
//char *str = GetString();
//正确的用法是
const char *str = GetString();*
- 如果函数返回值采用“值传递方式”,由于函数会把返回值复制到外部临时的存储单元中,加 const 修饰没有任何价值。 例如不要把函数 int GetInt(void) 写成 const int GetInt(void)。
- 如果返回值不是内部数据类型,将函数
A GetA(void)
改写为const A & GetA(void)
的确能提高效率。但此时千万千万要小心,一定要搞清楚函数究竟是想返回一个对象的“拷贝”还是仅返回“别名”就可以了,否则程序会出错。见 6.2 节“返回值的规则”。 - 函数返回值采用“引用传递”的场合并不多,这种方式一般只出现在类的赋值函数中,目的是为了实现链式表达。 例如:
class A
{
//…
A & operate = (const A &other); // 赋值函数
};
A a, b, c; // a, b, c 为 A 的对象
…
a = b = c; // 正常的链式赋值
(a = b) = c; // 不正常的链式赋值,但合法
//如果将赋值函数的返回值加 const 修饰,那么该返回值的内容不允许被改动。
//上例中,语句 a = b = c 仍然正确,但是语句 (a = b) = c 则是非法的。
11.1.3 const 成员函数
任何不会修改数据成员的函数都应该声明为 const 类型。如果在编写 const 成员函数时,不慎修改了数据成员,或者调用了其它非 const 成员函数,编译器将指出错误,这无疑会提高程序的健壮性。
以下程序中,类 stack 的成员函数 GetCount 仅用于计数,从逻辑上讲 GetCount 应当为 const 函数。编译器将指出 GetCount 函数中的错误。
class Stack
{
public:
void Push(int elem);
int Pop(void);
int GetCount(void) const; // const 成员函数
private:
int m_num;
int m_data[100];
};
int Stack::GetCount(void) const
{
++ m_num; // 编译错误,企图修改数据成员 m_num
Pop(); // 编译错误,企图调用非 const 函数
return m_num;
}
- const 成员函数的声明看起来怪怪的:const 关键字只能放在函数声明的尾部,大概是因为其它地方都已经被占用了。
11.2 提高程序的效率
- 程序的时间效率是指运行速度,空间效率是指程序占用内存或者外存的状况。
- 全局效率是指站在整个系统的角度上考虑的效率,局部效率是指站在模块或函数角度上考虑的效率。
- 【规则 11-2-1】不要一味地追求程序的效率,应当在满足正确性、可靠性、健壮性、可读性等质量因素的前提下,设法提高程序的效率。
- 【规则 11-2-2】以提高程序的全局效率为主,提高局部效率为辅。
- 【规则 11-2-3】在优化程序的效率时,应当先找出限制效率的“瓶颈”,不要在无关紧要之处优化。
- 【规则 11-2-4】先优化数据结构和算法,再优化执行代码。
- 【规则 11-2-5】有时候时间效率和空间效率可能对立,此时应当分析那个更重要,作出适当的折衷。例如多花费一些内存来提高性能。
- 【规则 11-2-6】不要追求紧凑的代码,因为紧凑的代码并不能产生高效的机器码。
11.3 一些有益的建议
- 【建议 11-3-1】当心那些视觉上不易分辨的操作符发生书写错误。我们经常会把“==”误写成“=”,象“||”、“&&”、“<=”、“>=”这类符号也很容易发生“丢 1”失误。然而编译器却不一定能自动指出这类错误。
- 【建议 11-3-2】变量(指针、数组)被创建之后应当及时把它们初始化,以防止把未被初始化的变量当成右值使用。
- 【建议 11-3-3】当心变量的初值、缺省值错误,或者精度不够
- 【建议 11-3-4】当心数据类型转换发生错误。尽量使用显式的数据类型转换(让人们知道发生了什么事),避免让编译器轻悄悄地进行隐式的数据类型转换。
- 【建议 11-3-5】当心变量发生上溢或下溢,数组的下标越界。
- 【建议 11-3-6】当心忘记编写错误处理程序,当心错误处理程序本身有误。
- 【建议 11-3-7】当心文件 I/O 有错误。
- 【建议 11-3-8】避免编写技巧性很高代码。
- 【建议 11-3-9】不要设计面面俱到、非常灵活的数据结构。
- 【建议 11-3-10】如果原有的代码质量比较好,尽量复用它。但是不要修补很差劲的代码,应当重新编写。
在 C++ 中,右值是一个重要的概念。
一、定义
- 右值是指可以出现在赋值表达式右边的值。它通常是临时对象或者即将被销毁的对象。例如,在表达式
int a = 5;
中,数字5
就是一个右值。
二、分类
- 纯右值(prvalue):
- 纯右值通常是字面量、临时对象或函数返回的非引用类型的值。例如,函数调用
foo()
返回的值在大多数情况下是纯右值,除非函数返回引用。 - 纯右值一般没有内存地址,不能通过取地址运算符
&
获取其地址。
- 纯右值通常是字面量、临时对象或函数返回的非引用类型的值。例如,函数调用
- 将亡值(xvalue):
- 将亡值是一种特殊的右值,它表示即将被移动或销毁的资源。例如,通过
std::move()
函数将一个左值转换为右值时,得到的就是将亡值。 - 将亡值可以被移动语义(move semantics)所利用,以高效地转移资源而不是进行传统的拷贝操作。
- 将亡值是一种特殊的右值,它表示即将被移动或销毁的资源。例如,通过
三、右值引用
C++11 引入了右值引用(T&&
)的概念。右值引用主要用于实现移动语义和完美转发。
- 移动语义:
- 利用右值引用,可以实现将资源从一个对象转移到另一个对象,而不是进行传统的深拷贝操作。例如,对于一个自定义的类,如果定义了移动构造函数和移动赋值运算符,就可以在适当的时候利用移动语义来高效地转移资源。
- 移动构造函数和移动赋值运算符通常接受右值引用作为参数,通过接管右值所拥有的资源,避免不必要的资源分配和释放操作。
- 完美转发:
- 右值引用还可以用于实现完美转发,即函数模板可以将参数原封不动地转发给另一个函数,保持参数的左值/右值属性不变。
四、应用场景
- 优化性能:
- 通过移动语义,可以避免不必要的拷贝操作,特别是对于大型对象或资源密集型对象,能够显著提高程序的性能。
- 例如,在容器类的操作中,如果使用移动语义,可以高效地插入和删除元素,而不是进行低效的拷贝操作。
- 资源管理:
- 右值引用可以用于管理动态分配的资源,如内存、文件句柄等。通过移动语义,可以在对象之间高效地转移资源的所有权,避免资源泄漏和重复释放的问题。
总之,理解 C++中的右值概念对于编写高效、灵活的 C++程序非常重要。右值引用的引入为 C++带来了更强大的功能和性能优化手段。
参考文献
- [Cline] Marshall P. Cline and Greg A. Lomow, C++ FAQs, Addison-Wesley, 1995
- [Eckel] Bruce Eckel, Thinking in C++(C++ 编程思想,刘宗田 等译),机械工业出版社,2000
- [Maguire] Steve Maguire, Writing Clean Code(编程精粹,姜静波 等译),电子工业出版社,1993
- [Meyers] Scott Meyers, Effective C++, Addison-Wesley, 1992
- [Murry] Robert B. Murry, C++ Strategies and Tactics, Addison-Wesley, 1993
- [Summit] Steve Summit, C Programming FAQs, Addison-Wesley, 1996
附录 A :C++/C 代码审查表
1.0
2.0
3.0
4.0
5.0
封面图
结束
- 如有阅读中感到不大对、不连贯时,请以原书为准,请以原书为准,请以原书为准…
- 确实是一本好书。本篇相较于原书PDF版本略方便的地方可能在于:热闹、字体没那么粗…。
- 2024国庆期间大致修缮完毕。