【C++】面向对象三大特性之多态

本篇文章主要讲解 C++ 中多态特性的实现。


1 什么是多态

在上一篇文章中我们讲解了面向对象语言都会有的继承特性,我们说继承本质上就是一种代码的复用,而多态是面向对象语言的另一种十分重要的特性。多态就是多种形态的意思,本质上就是一个动作,但是不同的对象来做就表现出不同的行为。就比如同样是说话这个动作,人说话就可能是 "你好"、"hello",但是狗说话就是 "汪汪",猫说话就是 "喵喵"。

多态分为编译时多态(静态多态)与运行时多态(动态多态)。编译时多态就是我们之前学过的函数重载和函数模板,其会根据传入参数的不同,在编译阶段就生成对应的函数,然后去做出对应的行为,其因为是在编译阶段就确定的,所以就叫做编译时多态:

cpp 复制代码
#include <iostream>

using namespace std;

int Add(int x, int y)
{
    cout << "Add(int x, int y)" << endl;
    return x + y;
}

double Add(double x, double y)
{
    cout << "Add(double x, double y)" << endl;
    return x + y;
}

int main()
{
    int x = 10, y = 20;
    cout << Add(x, y) << endl;

    double a = 1.2, b = 3.1;
    cout << Add(a, b) << endl;

    return 0;
}

上面写了一小段简单的函数重载的代码,同样是 Add 函数这一个接口(动作),当参数为 int 类型时,编译器在编译阶段就选择去使用整形版本的 Add 函数;如果是 double 类型的参数,那就选择去使用 double 版本的 Add 函数,此时就在编译阶段根据参数的不同表现出了不同的行为,这就是编译时多态。

本文讲解的多态主要是运行时多态,同样是一个函数,不同的对象在调用该函数时,会有不同的行为(其实就是调用了不同的函数,只不过一个在父类,一个在子类),之所以叫做运行时多态,是跟多态的实现有关的,多态的一个必备条件就是必须是父类的指针和引用来接收对象地址或者对象,所以编译时就只认识父类类型,到了运行阶段才具体知道是父类还是子类的对象,此时才能根据不同的对象去调用不同的方法:

cpp 复制代码
#include <iostream>
#include <string>

using namespace std;

class Animal
{
public:
	virtual void talk()
	{}
};

class Person : public Animal
{
public:
	virtual void talk()
	{
		cout << "hello" << endl;
	}
};

class Dog : public Animal
{
public:
	virtual void talk()
	{
		cout << "汪汪" << endl;
	}
};

class Cat : public Animal
{
public:
	virtual void talk()
	{
		cout << "喵喵" << endl;
	}
};

int main()
{
	Animal* p = new Person();
	Animal* c = new Cat();
	Animal* d = new Dog();

	p->talk();
	c->talk();
	d->talk();

	return 0;
}

运行结果:

cpp 复制代码
hello
喵喵
汪汪

在上面这个代码中,同样是 talk 这个函数,不同的对象调用,却产生了不同的行为。当指向的是一个 Person 对象的指针时,就打印出 "hello";如果指向 Cat 对象,就打印出 "喵喵";如果指向 Dog 对象,就打印出 "汪汪"。另外,这里为什么基类指针可以接收三个子类的对象的地址呢?原因就是因为有继承中**赋值兼容转换(切片)**的存在。


2 如何实现多态

多态的必要条件

在实现多态之前,我们先来看一下实现多态的必要条件是什么:

(1)必须是基类的指针或者引用来调用函数,而且被调用的函数必须是虚函数。

(2)在子类中,必须对父类中的虚函数完成了重写(覆盖)。

比如上面那个多态的场景,就是满足了这两个条件:


虚函数

如果在一个非静态成员函数前加上virtual关键字,那么该成员函数就变成了虚函数:

cpp 复制代码
class Animal
{
public:
	virtual void talk()
	{}
};

但是要注意,普通函数和静态成员函数是不能够加 virtual 关键字的,加了编译就会报错。

另外,我们在菱形继承中也使用了 virtual 关键字来实现了虚继承,虚继承中的 virtual 跟这里虚函数 virtual 的关键字没有任何关系,只是共用了同一个关键字一样,就像引用与取地址都是 '&' 这个符号一样。


虚函数的重写(覆盖)

要想实现多态,我们就要在子类中对基类中的虚函数进行重写。要想对基类的虚函数进行重写,要求基类与子类中重写的虚函数完全相同,也就是返回值类型、函数名、参数列表完全相同,注意,参数列表完全相同是指类型相同,并不是名字完全相同,参数名字是可以不同的。

另外,基类中的虚函数关键字 virtual 是必须写的,但是在子类中 virtual 关键字是可以不加的,也就是子类中与基类虚函数同名的函数前可以不加 virtual 关键字依然是重写了虚函数,构成多态:

cpp 复制代码
#include <iostream>

using namespace std;

// 父类
class Transport
{
public:
    virtual void run(int x)
    {
        cout << "交通工具正常行驶" << endl;
    }
};

class Car : public Transport
{
public:
    //在子类中重写虚函数前可以不加 virtual
    //参数列表中的参数名可以不同,但是类型必须相同
    void run(int y)
    {
        cout << "汽车公路飞驰" << endl;
    }
};

class Plane : public Transport
{
public:
    void run(int z)
    {
        cout << "飞机天空飞行" << endl;
    }
};

// 统一调用函数,必须是基类的引用
void drive(Transport& t)
{
    t.run(1);
}

int main()
{
    Transport t;
    Car c;
    Plane p;

    drive(t);
    drive(c);
    drive(p);
    return 0;
}

运行结果:

cpp 复制代码
交通工具正常行驶
汽车公路飞驰
飞机天空飞行

析构函数的重写

在多态这里,有一个特别重要的点,就是基类的析构函数需要设置为虚函数,而且在子类中要对析构函数完成重写。因为我们在继承那一篇文章中讲过,基类与子类的函数名在编译时会被编译器统一处理为 destructor,所以要是不写成虚函数并且重写,那么基类与子类的析构函数其实是隐藏关系,而多态又必须使用基类的指针或者引用来调用虚函数,所以析构时就只会调用基类的析构函数:

cpp 复制代码
#include <iostream>

using namespace std;

// 父类
class Transport
{
public:
    virtual void run()
    {
        cout << "交通工具正常行驶" << endl;
    }

    ~Transport()
    {
        cout << "~Transport()" << endl;
    }
};

class Car : public Transport
{
public:
    //在子类中重写虚函数前可以不加 virtual
    //参数列表中的参数名可以不同,但是类型必须相同
    void run()
    {
        cout << "汽车公路飞驰" << endl;
    }

    ~Car()
    {
        cout << "~Car()" << endl;
    }
};

int main()
{
    Transport* t = new Transport();
    Transport* c = new Car();

    t->run();
    c->run();

    delete t;
    delete c;

    return 0;
}

运行结果:

cpp 复制代码
交通工具正常行驶
汽车公路飞驰
~Transport()
~Transport()

可以看到虽然 c 指向的子类对象的地址,但是说到底其类型还是 Transport*,所以当释放 c 的时候,其调用的还是基类的析构函数,所以这就造成了一个问题:如果子类中有动态资源开辟,此时就会造成资源泄露现象。所以我们需要将基类的析构函数设置为虚函数,并且在子类中完成重写:

cpp 复制代码
#include <iostream>

using namespace std;

// 父类
class Transport
{
public:
    virtual void run()
    {
        cout << "交通工具正常行驶" << endl;
    }

    virtual ~Transport()
    {
        cout << "~Transport()" << endl;
    }
};

class Car : public Transport
{
public:
    //在子类中重写虚函数前可以不加 virtual
    //参数列表中的参数名可以不同,但是类型必须相同
    void run()
    {
        cout << "汽车公路飞驰" << endl;
    }

    ~Car()
    {
        cout << "~Car()" << endl;
    }
};

int main()
{
    Transport* t = new Transport();
    Transport* c = new Car();

    t->run();
    c->run();

    delete t;
    delete c;

    return 0;
}

运行结果:

cpp 复制代码
交通工具正常行驶
汽车公路飞驰
~Transport()
~Car()
~Transport()

可以看到将基类的析构函数设置成虚函数并且子类重写之后,子类的析构函数就被调用了,但是输出结果执行了两次基类的析构函数,这是因为子类在执行完自己的析构函数之后,会自动再去调用父类的析构函数,就是为了保证子类中基类成员的正确释放,就像继承文章中讲的子类的析构会自动调用基类的析构函数一样。


协变

上面我们说如果要构成多态,那么基类与派生类的虚函数要完全相同,返回值、参数列表、函数名要完全相同,但是这里存在一个特殊情况,就是如果基类虚函数返回基类指针,派生类虚函数重写时返回派生类指针也可以构成多态,此称之为协变:

cpp 复制代码
#include <iostream>

using namespace std;

// 父类
class Transport
{
public:
    //返回基类指针
    virtual Transport* run()
    {
        cout << "交通工具正常行驶" << endl;
        return this;
    }

    virtual ~Transport()
    {
        cout << "~Transport()" << endl;
    }
};

class Car : public Transport
{
public:
    //返回派生类指针
    virtual Car* run()
    {
        cout << "汽车公路飞驰" << endl;
        return this;
    }

    ~Car()
    {
        cout << "~Car()" << endl;
    }
};

void Func(Transport* ptr)
{
    ptr->run();
}

int main()
{
    Transport t;
    Car c;

    Func(&t);
    Func(&c);


    return 0;
}

运行结果:

cpp 复制代码
交通工具正常行驶
汽车公路飞驰
~Car()
~Transport()
~Transport()

override 与 final

override

我们在实现多态时,必须对基类的虚函数完成重写,而 C++ 对于虚函数的重写又比较严格,返回值、函数名、参数列表必须完全相同,如果我们没有完成重写,在编译阶段是检查不出来的,所以我们只有在运行之后没有得到必要结果来调试才能发现,这样的代价就会比较大,所以 C++ 11 就提出了一个新的关键字 override,添加了该关键字之后,编译器就会在编译阶段帮助我们检查是否对虚函数完成了重写:

cpp 复制代码
#include <iostream>

using namespace std;

// 父类
class Transport
{
public:
    //返回基类指针
    virtual void run()
    {
        cout << "交通工具正常行驶" << endl;
    }

    virtual ~Transport()
    {
        cout << "~Transport()" << endl;
    }
};

class Car : public Transport
{
public:
    //返回派生类指针
    virtual void run(int x) override
    {
        cout << "汽车公路飞驰" << endl;
    }

    ~Car()
    {
        cout << "~Car()" << endl;
    }
};

void Func(Transport* ptr)
{
    ptr->run();
}

int main()
{
    Transport t;
    Car c;

    Func(&t);
    Func(&c);


    return 0;
}

运行结果:

final

在继承中,我们使用过 final 关键字,当时主要是表示一个类为最终类,无法被别的类所继承。在多态这里,主要是将 final 加在基类的虚函数中,表示该虚函数不可以被派生类所重写:

cpp 复制代码
#include <iostream>

using namespace std;

// 父类
class Transport
{
public:
    //返回基类指针
    virtual void run() final
    {
        cout << "交通工具正常行驶" << endl;
    }

    virtual ~Transport()
    {
        cout << "~Transport()" << endl;
    }
};

class Car : public Transport
{
public:
    //返回派生类指针
    virtual void run() override
    {
        cout << "汽车公路飞驰" << endl;
    }

    ~Car()
    {
        cout << "~Car()" << endl;
    }
};

void Func(Transport* ptr)
{
    ptr->run();
}

int main()
{
    Transport t;
    Car c;

    Func(&t);
    Func(&c);


    return 0;
}

运行结果:


重载 vs 重写 vs 隐藏

重载、重写、隐藏这三个概念很像,很容易搞混,我们在这里区分一下:


3 纯虚接口与抽象类

之前我们在一个成员函数前加上 virtual 关键字,那么该函数就变成了虚函数;如果我们再在虚函数后面加上 = 0,那么该虚函数就变成了纯虚函数,比如 virtual void run() = 0,纯虚函数只写声明即可,不用写出定义,当然语法上时候支持写出纯虚函数的定义的,但是一般是不写的,因为**包含纯虚函数的类称之为抽象类,**抽象类是不能实例化出对象的,所以即使你写了纯虚函数的定义也不会去调用他的:

cpp 复制代码
#include <iostream>

using namespace std;

// 抽象类
class Transport
{
public:
    //纯虚函数
    virtual void run() = 0;
    virtual ~Transport() = default;
};

class Car : public Transport
{
public:
    //对纯虚函数进行重写
    virtual void run() override
    {
        cout << "汽车公路飞驰" << endl;
    }

    ~Car()
    {
        cout << "~Car()" << endl;
    }
};

void Func(Transport* ptr)
{
    ptr->run();
}

int main()
{
    //抽象类不能定义对象
    //Transport t;
    Car c;

    //Func(&t);
    Func(&c);


    return 0;
}

运行结果:

cpp 复制代码
汽车公路飞驰
~Car()

那么抽象类看出来很鸡肋啊,本身又不能实例化对象,难道只能用来给子类继承并且重写虚函数吗?其实抽象类更像是一个模板,由于其里面的成员函数为纯虚函数,如果子类不重写的话,那么子类就会将纯虚函数继承下来,导致子类本身也不能够实例化出对象,所以这就逼着子类必须重写虚函数。所以说,抽象类就是一个定标准、实现多态、统一接口的模板类,既然统一了方法与代码结构,自然就方便了后面的维护与扩展了


4 多态的原理

虚表指针

正常情况下,我们不实现多态,仅仅是继承,就意味着基类和子类中并没有虚函数,此时基类和子类的大小就是非静态成员变量按照内存对齐规则计算所占空间大小:

cpp 复制代码
#include <iostream>
#include <string>

using namespace std;

class Base
{
public:
    /*virtual void run()
    {
        cout << "Base->run()" << endl;
    }

    virtual void Speak()
    {
        cout << "hello" << endl;
    }*/

private:
    char _ch = 'x';
    int _x = 1;
};

class Deriv : public Base
{
public:
    //对纯虚函数进行重写
    /*virtual void run() override
    {
        cout << "Deriv->run()" << endl;
    }

    virtual void Speak()
    {
        cout << "world" << endl;
    }*/

private:
    std::string _name;
};

int main()
{
    //基类的大小是多少呢?
    Base b;
    Deriv d;

    cout << sizeof(b) << endl;
    cout << sizeof(d) << endl;

    return 0;
}

输出结果:

cpp 复制代码
8
36

但是一旦我们实现多态,在基类中有了虚函数,在派生类中完成了重写,其大小就会变成 12 字节(32位系统)或者 16 字节(64位系统):

cpp 复制代码
#include <iostream>
#include <string>

using namespace std;

class Base
{
public:
    virtual void run()
    {
        cout << "Base->run()" << endl;
    }

    virtual void Speak()
    {
        cout << "hello" << endl;
    }

private:
    char _ch = 'x';
    int _x = 1;
};

class Deriv : public Base
{
public:
    //对纯虚函数进行重写
    virtual void run() override
    {
        cout << "Deriv->run()" << endl;
    }

    virtual void Speak()
    {
        cout << "world" << endl;
    }

private:
    std::string _name;
};

int main()
{
    //基类的大小是多少呢?
    Base b;
    Deriv d;

    cout << sizeof(b) << endl;
    cout << sizeof(d) << endl;

    return 0;
}

运行结果:

cpp 复制代码
12
40

实际上就是 C++ 为了实现多态,如果类内写了虚函数,那么就会多开辟一个指针大小的空间来存放虚表指针:

可以看到在基类和子类对象中都有一个 _vfptr 的指针,这个就是基类和子类中的虚表指针。这个虚表指针指向了一个指针数组,其中的指针正是基类和派生类中的虚函数地址。所以虚表指针全称为虚函数表指针,再说全一点,就是虚函数指针数组指针,这个指针指向了一个数组,这个数组里面存放的是虚函数指针。

所以对于上面的 Base 和 Deriv 类,多态的实现过程如图所示:

对于不同的对象,就去找到其中的虚函数表指针 _vfptr,然后再去虚表中找到对应的虚函数地址,这样就可以达到调用不用函数的效果了。

静态绑定与动态绑定

这里说的绑定就是寻找函数地址然后调用。所谓的静态绑定就是指在编译时就确定了函数的地址,然后调用。一般都是在不满足多态条件时,调用函数的地址会在编译阶段绑定,然后运行时直接调用。

动态绑定就是指在满足多态条件时,运行阶段根据对象的不同去不同的虚函数表里面去寻找函数地址,然后再调用对应的函数。

cpp 复制代码
#include <iostream>
#include <string>

using namespace std;

class Base
{
public:
    virtual void run()
    {
        cout << "Base->run()" << endl;
    }

    void Speak()
    {
        cout << "hello" << endl;
    }

private:
    char _ch = 'x';
    int _x = 1;
};

class Deriv : public Base
{
public:
    //run 函数完成了重写
    virtual void run() override
    {
        cout << "Deriv->run()" << endl;
    }

private:
    std::string _name;
};

void Func(Base* ptr)
{
    ptr->Speak();
    ptr->run();
}

int main()
{
    Base b;
    Deriv d;

    Func(&b);

    return 0;
}

我们可以转到反汇编看一下静态绑定和动态绑定的不同:


虚函数表

前面我们讲解了多态主要是基于类中的虚表指针实现的,但是虚函数表本身还有一些值得注意的地方***。***

同类型对象共用一张虚表,不同类型对象有不同的虚表

基类的虚函数表中存放了基类中所有的虚函数指针,而且不同类型各自拥有各自的虚表,所以基类和子类的虚表地址是不同的:

派生类继承基类的虚表

派生类包含两部分,一部分是基类成员变量,另一部分是派生类自己的成员变量。所以派生类的虚表会从基类继承下来,但是并不是与基类的虚表完全相同,而是像其他的基类成员变量一样,会再独立开辟一个一样的虚表,基类和派生类各自拥有各自的虚表。

当在派生类中完成了基类中虚函数的重写,那么派生类就会在继承下来的虚表中将重写的虚函数地址替换为重写后的函数地址。所以重写更像是语法上多态的说法,覆盖是多态真正原理上的说法。这样,派生类的虚函数表中就会存在三部分:(1)基类中未被重写的虚函数地址(2)基类中被重写的虚函数地址(3)派生类自己的虚函数地址。

vs 中会在虚表最后放一个 0x00000000 标记

在 vs 中,为了将虚表中的函数地址与其他地址区分开来,一般会在虚表最后放一个 nullptr,也就是 0x00000000 来代表虚表的结束:

注意,C++ 中并没有规定必须在虚表后面添加一个空指针,不同的编译器有不同的区分虚表的方法,这个只是 vs 的区分方法。

虚函数在内存中的代码段,虚函数表在 vs 下存在代码段

虚函数与普通函数一样,都存在代码段,但是虚函数表存在哪里是不确定的,跟编译器有关,vs 下存在代码段中。我们可以通过下面这个代码验证一下:

cpp 复制代码
#include <iostream>
#include <string>

using namespace std;

class Base
{
public:
    virtual void run()
    {
        cout << "Base->run()" << endl;
    }

    virtual void Speak()
    {
        cout << "hello" << endl;
    }

private:
    char _ch = 'x';
    int _x = 1;
};

class Deriv : public Base
{
public:
    //run 函数完成了重写
    virtual void run() override
    {
        cout << "Deriv->run()" << endl;
    }

    virtual void Speak() override
    {
        cout << "world" << endl;
    }

private:
    std::string _name;
};

void Func(Base* ptr)
{
    ptr->Speak();
    ptr->run();
}

int main()
{
    int a = 1;
    int* pi = new int(10);
    const char* s = "abcdef";
    static int si = 10;

    printf("栈区: %p\n", &a);
    printf("堆区: %p\n", pi);
    printf("常量区: %p\n", s);
    printf("静态区: %p\n", &si);

    Base b;
    Deriv d;
    Base* pb = &b;
    Deriv* pd = &d;

    printf("Base-虚表地址: %p\n", *(int*)pb);
    printf("Derive-虚表地址: %p\n", *(int*)pd);
    printf("虚函数地址: %p\n", &Base::run);
    printf("普通函数地址: %p\n", Func);


    return 0;
}

输出结果:

cpp 复制代码
栈区: 010FFE58
堆区: 013CCEC0
常量区: 00B20CB8
静态区: 00B25020
Base-虚表地址: 00B20CB0
Derive-虚表地址: 00B20E1C
虚函数地址: 00B01CAD
普通函数地址: 00B01C8A

可以看到虚表地址跟常量区的地址是非常接近的。


总结

本篇文章我们讲解了多态特性以及 C++ 中的多态如何实现。多态的实现主要有两个条件:(1)基类的指针或者引用来调用虚函数(2)派生类对基类的虚函数完成了重写。

在多态场景中,基类中的析构函数一定要设为虚函数,然后在派生类中完成重写。因为编译器会将基类和派生类的析构函数统一命名为 destructor,所以构成多态,如果不重写,那就只是隐藏,会造成派生类的内存泄露。

C++ 中的多态主要是通过虚表来实现的。虚表全称为虚函数指针数组,只要在基类中写了虚函数,那么基类中就会多出一个成员变量 _vfptr,该变量指向了一张虚函数指针数组,所以该指针为虚函数指针数组指针。当不同成员调用虚函数时,就会去对应的类中去根据虚表指针检索虚函数表,然后找到对应的虚函数进行调用。

虽然这一篇讲的是 C++ 的多态,但是多态的核心思想对于各个面向对象语言是相同的。希望大家可以通过这一篇文章,体会到面向对象语言的多态特性。

相关推荐
threelab2 小时前
Three.js 银河星系效果 | 三维可视化 / AI 提示词
开发语言·javascript·人工智能
程序员敲代码吗2 小时前
探索JavaScript对象创建的灵活方式
开发语言·javascript·ecmascript
FlyWIHTSKY2 小时前
Next.js中客户端组件和服务端组件
开发语言·javascript·ecmascript
天若有情6732 小时前
轻量级状态事件总线 eventbusx-js 开源使用教程
开发语言·javascript·npm·开源·事件·事件总线
XMYX-02 小时前
36 - Go exec 执行命令
开发语言·golang
寻道码路2 小时前
LangChain4j Java AI 应用开发实战(二):大模型参数调优实战:Temperature、TopP、MaxTokens 深度解析
java·开发语言·人工智能·aigc
小张成长计划..2 小时前
【C++】33:反向迭代器的实现(扩展)
c++
吃好睡好便好2 小时前
在Matlab中绘制饼状图
开发语言·学习·matlab·3d·信息可视化
weixin_6682 小时前
DGX-spark上成功部署Voxtral-Mini-4B-Realtime-2602支持realtime ws接口
开发语言·python