继承

1.继承的概念

继承机制是面向对象程序设计使代码可以复用的重要手段,它允许程序员在保持原有类特性的基础上进行扩展,增加功能,这样产生新的类称之为派生类。

// 一个简单的继承结构
class Person
{
public:
	// 还有一个打印方法
	void Print()
	{
		cout << "name:" << _name << endl;
		cout << "age:" << _age << endl;
	}
protected:
	// 有名字和年龄两个属性
	string _name = "张三";
	int _age = 18;
};

class Student : public Person
{
protected:
	// 子类Student新增了学号
	int _stuid;   //学号
};

class Teacher : public Person
{
protected:
	// 子类Teacher新增了工号
	int _jobid;   //工号
};

几种访问限定的继承方式:

基类中的private成员在派生类中是不能被访问的,如果基类成员不想在类外面直接被访问,但需要在派生类中被访问,就需要将其定义为protected,因此,可以看出protected限定符是因继承才出现的。
默认的继承方式:在不指定继承方式的时候,使用关键字class时默认的继承方式是private,使用struct时默认的继承方式是public。

注意:友元关系不能进行继承

2.基类和派生类对象之间的转换

  • 派生类对象可以赋值给基类的对象,但是基类的对象不能赋值给派生类的对象(因为派生类对象的内存空间大小一般会大于基类,可能会导致访问越界)。

3.重定义

在继承中,基类和派生类都有自己独立的作用域,如果子类和父类中有同名的成员,子类成员将会将父类的同名成员给屏蔽掉,这种情况就叫做重定义。

class Person{
public:
	int _num = 100;
	void func(){
		cout << "Person" << endl;
	}
};

class Strdent : public Person{
public:
	int _num = 999;
	void func(){
		cout << "Student" << endl;
		cout << Person::_num << endl;
	}
};

int main(){
	Strdent s;
	s.func();
	// 如果要使用父类的成员,就需要使用作用域限定符来指定类域。
	s.Person::func();
	return 0;
}

注意:对于同名函数的隐藏,只需要函数名相同就构成了隐藏。上述的父类中的func和子类中的func并没有构成函数重载,因为函数重载需要在同一个作用域,而此时这两个func函数并不在一个作用域。

3.派生类和普通类的默认构造函数之间的差异

  • 派生类的构造函数被调用时,会自动调用基类的构造函数初始化基类的那一部分成员,如果基类当中没有默认的构造函数,则必须在派生类构造函数的初始化列表中显示的调用基类的构造函数;
  • 派生类的拷贝构造函数必须调用基类的拷贝构造函数完成对基类成员的拷贝构造;
  • 派生类的赋值运算符重载函数必须调用基类的赋值运算符重载函数完成基类成员的赋值;
  • 派生类的析构函数会在被调用完成后自动调用基类的析构函数清理基类的成员;
  • 构造时先基类的构造函数再派生类的构造函数,析构时先派生类的构造函数再基类的构造函数。

派生类的默认成员函数的注意点:

  • 派生类和基类的赋值运算符重载函数因为函数名相同构成隐藏,因此在派生类当中调用基类的赋值运算符重载函数时,需要使用作用域限定符进行指定调用。
  • 由于多态的原因,任何类的析构函数都会被统一处理成destructor();因此,派生类和基类的析构函数也会因为函数名相同而构成隐藏,如果需要在某一处调用使用基类的析构函数,那么就要使用作用域限定符进行指定调用。
  • 在派生类的拷贝构造函数和operator=当中调用基类的拷贝构造函数和operator的传参方式是一个切片的行为,都是将派生类的对象直接赋值给基类的引用。

4.继承与静态成员

若基类当中定义了一个static静态成员变量,则在整个继承体系里面只有一个该静态成员。无论派生出多少个子类,都只是一个static成员实例。可以在基类中定义一个静态成员变量,然后在基类的构造函数中设置其自增,那么就可以随时通过该静态成员变量来获取当前时刻已经实例化的基类以及其对应的派生类对象的总个数。

5.菱形继承

  • 单继承:一个子类只有一个直接父类时称这个继承关系为单继承
  • 多继承:一个子类有两个或两个以上的直接父类时称这个继承为多继承
  • 菱形继承:两个子类共同继承一个父类,然后再有一个孙子类继承了这两个子类。
class Person
{
public:
	string _name;
};

class Student : public Person
{
protected:
	int _num;
};

class Teacher : public Person
{
protected:
	int _id;
};
class Assistant : public Student, public Teacher
{
protected:
	string _majorCourse;
};

int main()
{
	Assistant a;
	// 其两个父类都继承的基类Person的_name成员,导致了二义性
	// 这里只能通过作用域限定符来指定类域
	a._name = "peter"; //二义性:无法明确知道要访问哪一个_name
	a.Student::_name = "he";
	a.Teacher::_name = "ha";
	return 0;
}

通过作用域限定符虽然可以解决二义性的问题,但是仍不能解决数据冗余的问题。

解决上述菱形继承的方法:菱形虚拟继承

class Person
{
public:
	string _name;
};

// 虚拟继承
class Student : virtual public Person
{
protected:
	int _num;
};

// 虚拟继承
class Teacher : virtual public Person
{
protected:
	int _id;
};

class Assistant : public Student, public Teacher
{
protected:
	string _majorCourse;
};
int main()
{
	Assistant a;
	a._name = "peter"; //无二义性
	return 0;
}

菱形虚拟继承的原理:虚基表指针,指向一个虚基表。

6.继承和组合

继承是一种is-a的关系,而组合是一种has-a的关系;如果两个类之间是is-a的关系,使用继承;如果两个类之间是has-a的关系,则使用组合;如果两个类之间的关系既可以看作is-a的关系,又可以看作has-a的关系,则优先使用组合。

1.概念

通俗来说,多态就是函数调用的多种形态,具体点就是去完成某个行为,当不同的对象去完成时会产生出不同的动作和结果。在继承关系中想要构成多态需要满足两个条件:

  • 必须通过基类的指针或者引用调用虚函数;(如果直接使用对象本身,那么多态将无法实现,因为在编译时,编译器会根据对象的静态类型即声明时的类型来决定
    调用那个方法,而不是根据对象的动态类型)。
  • 被调用的函数必须是虚函数,且派生类必须对基类的虚函数进行了重写。

2.虚函数的重写

虚函数的重写也叫做虚函数的覆盖,若派生类中有一个和基类完全相同的虚函数(返回值类型相同、函数名相同以及参数列表完全相同),此时就称该派生类的虚函数重写了基类的虚函数。在重写基类的虚函数的时候,派生类的虚函数不加virtual也可以构成重写,主要是因为继承后基类的虚函数被继承了下来,在派生类中依旧保持虚函数属性。

虚函数重写的两个例外:

  • 协变(基类和派生类虚函数的返回值类型不同)
    派生类重写基类的虚函数时,与基类虚函数返回值类型不同。即基类虚函数返回基类对象的指针或者引用,派生类虚函数返回派生类对象的指针或者引用,称之为协变。
#include <iostream>
using namespace std;

// 基类
class Animal {
public:
    virtual Animal* clone() const {
        return new Animal(*this);
    }
    virtual void makeSound() const {
        cout << "Some generic animal sound" << endl;
    }
};

// 派生类
class Dog : public Animal {
public:
    // 重写 clone 方法,返回类型是 Dog*
    Dog* clone() const override {
        return new Dog(*this);
    }
    void makeSound() const override {
        cout << "Woof!" << endl;
    }
};

class Cat : public Animal {
public:
    // 重写 clone 方法,返回类型是 Cat*
    Cat* clone() const override {
        return new Cat(*this);
    }
    void makeSound() const override {
        cout << "Meow!" << endl;
    }
};

void demonstrateCovariance(const Animal& animal) {
    Animal* clonedAnimal = animal.clone();
    clonedAnimal->makeSound();
    delete clonedAnimal;
}

int main() {
    Dog dog;
    Cat cat;
	// 这里也构成了多态。
    demonstrateCovariance(dog); // 输出 "Woof!"
    demonstrateCovariance(cat); // 输出 "Meow!"
    return 0;
}
  • 析构函数的重写
    如果基类的析构函数为虚函数,此时派生类中的析构函数只要定义,无论是否加virtual关键字,都与基类的析构函数构成重写,虽然基类与派生类析构函数的名字不一致。但是在程序编译后析构函数的名字统一都会被处理成destructor()。
int main()
{
	//分别new一个父类对象和子类对象,并均用父类指针指向它们
	Person* p1 = new Person;
	Person* p2 = new Student;

	//使用delete调用析构函数并释放对象空间
	delete p1;
	delete p2;
	return 0;
}

考虑上述的场景,若是父类和子类的析构函数没有构成重写就可能会导致内存泄露,因此此时delete p1和delete p2都是调用的父类的析构函数,而这里期望的是p1调用父类的析构函数,p2调用子类的析构函数,即一种多态的行为。这里只有将父类和子类的析构函数都定义成虚函数构成重写,才能使得delete按照我们的预期进行析构函数的调用,才能实现多态。

关键字override和final

  • final:修饰虚函数,表示该虚函数不能被重写
  • override:检查派生类的虚函数是否重写了基类的某个虚函数,如果没有重写则编译报错。

3.抽象类

在虚函数的后面加上=0,则这个函数为纯虚函数,包含纯虚函数的类叫做抽象类(也叫接口类),抽象类不能实例化出对象。派生类继承抽象类后也不能实例化出对象,只有重写纯虚函数,派生类才能实例化出对象。

抽象类存在的意义:

  • 可以更好的去表示现实世界,没有实例对象对应的抽象类型
  • 抽象类很好的体现了虚函数的继承是一种接口继承,强制子类去重写纯虚函数,因为如果子类不去重写纯虚函数那么子类将也是一个抽象类,无法实例化出对象。

4.多态的原理

class Base
{
public:
	virtual void Func1()
	{
		cout << "Func1()" << endl;
	}
private:
	int _b = 1;
};

可以计算出来,Base类实例化出现的对象的大小为8个字节。除了成员变量_b外,还有一个_vfptr放在对象的前面,该指针称之为虚函数表指针,也成为虚表指针,每一个包含虚函数的类中都至少有一个虚表指针。虚表指针指向一个虚函数表,简称虚表,虚表当中存储的就是虚函数的地址,对于Base的对象,其_vfptr指向
的就是虚函数Func1的地址(Base::Func1),如果Device类继承了Base类,同时重写了虚函数Func1,那么Device对象指向的虚表中存储的就是重写后的Func1的地址(Device::Func1)。这也是为什么虚函数的重写也叫做覆盖,覆盖就是指表中虚函数地址的覆盖,重写是语法上的叫法,覆盖是原理层的叫法。

这里注意:虚函数表本质是一个存储虚函数指针的指针数组,一般情况下会在这个数组最后放一个nullptr。

派生类的虚函数表生成步骤:
1.先将基类的虚表内容拷贝一份到派生类的虚表中
2.如果派生类重写了基类中的某个虚函数,则用派生类自己的虚函数地址覆盖虚表中基类的虚函数地址
3.派生类自己新增加的虚函数地址按其在派生类中的声明次序增加到派生类虚表的最后

虚表在什么阶段初始化的?虚函数存放在哪里?虚表存在哪里?
虚表实际上是构造函数初始化列表阶段进行初始化的,虚表中存储的是虚函数的地址,虚函数都是存储在代码段的。同时虚表是存储在代码段上面的。

动态绑定和静态绑定:
静态绑定:又称为前期绑定(早绑定),在程序编译期间确定了程序的行为,也称为静态多态,例如函数重载
动态绑定:又称为后期绑定(晚绑定),在程序运行期间,根据具体拿到的类型确定程序的具体行为,调用具体的函数,也称为动态绑定。

在单继承关系中,派生类的虚表生成的过程:
1.继承基类的虚表内容到派生类的虚表
2.对派生类重写的虚函数地址进行覆盖
3.虚表当中新增派生类当中新的虚函数地址

在多继承关系中,派生类的虚表生成过程:
1.分别继承各个基类的虚表内容到派生类的各个虚表中
2.对派生类重写了的虚函数地址进行覆盖(派生类中的各个虚表中存有该被重写虚函数地址的都需要进行覆盖)
3.在派生类第一个继承基类部分的虚表中新增派生类当中新的虚函数地址

posted @ 2024-09-12 22:23  alone_qing  阅读(16)  评论(0)    收藏  举报