《C++PrimerPlus 6th Edition》第13章 类继承 要点记录

章末习题会做的话,那么这章内容也就基本掌握了。下面笔者大体按照书中的顺序,并结合一些简单示例分析来记录本章的知识点。

本章内容

  1. is-a 关系的继承
  2. 如何以公有方式从一个类派生出另一个类
  3. 保护访问
  4. 构造函数成员初始化列表
  5. 向上和向下强制转换
  6. 虚成员函数
  7. 早期(静态)联编与晚期(动态)联编
  8. 抽象基类
  9. 纯虚函数
  10. 何时及如何使用公有继承

继承可以完成的工作:

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

13.1 一个简单的基类

给定一个类 Fruit 如下:

class Fruit{
    
    
private:
	string name;
	float price;
public:
	 Fruit(const string& nm = "none", float pr = 0.0f);
	 void Name() const;
	 void ResetPrice(float pr){
    
    price = pr;}
};

我们从 Fruit 类 ⌈ \lceil 公有派生 ⌋ \rfloor 出一个 Apple 类:

class Apple : public Fruit{
    
    
private:
	unsigned int numbers; // 数目
	...
public:
	Apple(unsigned int num = 0, 
		const string& nm = "none", float pr = 0.0f);
	unsigned int Amount(){
    
    return numbers;}
	void ResetAmount(unsigned int n){
    
    numbers = n;}
};

说明:

  1. 使用公有派生,基类的公有成员将成为派生类的公有成员;基类的私有部分也将成为派生类的一部分,但只能通过基类的公有保护方法访问。

  2. 派生类不能直接访问基类的私有成员,因此调用构造函数对基类私有数据成员初始化时,必须使用基类构造函数,实现的形式是成员初始化列表,下面两种写法等效:

    Apple::Apple(unsigned int num, 
    const string& nm, float pr):Fruit(nm, pr){
          
          
    	numbers = num;
    }
    
    Apple::Apple(unsigned int num, 
    	const string& nm, float pr):Fruit(nm, pr), numbers(num){
          
          }
    

    再多提一句,初始化成员列表中的变量顺序并不会影响变量初始化的顺序。

  3. 派生类构造函数执行过程:

    扫描二维码关注公众号,回复: 13606796 查看本文章
    1. 首先创建基类对象;

    2. 派生类构造函数通过成员初始化列表将基类信息传递给基类构造函数;

    3. 派生类构造函数初始化派生类新增的数据成员。

  4. 派生类对象释放过程:

    1. 首先执行派生类的析构函数;

    2. 然后自动调用基类的析构函数;

  5. 如果没有在成员初始化列表中提供基类构造函数,程序将使用默认的基类构造函数。

  6. 除了虚基类(第 14 章), 类只能将值传递回相邻的基类,然后由基类传递给基类的基类,以此类推。不能越级

另外,关于派生类和基类的特殊关系,注意以下几点:

  1. 基类的指针(或引用)可以在不进行显式类型转换的情况下,指向(或引用)派生类对象,但反过来不可行,即派生类的指针(或引用)不允许指向(或引用)基类对象。

  2. 基类指针(引用)只能调用基类方法。

  3. 下面两种情况下会分别涉及隐式复制构造函数、隐式重载赋值运算符:

    Apple apple(10, "LuoChuan", 5.5);
    Fruit fruit(apple); // copy constructor Fruit(const Fruit&)
    
    Apple apple(10, "LuoChuan", 5.5);
    Fruit fruit;
    fruit = apple;  // overloaded operator = Fruit& operator=(const Fruit&) const;
    

13.2 继承:is-a 关系

  1. C++ 有 3 种继承方式:①公有继承;②保护继承;③私有继承。

  2. 类与类之间的 5 种关系:

    1. is-a:继承关系;

    2. has-a:包含关系,一个作为另一个的数据成员,例如 Fruit 类作为 Lunch 类的成员;

    3. is-like-a:提取两者共同特征组成一个新类,然后建立 is-a 或 has-a 的关系;

    4. is-implemented-as-a:例如栈是种逻辑结构,具体实现可以是数组,也可以用链表;

    5. uses-a:例如计算机用到打印机,两者没有继承关系,可以使用友元函数或类来处理两者之间的通信。

公有继承只建立 is-a 的关系。


2020-11-30


笔者此时已经把章末习题全做完了,因此之后小节的记录顺序很可能与原书的介绍顺序有所不同。

13.3 多态公有继承:虚方法 & 虚函数的工作原理 & (重写, 重定义, 重载)

这一节将引入关键字 virtual,用它来修饰的方法称为 ⌈ \lceil 虚方法 ⌋ \rfloor 。它的一大优势是可以展现 ⌈ \lceil 多态 ⌋ \rfloor 的特性,即同一个方法的行为随上下文而异。然而展现多态性的机制除了使用虚方法之外,还有 ⌈ \lceil 重写 ⌋ \rfloor —— 在派生类中重新定义并覆盖基类的方法,我们可以通过对几个简单例子的分析得出 重写 与 虚方法 的异同,并进一步了解虚方法的一些注意事项。

【示例1】

class A{
    
     // base class A
	...
public:
	//void fun(); // without virtual -> redefine
	virtual void fun();
	...
};
class DA: public A{
    
     // derived class DA 
	...
public:
	void fun();
	...
};
void case1(){
    
    
	A obj_a;
	DA obj_da;
	obj_a.fun(); // use A::fun()
	obj_da.fun(); // use DA::fun()
}
void case2(){
    
    
	A obj_a;
	DA obj_da;
	
	// use base-class pointer
	A* p_a = &obj_a;
	p_a->fun(); // use A::fun()
	p_a = &obj_da;
	p_a->fun(); // use DA::fun() 
	
	// use base-class reference
	A& ref_a = obj_a;
	A& ref_da = obj_da;
	ref_a.fun(); // use A::fun()
	ref_da.fun(); // use DA::fun()
}

对于示例1的说明

  1. 对于 case1,如果基类 A 中的成员函数 fun() 没有 virtual 修饰,那么此时相当于派生类对基类的方法 fun() 进行 ⌈ \lceil 重定义 ⌋ \rfloor ,但是结果不会因为有无 virtual 修饰而改变,即在 case1 中,虚方法(重写) 和 重定义 这两种机制等价;

  2. 对于 case2,分别使用了一个基类指针和一个基类引用来指向对象,这个例子就能看出 重定义 与 虚方法(重写) 的差异:如果没有使用关键字 virtual,即使用了重定义,程序将根据引用类型或指针类型选择执行的方法;如果使用了 virtual,程序将根据引用或指针指向的对象的类型来选择方法。

  3. case2 将多态性体现得淋漓尽致:对于同样的指令 p_a->fun(),却会随对象类型而表现出不同的行为 (或者对于引用来说,同类型的引用却会随对象类型而表现出不同的行为)。

从上面的示例中我们也能得知一个事实,那就是虚方法是一种重写,是对基类相应函数的重定义,外加一个限定条件 —— 派生类相应函数的 ⌈ \lceil 函数签名 ⌋ \rfloor (特征标)要与对应基类虚函数的相同。

下面我们通过示例2来探讨一下 —— 当派生类的相应函数特征标与基类对应虚函数的不一致时会发生什么:

【示例2】

#include<iostream>
using namespace std;
class A{
    
     // base class A
public:
    //[caseA1]
	virtual void fun();
    virtual void fun(int i);
    //[caseA2]
    virtual void fun();
    //[caseA3]
    virtual void fun(int i);
};
class DA: public A{
    
     // derived class DA 
public:
    //[caseB1]
    void fun();
    void fun(int i);
    //[caseB2]
    void fun();
    //[caseB3]
    void fun(int i);
};
void CASE();
int main(){
    
    
    CASE();
    cin.get();
    return 0;
}

void CASE(){
    
    
	A obj_a;
	DA obj_da;
	obj_a.fun(); //  #1
    obj_a.fun(4); // #2
    obj_da.fun(4); // #3
    obj_da.fun(); // #4

    // use base-class pointer
	A* p_a = &obj_a; 
	p_a->fun(); // #5
    p_a = &obj_da;
	p_a->fun(); // #6

    cout << endl;
    // use base-class reference
	A& ref_a = obj_a;
	A& ref_da = obj_da;
	ref_a.fun(4); // #7
	ref_da.fun(5); // #8
}
// 函数定义省略
//...

对于示例2的说明

我们通过组合情形来讨论各种可能的情况,比如 case_A1B1 表示 [caseA1][caseA2] 处的声明启用时对应的情形。一共有 C 3 1 × C 3 1 = 9 C_3^1 \times C_3^1 = 9 C31×C31=9 种组合的情形,但其中部分情形我们在示例1中已经见到过,比如 case_A2B2、case_A3B3;另外还有一些等效的情况,比如 case_A2B3case_A3B2case_A2B1case_A3B1case_A1B2case_A1B3。综上,我们其实只需要讨论 4 种情况即可,看看函数 CASE() 中编号 #1 ~ #8 的指令是否合法,执行的结果列表如下:(表末尾还追加了一种默认情况,即派生类没有对基类虚函数进行重写,此情况命名为 case_A1B0

表 1 不同虚函数声明情况的结果对比
情况 #1 #2 #3 #4 #5 #6 #7 #8
case_A1B1
case_A1B2 X
case_A2B1 X X X
case_A2B3 X X X X
case_A1B0

注:上表中符号 表示语句合法,X 代表语句非法。

对以上列表的结果具体分析之前,我们先来看一下 虚函数的工作原理,并回顾一下 重载、重定义和重写的区别

虚函数的工作原理

  1. 编译器处理虚函数的方法是:给每个对象添加了一个隐藏成员 。隐藏成员中保存了一个指向函数地址数组的指针。这种数组称为 ⌈ \lceil 虚函数表 ⌋ \rfloor (virtual function table, vtbl )。 虚函数表中存储的是为类对象进行声明的虚函数的地址

  2. 基类与派生类的地址表是相互独立的,即它们的数组基地址不一样,但它们存储的函数地址有可能一样 —— 如果派生类没有重新定义虚函数,该 vtbl 将保存函数原始版本的地址;但如果派生类提供了基类虚函数的新定义,派生类的 vtbl 将保存新函数的地址,从而实现多态性;另外,如果派生类定义了新的虚函数(基类没有),则该函数的地址会被添加到派生类的 vtbl 中。

  3. 无论类中包含的虚函数是 1 个 或 10 个,都只需要在对象中添加 1 个 地址成员,只是表的大小不同而已。

  4. 调用虚函数时,程序将查看存储在对象中的 vtbl 地址,然后转向相应的函数地址表。

正因如此,虚函数使用时,在内存和执行速度方面有一定的成本,包括:

  • 每个对象都将增大,增大量为存储地址的空间(指针);

  • 对于每个类,编译器都创建一个虚函数地址表(数组);

  • 对于每个函数调用,都需要执行一项额外的操作,即到表中查找地址。

重载 & 重定义 & 重写 的区别

说区别之前,回顾一下函数特征标的组成部分

  • 参数列表中的参数数目和类型

  • 参数列表中的参数顺序

注意:特征标不包括函数名以及返回类型,这意味着函数名和返回类型相同与否不能用于判断特征标的异同。

有了特征标的概念,下面来对比 ⌈ \lceil 重载 ⌋ \rfloor ⌈ \lceil 重写 ⌋ \rfloor ⌈ \lceil 重定义 ⌋ \rfloor

  • 函数重载 (overload) :采用相同函数名不同特征标来构建函数,其实重载函数通常与其他函数没有啥区别。

  • 函数重定义 (redefine):采用相同函数名来构建函数,用新的函数定义"推翻"旧的函数定义。

  • 函数重写 (override):采用相同函数名相同特征标来构建函数,重写是一种重定义,但重定义不一定是重写。由于一个函数有唯一的 id (这里指的是(函数名,特征标)->函数地址的映射表中的键,这里的映射关系是笔者假想的,具体尚未求证 ),而重写函数的 id 是相同的,一个 id 对应唯一的值 (否则函数调用就乱了),即函数地址,那么重写行为会使新定义函数的地址覆盖掉旧值。

基类虚函数在派生类中重写本质上与一般函数重定义没区别,但虚函数额外增添了一个虚函数表的指针 vtbl 作为对象成员,注意,是作为对象成员!!!这么一强调你是不是对于 虚函数能根据对象类型调用相应的函数 这句话有了更深理解了?(笔者也是写到这里才恍然大悟的)

没错!!!事实上,基类指针和引用哪能识别对象类型,只不过是查找对象的虚函数表 vtbl 罢了~ 毕竟派生类和基类对象所持有的 vtbl 成员指向的不是同一个虚函数表,随类的不同而不同。

另外,虚函数的各个重载版本在 vtbl 指向的数组中对应不同的元素,毕竟特征标不同,其映射也会不同。再次提醒,特征标不包含返回值,这意味着派生类版本的虚函数可以与基类的版本具有不一样的返回类型,这种特性称为返回类型协变 (covariance of return type)

回到示例2的分析

我们可以根据虚函数的工作原理来简单分析表 1 的结果:

  1. case_A1B1 描述的是基类具有虚函数的重载版本,而派生类中对各自重载版本均有新定义。此时派生类的 vtbl 将分别记录两个虚函数的新定义地址,这样两个重载版本均能体现多态性,能正常工作。

  2. case_A1B2 描述的是基类具有虚函数的重载版本,而派生类具有一个或多个但非全部版本的新定义 fun()。此时语句 #3 -> obj_da.fun(4); 非法,即当派生类具有基类某一虚函数的新定义时,其余的基类对应重载版本如果不在派生类中给出对应的重写版本,则会被隐藏。 笔者尚不清楚其中的底层机理 (书中本章也没提),但从表层来看,这一点与默认构造函数类似:没有显式给出任何构造函数时才会提供不带参数的默认构造函数,一旦给出了一个构造函数的具体实现,那么这个默认构造函数就不再提供;类似地,对于虚函数,如果基类存在多个对应的重载版本,在派生类没有显式给出虚函数的新定义时,派生类对象可以调用所有基类版本 (对应 case_A1B0,说明:如果派生类位于派生链中,则将使用最新的虚函数版本,所以这里说的基类,是相对概念,不一定是最底层的基类),一旦给出某一或某些重载版本的新定义,则没给出定义的其他基类重载版本将被隐藏 (对派生类而言),不可被派生类对象调用。因此,如果在派生类给出一个虚函数的新定义,那么其他所有重载版本都应给出新定义,否则将被隐藏。

  3. case_A2B1 描述的是派生类中除了重写基类虚函数以外,还提供了该虚函数的重载版本。此时基类指针 (或引用) 可以访问派生类的虚函数,但无法访问其非虚函数,即便是其重载版本 —— 基类不具有相应重载版本的虚函数。毕竟,父类不能"偷窥"子类特有的东西,继承机制如此。

  4. case_A2B3 描述的是 派生类没有提供任何基类虚函数的新定义,但却提供了基类所不具备的重载版本。这就和 case_A2B1 类似,只不过派生类没有对基类的虚函数重写而已,这样基类的所有虚函数重载版本都将被隐藏 (对派生类对象),派生类对象只能用自己提供的重载版本。

小结一下示例 2 给我们的启示:

  1. 如果重新定义继承的方法,应确保与原来的原型完全相同,即重写;

  2. 如果基类声明被重载了,则应在派生类中重写所有的基类版本,否则其余未重写的基类版本将被隐藏。

  3. 如果重写的所有基类重载版本中存在不需要修改的版本,则其新定义可只调用基类版本,这样确保不被隐藏。比如: void derivedClass:: Func() {baseClass:: Func();}

【示例3】

之前两个例子已经展现了声明为虚函数的好处,那么是不是什么样的函数都能声明为虚函数呢?答案是不能。撇开非OOP设计的函数外,以下的几种在类中声明的函数也不能 (或不适合) 作为虚函数:

  • 构造函数。这个很好理解:首先,不同类的类名不一致,不可能做到重定义,因而声明为虚函数无意义;其次,派生类的构造函数调用时会先使用基类的构造函数,然后执行派生类的构造函数,这种顺序不同于继承机制。因此,派生类不继承基类的构造函数,所以将类构造函数声明为虚的没啥意义。

  • 赋值运算符。其特征标随类而异,而虚函数要求特征标要相同。

  • 友元函数。它不能是虚函数,因为它不是类成员,只有成员函数才能声明为虚函数。如果因为此原因引起了设计问题,可以通过让友元函数使用虚成员函数来解决。

我们从下面这个示例来体会:为什么最好将析构函数声明为虚函数

#include<iostream>
using namespace std;
class A{
    
     // base class A
public:
    // [case1]
    ~A() {
    
     cout << "base class A destroyed~" << endl; }
    // [case2]
    virtual ~A() {
    
     cout << "base class A destroyed~" << endl; }
};
class DA: public A{
    
     // derived class DA 
public:
    ~DA() {
    
     cout << "derived class DA destroyed~" << endl; }
};
class DDA: public DA{
    
     // derived class DA 
public:
    ~DDA() {
    
     cout << "derived class DDA destroyed~" << endl; }
};

int main(){
    
    
    {
    
    
        A *p_a[3];
        p_a[0] = new A;
        p_a[1] = new DA;
        p_a[2] = new DDA;
        delete p_a[0];
        delete p_a[1];
        delete p_a[2];
    }
    cin.get();
    return 0;
}

case1 结果

base class A destroyed~
base class A destroyed~
base class A destroyed~

case2 结果

base class A destroyed~
derived class DA destroyed~
base class A destroyed~
derived class DDA destroyed~
derived class DA destroyed~
base class A destroyed~

对示例3的说明

  1. 与示例 1 的演示基本一样,不同的是析构函数的函数名不同,不能被重写;而且每个类只可能有一个,不能被重载。但它的调用特性决定了我们应该将它作为虚函数 (即便用不到这种特性) —— 调用派生类析构函数后还会依次调用其基类的析构函数,如果用基类引用或指针指向一个派生类对象,而派生类存在新的动态分配内存时,我们如果不声明析构函数为虚函数,那么就会按照基类指针或引用的类型执行基类析构函数,而不会执行派生类析构函数。

  2. 示例 3 中类 ADADDA 形成了一个继承链条,目的是为了展现:派生类中的重写虚函数无需再用 virtual 关键字修饰,当然加上也没什么问题。


13.4 静态联编和动态联编

  1. 将源代码中的函数调用解释为执行特定的函数代码块被称为函数名联编 (binding)。C语言中,每个函数名对应一个不同的函数;但在C++中,由于函数重载的缘故,编译器必须查看函数参数以及函数名才能确定使用哪个函数。

  2. 在编译过程中进行联编被称为静态联编 (static binding),又称为早期联编 (early binding)

  3. 编译器必须生成能够在程序运行时选择正确的虚方法的代码,这被称为动态联编 (dynamic binding),又称为 晚期联编 (late binding)

  4. 将派生类引用或指针转换为基类引用或指针被称为向上强制转换 (upcasting),这使公有继承不需要进行显式强制转换。向上强制转换具有传递性,即如果一个派生关系为 A->DA->DDA,那么基类 A 指针或引用可以引用 A 对象、DA 对象 和 DDA 对象(示例 3)。

  5. 相反的过程 —— 向下强制转换 (downcasting),如果不使用显式类型转换,则这种转换是不允许的。

  6. 基类对象和基类指针(或引用) 分别对应按值传递和按引用传递,对于基类对象来说,按值传递只会将派生类对象的基类部分传递给它。

  7. 编译器对非虚方法使用静态联编,对虚方法使用动态联编。

  8. 编译器默认的联编方式是静态联编,原因是:① 静态联编效率更高,动态联编需要采取一些方法来跟踪基类指针或引用指向的对象类型,这增加了额外的处理开销; ② 不是所有设计都需要使用虚函数。C++之父 Strousstrup 说过,不要为不使用的特性付出代价 (内存或者处理时间)。

13.5 访问控制 protected

  1. 对于外部世界来说,保护成员的行为与私有成员相似;但对于派生类来说,保护成员的行为与公有成员相似。区别对待 -> protected。

  2. 使用保护数据成员可以简化代码的编写工作,但存在设计缺陷。毕竟有些成员的设计初衷便是不被其派生类访问,而保护成员在派生链条中相当于公有的,违背了设计初衷。因此,最好对类数据成员采用私有访问控制,不要使用保护访问控制;同时通过基类方法使派生类能够访问基类数据。

13.6 抽象基类 (ABC, Abstract Base Class)

  1. ⌈ \lceil 纯虚函数 ⌋ \rfloor (pure virtual function) 即未实现的函数,其声明的结尾处为 =0,声明方式:virtual <return_type> func_name(...) = 0;

  2. 要使一个类成为抽象基类,至少需要包含一个纯虚函数。声明了纯虚函数的类只用作基类,且不能创建该类的对象。

  3. 纯虚函数可以有定义,也可以没有定义。

  4. 与抽象类对应的是具体类 (concrete class),它们可以继承自抽象类。ABC描述的是至少使用一个纯虚函数的接口,从ABC派生出的类将根据派生类的具体特征,使用常规虚函数来实现这种接口。

  5. 设计ABC时之前,首先应开发一个模型 —— 指出编程问题所需的类以及他们之间相互关系。一种学院派思想认为,如果要设计类继承层次,则只能将那些不会被用作基类的类设计为具体的类。这种方法的设计更清晰,复杂程度更低。

  6. 可以将ABC看作是一种必须实施的接口。ABC要求具体派生类覆盖其纯虚函数 —— 迫使派生类遵循ABC设置的接口规则。这确保了从ABC派生的所有组件都至少支持ABC指定的功能。

为了说明更多细节,特提供如下示例:

【示例】

#include<iostream>
using namespace std;

// ABC
class A{
    
     // base class A
public:
    virtual void fun() = 0;
};

// ABC
class DA: public A{
    
     // derived class DA 
public:
    virtual void fun1() const = 0;
    void fun(); // override
};

// concret class
class DDA: public DA{
    
     // derived class DA 
public:
    //void fun() { cout << "virtual function fun()" << endl; }
    void fun1() const;
};

// ABC
class DDA2: public DA{
    
     // derived class DA 
public:
    // [case1] concret
    void fun() {
    
     cout << "virtual function fun()" << endl; }
    void fun1() const;
    // [case2] concret
    void fun1() const {
    
    ...}
    // [case3] ABC
    void fun() {
    
    }
    // [case4] ABC
    //no functions
};

void DA::fun() {
    
     cout << "virtual function fun() in DA" << endl; }
void DA::fun1() const{
    
    cout << "pure virtual function fun1() in DA" << endl;}
void DDA::fun1() const{
    
    
    DA::fun1();
    cout << "virtual function fun1() in DDA" << endl;
}

int main(){
    
    
    DDA obj_dda;
    obj_dda.fun1();
    obj_dda.fun();
    //DDA2 obj_dda2; // if it's ABC, it cannot declare an object
    cin.get();
    return 0;
}

运行结果:

pure virtual function fun1() in DA
virtual function fun1() in DDA
virtual function fun() in DA

对示例的说明

  1. 抽象类可以继承自另一个抽象类。

  2. 抽象类中可以有非虚函数,而且可以给出纯虚函数的定义,如果它存在基类,则还可以实现基类 (也是ABC) 的纯虚函数的定义。

  3. 如果派生类中有纯虚函数,或者没有把其基类的所有纯虚函数接口都实现,那么该派生类也是ABC,不能创建对象;换句话说,该派生类能创建对象当且仅当其基类的所有纯虚函数接口都被实现。

举一个形象 (恰不恰当不知道) 的例子来说明ABC接口的特性:可以把"纯虚函数"看作"欠的债",把"声明对象"过程视作"买东西",你作为一个派生类,如果想要具有购买东西的资格 (成为具体类),那么你得首先摊还清你祖辈累积的所有债务(纯虚函数具体实现)后,你才能购买东西 (声明对象),否则你就是个老赖 (ABC),是会被法律制裁的 (只能靠你的子孙来还债了)!

13.7 继承和动态内存分配

  1. 如果基类使用了动态内存分配,而派生类不使用,那么派生类不需要定义显式析构函数、复制构造函数和赋值运算符;如果派生类也使用动态内存分配,那就必须显式定义上述的"DMA三件套"了。

  2. 成员复制将根据数据类型采用相应的复制方式,使用常规赋值方式完成;但复制类成员或继承的类组件时,则是使用该类的复制构造函数来完成的。对于赋值来说,同样如此。类的默认赋值运算符将自动使用基类的赋值运算符来对基类组件进行赋值。

  3. 当基类和派生类都采用动态分配内存时,派生类的析构函数、复制构造函数、赋值运算符都必须使用相应的基类方法来处理基类元素。它们采取的方式有差异:① 析构函数自动完成;② 复制构造函数通过初始化成员列表;③ 赋值运算符通过作用域解析运算符显式地调用基类的赋值运算符来完成。

  4. 关于派生类的友元函数的问题:① 派生类友元函数不是基类的友元,如何访问基类的私有成员?② 友元不是成员函数,不能使用 :: 来指出要使用哪个函数,怎么办? 答案是调用基类相应的友元函数,并且进行强制类型转换,示例如下 (来自习题13-4 port.cpp),关键语句已用 vital 标出:

    // base class friend function
    ostream &operator<<(ostream &os, const Port &p){
          
          
        os << p.brand << ", ";
        os << p.style << ", ";
        os << p.bottles;
        return os;
    }
    // derived class friend function
    ostream &operator<<(ostream &os, const VintagePort &vp){
          
          
        os << (const Port &) vp; // vital
        os << ", " << vp.nickname << ", " << vp.year;
        return os;
    }
    

    注:第 15 章还有一种运算符可以实现强制类型转换:os<<dynamic_cast<const Port &> (vp);

还是那句话,如果能把习题完成,那么这些要点不看也罢。

13.8 类设计回顾

构造函数

  1. 默认构造函数的功能: ① 创建对象; ② 调用基类的默认构造函数以及调用本身是对象成员所属类的默认构造函数。

  2. 提供构造函数的动机之一是确保对象总能被正确地初始化。另外,如果类对象包含指针成员,则必须初始化这些成员。因此,最好提供一个显式默认构造函数,将所有的类数据成员都初始化为合理的值。

  3. 构造函数不能被继承的原因之一:构造函数创建新的对象,而其他类方法只是被现有的对象调用。继承意味着派生类对象可以使用基类的方法,然而,构造函数在完成其工作之前,对象并不存在。

析构函数

  • 对类来说,即使不需要析构函数,也应提供一个虚析构函数

复制构造函数的使用情况回顾:

  • 将新对象初始化为一个同类对象

  • 按值将对象传递给函数

  • 函数按值返回对象

  • 编译器生成临时对象

赋值运算符:不要混淆 初始化 和 赋值

classA a1;
classA a2 = a1; // initialization 
classA a3;
a3 = a1; // assignment

转换

  1. 使用关键字 explicit 将禁止隐式转换,但仍允许显式转换:

    class A{
          
          
    public:
    	explicit A(const char*);
    };
    A a;
    a = "happy"; // not allowed
    a = A("happy"); // allowed
    
  2. 对于某些类,包含转换函数将增加代码的二义性。因此慎用转换。

  3. C++11 支持将关键字 explicit 用于转换函数。与构造函数一样,explicit 允许使用强制类型转换进行显式转换,但不允许隐式转换。

习题

点此进入github参考习题解答

欢迎各位大佬们于评论区进行批评指正~


上一篇文章:《C++PrimerPlus 6th Edition》第12章 类和动态内存分配 要点记录

下一篇文章:《C++PrimerPlus 6th Edition》TBD

猜你喜欢

转载自blog.csdn.net/weixin_42430021/article/details/109746848