第12章 类和动态内存分配
本章内容包括:
- 对类成员使用动态内存分配
- 隐式和显式复制构造函数
- 隐式和显式重载赋值运算符
- 在构造函数中使用new所必须完成的工作
- 使用静态类成员
- 将特定new运算符用于对象
- 使用指向对象的指针
- 实现队列抽象数据类型(ADT)
12.1 动态内存和类
在构造函数中使用new来分配内存时,必须在相应的析构函数之中使用delete来释放内存。
· 请看如下代码:
StringBad sailor = sports;
其中sports也是一个StringBad对象,那么此时会调用哪个构造函数呢?
这种形式的语句初始化等效于如下的语句:
StringBad sailor = StringBad(sports);
因此相应的构造函数原型如下:
StringBad(const StringBad&);
当使用一个对象来初始化另一个对象时,编译器将自动生成上述的构造函数(成为复制构造函数)。自动生成的构造函数中不会带有其他处理语句。
12.1.2 特殊成员函数
C++ 会为类自动提供如下成员函数:
- 默认构造函数,如果没有定义构造函数;
- 默认析构函数,如果没有定义;
- 复制构造函数,如果没有定义;
- 赋值运算符,如果没有定义;
- 地址运算符,如果没有定义
C++11另外提供了两个特殊成员函数:移动构造函数,移动赋值运算符。
1.默认构造函数
如果构造函数没有参数,或者所有参数提供了默认值,那么就是默认构造函数。
但是只能有一个默认构造函数,否则会有二义性问题
2.复制构造函数
复制构造函数用于将一个对象复制到新创建的对象中。
类的复制构造函数原型如下:
Class_name (const Cliass_name &);
3.何时调用复制构造函数?
新建一个对象并将其初始化为同类现有对象时,都会调用复制构造函数。
每当程序生成了对象副本的时候,编译器都会调用复制构造函数。具体来说,当函数按值传递对象或者函数返回对象时,都将调用复制构造函数。
由于按值传递对象会调用复制构造函数,因此应该按引用传递对象。可以节省时间和空间。
4.默认的复制构造函数的功能
默认的复制构造函数将会复制非静态成员,赋值的是成员的值(成员复制也成为浅复制)。静态类变量不受影响,因为不属于各个对象而是属于整个类。
如果类中包含静态变量,并且在调用构造函数时,值会发生变化,则应该定义显式的复制构造函数,否则会引发问题。
如果类中有使用new分配的内容,则在复制构造时应该小心,因为默认是浅复制,析构函数可能会将内存释放两遍。解决的方式是使用深度复制。深度复制会复制指向的数据本身,而不仅仅是复制指针。
但是深度复制时应该避免将对象复制给自身,否则,在重新赋值前,释放内存操作可能会删除对象的内容。
12.1.4 赋值运算符
C++允许类对象赋值,是通过自动为类重载赋值运算符实现的,原型如下:
Class_name & Class_name::operator = (const Class_name &);
1,赋值运算符的功能以及何时使用
将已有的对象赋给另一个对象时,将使用重载的赋值运算符。
而初始化时,不一定会使用重载赋值运算符。一种情况是直接调用复制构造函数;另一种情况是分为两步,先使用复制构造函数生成一个临时变量,再使用重载赋值。
与复制构造函数相似,赋值运算符的隐式实现也对成员进行逐个复制。
C++11空指针
NULL是一个表示空指针的C语言宏,C++11引入了新关键字nullptr,用于表示空指针。当然也可以使用0来表示,(void *)0
string类,C++重载了中括号运算符:
char & String::operator [] (int i)
{
return str[i];
}
12.2.4 静态类成员函数
可以将成员函数声明为静态的,这样做会导致两个重要后果:
首先,不能通过对象调用静态类成员函数。因为静态类成员函数没有this指针,也不属于某个对象。如果静态成员函数是在公有部分声明的,那可以使用类名和作用域解析符来进行调用。
static int HowMany(){ return num;}
调用方式为:
int count = String::HowMany();
其次,静态成员函数只能够使用静态数据成员。
12.3 在构造函数中使用new时应该注意的事项
- 如果在构造函数中使用了new,那么应该在析构函数中使用delete
- new和delete一一对应,new []和delete []一一对应
- 如果有多个构造函数,那么必须使用相同的方法使用new, 要么都带括号,要么都不带,因为析构函数只能有一个,需要兼容。但是可以一个构造函数赋指针值,另外一个为空指针,因为delete可以作用于空指针
- 应该定义一个复制构造函数,可以通过深度复制将一个对象初始化为另一个对象
- 应该定义一个赋值运算符,可以通过深度复制将一个对象赋值给另一个对象
12.4 有关返回对象的说明
12.4.1 返回指向const对象的引用
使用const引用的常见原因是旨在提高效率。
Vector Max(const Vector & v1,const Vector & v2)
{
if v1.Val>v2.Val{
return v1;
}else{
return v2;
}
}
const Vector & Max(const Vector & v1,const Vector & v2)
{
if v1.Val>v2.Val{
return v1;
}else{
return v2;
}
}
如果按照第一种返回对象的方式,那么将会调用复制构造函数;但是第二种方式是返回的引用,不会调用复制构造函数。
12.4.2 返回指向非const对象的引用
两种常见的返回非const对象的情形是,重载赋值运算符以及重载与cout一起使用的<<运算符。
前者旨在提高效率,后者是不得不这样做。因为ostream没有公有的复制构造函数。
12.4.3 返回对象
如果被返回的对象时被调用函数中的局部变量,则应该返回对象而不是引用。通常,被重载的算数运算符属于这一类。
在这种情况下,存在调用复制构造函数来创建被返回的对象的开销,然而这是无法避免的。
总之,如果方法或函数要返回局部对象,则应返回对象。
如果要返回一个没有公有复制构造函数的类的对象,必须返回一个指向这种对象的引用。
如果既可以返回对象也可以返回引用,应首选引用,因为其效率最高。
12.5 使用指向对象的指针
使用new初始化对象。
Class_name * pclass = new Class_name(value);
将会调用如下构造函数:
Class_name(Type_name);
12.5.1 再谈new和delete
在下述情况下析构函数将会被调用:
- 如果对象是动态变量,则当执行完定义该对象的代码块时,将会调用对象的析构函数。
- 如果对象是静态变量(外部、静态、静态外部或来自名称空间),则在程序结束时调用对象的的析构函数。
- 如果对象是使用new创建的,在调用delete时调用析构函数
12.5.2 指针和对象小结
- 使用常规表示法声明指向对象的指针 String * glamour;
- 将指针初始化为已有的对象 String * glamour = &sayings[0];
- 使用new来初始化指针,这会创建一个新的对象 String * glamour = new String("aa");
- 对类使用new将调用相应的类构造函数
- 可以使用->运算符通过指针访问类方法 shortest -> length();
- 对对象指针应用解除引用运算符(*)来获取对象 *first
12.5.3 再谈定位new运算符
定位new运算符让您能够在分配内存时能够指定内存位置。
char * buffer = new char[BUF];
//JustTesting 是一个类
pc1 = new(buffer) JustTesting;
pc2 = new(buffer + sizeof(JustTesting) ) JustTesting;
使用定位new时,无法使用delete,因为delete是和new对应的,没法和定位new对应。
这种问题的解决方法是,显式的为使用定位new运算符创建的对象调用析构函数。
pc1 -> ~JustTesting();
成员初始化列表的语法
// 如果Classy是一个类
Classy::Classy (int n, int m) :mem1(n), mem2(m), mem3(n*m)
{
//...
}
上述代码将么么初始化为n,将马饿么初始化为m,将mem3初始化为n*m。
- 这种格式只能用于构造函数
- 必须用这种格式初始化引用数据成员
- 必须用这种格式来初始化非静态const数据成员(C++11之前是这样的)
- 数据成员被初始化的顺序与他们出现在类声明中的顺序相同,与初始化器中的排列顺序无关
C+11的类内初始化
C++11允许以更直观的方式进行初始化:
Class Classy
{
int mene1 = 10;
const int mem2 = 20;
}
这与在构造函数中使用成员初始化列表等价。如果同时调用列表,那么实际列表会覆盖这些默认初始值。
第13章 类继承
本章内容包括:
- is-a关系的继承
- 如何以公有方式从一个类派生出另一个类
- 保护访问
- 构造函数成员初始化列表
- 向上和向下强制转换
- 虚成员函数
- 早期(静态)联编和晚期(动态)联编
- 抽象基类
- 纯虚函数
- 何时以及如何使用公有继承
由于已有的代码已经被使用和测试过,所以重用经过测试的代码有助于在程序中引入错误。
C++提供了比修改代码更好的方法来扩展和修改类。这种方法叫类继承,能够从已有的类派生出新的类。派生类会继承原有类(称为基类)的特征,包括方法。
下面是可以通过继承完成的一些工作:
- 可以在已有的类的基础上添加功能。
- 可以给类添加数据。
- 可以修改类方法的行为。
13.1 一个简单的基类
从一个类派生出另一个类时,原始类称为基类,继承类称为派生类。
13.1.1 派生一个类
class RatedPlayer : public TableTennisPlayer
{
...
};
冒号指出RatedPlayer类的基类是TableTennisPlayer类。特殊的声明头表明TableTennisPlayer是一个公有基类,这被称为公有派生。
派生类对象会包含基类对象。使用公有派生,基类的公有成员将会成为派生类的公有成员;基类的私有部分也将成为派生类的一部分,但只能通过基类的公有和保护方法访问。
需要在继承特性中添加什么呢?
- 派生类需要自己的构造函数
- 派生类可以根据需求,添加自己的数据成员和成员函数
13.1.2 构造函数:访问权限的考虑
派生类不能直接访问基类的私有成员,必须通过基类方法进行访问。
创建派生类对象时,程序应该首先创建基类对象,C++使用成员初始化列表语法来完成这种工作。
RatedPlayer::RatedPlayer(
unsigned int r,
const string & fn,
bool ht) : TableTennisPlayer(fn,ht)
{
rating = r;
}
创建派生类对象首先要创建基类对象,如果不调用基类构造函数,程序将会使用默认的基类构造函数。
除非想要使用默认的基类构造函数,不然应该显式的调用正确的基类构造函数。
释放对象的顺序和创建对象的顺序相反,即,要先执行派生类的析构函数,再自动调用基类的析构函数。
13.1.3 使用派生类
要使用派生类,程序必须要能够访问基类声明。
13.1.4 派生类和基类之间的特殊关系
- 派生类对象可以使用基类的方法,条件是方法不能够是私有的。
- 基类指针可以在不进行显式类型转换的情况下指向派生类对象。
- 基类引用可以在不进行显式类型转换的情况下引用派生类对象。
然而, 基类指针或引用只能用于调用基类方法,不能使用派生类的方法。
也不可以将基类指针和对象赋给派生类指针和引用。
13.2 继承:is-a关系
派生类和基类之间的特殊关系是基于C++继承的底层模型的。
C++有三种继承方式:公有继承,保护继承和私有继承。
公有继承是最常见的方式,建立了一种is-a关系,即派生类对象也是一个基类对象,对基类对象执行的任何操作也可以对派生类对象执行。因为派生类可以添加特性,所以将这种关系称为is-a-kind-of 关系更准确,术语是is-a
继承可以在基类的基础上添加属性,但是不能删除基类的属性。
13.3 多态公有继承
派生类对象使用基类的方法而未做任何修改,然而,可能会希望同一个方法在基类和派生类中的行为是不同的,这种较为复杂的行为称为多态,即同一个方法的行为随上下文而异。
有两种重要的机制可用于实现多态公有继承:
- 在派生类中重新定义基类的方法
- 使用虚方法
使用关键字virtual的方法被称为虚方法。
如果方法是通过引用或者指针而不是对象调用的,将确定使用哪一种方法。如果没有使用关键字virtual,程序将根据引用类型或指针类型来选择方法;如果使用了virtual,程序将根据引用或指针指向的对象的类型来选择方法。
虚函数的这种行为非常方便,因此经常在基类中将派生类会重新定义的方法声明为虚方法。方法在基类中被声明为虚的后,它在派生类中将会自动成为虚方法。
如果要在派生类中重新定义基类的方法,通常应将基类方法声明为虚的。为基类声明一个虚析构函数也是一种惯例。
关键字virtual只用于类声明的方法原型中,没有用于程序方法定义。
如果派生类和基类包含同名的方法,那么在派生类中调用基类函数时需要使用作用域解析运算符,否则会产生递归调用。
void BrassPlus::View() const
{
...
Brass::View(); // 调用基类方法
...
View(); // 递归
}
如果派生类中的方法不存在于基类,那么不需要使用作用域解析运算符。
为何需要虚析构函数?
如果析构函数不是虚的,则将只调用对应于指针类型的析构函数。如果析构函数是虚的,则将调用相应对象类型的析构函数,如果指针指向的是BrassPlus对象,则将先调用BrassPlus的析构函数,再调用基类的析构函数。
因此使用虚析构函数可以保证正确的析构函数序列被调用。
13.4 静态联编和动态联编
将源代码中的函数调用解释为执行特定的函数代码块被称为函数名联编。C/C++编译器可以在编译过程完成这种联编。在编译过程中进行联编被称为静态联编,或者早期联编。
但是虚函数的存在使使用哪一个函数不能在编译时确定。编译器必须生成能够在程序运行时选择正确的虚函数方法的代码,被称为动态联编,或晚期联编。
13.4.1 指针和引用类型的兼容性
将派生类指针或引用转换为基类指针或引用被称为向上强制转换,这使公有继承不需要进行显式类型转换。
向上强制转换是可传递的,也就是说如果BrassPlus派生出BrassPlusPlus,那么Brass指针或引用可以引用Brass对象,BrassPlus对象,BrassPlusPlus对象。
相反的过程,将基类指针或引用转换为派生类指针或引用——称为向下强制转换。如果不使用显式类型转换,则向下强制转换是不允许的。
13.4.2 虚成员函数和动态联编
1.为什么有两种类型的联编以及为什么默认为静态联编?
从效率上来看,动态联编为使程序能够在运行阶段进行决策,就必须采取一些方法来跟踪基类指针或引用指向的对象类型,这增加了额外的处理开销。由于静态联编的效率更高,因此被设置为C++的默认选择。
从概念模型来看,可能会存在不需要被重新定义的成员函数,因此只使用动态联编会白白损失效率。
2.虚函数的工作原理
编译器处理虚函数的方法是:给每个对象添加一个隐藏成员。隐层成员中保存了一个指向函数地址数组的指针。这种数组被称为虚函数表(vtb1)。虚函数表中存储了为类对象进行声明的虚函数的地址。例如,基类对象包含一个指针,该指针指向基类中所有虚函数的地址表;派生类对象将包含一个指向独立地址表的指针。如果派生类提供了虚函数的新定义,该虚函数将会保存新函数的地址;如果派生类没有重新定义虚函数,该vtb1将会保存函数原始版本的地址。如果派生类定义了新的虚函数,则该函数的地址也将被添加到vtb1。
调用虚函数时,程序将查看存储在对象中的vtb地址,然后转向相应的函数地址表。
总之,使用虚函数时在内存和执行速度方面有一定的成本:
- 每个对象都将增大,增大量为存储地址的空间。
- 每个类,编译器都将创建一个虚函数地址表(数组)。
- 对于每个函数调用都将会增加一项额外的操作,即到表中查找地址。
虽然非虚函数比虚函数的效率稍高,但是不具备动态联编能力。
13.4.3 有关虚函数的注意事项
1.构造函数
构造函数不能是虚函数,派生类不能继承基类的构造函数,所以将类构造函数声明为虚的没有什么意义。
2.析构函数
析构函数应该是虚函数,除非类不用做基类。
因为派生类可以使用基类指针引用,所以销毁时,如果只有静态联编,会调用基类的析构函数。
3.友元
友元不能是虚函数,因为友元不是类成员,只有成员才能是虚函数。
4.没有重新定义
如果派生类没有重新定义函数,将使用该函数的基类版本。如果派生类位于派生连中,则将会使用最新的虚函数版本。
5.重新定义将隐藏方法
重新定义继承的方法不是重载。如果重新定义派生类中的函数,该操作将隐藏所有同名基类方法(无论参数列表是否相同)。
引出了两条经验:
第一,如果重新定义继承的方法,应确保与原来的原型完全相同,但如果返回类型是基类引用或指针,则可以修改为指向派生类的引用或指针。这种特性被称为返回类型协变。但是这种例外只适用于返回值,不适用于参数。
第二,如果基类声明被重载了,则应在派生类中重新定义所有的基类版本。如果只重新定义一个版本,则另外两个版本将会被隐藏,派生类对象将无法使用它们。
13.5 访问控制:protected
关键字protected和private相似,在类外只能用公有类成员来访问protected部分中的类成员。区别只有在派生类中才会表现出来。派生类的成员可以直接访问基类的保护成员,但不能直接访问私有成员。
使用保护数据成员可以简化代码的编写工作,但是存在设计缺陷,会在某种程度上将私有成员更改为公有成员。
因此最好对类数据成员采用私有访问控制,不要使用保护访问控制;同时通过基类方法使派生类能够访问基类成员数据。
13.6 抽象基类
C++通过使用纯虚函数提供未实现的函数。纯虚函数的声明结尾处为=0:。
class BaseEllipse
{
private:
double x;
double y;
...
public:
virtual double Area() const = 0;
...
}
当类声明中有纯虚函数时,则不能创建该类的对象。包含纯虚函数的类只用作基类。
13.7 继承和动态内存分配
13.7.1 派生类不适用new
如果派生类中没有使用new,那么不需要为派生类定义显式析构函数,复制构造函数和赋值运算符。
13.7.2 派生类使用new
在这种情况下必须需要为派生类定义显式析构函数,复制构造函数和赋值运算符。
这种要求是通过三种不同的方式来满足的。对于析构函数,这是自动完成的;对于构造函数,这是通过在初始化列表中调用基类的复制构造函数来完成的;对于赋值运算符,是通过使用作用域解析运算符显式调用基类的赋值运算符来完成的。
13.7.3 使用动态内存分配和友元的继承示例
因为友元不是成员函数,所以不能使用作用域解析运算符来指出要使用那个函数。
这个问题的解决方法是使用强制类型转换,以便匹配原型时能够选择正确的函数。
附表
成员函数属性表
函数 | 能否继承 | 成员还是友元 | 默认能否生成 | 能否为虚函数 | 是否可以有返回类型 |
---|---|---|---|---|---|
构造函数 | 否 | 成员 | 能 | 否 | 否 |
析构函数 | 否 | 成员 | 能 | 能 | 否 |
= | 否 | 成员 | 能 | 能 | 能 |
& | 能 | 任意 | 能 | 能 | 能 |
转换函数 | 能 | 成员 | 否 | 能 | 否 |
() | 能 | 成员 | 否 | 能 | 能 |
[] | 能 | 成员 | 否 | 能 | 能 |
-> | 能 | 成员 | 否 | 能 | 能 |
op= | 能 | 任意 | 否 | 能 | 能 |
new | 能 | 静态成员 | 否 | 否 | void* |
delete | 能 | 静态成员 | 否 | 否 | void |
其他运算符 | 能 | 任意 | 否 | 能 | 能 |
其他成员 | 能 | 成员 | 否 | 能 | 能 |
友元 | 否 | 友元 | 否 | 否 | 能 |