C++多态入门(上):从概念本质、语法规则到虚函数重写,结合实战代码的全方位指南

🔥小叶-duck个人主页

❄️个人专栏《Data-Structure-Learning》

《C++入门到进阶&自我学习过程记录》

《算法题讲解指南》--优选算法

《算法题讲解指南》--递归、搜索与回溯算法

未择之路,不须回头
已择之路,纵是荆棘遍野,亦作花海遨游


目录

前言

[一. 多态的概念](#一. 多态的概念)

[二. 多态的构成条件和核心语法](#二. 多态的构成条件和核心语法)

[1、条件 1:虚函数的定义](#1、条件 1:虚函数的定义)

[2、条件 2:虚函数的重写(覆盖)](#2、条件 2:虚函数的重写(覆盖))

3、多态场景的一个笔试选择题(重要):

题目解析:

改编扩展:

题目解析:

进阶拓展:

题目解析:

总结

三、虚函数重写的特殊情况

1、协变(了解)

2、析构函数的重写(重点)

反证逻辑:

解决思路:

[四. C++11:override 与 final 关键字](#四. C++11:override 与 final 关键字)

1、override:检测是否重写

2、final:禁止重写

[五. 易混淆概念:重载、重写、隐藏的对比(常考)](#五. 易混淆概念:重载、重写、隐藏的对比(常考))

结束语


前言

多态 是 C++ 面向对象 三大特性(封装、继承、多态) 的核心,它让 "同一行为作用于不同对象产生不同结果" 成为可能。本文将从多态的基础概念切入,逐步拆解多态的构成条件、虚函数重写规则及关键细节,帮你彻底掌握运行时多态的实现逻辑。

一. 多态的概念

多态通俗来说就是 "多种形态" ,在C++中分为两类:

  • 编译时多态(静态多态) :通过函数模板,重载 来实现,编译阶段 确定调用的函数(如 add(1,2) 和 add(1,2,3,4) 调用的不同的函数)
  • 运行时多态(动态多态) :本篇博客核心讲解的地方,通过"基类指针/引用 + 虚函数重写 ",运行阶段根据指向的对象的类型确定调用的函数(指针的类型声明在虚函数调用时被忽视了)

二. 多态的构成条件和核心语法

多态是一个继承关系 下的类对象 ,去调用同一函数产生了不同的行为

要实现多态的效果,除了要有继承关系,还必须满足下面这两个 强制条件,缺一不可:

  • 必须是基类的指针或者引用调用虚函数
  • 被调用的函数 必须是虚函数 ,并且派生类 对基类的虚函数完成了 "重写" (覆盖)。

说明 :要实现多态的效果,第一必须是基类的指针或者引用 ,因为只有基类的指针或引用 才能既指向基类对象又指向派生类对象(派生类对象 通过切割 可赋值给基类的指针/基类的引用)第二派生类必须对基类的虚函数完成重写/覆盖,重写了虚函数,基类和派生类之间才能有不同的函数,多态的不同形态效果才能达到。)

1、条件 1:虚函数的定义

类成员函数前加 virtual 关键字 ,该函数即为虚函数 (非成员函数和静态成员函数不能加virtual)。虚函数的作用是 "标记" 该函数需要参与多态,让编译器为其生成动态绑定逻辑。

cpp 复制代码
// 基类:Person
class Person
{
public:
	// 虚函数:标记为需要参与多态
	virtual void BuyTicket() 
	{ 
		cout << "买票-全价" << endl; 
	}
};

2、条件 2:虚函数的重写(覆盖)

派生类中定义 一个 "与基类虚函数完全一致 " 的函数 ,即为重写。这里 "完全一致" 指:

  • 函数名相同;
  • 参数列表(参数类型、个数、顺序)相同(缺省参数可以不管);
  • 返回值类型相同(协变除外,下文讲解)。
cpp 复制代码
#include<iostream>
using namespace std;

// 基类:Person
class Person
{
public:
	// 虚函数:标记为需要参与多态
	virtual void BuyTicket()
	{ 
		cout << "买票-全价" << endl; 
	}
};

// 派生类:Student(继承Person)
class Student : public Person
{
public:
	// 重写基类虚函数:函数名、参数、返回值完全一致
	// 派生类中virtual可以省略不写
	virtual void BuyTicket() 
	{
		cout << "买票-打折" << endl; 
	}
};


void Func(Person* ptr)
{
	// 运行时根据ptr指向的对象类型,调用对应类的BuyTicket
	// Person* ptr
	ptr->BuyTicket(); // 关键:用的是基类指针(Person* ptr)调用虚函数(满足多态条件1)
	//关键:并且函数BuyTicket()为虚函数,且完成了虚函数的重写(满足多态条件2)

	// 如果是基类引用(Person& ptr)
	//ptr.BuyTicket();
}

int main()
{
	Person p;
	Student s;
	Func(&p);
	Func(&s);
	Person* ps = &s;
	Person* pp = &p;
	pp->BuyTicket();
	ps->BuyTicket();

	Student* pst = &s;
	pst->BuyTicket();//不满足多态的条件1:必须是基类的指针或者引用调用虚函数
	                 //所以只是普通的函数调用,不涉及多态
	return 0;
}

注意派生类重写 时,即使不加 virtua l,也能构成重写 (因为基类虚函数的 "虚属性" 会被继承),但不建议这么写,可读性差且易出错。

这里有一个容易理解为什么继承关系下两个函数如果构成多态,通过基类指针指向派生类的对象就能调用到派生类中的函数 的方法:

我们可以把上面对象ps、pp基类指针类型(Person*)当成是"虚假的"(前提是对象的类型和调用的函数构成多态),为什么说理解成"虚假的"呢?因为如果构成多态,对象的指针类型声明调用虚函数 时是被"无视的",而只取决于指向的对象的类型来确定调用的函数。 (但如果不构成多态 ,即对象的类型不是基类的指针/引用 或者 函数本身不是虚函数 。则对象ps、pp的基类指针类型(Person*)就是"真实的", 直接调用对应的类中函数即可,无需考虑指向的对象的类型)

坑点(下面的笔试题会有体现) :在 C++ 中,虚函数重写时 若基类和派生类的虚函数都指定了缺省参数,调用时的缺省值只由 "基类的函数声明" 决定,与派生类的重写实现 无关。(《Effective C++》 这本书中的条款37:绝不重新定义继承而来的缺省参数值)

一定要注意的是:这个条款的前提是构成多态的情况!

3、多态场景的一个笔试选择题(重要):

以下程序输出的结果是什么( B )
A: A->0 B: B->1 C: A->1 D: B->0 E: 编译出错 F: 以上都不正确

cpp 复制代码
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; 
	}
	//void test() { func(); }
	//有了这个函数,对象p调用test就会直接在自己这个类中找到,
	//此时自己类中的test()函数的this指针类型是派生类指针
	//所以不符合多态的条件1( b* this->func() ):必须是基类的指针或者引用调用虚函数
    //所以此时对象q的类型就不再是"虚假的",
    //也就是不再由指向的对象类型来决定调用什么函数,对象自己是什么类型就调用对应类中的函数
};

int main(int argc, char* argv[])
{
	b* p = new b;
	p->test();
	b* q = new b;
	q->func();
	return 0;
}

题目解析:

改编扩展:

以下程序输出的结果是什么( D )
A: A->0 B: B->1 C: A->1 D: B->0 E: 编译出错 F: 以上都不正确

cpp 复制代码
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; 
	}
	//void test() { func(); }
	//有了这个函数,对象p调用test就会直接在自己这个类中找到,
	//就会将基类进行隐藏而去查找,此时自己类中的test()函数的this指针类型是派生类指针
	//所以不符合多态的条件1( B* this->func() ):必须是基类的指针或者引用调用虚函数
};

int main(int argc, char* argv[])
{
	B* q = new B;
	q->func();
	return 0;
}

题目解析:

进阶拓展:

基于上面的题我对其进行了改编,难度上比上面的题更难,但可以让大家更好的去理解和解决这类"坑题",并且讲解完我会对多态进行一个总结:

cpp 复制代码
class A
{
public:
    virtual void f(int x = 1) 
    {
        cout << "A->" << x << endl;
    }

    virtual void g() 
    {
        f();
    }
};

class B : public A
{
public:
    void f(int x = 2) 
    {
        cout << "B->" << x << endl;
    }

    void g() 
    {
        f();
    }

};

class C : public B
{
public:
    void f(int x = 3) 
    {
        cout << "C->" << x << endl;
    }
};

int main()
{
    B* p = new C;
    p->g();
}

题目解析:

我们先看一下对象 p ,p 的类型是B*p 指向的对象的类型是 C (这是为了后续是否与调用函数构成多态而判断到底调用哪个类的函数)

我们再看一下 p 调用的函数 g(),因为g()函数为虚函数( 虽然B类中没有virtual,但是A类中g()的虚属性继承给了B类 ),并且是基类的指针(B* p)调用g(),所以p 和 g() 构成多态 。但由于C类中没有重写 g(),因此调用的是B类中的 g()

当进入B类的 g() 后,函数内部又调用了函数 f(),这里非常重要:因为B类中函数 g() 存在隐藏的 this 指针 ,this指针的类型是B*,所以f(); 这个代码实际相当于是this->f(); 又因为 f() 函数是虚函数 ,并且是基类的指针( B* this->f() )调用 f() ,所以this 指针和 f() 两者构成多态 ,因为this 指针指向的对象类型是C ,并且C类中对 f() 函数完成了复写 ,所以会调用C类的 f()

最后因为p 的类型是B* ,所以缺省参数值是取决于B类中 f() 函数的声明(即 x = 2),所以最终结果为 C->2。

总结

为了让大家以后对这种类型的"坑题"都有思路和想法,我对多态进行了一下的总结:

一、多态到底什么时候 触发

只要同时满足:

  1. 函数是 virtual(基类标了 virtual,子孙全是虚函数)
  2. 基类的指针 / 引用 调用

一定触发多态

→ 一定去对象真实类型 里找函数

重写不重写,不影响 "是否触发多态",只影响 "多态找到谁"。

二、多态找到哪个函数?(规则)

对象真实类型 开始,向上找

  • 自己类有重写 → 用自己的
  • 自己没有 → 往上继承链找最近的那个

(但是如果不构成多态 ,则只需要看指针的类型即可,是什么类型就到哪个类中找)

三、默认参数用谁的?(铁律)

默认参数 = 看静态类型(编译期看指针 / this 是什么类型)

  • 指针是 A* → 用A 的默认参数
  • 指针是 B* → 用 B 的默认参数
  • A 类里调用this 是 A* → 用 A 的默认参数
  • B 类里调用this 是 B* → 用 B 的默认参数

默认参数和多态无关,只看 "调用时的静态类型"。

四、this 指针是什么类型?(铁律)

this 的类型 = 当前在哪个类的函数里

  • A 的函数里this = A*
  • B 的函数里this = B*
  • C 的函数里this = C*

和外面指针是 A*、B* 无关!

在这里我们对多态相关的题目进行非常详细的讲解就是为了希望大家对多态有更加深入的理解,并且也为下面将要讲解的析构函数的重写(面试中的高频考点)做铺垫。

三、虚函数重写的特殊情况

虚函数重写并非只有 "完全一致" 一种情况 ,还有两种特殊场景需要注意:协变析构函数重写 ,这也是面试高频考点

1、协变(了解)

派生类重写基类虚函数时,返回值类型可以不同,但必须满足:

  • 基类虚函数返回 "基类对象的指针 / 引用";
  • 派生类虚函数返回 "派生类对象的指针 / 引用"。

这种情况称为 "协变",实际开发中使用较少,了解即可。

需要注意的是:这里返回的**"基类对象的指针 / 引用** "和**"派生类对象的指针 / 引用**"并不一定需要是自己的,比如下面的情况:

cpp 复制代码
// 基类A
class A {};
// 派生类B(继承A)
class B : public A {};

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

    // 虚函数:返回自己类(基类)的指针
    /*virtual Person* BuyTicket()
    {
        cout << "买票-全价" << endl;
        return nullptr;
    }*/
};

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

    // 重写:返回自己类(派生类)的指针(协变)
   /* virtual Student* BuyTicket() {
        cout << "买票-打折" << endl;
        return nullptr;
    }*/
};

void Func(Person* ptr) 
{
    ptr->BuyTicket(); // 多态调用依然生效
}

int main()
{
    Person ps;
    Student st;
    Func(&ps); // 输出"买票-全价"
    Func(&st); // 输出"买票-打折"
    return 0;
}

2、析构函数的重写(重点)

基类的析构函数为虚函数 ,此时派生类析构函数只要定义 ,无论是否加 virtual 关键字 ,都与基类的析构函数构成重写。 虽然基类与派生类析构函数名字不同 看起来不符合重写的规则 ,实际上编译器对析构函数的名称做了特殊处理编译后 析构函数的名称统一处理成 destructor,此时就符合了函数名相同的规则了,所以当基类的析构函数前面加上 virtual 修饰,派生类的析构函数就构成重写。

注意 :这个在面试题中经常考到:问基类中的析构函数建不建议写成虚函数?

答:建议。为什么建议写成虚函数呢?就是为了解决下面这种继承关系的情况:

cpp 复制代码
int main() 
{
    // 基类指针指向派生类对象
    A* ptr1 = new B;
    delete ptr1;
    // 基类指针指向基类对象
    A* ptr2 = new A;
    delete ptr2;
    return 0;
}

反证逻辑:

对于上面的这种情况,我们用两个基类指针(A*)分别指向基类对象和派生类对象,然后将两个基类指针进行delete时就会调用析构函数,如果 基类指针 和 析构函数 不构成多态,就会出现问题:

由于不构成多态基类指针无论指向的对象是什么类型 ,都只会调用指针类型(A*)的函数 。所以不管是 new 一个派生类对象给 ptr1 还是 new 一个基类对象给 ptr2,最终都会调用A类的析构函数。

但是这并不是我们想要的结果,因为我 new 一个 派生类对象本应该是调用对应的派生类中析构函数,否则可能会出现派生类中存在动态申请的资源无法释放 ,引发内存泄漏

cpp 复制代码
//析构函数的重写
class A {
public:
    // 基类析构函数加virtual,支持重写
     virtual ~A() {
        cout << "~A()" << endl;
    }
};

class B : public A {
public:
    // 派生类析构函数:自动构成重写(加不加virtual都可以)
    ~B() {
        cout << "~B()->delete:" << _p << endl;
        delete _p; // 释放派生类动态申请的资源
    }
protected:
    int* _p = new int[10]; // 派生类动态申请的数组
};

void test()
{
    cout << "--------额外测试结果--------" << endl;
    //额外测试,这个是正常场景,加不加都行
    //只是为了让大家复习一下这个派生类对象的析构顺序
    //析构顺序:~B(),~A(),~A()
    //其中第一个~A()是因为子类B析构完后调用基类的(先子后父),后面一个是a对象析构
    A a;
    B b;
}
// 基类只要保障了析构函数是虚函数,下面场景就不会存在内存泄漏
int main()
{
    // 基类指针指向派生类对象
    A* ptr1 = new A;
    delete ptr1; // 调用~A()

    // 基类指针指向基类对象
    A* ptr2 = new B;
    delete ptr2; // 多态调用:先调用~B(),再先子后父自动调用~A(),无内存泄漏

    test();
    return 0;
}

解决思路:

那怎样才能调用到派生类中的析构函数呢?就需要让基类指针是根据指向对象的类型调用对应的函数 ,而只有当 基类指针 和 析构函数 构成多态 才能实现这个要求。

那怎样让 基类指针 和 析构函数 构成多态呢?就需要满足上面所讲解的构成多态两大条件:(1)必须是基类的指针或者引用调用虚函数 ;(2)被调用的函数 必须是虚函数 ,并且派生类基类的虚函数完成了 "重写" (覆盖)。

首先函数的确是基类的指针(A*)调用的,这是没有问题的,但是调用的析构函数并非是虚函数,也就无法构成多态,也就无法解决上述的问题。所以反过来,当我们对析构写成虚函数后,构成多态的条件1也就完成了。

但是条件1完成后还不够,还需要派生类对虚函数完成"重写"操作 ,此时我们就会发现一个问题:上面我们讲解虚函数重写时说到了想要实现虚函数重写就需要满足"三同",即函数名、返回类型、参数列表相同 。我们就会发现派生类的析构函数名( ~B() )和基类的析构函数名( ~A() )是不可能相同的,那这样怎么能实现虚函数重写呢?

为了让析构函数能实现虚函数重写 ,编译器对于析构函数就做了特殊处理将所有析构函数的名称统一处理为 destructor,使得所有析构函数的名字统一起来,这样也就满足了虚函数重写的所有条件了。

到此,我们也就解决了为什么析构函数建议并且应该要写成虚函数的问题了。

当面试的时候如果被问到这个问题,如果大家真正的完全理解上面我所讲解的思路,也就不需要去背所谓的标答了,因为析构函数写成虚函数的逻辑是非常顺理成章的。

四. C++11:override 与 final 关键字

虚函数重写对语法要求严格(如函数名写错、参数类型不匹配),这些错误编译时不会报错,只会在运行时出现非预期结果。C++11 提供 override 和 final 两个关键字,帮我们在编译阶段检测错误。

1、override:检测是否重写

在派生类虚函数后加 override ,编译器会检查该函数是否真的重写了基类虚函数。若未重写(如函数名错、参数错),直接编译报错。

cpp 复制代码
//override:检测是否重写
class Car 
{
public:
    // 基类虚函数:Drive(注意拼写是Drive,不是Dirve)
    virtual void Drive() 
    {
        cout << "Car-行驶" << endl;
    }
};

class Benz : public Car 
{
public:
    // 错误示例:函数名写成Dirve,加override后编译报错
    //error C3668: "Benz::Dirve": 包含重写说明符"override"的方法没有重写任何基类方法
    virtual void Dirve() override { cout << "Benz-舒适" << endl; }

    // 正确示例:函数名正确,override检测通过
    virtual void Drive() override 
    {
        cout << "Benz-舒适" << endl;
    }
};

int main()
{
    Car* p = new Benz;
    p->Drive(); // 多态调用:输出"Benz-舒适"
    return 0;
}

2、final:禁止重写

在基类虚函数后加 final,表示该虚函数不允许任何派生类重写。若派生类强行重写,编译报错。

cpp 复制代码
//final:禁止重写
class Car 
{
public:
    // 基类虚函数加final:禁止派生类重写
    virtual void Drive() final
    {
        cout << "Car-行驶" << endl;
    }
};

class Benz : public Car
{
public:
    // 错误:Drive()被final修饰,无法重写,编译报错
    // error C3248: "Car::Drive": 声明为 "final" 的函数不能由 "Benz::Drive" 重写
    virtual void Drive() override { cout << "Benz-舒适" << endl; }
};

int main()
{
    return 0;
}

五. 易混淆概念:重载、重写、隐藏的对比(常考)

多态相关的三个概念:重载、重写(覆盖)、隐藏(重定义) 极易混淆,我们通过下面的图片,代码示例和表格来加强一下对它们的区分:

代码示例:

cpp 复制代码
//重载、重写、隐藏的对比
class Base
{
public:
    // 1. 重载:同一作用域,函数名相同,参数不同
    virtual void func(int a) { cout << "Base::func(int)" << endl; }
    void func(double b) { cout << "Base::func(double)" << endl; }

    // 虚函数:用于重写
    virtual void show() { cout << "Base::show()" << endl; }
};

class Derive : public Base
{
public:
    // 2. 重写:基类与派生类,虚函数+函数名/参数/返回值相同
    virtual void show() override { cout << "Derive::show()" << endl; }

    // 3. 隐藏:基类与派生类,函数名相同但不构成重写(参数列表)
    void func(int a, int b) { cout << "Derive::func(int,int)" << endl; }
};

int main()
{
    //重载
    Base b1;
    Base b2;
    b1.func(1);
    b2.func(2.0);

    //隐藏
    Derive d;
    d.func(1, 2);    // 调用Derive::func(隐藏基类func)
    // d.func(3);    // 编译报错:基类func(int)被隐藏,需显式调用Base::func(3)

    //重写
    Base* p = &d;
    p->show();       // 多态调用:Derive::show(重写)
    return 0;
}

|-----------|------------------------------------|--------------------------------------|-------------------------------------|
| 特性 | 重载(Overload) | 重写(Override) | 隐藏(Hide) |
| 作用域 | 同一类(同一作用域) | 基类与派生类(不同作用域) | 基类与派生类(不同作用域) |
| 函数名 | 必须相同 | 必须相同 | 必须相同 |
| 参数列表 | 必须不同 (类型/个数/顺序) | 必须相同 | 可相同可不同 |
| 返回值类型 | 无要求 | 必须相同(协变除外) | 无要求 |
| 虚函数要求 | 无 | 必须都是虚函数 | 无 |
| 核心场景 | 同一类中同名函数的不同实现 | 多态的核心,动态绑定 | 派生类屏蔽基类同名成员 (非重写) |
| 底层机制 | 编译期静态绑定,通过参数列表区分函数 | 运行期动态绑定,依赖虚函数表 | 编译期静态绑定,通过作用域区分 |
| 注意事项 | 仅在同一类中生效,派生类中若与基类函数同名且参数不同,会隐藏基类函数 | 重写时函数签名(函数名+参数+返回值)必须严格一致,析构函数重写有特殊性 | 若派生类函数与基类虚函数同名但参数不同,会隐藏基类虚函数,导致多态失效 |

结束语

到此,多态入门(上)就讲解完了,**C++ 多态的本质,是用 "统一接口" 包裹 "差异化实现",让代码既能保持调用逻辑的一致性,又能适配不同对象的特性 ------ 从 "买票" 的场景差异,到析构函数的资源安全释放,多态始终在平衡 "通用性" 与 "灵活性"。**希望对大家学习C++能有所收获!

C++参考文档:
https://legacy.cplusplus.com/reference/
https://zh.cppreference.com/w/cpp
https://en.cppreference.com/w/

相关推荐
汉克老师2 小时前
GESP2026年3月认证C++二级( 第三部分编程题(2)画画 )
c++·二维数组·gesp二级·gesp2级·打印图形
XiYang-DING2 小时前
【Java SE】继承
java·开发语言
故以往之不谏2 小时前
快慢双指针算法--数组删除目标元素--LeetCode27
开发语言·数据结构·c++·算法·leetcode·学习方法·数组
御承扬2 小时前
鸿蒙NDK UI 之文本输入框监听
c++·harmonyos·ndk ui
DREW_Smile2 小时前
C语言内存函数
c语言·开发语言
minji...2 小时前
Linux 进程控制(四)自主Shell命令行解释器.
linux·运维·服务器·数据结构·c++
历程里程碑2 小时前
Linux 38 网络协议:从独立主机到全球互通
java·linux·运维·服务器·网络·c++·职场和发展
任子菲阳2 小时前
学JavaWeb第七天——yml配置文件 & 后端实战Tlias案例
java·开发语言·spring
AI科技星2 小时前
空间光速螺旋动力学:统一质量、引力、电磁与时空本源的公理化理论与全现象验证
c语言·开发语言·opencv·算法·r语言
qq_404265832 小时前
C++中的代理模式实战
开发语言·c++·算法