10. 继承与派生

163 阅读16分钟

10. 继承与派生


什么是继承

用于从原有类中派生出新的类,而派生类继承了原有类的特征(数据成员和方法成员)。原有类被称为基类,继承类称为派生类。

// 从TableTennisPlayer 共有派生出RatePlayer
class RatePlayer : public TableTennisPlayer
{
    ...;
}
  1. 使用关键字public派生出的类称为共有派生。其具有下列特性:

    • 基类的公有方法成为派生类的公有方法。
    • 基类的私有成员成为派生类的一部分,但是只能通过继承的公有和保护方法来访问。
  2. 在派生类中,需要完善什么?

    • 重写派生类自己的构造函数。必须。因为添加了数据成员,必须给新成员和继承的成员提供数据。
    • 添加自己的数据成员和方法。
  3. 如何编写派生类的构造函数?

    • 首先创建基类对象。并且使用成员初始化列表调用基类构造函数将基类信息传给基类构造函数。

      程序在创建派生类对象之前,首先创建基类对象,这意味着基类对象在程序进入派生类构造函数之前被创建。C++使用成员初始化列表这种语法来完成这种工作。

    • 补齐自身数据成员。

    // 派生类使用成员初始化列表调用基类构造函数。
    // 注意这不同于继承机制,继承是覆盖了原方法,于原方法的实现没有关系
    // 这是派生类构造函数调用了基类的构造函数。派生构造函数不继承构造函数。
    RatePlayer::RatePlayer(int x, int y, int z) : TableTennisPlayer(x, y)
    {
        z_ = z;
    }
    
  4. 一个派生类的生命周期:

    创建派生类对象时,首先调用基类的构造函数来创建基类对象,然后再调用派生类的构造函数,基类构造函数负责初始化基类成员,派生类构造函数负责初始化新增成员。在生命周期结束后,首先调用派生类的析构函数,然后自动调用基类的析构函数。

  5. 基类和派生类之间的关系

    基类指针可以在不进行显式类型转换的情况下指向派生类对象;基类引用可以在不进行显式类型转换的情况下引用派生类对象。 在参数传递时也满足这个规则,可以将基类对象的地址或者派生类的地址实参传递给指向基类的指针和函数。

    但反过来不行。

  6. 友元函数不能被继承

    因为被继承的是类的成员。而友元不是类的成员。如果派生类想要调用基类的友元函数,那么可以使用强制类型转化将派生类的引用或指针转换为基类的然后调用基类友元。

多态公有继承 -- 虚方法

将同一个方法,在派生类和基类中做出不同的行为。即从继承角度的多态方法,方法的行为取决于调用对象。

  1. 如何实现?

    • 在派生类中重新定义基类的方法,
    • 在基类中将方法声明为虚方法
  2. 什么是虚方法?

    在类声明中带有关键字virtual的实例(应该是为了与静态方法分开?)方法。当方法被声明为虚方法后,程序将根据对象的类型或指针指向、引用的实际被引用对象来选择方法。如果没有virtual,只能根据指针或引用的类型选择方法。

  3. 哪些应该被声明为虚方法?

    • 需要在派生类中重新定义的方法
    • 虚析构函数--确保被释放的派生类对象是按正确的顺序调用析构函数。如果析构函数不是虚的,则将只调用对应于指针类型的析构函数(如果是使用基类指针但创建了派生类对象的话)。
    class Brass
    {
    private:
        string fullname_;
        long acct_NUM_;
        double balance_;
        
    public:
        Brass(const string & s = "Nullbody", long an = -1,
             double b = 0);  // 默认、显式构造
        void Deposit(double amt);
        virtual void Withdraw(double amt);
        virtual ~Brass() {}  // 将析构函数声明为虚方法
        
    }
    ​
    class BrassPlus : public Brass
    {
    private:
        double max_loan_;
        double rate_;
    public:
        BrassPlus(const string & s = "Nullbody", long an = -1,
             double b = 0, double ml = 500,
             double r = 0.11125);  // 构造函数
        BrassPlus(const Brass & ba, double ml = 500,
                 double r = 0.11125);
        virtual void Withdraw(double amt);
        void ResetMax (double m) { max_loan_ = 0}
        virtual ~BrassPlus(){}  // 派生类的析构函数也可以不写,使用自动提供的。
        
    }
    ​
    ​
    // .cpp
    // 在定义时使用成员初始化列表
    BrassPlus(const string & s = "Nullbody", long an = -1,
             double b = 0, double ml = 500,
             double r = 0.11125) : Brass(s, an, b)  
    {
        max_loan_ = ml;
        rate_ = r;
    }
    // 也可以全部用
    BrassPlus(const string & s = "Nullbody", long an = -1,
             double b = 0, double ml = 500,
             double r = 0.11125) : Brass(s, an, b), max_loan_(ml), rate_(r)
    {
        
    }
    
  4. 派生类和基类方法的覆盖(override)和覆写(overwrite)

    1. 覆盖:是指派生类中重新定义了基类中的虚函数,实现有两个点:

      • 基类函数被声明为虚函数
      • 两次声明(基、派生)函数具有相同函数原型(函数名、参数列表、和返回类型)。
    2. 覆写:指在派生类中定义与基类中同名但不同特征标的函数。不是多态。因为函数签名不同,不能通过基类的指针或引用调用派生类中的覆写函数。

静态联编和动态联编

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

静态联编通常用于非虚函数,在编译时就确定了方法调用的具体实现,这意味着函数调用时编译器会直接调用该方法。而动态联编是在运行阶段,根据对象的类型确定方法调用的具体实现。用于虚函数根据指针或引用所指向的对象的实际类型在运行阶段来确定方法的调用。

  1. 强制转换:

    • 向上强制转换(upcasting):从派生类到基类的转换。不需要显式强制转换。
    • 向下强制转换(downcasting):从基类到派生类。必须显式转换。

    对于使用基类指针和引用作为参数的函数调用,如果传入派生对象实参,将进行向上转换。 对于此类函数,需要使用动态联编,因为到运行时,才知道传入的对象是基类还是派生类。

  2. 两种联编的区别

    • 静态联编:在编译阶段就确定了方法调用的具体实现,效率高,被设置为C++的默认选择。
    • 动态联编:在运行阶段才确定方法的具体实现,其使用了一些方法来跟踪基类指针或引用指向的对象类型,这增加了处理开销,效率低,但是能跟踪对象类型。

    所以当需要在虚函数

  3. 虚函数的工作原理-- 虚函数表

    编译器处理虚函数的方法是:给每个对象(注意是对象,而不是类) 添加一个隐藏成员。隐藏成员中保存了一个指针。这个指针指向了为类声明的虚函数地址数组。这种数组称为虚函数表(virtual tunction table, vtbl)。

    有以下注意:

    • 只有该类及其所有基类中声明为虚函数的函数才会在虚函数表中有对应的地址。

    • 无论类中包含的虚函数是1个还是10个,都只需要在对象中添加一个地址成员(多重继承将会有多个虚函数指针),只是表的大小不同。

    • 对于虚函数表来说,在编译的过程中编译器就为含有虚函数的类创建了虚函数表,并且编译器会在构造函数中插入一段代码,这段代码用来给虚函数指针赋值。因此虚函数表是在编译的过程中创建

    • 几种继承和覆盖虚函数的方式:

      • 单继承+非覆盖虚函数

        派生类首先继承基类的虚函数表然后将自己的(不是覆盖)虚函数放在后面,所以虚函数表的顺序为基类虚函数表中虚函数的顺序+自己虚函数的顺序

        class Base1 
        {
        public:
           virtual void A() { cout << "Base1 A()" << endl; }
           virtual void B() { cout << "Base1 B()" << endl; }
           virtual void C() { cout << "Base1 C()" << endl; }
        };
        ​
        class Derive : public Base1
        {
        public:
            virtual void MyA() { cout << "Derive MyA()" << endl; }
        };
        

        image-20240507164713958

      • 单继承+覆盖虚函数

        首先继承基类的虚函数表,然后将覆盖虚函数在表中的地址也覆盖。

        class Base1 {
        public:
            virtual void A() { cout << "Base1 A()" << endl; }
            virtual void B() { cout << "Base1 B()" << endl; }
            virtual void C() { cout << "Base1 C()" << endl; }
        };
        ​
        class Derive : public Base1{
        public:
            virtual void MyA() { cout << "Derive MyA()" << endl; }
            virtual void B() { cout << "Derive B()" << endl; }  // 重定义虚函数B
        };
        

        image-20240507164755530

      • 多重继承+覆盖虚函数

        对于多重继承的派生类来说,它含有多个虚函数指针,对于上述代码而言,Derive含有两个虚函数指针,所以它不是只有一个虚函数表,然后把所有的虚函数都塞到这一个表中,而是继承两个表,然后再覆盖和添加新的虚函数。

        class Base1 {
        public:
            virtual void A() { cout << "Base1 A()" << endl; }
            virtual void B() { cout << "Base1 B()" << endl; }
            virtual void C() { cout << "Base1 C()" << endl; }
        };
        ​
        class Base2 {
        public:
            virtual void D() { cout << "Base2 D()" << endl; }
            virtual void E() { cout << "Base2 E()" << endl; }
        };
        ​
        class Derive : public Base1, public Base2{
        public:
            virtual void A() { cout << "Derive A()" << endl; }           // 覆盖Base1::A()
            virtual void D() { cout << "Derive D()" << endl; }           // 覆盖Base2::D()
            virtual void MyA() { cout << "Derive MyA()" << endl; }
        };
        

        image-20240507165031934

  4. 虚函数注意事项:

    1. 构造函数不能是虚函数

    2. 析构函数应是虚构函数,除非此类不做基类

    3. 友元函数不能是虚函数,它非类成员

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

    5. 重新定义虚函数时,应

      1. 确保与原来的原型(返回类型,函数名,参数表)完全相同,但如果返回类型是基类的引用或指针,则可以修改为指向派生类的引用或指针。
      2. 如果基类声明被重载(函数多态)了,应在派生类中重新定义所有的基类版本。
  5. 保护成员 protected

    从类外看,保护成员和私有成员一样被隐藏。从派生类看,派生类可以直接访问基类的保护成员。

抽象基类

拥有至少一个纯虚函数的类将称为抽象基类(Abstract Base Class, ABC)。抽象基类不能实例化,只能作为基类。

  1. 纯虚函数:将虚函数声明的结尾处为 = 0,虚函数将成为纯虚函数。它定义了一个接口,要求派生类必须实现该函数,并且保证了派生类都具有相同的接口。

    virtual double Area() const = 0
    
// 一个ABC示例
class BaseEillipse
{
private:
    double x_;
    double y_;
public:
    BaseEillipse(double x = 0, double y = 0) : x_(x), y_(y) {}
    virtual ~BaseEillipse(){}
    virtual double Area() const = 0;  // pure virtual fuc
}
  1. 抽象基类理念:

    可以将ABC看成是一种必须实施的接口,ABC要求派生类必须覆盖其纯虚函数--迫使派生类遵循ABC设置的接口规则。

继承和动态内存分配--成员使用了new的派生类

派生类不使用new

class BaseDMA
{
private:
    char * lable;
    int rating;
public:
    BaseDMA(const char *l = "null", int r = 0);
    BaseDMA(const BaseDMA & bm);
    virtual ~BaseDMA();
    BaseDMA & operator=(const BaseDMA & bm);
    ...;
}

// 派生类不使用动态内存分配
class LacksDMA : public BaseDMA
{
private:
    char color[40];
public:
    ...;
    
}

此时派生类就不需要定义显式析构函数、复制构造函数和赋值运算符。

  • 派生类的默认析构函数总会在执行完自身代码后调用基类析构函数。
  • 派生类的默认复制构造函数会使用显式定义的基类复制构造函数来复制基类成员部分。
  • 赋值运算符同,会自动调用基类的显式定义的赋值运算符。

派生类使用了new

class BaseDMA
{
private:
    char * lable;
    int rating;
public:
    BaseDMA(const char *l = "null", int r = 0);
    BaseDMA(const BaseDMA & bm);
    virtual ~BaseDMA();
    BaseDMA & operator=(const BaseDMA & bm);
    ...;
}

// 派生类使用动态内存分配
class HasDMA : public BaseDMA
{
private:
    char * style;  
public:
    ...;
    
}

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

// 析构函数
// 基类
BaseDMA::~BaseDMA()
{
    delete [] lable;
}
// 派生类
// 将自动调用基类的析构函数
HasDMA::~HasDMA()
{
    delete [] style;
}


// 复制构造函数
// 基类
// 基类构造函数要初始化全部非静态数据成员
BaseDMA::BaseDMA(const BaseDMA & bs)
{
    lable = new char[strlen(bs.lable) + 1];
    strcpy(lable, bs.lable);
    rating = bs.rating;
}
// 派生类
// 只需要初始化新增成员。
// 注意还要使用列表初始化显式调用基类的复制构造函数
HasDMA::HasDMA(const HasDMA & hs) : BaseDMA(hs)
{
    style = new char[strlen(hs.style) + 1];
    strcpy(style, hs.style);
}


// 赋值运算符
// 基类
BaseDMA & BaseDMA::operator=(const BaseDMA &bs)
{
    if(this == bs)
        return *this;
    else
    {
        delete [] lable;
        lable = new char[strlen(bs.lable) + 1];
    	strcpy(lable, hs.lable);
    }
        
}
// 派生类
// 注意显式调用基类的赋值运算符, 使用作用域解析符
// 不能使用成员初始化列表,只能在函数内调用。
HasDMA & HasDMA::operator=(const HasDMA &hs)
{
    if(hs == this)
        return *this;
    else
    {
        base::operator=(hs);  // 为基类部分赋值
        delete [] style;
        style = new char[strlen(hs.style) + 1];
    	strcpy(style, hs.style);
        return *this;  // 一定别忘了返回
    }
}

私有和保护继承

is-a, has-a

  1. is-a:A is a (kind of) B, 表示A是B的一种, 可以使用公有继承表示这种关系。A拥有B的全部属性和方法。
  2. has-a:B has a A, 表示B中有个A(的实例,就是A作为了B的数据成员),是一种包含关系。可以通过包含类的实例(命名的成员)或者私有继承(未命名的成员)来实现这种关系。使用子对象来表示通过继承或包含添加的对象。

私有继承与保护继承

  1. 私有继承:基类的(protected, public)都成为派生类的私有成员。这意味着基类方法将不会成为派生对象的公有接口的一部分,而仅可以在派生类的成员函数中使用它们。主要为了实现has-a关系。
  2. 保护继承,基类的公有成员和保护成员都将成为派生类的保护成员。
特征公有继承保护继承私有继承
公有成员变成派生类中的公有成员保护成员私有成员
保护成员变成派生类中的保护成员保护成员私有成员
私有成员变成派生类中的只能通过基类接口访问只能通过基类接口访问只能通过基类接口访问
是否能隐式向上转换是(但只能在派生类中)
  1. 使用using重新定义访问权限:

    通过保护继承或私有继承后,全部数据成员和接口在类外(指不能通过成员运算符.调用)不可见,如何暴露出来?

    方法一:可以在派生类中定义一个使用该基类方法的派生类方法。

    double Student::sum() const 
    {
        return valarray<double>::sum();  // 使用私有继承的基类方法
    }
    

    方法二:使用using声明,指出可以使用的特定基类方法,将函数调用包装在另一个调用中。

    class Student : private string, private valarray<double>
    {
        ...;
    public:
        // 使用私有继承方法
        using valarray<double>::max;  // 注意只有成员名
        using valarray<double>::min;  
    }
    

多重继承

直接继承多个类。

class SingingWaiter : public Waither, public Singer
{
    ...;
}

主要存在两个问题:

  • 如果多个基类从同一个基类(基类的基类)中派生出来的,那么可能会创建多个基类实例(派生类构造首先构造基类)。
  • 从不同的基类中继承了同名的方法,可能造成了方法的二义性。

解决问题1:使用虚基类。

虚基类:在声明派生类时,指定继承方式为虚,则会将基类成为虚基类。虚基类使得从多个类派生出的对象只继承一个基类对象。

class Singer : public virtual Worker{...};
class Waiter : virtual public Worker{...};
// 关键字顺序不影响作用

Q: 又衍生出问题:如果创建的基类只有一个,那么如何进行构造函数呢?worker的部分应该传递给哪个基类呢?

A: c++规定在基类时虚的时,禁止信息通过中间类自动传递给基类。

// 下面这种构造函数将不起作用
SingingWaiter::SingingWaiter(const Worker &wk, int p = 0, 
                            int v = Signer::other) : Waiter(wk, p), Singer(wk, v){}

// 下面的才正确,直接显式调用基基类的构造函数
SingingWaiter::SingingWaiter(const Worker &wk, int p = 0, 
                            int v = Signer::other) : Worker(wk), Waiter(wk, p), Singer(wk, v){}

解决问题2:当多个基类中存在同名方法时,使用哪个方法?

  • 方法1:虚基类 + 重写方法
  • 方法2:虚基类 + 作用域解析符。使用作用域解析符指明调用的是哪个方法。在类外依旧可以调用基类方法(d.B::foo(); // 明确指定通过类B调用foo()方法)。

虚基类通过优先规则解决名称二义性。

类模板

与函数模板相似,通过传入类型参数生成专门于不同类型的类。

要关注的几点:

  • 模板类及成员函数(模板)全都放在头文件中
  • 类模板不生成类,只是告诉编译器如何生成类及类函数的定义。
  • 类模板的声明语句及其成员函数(模板)都要以template <typename T>打头
  • *类(名)不是ClassName,而是ClassName<T> *,这在实现方法或者类模板外部时需要类模板的名称时很重要。
  • 使用类模板创建类要传入类型。
// classname.h  全都放在这个头文件中

#ifndef CLASSNAME_H_
#define CLASSNAME_H_

// 类的模板声明:以template <typename T>打头,其成为类名的一部分
template <typename T>
class ClassName
{
private:
    const static int MAX = 10;
    T items[MAX];
    ...;
public:
    ClassName();
    bool isempty();
}


// 模板类方法不能放在独立的实现文件中,
// 因为模板不是函数,他们不能单独编译,模板必须于特定的模板实例化请求一起使用

// 模板类方法同样要以类模板声明打头
//并且类限定符要改为 ClassName<T>
template <typename T>
ClassName<T>::ClassName()
{
    
}

template <typename T>
bool ClassName<T>::isempty()
{
    
}


#endif

// 使用类模板的文件 .cpp

#include "classname.h"
#include <string>

int main()
{
    ClassName<string> st;  // 传入类型名
}

ref