【C++ Primer Plus】学习笔记--第13章 类继承

【C++ Primer Plus】完整学习笔记目录

第13章目录

  • 13.1 一个简单的基类
    • 派生类构造函数
    • 基类指针指向派生类对象
      • why
      • 用途之一
  • 13.2 继承:is-a 关系
  • 13.3 多态公有继承
    • virtual 关键字
    • 派生类中调用基类方法
    • 为何基类需要虚析构函数?
    • 多态小结
  • 13.4 静态联编和动态联编
    • 指针和引用类型的兼容性
      • 向上强制转换
      • 向下强制转换
    • 虚函数和动态联编
      • 静态联编的优点
      • 虚函数的工作原理
      • 虚函数注意事项
        • 1. 构造函数不能为virtual
        • 2.virtual析构函数
        • 3. 友元不能为virtual
        • 4.派生类没有重新定义
        • 5. 重新定义将隐藏方法
  • 13.5 访问控制:protected
  • 13.6 抽象基类(abstract base class,ABC)
    • 纯虚函数
  • 13.7 继承和动态内存分配
    • 第一种情况:派生类不使用new
    • 第二种情况:派生类使用new
      • 派生类析构函数
      • 派生类复制构造函数
      • 派生类赋值运算符
    • 总结
    • 派生类友元函数


类继承:能够充已有的类派生出新的类,而派生类继承了原有类(基类)的特征。

  • 可以在已有的类的基础上添加功能。
  • 可以给类添加数据。
  • 可以修改类方法的行为。

13.1 一个简单的基类

从一个类派生出另一个类时,原始类称为基类(base class)继承类称为派生类(derived class)

派生类代码:

class RatedPlayer:public TableTennisPlayer
{
     }
  • 派生类继承了基类的实现
  • 派生类继承了基类的接口

需要在派生类添加
1.自己的构造函数
2.可以根据需要添加额外的数据成员和成员函数

派生类构造函数

派生类不能直接访问基类的私有成员,而必须通过基类方法进行访问。

派生类构造函数在初始化基类私有数据时,采用的是成员初始化列表语法。

注意派生类构造函数必须使用基类构造函数

RatePlayer::RatedPlayer(unsigned int r, const string& fn,
	const string& ln, bool ht):TableTennisPlayer(fn, ln, ht)
{
     
	rating = r;
}

注意】:如果不调用基类构造函数,程序将使用默认的基类构造函数。此时,基类必须有默认的构造函数成员初始化列表只能用于构造函数

等价于

RatePlayer::RatedPlayer(unsigned int r, const string& fn,
	const string& ln, bool ht) //:TableTennisPlayer()
{
     
	rating = r;
}

第二种构造函数,调用基类的复制构造函数

RatePlayer::RatedPlayer(unsigned int r,const TableTennisPlayer& tp):
	TableTennisPlayer(tp)  // 调用基类的复制构造函数
{
     
	rating = r;
}

有关派生类构造函数的要点

  • 首先创建基类对象;
  • 派生类对象应通过成员初始化列表将基类信息传递给基类构造函数;
  • 派生类构造函数应初始化派生类新增的数据成员。

要点
创建派生类对象时
首先调用基类构造函数,然后再调用派生类构造函数
首先执行派生类的析构函数,然后调用基类的析构函数

基类指针指向派生类对象

C++ 要求引用和指针类型与赋给的类型匹配,但这一规则对继承来说是例外的。但这种例外只是单向的。

只允许基类指针(引用) 在不进行显示类型转换的情况下指向(引用)派生类对象,不可以将基类对象和地址赋给派生类引用和指针。

基类指针或引用只能用于调用基类方法,不能调用派生类的方法。

why

基类指针指向派生类对象,通常派生类都会继承基类的数据和方法;基类有的方法,派生类都有,可以正常访问。
而假如派生类指向基类,派生类的数据内存通常大于基类,造成内存溢出,当访问子类方法时,会出现非法访问。

用途之一

基类引用定义的函数或指针参数可用于基类对象或派生类对象。

class TableTennisPlayer {
      ... }; //base class
class RatePlayer:public TableTennisPlayer {
      ... }; // derived class
void Show(const TableTennisPlayer& rt){
      ... } // show() function of base class reference

TableTennisPlayer player; // base class object
RatePlayer rplayer; // derived class object
Show(player); // works with TableTennisPlayer argument
Show(rplayer); // works with RatePlayer argument

13.2 继承:is-a 关系

C++ 有3种继承方式:公有继承、保护继承和私有继承

公有继承是最常见的方式,它建立一种is-a 关系,即派生类对象也是一个基类对象。可对基类对象执行的任何操作,也可以对派生类对象执行。

is-a 关系通常是不可逆的,例如:香蕉是水果,不能说水果是香蕉。

公有继承不建立has - a关系。has -a 关系通常指类中含有的方法或接口。

13.3 多态公有继承

多态:同一个方法在派生类和基类中的行为不同。换句话说,方法的行为取决于调用该方法的对象。

多态公有继承:

  • 在派生类中重新定义基类方法
  • 使用虚方法,关键字virtual

virtual 关键字

切记只有类成员才能使用virtual关键字

Brass* p = new BrassPlus; 

重点】:
如果没有使用关键字virtual,程序将根据引用类型或指针类型选择方法。将调用基类类型Brass的方法
如果使用virtual,程序将根据引用或指针指向的对象类型来选择方法。将调用指向BrassPlus类型的方法

方法在基类中被声明为virtual,它在派生类中将自动成为虚方法

派生类中调用基类方法

在派生类方法中,可以使用作用域解析运算符来调用基类方法。

Brass::ViewAcct(); // display base portion
// 如果代码没有使用作用域解析运算符,编译器认为是派生类方法
// BrassPlus::ViewAcct();

为何基类需要虚析构函数?

基类声明一个虚析构函数是为了确保释放派生对象时,按正确的顺序调用析构函数

如果基类析构函数不是虚的,那只调用对应与指针类型的析构函数;
如果基类的析构函数是的,将调用指向的对象类型的析构函数。

Brass* p = new BrassPlus; 
delete p;
// 如果析构函数不是虚的,只调用Barss的析构函数
// 如果析构函数是虚的,将调用派生类BrassPlus的析构函数,将自动调用基类析构函数。

多态小结

  • 继承
  • virtual 重写父类需要不同实现的方法和析构函数
  • 父类指针指向子类对象

13.4 静态联编和动态联编

将源代码中的函数调用解释为执行特定的函数代码块称为函数名联编

静态联编:在编译过程中进行联编被称为静态联编。又称为早期联编

虚函数使得使用哪个函数不能在编译时确定的,编译器不知道用户将选择哪种类型的对象

动态联编:编译器必须生成能够在程序运行时选择正确的虚方法的代码,被称为动态联编,又称为晚期联编。在C++中,动态联编与通过指针和引用调用方法相关,由继承控制。

指针和引用类型的兼容性

向上强制转换

将派生类引用或指针转化为基类引用或指针被称为向上强制转换,使公有继承不需要进行显示类型转换

该规则是is-a关系的一部分。可以对base class对象执行的任何操作,都适合derived class对象。

对基类引用或指针作为参数的函数调用,将进行向下转换。

隐式向上强制转换使基类指针或引用可以指向基类对象或派生类对象,因此需要动态联编。C++使用virtual函数来实现。

向下强制转换

相反的过程, 将基类指针或引用转换为派生类指针或引用称为向下强制转换

向下强制转换是不被允许的,因为is-a关系是不可逆的。

虚函数和动态联编

BrassPlus ophelia; // derived class object
Brass* bp; // base class object
bp = &ophelia; //Brass pointer to BrassPlus object
bp->ViewAcct(); //which version?

如果没有将ViewAcct()声明为虚函数,则调用Brass::ViewAcct(),指针类型在编译时已知。编译器对非虚方法使用静态联编

如果在基类中将ViewAcct()声明为虚函数,根据对象类型调用(BrassPlus)调用BrassPlus::ViewAcct()。通常只有在运行程序时才能确定对象的类型。编译器对虚方法使用动态联编

静态联编的优点

  • 效率高
    动态联编为使程序能够在运行阶段进行决策,必须采用一些方法来跟踪基类指针和引用指向的对象类型,这将增加了额外的处理开销
  • 概念模型,不将函数设置为虚函数,不需要重新定义该函数。

技巧】:如果类不会用作基类或者派生类不重新定义基类的任何方法,不需要使用动态联编。使用C++默认选择,静态联编效率更高
注意】:如果要在派生类中重新定义基类的方法,则将它设置为虚方法。
切记,不要为不使用的特性付出代价(内存或者处理时间)

虚函数的工作原理

编译器处理虚函数的方法是:给每个对象添加指向虚函数地址数组的vptr指针
这个数组被称为虚函数表。虚函数表中存储了为类对象进行声明的虚函数的地址
【注意】:无论类中包含的虚函数是1个还是10个,都只含有一个vptr指针

调用虚函数时,程序将查看存储在对象中的虚函数表,然后转向相应函数地址表。
【C++ Primer Plus】学习笔记--第13章 类继承_第1张图片
总之,使用虚函数时,在内存和执行速度方面有一定的成本,包括:

  • 每个对象内存都将增大,增大量为vptr指针的内存
  • 对于每个类,编译器都创建一个虚函数地址表(数组)
  • 对于每个函数调用,都需要执行一项额外的操作,即到表中查找地址

虚函数注意事项

1. 构造函数不能为virtual

构造函数不能为虚函数
其一构造函数是构造新对象,创建对象的内存空间,如果virtual构造函数,将会调用虚函数表,但还没有内存空间
再者创建派生类对象将自动调用基类构造函数,不能通过base class 指针指向derived class对象。一定不能将构造函数声明为虚函数,编译会报错。

2.virtual析构函数

带有多态性质base class 应该声明一个virtual析构函数
将调用派生类析构函数,然后自动调用基类析构函数否则将只调用基类析构函数,造成内存泄漏

详细内容见下链接:
Effective C++ T07:为多态基类声明virtual析构函数

3. 友元不能为virtual

友元不能是虚函数,因为友元不是类成员,而只有类成员才能是虚函数。但友元函数可以调用虚函数。

4.派生类没有重新定义

如果派生类没有重新定义函数,将使用该函数的基类版本。

5. 重新定义将隐藏方法

virtual void showperks(int) const; // base class 
virtual void showperks() const; // derived class 
...
trump.showperks(); // valid
trump.showperks(); // invalid

重新定义继承的方法不是重载

如果重新定义派生类中的函数,将不只是使用相同的函数参数列表覆盖基类声明。

无论参数列表是否相同,该操作将隐藏所有同名基类方法

经验】:
第一:如果重新定义继承的方法,应确保与原来的原型完全相同。‘
第二:如果基类声明被重载了,则应在派生类中重新定义所有的基类版本

但如果返回类型基类引用或者指针,派生类中可以修改为指向派生类的引用或者指针。这种特性被称为返回类型协变。因为允许返回类型随类类型的变化而变化:

class Dwelling
{
     
public:
	virtual Dwelling& build(int); // a base class function
	...
}
class Hovel: public Dwelling
{
     
public:
	virtual Hovel& build(int); // same function signature of derived class
	...
}

注意】:这种例外只适用于返回值,而不适用于参数

13.5 访问控制:protected

protected 与 private相似,区别是派生类成员可以直接访问基类的protected,但不能直接访问基类的private。

因此, 对于外部世界,protected的行为和private;但对于派生类,protected的行为和public相同。

警告】:最好对类数据成员采用private访问,不要使用保护访问;同时提供基类方法使派生类能够访问基类数据

对于成员函数保护访问可以使得派生类能够访问公众不能使用的内部函数。

13.6 抽象基类(abstract base class,ABC)

问题导引:
建立椭圆类Ellipse,其中应该包括长轴长和短轴长等内容。假设从Ellipse派生出一个圆类Circle。但是这种派生是笨拙的,圆只有半径,导致信息冗余
相比继承,直接声明Circle类更加简单
但这种解决办法效率不高Ellipse和Circle类中含有很多共同点

解决办法:
从Ellipse和Circle类中抽象出它们的共性,将这些特性放到一个抽象基类(ABC)中。

纯虚函数

C++通过使用纯虚函数实现抽象基类(ABC)。纯虚函数声明的结尾处为 = 0.

纯虚函数格式:

virtual double Area() const = 0; // a pure virtual function

当类声明中包含纯虚函数时,则不能创建该类对象,无法实例化对象。

像Circle和Ellipse类被称为具体类,可以创建这些类的对象。

总之,ABC描述的是至少使用一个纯虚函数接口

注意】:纯虚函数可以不定义,但虚函数必须定义。此外,纯虚析构函数例外,必须有一份定义。

13.7 继承和动态内存分配

问题导引:
继承时候怎样与动态内存分配(使用new和delete)进行互动的呢?

第一种情况:派生类不使用new

假设基类使用了动态内存分配
声明中包含了构造函数使用new时需要的特殊方法:析构函数,复制构造函数和重载赋值运算符。

是否需要派生类定义显式析构函数、复制构造函数和赋值运算符呢?
答案是不需要

【注意】:成员复制将根据数据类型采用相应的复制方式。 复制类成员或继承类组件时,则是使用该类的复制构造函数完成的。
类的默认赋值运算符将自动使用基类的复制运算符来对基类组件进行赋值。

第二种情况:派生类使用new

假设派生类使用了new

必须为派生类定义显式析构函数、复制构造函数和赋值运算符。

派生类析构函数

class baseDMA // base class
{
     	
private:
	char* label; //use new in constructor
	int rating;
public:
	...
};
class hasDMA // derived class
{
     
private:
	char* style; //use new in constructor
public:
	...
};


baseDMA::~baseDMA() //destructor function 
{
     
	delete[] label;
}

hasDMA::~hasDMA() //destructor function
{
     
	delete[] style;
}

派生类复制构造函数

baseDMA::baseDMA(const baseDMA& rs)
{
     
	label = new char[std::strlen(rs.label) + 1];
	std::strcpy(label, rs.label);
	rating = rs.rating;
}

hasDMA::hasDMA(const hasDMA& hs):baseDMA(hs) //call baseDMA constructor function
{
     
	style = new char[std::strlen(hs.style) + 1];
	std::strcpy(style, hs.style);
}

值得注意的是,成员初始化列表将一个hasDMA引用传递给baseDMA构造函数基类引用可以指向派生类类型。

因此,baseDMA复制构造函数将使用hasDMA参数的baseDMA部分来构造新对象的baseDMA部分。

派生类赋值运算符

baseDMA& baseDMA::operator=(const baseDMA& rs) // base class
{
     
	if(this == &rs)
		return *this;
	delete[] label;
	label = new char[std::strlen(rs.label) + 1];
	std::strcpy(label, rs.label);
	rating = rs.rating;
	return *this;
}

作为hasDMA的方法,它只能访问hasDMA的数据。因此,可以使用作用域解析运算符显示调用基类赋值运算符

hasDMA& hasDMA::operator=(const hasDMA& hs) // derived class
{
     
	if(this == &hs)
		return *this;
	//**************重点
	baseDMA::operator=(hs); // copy base portion
	//**************
	delete[] style; // prepare for new style
	style = new char[std::strlen(hs.style) + 1];
	std::strcpy(style, hs.style);
	return *this;
}

总结

  • 当派生类不使用动态内存分配,不需要操作

  • 当基类和派生类都采用动态内存分配时,派生类的析构函数、复制构造函数、赋值运算符都必须使用相应的基类方法来处理基类元素。
    对于析构函数,是自动完成的。
    对于构造函数,通过在初始化列表中调用基类的复制构造函数完成。
    对于赋值运算符,通过使用作用域解析运算符显式地调用基类的赋值运算符来完成。

派生类友元函数

问题:
该函数不是基类baseDMA的友元函数,如何访问私有成员label和rating呢?

  • 使用基类baseDMA的友元函数 operator<<()。
    但是友元函数不是成员函数不能使用作用域解析运算符来指出要使用哪个函数。
    这个问题的解决办法是 使用强制类型转换,以便匹配原型时能够选择正确的函数。
// function prototype
friend std::ostream& operator<<(std::ostream& os, const hasDMA& hs);

// function methods
std::ostream& hasDMA::operator<<(std::ostream& os, const hasDMA& hs)
{
     
	//**************重点
	os << (const baseDMA& )hs; // 使用强制类型转换
	//or 使用dynamic_cast<> 来进行强制类型转换,是更佳的(15章介绍原因)
	os << dynamic_cast<const baseDMA&>(hs);
	//**************
	os << "Style:" << hs.style << std::endl;
	return os;
}

派生类友元函数使用基类友元函数,可用通过强制类型转换。

你可能感兴趣的:(c++,学习笔记,c++,继承,多态)