C++进阶(1)——继承

简介: 本文系统讲解C++继承机制,涵盖继承定义、访问限定符、派生类默认成员函数、菱形虚拟继承原理及组合与继承对比,深入剖析其在代码复用与面向对象设计中的应用。

目录

继承的相关概念

继承的定义

定义的格式

继承和访问限定符的分类

继承基类成员访问方式的变化

默认的继承方式

基类和派生类之间的转换

继承中的作用域

派生类的默认成员函数

继承和友元

继承和静态成员

继承的方式

菱形虚拟继承

菱形虚拟继承的原理

继承和组合


继承的相关概念

继承机制是我们面向对象程序设计使得代码可以复用的最重要的手段,它允许我们在保持原有的类的特性的基础上进行扩展,增加了方法(成员函数)和属性(成员变量),这样做产生的新的类,我们就称之为派生类。

继承呈现了面向对象程序设计的层次结构,这也体现了我们的代码由简单到复杂的认知过程,以前我们所接触的复用都是函数层次,而我们的继承是类设计层次的复用。

下面我们还是来举一个学生和老师继承的栗子来理解:

#include <iostream>
using namespace std;
class Person {
    public:
        void identity() {
            cout << _name << endl;
        }
    protected:
        string _name = "张三";
        int _age = 18;
};
class Student : public Person {
    public:
    void study() {
        cout << "study" << endl;
    }
    protected: 
        int _stuid;
};
class Teacher : public Person {
    public:
        void teaching() {
            cout << "teaching()" << endl;
        }
    protected:
        string title; // 职称
};
int main() {
    Student s;
    Teacher t;
    s.identity();
    t.identity();
    return 0;
}

image.gif

在这个栗子中我们的继承关系如图:

image.gif 编辑

继承之后我们的父类Person的成员包括了成员函数和成员变量,都会变成子类的一部分(权限允许的情况下,下面就会讲)。

继承的定义

定义的格式

我们这里的Person就是我们的基类,也叫做是父类;我们的Student和Teacher都是派生类,常被称之为子类

图示如下:

image.gif 编辑

继承和访问限定符的分类

图示:

image.gif 编辑

继承基类成员访问方式的变化

我们实际上在继承的时候,我们的基类不同限定符和不同继承方式继承到派生类后,我们的派生类的访问方式是有变化的。

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

我们这里观察发现了了一个比较明显的访问限定符的权限的大小关系:public > protected > private,规则如下:

1、在我们的基类中的是public或是prtoected修饰的成员的时候,在派生类总的访问的方式就是在基类访问方式继承方式中权限最小的那个。

2、在我们的基类中的是private修饰的成员的时候,我们的派生类不管是什么继承方式都是不可见的。

敲黑板:

基类的private成员在我们的派生类中不可见说的是:我们的派生类是不可以访问我们基类的parivate成员的。

默认的继承方式

我们在使用继承的时候,我们如果不指定继承的方式的话,我们是会有默认的继承方式的,对于我们的class来说,我们的默认继承方式就是我们的private,使用struct时我们的默认继承方式就是我们的public。(这里其实很好记,就是我们这两种结构默认的成员的限定符)。

下面我们来举一个栗子:

对于我们的class:

class Person {
    public:
        string _name = "LiSi";
};
// 派生类
class Student : Person {
    public:
        void print() {
            cout << _name << endl;
        }
    private:
        int _stuid = 0;
};
int main() {
    Student a;
    a.print();
    cout << a._name << endl;
    return 0;
}

image.gif

测试效果:

对于类里面:

image.gif 编辑

对于类外面:

image.gif 编辑

对于我们的struct:

// 基类
class Person {
    public:
        string _name = "LiSi";
};
// 派生类
struct Teacher : Person {
    void print() {
        cout << _name << endl;
    }
    private:
        string _title = "教师";
};
int main() {
    Teacher a;
    a.print();
    cout << a._name << endl;
    return 0;
}

image.gif

测试效果如图:

image.gif 编辑

基类和派生类之间的转换

我们的派生类对象实际上是可以复制给我们的基类对象、基类指针或是基类应用的,在这个过程中会发生基类和派生类之间的赋值转换。

比如下面的栗子:

// 基类
class Person {
    protected:
        string _name;
        string _sex;
        int _age;
};
// 派生类
class Student : public Person {
    protected:
        int _stuid;
};
int main() {
    Student s;
    Person p = s;
    Person* ptr = &s;
    Person& ref = s;
    return 0;
}

image.gif

我们称这种现象叫做切片,寓意就是把我们的派生类的基类部分切割出来(三种情况都是切割了之后的赋值)。下面是三种情况的图示:

派生类对象赋值个了基类对象:

image.gif 编辑

派生类对象赋值给了基类指针:

image.gif 编辑

派生类对象赋值给了基类的引用:

image.gif 编辑

敲黑板:

我们这里基类对象不能赋值给派生类对象,基类的指针可以通过我们的强制类型的转换给派生类指针,但是这个时候我们的基类指针就必须是指向派生类对象才是安全的。

继承中的作用域

隐藏的规则:

1、在继承的体系中我们的基类和派生类是有自己独立的作用域。

2、派生类和基类有同名成员,派生类的成员将会屏蔽基类对于同名成员的直接访问,这种行为叫做隐藏。

3、需要注意的是,如果是我们的成员函数进行的隐藏,只要我们的函数名一样就可以构成隐藏了。

4、实际应用的时候,我们在继承体系中最好是不要定义同名的成员。

下面我们还是来举一个栗子:

// 父类
class Person {
    protected:
        string _name = "LiSi";
        int _age = 18;
};
// 子类
class Student : public Person {
    public: 
        void Print() {
            cout << "age: " << _age << endl;
        }
    protected:
        int _age = 20;
};
int main() {
    Student s;
    s.Print();
    return 0;
}

image.gif

测试效果如图:

我们可以看到这个时候,我们访问的是我们的子类中的_age成员。

image.gif 编辑

如果我们想要访问我们的父类中的_age成员,我们就要使用我们的作用域限定符了。

代码如下:

// 子类
class Student : public Person {
    public: 
        void Print() {
            // cout << "age: " << _age << endl;
            cout << Person::_age << endl;
        }
    protected:
        int _age = 20;
};

image.gif

测试效果如图:

image.gif 编辑

下面就是我们对于我们的成员函数的隐藏,也就是使用我们的同名函数。

示例代码如下:

// 父类
class Person {
    public:
        void Print(int x) {
            cout << "person: " << x << endl;
        }
};
// 子类
class Student : public Person {
    public: 
        void Print(int x) {
            cout << "student: " <<  x << endl;
        }
};
int main() {
    Student s;
    s.Print(666);
    s.Person::Print(888);
    return 0;
}

image.gif

测试效果如图:

image.gif 编辑

我们这里来有一个关于继承作用域的选择题:

代码如下:

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;
};

image.gif

第一问:AB 类中的 fun 函数构成的关系

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

答案:B

第二问:下面程序的编译运行结果是什么?

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

答案:A

派生类的默认成员函数

我们不写编译器会自动生成的函数就是我们的默认成员函数了,类里面的默认成员函数如下:

image.gif 编辑

那么我们的派生类的成员函数有什么不一样的呢?

我们下面来对比一下:

对于我们的基类:

// 基类
class Person {
    public:
        // 构造函数
        Person(const string& name = "LiSi")
            : _name(name) {
                cout << "Person(const string& name = 'LiSi')" << endl;
            }
        // 拷贝构造函数
        Person(const Person& p) 
            :_name(p._name)
        {
            cout << "Person(const Person& p)" << endl;
        }
        // 赋值运算符重载函数
        Person& operator=(const Person& p) {
            cout << "operator=(const Person& p)" << endl;
            if(this != &p) {
                _name = p._name;
            }
            return *this;
        }
        // 析构函数
        ~Person() {
            cout << "~Person" << endl;
        }
    private:
        string _name;
};

image.gif

对于我们的子类:

// 子类
class Student : public Person {
    public:
        // 构造函数
        Student(const string& name, int stuid)
            :Person(name),
            _stuid(stuid){
                cout << "Student(const string& name, int stuid)" << endl;
            }
        // 拷贝构造函数
        Student(const Student& s) 
            :Person(s) // 使用了切片
            , _stuid(s._stuid)
        {
            cout << "Student(const Student& s)" << endl;
        }
        // 赋值运算符重载函数
        Student& operator=(const Student& s) {
            cout << "operator=(const Student& s)" << endl;
            if(this != &s) {
                Person::operator=(s);
                _stuid = s._stuid;
            }
            return *this;
        }
        // 析构函数
        ~Student() {
            cout << "~Student()" << endl;
        }
    private:
        int _stuid;
};

image.gif

我们总结一下:

1、派生类的构造函数必须调用基类的构造函数初始化基类的那一部分成员。如果基类没有默认的构造函数,则必须在派生类构造函数的初始化列表阶段显示调用。

2、派生类的拷贝构造函数必须调用基类的拷贝构造完成基类的拷贝初始化。

3、派生类的operator=必须要调用基类的operator=完成基类的复制。需要注意的是派生类的operator=隐藏了基类的operator=,所以显示调用基类的operator=,需要指定基类作用域。

4、派生类的析构函数会在被调用完成后自动调用基类的析构函数清理基类成员。因为这样才能保证派生类对象先清理派生类成员再清理基类成员的顺序。

5、派生类对象初始化先调用基类构造再调派生类构造。

6、派生类对象析构清理先调用派生类析构再调基类的析构。

7、因为多态中一些场景析构函数需要构成重写,重写的条件之一是函数名相同(这个我们多态章节会讲解)。那么编译器会对析构函数名进行特殊处理,处理成destructor(),所以基类析构函数不加virtual的情况下,派生类析构函数和基类析构函数构成隐藏关系

如何实现一个不可以被继承的类呢?

这个思想经常会被用来考察选择题,事实上我们有两种常见的方案:

方案1:我们可以将我们的基类的构造函数私有化(private),那么我们的基类就构造不了对象了,我们的派生类的构成又是必须要调用我们的基类构造函数的,但是这个时候我们基类的构造函数私有了,派生类看不见了,这个时候我们的派生类就不能实例化对象了。

方案2:C++11里面新增了一个关键字叫final,这个关键字可以用来修饰我们的基类,派生类就不能继承了。

继承和友元

我们的友元关系是不可以被继承的,也就是说我们的基类的友元是不可以访问我们派生类的私有和保护成员的

比如下面的这个代码:

class Student; // 前置声明
class Person {
    public:
        friend void Display(const Person& p, const Student& s);
    protected:
        string _name;
};
class Student : public Person {
    protected:
        int _stuid;
};
void Display(const Person& p, const Student& s) {
    cout << p._name << endl; // 可以访问
    cout << s._name << endl; // 可以访问
    cout << s._stuid << endl; // 不可以访问
}
int main() {
    Person p;
    Student s;
    Display(p, s);
    return 0;
}

image.gif

测试效果:

image.gif 编辑

这个时候如果我们想要Display函数能够访问成功,我们只要在Student中也写一个友元声明即可了:

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

image.gif

测试效果:

image.gif 编辑

继承和静态成员

基类中如果定义了一个static静态成员,那么整个的继承体系中就只有一个这样的成员,无论派生除了多少个派生类,我们都是只有一个static成员的实例。

我们这里可以写个代码来验证一下:

#include <iostream>
using namespace std;
class Person {
    public:
        Person() {
            _count++;
        }
    public:
        string _name;
        static int _count;
};
int Person::_count = 0;
class Student : public Person {
    protected:
        int _stuid;
};
int main() {
    Person p1;
    Person p2;
    Student s1;
    Student s2;
    cout << Person::_count << endl;
    cout << Student::_count << endl;
    return 0;
}

image.gif

测试效果: image.gif 编辑

我们这里也是可以对比一下它们的地址的差异来验证的,代码如下:

#include <iostream>
using namespace std;
class Person {
    public:
        Person() {
            _count++;
        }
    public:
        string _name;
        static int _count;
};
int Person::_count = 0;
class Student : public Person {
    protected:
        int _stuid;
};
int main() {
    Person p1;
    Person p2;
    Student s1;
    Student s2;
    // cout << Person::_count << endl;
    // cout << Student::_count << endl;
    cout << &p1._name << endl;
    cout << &s1._name << endl;
    cout << &p1._count << endl;
    cout << &s1._count << endl;
    return 0;
}

image.gif

测试效果如图:

image.gif 编辑

继承的方式

单继承:一个子类对应一个父类

image.gif 编辑

多继承:一个子类有两个及以上的父类

image.gif 编辑

菱形继承:多继承的一种特殊情况

image.gif 编辑

我们这里重点还是讨论一下这个菱形继承的问题,其实这个问题在Java中是不存在的,这种继承的方式存在明显的数据的冗余二义性问题

比如下面这个栗子:

#include <iostream>
using namespace std;
class Person {
    public:
        string _name;
};
class Student : public Person {
    protected:
        int _stuid;
};
class Teacher : public Person {
    protected:
        string title;
};
class Assistant : public Student, public Teacher {
    protected:
        string _assid;
};
int main() {
    Assistant a;
    a._name = "LiSi";
    return 0;
}

image.gif

测试效果:

其实我们的语法检查也发现了问题所在,那就是我们我们的Sudent和Teacher当中都是继承了Person的,所以我们的Student和我们的Teacher都是有_name成员的,所以我们直接访问我们的Assistant对象的_name成员的时候会有二义性在。

image.gif 编辑

我们这里比较好的解决方案就是在我们的代码中显示地指定我们的Assistant是哪一个父类的_name成员。

代码如下:

int main() {
    Assistant a;
    a.Student::_name = "LiSi";
    a.Teacher::_name = "WangWu";
    return 0;
}

image.gif

但是还是不能消除我们的数据冗余,因为在我们的Assistant的对象的Person成员还是会有两份。

菱形虚拟继承

我们为了解决我们的菱形继承的二义性和数据冗余的问题就出现了虚拟继承,根据上面的这个栗子,我们只需要在Student和Teacher继承Person的时候使用虚拟继承即可解决问题了。

代码如下:

#include <iostream>
using namespace std;
class Person {
    public:
        string _name;
};
class Student : virtual public Person {
    protected:
        int _stuid;
};
class Teacher : virtual public Person {
    protected:
        string title;
};
class Assistant : public Student, public Teacher {
    protected:
        string _assid;
};
int main() {
    Assistant a;
    a._name = "LiSi";
    return 0;
}

image.gif

测试效果如下:

这个时候我们的代码没有报错了。

image.gif 编辑

这个时候我们的二义性就解决了,我们我们这个时候去访问Assistant的父类Student的_name成员和父类Teacher的_name成员的时候得到的是同一个结果。

int main() {
    Assistant a;
    a._name = "LiSi";
    cout << a.Student::_name << endl;
    cout << a.Teacher::_name << endl;
    return 0;
}

image.gif

image.gif 编辑

那么这个时候有没有解决我们之前所提到的数据冗余的问题呢?

我们这里可以通过变量的指针来看看是不是一样的,我们通过下面的代码可以知道解决了数据冗余的问题。

代码如下:

image.gif 编辑

菱形虚拟继承的原理

我们这里可以看一看我们的菱形继承中各个类的成员在内存中的分布情况。

代码如下:

#include <iostream>
using namespace std;
class A {
public:
    int a;  // 类 A 的成员变量
};
class B : public A {
public:
    int b;  // 类 B 的成员变量
};
class C : public A {
public:
    int c;  // 类 C 的成员变量
};
class D : public B, public C {
public:
    int d;  // 类 D 的成员变量
};
int main() {
    D d;
    d.B::a = 1;
    d.C::a = 2;
    d.b = 3;
    d.c = 4;
    d.d = 5;
    return 0;
}

image.gif

image.gif 编辑

这也就说明了我们的D类中的各个成员在内存中的分布情况是下面这种情况:

image.gif 编辑

那么对于我们的菱形虚拟继承是什么情况呢?

测试代码如下:

#include <iostream>
using namespace std;
class A {
public:
    int a;  // 类 A 的成员变量
};
class B : virtual public A {
public:
    int b;  // 类 B 的成员变量
};
class C : virtual public A {
public:
    int c;  // 类 C 的成员变量
};
class D : public B, public C {
public:
    int d;  // 类 D 的成员变量
};
int main() {
    D d;
    d.B::a = 1;
    d.C::a = 2;
    d.b = 3;
    d.c = 4;
    d.d = 5;
    return 0;
}

image.gif

我们可以通过我们的内存窗口看到我们D类中各个成员在内存中的分布情况:

image.gif 编辑

我们这里发现我们D类对象里面的a成员被放在了内存的最后面而且时候2被存了下来(第一次是1但是我们第二次赋值给覆盖了,只能有一个a),原来存放两个a成员的地方变成了两个指针,这里的两个指针被称之为虚机表指针,它们分别指向了一个虚机表。这里可以看到虚机表里面有两个值(第一个值是我们为多态的虚表预留的存放偏移量的位置,第二个值是我们当前类对象位置距离公共虚基类的偏移量。)

image.gif 编辑

我们这里进行切片操作之后我们的成员分布还是会保持上面的分布:

示例:

int main() {
    D d;
    d.B::a = 1;
    d.C::a = 2;
    d.b = 3;
    d.c = 4;
    d.d = 5;
    B b = d;
    return 0;
}

image.gif

分布情况

image.gif 编辑

继承和组合

我们这里简单地理解就是我们的public继承是一种is-a的关系,也就是我们的每一个派生类对象都是一个基类对象;组合就是一种has-a的关系了,如果是B组合了A,那么每个B对象中都会有一个A对象。

我们这里还是来举出两个栗子:

第一个就是我们的is-a的关系了,也就是我们的继承。

代码如下:

这个代码我们不难理解,就是我们的宝马和我们的车是is-a的关系,所以这里用了继承。

class Car {
    protected:
        string _colour;
        string _num;
};
class BWM : public Car {
    public:
        void Drive() {
            cout << "BWM" << endl;
        }
};

image.gif

第二个就是我们has-a的关系了,也就是我们的组合关系。

代码如下:

这个代码也不难理解,我们的轮胎和我们的车是有has-a的关系的,并且我们的车一般是有四个轮子的。

class Tire {
    protected:
        string _brand;
        string _size;
};
class Car {
    protected:
        string _colour;
        string _num;
        Tire _t1;
        Tire _t2;
        Tire _t3;
        Tire _t4;
};

image.gif

敲黑板:

我们一般的两个类既可以是is-a的关系也可以是has-a的关系,我们一般优先使用组合

原因如下:

1、继承允许你根据基类的实现来定义派生类的实现。这种通过生成派生类的复用通常被称为白箱复用(white-box reuse)。术语“白箱”是相对可视性而言:在继承方式中,基类的内部细节对派生类可见。继承一定程度破坏了基类的封装,基类的改变,对派生类有很大的影响。派生类和基类间的依赖关系很强,耦合度高。

2、对象组合是类继承之外的另一种复用选择。新的更复杂的功能可以通过组装或组合对象来获得。对象组合要求被组合的对象具有良好定义的接口。这种复用风格被称为黑箱复用(black-boxreuse),因为对象的内部细节是不可见的。对象只以“黑箱”的形式出现。组合类之间没有很强的依赖关系,耦合度低。优先使用对象组合有助于你保持每个类被封装。

3、优先使用组合,而不是继承。实际尽量多去用组合,组合的耦合度低,代码维护性好。不过也不太那么绝对,类之间的关系就适合继承(is-a)那就用继承,另外要实现多态,也必须要继承。类之间的关系既适合用继承(is-a)也适合组合(has-a),就用组合。

相关文章
|
11天前
|
存储 关系型数据库 分布式数据库
PostgreSQL 18 发布,快来 PolarDB 尝鲜!
PostgreSQL 18 发布,PolarDB for PostgreSQL 全面兼容。新版本支持异步I/O、UUIDv7、虚拟生成列、逻辑复制增强及OAuth认证,显著提升性能与安全。PolarDB-PG 18 支持存算分离架构,融合海量弹性存储与极致计算性能,搭配丰富插件生态,为企业提供高效、稳定、灵活的云数据库解决方案,助力企业数字化转型如虎添翼!
|
10天前
|
存储 人工智能 搜索推荐
终身学习型智能体
当前人工智能前沿研究的一个重要方向:构建能够自主学习、调用工具、积累经验的小型智能体(Agent)。 我们可以称这种系统为“终身学习型智能体”或“自适应认知代理”。它的设计理念就是: 不靠庞大的内置知识取胜,而是依靠高效的推理能力 + 动态获取知识的能力 + 经验积累机制。
356 131
|
10天前
|
存储 人工智能 Java
AI 超级智能体全栈项目阶段二:Prompt 优化技巧与学术分析 AI 应用开发实现上下文联系多轮对话
本文讲解 Prompt 基本概念与 10 个优化技巧,结合学术分析 AI 应用的需求分析、设计方案,介绍 Spring AI 中 ChatClient 及 Advisors 的使用。
443 131
AI 超级智能体全栈项目阶段二:Prompt 优化技巧与学术分析 AI 应用开发实现上下文联系多轮对话
|
4天前
|
存储 安全 前端开发
如何将加密和解密函数应用到实际项目中?
如何将加密和解密函数应用到实际项目中?
206 138
|
10天前
|
人工智能 Java API
AI 超级智能体全栈项目阶段一:AI大模型概述、选型、项目初始化以及基于阿里云灵积模型 Qwen-Plus实现模型接入四种方式(SDK/HTTP/SpringAI/langchain4j)
本文介绍AI大模型的核心概念、分类及开发者学习路径,重点讲解如何选择与接入大模型。项目基于Spring Boot,使用阿里云灵积模型(Qwen-Plus),对比SDK、HTTP、Spring AI和LangChain4j四种接入方式,助力开发者高效构建AI应用。
405 122
AI 超级智能体全栈项目阶段一:AI大模型概述、选型、项目初始化以及基于阿里云灵积模型 Qwen-Plus实现模型接入四种方式(SDK/HTTP/SpringAI/langchain4j)
|
4天前
|
存储 JSON 安全
加密和解密函数的具体实现代码
加密和解密函数的具体实现代码
204 136
|
22天前
|
弹性计算 关系型数据库 微服务
基于 Docker 与 Kubernetes(K3s)的微服务:阿里云生产环境扩容实践
在微服务架构中,如何实现“稳定扩容”与“成本可控”是企业面临的核心挑战。本文结合 Python FastAPI 微服务实战,详解如何基于阿里云基础设施,利用 Docker 封装服务、K3s 实现容器编排,构建生产级微服务架构。内容涵盖容器构建、集群部署、自动扩缩容、可观测性等关键环节,适配阿里云资源特性与服务生态,助力企业打造低成本、高可靠、易扩展的微服务解决方案。
1363 8
|
9天前
|
监控 JavaScript Java
基于大模型技术的反欺诈知识问答系统
随着互联网与金融科技发展,网络欺诈频发,构建高效反欺诈平台成为迫切需求。本文基于Java、Vue.js、Spring Boot与MySQL技术,设计实现集欺诈识别、宣传教育、用户互动于一体的反欺诈系统,提升公众防范意识,助力企业合规与用户权益保护。