C++面向对象之多态

发布于:2025-09-12 ⋅ 阅读:(21) ⋅ 点赞:(0)

        都说面向对象有三大特性,分别是封装、继承和多态。今天我们就来简单的了解一下多态到底是什么。

        多态分为两种多态,分别是编译时多态(静态多态)和运行时多态(动态多态)。编译时多态在我们之前的学习中其实也已经学到了,主要是函数重载和函数模板,它们通过传递不同的参数就可以调用不同的函数,通过参数的不同达到多态的效果。之所以叫编译时多态是因为它们实参传给形参的参数匹配是在编译时完成的,我们一般把编译时归类为静态,运行时为动态。今天我们要讲的就是运行时多态。

        运行时多态就是,当我们调用同一个函数的时候,传不同的对象就会完成不同的行为。通俗的来说,如果我们有许多小动物的对象,同时把它们传给一个“叫”的函数,我们传小狗过去就会返回“汪汪”,我们传牛过去就会返回“哞哞哞”。又或者是,在我们平时买火车票的时候,如果我们是大学生,就可以youhui买票,但是

1. 多态的定义及实现

        我们要实现运行时多态,首先要保证是在继承条件下。在这个条件下,我们用不同的子类去调用同一个函数,就会返回不同的效果。

        实现多态有两个必要条件,两个条件必须同时存在,缺一不可:

  1. 必须是父类的指针或引用
  2. 被调用的函数必须是虚函数,并且完成了虚函数的重写或覆盖

        解释一下上面两点,为什么一定要是父类的指针或调用?因为只有父类的指针或调用才可以既能指向父类又可以指向子类,也就是我们继承里面的切割。那么为什么要实现重写或覆盖呢?因为只有我们重写或覆盖了了这个虚函数,父类和子类之间才会有相同的函数,多态的多种形态效果才会达到。

1.1 虚函数

        我们这里先介绍一下虚函数。在一个类成员的成员函数前面加上一个virtual关键字,这个函数就成为了虚函数。需要注意的是,不是成员函数的函数不可以用virtual修饰。

class Person
{
public:
    virtual void buyTicket(){cout << "全价" << endl;}
}

        这里 virtual 就是起到修饰一个成员函数的作用,让它成为虚函数,可以被重写,不要和之前的菱形继承中的虚继承混在一起,虽然它们的关键字是一样的,但是行为是不同的。

1.2 虚函数的重写 / 覆盖

        当一个子类继承了一个父类以后,子类中有一个完全和父类一样的函数,称为子类的虚函数重写了父类的虚函数。这里的完全一样要遵循“三同”:返回值类型相同、函数名相同、参数列表相同。只有同时达成这三种要求的虚函数才叫重写了父类的虚函数,只要有一个没有达到就叫隐藏(就是之前继承中说到的隐藏)。

        要注意的是即使子类中的虚函数没有加 virtual 关键字,但是依然满足“三同”,那么子类的虚函数依旧构成对父类虚函数的重写。但是这样并不是很规范,代码阅读性会降低,建议还是要写上。

class Person
{
public:
    virtual void buyTicket(){cout << "全价" << endl;}
}

class Student : class Person
{
public:
    //void buyTicket(){cout << "半价" << endl;} 这样也是构成重写的
    virtual void buyTicket(){cout << "半价" << endl;}
}

        我们要调用多态的话还是要用父类的指针或引用,所以一般我们会再创建一个新函数用来调用多态。

class Person
{
public:
    virtual void buyTicket(){cout << "全价" << endl;}
}

class Student : class Person
{
public:
    virtual void buyTicket(){cout << "半价" << endl;}
}

void func(Person* ptr)
{
    ptr->buyTicket();
}

int main()
{
    Person p;
    Student s;

    func(&p); //这里就会打印“全价”
    func(&s); //这里就会打印“半价”
    return 0;
}

1.3 协变

        子类重写父类的虚函数的时候,与父类虚函数的返回值类型不同。也就是父类的虚函数返回父类对象的指针或引用,子类对象返回子类对象的指针或引用时,称为协变。这里的父类对象的指针或引用和子类对象的指针或引用不一定是自己的类型,也可以是别的父类子类的类型。不过,协变的意义并不是很大,所以只需要简单了解一下就好。这里提一下只是希望见的时候可以看的出来这里是多态,而不是隐藏。

class A{}
class B : public A{}

class Person
{
public:
    virtual A* buyTicket(){
        cout << "全价" << endl;
        return nullptr;
    }
/*  这样也是可以的
    virtual Person* buyTicket(){
        cout << "全价" << endl;
        return nullptr;
    }  
*/
}

class Student : class Person
{
public:
    virtual B* buyTicket(){
        cout << "半价" << endl;
        return nullptr;
    }
/*  这样也是可以的
    virtual Student* buyTicket(){
        cout << "半价" << endl;
        return nullptr;
    }  
*/
}

void func(Person* ptr)
{
    ptr->buyTicket();
}

int main()
{
    Person p;
    Student s;

    func(&p); //这里就会打印“全价”
    func(&s); //这里就会打印“半价”
    return 0;
}

2. 析构函数的多态

        父类的析构函数只要加上 virtual ,此时子类的析构函数只要定义出来,无论是否有 virtual 关键字,都与父类的析构函数构成多态。这个时候我们感觉好像并不符合多态中“三同”的要求,可为什么还是会构成多态呢?原因是编译器会对析构函数进行特殊处理,编译后的析构函数统一命名为destructor,所以只要父类的析构有 virtual 关键字就会构成重写。

        那么为什么要这么设计呢?举个例子,如果我们的子类中存在需要申请空间的成员变量,当我们把子类类型赋值给父类类型的时候,父类类型会进行切割,但是在程序结束后,父类类型会调用它的析构函数,这时我们子类中申请的空间不走子类的析构函数,就无法释放,造成内存泄漏。所以我们把子类传给父类类型,最后进行析构的时候,肯定是希望调用子类的析构函数来对这个类型进行析构,这样我们才可以把空间清除干净。所以我们对析构函数统一命名,就达到了多态的效果,我们虽然是父类指针或引用调用析构函数,但是因为多态的存在,这里调用的析构会是子类的析构函数,从而保证程序可以正常执行。这就是为什么要统一析构函数的名字。

        下面的程序中,如果析构函数没有统一命名,不构成重写,那么在delete p2 的时候就不会释放B中申请的 _p 变量。从而导致内存泄漏。正是因为析构函数构成了重写,我们在delete p2 的时候才会调用B的析构函数。

class A
{
public:
    virtual ~A()
    {}
};

class B : public A {
public:
    ~B()
    {
        delete _p;
    }
    protected:
    int* _p = new int[10];
};

int main()
{
    A* p1 = new A;
    A* p2 = new B;
    delete p1;
    delete p2;

    return 0;
}

3. override 和 final

        接下来我们将两个关键字,分别是 override 和 final。

3.1 override

        在C++中,我们要实现对一个函数的重写是十分严格的,需要保证“三同”,稍有不慎写错一两个字母就不会构成重写了。override 可以帮助用户检测是否重写,我们在函数的参数列表后面加上这个关键字,表明我们需要重写这个函数,如果我们没有完成重写,编译器会显示报错。

class Person
{
public:
    virtual void buyTicket(){cout << "全价" << endl;}
}

class Student : class Person
{
public:
    //这里如果我们没有“三同” 编译器就会显示警告
    virtual void buyTicket() override {cout << "半价" << endl;}
}

3.2 final

        如果我们不想让子类重写这个虚函数,我们就可以加上final 关键字。

class Person
{
public:
    virtual void buyTicket() final {cout << "全价" << endl;}
}

class Student : class Person
{
public:
    // 我们尝试对buyTicket进行重写的时候就会报错
    virtual void buyTicket(){cout << "半价" << endl;}
}

4. 重载、重写、隐藏的比较

        说了这么多,感觉重载、重写、隐藏之间好像大差不差的,下面我们来梳理一下这三个概念之间的差异。

        重载需要满足两个条件:

  1. 一个是我们要保证两个函数在同一个作用域里
  2. 另一个是要保证函数名相同,参数不同、参数的类型或个数不同,返回值可以相同,也可以不同。

         重写要满足以下三个条件:

  1. 两个函数分别在父类作用域和子类作用域
  2. 函数名、参数、返回值都必须相同,也就是“三同”。协变除外
  3. 两个函数都必须是虚函数,这里父类是虚函数子类也就一定是虚函数。

        隐藏要满足四个条件:

  1. 两个函数分别在父类作用域和子类作用域
  2. 函数名相同
  3. 两个函数之间只要不构成重写就一定是隐藏
  4. 父子类之间的成员变量也构成隐藏

5. 纯虚函数和抽象类

         在一个虚函数的最后面写上“ =0 ” ,这个函数就被称之为纯虚函数。纯虚函数不需要定义实现,只需要声明就可以了。因为纯虚函数就算定义实现了也没什么用,因为它一定会被子类重写的。

        包含纯虚函数的类叫做抽象类,抽象类不能实例化出对象,如果子类继承后不重写纯虚函数,那么子类也是抽象类。只有重写了纯虚函数以后才可以实例化出对象。纯虚函数在某种程度上强制子类必须重写虚函数,因为不重写没法初始化对象。

class Person
{
public:
    //这里直接=0即可
    virtual void buyTicket()=0;
}

class Student : class Person
{
public:
    //这里重写了才可以实例化出对象,不重写就不能实例化出对象
    virtual void buyTicket(){cout << "半价" << endl;}
}

6. 虚函数表指针

        在多态中,还有一个虚函数表的概念(简称虚表),这个虚函数表占四个字节,是一个指针数组,它会指向这个类中所有的虚函数。所以当我们查看一个有虚函数的类的大小的时候,会发现它的大小会是正常的成员变量的大小再加上四字节(在32位下)

        这段代码最后的运行结果会是12,也就是b的大小为12。

6.1 多态的实现

        这里我们就可以解释多态是如何实现的的了。每一个实现多态的类中都有一个虚函数表,当子类也满足多态的条件后 ,子类就会继承父类的虚表。这时我们对函数进行重写,本质上是改变了虚表中虚函数的地址。这里我们回顾一下切片的概念,父类的指针会指向子类对象的最开始的地方。那么这时我们调用虚函数,指针就会去它指向的虚表中进行查询,也就构成了多态。

        下面我们用一段代码和示意图来举个例子:

class Person {
public:
    virtual void BuyTicket() { cout << "买票-全价" << endl; }
private:
    string _name;
};

class Student : public Person {
public:
    virtual void BuyTicket() { cout << "买票-打折" << endl; }
private:
    string _id;
};
void Func(Person* ptr)
{
    ptr->BuyTicket();
}

int main()
{
    Person p;
    Student s;

    Func(&p);
    Func(&s);

    return 0;
}

6.2 虚函数表

        接下来我们来讲几个虚函数表的小知识。

  •         首先,父类对象的虚表中存放着父类所有虚函数的地址。同类型的对象是共用一张虚表的,不同类型的对象有各自的虚表,所以父类和子类各自有各自的虚表。
  •         子类由继承下来的基类和自己的成员两部分构成,一般情况下,继承下来的父类中如果有虚函数表指针,子类就不会再生成虚函数表指针了。但是这里继承下来的父类部分的虚函数表和父类的虚函数表指针不是同一个,上面说到的,各自有各自的虚函数表指针。
  •         子类中重写的父类的虚函数,子类的虚函数表中对应的虚函数就会覆盖为子类已经重写的虚函数地址。也就是重写了就会进行覆盖。
  •         子类的虚函数表中包括三个部分:父类的虚函数地址、子类重写后的虚函数地址、子类自己的虚函数。

网站公告

今日签到

点亮在社区的每一天
去签到