第12章 类继承
下面是类继承完成的一些工作:
- 可以在已有类的基础上继续添加功能
- 可以给类添加数据
- 可以修改类方法的行为
类继承可以在不提供公开实现的情况下将自己的类分发给其他人,同时允许他们在类中添加新特性。
一个简单的基类
string类有一个将const char *作为参数的构造函数,使用C-风格字符串初始化string对象时,将自动调用这个构造函数。
派生一个类,下面就是派生的语法,RatedPlayer类就是派生于类TableTennisplayer,而public表明了这个派生为公有派生,基类的公有成员将成为派生类的公有成员,基类的私有部分也将成为派生类的一部分,但只能通过基类的公有和保护方法访问。(虽然继承了私有部分,但是不能直接访问,只能通过公有的方法进行访问)
- 派生类对象储存了基类的数据成员(派生类继承了基类的实现)
- 派生类对象可以使用基类的方法(派生类继承了基类的接口)
class RatedPlayer : public TableTennisplayer
{
...
};
需要在继承特性中添加什么呢?
- 派生类需要自己的构造函数
- 派生类可以根据需要添加额外的数据成员和成员函数
构造函数必须给新成员(如果有的话)和继承的成员提供数据。
class RatedPlayer : public TableTennisPlayer
{
private:
unsigned int rating;
public:
RatedPlayer (unsigned int r = 0, const string & fn = "none", const string & ln = "none", bool ht = false);
RatedPayer (unsigned int r, const TableTennisPlayer & tp);
unsigned int Rating() const { return rating; }
void ResetRating (unsigned int r) { rating = r; }
}
成员初始化列表
RatedPlayer::RatedPlayer(unsigned int r, const string & fn, const string & ln, bool ht) : TableTennisPlayer(fn, ln, ht)
{
rating = r;
}
RatedPlayer::RatedPlayer(unsigned int r, const TableTennisPlayer & tp) : TableTennisPlayer(tp)
{
rating = r;
}
可以看到,这里的初始化不太一样,使用的TableTennisPlayer的一个构造函数,因为我们不能通过派生类直接访问私有成员,只能够通过TableTennisPlayer的一个构造函数进行初始化。
如果没有成员初始化列表会怎么样呢?答案是TableTennisPlayer会调用它的默认构造函数,除非我们真正需要调用默认构造函数,最好显式地使用构造函数进行初始化。
有关派生类构造函数的要点如下:
- 首先创建基类对象
- 派生类构造函数应通过成员初始化列表将基类信息传递给基类构造函数
- 派生类构造函数应初始化派生类新增的数据成员。
创建派生类对象时,程序首先调用基类构造函数,然后再调用派生类构造函数。基类构造函数负责初始化继承的数据成员;派生类构造函数主要用于初始化新增的数据成员。派生类的构造函数总是调用一个基类构造函数。可以使用初始化器列表语法指明要使用的基类构造函数,否则将使用默认的基类构造函数。派生类对象过期时,程序将首先调用派生类析构函数,然后再调用基类析构函数。
扫描二维码关注公众号,回复: 12638279 查看本文章
我们知道,派生类可以使用基类的非私有方法,但是还有另外两个重要的关系是:基类指针可以在不进行显式类型转换的情况下指向派生类对象;基类引用可以在不进行显式类型转换的情况下引用派生类对象。
RatedPlayer rplayer(1140, "Mallory", "Duck", true);
TableTennisPlayer & rt = rplayer;
TableTennisPlayer * pt = &rplayer;
rt.Name();
pt->Name();
但是反过来就不行了,例如下式
TableTennisPlayer rplayer("Mallory", "Duck", true);
RatedPlayer & rt = rplayer;
RatedPlayer * pt = &rplayer;
继承:is-a关系
三种继承关系:公有继承、保护继承和私有继承
多态公有继承
virtual关键字,带有virtual关键字的方法被称为虚方法,
使用了这样的方法就说明了有两个方法,属于不同的类,一个是基类的方法,一个是派生类的方法,什么时候使用这个,取决于你的数据类型,基类的就用基类的方法,派生类的就用派生类的。
// Brass Account Class
class Brass
{
private:
std::string fullName;
long acctNum;
double balance;
public:
Brass(const std::string & s = "Nullbody", long an = -1,
double bal = 0.0);
void Deposit(double amt);
virtual void Withdraw(double amt);
double Balance() const;
virtual void ViewAcct() const;
virtual ~Brass() {}
};
//Brass Plus Account Class
class BrassPlus : public Brass
{
private:
double maxLoan;
double rate;
double owesBank;
public:
BrassPlus(const std::string & s = "Nullbody", long an = -1,
double bal = 0.0, double ml = 500,
double r = 0.11125);
BrassPlus(const Brass & ba, double ml = 500,
double r = 0.11125);
virtual void ViewAcct()const;
virtual void Withdraw(double amt);
void ResetMax(double m) { maxLoan = m; }
void ResetRate(double r) { rate = r; };
void ResetOwes() { owesBank = 0; }
};
-
相比于基类,派生类添加了三个私有数据成员和3个公有成员函数
-
可以看到ViewAcct()和Withdraw()有两个原型,例如ViewAccct(),一个是Brass::ViewAccct(),一个是BrassPlus::ViewAccct(),如何使用呢,就是根据这个限定名来进行判定到底是用哪个ViewAcct函数。上面的分析是不带virtual关键字的。
-
使用virtual关键字就不同了,如果没有使用关键字virtual,程序将根据引用类型或指针类型选择方法;如果使用了virtual,程序将根据引用或指针指向的对象的类型来选择方法。
如果ViewAcct()不是虚的,则程序的行为如下:
// behavior with non-virtual ViewAcct() // method chosen according to reference type Brass dom("Dominic Banker", 11224, 4183.45); BrassPlus dot("Dorothy Banker", 12118, 2592.00); Brass & b1_ref = dom; Brass & b2_ref = dot; b1_ref.ViewAcct(); // use Brass::ViewAcct() b2_ref.ViewAcct(); // use Brass::ViewAcct()
引用变量的类型为Brass,所以选择了Brass::ViewAcct(),
如果ViewAcct()是虚的,则行为如下:
// behavior with virtual ViewAcct() // method chosen according to reference type Brass dom("Dominic Banker", 11224, 4183.45); BrassPlus dot("Dorothy Banker", 12118, 2592.00); Brass & b1_ref = dom; Brass & b2_ref = dot; b1_ref.ViewAcct(); // use Brass::ViewAcct() b2_ref.ViewAcct(); // use BrassPlus::ViewAcct()
这里两个引用都是Brass,但是b2_ref引用的对象是一个BrassPlus,所以ViewAcct使用的BrassPlus类中的方法。
-
可以看到还声明了一个虚析构函数,这样做是为了确保释放派生对象时,按正确的顺序调用析构函数,在后面会讲到为什么。
请细看P499,3.演示虚方法的行为
为何需要虚析构函数?
如果析构函数不是虚的,则将只调用对应于指针类型的析构函数。这意味着只有Brass的析构函数被调用,即使指针指向的是一个BrassPlus对象。如果析构函数是虚的,将调用相应对象类型的析构函数。因此,如果指针指向的是BrassPlus对象,将调用BrassPlus的析构函数,然后自动调用基类的析构函数。因此,使用虚析构函数可以确保正确的析构函数序列被调用。
静态联编和动态联编
什么是联编?
联编发生在编译过程,将源代码中的函数调用解释为执行特定的函数代码块被称为函数名联编。在C语言中,每个函数名能够对应一个不同的函数,但是在C++中不行,因为引入了重载的概念,这使得编译器需要根据函数名和函数参数才能够确定使用哪个函数。这种联编C/C++编译器都能够很好地应付,能够在编译过程中进行联编,这种在编译过程中联编被称为静态联编,又称为早期联编。
正如之前介绍的虚函数,这让联编变得更加困难了,虚函数使得需要在程序运行过程中进行选择正确的虚函数的代码,这种方式叫做动态联编,又称为晚期联编。
我们知道,不同类型的指针操作往往是不允许的,即不允许将一种类型的地址赋给另一种类型的指针。但是指向基类的引用或指针可以引用派生类对象,而不必进行显式类型转换。下面这种初始化方法是允许的。因为公有继承是一种is-a关系,所以像这种将派生类引用或指针转换为基类引用或指针被称为向上强制转换,这使公有继承不需要进行显式类型转换。
BrassPlus dilly("Annie Dill", 23798, 324);
Brass * pb = &dilly;
Brass & rb = dilly;
相反的,将基类指针或引用转换为派生类指针或引用称为向下强制转换,如果不进行显式类型转换,则向下强制转换时不允许的。
有关虚函数注意事项
-
构造函数不能是虚的。
-
析构函数应当是虚的。
-
友元不能是虚函数。友元不是类成员,而只有成员才能是虚函数。如果由于这个原因引起了设计问题,可以通过让友元函数使用虚成员函数来解决。
-
如果派生类没有重新定义函数,将使用该函数的基类版本。如果派生类位于派生链中,则将使用最新的虚函数版本,例外的情况是基类版本是隐藏的。
-
重新定义继承的方法并不是重载。
这里有两条经验规则:第一,如果重新定义继承的方法,应确保与原来的原型完全相同,但如果返回类型是基类引用或指针,则可以修改为指向派生类的引用或指针。这种特性被称为返回类型协变,因为允许返回类型随类类型的变化而变化。注意,这种例外只适用于返回值,而不适用于参数。第二,如果基类声明被重载了,则应在派生类中重新定义所有的基类版本。如果只重新定义一个版本,则另外两个版本将被隐藏,派生类对象将无法使用他们。注意,如果不需要修改,则新定义可只调用基类版本。
访问控制:protected
protected关键字与private相似,在类外只能通过类的公有方法进行访问,他的作用体现在基类派生的类中。当一个基类派生了一个类,派生类就可以直接访问protected数据成员,但是任然不能直接访问private数据成员。
抽象基类
abstract base class,ABC,抽象基类。
现在思考这么一个问题:椭圆为一个基类,我们知道圆是椭圆的一个特例,椭圆需要有很多参数,比如椭圆中心坐标、长半轴、短半轴、以及方向角。但是问题来了,对于圆来说,我们只需要原点坐标和半径两个参数即可,如果直接派生了,这将产生信息冗余,这样是不够效率的。那么我们将椭圆和圆分开来,都写成类,这也不是一个很高效的写法,同时也违背了C++的中心思想。
还有一种方法,就是找到椭圆和圆的共同特性,将这些共性进行封装,变成一个基类,C++提供一个抽象基类的方法,抽象基类只能够用于基类,也就是说我们不能够在程序中使用这种基类,这是一种“真”基类。
比如说椭圆和圆的共性是中心坐标、Move()方法(对于这两个类是相同的)、Area()方法(对于这两个类是不同的)。而在ABC中我们并不能实现Area方法,因为两个方法是不同的,C++通过使用纯虚函数提供为实现的函数。纯虚函数声明的结尾处为=0,
class BaseEllipse
{
private:
double x;
double y;
...
public:
BaseEllipse(double x0 = 0, double y0 = 0) : x(x0), y(y0) { }
virtual ~BaseEllipse() { }
void Move(int nx, ny) { x = nx; y = ny; }
virtual double Area() const = 0; // a pure virtual function
}
当类声明中包含纯虚函数时,则不能创建该类的对象,只能够用作基类。所以要称为真正的ABC,在类声明中至少包含一个纯虚函数。总之,在原型中使用=0指出类是一个抽象基类,在类中可以不定义该函数,但是C++甚至允许纯虚函数有定义。
由ABC派生出来的类可以称为具体类。
继承和动态内存分配
第一种情况:派生类不使用new
第二种情况:派生类使用new
细看原文
类设计回顾
编译器生成的成员函数
我们知道,在类中,除了我们自己定义的成员函数之外,还有一些编译器自动生成的成员函数——特殊成员函数。
-
默认构造函数
自动生成的默认构造函数的另一项功能是,调用基类的默认构造函数以及调用本身是对象的成员所属类的默认构造函数。
如果类包含指针成员,则必须初始化这些成员。因此,最好提供一个显式默认构造函数,将所有的类数据成员都初始化为合理的值。
-
复制构造函数
在下述情况下,将使用复制构造函数:
- 将新对象初始化为一个同类对象
- 按值将对象传递给函数
- 函数按值返回对象
- 编译器生成临时对象
-
赋值运算符
默认的赋值运算符用于处理同类对象之间的赋值,不要同初始化混淆了。
Star sirius; Star alpha = sirius; // initialization Star dogstar; dogstar = sirius; // assignment
其他的类方法
-
构造函数
只能调用基类的构造函数,并不能被继承。
-
析构函数
一定要定义显式析构函数来释放类构造函数使用new分配的所有内存,并完成类对象所需的任何特殊的清理工作。对于基类,即使它不需要析构函数,也应提供一个虚析构函数。
-
转换
细看原文
-
按值传递对象与传递引用
-
返回对象和返回引用
返回引用可以节省时间和内存。但是明确要使用对象的应该返回对象,比如要返回函数中临时创建的对象,这种返回类型一定要是对象。
-
使用const
公有继承的考虑因素
-
is-a关系
-
什么不能被继承
构造函数、析构函数、赋值运算符不能被继承
-
赋值运算符
-
私有成员与保护成员
-
虚方法
-
析构函数
-
友元函数
-
有关使用基类方法的说明
类函数小结
函数 | 能够继承 | 成员还是友元 | 默认能够生成 | 能否为虚函数 | 是否可以有返回类型 |
---|---|---|---|---|---|
构造函数 | 否 | 成员 | 能 | 否 | 否 |
析构函数 | 否 | 成员 | 能 | 能 | 否 |
= | 否 | 成员 | 能 | 能 | 能 |
& | 能 | 任意 | 能 | 能 | 能 |
转换函数 | 能 | 成员 | 否 | 能 | 否 |
() | 能 | 成员 | 否 | 能 | 能 |
[] | 能 | 成员 | 否 | 能 | 能 |
-> | 能 | 成员 | 否 | 能 | 能 |
op= | 能 | 任意 | 否 | 能 | 能 |
new | 能 | 静态成员 | 否 | 否 | void* |
delete | 能 | 静态成员 | 否 | 否 | void* |
其他运算符 | 能 | 任意 | 否 | 能 | 能 |
其他成员 | 能 | 成员 | 否 | 能 | 能 |
友元 | 否 | 友元 | 否 | 否 | 能 |