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),就用组合。

相关文章
|
5月前
|
存储 安全 Java
c++--继承
c++作为面向对象的语言三大特点其中之一就是继承,那么继承到底有何奥妙呢?继承(inheritance)机制是面向对象程序设计使代码可以复用的最重要的手段,它允许程序员在保持原有类特性的基础上进行扩展,增加功能,这样产生新的类,称派生类。继承呈现了面向对象程序设计的层次结构,体现了由简单到复杂的认知过程。以前我们接触的复用都是函数复用,继承是类设计层次的复用,继承就是类方法的复用。
113 0
|
8月前
|
安全 C++
【c++】继承(继承的定义格式、赋值兼容转换、多继承、派生类默认成员函数规则、继承与友元、继承与静态成员)
本文深入探讨了C++中的继承机制,作为面向对象编程(OOP)的核心特性之一。继承通过允许派生类扩展基类的属性和方法,极大促进了代码复用,增强了代码的可维护性和可扩展性。文章详细介绍了继承的基本概念、定义格式、继承方式(public、protected、private)、赋值兼容转换、作用域问题、默认成员函数规则、继承与友元、静态成员、多继承及菱形继承问题,并对比了继承与组合的优缺点。最后总结指出,虽然继承提高了代码灵活性和复用率,但也带来了耦合度高的问题,建议在“has-a”和“is-a”关系同时存在时优先使用组合。
424 6
|
10月前
|
C++ 开发者
C++学习之继承
通过继承,C++可以实现代码重用、扩展类的功能并支持多态性。理解继承的类型、重写与重载、多重继承及其相关问题,对于掌握C++面向对象编程至关重要。希望本文能为您的C++学习和开发提供实用的指导。
146 16
|
10月前
|
编译器 数据安全/隐私保护 C++
【C++面向对象——继承与派生】派生类的应用(头歌实践教学平台习题)【合集】
本实验旨在学习类的继承关系、不同继承方式下的访问控制及利用虚基类解决二义性问题。主要内容包括: 1. **类的继承关系基础概念**:介绍继承的定义及声明派生类的语法。 2. **不同继承方式下对基类成员的访问控制**:详细说明`public`、`private`和`protected`继承方式对基类成员的访问权限影响。 3. **利用虚基类解决二义性问题**:解释多继承中可能出现的二义性及其解决方案——虚基类。 实验任务要求从`people`类派生出`student`、`teacher`、`graduate`和`TA`类,添加特定属性并测试这些类的功能。最终通过创建教师和助教实例,验证代码
207 5
|
12月前
|
编译器 C++ 开发者
【C++】继承
C++中的继承是面向对象编程的核心特性之一,允许派生类继承基类的属性和方法,实现代码复用和类的层次结构。继承有三种类型:公有、私有和受保护继承,每种类型决定了派生类如何访问基类成员。此外,继承还涉及构造函数、析构函数、拷贝构造函数和赋值运算符的调用规则,以及解决多继承带来的二义性和数据冗余问题的虚拟继承。在设计类时,应谨慎选择继承和组合,以降低耦合度并提高代码的可维护性。
217 1
【C++】继承
|
安全 程序员 编译器
【C++篇】继承之韵:解构编程奥义,领略面向对象的至高法则
【C++篇】继承之韵:解构编程奥义,领略面向对象的至高法则
194 11
|
C++
C++番外篇——对于继承中子类与父类对象同时定义其析构顺序的探究
C++番外篇——对于继承中子类与父类对象同时定义其析构顺序的探究
144 1
|
C++
C++番外篇——虚拟继承解决数据冗余和二义性的原理
C++番外篇——虚拟继承解决数据冗余和二义性的原理
141 1
C++(二十)继承
本文介绍了C++中的继承特性,包括公有、保护和私有继承,并解释了虚继承的作用。通过示例展示了派生类如何从基类继承属性和方法,并保持自身的独特性。此外,还详细说明了派生类构造函数的语法格式及构造顺序,提供了具体的代码示例帮助理解。
|
安全 编译器 程序员
C++的忠实粉丝-继承的热情(1)
C++的忠实粉丝-继承的热情(1)
113 0