前言:这是一个较为重要的概念,需要学会继承才能深入理解,欢迎大家查看这篇博客“C++继承全揭秘:原来编程也能“拼爹

目录

一、多态的概念

二、虚函数的用法及多态的实现

1. 虚函数

2. 虚函数的重写/覆盖

3. 多态的构成条件

4. 多态的简单应用

5. 析构函数的重写

6. override与final关键字

7. 协变

三、纯虚函数与抽象类

1. 定义

2. 纯虚函数语法

3. 抽象类的语法规则

4. 它们的作用及应用

四、多态的原理

1. 核心机制:虚函数表(vtable)

2. 多态调用的底层过程

3. 虚函数表知识点

五、经典题目



一、多态的概念

多态(Polymorphism),字面意思就是"多种形态"。在C++中,它分为两种:

  • 编译时多态(静态多态):比如函数重载和模板,编译器在编译期间就能确定调用哪个函数(因为他们实参传给形参的参数匹配是在编译时完成的)

  • 运行时多态(动态多态):通过虚函数机制,在程序运行时根据实际对象类型决定调用哪个函数

我们重点要掌握的是运行时多态,它让我们的代码具备了真正的"智能"。

二、虚函数的用法及多态的实现

1. 虚函数

        上节课讲继承的时候有一个概念叫虚继承,是,它和我们虚函数用的时一个关键字 “virtual” ,但是他们俩可没有任何的关系。

        类成员函数前⾯加virtual修饰,那么这个成员函数被称为虚函数。注意⾮成员函数不能加virtual修饰。

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

它现在还和普通函数没啥区别,正常调用就行。

2. 虚函数的重写/覆盖

        虚函数的重写/覆盖:派⽣类中有⼀个跟基类完全相同的虚函数(即派⽣类虚函数与基类虚函数的返回值类型、函数名字、参数列表完全相同),称派⽣类的虚函数重写了基类的虚函数。

        在重写基类虚函数时,派生类的虚函数在不加virtual关键字时,虽然也可以构成重写(因为继承后基类的虚函数被继承下来了在派生类依旧保持虚函数属性),但是该种写法不是很规范,不建议这样使用,不过在考试选择题中,经常会故意埋这个坑。

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

class Student : public Person 
{
public:
    //void BuyTicket()
	virtual void BuyTicket() { cout << "买票-打折" << endl; }
};

//BuyTicket函数构成重写

重载/重写/隐藏的对⽐

3. 多态的构成条件

        多态是一个继承关系的下的类对象,去调用同⼀函数,产生了不同的行为。比如Student继承了 Person。Person对象买票全价,Student对象优惠买票。

实现多态还有两个必须重要条件:

  • 必须是基类的指针或者引用调用虚函数
  • 被调用的函数必须是虚函数,并且完成了虚函数重写/覆盖

4. 多态的简单应用

分别实现动物的叫声:

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

class Dog : public Animal
{
public:
	virtual void talk() const
	{
		std::cout << "汪汪" << std::endl;
	}
};
class Cat : public Animal
{
public:
	virtual void talk() const
	{
		std::cout << "(>^ω^<)喵" << std::endl;
	}
};

void letsHear1(const Animal& animal)
{
	animal.talk();
}

void letsHear2(const Animal* animal)
{
	animal->talk();
}

int main()
{
	Cat cat;
	Dog dog;
	letsHear1(cat);
	letsHear1(dog);

	letsHear2(&cat);
	letsHear2(&dog);

	return 0;
}

5. 析构函数的重写

我们先来看一个例子:

class Base {
public:
    ~Base() { cout << "~Base()" << endl; }  // 非虚析构函数
};

class Derived : public Base {
public:
    Derived() { data = new int[100]; }       // 分配资源
    ~Derived() {                             // 析构函数
        cout << "~Derived()" << endl;
        delete[] data;                       // 释放资源
    }
private:
    int* data;
};

int main() {
    Base* ptr = new Derived();  // 基类指针指向派生类对象
    delete ptr;                 // ❌ 只调用~Base(),不调用~Derived()
    // 内存泄漏!data没有被释放
    return 0;
}

        这就说明析构函数必须构成重写,这样才能保证当我们用传父类指针或引用构成多态时,调用的是派生类的析构函数。学习继承时我们说调用派生类的析构后,会自动调用基类的析构,也是怕资源泄漏。

class Base {
public:
    virtual ~Base() { cout << "~Base()" << endl; }  // 虚析构函数
};

class Derived : public Base {
public:
    Derived() { data = new int[100]; }
    ~Derived() override {                          // 重写基类析构函数
        cout << "~Derived()" << endl;
        delete[] data;
    }
private:
    int* data;
};

int main() {
    Base* ptr = new Derived();
    delete ptr;  // ✅ 正确调用~Derived()和~Base()
    return 0;
}

为什么函数名不同仍能构成析构

        编译器在内部对析构函数进行统一重命名  void destructor() { },所以它们本质还是相同的。

6. override与final关键字

  • C++11提供了关键字override,可以帮助⽤户检测是否完成重写,override关键字加在派生类的虚函数中(像派生类的析构函数就可以在后面加一个检测)

  • ⽤final去修饰的虚函数无法被重写

7. 协变

        派生类重写基类虚函数时,与基类虚函数返回值类型不同。即基类虚函数返回基类对象的指针或者引用,派生类虚函数返回派生类对象的指针或者引用时(可以不是本身),称为协变。

class A {};
class B : public A {};
class Person {
public:
	virtual A* BuyTicket()  // 返回基类指针
	{
		cout << "买票-全价" << endl;
		return nullptr;
	}
	virtual Person* test1()
	{
		cout << "1" << endl;
		return nullptr;

	}
};

class Student : public Person {
public:
	virtual B* BuyTicket()  // 返回派生类指针
	{
		cout << "买票-打折" << endl;
		return nullptr;
	}

	virtual Student* test1()
	{
		cout << "2" << endl;
		return nullptr;
	}
};

三、纯虚函数与抽象类

1. 定义

        纯虚函数是一种没有具体实现的虚函数,它只是一个接口声明,相当于告诉派生类:“你必须实现这个功能,但我不管你怎么实现”。包含至少一个纯虚函数的类就是抽象类。抽象类更像是一份设计蓝图技术规范,它定义了应该做什么,但不具体实现。

2. 纯虚函数语法

  • 在函数声明的末尾加上“=0”

  • 只有函数声明,没有函数体(或函数体为空)

  • 存在于基类中

class Car{
public:
   virtual void Drive() = 0;
};

3. 抽象类的语法规则

  • 如果继承抽象类的派生类没有实现所有纯虚函数,那么它仍然是抽象类

  • 抽象类可以有构造函数和析构函数(析构函数通常应该是虚函数)

  • 抽象类可以有数据成员和普通成员函数,可以包含非纯虚函数(这些函数可以有默认实现)

  • 抽象类不能实例化!!

4. 它们的作用及应用

        抽象类既然不能实例化,就证明他也不可能去存储调用,上面我们实现不同动物的叫声时,基类那个函数的作用是啥?没用,反正哪个函数会被重写,而且你实例化对象不可能写出 Animal ,那有啥用。所以不如写成抽象类。

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

class Dog : public Animal
{
public:
	virtual void talk() const
	{
		std::cout << "汪汪" << std::endl;
	}
};
class Cat : public Animal
{
public:
	virtual void talk() const
	{
		std::cout << "(>^ω^<)喵" << std::endl;
	}
};

void letsHear1(const Animal& animal)
{
	animal.talk();
}

void letsHear2(const Animal* animal)
{
	animal->talk();
}

int main()
{
	Cat cat;
	Dog dog;
	letsHear1(cat);
	letsHear1(dog);

	letsHear2(&cat);
	letsHear2(&dog);

	return 0;
}

四、多态的原理

1. 核心机制:虚函数表(vtable)

从上图发现,这个虚函数的指针存在了一个指针数组中,这些指针简称为虚表指针。这也是虚函数与普通函数的主要区别!

特性 普通函数 虚函数
绑定时间 编译时 运行时
调用方式 直接调用 间接调用(通过虚表)
内存开销 无额外开销 每个对象多一个指针
性能 更快 稍慢(多两次内存访问)
多态支持 不支持 支持
适用场景 确定性的函数调用 需要运行时多态

2. 多态调用的底层过程

class Animal
{
public:
	virtual void talk() const = 0;
	virtual void body() const = 0;
};

class Dog : public Animal
{
public:
	virtual void talk() const
	{
		std::cout << "汪汪" << std::endl;
	}
	virtual void body() const
	{
		std::cout << "更大" << std::endl;
	}

};
class Cat : public Animal
{
public:
	virtual void talk() const
	{
		std::cout << "(>^ω^<)喵" << std::endl;
	}
	virtual void body() const
	{
		std::cout << "更小" << std::endl;
	}

};

void letsHear(const Animal& animal)
{
	animal.talk();
	animal.body();
}

int main()
{
	Cat cat;
	Dog dog;
	letsHear(cat);
	letsHear(dog);
	return 0;
}

底层执行步骤:

  1. 通过&cat找到自身对象中的vptr(每个对象里的 void** vptr 是不一样的)

  2. 通过vptr找到虚函数表

  3. 在虚函数表中找到talk,body对应的槽位

  4. 调用该槽位中的函数地址

3. 虚函数表知识点

  • 基类对象的虚函数表中存放基类所有虚函数的地址。同类型的对象共用同一张虚表,不同类型的对象各自有独立的虚表,所以基类和派生类有各自有独立的虚表。
  • 派生类由两部分构成,继承下来的基类和自己的成员,一般情况下,继承下来的基类中有虚函数表指针,自己就不会再生成虚函数表指针。但是要注意的这里继承下来的基类部分虚函数表指针和基类对象的虚函数表指针不是同一个,就像基类对象的成员和派生类对象中的基类对象成员也独立的。
  • 派生类中重写的基类的虚函数,派生类的虚函数表中对应的虚函数就会被覆盖成派生类重写的虚函数地址。
  • 派生类的虚函数表中包含:(1)基类的虚函数地址,(2)派生类重写的虚函数地址完成覆盖,(3)派生类自己的虚函数地址 三个部分。

  • 虚函数表本质是一个存虚函数指针的指针数组,⼀般情况这个数组最后面放了一个0x00000000标记。(这个C++并没有进行规定,各个编译器自行定义的,vs系列编译器会再后面放个0x00000000 标记,g++系列编译不会放)

  •  虚函数存在代码段的,虚函数的地址又存到了虚表中,虚函数表vs下是存在代码段(常量区)(C++标准并没有规定,各个编译器自行定义)

五、经典题目

题目:

class A
{
public:
	virtual void func(int val = 1) { std::cout << "A->" << val << std::endl; }
	virtual void test() { func(); }
};

class B : public A
{
public:
	void func(int val = 0) { std::cout << "B->" << val << std::endl; }
};

int main( )
{
	B* p = new B;
	p->test();
	return 0;
}

解析:

        B* 类型指针 p 传入 test() 中,发现并没有重写,编译器通过继承关系找到 A::test();调用func()在 A::test() 中,func() 调用相当于 func(A* const this, val),但是这但是里的 this 指针指向的是 B 对象,发现构成多态,通过虚函数表查找实际要调用的函数 B::func();

默认参数绑定

  • 默认参数是静态绑定的,在编译时确定

  • 在 A::test() 中调用 func(),编译器看到的是 A::func 的声明

  • 所以使用的默认参数是 A::func 的默认值 1,而不是 B::func 的默认值 0

所以,如果是多态,派生类的缺省值是没用的!

答案: B



后记:掌握了多态,你就掌握了面向对象编程的精髓之一。不太难,但很重要。下面我们就又要开始数据结构和stl了,大家觉得有帮助的话,点个赞支持一下吧!

Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐