温故而知新
参考: 林锐 《高质量C/C++编程》
极小部分规范是自己根据个人经验自行修改或添加的
/*
* Copyright (c) 2020, tsuibeyond
* All rights reserved.
*
* FileName: example.h
* Abstract:
* Author: tsuibeyond
* Date: 2020.06.06
* Version: 0.0
*/
#ifndef _EXAMPLE_H_
#define _EXAMPLE_H_
#include //标准库头文件引用
#include "userheader.h" //非标准库头文件引用
void myFunction(...); //全局函数声明
class MyClass // 类结构声明
{
...
}
#endif
/*
* Copyright (c) 2020, tsuibeyond
* All rights reserved.
*
* FileName: example.h
* Abstract:
* Author: tsuibeyond
* Date: 2020.06.06
* Version: 0.0
*/
#include "example.h"
// 全局函数的实现体
void MyFunction(...)
{
}
// 类成员函数的实现体
void MyClass::myFunc(...)
{
...
}
// end for if
// end for while
将public类型的函数写在类内部的前面,private类型的数据写在类内部的后面,即以
以行为为中心
重点关注类应该实现什么样的接口。
(不仅让自己在设计类的时候思路清晰,也方便其他人阅读,没有人乐意先看到一堆私有数据成员!)
没有一种命名规则能让所有程序员赞同
AddElement
和 add_element
int x, X;
void foo(int x);
void FOO(int x);
float value
float oldValue
float newValue
BoilEgg(); // 全局函数
egg->boil(); // 类的成员函数
class Point; // 类名
class Line; // 类名
void Draw(void); // 全局函数名
void SetValue(void); // 全局函数名
bool flag;
int actMode;
const int MAX = 100;
const int MAX_LENGTH = 100;
void Init(void)
{
static int s_initValue; // 静态变量
...
}
int g_howManyPeople;
int g_howMuchMoney;
void Object::setValue(int width, int height)
{
m_width = width;
m_height = height;
}
如果代码行中的运算符比较多,需要用括号确定表达式的操作顺序,避免使用默认的优先级。
如:if((a|b) && (a&c))
if(flag) // 表示 flag 为真
if(!flag) // 表示 flag 为假
if(flag == TRUE)
if(flag == 1)
if(flag == FALSE)
if(flag == 0)
if(value == 0)
if(value != 0)
// 不可以模仿布尔变量的风格写成
if(value) // 会让人误解value是布尔变量
if(!value)
if((x>=-EPSINON) && (x<=EPSINON)) // EPSINON是允许的误差(即精度)
应当将指针变量用“==”或“!=”与NULL比较
指针变量的零值是“空”(记为NULL),尽管NULL的值与0相同,但是两者意义不同,假设指针变量的名字为p,它与零值比较的标准if语句如下:
// p与NULL显示比较,强调p是指针变量
if(p == NULL)
if(p != NULL)
// 不要写成以下
if(p == 0) // 容易让人误解p是整型变量
if(!p) // 容易让人误解p是布尔变量
C语言使用 #define 来定义常量,称为宏常量
C++ 除了使用#define之外,还可以使用 const 来定义常量。
用 const 定义常量相比于#define,有以下优点:
因此,C++程序中只使用 const 常量而不使用 宏常量,即要用
const常量完全取代宏常量
const float RADIUS = 100;
const float DIAMETER = RADIUS *2;
有时我们希望某些常量只在类中有效。由于#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
建立在整个类中都恒定的常量,需要用枚举常量实现,例如
class A
{
enum { SIZE1 = 100; SIZE2 = 200;}; // 枚举常量
int array1[SIZE1];
int array2[SIZE2];
};
枚举常量不会占用对象的存储空间,会在编译时被全部求值。这样做的缺点是:
void SetValue(int width, int height); // 良好的风格
void SetValue(int, int); // 不良的风格
float GetValue(void); // 良好的风格
float GetValue(); // 不良的风格
void StringCopy(char* strSource, char* strDestination);
// 调用时,根据参数命名,可以自然的使用如下调用
char str[20];
StringCopy(str, "hello world");
void StringCopy(char* strDestination, const char* strSource);
const &
方式来传递,这样可以省去临时对象的构造和析构过程,从而提高效率。int printf(const char *format[, argument] ...);
char c;
c = getchar();
if( c == EOF )
{
...
}
按照 getchar 名字的意思,将变量 c 声明为 char 类型是很自然的事情。但
不幸的是 getchar 的确不是 char 类型,而是 int 类型,其原型如下:
int getchar(void);
由于 c 是 char 类型,取值范围是[-128,127],如果宏 EOF 的值在 char 的 取值范围之外,那么 if 语句将总是失败,这种“危险”人们一般哪里料得到! 导致本例错误的责任并不在用户,是函数 getchar 误导了使用者
bool GetChar(char* c);
char* strcpy(char* strDest, const char* strSrc);
char str[20];
int length = strlen(strcpy(str, "hello world"));
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 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;
}
对于相加函数,应当用“值传递”的方式返回String对象。如果改用“引用传递”,那么函数返回值是一个指向局部对象temp的“引用”,由于temp在函数结束时被自动销毁,将导致返回的“引用”无效,例如:
c = a + b;
此时a+b
并不返回期望值,c什么也得不到,留下了隐患。
char* Func(void)
{
char str[] = "hello world"; // str的内存位于栈上
...
return str; // 将导致错误
}
return String(s1+s2);
这是临时对象的语法,表示“创建一个临时对象并返回它”。不要以为它与“先创建一个局部对象temp并返回它的结果”是等价的,如String temp(s1+s2);
return temp;
实质不然,上述代码将发生三件事。首先,temp 对象被创建,同时完成初始化;然后拷贝构造函数把 temp 拷贝到保存返回值的外部存储单元中;最后,temp在函数结束时被销毁(调用析构函数)。然而“创建一个临时对象并返回它”的
过程是不同的,编译器直接把临时对象创建并初始化在外部存储单元中,省去了拷贝和析构的化费,提高了效率。
类似地,不要将
return int(x+y); // 创建一个临时变量并返回它
写成
int temp = x + y; return temp;
由于内部数据类型如 int,float,double 的变量不存在构造函数与析构函数, 虽然该“临时变量的语法”不会提高多少效率,但是程序更加简洁易读。
程序一般分为 Debug 版本和 Release 版本,Debug 版本用于内部调试,Release版本发行给用户使用。
断言 assert 是仅在 Debug 版本起作用的宏,它用于检查“不应该”发生的情况。在运行过程中,如果 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 不是一个仓促拼凑起来的宏。为了不在程序的 Debug 版本和 Release版本引起差别,assert 不应该产生任何副作用。所以 assert 不是函数,而是宏。
程序员可以把 assert 看成一个在任何系统状态下都可以安全使用的无害测试手段。如果程序在 assert 处终止了 assert 处终止了,并不是说含有该 ,并不是说含有该 assert 的函数有错误 assert 的函数有错误,而是调用者出了差错,assert ,assert 可以帮助我们找到发生错误的原因 assert 可以帮助我们找到发生错误的原因。
引用是 C++中的概念,初学者容易把引用和指针混淆一起。一下程序中,n 是 m 的一个引用(reference),m 是被引用物(referent)。
int m;
int &n = m;
n相当于m的别名(绰号),对n的任何操作就是对m的操作。
引用的一些规则如下:
一个容易混淆的例子:
int i = 5;
int j = 6;
int &k = i;
k = j; // k和i的值都变成了6
上面的程序看起来象在玩文字游戏,没有体现出引用的价值。引用的主 要功能是传递函数的参数和返回值。C++语言中,函数的参数和返回值的传递方式有三种:值传递、指针传递和引用传递
实际上,“引用”可以做的任何事情“指针”也都能够做,为什么还要设计“引用”机制?
实际上是为了用适当的工具做恰如其分的工作。
(减小使用指针时可能造成的风险)
C++/C程序中,指针和数组在不少地方可以相互替换着用,但实际上两者是不等价的。
#include
int main()
{
std::cout << "Hello World!\n";
char a[] = "hello";
a[0] = 'X';
std::cout << a << std::endl;
char* p = (char*)"world"; // 注意p指向常量字符串
p[0] = 'X'; // 编译器不能发现该错误,可以正常通过编译,但是会在执行时报错
std::cout << p << std::endl;
return 0;
}
数组和指针的内容复制方面比较类似
// 数组
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)) // 不能使用if(p == a)
C++/C语言无法知道指针所指的内存容量,除非在申请内存时记住它。如:
char a[] = "hello world";
char *p = a;
cout<<sizeof(a)<<endl; // 12 字节
cout<<sizeof(p)<<endl; // 4 字节
注意当数组作为函数的参数进行传递时,该数组自动退化为同类型的指针 ,该数组自动退化为同类型的指针。
void Func(char a[100])
{
cout<<sizeof(a)<<endl; // 4字节而不是100字节
}
如果函数的参数是一个指针,不要指望用该指针去申请动态内存。如:
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"); // 运行错误
}
问题分析:编译器总是要为函数的每个参数制作临时副本,指针参数 p 的副本是 _p,编译器使 _p = p。如果函数体内的程序修改了_p的内容,就导致参数 p 的内容作相应的修改。这是指针可以用作输出参数的原因。在本例中,_p 申请了新的内存,只是把 _p 所指的内存地址改变了,但是 p 丝毫未变。所以函数 GetMemory 并不能输出任何东西。事实上,每执行一次GetMemory 就会泄露一块内存,因 为没有用 free 释放内存。
**解决方法1:**改用 “指向指针的指针”,如
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);
}
**解决方法2:**用函数返回值来传递动态内存,这种方法更加简单,如
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语句返回指向“栈内存”的指针,因为该内存在函数结束时自动消亡。如下例子:
char *GetString(void)
{
// return 语句返回指向了“栈内存”的指针
char p[] = "hello world";
return p; // 编译器将提出警告
}
void test4(void)
{
char *str = NULL;
str = GetString(); // str的内容是垃圾
cou<<str<<endl;
}
如果将上例改写为:
char *GetString2(void)
{
char* p = "hello world";
return p;
}
void Test5(void)
{
char* str = NULL;
str = GetString2();
cout<<str<<endl;
}
函数运行虽然不会出错,但是设计概念有错,因为“hello world”是常量字符串,位于静态存储区,它在程序生命周期中恒定不变。无论什么时候调用GetString2,它返回的始终是同一个“只读”的内存块。
free与delete只把指针所指的内存释放掉,但是并没有把指针本身清除。
一个指针p被free或delete之后,其地址仍然不变(非NULL),只是该地址对应的内存是垃圾,p成了“野指针”。如果此时不把p设置成NULL,会让人误以为p是个合法的指针。
基于以上分析,用if(p!=NULL)进行防错处理将起不到预期的作用,因为即便p不是NULL指针,它也不指向合法的内存块。
函数体内的局部变量在函数结束时自动消亡,但是局部指针在函数内申请的内存,不会自动释放,如
void Func(void)
{
char* p = (char*)malloc(100); // 动态内存不会自动释放
}
“野指针”不是NULL指针,是指向“垃圾”内存的指针。人们一般不会错用 NULL 指针,因为用 if 语句很容易判断。但是“野指针”是很危险的,if 语句对它不起作用。
“野指针”的成因主要有两种:
(1)指针变量没有被初始化。任何指针变量刚被创建时不会自动成为 NULL 指针,它的缺省值是随机的,它会乱指一气。所以,指针变量在创建的同时应当被初始化,要么将指针设置为 NULL,要么让它指向合法的内存。
(2)指针 p 被 free 或者 delete 之后,没有置为 NULL,让人误以为 p 是个合法的指针。
(3)指针操作超越了变量的作用范围。这种情况让人防不胜防。
#include
class A
{
public:
void func(void)
{
std::cout << "func of class A" << std::endl;
}
};
int main()
{
A* p;
{
A a;
p = &a; // 注意 a 的生命周期
}
p->func(); // p 是“野指针”
std::cout << "Hello World!\n";
return 0;
}
但是 上面的程序运行时没有报错或出错!!!有点诡异!
malloc 与 free 是 C++/C 语言的标准库函数,new/delete 是 C++的运算符。它们都可用于申请动态内存和释放内存。
对于非内部数据类型的对象而言,光用 maloc/free 无法满足动态对象的要求。对象在创建的同时要自动执行构造函数,对象在消亡之前要自动执行析构函数。由于 malloc/free 是库函数而不是运算符,不在编译器控制权限之内,不能够把执行构造函数和析构函数的任务强加于 malloc/free。
因此 C++语言需要一个能完成动态内存分配和初始化工作的运算符 new,以及一个能完成清理与释放内存工作的运算符 delete。注意 new/delete不是库函数。
由于内部数据类型的“对象”(如int double之类)没有构造与析构的过程,对它们而言malloc/free 和 new/delete 是等价的。
如果在申请动态内存时找不到足够大的内存块,malloc 和 new 将返回 NULL 指针,宣告内存申请失败。通常判断指针是否为 NULL,如果是则马上用 return 语句或exit(1)终止本函数。如:
void Func(void)
{
A *a = new A;
if(a == NULL)
{
return;
// 或
// exit(1);
}
}
如果发生“内存耗尽”这样的事情,一般说来应用程序已 经无药可救。如果不用 exit(1) 把坏程序杀死,它可能会害死操作系统。
函数 malloc 的原型如下:
void * malloc(size_t size);
用malloc申请一块长度为length的整数类型的内存,程序如下:
int *p = (int*)malloc(sizeof(int)*length);
malloc 返回值的类型是 void*,所以在调用 malloc 时要显式地进行类型 转换,将 void* 转换成所需要的指针类型。
函数free的原型如下:
void free(void * memblock);
是因为指针 p 的类型 以及它所指的内存的容量事先都是知道的,语句 free§能正 确地释放内存。 如果 p 是 NULL 指针,那么 free 对 p 无论操作多少次都不会出问题。
如果 p 不是NULL 指针,那么 free 对 p 连续操作两次就会导致程序 运行错误。
new 内置了 sizeof、类型转换和类型安全检查功能。对于非内部数据类型的对象而言,new 在创建动态对象的同时完成了初始化工作。如果对象有多个构造函数,那么 new 的语句也可以有多种形式。如:
class Obj
{
public:
Obj(void); // 无参数的构造函数
Obj(int x); // 带一个参数的构造函数
}
void Test(void)
{
Obj* a = new Obj;
Obj* b = new Obj(1); //初值为1
// ...
delete a;
delete b;
}
如果用 new 创建对象数组,那么只能使用对象的无参数构造函数。例如
Obj* objects = new Obj[100];
不能写成
Obj* objects = new Obj[100](1);
在用delete释放对象数组时,留意不能丢失符号’[]’,例如:
delete []objects; // 正确的用法
对比于 C 语言的函数,C++增加了重载(overloaded)、内联(inline)、 const 和 virtual 四种新机制。
重载和内联机制既可用于全局函数也可用于类的成员函数,const 与 virtual 机制仅用于类的成员函数。
在 C++程序中,可以将语义、功能相似的几个函数用同一个名字表示, 即函数重载。这样便于记忆,提高了函数的易用性,这是 C++语言采用重 载机制的一个理由。
C++语言采用重载机制的另一个理由是:类的构造函数需要重载机制。因为 C++规定构造函数与类同名,构造函数只能有一个名字。如果想用几种不同的方法创建对象该怎么办?别无选择,只能用重载机制来实现。所以类可以有多个同名的构造函数。
以只能靠参数而不能靠返回值类型的不同来区分重载函数。编译器根据参数为每个重载函数产生不同的内部标识符。
如果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++编译译器,函数 foo 是个 C 连接,应该到库中找名字_foo 而不 是找_foo_int_int。C++编译器开发商已经对 C 标准库的头文件作了 extern“C”处理,所以我们可以用#include 直接引用这些头文件。
注意并不是两个函数的名字相同就能构成重载。全局函数和类的成员函数同名不算重载,因为函数的作用域不同。例如:
void Print(...); // 全局函数
class A
{
void Print(...); // 成员函数
}
不论两个 Print 函数的参数是否不同,如果类的某个成员函数要调用全局函数 Print,为了与成员函数 Print 区别,全局函数被调用时应加‘::’标志。如
::Print(...); // 表示 Print 是全局函数而非成员函数
成员函数的重载、覆盖(override)与隐藏很容易混淆,C++程序员必须 要搞清楚概念,否则错误将防不胜防。
成员函数被重载的特征:
覆盖是指派生类函数覆盖基类函数,特征是:
本来仅仅区别重载与覆盖并不算困难,但是 C++的隐藏规则使问题复杂 性陡然增加。这里“隐藏”是指派生类的函数屏蔽了与其同名的基类函数,规则如下:
有一些参数的值在每次函数调用时都相同,书写这样的语句会使人厌烦。C++语言采用参数的缺省值使书写变得简洁(在编译时,缺省值由编译器自动插入)。
参数缺省值的使用规则:
在 C++语言中,可以用关键字 operator 加上运算符来表示函数,叫做运算符重载。
从语法上讲,运算符既可以定义为全局函数,也可以定义为成员函数。
在 C++运算符集合中,有一些运算符是不允许被重载的。这种限制是出 于安全方面的考虑,可防止错误和混乱:
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++ 而言,使用宏代码还有另一种缺点:无法操作类的私有数据成员。
对于任何内联函数,编译器在符号表里放入函数的声明(包括名字、参数类型、返回值类型)。如果编译器 没有发现内联函数存在错 误,那么该函数的代码也被放入符号表里。在调用一个内联函数时,编译器首先检查调用是否正确(进行类型安全检查,或者进行自动类型转换,当然对所有的函数 都一样)。如果正确,内联函数的代码就会直 接替换函数调用,于是省去了函数调用的开销。这个过程与预处理有显著的不同, 因为预处理器不能进行类型安全检 查,或者进行自动类型转换。假如内联函数是成员函数,对象的地址(this)会被放在合适的地方,这也是预处理器办不到的。
C++ 语言的函数内联机制既具备宏代码的效率,又增加了安全性,而且可以自由操作类的数据成员。所以在 C++ 程序中,应该用内联函数取代所有宏代码,“断言 assert”恐怕是唯一的例外。assert 是仅在 Debug 版本起作用的宏,它用于检查“不应该”发生的情况。为了不在程序的 Debug 版本和 Release 版本引起差别,assert 不应该产生任何副作用。如果 assert 是函数,由于函数调用会引起内存、代码的变动,那么将导致 Debug 版本与 Release 版本存在差异。所以assert 不是函数,而是宏。
inline 是一种“用于实现的关键字”,而不是一种“用于声明的关键字”。一般地,用户可以阅读函数的声明,但是看不到函数的定 义。尽管在大多数教科书中内联函数的声明、定义体前面都加了 inline 关键字,但我认为 inline 不应该出现在函数的声明中。这个细节虽然不会影响函 数的功能, 但是体现了高质C++/C 程序设计风格的一个基本原则:声明与定义不可混为一谈,用户没有必要、也不应该知道函数是否需要内联。
需要注意的是:
以下情况不宜使用内联:
类的构造函数和析构函数容易让人误解成使用内联更有效。要当心构造函数和析构函数可能会隐藏一些行为,如“偷偷地”执行了基类或成员对象的构造函数和析构函数。所以不要随便地将构造函数和析构函数的定义体放在类声明中。
一个好的编译器将会根据函数的定义体,自动地取消不值得的内联(这进一 步说明了 inline 不应该出现在函数的声明中)。
每个类只有一个析构函数和一个赋值函数,但可以有多个构造函数(包含一个拷贝构造函数,其它的称为普通构造函数)。对于任意一个类 A,如果不想编写上述函数,C++编译器将自动为 A 产生四个缺省的函数,如
A(void); // 缺省的无参数构造函数
A(const A &a); // 缺省的拷贝构造函数
~A(void); // 缺省的析构函数
A & operate=(const A & a); // 缺省的赋值函数
构造函数与析构函数与类名同名,且没有返回值,与返回值类型为void的函数不同。
构造函数有个特殊的初始化方式叫“初始化表达式表”(简称初始化表)。初始化表位于函数参数表之后,却在函数体 {} 之前。这说明该表里的初始化工作发生在函数体内的任何代码被执行之前。
构造函数初始化表的使用规则:
class A
{
A(int x); // A 的构造函数
};
class B:public A
{
B(int x, int y); // B 的构造函数
};
B::B(int x, int y)
:A(x)
{
// ...
};
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; // 成员对象
};
B::B(const A &a)
:m_a(a)
{
}
B::B(const A &a)
{
m_a = a;
}
构造从类层次的最根处开始,在每一层中,首先调用基类的构造函数,然后调用成员对象的构造函数。析构则严格按照与构造相反的次序执行, 该次序是唯一的,否则编译器将无法自动执行析构过程。
一个有趣的现象是,成员对象初始化的次序完全不受它们在初始化表中次序的影响,只由成员对象在类中声明的次序决定。这是因为类的声明是唯一的, 而类的构造函 数可以有多个,因此会有多个不同次序的初始化表。如果成员对象按照初始化表的次序进行构造,这将导致析构函数无法得到唯一的逆序。
类String的构造与析构函数
// 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::~String(void)
{
delete [] m_data;
}
String a("hello");
String b("world");
String c = a; // 调用了拷贝构造函数,最好写成c(a);
c = b; // 调用了赋值函数
第三个语句的风格较差,宜改写成 String c(a) 以区别于第四个语句。
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拷贝构造函数与普通构造函数的区别是:在函数入口处无需与NULL进行比较,这是因为“引用”不可能是NULL,而“指针”可以为NULL。
类String的赋值函数比构造函数复杂得多,分四步实现:
(1)检查自赋值。也许不会写出a=a这样的自赋值语句,按时有可能无意中产生间接的自赋值,如
内容自赋值:
b = a;
...
c = b;
...
a = c;
地址自赋值:
b = &a; // 把a的地址赋值给b
...
a = *b; // b的地址处的内容赋值给a
臆想:“即使出现自赋值,我也可以不理睬,大不了化点时间让对 象复制自己而已,反正不会出错!”
但是,如果在类的赋值函数中有delete,就不能正确的复制自己。(意思是说:间接自赋值可能会被编译器优化成直接赋值??!)
(2)第二步,用delete释放原有的内存资源。如果现在不释放,以后就没机会了,将造成内存泄露。
(3)第三步,分配新的内存资源,并复制字符串。注意函数 strlen 返回的是有效字符串长度,不包含结束符‘\0’。函数 strcpy 则连‘\0’一起复制。
(4)第四步,返回本对象的引用,目的是为了实现象 a = b = c 这样的链式表达。注意不要将 return *this 错写成 return this 。注意: 不可以写成return other,因为我们不知道参数other的生命周期,有可能other是个临时对象,在赋值结束后它会马上消失,那么return other返回的将是垃圾。
安全而且省事的处理拷贝构造函数和赋值函数的办法是:
将拷贝构造函数和赋值函数声明为私有函数,
不用编写代码
例如:
class A
{
private:
A(const A &a); // 私有的拷贝构造函数
A & operate = (const A &a); // 私有的赋值函数
};
如果有人试图编写如下程序:
A b(a); // 调用了私有的拷贝构造函数
b = a; // 调用了私有的赋值函数
编译器将指出错误,因为外界不可以操作A的私有函数。
基类的构造函数、析构函数、赋值函数都不能被派生类继承。如果类之间存在继承关系,在编写上述基本函数时应注意以下事项:
(1)派生类的构造函数应在其初始化表里调用基类的构造函数。
(2)基类与派生类的析构函数应该为虚(即加 virtual 关键字)
#include
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
在编写派生类的赋值函数时,注意不要忘记对基类的数据成员重新赋值,如
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;
}
对于 C++程序而言,设计孤立的类是比较容易的,难的是正确设计基类及其派生类。本章仅仅论述“继承”(Inheritance)和“组合”(Composition)的概念。
正因为“继承”太有用、太容易用,才要防止乱用“继承”,需要给“继承”设定一些使用规则:
(1)如果类A和类B毫不相关,不可以为了使B的功能更多些而让B继承A的功能和属性。
(2)若在逻辑上B是A的“一种”,则允许B继承A的功能和属性。例如男人(Man)是人(Human)的一种,男孩(Boy)是男人的一种。那么类Man可以从类Human派生,类Boy可以从类Man派生。
(3)看起来很简单,但是实际应用时可能会有意外,继承的概念在程序世界与现实世界并不完全相同。例如从生物学角度讲,鸵鸟(Ostrich)是鸟(Bird)的一种,按理说类 Ostrich 应该可以从类 Bird 派生。但是鸵鸟不能飞。从数学角度讲,圆(Circle)是一种特殊的椭圆(Ellipse),按理说
类 Circle 应该可以从类 Ellipse 派生。但是椭圆有长轴和短轴,如果圆继承了椭圆的长轴和短轴,岂非画蛇添足?
更加严格的继承规则应当是:若在逻辑上 BBB 是B 是 A 是 AAA 的的“的“一种 “一种”,”,并且 AAA 的所有功能和属性对 A 的所有功能和属性对 BBB 而言都有意义 B 而言都有意义,则允许 ,则允许BBB 继承 B 继承 AAA 的功能和属性 A 的功能和属性。
(4)若在逻辑上 A 是 B 的“一部分”(a part of),则不允许B 从 A 派生,而是要用 A 和其它东西组合出 B。例如眼(Eye)、鼻(Nose)、口(Mouth)、耳(Ear)是头(Head)的一部分,所以类 Head 应该由类 Eye、Nose、Mouth、Ear 组合而成,不是派生而成。
以下的代码虽然正确,但是设计却不合理:
class Head : public Eye, public Nose, public Mouth, public Ear
{
...
};
const 更大的魅力是它可以修饰函数的参数、返回值,甚至函数的定义体。所以很多 C++程序设
计书籍建议:“Use const whenever you need”。
(1)用 const 修饰函数的参数。如果参数作输出用,不论它是什么数据类型,也不论它采用“指针传递”还是“引用传递”,都不能加 const 修饰,否则该参数将失去输出功能。
(2)如果输入参数采用“指针传递”,那么加 const 修饰可以防止意外地改动该指针,起到保护作用。
例如StringCopy函数:
void StringCopy(char* strDestination, const char* strSource);
其中 strSource 是输入参数,strDestination 是输出参数。
(3)如果输入参数采用“值传递”,由于函数将自动产生临时变量用于复制该参数,该输入参数本来就无需保护,所以不要加const修饰。例如不要将函数void Func1(int x)写成void Func1(const int x)。
(4)对于非内部数据类型的参数而言,象 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), 以便提高效率?完全没有必要,因为内部数据类型的参数不存在构造、析构的过程,而复制也非常快,“值传递”和“引用传递”的效率几乎相当。
(5)用 const 用 const 修饰函数的返回值
如果给以“指针传递”方式的函数返回值加 const 修饰,那么函数返回值(即指针)的内容不能被修改,该返回值只能被赋给加const 修饰的同类型指针。例如函数
const char* GetString(void);
如下语句将出现编译错误:
char *str = GetString();
正确的用法是
const char *str = GetString();
如果函数返回值采用“值传递方式”,由于函数会把返回值复制到外部临时的存储单元中,加 const 修饰没有任何价值。
函数返回值采用“引用传递”的场合并不多,这种方式一般只出现在类的赋值函数中,目的是为了实现链式表达。例如:
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
则是非法的。
(6)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 关键字只能放在函数声明的尾部,大概是因为其它地方都已经被占用了。
(6)提高程序的效率
(7)其他有益的建议