0%

C++类多态

基类和派生类

还记得面对对象篇章中提到过的对“Mk2”进行“改造”的内容吗,接下来这一篇章,将引入C++继承多态的相关操作与衍生内容。

基类和派生类

开门见山,一个类C1从另一个类C2扩展而来,称C1为派生类/子类,C2为基类/父类/超类

对于派生类,它能继承父类的所有数据域和函数,也能增加新的数据域和函数。

继承,也就是is a关系。

class vegetable{
    int shape;
    int color;
};

class cabbage : public vegetable{
bool edible;
};

白菜继承与蔬菜,并多赋予了“可食用”布尔值

对于基类vegetable,可以派生多个类似cabbage这样的类,什么胡萝卜番茄都行。

对于派生类cabbage,可以继承多个类似vegetable这样的类,比如白菜可以是蔬菜也可以是食物。

 

安全

派生类中冒号后面的标识符叫“访问修饰符access-specifier”,默认是private

一般情况下在publicprotectedprivate之中选一个

对于数值类型本身访问类型:

访问publicprotectedprivate
同一个类yesyesyes
派生类yesyesno
外部的类yesnono

对于类继承类型(也就是访问修饰符作用):

  • 一般情况下都是用public来继承,除了( )
数据类型\继承类型publicprotectedprivate
publicpublicprotectedprivate
protectedprotectedprotectedprivate
privateprivateprivateprivate

当然,被保护的成员或者私有成员可以通过基类的其他公有函数和成员来被派生类访问

 

构造函数和析构函数

派生类的构造函数在执行其自身代码之前首先调用它的基类的构造函数。派生类的构造析构函数首先执行其自身的代码,然后自动调用其基类的析构函数

派生类并不继承基类的构造函数,取而代之的是,派生类会调用基类的构造函数来给基类的数据进行初始化。

可以通过初始化列表的方式来主动调用基类的构造函数,额,当然更专业的说法是显式调用。

DerivedClass(parameterList): BaseClass(){}
DerivedClass(parameterList): BaseClass(argumentList){}//带参数的构造函数

当然,你不主动人家就会主动调用,这个叫隐式调用。

如果类叠的很多,那么构造函数和析构函数会在该调用的时候依次调用,特别是构造函数,创建一个派生类的对象的时候,构造函数会从祖宗类开始一连串地被调用,这个叫构造函数链;析构函数则是反着来,从自己到祖宗一连串被调用,这个叫析构函数链

 

函数重定义、函数重写与函数重载

这三个都是C++中截然不同的概念,因为长得像我把他们放一块讲

 

函数重定义

在基类定义的函数能够在派生类中被重新定义

用法是重写并重新定义函数内容。

void print(int num) {
    cout << "Original: " << num << endl;
}

void print(int num, string message) {
cout << "Redefined: " << message << num << endl;
}

print(10); // 调用 print(int)
print(20, "Value is: "); // 调用 print(int, string)

  1. 函数重定义会覆盖原有的函数定义,使得原有函数的行为改变。
  2. 函数重定义只能在同一个作用域内进行,不同的作用域内可以存在同名函数,互相之间不构成重定义关系。
  3. 函数重定义与函数的参数列表和返回类型都必须完全一致。

在子类中可以重定义父类的函数

 

函数重载

在一个作用域内有多个同名但参数列表不同的函数

只是用于适应不同的参数类型或参数个数。

void print(int num) {
    cout << "Integer: " << num << endl;
}

void print(double num) {
cout << "Double: " << num << endl;
}

print(10); // 调用 print(int)
print(3.14); // 调用 print(double)

  1. 同一个作用域内可以有多个同名函数,但它们的参数列表必须不同(参数类型、参数个数或参数顺序不同)。
  2. 函数重载是静态多态性(编译时多态性)的一种表现,编译器根据函数调用时的参数类型或个数来确定调用哪个重载函数。
  3. 函数重载只能在同一个作用域内进行,不同的作用域内可以存在同名函数,互相之间不构成重载关系。
  4. 函数重载与函数的返回类型无关,只与参数列表有关。

 

函数重写/覆盖

指子类重新定义父类中已有的虚函数。

用于实现多态性,对父类虚函数进行重新实现。

class Shape {
public:
    virtual void draw() {
        cout << "Drawing a shape." << endl;
    }
};

class Circle : public Shape {
public:
void draw() override {
cout << "Drawing a circle." << endl;
}
};

class Rectangle : public Shape {
public:
void draw() override {
cout << "Drawing a rectangle." << endl;
}
};

Shape* shape1 = new Circle();
Shape* shape2 = new Rectangle();

shape1->draw(); // 调用 Circle 类的 draw 函数
shape2->draw(); // 调用 Rectangle 类的 draw 函数

  1. 函数重写只能发生在父类和子类之间,在同一个继承关系中进行。
  2. 函数重写要求父类中的函数必须是虚函数(通过关键字 virtual 声明)。
  3. 函数重写的函数签名(函数名、参数列表和返回类型)必须与父类中被重写的虚函数完全一致。
  4. 函数重写可以改变函数的实现细节,子类可以根据自身需要重新定义虚函数的行为。
  5. 函数重写是动态多态性(运行时多态性)的一种表现,具体调用哪个函数是在运行时根据对象的实际类型确定的。

子类和父类同名函数不是重定义就是重写

 

多态

首先,子类型是被派生类定义的类型,超类型是被基类定义的类型。

多态意味着一个超类型的变量可以引用一个子类型的对象。

另外《C++程序设计》这本书讲的太抽象了,所以我引用了CSDN@programing菜鸟的文章内容

文章中提到:

C++的多态必须满足两个条件: 1 必须通过基类的指针或者引用调用虚函数 2 被调用的函数是虚函数,且必须完成对基类虚函数的重写

class Person //成人
{
  public:
  virtual void fun()
   {
       cout << "全价票" << endl; //成人票全价
   }
};
class Student : public Person //学生
{
   public:
   virtual void fun() //子类完成对父类虚函数的重写
   {
       cout << "半价票" << endl;//学生票半价
   }
};
void BuyTicket(Person* p)
{
   p->fun();
}

int main()
{
Student st;
Person p;
BuyTicket(&st);//子类对象切片过去
BuyTicket(&p);//父类对象传地址
}

代码中Student类继承了父类Person,众所周知学生应该享受半价票,所以是否为半价票的输出结果藏在响应类中。但是函数可认不出是不是学生,如果要实现”认出是学生“这个功能,那么不可避免的可能要写两个函数或者写一个判断条件甚至引用不同的类,这样不仅浪费了计算机资源还不符合代码工程基本规范,素质极低。

尝试使用多态,首先在主函数中将各自类的地址传入相同函数中,BuyTicket()函数内使用指针引用了虚函数fun(),结果如下:

函数BuyTicket()中的参数为父类对象,理论上应该打出两个全价票,不过传入的是子类地址,所以就实现了子类方法,这就是C++多态的一个理解思路。

如果只是传类本身,而不是指针,那么就不满足多态了。

Student st;
Person p;
BuyTicket(&st);
BuyTicket(&p);

结果是:

如果满足多态,编译器会调用指针指向对象的虚函数,而与指针的类型无关。如果不满足多态,编译器会直接根据指针的类型去调用虚函数。

 

虚函数

虚函数使得系统能够基于对象的实际类型决定在运行时调用哪一个函数

首先,虚函数是定义在类里头的,类外面的虚函数没有意义。

基类使用virtual进行函数声明,然后子类再进行重写(重定义),这个动作叫函数覆盖,而这样一来,就能在运行时系统基于对象类型来判断调用的哪个函数,这个功能叫动态绑定

不过有意思的是在《C++程序设计》中提到过如果在基类中定义为虚函数,那么在派生类中重写的函数不需要再加上关键字virtual,已经是关键字了(上示例中没有这样做可能是为了更好体现)。

匹配一个函数的签名与绑定一个函数的实现是两个独立的问题。

  • 变量类型决定编译时匹配到的函数,这个叫静态绑定
  • C++通过参数类型、参数个数、参数顺序在编译时寻找匹配的函数,在运行时动态绑定函数的实现,这个叫动态绑定
  • 把含有虚函数的类称为多态类型

 

虚函数表

动态绑定的技术核心就是虚函数表

如果定义了一个虚函数,那么编译器会生成一个虚函数表和一个虚表指针,这些都是编译器自动生成的。虚函数表是一个指针数组(这个数组和字符串数组相似也有“尾部空间”),元素时虚函数的指针,用来存储虚函数的地址。虚表指针是用来指向虚表本身的,在类的对象创建时,这个指针自动指向虚表。对象在调用虚函数的时候,就是用过指针来查阅虚表,然后再通过虚表内存储的虚函数地址来找到正确的要调用的虚函数。

非虚函数不需要经过虚表

所以虚表指针就是多态形成的关键,拿上面儿童票成人票举例

int main()
{
   Student st;
   Person* p = &st;
   p->fun();
}

在对象被实例化的时候,虚表指针指向相应的类的虚表,于是对象st中的虚表指针便指向了Student类的虚函数表。尽管指针类型为Person类,但由于基类本身也自带虚表指针类成员,所以指向子类对象也是可行的,这个时候用这个指针调用函数fun(),发现这个指针指向的对象是子类对象,于是通过子类的虚表指针(隐式操作p->__vptr)访问到子类虚表,于是便调用到子类的相应函数。

虚表中是如何储存的呢,首先这个指针数组存的都是虚函数的地址,类中有几个虚函数就会有几个成员地址。当子类重写虚函数时,虚函数表在子类中的表现:新的重写过后的虚函数地址就会替换掉掉相应原来的地址。当然这只是替换子类这个“复制”过来的虚函数表,并不是真的替换掉了。

对应的虚表指针找到对应的虚表,对应的虚表储存对应的虚函数,这就是多态的一句话逻辑,而上文所解释的一系列操作,其实就是动态绑定的过程,也就是这些操作都是在函数运行时进行的,和传统的静态绑定不同(函数调用在编译阶段就能确定下来)。

动态绑定三条件

  • 通过指针来调用函数
  • 指针upcast向上转型(继承类向基类的转换称为upcast)
  • 调用的是虚函数

虚函数表占用空间:将每个虚函数的指针大小乘以虚函数数量,并添加额外的空间用于虚函数表指针。虚函数表指针的大小等于指针的大小。

通常,32位平台上的指针大小为4字节,64位平台上的指针大小为8字节。

 

 

抽象类和纯虚函数

纯虚函数与抽象类定义

virtual double getArea()=0;

也就是虚函数后面加=0

抽象类就是包含纯虚函数的类。

言简意赅,抽象类不会包含任何具体的实例,实际的作用就是强制子类重写虚函数,实现多态,这里书中给出了一个很好的例子解释,大概意思就是不同形状的图形都由一个抽象类抽象出来,但是实际上无法完成实例化,说具体点就是这个抽象类定义了纯虚函数”算周长“和”算面积“,任何图形都得包含这两个功能。但是这么一个类没办法给出具体形状,所以只能通过子类继承重写虚函数来实现多态以实现实例化。

这里纯是书中写的太长懒得记录

 

C++11新关键字:override&&final

  • 用final修饰的虚函数无法重写。用final修饰的类无法被继承。final像这个单词的意思一样,这就是最终的版本,不用再更新了。
  • 被override修饰的虚函数,编译器会检查这个虚函数是否重写。如果没有重写,编译器会报错。
virtual void fun() final{}
virtual void fun1() override{}

 

强制类型转换

  • static_cast(静态转换)

处理非多态类型,比如基本数据类型。与常见的隐式转换类似,使用这个关键字更多的是用于代码可读性

  • dynamic_cast(动态转换)

用于在继承关系中进行类型转换,特别是在具有多态性的类层次结构中。它可以在运行时检查转换的有效性,如果转换不合法,则返回空指针(对于指针类型)或引发 std::bad_cast 异常(对于引用类型)

// 语法:dynamic_cast<目标类型>(表达式)
BaseClass* basePtr = new DerivedClass();
DerivedClass* derivedPtr = dynamic_cast<DerivedClass*>(basePtr);  // 在继承关系中进行向下转型
if (derivedPtr != nullptr) {
    // 转型成功
} else {
    // 转型失败
}
  • reinterpret_cast(重新解释转换)

用于将一个指针或引用重新解释为不同的类型,通常用于进行低级别的类型转换,如将指针转换为整数或将整数转换为指针。它不进行任何类型检查,因此需要谨慎使用。

  • const_cast(常量转换)

用于从常量类型中移除 const 修饰符,以便进行修改。它主要用于旧代码的兼容性或特殊情况下的修改操作,但需要注意不要违反 const 的语义。

 

 


2023.9.7更新

上面多态的实例代码有问题。。先不看那个

 

构造函数和析构函数的虚函数问题

构造函数不能设置成虚函数,因为虚函数表是在对象创建的时候被分配空间,而构造函数正是在对象创建的时候被调用。在构造函数被创建时,对象实际上还没完全创建,虚函数表尚未形成,所以理论上这样的操作是冲突的(在vs编译器中设置构造函数为虚函数会直接提示错误)。

析构函数,基类的析构函数有必要设置成虚函数的。在父类指针指向对象被释放的时候,只会运行父类指针的析构函数(不是指向的),一般情况下并不会有太大影响,但若析构函数涉及空间释放等操作,那么不执行子类的析构函数就会造成内存泄漏!

 

虚函数的底层实现逻辑

现在发现我之前写的好像更抽象,我自己都看不太懂。

首先如果类中含有虚函数,则会为类创建一个虚函数表和一个隐藏起来的指针(虚指针vptr),每个类包括子类等都有自己的虚函数表。虚指针指向自己的虚函数表,在对象创建时,对象的虚指针就会自动指向自己原本类的虚函数表。而虚函数表中存什么呢,虚函数表本质是指针数组,内容是虚函数的位置。比如说一个类中有一个虚函数,那么表中就会存这个虚函数的地址,如果有两个,那么第二个的位置就会顺着存起来。

如果有子类继承并重写虚函数,那么子类的表除了会复制父类表,同时也会改写对应重写的虚函数地址变为更新后的地址,这样一来,多态系统中的类表不太一样的地方就是这个重写函数不太一样。

此时有一个父类指针指向子类对象,这个指针在调用虚函数的时候,会触发一个叫动态绑定的机制,在调用时,这个父类指针首先会找到子类对象的虚指针vptr,这个指针恰恰指向的就是子类的虚函数表,我们之前提到过这个表在重写的过程中发生了相应改变,这个改变正好就是虚函数的重写,所以我们能在这个表中直接找到对应的应该调用的子类重写过后的虚函数。

当然,这个过程是运行时发生的,之所以要在运行时发生,因为编译的时候对象的实际类型是未知的,之所以未知,可以理解成这个对象可以在运行时改变类型,所以最好不要在编译时让编译器知道类型。为了解决这个问题,动态绑定就被引入了。

不过这个暴论是通过结果说原因,属于是口才的艺术,不过我也懒得去关注这些无关紧要的了

 

”父类指针“相关乱想

首先在编译器中用子类指针会发生报错

”为什么一定要用父类指针“,其实这个也可以从结果出发,比如我们为什么要用多态,因为要操作方便,操作方便的表现就在多种情况下我们只用到一个接口,那么父类指针就是最好的选择。

 

引用也可以代替指针

第一次听说有”父类引用“这个说法

 

虚函数表位置

不能说一定在什么位置,这个和对象相关,如果对象在堆上表就在堆上,对象在栈上就在栈上

 

todo:C语言实现多态

可见11.21碎片问题补充C++相关计算机基础相关 | Coding中。。。 (jiuriri.com)

 

什么不能被继承

  • 构造函数
  • 析构函数
  • 赋值运算符

 


2023/12/5更新

重温多态

首先在理解多态的时候最好先完全得将这些概念区分开来。

  • 虚函数是多态的实现工具
  • 虚函数和类继承息息相关
  • 类继承和多态没有直接联系

对类继承的学习导致了我在这方面极其敏感,以至于看见一个类继承就联想起了多态。

这几句话可能并不严谨,但很好记忆:

类继承和多态是方便程序员的,它实现了代码复用,但他们的侧重点不同

类继承复用了实现的过程,而多态复用了创建的过程

还是那个问题,“明明可以直接使用if树,为什么要使用多态”,这几个月我一直在寻找这个问题的答案,直到学习到了各种设计模式设计原则之后才逐渐明白其中的道理。

  • 开闭原则规定了对扩展开放,对修改封闭,在大型项目中if树往往会变得十分冗长且难以修改,多态就是解决这个问题的完美方案,在对多态修改的时候对不用修改的地方不会造成影响。
  • 多态的特点是把if树中的内容看作一个个对象,把“条件判断”改为“直接呼号”,通过类继承的父类接口,利用虚函数的虚指针寻到目标方法,再直接对其进行修改,而且为一个个对象定义不同的子类,互不干扰。
  • 工厂模式是多重多态组合实现的结果,把同一款面粉制造成包子或者馒头,这个原本使用if树的地方改造成多态,这就是工厂模式中的抽象产品类到具体产品类;制造馒头还是制造包子,这个if树也改造成多态,这就是工厂模式中抽象工厂类到具体工厂类。你问为什么步骤要分开,因为我作为老板我需要包子和馒头的不同设计图,多态给了老板我一个产品接口,而用户在点单的时候需要工厂接口来获取不同的选择。由于用户的需求是无限的,也许某一天用户又想吃饺子了,那么设计饺子设计图、搭建饺子工厂和包子和馒头是没关系的,这样做的目的又符合了开闭原则

这样一看,这些概念就形成了闭环,一套嵌一套。那么现在何不重新回想一开始的问题呢,那就是将多态和类继承区分开来。

#include<iostream>
using namespace std;
class Base {
public:
    Base() {
        cout << "Base constructor" << endl;
    }
    ~Base() {
        cout << "Base destructor" << endl;
    }
};

class Derived : public Base {
public:
Derived() {
cout << "Derived constructor" << endl;
}
~Derived() {
cout << "Derived destructor" << endl;
}
};

int main() {
Derived p;
return 0;
}
//实现结果:
//Base constructor
//Derived constructor
//Derived destructor
//Base destructor
//这是类继承最基础的规定,那就是
//“构造子类对象时先调用基类构造函数再调用子类构造函数,销毁子类对象时先调用子类构造函数再调用基类构造函数”

int main() {
Base* p=new Derived;
delete p;
return 0;
}
//实现结果:
//Base constructor
//Derived constructor
//Base destructor
//类继承的规定仍然起作用,只不过这个时候的指针对象类型其实是“Base”
//所以自动调用的是基类的析构函数

//如果将基类析构函数设为虚函数:
//Base constructor
//Derived constructor
//Derived destructor
//Base destructor
//虚函数将这个析构函数地址存进虚函数表中,这个时候删除指针p,触发多态于是运行子类析构函数,随后继续调用基类的析构函数。
//——这个是因为子类直接包含了基类的部分,也就是说子类析构函数中其实自动包括了基类的析构函数,而不是析构函数被调用了两次!

苦于技术不到位,没办法画图更好理解。。。

可以意识到多态的精髓还是这一句

Base* p=new Derived;

  • Base说明无论如何这个对象的类型就是基类类型的,于是析构函数会使用基类的——也就是说“多态包子”和“多态馒头”其实就是“面粉类型”。
  • 对象是指针,说明最后的实现还是要通过虚表指针去找。
  • new Derived显式调用子类构造函数,这个地方可以加深“对象都是由构造函数创建”的概念,同时也能得知“构造函数不能定义为虚函数”这个概念。
  • 于是缺的就是子类的析构函数了,这个时候如果在子类中分配一些新内存就容易导致错误,于是我们要将基类析构函数定义为虚函数

如果在深入复习,那么就涉及到虚表指针静态绑定动态绑定的过程了,读者可以往上翻翻看,这里就先缓一缓继续探讨面向对象了。

 


2024/9/17更新

虚函数表的内存分布

虚函数表存放位置,data段、数据段、常量区。

由于虚函数表是编译期间决定的,而堆栈区都是运行期间被分配的。而虚函数表本身内容是一个数据,不能把他当作代码,所以也不会放在代码段。这样从结果开始分析就发现放在只读常量段最好。

虚函数表中存什么,存的是虚函数地址,由虚表指针来指向虚函数表。

虚表指针和类的成员变量一样放在类中,也就是说他的存放地址随对象分配,可能是堆区也可能是栈区。这里要注意虚表指针是放在类初始地址上的,由于是指针占四个字节,所以可以理解成占类的初始四地址。

 

类的多继承

一个类继承多个父类,继承了多少个类就有多少个虚表,也就有多少个虚表指针。

类本身额外多写的虚函数和第一个继承的父类的虚函数混在一起记在第一个虚表中,按照继承的顺序来存放,父类函数放在子类前面。

后面继承的类虚函数依照父类不同,分来一个个单独的虚函数表中。

重写的虚函数同样会重写地址,这个不影响地址分布。

 

菱形继承

容易出现二义性和内存浪费的现象,因为孙子类中留有两份基类对象。

虚继承的原理就是同样是派生类对象拥有虚表指针,虚继承类同样拥有虚表,不过这个虚表内保存的是基类地址偏移量(指针),也就是无论多少个派生类他都是指向同一个基类。

含虚继承的派生类,在构造时需要将基类与虚基类都构造一遍。因为虚继承关系中只有最终派生类会负责虚基类的构造(程序运行时会忽略其他派生类对虚基类的构造)。这个是编译器规定的,因为虚继承当中只保留一份虚基类实例,这样就导致了其直接派生类可能产生的二义现象,所以直接让间接派生类负责对其的构造。

引入虚继承会导致额外的性能开销(指针的额外查询)、空间开销(虚继承表)。

其内存空间布局同样可以递推,比如虚表空间布局、虚类指针空间布局等。不过虚基类表中存储的是从派生类对象的起始地址到虚基类子对象中的偏移量,如果有多个则是依照顺序的多个虚继承。而虚基类表指针这个和虚表指针就一致了,他们都是直接指向表的,由程序在动态运行时经过虚基类表指针找到表,在找到对应偏移值。

需要注意的是虚基类表指针总是在虚函数表指针之后

虚继承后,派生类都会生成它自己的虚函数表和虚表指针,并不完全准确,准确来讲,当虚基类有成员变量时,派生类会生成它自己的虚函数表和虚表指针,当派生类没有成员变量时,并不会重新生成派生类自己的虚函数表和虚表指针。

有一种问法是这样写会如何继承。

class A {
public:
    int data;
};

class B : virtual public A { // B 使用虚继承
};

class C : public A { // C 不使用虚继承
};

class D : public B, public C { // D 继承 B 和 C
};

这样会导致D中存有A的实例又会存有A的偏移量。

 


2024/10/3日更新

所有的类默认函数

  • 构造函数

    • 带参数的构造函数
    • 拷贝构造函数
    • 移动构造函数
  • 析构函数

  • 赋值运算符重载

牢记一点就是不同构造函数都是函数重载实现的。

MyClass()

MyClass(int a)

MyClass(const MyClass& class)

MyClass(const MyClass&& class)

拷贝构造函数使用引用的原因:

如果直接传值,那么编译器会为这个值生成一个临时对象,如果说这个值是一个类对象值,那么意味着会调用这个类的拷贝构造函数来进行构造,也就是会无限递归拷贝构造函数导致栈溢出。

拷贝构造函数和移动构造函数使用const的原因:

  1. 防止值被修改,契合构造函数的语义——构造出一个新对象而不是对原对象进行修改
  2. 允许传递常量对象