云计算百科
云计算领域专业知识百科平台

对于C++:多态的解析—上

开篇介绍:

hello 大家,我们又见面啦,very happy,哈哈,那么大家,我们在前面两篇博客中,学习了C++三大特性之一的继承,那么既然学习完了继承,我们接下来肯定就得学习C++三大特性之一的多态,因为多态正是建立在继承的基础之上的,而且它的重要性以及实用性,那都是毋庸置疑的高,是面试和笔试中的高频考点,所以我们接下来就要用两篇博客来将多态给“吃干抹净”,哈哈。

其实大家仔细、认真理解的话,多态并不算的上是多难,前提是大家对继承足够的了解以及清晰,OK,那么我们话不多说,接下来就进行对多态的解析之后。

多态的概念:

多态(polymorphism)的通俗概念是 “多种形态”,核心是同一行为(或函数)在不同场景下呈现出不同的执行效果,它在 C++ 中分为编译时多态(静态多态) 和运行时多态(动态多态) 两类,其中运行时多态是面向对象编程的核心特性,也是此处的重点讲解内容。​

一、编译时多态(静态多态)​

编译时多态主要包含前面提到的函数重载和函数模板两种形式,其核心逻辑是 “凭借参数差异实现多种形态”。具体来说,当我们定义多个同名函数(函数重载),或使用模板定义通用函数(函数模板)时,只需传入不同类型、不同数量或不同顺序的参数,编译器就能自动匹配到对应的函数实现。例如函数重载中,add(int a, int b) 和 add(double a, double b) 是同名函数,传入整型参数时调用前者,传入浮点型参数时调用后者;函数模板 template <typename T> T add(T a, T b) 则能通过传入的参数类型,自动生成对应类型的函数实例。​

之所以称为 “编译时多态” 或 “静态多态”,是因为实参与形参的匹配过程、函数地址的绑定过程都在编译阶段完成—— 编译器在编译时就已确定要调用的具体函数,后续运行时无需再做额外判断,直接执行编译好的指令即可。由于编译是程序运行前的静态操作,这类多态因此得名。​

二、运行时多态(动态多态)​

运行时多态的核心表现是 “同一函数,不同对象调用产生不同行为”,即完成某个特定行为(函数)时,传入继承关系下的不同类对象,会执行该对象专属的行为逻辑,进而达成 “多种形态” 的效果。​

1. 生活场景类比​

最直观的例子就是 “买票” 行为:​

  • 普通人(对应基类对象)买票,执行 “全价购票” 的逻辑;​
  • 学生(对应派生类对象)买票,执行 “优惠购票” 的逻辑(如 5 折、75 折);​
  • 军人(另一派生类对象)买票,执行 “优先购票” 的逻辑。​

再比如 “动物叫” 行为:​

  • 传入猫对象,执行 “(>^ω^<) 喵” 的发声逻辑;​
  • 传入狗对象,执行 “汪汪” 的发声逻辑;​
  • 传入鸡对象,执行 “咯咯” 的发声逻辑。​

这些场景中,“买票”“动物叫” 都是统一的行为名称,但不同对象调用时,执行的具体操作不同,这就是运行时多态的核心体现。​

2. 编程逻辑本质​

从编程层面来看,运行时多态的本质是 “继承关系下的类对象,调用同一基类定义的虚函数,产生不同的执行结果”。例如定义基类Person,其中声明虚函数buyTicket()(表示 “买票” 行为);派生类Student继承Person,并重写buyTicket()函数,实现优惠购票逻辑;此时Person对象调用buyTicket()执行全价逻辑,Student对象调用buyTicket()执行优惠逻辑 —— 二者调用的是基类声明的同一函数名称,但执行的是各自类中重写的实现。​

之所以称为 “运行时多态” 或 “动态多态”,是因为函数地址的绑定过程在程序运行时才完成:编译器编译时无法确定要调用的具体函数(因为不知道指针 / 引用实际指向的是基类对象还是派生类对象),只能在运行时根据对象的实际类型,通过虚函数表指针找到对应的函数地址,再执行相应逻辑,这一动态绑定过程是运行时多态的技术核心,这一段话大家先看看,到了最后的时候,我们会进行详细的解释。

OK大家,从上面的介绍大家就不难看出,多态绝对是一个很重要的特性,所以也希望大家能够认真对待。

多态的定义及实现:

多态的构成条件:

实现 C++ 多态必须满足两个不可缺少的重要条件,这两个条件共同支撑 “同一行为呈现不同形态” 的核心效果,具体细节如下:​

一、必须通过基类的指针或引用调用虚函数​

这一条件的核心是利用基类指针 / 引用的 “类型兼容性” 特性,为多态的 “动态绑定” 提供基础,具体可从三个层面理解:​

1. 基类指针 / 引用的特殊能力​

在 C++ 继承体系中,只有基类的指针或引用具备 “双重指向能力”—— 既能指向基类自身的对象,也能指向其派生类的对象(这是子类对象可以隐式转换为基类类型的体现)。例如定义基类Person、派生类Student后,Person* p1 = new Person();(指向基类对象)和Person* p2 = new Student();(指向派生类对象)都是合法的;引用同理,Person& ref = Student();也能正常编译。​

而如果用派生类的指针 / 引用(如Student* p = new Person();),则会直接编译报错 —— 因为派生类是基类的 “扩展”,基类对象无法满足派生类的所有成员需求,所以派生类指针 / 引用不能指向基类对象,自然无法实现 “同一调用方式适配不同对象” 的多态场景。​

2. 与 “切片” 机制的关联​

这一条件本质是对继承中 “切片” 机制的合理利用:当派生类对象赋值给基类指针 / 引用时,基类指针 / 引用会 “切片” 获取派生类对象中的基类部分,但不会丢失派生类的核心标识(即虚函数表指针指向的仍是派生类的虚表)。正是这种 “切片不丢标识” 的特性,才能让后续调用虚函数时,准确找到派生类的重写实现,而非仅执行基类的默认逻辑。​

3. 对实际编程的暗示​

这一点直接指导了多态场景的代码设计:在定义调用虚函数的函数时,形参类型必须设为基类的指针或引用,而非子类类型。

例如要实现 “统一处理买票行为” 的函数,应定义为void handleBuyTicket(Person* person) { person->buyTicket(); },而非void handleBuyTicket(Student student) { … }—— 前者能接收Person、Student、Soldier(其他派生类)等多种对象,通过同一函数调用触发不同的买票逻辑;后者只能接收Student对象,无法适配其他派生类,完全失去多态的灵活性,这也是后续示例会重点体现的核心差异。​

二、被调用的函数必须是虚函数,且派生类需完成重写(覆盖)​

这一条件是实现 “不同形态” 的核心 —— 只有让基类与派生类针对同一函数拥有不同实现,才能在调用时呈现差异,具体细节如下:​

1. “虚函数” 的关键作用​

函数必须被virtual关键字修饰(即虚函数),是因为虚函数会触发 C++ 的 “虚函数表机制”:基类的虚函数会被记录在基类的虚表中,派生类继承后会生成自己的虚表,若派生类重写虚函数,会将虚表中对应位置的基类函数地址 “覆盖” 为自己的函数地址。若函数不是虚函数,则不会进入虚表,调用时会直接根据指针 / 引用的 “静态类型”(即编译时的类型)绑定函数地址,无论指向哪个对象,都只会执行基类的函数实现,完全无法触发多态。​

2. “重写” 的严格定义(与重载区分)​

这里必须明确:“重写”(覆盖)≠“重载”,二者是完全不同的概念,错误混淆会直接导致多态失效。​

  • 重写:要求基类与派生类的函数满足 “三同”(函数名、参数列表、返回值类型完全一致,协变场景除外),且基类函数为虚函数,派生类函数可加virtual(也可省略,因继承会保留虚属性);重写的目的是 “替换” 基类函数的实现,让派生类拥有专属逻辑,例如Person的buyTicket()(全价)和Student的buyTicket()(优惠)就是典型重写。​
  • 重载:是同一作用域内(如同一类中)的同名函数,通过参数的 “类型、数量、顺序” 不同区分,与virtual无关,例如Person类中showInfo()和showInfo(int age)就是重载;重载仅作用于同一类内部,无法跨继承体系实现多态的 “不同对象不同行为”。​

只有当派生类严格按照重写规则,对基类的虚函数提供新的实现(即完成重写),基类与派生类之间才会形成 “同一函数名、不同实现” 的对应关系。此时调用虚函数时,才能根据对象的 “动态类型”(即运行时实际指向的对象类型),从虚表中找到对应的函数地址,执行派生类的重写逻辑 —— 若派生类未重写,则会继承基类的虚函数实现,此时调用仍执行基类逻辑,无法呈现多态的 “不同形态”,甚至若基类虚函数为纯虚函数,派生类未重写会导致自身成为抽象类,无法实例化。

OK大家,光看上面大家可能还是有点蒙,下面我们对虚函数以及重写进行详细的解析。

虚函数:

在 C++ 中,虚函数是实现运行时多态的核心载体,其定义、特性及约束需要精准理解,具体细节如下:​

一、虚函数的基本定义与语法​

虚函数的核心标识是virtual关键字,其语法规则明确:仅当类的成员函数前(即函数返回类型前面)添加virtual关键字修饰时,该成员函数才被称为虚函数。​

1. 语法示例​

以之前 “买票” 场景中的buyTicket函数为例,基类Person中定义虚函数的正确写法为:​

class Person {
public:
// 成员函数前加virtual,成为虚函数
virtual void buyTicket() {
cout << "普通人买票:全价" << endl;
}
};

派生类Student继承后重写该虚函数时,virtual关键字可加可不加(因继承会保留函数的虚属性),但为了代码可读性,通常建议显式添加:​

class Student : public Person {
public:
// 派生类重写虚函数,virtual可省略但建议保留
virtual void buyTicket() override {
cout << "学生买票:75折优惠" << endl;
}
};

2. 语法位置的严格性​

需特别注意virtual的修饰位置:必须放在 “函数返回类型前面”,且仅能修饰类的成员函数。例如以下两种写法均不合法:​

  • 错误 1:void virtual buyTicket()——virtual位置错误,虽部分编译器可能兼容,但不符合标准语法规范,易导致代码可读性混乱;​
  • 错误 2:在全局函数或静态成员函数前加virtual,如virtual void globalFunc()(全局函数)、static virtual void staticFunc()(静态成员函数)—— 前者因 “非成员函数” 无法被virtual修饰,后者因静态成员函数属于类而非对象,无虚函数表指针关联,均会直接编译报错。​

二、虚函数的关键约束:非成员函数不可修饰​

“非成员函数不能使用virtual关键字修饰” 是 C++ 的硬性语法规则,背后有明确的设计逻辑:​

1. 非成员函数的本质限制​

非成员函数(如全局函数、友元函数)不隶属于任何类,也不与具体对象绑定 —— 全局函数独立于所有类,友元函数虽能访问类的私有成员,但本质仍是外部函数,不具备 “类成员函数” 的属性。而虚函数的核心作用是 “通过对象的虚函数表指针,在运行时找到对应类的函数实现”,这需要函数与类、对象强关联(依赖类的虚表机制),非成员函数无此关联基础,自然无法被virtual修饰。​

2. 常见非成员函数的排除场景​

  • 全局函数:如void printInfo(),完全独立于类,加virtual会编译报错;​
  • 友元函数:即便在类内声明friend virtual void show(Person& p),也会因 “友元不是成员函数” 被编译器拒绝;​
  • 静态成员函数:虽属于类,但不依赖对象实例(无this指针),而虚函数调用需通过this指针访问对象的虚函数表指针,因此静态成员函数也不能加virtual(编译时会提示 “静态成员函数不能是虚函数”)。

三、虚函数的常见认知误区​

1. 派生类重写时必须加virtual?​

不必。派生类重写基类虚函数时,virtual关键字可省略,因基类已标记该函数为虚函数,派生类继承后会自动保留其虚属性。但为了代码可读性和严谨性(避免后续维护时误判函数是否为虚函数),建议显式添加virtual,或结合 C++11 的override关键字(如void buyTicket() override),既能明确重写意图,又能让编译器检查重写是否正确。​

2. virtual修饰后函数就一定能触发多态?​

不一定。虚函数只是多态的 “前提之一”,还需满足 “通过基类指针 / 引用调用” 这一条件。若直接用对象调用虚函数(如Person p; p.buyTicket(); Student s; s.buyTicket();),虽函数是虚函数,但调用时会根据对象的 “静态类型” 直接绑定(编译时确定地址),无法触发多态;只有用Person* p = new Student(); p->buyTicket();这类基类指针 / 引用调用,才能触发动态绑定,实现多态。

虚函数的重写/覆盖:

在 C++ 运行时多态中,虚函数的重写(又称覆盖)是实现 “不同对象调用同一函数呈现不同行为” 的核心操作,其规则需严格遵循 “三同” 要求,具体细节及易错点如下:​

一、虚函数重写的核心定义:什么是 “重写 / 覆盖”​

虚函数的重写(或覆盖),本质是派生类针对基类已声明的虚函数,提供一个 “完全匹配” 的函数实现—— 即派生类中存在一个与基类虚函数满足 “三同” 条件的函数,此时派生类的该函数会 “覆盖” 基类虚函数在虚表中的地址,后续通过基类指针 / 引用调用时,会优先执行派生类的重写实现。​

需明确:重写的核心是 “函数接口匹配 + 虚函数属性”,与函数体内部逻辑无关 —— 基类和派生类的函数体可完全不同(这正是多态 “不同行为” 的来源),但必须满足 “三同” 的接口约束。​

二、重写的核心规则:严格的 “三同” 要求​

“三同” 指派生类虚函数与基类虚函数的 “返回值类型、函数名字、参数列表” 完全相同,三者缺一不可,任何一项不匹配都会导致重写失败,沦为普通的派生类新增函数(无法触发多态),具体拆解如下:​

1. 第一同:函数名字完全相同​

  • 要求:基类与派生类的函数名必须一字不差,大小写、拼写均需一致,不存在 “近似匹配” 或 “模糊匹配”。​
  • 示例:​
  • 基类虚函数:virtual void buyTicket()​
  • 派生类正确重写:virtual void buyTicket()(函数名完全一致)​
  • 派生类错误写法:virtual void buy_ticket()(下划线差异)、virtual void BuyTicket()(大小写差异)—— 均不构成重写,会被视为派生类新增的普通函数。​

2. 第二同:参数列表完全相同(重点:类型一致,非名字 / 缺省值一致)​

这是最易混淆的规则,需明确:“参数列表相同” 的核心是形参的 “类型及顺序” 完全一致,与形参的 “名字” 和 “缺省值” 无关,具体细节如下:​

  • 允许不同的情况:​
  • 形参名字不同:基类virtual void showInfo(int age),派生类virtual void showInfo(int userAge)—— 形参名字age与userAge不同,但类型均为int,不影响重写;​
  • 缺省值不同:基类virtual void calcPrice(double discount = 0.9),派生类virtual void calcPrice(double discount = 0.75)—— 缺省值不同,但类型均为double,仍构成重写(但实际调用时缺省值按 “静态类型” 取,需谨慎使用)。​
  • 不允许不同的情况(类型或顺序差异):​
  • 形参类型不同:基类virtual void setScore(double score),派生类virtual void setScore(int score)—— 前者double、后者int,类型不匹配,不构成重写;​
  • 形参数量不同:基类virtual void print(int a),派生类virtual void print(int a, int b)—— 参数数量差异,不构成重写;​
  • 形参顺序不同:基类virtual void func(int a, double b),派生类virtual void func(double a, int b)—— 类型顺序差异,不构成重写。​
  • 错误示例警示:​

class Base {
public:
virtual void add(int x, double y) {} // 基类虚函数:int + double
};
class Derived : public Base {
public:
// 错误1:形参类型顺序颠倒,不构成重写
virtual void add(double x, int y) {}
// 错误2:形参类型改为int+int,不构成重写
virtual void add(int x, int y) {}
// 正确:形参类型及顺序与基类一致,仅名字不同,构成重写
virtual void add(int a, double b) {}
};

3. 第三同:返回值类型完全相同(特殊:协变场景除外)​

  • 基本规则:派生类虚函数的返回值类型必须与基类虚函数完全一致,如基类返回void,派生类也需返回void;基类返回int,派生类也需返回int。​
  • 特殊场景:协变(父子类指针 / 引用返回)​

若基类虚函数返回 “基类指针 / 引用”,派生类虚函数返回 “派生类指针 / 引用”,且二者为继承关系(如基类Person*,派生类Student*),则视为返回值类型兼容,仍构成重写(这是 “三同” 的唯一例外,称为协变)。​

  • 示例:​

class Person {};
class Student : public Person {};
class Base {
public:
// 基类虚函数返回基类指针
virtual Person* createPerson() { return new Person(); }
};
class Derived : public Base {
public:
// 派生类虚函数返回派生类指针,构成协变,属于合法重写
virtual Student* createPerson() override { return new Student(); }
};

  • 注意:协变仅支持 “指针或引用”,不支持值类型 —— 若基类返回Person(值类型),派生类返回Student(值类型),则不构成重写(会触发切片,且编译报错)。​

三、重写的前提:基类函数必须是虚函数​

除 “三同” 外,还有一个隐性前提:基类的函数必须被virtual关键字修饰(即虚函数)。若基类函数不是虚函数,即便派生类函数与基类满足 “三同”,也只是 “隐藏”(hide)基类函数,而非 “重写”—— 调用时会根据指针 / 引用的 “静态类型” 绑定,无法触发多态。​

  • 反例(无重写,仅隐藏):​

class Base {
public:
// 基类函数无virtual,不是虚函数
void show() { cout << "Base show" << endl; }
};
class Derived : public Base {
public:
// 虽满足“三同”,但基类函数非虚函数,仅隐藏基类show
void show() { cout << "Derived show" << endl; }
};
// 调用时:Base* p = new Derived(); p->show();
// 结果:输出“Base show”(静态绑定,无多态)

四、重写的验证:借助 C++11 的override关键字​

为避免因 “三同” 规则不满足导致的隐性重写失败,建议在派生类重写函数后加override关键字 —— 它会让编译器强制检查 “是否满足重写条件”:若不满足(如参数类型不匹配、基类函数非虚函数),编译器会直接报错,帮助开发者提前发现问题。​

  • 示例(正确使用override):​

class Person {
public:
virtual void buyTicket(double price) { cout << "全价:" << price << endl; }
};
class Student : public Person {
public:
// 加override,编译器检查重写条件
virtual void buyTicket(double price) override {
cout << "75折:" << price * 0.75 << endl;
}
// 错误示例:参数类型改为int,编译器报错(不满足三同)
// virtual void buyTicket(int price) override { … }
};

五、常见易错点总结​

  • 混淆 “形参名字” 与 “形参类型”:误以为形参名字不同就不构成重写 —— 实际只需类型一致;​
  • 忽略 “形参顺序”:误以为参数类型相同即可,顺序不同也没关系 —— 顺序属于参数列表的一部分,必须一致;​
  • 基类函数忘加virtual:误以为 “三同” 就够,忽略基类函数需是虚函数 —— 无virtual则无重写;​
  • 协变误用:误以为值类型也支持协变 —— 仅指针 / 引用支持,值类型不支持。​
  • 示例代码:

    OK大家,了解完了上面,我们就可以看看示例代码进行进一步了解了:

    class person
    {
    public:
    virtual void buyticket(int ticketprice = 80)
    {
    cout << "是普通人,原价买票" << endl;
    cout << "票价=" << ticketprice << endl;
    }
    protected:
    int mnum = 0;

    };
    class student :public person//再次强调,多态使用的前提是继承
    {
    public:
    //要和父类所写的虚函数构成重写函数
    //三同:返回类型相同,函数名字,形参类型相同
    virtual void buyticket(int ticketprice = 80)
    {
    cout << "是学生,85折买票" << endl;
    cout << "票价=" << ticketprice*0.85 << endl;
    }
    protected:
    int mid = 0;
    };
    //调用虚函数的函数
    //注意函数形参类型要是父类类型的指针或者引用
    void printzhizhen(person* ptr)
    {
    ptr->buyticket();
    //如果不构成多态的话,那么这里就相当于是运行print函数形参类型里面的函数
    //而要是构成多态的话,那么编译器会忽略print函数形参类型里面的函数
    //而是去看我们所传入的对象是哪个类的
    //然后去运行对应的对象的类的函数
    }

    void printyinyong(person& ptr)
    {
    //这里可以看到虽然都是Person指针Ptr在调用BuyTicket
    //但是跟ptr没关系,而是由ptr指向的对象决定的。
    ptr.buyticket();
    //如果不构成多态的话,那么这里就相当于是运行print函数形参类型里面的函数
    //而要是构成多态的话,那么编译器会忽略print函数形参类型里面的函数
    //而是去看我们所传入的对象是哪个类的
    //然后去运行对应的对象的类的函数
    }

    int main()
    {
    person p;
    student s;
    printzhizhen(&p);//传地址
    printzhizhen(&s);//传地址

    cout<<endl;

    printyinyong(p);//直接传对象就行
    printyinyong(s);//直接传对象就行
    return 0;
    }

    一个小点:

    在重写基类虚函数时需注意,即便派生类的虚函数不加virtual关键字,也可构成重写 —— 这是因为派生类会继承基类虚函数的属性,即便不显式添加virtual,该函数在派生类中依旧保持虚函数特性,只要满足 “三同” 条件(返回值类型、函数名字、参数列表完全相同),就能完成对基类虚函数的重写。

    但这种省略virtual的写法并不规范,会降低代码可读性(后续维护者可能误判函数是否为虚函数),因此不建议在实际开发中使用;不过在考试选择题中,这类场景经常被作为考点,用于考查对重写规则的理解,判断省略virtual后是否仍能构成重写、是否支持多态。

    下面是示例代码:

    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:
    void talk() const
    {
    //比如我在Cat这个子类不加virtual,但是由于这个函数也是达到三同,
    //所以也构成重写以及虚函数
    //(因为继承后基类的虚函数被继承下来,在派生类中依旧保持虚函数属性)
    std::cout << "(>^ω^<)喵" << std::endl;
    }
    };

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

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

    大家仔细看代码里的注释哦,会很有帮助。

    多态场景的一个选择题:

    OK大家,了解完了之后,我们来看看一道选择题,这道选择题可是非常有意思,非常恶心人,我们先看代码:

    题目:

    class A
    {
    public:
    virtual void func(int val = 1)
    {
    std::cout << "A->" << val << std::endl;
    }
    virtual void test() //里面参数是有着this指针的,不要忘记了
    {
    func();
    }
    };
    class B : public A
    {
    public:
    void func(int val = 0)
    {
    std::cout << "B->" << val << std::endl;
    }
    };

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

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

    答案及解析:

    哈哈大家,我相信很多人都会选择D,可惜答案是B,那么是为什么呢,我们来看解析:

    这一现象的本质是 “虚函数的多态性仅作用于函数体,缺省值属于函数声明的静态属性,不参与动态绑定”,具体可从三个关键知识点展开:​

    1. 第一步:多态的实现依赖 “函数体的动态绑定”​

    当调用虚函数func时,多态的核心是 “根据指针 / 对象的动态类型(即实际指向的对象类型),找到对应的函数体执行”:​

    • 在代码中,指针p的静态类型是B*(编译时已知),动态类型也是B*(指向B类对象);​
    • 由于func是虚函数且已重写,程序会在运行时通过p的虚表指针(__vfptr),找到B类虚表中func对应的地址 —— 即B::func的函数体,因此最终执行的是cout << "B->" << val << endl;(体现 “子类函数体”)。​

    这里需要明确:虚函数的动态绑定只负责匹配 “函数体”,不涉及 “函数声明中的缺省值”—— 缺省值的确定不依赖虚表,而是由编译时的静态类型决定。​

    2. 第二步:缺省值的确定依赖 “函数声明的静态绑定”​

    C++ 标准规定:函数的缺省参数属于 “函数声明的一部分”,其值由调用者的 “静态类型”(编译时已知的类型)决定,而非动态类型。具体逻辑如下:​

    • 当调用p->func();时,未显式传参,编译器需要确定 “使用哪个缺省值”;​
    • 编译器在编译阶段,只能获取指针p的静态类型(B*,或若为基类指针则是A*),无法预知其动态类型(运行时才确定);​
    • 对于虚函数的缺省值,编译器会 “回溯到基类的虚函数声明”—— 因为派生类的func是重写基类的虚函数,其函数声明(包括参数列表、缺省值)本质是 “继承并兼容基类”,缺省值作为函数声明的静态属性,会被编译器绑定到基类的声明上(即A::func的val=1);​
    • 因此,编译器会将p->func();解析为p->func(1);(传入基类的缺省值1),再通过动态绑定找到B::func的函数体,最终输出B->1。​

    3. 第三步:this 指针的作用 —— 确保函数体与对象匹配​

     “test函数里面其实有个this指针的形参”,这一点是理解 “函数体归属” 的关键:​

    • 每个非静态成员函数(包括虚函数)都隐含一个this指针形参,其类型为 “当前类的指针”(如A::func的this是A*,B::func的this是B*);​
    • 当调用p->func();时,编译器会自动将p的地址作为this指针传入函数体 —— 由于p指向B类对象,this指针的动态类型是B*,因此函数体中访问的是B类的成员(此处无额外成员,但函数体本身是B类的实现);​
    • 这一步确保了 “即便使用基类的缺省值,执行的仍是派生类对象对应的函数体”,即 “多态的条件完美满足”——this指针是连接 “静态缺省值” 与 “动态函数体” 的桥梁。​

    C++ 标准的明确规定:为什么要这样设计?​

    这一现象并非编译器漏洞,而是 C++ 标准的刻意设计,核心原因是 “保证缺省值的静态确定性,避免运行时歧义”:​

    • 若允许缺省值随动态类型变化,编译器在编译阶段无法确定缺省值(需等到运行时才知道对象类型),会导致 “函数调用的参数个数 / 值” 无法在编译时校验,增加程序错误风险;​
    • 例如:若基类func缺省值为int类型,派生类func缺省值为double类型,编译时无法确定参数类型,会导致函数签名不匹配,引发调用错误;​
    • 因此,标准规定 “虚函数的缺省值绑定到基类声明”,既保证了编译时的参数确定性,又通过动态绑定保留了函数体的多态性 —— 代价是 “子类缺省值被忽略”,这也是开发者需要规避的坑。​

    关键结论与避坑建议​

  • 核心结论:多态调用虚函数时,遵循 “函数体动态绑定(按对象实际类型),缺省值静态绑定(按基类声明)” 的规则,即 “子类函数体 + 父类缺省值” 是标准行为;​
  • 避坑建议:​
    • 重写虚函数时,务必让派生类与基类的缺省值保持一致(如均设为val=1),避免因缺省值差异导致逻辑错误;​
    • 若需不同的参数默认值,建议通过 “显式传参” 实现(如p->func(0);),而非依赖缺省值 —— 显式传参的优先级高于缺省值,可覆盖静态绑定的基类缺省值;​
    • 尽量避免在虚函数中使用缺省值,若必须使用,通过注释明确 “缺省值以基类为准”,提醒后续维护者。​

    扩展验证:基类指针指向派生类对象,结果是否一致?​

    将main函数中的指针改为 “基类指针指向派生类对象”(更典型的多态场景),代码如下:​

    int main() {
    A* p = new B(); // 基类指针p,动态类型是B*
    p->func(); // 调用虚函数
    delete p;
    return 0;
    }

    运行结果仍为B->1—— 因为:​

    • 函数体动态绑定:p的动态类型是B*,执行B::func的函数体;​
    • 缺省值静态绑定:p的静态类型是A*,编译器仍使用A::func的缺省值val=1;​
    • 这进一步验证了 “缺省值与静态类型绑定” 的规则,与指针本身是基类还是派生类类型无关。​

    我给大家一个大白话版本的:

    我们创建的是B类类型的指针变量,然后func这个函数是构成重写的,也就是多态,所以调用p的test函数,也就是相当于调用子类B中的test函数,而tset函数里面其实是有个this指针的形参的,虽然明面上看起来什么都没有,所以就完美的达到了多态的条件,又因为p是B类型的对象,所以就会调用B类里面的func函数,所以就有B->这个表达出来,但是呢,却不是用子类B中func函数的缺省值val = 0,而是用父类A中的func函数的缺省值val = 1,其实这是C++的一个规定,就是多态时,是用父类虚函数的声明,即形参,加上子类的相同虚函数的函数体,所以上面就是用子类B的func函数的函数体,然后加上父类A的func函数的形参,所以最后结果是B->1

    大家可得理解好了,这题之前腾讯笔试,面试中也都有所提及。

    虚函数重写的一些其他问题:

    协变:

    协变是 C++ 虚函数重写规则中的一种特殊例外情况,它打破了 “返回值类型必须完全一致” 的常规要求,但有严格的适用范围和约束条件,由于实际编程中应用场景较少,通常作为了解内容即可。以下从 “协变的核心定义→适用条件拆解→底层逻辑→示例验证→实际意义” 五个层面展开详细分析:​

    一、协变的核心定义:重写规则的 “特殊例外”​

    在虚函数重写的常规规则中,派生类虚函数必须与基类虚函数满足 “三同”(函数名、参数列表、返回值类型完全一致),而协变是唯一允许返回值类型不同的特殊情况,其核心定义为:​

    若基类虚函数返回的是 “基类对象的指针或引用”,则派生类在重写该虚函数时,可返回 “派生类对象的指针或引用”(且派生类需是基类的直接 / 间接子类,符合继承关系),这种满足特定条件的重写场景,称为协变。​

    简单来说,协变的本质是 “返回值类型的父子兼容替代”—— 用派生类的指针 / 引用,替代基类的指针 / 引用作为返回值,且不破坏虚函数重写的多态特性。​

    二、协变的严格适用条件:缺一不可​

    协变并非 “任意返回值不同都允许”,而是有三个必须同时满足的硬性条件,一旦违背则不构成重写,编译器会直接报错:​

    1. 条件一:基类与派生类的返回值类型,必须是 “指针或引用”​

    这是协变的核心前提 ——返回值类型不能是值类型,只能是指针或引用。​

    • 允许的情况:基类返回Base*(基类指针),派生类返回Derived*(派生类指针);基类返回Base&(基类引用),派生类返回Derived&(派生类引用);​
    • 禁止的情况:基类返回Base(基类值类型),派生类返回Derived(派生类值类型)—— 值类型返回时会触发 “切片”(派生类对象赋值给基类时丢失子类成员),且值类型的内存大小、布局可能不同,无法安全兼容,因此编译器禁止。​

    2. 条件二:返回值的指针 / 引用类型,必须符合 “继承关系”​

    派生类虚函数返回的 “派生类指针 / 引用”,其指向的类(即Derived)必须是基类虚函数返回的 “基类指针 / 引用” 指向的类(即Base)的直接或间接子类,形成严格的父子继承关系。​

    • 允许的情况:基类返回Person*(基类Person),派生类返回Student*(派生类Student,继承Person);​
    • 禁止的情况:基类返回Person*,派生类返回Car*(Car与Person无继承关系)—— 无继承关系的指针 / 引用无法兼容,不满足协变要求,编译器报错。​

    3. 条件三:返回的指针 / 引用类型,必须是 “完整类型”​

    协变要求返回的指针 / 引用所指向的类(Base和Derived)必须是 “完整类型”—— 即类已提前声明并定义(至少包含类体),不能是 “不完全类型”(仅声明未定义)。​

    • 禁止的情况:​

    class Person; // 仅声明,未定义,属于“不完全类型”
    class Student : public Person {}; // 虽继承,但Person未定义

    class Base {
    public:
    // 错误:返回不完全类型Person的指针,无法构成协变
    virtual Person* create() {}
    };

    原因:编译器需要知道类的内存布局(如成员大小、虚表位置),才能生成正确的虚函数调用逻辑,不完全类型无法提供这些信息,因此禁止作为协变的返回值类型。​

    三、协变的底层合理性:为什么允许 “指针 / 引用替代”?​

    协变之所以被 C++ 标准允许,核心原因是 “父子类指针 / 引用的内存兼容性”—— 无论基类还是派生类的指针,在同一平台下的内存大小完全一致(如 32 位系统 4 字节,64 位系统 8 字节);引用的底层实现本质是指针,因此也具备相同的内存兼容性,具体逻辑如下:​

    1. 指针的内存兼容性​

    以 64 位系统为例,Base*(基类指针)和Derived*(派生类指针)的大小均为 8 字节,存储的都是内存地址。当派生类虚函数返回Derived*时,该地址可直接隐式转换为Base*(符合继承的类型兼容性规则),调用者接收时无需额外处理内存格式,不会出现数据错误。​

    例如:​

    class Base {};
    class Derived : public Base {};
    Base* func() {
    Derived* d = new Derived();
    return d; // Derived*隐式转换为Base*,地址值不变,内存安全
    }

    2. 引用的内存兼容性​

    引用是 “变量的别名”,底层通过指针实现(占用与指针相同的内存空间)。当基类虚函数返回Base&,派生类返回Derived&时,本质是返回 “派生类对象的地址别名”,该地址可安全转换为基类引用的底层指针,调用者访问时能通过引用的动态类型(实际对象类型)正确操作成员,不破坏多态。​

    四、协变的示例验证:正确与错误场景对比​

    通过具体代码示例,直观理解协变的正确用法和错误场景:​

    1. 正确示例 1:指针类型的协变(最常见场景)​

    #include <iostream>
    using namespace std;

    // 基类:Animal
    class Animal {
    public:
    virtual void eat() { cout << "Animal eat" << endl; }
    // 基类虚函数:返回Animal*(基类指针)
    virtual Animal* createAnimal() {
    cout << "Create Animal" << endl;
    return new Animal();
    }
    virtual ~Animal() {} // 析构函数设为虚函数,确保多态释放
    };

    // 派生类:Cat(继承Animal)
    class Cat : public Animal {
    public:
    void eat() override { cout << "Cat eat fish" << endl; }
    // 派生类虚函数:返回Cat*(派生类指针),符合协变条件
    Cat* createAnimal() override {
    cout << "Create Cat" << endl;
    return new Cat();
    }
    };

    int main() {
    Animal* animalPtr = new Cat(); // 基类指针指向派生类对象
    // 多态调用createAnimal:执行Cat::createAnimal,返回Cat*(隐式转为Animal*)
    Animal* newAnimal = animalPtr->createAnimal();
    newAnimal->eat(); // 多态调用:输出“Cat eat fish”

    delete newAnimal;
    delete animalPtr;
    return 0;
    }

    运行结果:​

    Create Cat​

    Cat eat fish​

    • 分析:animalPtr的动态类型是Cat*,调用createAnimal时执行Cat的重写实现(体现多态),返回Cat*并隐式转为Animal*,满足协变规则,逻辑正确。​

    2. 正确示例 2:引用类型的协变​

    class Base {
    public:
    virtual Base& getSelf() {
    cout << "Base self" << endl;
    return *this;
    }
    };

    class Derived : public Base {
    public:
    Derived& getSelf() override {
    cout << "Derived self" << endl;
    return *this;
    }
    };

    int main() {
    Derived d;
    Base& baseRef = d; // 基类引用绑定派生类对象
    baseRef.getSelf(); // 多态调用:输出“Derived self”
    return 0;
    }

    运行结果:Derived self​

    • 分析:baseRef的动态类型是Derived&,调用getSelf时执行Derived的重写实现,返回Derived&并隐式转为Base&,符合协变规则。​

    3. 错误示例:返回值为值类型,不满足协变​

    class Base {};
    class Derived : public Base {};

    class A {
    public:
    // 基类虚函数:返回Base(值类型)
    virtual Base func() { return Base(); }
    };

    class B : public A {
    public:
    // 错误:返回值为Derived(值类型),不满足协变条件,编译器报错
    virtual Derived func() override { return Derived(); }
    };

    • 编译器报错原因:返回值为值类型,无法构成协变,违背重写的 “三同” 规则(返回值类型不同且非协变允许的指针 / 引用)。​

    五、协变的实际意义:应用场景少,仅作了解​

    虽然协变是 C++ 标准支持的重写特殊情况,但在实际编程中应用场景极少,核心原因有两点:​

  • 需求场景有限:大部分多态场景中,虚函数的返回值无需 “父子指针 / 引用替代”—— 例如 “买票”“发声” 等行为,返回值多为void或基础类型(如int),无需返回类对象的指针 / 引用;​
  • 可被其他方式替代:即便需要返回类对象的指针 / 引用,也可通过 “基类指针返回派生类对象”(常规重写,不依赖协变)实现,例如基类虚函数返回Base*,派生类重写时直接返回new Derived()(Derived*隐式转为Base*),无需专门使用协变;​
  • 因此,协变更多是 C++ 语法的 “完整性补充”,而非高频实用特性,掌握其定义和规则即可,无需深入应用。​

    六、关键结论:协变的核心要点总结​

  • 协变是虚函数重写的特殊例外,仅允许返回值类型不同,且必须是 “基类指针 / 引用→派生类指针 / 引用”;​
  • 协变的三个硬性条件:返回值为指针 / 引用、符合继承关系、类型为完整类型;​
  • 底层依赖父子类指针 / 引用的内存兼容性,确保调用安全;​
  • 实际应用场景少,仅作了解,无需优先使用。
  • 大家仅作了解,不必太在乎。

    析构函数的重写:

    在 C++ 继承体系中,析构函数的虚函数处理是一个特殊且关键的知识点,直接关系到内存安全,也是面试高频考点。以下从 “析构函数重写的特殊性→内存泄漏风险→底层原理→面试考点” 四个层面展开详细分析:

    一、虚析构函数的核心规则:析构函数重写的 “特殊情况”

    通常情况下,虚函数重写要求 “函数名、参数列表、返回值类型完全一致”(协变除外),但析构函数的重写是唯一例外 ——当基类的析构函数被virtual关键字修饰时,派生类的析构函数只要定义(无论是否加virtual),都会与基类析构函数构成重写,具体规则如下:

    1. 析构函数名称的 “编译器特殊处理”

    表面上看,基类析构函数名为~A(),派生类析构函数名为~B(),二者名字不同,似乎不符合重写的 “函数名一致” 要求。但实际上,编译器会对析构函数的名称做统一的底层处理:编译后所有类的析构函数名称都会被替换为destructor(不同编译器底层命名可能略有差异,如_Zdtor1A,但核心是 “统一标识”)。

    这意味着:基类A的虚析构函数(virtual ~A())编译后为 “虚函数destructor”,派生类B的析构函数(~B())编译后也为 “函数destructor”,二者满足 “函数名一致 + 基类函数为虚函数” 的重写前提,因此派生类析构函数会自动重写基类的虚析构函数 —— 即便派生类析构函数不加virtual,也会因 “继承基类虚函数属性” 和 “名称统一处理”,构成合法重写。

    所以这也也是为什么我们上篇博客讲继承的时候说,想要在子类的析构函数调用父类的析构函数时,必须要指定为父类的类域,这样子才行,虽然说并不需要我们在子类的析构函数中调用父类的析构函数。

    2. 派生类析构函数的virtual可省略(但建议显式添加)

    派生类析构函数是否加virtual不影响重写效果:

    • 加virtual:明确标注为虚函数,代码可读性更高,后续若有多层继承(如C继承B),C的析构函数可继续重写;
    • 不加virtual:因继承基类虚析构函数的属性,仍会被视为虚函数,与基类构成重写。

    但从代码规范角度,建议在派生类析构函数前显式添加virtual,避免后续维护者误判其是否为虚函数。

    二、为什么需要虚析构函数?—— 避免内存泄漏的核心场景

    虚析构函数的核心作用 ——当用基类指针指向派生类对象,并通过delete释放时,确保派生类的析构函数被调用,避免派生类中申请的资源未释放。

    1. 非虚析构函数的内存泄漏风险(反例分析)

    若基类A的析构函数不加virtual(即非虚析构函数),代码如下:

    class A {
    public:
    ~A() { cout << "~A()" << endl; } // 非虚析构函数
    };
    class B : public A {
    public:
    ~B() {
    cout << "~B()->delete:" << _p << endl;
    delete _p; // 释放派生类申请的int数组资源
    }
    protected:
    int* _p = new int[10]; // 派生类在堆上申请的资源
    };

    int main() {
    A* p2 = new B(); // 基类指针指向派生类对象
    delete p2; // 释放对象
    return 0;
    }

    运行结果:仅输出~A(),未输出~B()->delete:…

    • 问题分析:
  • 由于基类析构函数非虚函数,delete p2时会触发 “静态绑定”—— 编译器根据指针p2的静态类型(A*),直接调用基类A的析构函数;对于这一点,大家可能不太理解,但是由于对这一部分的解释还需要后面的知识,所以我会放在下一篇博客中进行解释,大家看到这里有不懂的可以直接跳到下一篇博客中进行查看。
  • 派生类B的析构函数未被调用,导致B中new int[10]申请的堆内存(_p指向的空间)未被释放,造成内存泄漏;
  • 若_p指向的资源是文件句柄、网络连接等,还会导致资源泄漏,引发程序异常。
  • 2. 虚析构函数的正确释放(代码示例分析)

    当基类A的析构函数加virtual后(即virtual ~A()),代码运行结果如下:

    // 运行结果:
    ~B()->delete:0x00000123456789ab // 先调用派生类B的析构函数,释放_p资源
    ~A() // 再调用基类A的析构函数,释放基类资源

    • 正确释放流程:
  • delete p2时,因基类析构函数是虚函数,触发 “动态绑定”—— 程序根据p2的动态类型(B*,即实际指向的B类对象),先调用派生类B的析构函数;
  • 派生类B的析构函数执行:释放_p指向的堆内存(delete _p),完成派生类资源清理;
  • 派生类析构函数执行完毕后,自动调用基类A的析构函数(继承体系的析构函数调用规则:先派生后基类),完成基类资源清理;
  • 整个过程无内存泄漏,资源释放完整。
  • 3. 关键结论:虚析构函数的适用场景

    只有当 “用基类指针 / 引用指向派生类对象,并通过该指针 / 引用释放对象” 时,才需要将基类析构函数设为虚函数。若不存在 “基类指针指向派生类对象” 的场景(如仅用派生类指针B* p = new B();),则基类析构函数可设为非虚函数 —— 但从代码扩展性角度,只要类可能被继承,建议默认将析构函数设为虚函数,避免后续使用时出现内存泄漏。

    三、底层原理:虚析构函数如何实现 “动态释放”?

    虚析构函数的动态绑定机制与普通虚函数一致,依赖 “虚表指针 + 虚表” 的底层结构:

    1. 基类与派生类的虚表变化

    • 基类A(含虚析构函数):
    • 编译器为A生成虚表,表中存储A::destructor(即~A())的地址;
    • A类对象包含虚表指针__vfptr,指向A的虚表。
    • 派生类B(继承A,含析构函数):
    • 编译器为B生成独立虚表,表中原本继承A的A::destructor地址,会被B::destructor(即~B())的地址覆盖(重写效果);
    • B类对象的虚表指针__vfptr指向B的虚表,且对象内存布局中包含基类A的成员(若有)和自身成员_p。

    2. delete操作的底层执行流程

    当执行delete p2(p2为A*,指向B对象)时,底层流程如下:

  • 读取p2指向对象的虚表指针__vfptr,找到B的虚表;
  • 从B的虚表中取出B::destructor的地址,调用B的析构函数,释放_p资源;
  • 派生类析构函数执行完毕后,自动调用基类A的析构函数(编译器在B::destructor中插入调用A::destructor的代码);
  • 释放p2指向的堆内存(对象本身的内存)。
  • 若基类析构函数非虚函数,则步骤 1-2 会跳过虚表查找,直接调用A::destructor,导致派生类资源未释放。

    四、面试考点拆解:如何清晰讲解虚析构函数?

    虚析构函数是 C++ 面试的高频考点,面试官通常会要求 “结合代码示例讲解为什么需要虚析构函数”,可按以下逻辑梳理回答:

    1. 核心问题:基类析构函数不加virtual的风险

    • 用提供的代码示例说明:当基类析构函数非虚函数时,A* p2 = new B(); delete p2;仅调用~A(),B中new int[10]的资源未释放,导致内存泄漏;
    • 强调:若派生类有堆资源(如指针、容器等),非虚析构函数会导致资源泄漏,这是程序稳定性的重大隐患。

    2. 解决方案:基类析构函数加virtual

    • 解释析构函数重写的特殊性:编译器统一处理析构函数名称为destructor,派生类析构函数自动重写基类虚析构函数;
    • 展示代码运行结果:加virtual后,delete p2先调用~B()释放资源,再调用~A(),无内存泄漏。

    3. 扩展注意事项

    • 若类不被继承,析构函数可设为非虚函数(减少虚表开销);
    • 若类是抽象基类(含纯虚函数),析构函数必须设为虚函数(否则无法实例化,且派生类释放会有风险);
    • 多层继承场景(如C继承B,B继承A),只需基类A的析构函数加virtual,B和C的析构函数会自动继承虚属性并完成重写。

    五、代码验证:完整可运行示例

    将代码补充完整,可直接编译运行,验证虚析构函数的效果:

    #include <iostream>
    using namespace std;

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

    class B : public A {
    public:
    ~B() { // 派生类析构函数,自动重写基类虚析构函数
    cout << "~B()->delete:" << _p << endl;
    delete _p; // 释放派生类申请的堆资源
    }
    protected:
    int* _p = new int[10]; // 派生类在堆上申请10个int的空间
    };

    int main() {
    A* p1 = new A; // 基类指针指向基类对象
    A* p2 = new B; // 基类指针指向派生类对象

    delete p1; // 调用A的析构函数,输出“~A()”
    delete p2; // 先调用B的析构函数(释放_p),再调用A的析构函数,输出“~B()->delete:… ”和“~A()”

    return 0;
    }

    运行结果(示例,_p地址为随机值):

    ~A()
    ~B()->delete:0x55f8d7a7c2c0
    ~A()

    六、关键结论:虚析构函数的核心要点

  • 基类析构函数加virtual后,派生类析构函数自动重写(编译器统一处理析构函数名称);
  • 核心作用:用基类指针释放派生类对象时,确保派生类析构函数被调用,避免内存 / 资源泄漏;
  • 适用场景:类有继承关系,且可能用基类指针指向派生类对象;
  • 面试必背:结合 “非虚析构函数导致内存泄漏” 的代码示例,讲解虚析构函数的必要性和底层机制。
  • 这个也是一个比较高频的考点,希望大家注意。

    override和final关键字:

    在 C++ 虚函数重写场景中,由于重写规则(如函数名、参数列表、返回值类型需严格匹配)较为严格,实际开发中容易因疏忽(如函数名拼写错误、参数类型写错、漏写virtual)导致 “看似重写,实则未重写” 的隐性错误 —— 这类错误编译期不会报错,但运行时会因多态失效出现非预期结果,调试成本极高。为此,C++11 引入override和final两个关键字,分别解决 “重写有效性检测” 和 “禁止重写” 的需求,具体细节如下:

    一、先明确核心痛点:虚函数重写的隐性错误隐患

    结合之前虚函数重写的严格规则(三同 + 协变例外),实际开发中常见的重写疏忽场景包括:

  • 函数名拼写错误:基类虚函数为virtual void buyTicket(),派生类误写为virtual void buyTcket()(少写字母i);
  • 参数列表不匹配:基类虚函数为virtual void calc(double price),派生类误写为virtual void calc(int price)(参数类型从double改为int);
  • 返回值类型错误:基类虚函数为virtual int getValue(),派生类误写为virtual double getValue()(返回值从int改为double,且非协变场景);
  • 基类函数非虚函数:派生类想重写基类函数,但基类函数未加virtual,派生类即便满足 “三同”,也只是函数隐藏而非重写。
  • 这些错误的共性是:编译期编译器会将其判定为 “派生类新增函数”,无报错提示,但运行时调用该函数时,多态失效(仍执行基类逻辑),例如基类指针指向派生类对象调用 “未成功重写的函数”,会执行基类实现,与预期不符。

    二、override 关键字:解决 “重写有效性检测” 问题

    override关键字的核心作用是 “显式声明派生类函数意图重写基类虚函数,并让编译器强制校验重写条件”—— 若不满足重写条件,编译器直接报错,将隐性错误提前到编译期暴露,避免运行时调试。

    1. override 的核心功能:编译期强制校验重写条件

    当派生类成员函数后加override时,编译器会自动检查以下 4 个重写条件,只要有一个不满足,就会编译报错:

    • 条件 1:基类中存在与派生类函数 “函数名、参数列表、返回值类型完全匹配” 的虚函数(协变场景除外);
    • 条件 2:基类中的该函数必须被virtual修饰(即虚函数);
    • 条件 3:派生类函数的参数列表与基类虚函数完全一致(参数类型、数量、顺序均需匹配,与参数名无关);
    • 条件 4:派生类函数的返回值类型与基类虚函数一致(或满足协变条件:基类返回基类指针 / 引用,派生类返回派生类指针 / 引用)。

    通过这四重校验,override能 100% 确保派生类函数是 “有效重写”,而非 “新增函数” 或 “错误隐藏”。

    2. override 的语法细节:位置与使用规范

    • 语法位置:必须加在派生类成员函数的 “参数列表后面”,const(若函数为 const 成员函数)之后,函数体之前;
    • 正确写法:virtual void buyTicket() override { … }、virtual int getValue() const override { … };
    • 错误写法:override virtual void buyTicket() { … }(位置错误,override不能在virtual前)、virtual void buyTicket(override) { … }(加在参数列表内,语法错误)。
    • virtual的可省略性:派生类函数加override后,virtual关键字可省略(因override已暗示该函数是重写的虚函数,继承基类的虚属性),但为可读性建议保留;
    • 示例:void buyTicket() override { … }(合法,virtual省略)、virtual void buyTicket() override { … }(更规范,明确虚函数属性)。

    3. override 的实际代码示例:正确与错误场景对比

    (1)正确场景:有效重写,编译通过

    class Person { // 基类
    public:
    virtual void buyTicket(double price) { // 基类虚函数
    cout << "普通人买票:" << price << "元" << endl;
    }
    };

    class Student : public Person { // 派生类
    public:
    // 加override,编译器校验重写条件(函数名、参数、返回值均匹配基类虚函数)
    virtual void buyTicket(double price) override {
    cout << "学生买票:" << price * 0.75 << "元(75折)" << endl;
    }
    };

    int main() {
    Person* p = new Student();
    p->buyTicket(100); // 多态生效,输出“学生买票:75元(75折)”
    delete p;
    return 0;
    }

    • 编译结果:无报错,运行时多态正常生效。

    (2)错误场景 1:函数名拼写错误,编译报错

    class Person {
    public:
    virtual void buyTicket(double price) { … }
    };

    class Student : public Person {
    public:
    // 错误:函数名误写为buyTcket(少写i),加override后编译器检测到无匹配基类虚函数,报错
    virtual void buyTcket(double price) override { … }
    };

    • 编译器报错信息(以 GCC 为例):error: ‘virtual void Student::buyTcket(double)’ marked ‘override’, but does not override any member function(函数加了override,但未重写任何基类成员函数)。

    (3)错误场景 2:参数类型不匹配,编译报错

    class Person {
    public:
    virtual void calc(double price) { … } // 基类参数为double
    };

    class Student : public Person {
    public:
    // 错误:参数类型改为int,与基类虚函数不匹配,加override后编译器报错
    virtual void calc(int price) override { … }
    };

    • 编译器报错信息:error: ‘virtual void Student::calc(int)’ marked ‘override’, but does not override any member function(无匹配的基类虚函数)。

    三、final 关键字:解决 “禁止虚函数重写” 问题

    final关键字的核心作用是 “显式禁止基类虚函数被后续派生类重写”—— 当基类虚函数加final后,任何派生类若试图重写该函数,编译器会直接报错,适用于 “基类虚函数逻辑固定,不允许子类修改” 的场景(如基础工具类的核心函数)。

    1. final 的核心功能:强制禁止重写

    final的约束逻辑分为两种场景:

    • 场景 1:修饰基类虚函数:禁止该虚函数被任何派生类重写(包括直接派生类、间接派生类);
    • 场景 2:修饰类:禁止该类被继承(与虚函数重写无关,属于额外功能,此处重点关注虚函数修饰场景)。

    此处重点关注 “修饰虚函数” 的场景:基类虚函数加final后,派生类即便满足 “三同” 规则,也不能重写该函数,否则编译报错。

    2. final 的语法细节:位置与使用规范

    • 语法位置:与override一致,必须加在基类虚函数的 “参数列表后面”,const之后,函数体之前;
    • 正确写法:virtual void showInfo() final { … }、virtual double getDiscount() const final { … };
    • 错误写法:final virtual void showInfo() { … }(位置错误,final不能在virtual前)、virtual void showInfo(final) { … }(加在参数列表内,语法错误)。
    • virtual的必要性:final只能修饰虚函数(因只有虚函数才有 “重写” 的概念),因此基类函数必须加virtual,否则编译器报错;
    • 错误示例:void showInfo() final { … }(无virtual,非虚函数,加final无意义,编译器报错:error: ‘void Person::showInfo()’ marked ‘final’ but is not virtual)。

    3. final 的实际代码示例:正确与错误场景对比

    (1)正确场景:基类虚函数加 final,派生类不重写,编译通过

    class Base { // 基类
    public:
    // 加final,禁止该虚函数被派生类重写
    virtual void coreFunc() final {
    cout << "基类核心逻辑:不可修改" << endl;
    }
    // 未加final,允许派生类重写
    virtual void normalFunc() {
    cout << "基类普通逻辑:可修改" << endl;
    }
    };

    class Derived : public Base { // 派生类
    public:
    // 正确:不重写coreFunc(因加了final),仅重写normalFunc
    virtual void normalFunc() override {
    cout << "派生类修改后的普通逻辑" << endl;
    }
    };

    int main() {
    Base* p = new Derived();
    p->coreFunc(); // 执行基类逻辑:输出“基类核心逻辑:不可修改”
    p->normalFunc(); // 执行派生类逻辑:输出“派生类修改后的普通逻辑”
    delete p;
    return 0;
    }

    • 编译结果:无报错,coreFunc因加final无法被重写,始终执行基类逻辑;normalFunc可正常重写。

    (2)错误场景:派生类重写加 final 的虚函数,编译报错

    class Base {
    public:
    virtual void coreFunc() final { … } // 基类虚函数加final
    };

    class Derived : public Base {
    public:
    // 错误:试图重写加final的coreFunc,编译器直接报错
    virtual void coreFunc() override { … }
    };

    • 编译器报错信息(以 GCC 为例):error: virtual function ‘virtual void Derived::coreFunc()’ overriding final function ‘virtual void Base::coreFunc()’(派生类函数试图重写被final修饰的基类虚函数)。

    四、override 与 final 的关键区别与共性

    1. 核心区别:作用目标与效果

    关键字

    作用目标

    核心效果

    适用场景

    override

    派生类成员函数

    强制校验重写条件,不满足则编译报错

    确保派生类函数是有效重写,避免疏忽

    final

    基类虚函数(或类)

    禁止基类虚函数被派生类重写(或禁止类继承)

    固定基类核心虚函数逻辑,不允许修改

    2. 共性:语法位置与编译期生效

    • 语法位置一致:均需加在 “函数参数列表后面”,const之后,函数体之前;
    • 编译期生效:二者均在编译期发挥作用(override校验重写有效性,final检查是否违规重写),避免运行时错误;
    • 均不影响性能:仅在编译期提供语法校验,生成的可执行文件中不会保留这两个关键字的信息,无额外性能开销。

    五、实际开发建议:如何合理使用两个关键字

  • 优先使用 override:只要派生类意图重写基类虚函数,就必须加override—— 即便重写条件完全满足,override也能让代码可读性更高(明确告知维护者 “该函数是重写的虚函数”),同时避免后续基类虚函数修改(如参数类型变更)导致的派生类重写失效;
  • 谨慎使用 final:仅在 “基类虚函数逻辑绝对固定,后续任何派生类都不允许修改” 的场景下使用final(如框架核心类的基础接口),避免过度使用final限制类的扩展性;
  • 避免混用误区:override和final不能同时修饰同一个函数(因override用于 “确认重写”,final用于 “禁止重写”,逻辑矛盾),例如virtual void func() override final { … }是错误写法(编译器报错)。
  • 六、总结:override 与 final 的核心价值

    • override:将虚函数重写的 “隐性错误” 提前到编译期暴露,降低调试成本,同时提升代码可读性;
    • final:明确禁止基类虚函数被重写,固化核心逻辑,避免后续派生类不当修改导致的风险;
    • 二者共同解决了 C++ 虚函数重写场景中的 “有效性检测” 和 “权限控制” 问题,是 C++11 对虚函数机制的重要补充,也是实际开发中规范多态代码的必备工具。

    这个是比较简单的,大家记住使用方法就行,毕竟final在我们前面说继承的时候,也有提到。

    OK到了这里,我们可以先休息休息,剩下的内容放在下一篇博客进行讲解。

    结语:深耕多态,步履不停

    当我们终于把 “多态” 这座 C++ 面向对象的 “小山丘” 拆解到最后一个知识点,从编译时多态的函数重载与模板,到运行时多态的虚函数与重写;从 “三同” 规则的严苛细节,到协变的特殊例外;从虚析构函数的内存安全守护,到 override 与 final 的语法规范 —— 此刻再回头看,那些曾让我们反复琢磨的代码示例、纠结许久的选择题答案、恍然大悟的底层逻辑,早已在脑海中织成一张清晰的知识网。或许你还记得第一次看到 “B->1” 这个答案时的诧异,记得为 “虚函数缺省值静态绑定” 挠头的时刻,记得搞懂 “析构函数重写特殊性” 时的豁然开朗,而这些细碎的瞬间,正是我们在编程路上一步步成长的印记。

    其实学习多态的过程,就像在解锁一款复杂的拼图游戏。最开始,我们只看到 “多种形态” 这个模糊的拼图封面,不知道该从何下手;接着我们找到 “继承” 这块核心底座,明白没有继承就没有多态的立足之地;然后我们一片片拼接 “虚函数”“重写规则”“动态绑定” 这些关键碎片,每拼对一块,就离完整的图景更近一步;直到最后,我们把 “协变”“虚析构函数”“override 与 final” 这些补充碎片一一归位,才真正看清多态的全貌 —— 它不只是一个语法特性,更是 C++ 对 “现实世界多样性” 的精妙模拟,是让代码从 “能运行” 走向 “灵活且安全” 的关键桥梁。

    在这场拼图之旅中,我们难免会遇到 “卡壳” 的时刻。可能是混淆了 “重写” 与 “重载” 的概念,把同一作用域的函数同名当成了跨继承的多态;可能是忽略了 “基类指针 / 引用调用虚函数” 这个前提,写了一堆虚函数却看不到多态效果;也可能是没注意到虚析构函数的重要性,看着内存泄漏的报错却找不到问题所在。但正是这些 “卡壳”,让我们学会了更细致地阅读代码,更深入地思考底层逻辑,更耐心地验证每一个知识点。就像那次为了搞懂 “缺省值静态绑定”,我们反复调试代码,对比基类与派生类的函数声明,直到明白 “虚函数动态绑定的是函数体,缺省值归属于静态声明”—— 这种亲手拆解问题、最终找到答案的过程,比任何死记硬背都更能让知识扎根。

    而当我们真正掌握多态的核心逻辑后,会发现它给编程带来的改变是巨大的。以前写 “买票” 功能,可能需要为普通人、学生、军人分别写三个不同的函数,调用时还要反复判断对象类型;现在有了多态,只需定义一个基类虚函数,让不同派生类重写实现,用基类指针就能统一调用,代码瞬间变得简洁又灵活。以前释放派生类对象时,可能会因为忘记虚析构函数而导致内存泄漏;现在明白 “基类析构函数加 virtual” 的必要性,就能轻松守护内存安全。这种 “化繁为简”“化险为夷” 的能力,正是多态教给我们的编程智慧,也是我们从 “编程新手” 向 “合格开发者” 迈进的重要一步。

    当然,我们也要清醒地知道,这篇博客的结束,绝不是多态学习的终点。就像我们在文中提到的,虚函数表的底层实现、静态绑定与动态绑定的本质差异、抽象类与纯虚函数的应用场景,这些更深层次的知识还在前方等待我们探索。或许未来某天,当我们深入研究编译器原理时,会对 “虚表指针如何在对象初始化时赋值” 有更直观的认识;当我们面对复杂项目时,会更深刻地体会到 “多态带来的代码扩展性” 有多重要;当我们指导新人时,会能更清晰地讲解 “为什么重写必须满足三同规则”—— 学习就是这样一个 “温故知新、层层递进” 的过程,每一次回头看,都能从旧知识里发现新细节;每一次向前走,都能把新知识融入旧体系。

    在这里,我想对每一位坚持读到这里的读者说:你们真的很棒!能够耐下心来啃完 “协变的三个条件”“虚析构函数的底层原理” 这些细节满满的内容,能够在面对 “B->1” 这样的 “坑题” 时不轻易放弃,能够跟着代码示例一步步验证知识点 —— 这份对编程的热爱与执着,本身就是最珍贵的财富。或许现在你还有些知识点记得不牢,比如偶尔会忘记 “override 的语法位置”,或者对 “协变的实际应用场景” 还有些困惑,但没关系,学习本就是一个 “反复巩固、逐渐深化” 的过程。你可以把这篇博客收藏起来,在下次遇到多态问题时翻出来看看;可以试着用今天学到的知识,自己写一个 “动物发声” 的多态案例,故意漏掉 virtual 关键字,看看会出现什么问题;也可以和身边的编程伙伴一起讨论,分享彼此对 “虚函数重写” 的理解 —— 实践与交流,永远是巩固知识最好的方式。

    编程这条路,从来没有捷径可走。我们会遇到晦涩的语法,会踩过隐蔽的 bug,会在调试到深夜时感到疲惫,但正是这些经历,让我们一点点变得更强大。多态只是 C++ 庞大知识体系中的一小部分,未来我们还会遇到 STL、模板元编程、并发编程等更具挑战性的内容,但只要我们保持这份 “拆解问题、耐心钻研” 的态度,就没有攻克不了的难关。就像今天我们能把复杂的多态知识梳理清楚一样,未来面对任何知识点,我们都能一步一个脚印,慢慢啃、慢慢学,最终将其化为自己的能力。

    最后,愿我们都能在编程的世界里保持好奇与热爱,不畏惧难题,不放弃探索。每一次对知识点的深入理解,每一次代码运行成功的喜悦,每一次解决 bug 后的成长,都是我们前行路上最亮的光。深耕多态,只是我们编程旅程中的一个小站点,接下来还有更广阔的世界等着我们去探索。让我们带着今天的收获,继续步履不停,在编程的道路上走得更稳、更远,终有一天,我们都能成为自己想要成为的优秀开发者!

     

    赞(0)
    未经允许不得转载:网硕互联帮助中心 » 对于C++:多态的解析—上
    分享到: 更多 (0)

    评论 抢沙发

    评论前必须登录!