【C++】继承和多态(上)

一. 继承的概念

假设你现在要写一个校内人员管理系统的项目,此时需要为校内不同的职位人员(学生,老师,保安等等)写不同的类来存储他们的信息。虽然说每个职位的人员都有其特殊的信息(如老师需要职称,学生需要专业名称),但是只要是人,就都需要存储名字,电话,身份证号这种基础信息。

对于这种每个人都需要的基础信息,我们不可能在每种职位人员的类中都写一遍,这时,我们就可以将个人基础信息封装成一个类Person,再使用其它的类(如Student,Teacher)复用类Person,以达到每个类都可以存储个人基本信息。而这种通过复用类 来减少程序代码量的方式就称为继承

类比理解 :初学编程时,相信你一定知道,如果一段相同逻辑的代码在程序中需要被多次使用,我们一般会将这段代码封装成一个函数。如果将这种逻辑相同的代码封装成一个函数的方式看成是函数层次的复用,那么,继承就可以看成是类层次的复用

1. 继承的定义

继承类和被继承类之间的有特殊的称谓。被继承类一般称为基类/父类,继承类称为派生类/子类。在上面的例子中,Person是被继承类,所以也叫基类/父类,Student和Teacher是继承类,所以也叫派生类/子类。

继承的定义格式为:派生类 : 继承的方式 基类类名,继承的方式有:public,protected,private。


图 1-1 继承方式,访问限定符种类

cpp 复制代码
// 基类
class Person
{
protected:
    string _name = "龙傲天";
    int _age = 9999;
};
// 派生类
class Student : public Person
{
protected:
    string _id = "0001";  // 学号
};

当两个类具有继承关系后,可以将派生类看成是包含了父类,即派生类中的成员包含了基类中的成员。在上面代码中,对于派生类Student类型的对象s,s的成员中不只有_id,还有_name_age

cpp 复制代码
Student s;
cout << s._name << " " << s._age << " " << s._id << endl;
// 输出:龙傲天 9999 0001

2. 派生类中基类成员的访问权限

当基类的成员被继承到派生类中时,其访问的权限可能会发生变化,其变化的结果由基类成员本身的访问权限和继承的方式共同决定。我们可以将派生类中基类的成员的访问权限做以下划分:

  1. 基类中的私有成员(private)

    基类中的私有成员在派生类中不可以直接使用,但是可以通过函数间接访问。基类中私有成员在派生类不可直接访问,可以类比普通类的私有成员在外界不可直接访问。

  2. 基类中的保护(protected)或公共成员(public)

    基类中保护或者公共成员在派生类中的访问权限为++该成员在基类中的访问权限与继承方式的权限的较小值++。例如:_name在基类Person中访问权限为protected,继承方式为public,因为 public > protected,所以 _name在派生类Student中的访问权限为protected(意为被protected修饰的成员)。

    注:访问权限的大小关系为:public > protected > private

类成员/继承方式 public继承 protected继承 private继承
基类的public成员 派生类的public成员 派生类的protected成员 派生类的private成员
基类的protected成员 派生类的protected成员 派生类的protected成员 派生类的private成员
基类的private成员 在派生类中不可见 在派生类中不可见 在派生类中不可见

需要注意的是,继承的方式可以不写。继承方式没有显式的写的话,就以默认的继承方式继承,对于class定义的类的默认继承方式为private,对于struct定义的结构体/类的默认继承方式为public

虽然说继承的方式一共有3种,但是在实践中一般很少使用protected,private的继承方式,这是因为,使用这两种继承方式会改变基类成员原先的访问权限。不止是继承方式,实践中也很少使用private来修饰成员,因为如果这个类作为父类的话,那被private修饰的成员在子类不能直接访问,这样就损害了继承的效果了。

3. 模板类的继承

除了普通的类有继承关系外,类模板之间也可以继承。

在模板类的继承体系中,派生类中不能直接使用基类的成员函数。因为如果直接调用deque的push_back函数,此时push_back还没有实例化出插入T类型的数据的版本,所以编译器会报错。而让push_back实例化的方式就是显式类域的调用push_back函数。

cpp 复制代码
template<class T>
class Stakc : public deque<T>
{
public:
    push(const T& val)
    {
        // push_back(val);  不能直接使用,因为模板是按需实例化,此时编译器还不认识push_back
        deque<T>::push_back(val);   // 显式类域即可将push_back(T)实例化
    }
    // ......
};

二. 派生类和基类间的转换

1. 派生类赋值给基类

在之前我们学习 引用 时曾详细讲过,C++中支持有关联的类型间的隐式类型转换,隐式类型转换会产生临时对象。

这个在之前算是语法规则的定律在派生类和基类间的类型转换却无法解释。简单来说,就是派生类的对象转换为基类的对象不会产生临时变量。究其原因,其实就是因为继承关系的特殊性,下面我们来看具体的定义。

1.1 派生类的对象给基类的指针/引用赋值

++派生类的对象给基类类型的指针或引用赋值时不会产生临时对象++。我们可以使用普通类型的隐式转换与派生类和基类的类型转换做比较来证明这个结论:

  1. double类型的对象转换为int*/int&类型

    cpp 复制代码
    double d = 3.14;
    const int* p = &d;
    const int& r = d;

    结果:产生临时对象,int*和int&需要使用const修饰。

  2. 派生类类型的对象转换为基类类型的指针/引用类型

    cpp 复制代码
    Student s;
    Person* p = &s;
    Person& p = s;

    结果:没有产生临时对象,Person*和Person&不需要使用const修饰。

注意:派生类的对象给基类类型的指针/引用赋值不产生临时对象的原因是,基类类型的指针/引用直接绑定(或者说指向)派生类的对象中基类的那部分成员。所以,需要小心使用基类类型的指针/引用,以免伤及到派生类的对象。


图 2-1 派生类的对象给基类类型的指针/引用赋值

1.2 派生类的对象给基类的对象赋值

派生类的对象给基类类型的对象赋值时不会产生临时对象。赋值的原理是直接将派生类的对象中基类那部分的对象拷贝给基类的对象。所以,派生类的对象给基类的对象赋值也称为"切割"。

cpp 复制代码
Student s;
Person p = s;
// 底层逻辑
// p._name = s._name;
// p._age = s._age;


图 2-2 派生类的对象给基类的对象赋值

三. 继承的"隐藏"机制

具有继承关系的两个类之间难免会出现基类和派生类间具有名字相同的成员。为了更好的区分这两个成员,C++中就有了"隐藏"的机制。

cpp 复制代码
// 基类
class Person
{
public:
    void print() { cout << _name << " " << _id << endl; }
protected:
    string _name = "龙傲天";
    string _id = "123456" // 身份证号
};
// 派生类
class Student : public Person
{
public:
    void print() { cout << _id << endl; }
protected:
    string _id = "0001";  // 学号
};

1. 同名成员变量的隐藏

基类和派生类的成员有自己独立的作用域,当基类和派生类中有相同名字的成员变量时(如成员变量_id),在派生类中优先使用派生类自己的成员,而如果想要访问基类中该名字成员变量,需要显式的使用域名访问(如Person::_id)。而这种调用机制被称为"隐藏"。

cpp 复制代码
Student s;
// 优先访问Student中的_id
cout << s._id << " ";
// 显式域名调用,访问Student中基类Person中的_id
cout << s.Person::_id;
// 输出:0001 123456

2. 同名成员函数的隐藏

如果基类和派生类中有相同名字的成员函数,则会直接触发"隐藏"机制。

cpp 复制代码
Student s;
// 优先访问Student中的print()
s.print();
// 显式域名调用,访问Student中基类Person中的print()
s.Person::print();
/* 输出: 0001
         龙傲天 123456   */

3. 面试题分析

  1. A和B类中的两个func构成什么关系()

    A. 重载 B. 隐藏 C.没关系

  2. 下面程序的编译运行结果是什么()

    A. 编译报错 B. 运行报错 C. 正常运行

cpp 复制代码
class A
{
public:
     void fun()
     {
     cout << "func()" << endl;
     }
};
class B : public A
{
public:
     void fun(int i)
     {
     cout << "func(int i)" <<i<<endl;
     }
};
int main()
{
    B b;
    b.fun(10);
    b.fun();
    return 0;
}

解答:

(1) B

解析:参考上文说的------"如果基类和派生类中有相同名字的成员函数,则会直接触发'隐藏'机制。"

(2) A

解析:因为触发了隐藏机制,所以程序使用b直接调用fun时,编译器只能找到B类中的fun(int)的函数,找不到没有形参的fun()函数。正确调用方式为:b.A::fun();

四. 派生类的默认成员函数

cpp 复制代码
// 基类
class Person
{
public:
    构造函数 拷贝构造 赋值运算符重载 析构函数......
protected:
    string _name;
    int _age;
};
// 派生类
class Student : public Person
{
public:
    构造函数 拷贝构造 赋值运算符重载 析构函数......
protected:
    string _id;
};

1. 四个主要的默认成员函数

  • 构造函数

    派生类的构造函数中的需要遵循先构造基类的成员,将基类的成员看成一个整体构造,之后构造派生类,派生类自己的成员和之前的构造方式一样。这里唯一有问题的就是如何将基类成员看成一个整体构造,方法很简单,就是直接使用基类的构造函数来初始化派生类中基类的成员变量。

    cpp 复制代码
    Person(const string& name = "龙傲天", int age = 9999)
            :_name(name)
            ,_age(age)
    {}
    // 派生类的构造函数
    Student(const string& name, int age, const string& id)
        :Person(name, age)
        ,_id(id)
    {}
  • 拷贝构造函数

    派生类的拷贝构造函数与构造函数的规则相似,需要将派生类中基类的成员看成一个整体来拷贝构造。这里需要注意的是,基类的拷贝构造需要传递基类类型的实参。基类类型的实参我们可以通过派生类隐式类型转换得到。

    cpp 复制代码
    Person(const Person& p)
        :_name(p._name)
        ,_age(p._age)
    {};
    // 派生类的拷贝构造函数
    Student(const Student& s)
        :Person(s)   // 隐式类型转换 派生类的对象给基类的对象赋值
        ,_id(s._id)
    {}
  • 赋值运算符重载

    派生类中的赋值运算符重载遵循将基类成员看成整体来赋值,所以其函数内部需要调用基类的operator=函数。但是,当派生类中写了operator=函数时,就会隐藏基类中的operator=。如果要调用基类中的operator=,则需要声明作用域。

    cpp 复制代码
    Person& operator=(const Person& p)
    {
        _name = p._name;
        _age = p._age;
    }
    // 派生类的赋值运算符重载
    Student(const Student& s)
    {
        Person::operator=(s);
        _id = s._id;
    }
  • 析构函数

    派生类中的析构函数不需要对基类的成员变量做销毁工作。在派生类的析构函数结束时,编译器会自行调用基类的析构函数来销毁派生类中基类的成员。

    cpp 复制代码
    ~Person()
    {
        _name = "???";
        _age = 000;
    }
    ~Student()
    {
        _id = "???"
    } // 派生类析构函数结束时,会自动调用基类的析构函数

    关于为什么C++中要规定派生类的析构函数在结束时会自动调用基类的析构函数,原因有如下3点:

    1. 对象物理内存的"嵌套容器"模型(根本原因)

    在 C++ 中,派生类对象的内存布局中直接包含基类的子对象(subobject)。当你销毁派生类对象时,操作系统需要回收这块连续的内存。如果基类子对象没有被析构,那么基类部分管理的资源(如堆内存、句柄)将永远无法释放。编译器必须确保这块"子内存"被正确地清理掉,这是物理内存回收的逻辑闭环。


    2. 严格的栈式生命周期对称(顺序安全)

    对象的构造顺序是:先基类(初始化父辈)→ 后派生类(初始化自己)

    析构顺序必须严格相反:先派生类(清理自己)→ 后基类(清理父辈)

    如果允许基类析构函数在派生类析构函数之前 执行,那么派生类析构函数中若访问了继承自基类的成员变量,将面临"访问已销毁对象"的未定义行为(UB)。C++ 将基类析构强制放在派生类析构体的末尾(即成员析构之后)自动执行,就是为了保证派生类在清理自身时,基类部分依然存活且有效。


    3. RAII 的终极兜底机制(强制资源释放)

    C++ 的设计哲学是"不放过任何一个资源泄漏的机会"。基类可能持有锁(mutex)、文件描述符或动态内存。即使程序员的派生类析构函数中忘记显式调用 Base::~Base(),编译器也必须强制介入。这种自动调用相当于一道防火墙,确保基类层面的资源无论如何都会被释放,极大地降低了因人为疏忽导致的资源泄漏风险。

2. 禁止继承的方法

2.1 将基类的构造函数设为私有(private)

当基类的构造函数被设为私有时,派生类中就无法构造基类的成员,这样就使得该基类无法被继承。

cpp 复制代码
class Person
{
private:
    // Person的构造函数被设为私有,此时Person就无法被继承了
    Person(const string& name = "龙傲天", int age = 9999)
        :_name(name)
        ,_age(age)
    {}
protected:
    string _name;
    int _age;
};

2.2 使用关键字final (C++11的特性)

除了使用类中的语法规则来使得一个类无法被继承,还可以直接使用关键final。final关键字是C++11的特性,被final修饰的类将无法被继承。

cpp 复制代码
class Person final // Person被final修饰,也无法被继承
{
public:
    // Person的构造函数被设为私有,此时Person就无法被继承了
    Person(const string& name = "龙傲天", int age = 9999)
        :_name(name)
        ,_age(age)
    {}
protected:
    string _name;
    int _age;
};

五. 友元不能被继承

C++中规定,友元无法被继承。即基类中的友元不能被派生类继承。

cpp 复制代码
class Person
{
public:
    friend void func(const Student& s, const Person& p);
protected:
    string _name;
    int _age;
};

class Student : public Person
{
protected:
    string _id;
};
// 友元无法被继承,这里func是Person的友元,但是不是Student的友元
// 所以func无法访问Student的私有成员,这里编译器会报错
void func(const Student& s, const Person& p)
{
    cout << p._name << " " << s._id << endl;
}

六. 静态成员变量不能被继承

基类中的静态成员变量无法被继承。不管一个基类有多少个派生类,有多少个对象,对于基类中的静态成员变量,它们始终保持共享。

cpp 复制代码
class Person
{
public:
    static string _nationality;
protected:
    string _name;
    int _age;
};
string Person::_nationality = "China";
class Student : public Person
{
protected:
    string _id;
};

int main()
{
    Person p;
    Student s;
    // 数值
    cout << p._nationality << endl;
    cout << s._nationality << endl;
    // 地址
    cout << &p._nationality << endl;
    cout << &s._nationality << endl;
    return 0;
}

七. 继承的方式

1. 单继承

在继承关系中,只要派生类只有一个直接的基类(如图 7-1所示),这种方式就称为单继承。


图 7-1 单继承的继承方式

2. 多继承

在继承关系中,只要派生类有多个直接的基类(如图 7-2 所示),这种方式就称为多继承。多继承的设计的初心是为了更好的解决++一个类可能同时包含两个类的成员++的问题,而在实践中也确实避不开使用多继承的方式。但是,多继承的这个看似"语法糖"的语法,确是产生了一个很大的陷阱,即菱形继承。


图 7-2 多继承的继承方式

2.1 多继承中的菱形继承

在多继承关系中,派生类的若干个直接或间接基类含有共同的基类,导致最后派生类中,从最初的基类继承的成员具有冗余性和二义性的场景就是菱形继承。如图 7-3 所示,为菱形继承的示意图。

图中的派生类Assistant含有两个基类StudentTeacher,而这两个基类又有共同的基类Person(如图 7-4)。所以,在派生类Assistant中就具有两份Person类的成员,所以就导致了最后派生类中的成员具有冗余性和二义性。

cpp 复制代码
class Person
{
public:
     string _name; // 姓名
};
class Student : public Person
{
protected:
     int _num; //学号
};
class Teacher : public Person
{
protected:
     int _id; // 职⼯编号
};
class Assistant : public Student, public Teacher
{
protected:
     string _majorCourse; // 主修课程
};
  • 二义性

    cpp 复制代码
    Assistant a;
    a._name = "龙傲天";   // 编译报错:error C2385: 对"_name"的访问不明确
    // 需要显⽰指定访问哪个基类的成员可以解决⼆义性问题
     a.Student::_name = "xxx";
     a.Teacher::_name = "yyy";
  • 冗余性

    cpp 复制代码
    Assistant a;
    a.Student::_name = "龙傲天";
    a.Teacher::_name = "凤凌月";

不难看出,只要使用了多继承,就难免会发生菱形继承。菱形继承又会导致派生类成员具有冗余性和二义性。二义性可以通过显式作用域解决,但冗余性确没有办法通过语法直接解决。

2.2 虚继承

为了解决菱形继承的冗余性和二义性的问题,C++中提出了虚继承的方法。虚继承即在继承冗余的类的时候,加上关键字virtual。对于冗余的类Person,我们在Student和Teacher继承它时使用virtaul来进行虚继承。

cpp 复制代码
class Student : virtual public Person
{
protected:
     int _num; //学号
};
class Teacher : virtual public Person
{
protected:
     int _id; // 职⼯编号
};

虚继承可以解决派生类中最初基类的成员的冗余性和二义性,其本质其实就是:将冗余的类单独剥离出来,看作是派生类的直接基类。

如上代码,使用虚继承后,Assistant类中冗余的Person从Student和Teacher中被剥离出来,成为Assistant的直接基类。

需要注意的是,虽然Person看成是从Student和Teacher中剥离出来了。但是为了语法的正确性,Assistant类中的Student和Teacher类还是可以使用Person的成员。

cpp 复制代码
Assistant a;
a._name = "龙傲天";
cout << a.Student::Person::_name << endl;
cout << a.Teacher::Person::_name << endl;
相关推荐
xian_wwq2 小时前
【学习笔记】深度认知系列-第14讲 端侧AI崛起——为什么AI正在从云端走向本地
人工智能·笔记·学习
郝学胜-神的一滴3 小时前
Qt 高级编程 045:坐标体系深度实战
开发语言·c++·windows·python·qt·程序人生
码匠许师傅3 小时前
【设计模式精讲】22.中介者模式(Mediator)
c++·设计模式·软件工程·uml·中介者模式
策案案案3 小时前
YOLO: 将AI Agents嵌入到IntelliJ IDEA
java·大数据·人工智能·笔记·yolo·microsoft·intellij-idea
爱吃巧克力的程序媛5 小时前
main.cpp注册 C++ 类到 QML 元对象系统
c++·qt
YYYing.6 小时前
【C++进阶系列 (二)】关于线程堆栈的那些事——函数调用过程
开发语言·c++·函数·线程堆栈
啵啵啵鱼6 小时前
DS-顺序表
java·数据结构·笔记·后端·链表·obsidian
键盘会跳舞7 小时前
【C++多线程】死锁诊断工具实战:从 Windows 到 Linux 的全链路排查
linux·c++·windows·死锁
xian_wwq7 小时前
【学习笔记】深度认知系列-第13讲AI Agent时代到来——从“回答问题”到“执行任务”
人工智能·笔记·学习