【C++】继承与多态:从语法到底层原理

继承和多态是 C++ 的灵魂,也是很多初学者的噩梦。你可能背过"父类指针指向子类对象",但你真的理解编译器背后做了什么吗? 这篇文章不仅讲怎么用,更讲为什么。 我们将从最基础的定义开始,一层层剥开 C++ 的外衣,直抵内存深处。

目录

一、继承的概念

二、继承的定义

[2.1 定义格式](#2.1 定义格式)

[2.2 继承方式与访问属性变化](#2.2 继承方式与访问属性变化)

三、继承类模板

四、基类和派生类间的转换

五、继承中的作用域

[5.1 隐藏规则](#5.1 隐藏规则)

[5.2 继承作用域相关的典型考点](#5.2 继承作用域相关的典型考点)

六、派生类的默认成员函数

[6.1 4个常见默认成员函数](#6.1 4个常见默认成员函数)

七、实现一个不能被继承的类

[7.1 C++98 写法:构造函数设为 private](#7.1 C++98 写法:构造函数设为 private)

[7.2 C++11 写法:final 关键字](#7.2 C++11 写法:final 关键字)

八、继承与友元

九、继承与静态成员

十、多继承及其菱形继承问题

[10.1 多继承与菱形继承](#10.1 多继承与菱形继承)

[10.2 虚继承](#10.2 虚继承)

[10.3 多继承中的指针偏移问题](#10.3 多继承中的指针偏移问题)

[10.4 IO 库中的菱形虚继承](#10.4 IO 库中的菱形虚继承)

十一、继承和组合

[11.1 继承和组合](#11.1 继承和组合)

[11.2 继承与组合的例子](#11.2 继承与组合的例子)

十二、多态的概念

十三、多态的构成条件

十四、虚函数与重写

[14.1 虚函数](#14.1 虚函数)

[14.2 虚函数的重写](#14.2 虚函数的重写)

十五、默认参数与虚函数

十六、协变

十七、虚析构函数与多态销毁

[十八、override 和 final 关键字](#十八、override 和 final 关键字)

十九、重载、重写与隐藏对比

二十、纯虚函数与抽象类

二十一、多态的原理

[21.1 虚函数表指针](#21.1 虚函数表指针)

[21.2 虚函数表与动态绑定](#21.2 虚函数表与动态绑定)

[21.3 虚函数表](#21.3 虚函数表)


一、继承的概念

继承(inheritance) 是 C++ 面向对象中实现"类级别代码复用"的关键机制。它允许你在一个已有类(基类)的基础上,扩展出一个新类(派生类),复用原有成员,再加上自己的成员。

先看一个没用继承的写法:同时表示学生和老师。

cpp 复制代码
class Student
{
public:
    void identity()
    {
        //身份认证逻辑
    }

    void study()
    {
        //学习
    }

protected:
    std::string _name;    //姓名
    std::string _address; //地址
    std::string _tel;     //电话
    int _age;             //年龄
    int _stuid;           //学号
};

class Teacher
{
public:
    void identity()
    {
        //身份认证逻辑
    }

    void teaching()
    {
        //授课
    }

protected:
    std::string _name;    //姓名
    int _age;             //年龄
    std::string _address; //地址
    std::string _tel;     //电话
    std::string _title;   //职称
};

问题:

  • identity重复;

  • 姓名/地址/电话/年龄等字段也重复;

  • 一旦认证规则改了,要改两处。

下面我们公共的成员都放到Person类中,Student和teacher都继承Person,就可以复用这些成员,就不需要重复定义了,省去了很多麻烦:

cpp 复制代码
class Person
{
public:
    void identity()
    {
        std::cout << "void identity() " << _name << std::endl;
    }

protected:
    std::string _name = "张三";  //姓名
    std::string _address;        //地址
    std::string _tel;            //电话
    int _age = 18;               //年龄
};

class Student : public Person
{
public:
    void study()
    {
        //学习
    }

protected:
    int _stuid;//学号
};

class Teacher : public Person
{
public:
    void teaching()
    {
        //授课
    }

protected:
    std::string _title;//职称
};

int main()
{
    Student s;
    Teacher t;
    s.identity();
    t.identity();
    return 0;
}
  • 学生是人;

  • 老师是人;

  • 公共属性放在Person里,派生类只扩展自己的部分。

cpp 复制代码
classDiagram
    class Person{
        string _name
        string _address
        string _tel
        int _age
        identity()
    }
    class Student{
        int _stuid
        study()
    }
    class Teacher{
        string _title
        teaching()
    }
    Person <-- Student
    Person <-- Teacher

二、继承的定义

2.1 定义格式

下面我们看到Person是基类,也称作父类。Student是派生类,也称作子类。(因为翻译的原因,所以既叫基类/派生类,也叫父类/子类)

继承的基本语法:

cpp 复制代码
class 派生类名 : 继承方式 基类名
{
    //新增成员
};

例如:

cpp 复制代码
class Student : public Person
{
public:
    int _stuid;//学号
    int _major;//专业
};

名词对应:

  • 基类/父类:Person

  • 派生类/子类:Student

  • 继承方式:publicprotectedprivate

2.2 继承方式与访问属性变化

三种继承方式,会改变"基类成员在派生类中的访问级别"(注意:只影响访问级别,不影响对象里是否有那块内存)。

基类成员原属性 public 继承后 protected 继承后 private 继承后
基类的 public 成员 变成 public 变成 protected 变成 private
基类的 protected 成员 变成 protected 变成 protected 变成 private
基类的 private 成员 在派生类中不可见 在派生类中不可见 在派生类中不可见

总结:

派生类中一个成员的最终访问级别 = Min(该成员在基类中的访问限定符, 继承方式)

排序规则:public > protected > private

要点:

  1. 基类的private成员在派生类中语法不可访问,但在对象里仍然有那块内存;

  2. 需要"类外不能访问,但派生类能访问"的成员,就应该放到protected里;

  3. class默认继承方式是privatestruct默认是public

  4. 实际编码中大多 采用public 继承


三、继承类模板

继承同样可以作用在类模板上,比如基于std::vector<T>封装一个简单的stack

cpp 复制代码
namespace bit
{
    template<class T>
    class stack : public std::vector<T>
    {
    public:
        void push(const T& x)
        {
            std::vector<T>::push_back(x);
        }

        void pop()
        {
            std::vector<T>::pop_back();
        }

        const T& top()
        {
            return std::vector<T>::back();
        }

        bool empty()
        {
            return std::vector<T>::empty();
        }
    };
}

int main()
{
    bit::stack<int> st;
    st.push(1);
    st.push(2);
    st.push(3);

    while (!st.empty())
    {
        std::cout << st.top() << " ";
        st.pop();
    }

    return 0;
}

模板细节:

  • 模板是按需实例化的;

  • 基类是类模板时,在派生类模板中访问基类成员,编译器不一定能推断;

  • 所以在模板中调用基类模板成员时,最好写成std::vector<T>::push_back(x)这种带类名限定的形式。


四、基类和派生类间的转换

cpp 复制代码
class Person
{
protected:
    std::string _name;//姓名
    std::string _sex;//性别
    int _age;//年龄
};

class Student : public Person
{
public:
    int _No;//学号
};

int main()
{
    Student sobj;

    //1.派生类对象可以转换为基类
    Person* pp = &sobj;//向上转型
    Person& rp = sobj;//向上转型
    Person pobj = sobj;//对象切片,只拷贝Person那部分

    //2.基类对象不能隐式转换为派生类对象
    //sobj = pobj;//错误,派生类多出来的信息没法填

    return 0;
}

规则:

  • public继承下,派生类对象可以隐式转换为基类对象/指针/引用(向上转型);

  • 反过来不行,因为基类不包含派生类扩展的那部分信息;

  • 基类指针如果实际上指向派生类对象,可以用强转转回来:

    • C 风格 (Student*)pp 很危险;

    • 多态场景下应优先使用dynamic_cast<Student*>(pp)做运行时检查。


五、继承中的作用域

5.1 隐藏规则

继承体系中有两个独立的作用域:基类作用域和派生类作用域。同名成员会发生隐藏

规则:

  1. 基类和派生类的作用域彼此独立;

  2. 在派生类中定义了与基类同名的成员(变量或者函数),则派生类的同名成员会隐藏基类成员;

  3. 对函数来说,只要名字相同就隐藏,不看参数列表;

  4. 想在派生类中访问被隐藏的基类成员,需要写成基类名::成员名

  5. 实际编码中尽量避免在继承体系里大量使用同名成员,容易搞混。

示例:

cpp 复制代码
class Person
{
protected:
    std::string _name = "好评"; //姓名
    int _num = 111;             //身份证号
};

class Student : public Person
{
public:
    void Print()
    {
        std::cout << "姓名:" << _name << std::endl;
        std::cout << "身份证号:" << Person::_num << std::endl;
        std::cout << "学号:" << _num << std::endl;
    }

protected:
    int _num = 999;//学号
};

int main()
{
    Student s1;
    s1.Print();
    return 0;
}

这里:

  • Person::_num表示身份证号;

  • Student::_num表示学号;

  • Student作用域中直接写_num访问的是学号,身份证那一份被隐藏了。

5.2 继承作用域相关的典型考点

例1:两个fun是什么关系?

cpp 复制代码
class A
{
public:
    void fun()
    {
        std::cout << "func()" << std::endl;
    }
};

class B : public A
{
public:
    void fun(int i)
    {
        std::cout << "func(int i) " << i << std::endl;
    }
};

A::fun()B::fun(int)

  • 不在同一作用域;

  • 名字相同;

  • 参数列表不同;

它们之间的关系是隐藏,不是重载。

例 2:下面程序结果?

cpp 复制代码
int main()
{
    B b;
    b.fun(10);
    b.fun();
    return 0;
}

答案:编译错误。

原因:

  • B的作用域中,A::funB::fun(int)隐藏了;

  • 名字查找只看到B::fun(int)

  • 调用b.fun()时,没有无参版本匹配,编译失败。

如果既想保留A::fun()又要在B中增加fun(int),可以:

cpp 复制代码
class B : public A
{
public:
    using A::fun;//把A中的fun引入当前作用域

    void fun(int i)
    {
        std::cout << "func(int i) " << i << std::endl;
    }
};

六、派生类的默认成员函数

派生类也有那几大默认函数:构造、拷贝构造、赋值、析构等。在继承体系中它们有统一的调用顺序。

6.1 4个常见默认成员函数

6个默认成员函数,默认的意思就是指我们不写,编译器会变我们自动生成---个,那么在派生类中,这几个成员函数是如何生成的呢?

  1. 派生类的构造函数 必须调用基类的构造函数初始化基类的那一部分成员。如果基类 没有默认的构造****函数 ,则必须在 派生类构造函数的初始化列表阶段显示调用。
  2. 派生类的拷贝构造函数 必须调用基类拷贝构造 完成基类 的拷贝初始化。
  3. 派生类的operator=必须要调用基类的 operator=完成基类 的复制。需要注意的是派生类的operator=隐藏了基类 的operator=,所以显示调用基类 的operator=,需要指定基类作用域
  4. 派生类的析构函数 会在被调用完成后用自动调用基类 的析构函数清理基类 成员。因为这样才能保证派生****类 对象先清理派生类成员再清理基类成员的顺序。
  5. 派生类对象初始化先调用基类构造 再调派生类构造
  6. 派生类对象析构清理 先调用派生类 析构再调基类 的析构。
  7. 因为多态中一些场景析构函数 需要构成重写,重写的条件之一是函数名相同。那么编译器会对析构函数 名进行特殊处理,处理成destructor(),所以基类析构函数 不加virtual的情况下,派生类析构函 数和基类析构函数 构成隐藏关系。
cpp 复制代码
class Person
{
public:
    Person(const char* name = "peter")
        : _name(name)
    {
        std::cout << "Person()" << std::endl;
    }

    Person(const Person& p)
        : _name(p._name)
    {
        std::cout << "Person(const Person& p)" << std::endl;
    }

    Person& operator=(const Person& p)
    {
        std::cout << "Person operator=(const Person& p)" << std::endl;
        if (this != &p)
        {
            _name = p._name;
        }
        return *this;
    }

    ~Person()
    {
        std::cout << "~Person()" << std::endl;
    }

protected:
    std::string _name;//姓名
};

class Student : public Person
{
public:
    Student(const char* name, int num)
        : Person(name)
        , _num(num)
    {
        std::cout << "Student()" << std::endl;
    }

    Student(const Student& s)
        : Person(s)
        , _num(s._num)
    {
        std::cout << "Student(const Student& s)" << std::endl;
    }

    Student& operator=(const Student& s)
    {
        std::cout << "Student& operator=(const Student& s)" << std::endl;
        if (this != &s)
        {
            Person::operator=(s);//显式调用基类的=
            _num = s._num;
        }
        return *this;
    }

    ~Student()
    {
        std::cout << "~Student()" << std::endl;
    }

protected:
    int _num;//学号
};

int main()
{
    Student s1("jack", 18);
    Student s2(s1);
    Student s3("rose", 17);
    s1 = s3;
    return 0;
}

总结:

  • 构造顺序:先构造基类子对象,再构造派生类自己的成员;

  • 拷贝构造:先调用基类拷贝构造,再拷贝派生类成员;

  • 赋值运算符 :先调用基类operator=,再赋值派生类成员;

  • 析构顺序:先析构派生类,再析构基类(栈式反向);

  • 派生类的operator=会隐藏基类的operator=,所以要显式写Person::operator=(s)


七、实现一个不能被继承的类

有些类希望"只能被使用,不能被继承",例如工具类、单例类等。常见做法有两种。

7.1 C++98 写法:构造函数设为 private

cpp 复制代码
class Base
{
private:
    Base()
    {}
};
  • 派生类构造函数必须先调用基类构造;

  • 但基类构造是private,派生类调用不到;

  • 语法上可以写class Derive : public Base {};,但无法构造派生类对象。

7.2 C++11 写法:final 关键字

C++11 提供了final关键字,直接在类定义后标记:

cpp 复制代码
class Base final
{
public:
    void func5()
    {
        std::cout << "Base::func5" << std::endl;
    }

protected:
    int a = 1;
};

class Derive : public Base
{
public:
    void func4()
    {
        std::cout << "Derive::func4" << std::endl;
    }

protected:
    int b = 2;
};

Base继承会直接编译失败,因为Base被标记为final,无法被继承。


八、继承与友元

**友元关系不会自动继承。**基类的友元不是派生类的友元。

cpp 复制代码
class Student;

class Person
{
public:
    friend void Display(const Person& p, const Student& s);

protected:
    std::string _name;//姓名
};

class Student : public Person
{
protected:
    int _stuNum;//学号
};

void Display(const Person& p, const Student& s)
{
    std::cout << p._name << std::endl;
    std::cout << s._stuNum << std::endl;
}

想让Display既能访问Person的内部成员,又能访问Student的内部成员,规范的做法是:

  • Person中声明friend void Display(const Person&, const Student&);

  • Student中也声明同样的friend


九、继承与静态成员

静态成员在整个继承体系中只有一份,是全类共享的。

cpp 复制代码
class Person
{
public:
    std::string _name;
    static int _count;
};

int Person::_count = 0;

class Student : public Person
{
protected:
    int _stuNum;
};

int main()
{
    Person p;
    Student s;

    //访问静态成员的两种写法,本质是同一块内存
    Person::_count = 10;
    Student::_count = 20;

    std::cout << Person::_count << std::endl;//20
    std::cout << Student::_count << std::endl;//20

    return 0;
}

要点:

  • 静态成员属于类,而不是具体哪个对象;

  • 在 public 继承下,可以用Person::_countStudent::_count两种形式访问同一份静态成员。


十、多继承及其菱形继承问题

10.1 多继承与菱形继承

多继承 :一个类同时继承多个直接基类。对象布局通常是"基类1子对象 + 基类2子对象 + ... + 派生类成员"。

典型菱形结构:

cpp 复制代码
class Person
{
public:
    std::string _name;//姓名
};

class Student : public Person
{
protected:
    int _num;//学号
};

class Teacher : public Person
{
protected:
    int _id;//职工编号
};

class Assistant : public Student, public Teacher
{
protected:
    std::string _majorCourse;//主修课程
};

Assistant对象中会有两份Person子对象:一份来自Student,一份来自Teacher

cpp 复制代码
int main()
{
    Assistant a;

    //错误:_name不明确,既可以来自Student,也可以来自Teacher
    //a._name = "peter";

    a.Student::_name = "peter";
    a.Teacher::_name = "jack";

    return 0;
}

问题:

  • 数据冗余:_name有两份;

  • 使用有二义性:a._name不明确,必须带上类域。

这就是绝大部分人不推荐菱形继承的原因。

10.2 虚继承

为了解决菱形继承中"同一基类多份"的问题,C++ 提供了虚继承(virtual inheritance)

cpp 复制代码
class Person
{
public:
    std::string _name;//姓名
};

//虚继承Person
class Student : virtual public Person
{
protected:
    int _num;//学号
};

//虚继承Person
class Teacher : virtual public Person
{
protected:
    int _id;//职工编号
};

class Assistant : public Student, public Teacher
{
protected:
    std::string _majorCourse;//主修课程
};

int main()
{
    Assistant a;
    a._name = "peter";//现在只有一份Person子对象,不再二义
    return 0;
}

效果:

  • 对每条虚继承链,最终派生类对象中只有一份虚基类子对象;

  • 避免数据冗余和访问二义性。

代价:

  • 对象布局更复杂;

  • 构造函数初始化顺序更绕;

  • 一般只在库级基础设施里使用,业务代码中尽量避免。

示例:

cpp 复制代码
class Person
{
public:
    Person(const char* name)
        : _name(name)
    {}

    std::string _name;//姓名
};

class Student : virtual public Person
{
public:
    Student(const char* name, int num)
        : Person(name)
        , _num(num)
    {}

protected:
    int _num;//学号
};

class Teacher : virtual public Person
{
public:
    Teacher(const char* name, int id)
        : Person(name)
        , _id(id)
    {}

protected:
    int _id;//职工编号
};

class Assistant : public Student, public Teacher
{
public:
    Assistant(const char* name1, const char* name2, const char* name3)
        : Person(name3)
        , Student(name1, 1)
        , Teacher(name2, 2)
    {}

protected:
    std::string _majorCourse;//主修课程
};

int main()
{
    Assistant a("张三", "李四", "王五");
    //最终Person那一份名字会被构造成"王五"
    return 0;
}

10.3 多继承中的指针偏移问题

多继承下,不同基类子对象在派生类对象中位置不同,因此把派生类对象地址转成不同基类指针时,会产生偏移

cpp 复制代码
class Base1
{
public:
    int _b1;
};

class Base2
{
public:
    int _b2;
};

class Derive : public Base1, public Base2
{
public:
    int _d;
};

int main()
{
    Derive d;

    Base1* p1 = &d;
    Base2* p2 = &d;
    Derive* p3 = &d;

    //通常:p1 == p3,p2 == p3 + sizeof(Base1)
    return 0;
}
  • Base1子对象一般位于Derive对象开头,所以p1p3相等;

  • Base2子对象在Base1之后,因此p2在地址上相对p3有偏移。

10.4 IO 库中的菱形虚继承

  • 顶层有一个ios_base

  • basic_iosios_base继承;

  • basic_istreambasic_ostream都虚继承自basic_ios

  • basic_iostream多继承自basic_istreambasic_ostream

  • 因为是虚继承,basic_iostream对象里只会有一份basic_ios子对象。


十一、继承和组合

11.1 继承和组合

两种最常见的代码复用关系:

  • 继承(inheritance) :is-a 关系

    • Student是一种Person

    • Benz是一种Car

    • 常和多态一起出现。

  • 组合(composition) :has-a 关系

    • Car里有 4 个Tire

    • stack内部有一个vector做底层存储。

从封装和耦合角度看:

  • 继承属于白箱复用

    • 派生类能看到基类大部分实现细节;

    • 基类实现变动会把派生类一起拖下水;

    • 耦合高,封装性相对差一些。

  • 组合属于黑箱复用

    • 只通过接口和被组合对象交互;

    • 内部实现对外隐藏;

    • 耦合更低,更利于维护。

总结:

能用组合解决的,就尽量用组合;只有在天然 is-a 且需要多态时,再考虑继承。

11.2 继承与组合的例子

轮胎和车:很明显是 has-a 关系。

cpp 复制代码
class Tire
{
protected:
    std::string _brand = "Michelin";//品牌
    size_t _size = 17;//尺寸
};

class Car
{
protected:
    std::string _colour = "白色";//颜色
    std::string _num = "陕ABIT00";//车牌号
    Tire _t1;
    Tire _t2;
    Tire _t3;
    Tire _t4;
};

具体车型和车:很自然是 is-a 关系。

cpp 复制代码
class BMW : public Car
{
public:
    void Drive()
    {
        std::cout << "好开-操控" << std::endl;
    }
};

class Benz : public Car
{
public:
    void Drive()
    {
        std::cout << "好坐-舒适" << std::endl;
    }
};

vectorstack

cpp 复制代码
template<class T>
class vector
{};

//is-a写法:stack继承vector
template<class T>
class stack_is_a : public vector<T>
{};

//has-a写法:stack里组合一个vector
template<class T>
class stack_has_a
{
public:
    vector<T> _v;
};

从语义上讲,stack 更像"内部用某种顺序容器实现"的抽象,不是"某个特殊的 vector",所以组合更合适。


十二、多态的概念

多态(polymorphism) 字面意思就是多种形态

从发生阶段看:

  • 编译时多态(静态多态):函数重载、函数模板;

    • 根据参数类型/个数不同,在编译期决定调用哪个函数;
  • 运行时多态(动态多态):虚函数+基类指针/引用;

    • 调用哪个版本在运行时决定。

这里重点讲运行时多态。

例子:买票。

  • 每个人都执行BuyTicket()

  • 普通人:全价;

  • 学生:打折;

  • 军人:优先窗口;

  • 函数名一样,行为随对象类型改变,这就是多态。

例子:动物叫:

  • Animal定义接口talk()

  • 猫重写后输出"喵",狗重写后输出"汪",接口统一,行为不同。


十三、多态的构成条件

要形成运行时多态,必须满足三个条件:

  1. 存在继承关系 (例如Student继承Person);

  2. 基类中有虚函数,派生类对其重写

  3. 通过基类指针或引用调用虚函数。

少任何一条都不是动态多态。

示例:买票。

cpp 复制代码
class Person
{
public:
    virtual void BuyTicket()
    {
        std::cout << "买票-全价" << std::endl;
    }
};

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

void Func(Person* ptr)
{
    ptr->BuyTicket();
}

int main()
{
    Person ps;
    Student st;

    Func(&ps);//买票-全价
    Func(&st);//买票-打折

    return 0;
}

十四、虚函数与重写

14.1 虚函数

在成员函数前加virtual,这个成员函数就变成虚函数:

cpp 复制代码
class Person
{
public:
    virtual void BuyTicket()
    {
        std::cout << "买票-全价" << std::endl;
    }
};

注意:

  • 只有成员函数可以是虚函数;

  • 虚属性会被派生类继承,即使派生类省略virtual关键字,该函数仍然是虚函数。

14.2 虚函数的重写

重写(override) 的条件:

基类中有虚函数virtual R f(Args...),派生类中也有R f(Args...),参数列表完全相同,即重写。

示例:动物叫。

cpp 复制代码
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 letsHear(const Animal& animal)
{
    animal.talk();
}

int main()
{
    Cat cat;
    Dog dog;

    letsHear(cat);
    letsHear(dog);

    return 0;
}
  • letsHear只知道拿到一个Animal&

  • 调用talk()时,通过虚函数机制,根据实际对象类型选择对应版本。


十五、默认参数与虚函数

多态场景的选择题:
以下程序输出结果是什么()
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;
    }
};

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

输出是:B: B -> 1

原因:

  • 虚函数调用的动态绑定发生在运行时;

  • 默认参数的选择发生在编译时,依据的是"静态类型"。

想象你去吃汉堡:

  1. 优惠券(编译时的类型 A) :你手里拿着一张由A公司 印发的优惠券(代码里 test() 函数是在 A 类里写的)。这张优惠券上写着一行小字:"默认赠送 1 包番茄酱"。

  2. 厨师(运行时的对象 B) :真正给你做汉堡的厨师是B师傅 (代码里 new B)。B师傅平时的习惯是"默认赠送 0 包番茄酱"。

冲突发生了: 当你拿着 A公司的优惠券给 B师傅看时:

  • 谁做汉堡? 当然是 B师傅 做(因为是 virtual 虚函数,看实际对象)。

  • 给几包酱? 必须按 优惠券 上写的来(默认参数是编译时决定的,看纸上写了啥)。

结果 :B师傅做了汉堡(打印 B->),但是被迫按优惠券给了 1 包酱(打印 1)。


十六、协变

协变(covariant) 返回类型指的是:基类虚函数返回"基类指针/引用",派生类重写时返回"派生类指针/引用"。

示例:

cpp 复制代码
class A {};
class B : public A {};

class Person
{
public:
    virtual A* BuyTicket()
    {
        std::cout << "买票-全价" << std::endl;
        return nullptr;
    }
};

class Student : public Person
{
public:
    virtual B* BuyTicket()
    {
        std::cout << "买票-打折" << std::endl;
        return nullptr;
    }
};

这种写法在语义上仍然算重写,C++ 允许这种"协变"返回。

实际业务中不常用,了解就行。


十七、虚析构函数与多态销毁

多态最容易翻车的地方:用基类指针delete派生类对象。

cpp 复制代码
class A
{
public:
    virtual ~A()
    {
        std::cout << "~A()" << std::endl;
    }
};

class B : public A
{
public:
    ~B()
    {
        std::cout << "~B()->delete:" << _p << std::endl;
        delete[] _p;
    }

protected:
    int* _p = new int[10];
};

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

    delete p1;//调用A::~A
    delete p2;//先调用B::~B,再调用A::~A

    return 0;
}

如果把A的析构函数上的virtual删掉,那么:

cpp 复制代码
A* p2 = new B;
delete p2;//只会调用A::~A,不会调用B::~B,数组泄漏

因此:

  • 凡是"打算当多态基类使用"的类,都应该把析构函数声明为virtual

  • 确保delete 基类指针时能正确析构派生类对象。


十八、override 和 final 关键字

默认情况下,如果派生类函数"看起来像是重写",实则签名不一致,编译器不会报错,只会当作隐藏。这很容易埋坑。

C++11 提供了两个关键字:

  • override:表示"我就是要重写基类虚函数",如果没成功重写,则编译报错;

  • final:表示"这个虚函数到我这里为止",派生类不能再重写。

示例一:用override查拼写错误。

cpp 复制代码
class Car
{
public:
    virtual void Dirve()//故意拼错
    {}
};

class Benz : public Car
{
public:
    virtual void Drive() override
    {
        std::cout << "Benz-舒适" << std::endl;
    }
};

编译器会报类似"带 override 却没有重写任何基类方法"的错误,暴露出Dirve的拼写问题。

示例二:用final禁止重写。

cpp 复制代码
class Car
{
public:
    virtual void Drive() final {}
};

class Benz : public Car
{
public:
    virtual void Drive()
    {
        std::cout << "Benz-舒适" << std::endl;
    }
};

这会直接编译失败,因为试图重写一个被final修饰的虚函数。

推荐习惯:

  • 只要是"故意要重写"的虚函数,派生类都加上override

  • 某个虚函数不希望再被重写时,加上final


十九、重载、重写与隐藏对比


二十、纯虚函数与抽象类

在虚函数声明后写**= 0** ,就变成纯虚函数(pure virtual)

cpp 复制代码
class Car
{
public:
    virtual void Drive() = 0;
};

特性:

  1. 纯虚函数可以只声明不实现

  2. 包含纯虚函数的类称为抽象类(abstract class)

  3. 抽象类不能实例化对象;

  4. 派生类如果没有重写完所有纯虚函数,它自己也是抽象类。

例子:

cpp 复制代码
class Car
{
public:
    virtual void Drive() = 0;
};

class Benz : public Car
{
public:
    virtual void Drive()
    {
        std::cout << "Benz-舒适" << std::endl;
    }
};

class BMW : public Car
{
public:
    virtual void Drive()
    {
        std::cout << "BMW-操控" << std::endl;
    }
};

int main()
{
    //Car car;//错误,抽象类不能实例化

    Car* pBenz = new Benz;
    pBenz->Drive();

    Car* pBMW = new BMW;
    pBMW->Drive();

    delete pBenz;
    delete pBMW;

    return 0;
}

抽象类常用来做"接口类":

  • 提供一组纯虚函数,规定"要做什么";

  • 具体"怎么做"由派生类去实现。


二十一、多态的原理

21.1 虚函数表指针

下⾯编译为32位程序的运行结果是什么()
A. 编译报错 B. 运行报错 C. 8 D. 12

cpp 复制代码
class Base
{
public:
    virtual void Func1()
    {
        std::cout << "Func1()" << std::endl;
    }

protected:
    int _b = 1;
    char _ch = 'x';
};

int main()
{
    Base b;
    std::cout << sizeof(b) << std::endl;
    return 0;
}

答案是D.12:

  • 一个int 4 字节;

  • 一个char加上对齐填充;

  • 再加上一个隐藏的虚函数表指针 vptr 4 字节。

这个 vptr 就是多态的关键。

21.2 虚函数表与动态绑定

买票例子:

cpp 复制代码
class Person
{
public:
    virtual void BuyTicket()
    {
        std::cout << "买票-全价" << std::endl;
    }

private:
    std::string _name;
};

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

private:
    std::string _id;
};

class Soldier : public Person
{
public:
    virtual void BuyTicket()
    {
        std::cout << "买票-优先" << std::endl;
    }

private:
    std::string _codename;
};

void Func(Person* ptr)
{
    ptr->BuyTicket();
}

int main()
{
    Person ps;
    Student st;
    Soldier sr;

    Func(&ps);
    Func(&st);
    Func(&sr);

    return 0;
}

底层大致这样:

  1. 每个含虚函数的类对应一张虚函数表(vtable) ,里面存放该类所有虚函数的地址

  2. 每个对象中有一个 vptr 指向对应类型的虚表;

  3. 调用ptr->BuyTicket()时:

    • 从对象中取出 vptr;

    • 在虚表中按固定偏移找到BuyTicket对应的函数指针;

    • 跳转到该函数地址执行。

因此:

  • ptr指向Person对象→调用Person::BuyTicket

  • 指向Student对象→调用Student::BuyTicket

  • 指向Soldier对象→调用Soldier::BuyTicket

这就是动态绑定(dynamic binding)

21.3 虚函数表

cpp 复制代码
class Base
{
public:
    virtual void func1()
    {
        std::cout << "Base::func1" << std::endl;
    }

    virtual void func2()
    {
        std::cout << "Base::func2" << std::endl;
    }

    void func5()
    {
        std::cout << "Base::func5" << std::endl;
    }

protected:
    int a = 1;
};

class Derive : public Base
{
public:
    virtual void func1()
    {
        std::cout << "Derive::func1" << std::endl;
    }

    virtual void func3()
    {
        std::cout << "Derive::func3" << std::endl;
    }

    void func4()
    {
        std::cout << "Derive::func4" << std::endl;
    }

protected:
    int b = 2;
};

通常:

  • Base的虚表类似[&Base::func1, &Base::func2]

  • Derive的虚表类似[&Derive::func1, &Base::func2, &Derive::func3]

  • func4func5因为不是虚函数,不在虚表中。

cpp 复制代码
int main()
{
    int i = 0;
    static int j = 1;
    int* p1 = new int;
    const char* p2 = "xxxxxxxx";

    printf("栈:%p\n", &i);
    printf("静态区:%p\n", &j);
    printf("堆:%p\n", p1);
    printf("常量区:%p\n", p2);

    Base b;
    Derive d;

    Base* p3 = &b;
    Derive* p4 = &d;

    printf("Base虚表地址:%p\n", *(void**)p3);
    printf("Derive虚表地址:%p\n", *(void**)p4);
    printf("虚函数地址:%p\n", (void*)&Base::func1);
    printf("普通函数地址:%p\n", (void*)&Base::func5);

    delete p1;
    return 0;
}

可以观察到:

  • 栈、堆、静态区、常量区的地址大致位于不同区间;

  • 虚表指针*(void**)p3*(void**)p4指向的区域,通常与代码/常量区比较接近;

  • 虚函数和普通函数的地址都落在代码段;

  • 虚表本质上就是一个"函数指针数组"。


相关推荐
历程里程碑2 小时前
C++ 8:list容器详解与实战指南
c语言·开发语言·数据库·c++·windows·笔记·list
UpgradeLink2 小时前
Electron项目使用electron-updater与UpgradeLink接入参考
开发语言·前端·javascript·笔记·electron·用户运营
小尧嵌入式2 小时前
C++11线程库的使用(上)
c语言·开发语言·c++·qt·算法
m0_616188492 小时前
JS文件批量下载并打包成ZIP的功能
开发语言·javascript·ecmascript
蓝色汪洋2 小时前
luogu填坑
开发语言·c++·算法
咖啡の猫2 小时前
Python列表推导式
开发语言·python
毕设源码-朱学姐2 小时前
【开题答辩全过程】以 基于PHP的高校心理测评系统的设计与实现为例,包含答辩的问题和答案
开发语言·php
while(1){yan}2 小时前
网络编程UDP
java·开发语言·网络·网络协议·青少年编程·udp·电脑常识
大猫子的技术日记2 小时前
【工具篇】极简入门 UV Python项目管理工具
开发语言·python·uv