C++ 继承

文章目录

  • [C++ 继承](#C++ 继承)
    • [1. 继承的概念和定义](#1. 继承的概念和定义)
      • [1.1 继承的概念](#1.1 继承的概念)
      • [1.2 继承定义](#1.2 继承定义)
      • [1.3 继承类模板](#1.3 继承类模板)
    • [2. 基类和派生类之间的转换](#2. 基类和派生类之间的转换)
    • [3. 继承中的作用域](#3. 继承中的作用域)
      • [3.1 隐藏的规则](#3.1 隐藏的规则)
      • [3.2 选择题 考察继承作用域](#3.2 选择题 考察继承作用域)
    • [4. 子类的默认成员函数](#4. 子类的默认成员函数)
        • [4.1 构造](#4.1 构造)
        • [4.2 拷贝构造](#4.2 拷贝构造)
        • [4.3 赋值重载](#4.3 赋值重载)
        • [4.4 析构](#4.4 析构)
        • [4.5 如何实现一个不能被继承的类](#4.5 如何实现一个不能被继承的类)
    • [5. 继承和友元](#5. 继承和友元)
    • [6. 继承和静态成员](#6. 继承和静态成员)
    • [7. 多继承以及菱形继承问题](#7. 多继承以及菱形继承问题)
      • [7.1 继承模型](#7.1 继承模型)
      • [7.2 虚继承](#7.2 虚继承)
      • [7.3 多继承指针便宜问题](#7.3 多继承指针便宜问题)
    • [8. 继承和组合](#8. 继承和组合)

C++ 继承

1. 继承的概念和定义

1.1 继承的概念

C++ 是面向对象的语言,面向对象的三大特性,封装, 继承和多态。 之前的章节中我们讲到封装, 比如说,类就是一种封装,方法封装起来,给外面使用。 还有一种封装是迭代器的封装, 迭代器屏蔽了一些底层细节,不管底层的迭代器怎么实现,是原生指针还是一个类,统一使用 iterator 就可以来访问,都是没有问题的。

继承的核心特点呢就是一种代码的复用, 一种类层次的复用。 复用我们之前也见到过就是模板,模板也是一种复用,模板的复用就像我们之前说的是苦了编译器, 是一种函数层次的复用。

继承允许我们在保持原有类特性的继承上进行扩展,增加方法(成员函数)和属性(成员变量),这样来产生新的类, 称为派生类。

打个比方, 假设有一个老师类 和学生类,

Cpp 复制代码
class Student
{
public:
	void identity()
	{
		// ... 
	}

	void study()
	{
	 	//...
	}

protected:
	string _name = "peter";
	string _address;
	strng _tel;
	int _age = 18;

	int _stuid; // 学号

};

class Teacher
{
	
public:
	void identity()
	{
		// ... 
	}

	void teaching()
	{
		// ....
	}

protected:
	sring _name = "张三";
	int _age = 18;

	string _address;
	string _tel;

	string _title;
};

可以看到这个老师类和学生类里面有很多相似的信息, 比如 identity() 身份函数, 还有姓名, 电话号,地址, 年龄等等。 如果定义成两个类就像这样就会显得很冗余,冗余总是不好的 。 如果定义成一个类,也不行, 因为还有一些别的不同的东西, 比如说学生有学号,老师有职称,还有学生要学习,老师要授课。这是定义在一个类里面解决不了的。

那我们该怎么做呢? 这个时候就可以用继承来解决。 把这两个类含有的公共部分提取出来,抽象出一个类,让老师和学生都继承者一个类就好。

比如,我们把老师学生都有的这些属性或者方法提取出来,统一放到一个 Person 类里面:

C++ 复制代码
class Person
{
public:
	void identity()
	{
		//...;
	}
protected:
	string _name;
	int _age;
	string _tel;
	string address;


};

class Student : public Person
{
public:

	void study()
	{
	 	//...
	}

protected:
	
	int _stuid; // 学号

};

class Teacher : public Person
{
	
public:

	void teaching()
	{
		// ....
	}

protected:
	
	string _title;
};

可以看到,通过继承来让 student 和 teacher 来对 person 进行复用, 冗余问题就解决了,而且两个派生类在原有类的基础上进行扩展,得到不同的派生类,具有各自的特点。这样就完美解决我们上述提到的代码冗余问题,这也是继承的其中一个意义,进行代码发复用,父类里面有,子类里面也有。

1.2 继承定义

继承的定义格式用到了我们之前讲到的访问限定符,可以看到刚才的学生类和老师类就用到了public继承。 而且细心的话,会发现对于成员变量,我们并没有使用 private 私有, 而是使用了 proteceted 保护 。

这就是定义派生类的时候的格式, 通过访问限定符来确定继承方式, 基类(也叫父类)里面的不同访问限定符的成员变量搭配不同的继承方式会在子类里面产生不同的访问限定效果。 换句话说,从父类里面继承下来的那一部分的访问限定符,但是还有能不能使用的情况,和他们原来在父类里面的访问限定符和继承方式有关。

按理来说,不同的组合搭配起来一共是 3*3 是九种,这里有一个比较好记的方法。

我们分成两类来看, 首先就是父类里面的 private 对象, 不管继承方式是是什么在子类里面都是不能够使用的,子类里面是看不到的,相当于子类里面不能访问,是父类独有的。但是,是继承下来了的,只是语法上子类不能访问。

另外一类就是父类里面public 和 protected ,修饰的对象,他们继承到子类的情况是他们的访问限定符和继承方式取权限小的那个。我们任务 public > protected > private , 也就是说 父类里面的 public 对象 protected 继承下来 在子类里面是 protected 的, protracted 对象 private 继承下来就是 private 的。 上述这两种情况就涵盖了那九种情况, 理解着记忆下来就好。

我们之前在讲访问限定符的时候,讲到这个 private 和 protected 的区别, 当时我们从类里面和类外面使用的情况来说的话,这两个没有区别, 他们的区别其实在继承这里体现出来, private 对象无论怎么继承下来后子类里面看不到, protected 是可以被继承下来给子类里面用的。

对于父类里面私有对象, 虽然在子类里面不可见,但是如果子类里面想要使用的话,也是有方法的。 在父类里面使用就好了。 就像老爹有私房钱,虽然名义上以后都是你的,但是你直接找他要他肯定不给你。 假设老爹喜欢钓鱼,下次老爹钓鱼的时候,和他一起去, 到时候你说想吃个冰糕喝个饮料什么的,老爹就会掏钱买,这不也是变相的间接使用了老爹的私房钱。 同理,子类也是可以间接使用父类里面的私有成员的,只要在父类里面进行相应的草籽就好

实践中呢绝大多数情况下用到的都是公有继承,很少用到保护和私有继承 . 主要是因为保护和私有的继承的扩展维护性不好, 因为会缩小子类里面继承下来的对象的权限嘛, 如果多继承几次的话,权限一直在减小到时候类外面说不定就用不了了, 这肯定是我们不愿意见到的.

当然这两种继承方式有用到的情况吗?是有的,但是要一般要复杂不少的类才会用得到. 说实话, 可以理解为这里是有一些过度设计了.

继承方式呢不写也是 ok 的, 有默认的. class当子类继承 默认是私有继承, struct当子类继承 默认是共有继承. 建议用的时候还是写出来规范一些的好.

1.3 继承类模板

刚才我们讲到的是普通类的继承,那类模板的继承呢? 语法上差不多,只不过把普通的类变成了类模板而已.

来看下面这个例子, 之前说栈式一种容器适配器, 是一种组合的方式,其实继承也是可以的.

但是这里要注意一个问题就是按需实例化的问题,来看例子:

C++ 复制代码
namespace slxy
{
	template<T>
	class stack : public std::vector<T>
	{
	public:		

		void push(const T& x)
		{
			//push_back(x);  注意这里是不可以这样写的, 按需实例化,vector //是类模板用的时候要指定一下类域

			vector<T>::push_back(x);
		}


		void pop(const T& x)
		{
			vector<T>:: pop_back(x);
		}

		//...

	};

}

按需实例化是针对模板来说的,意思是用到谁实例化谁. 首先创建这个栈对象的时候,会实例化出一个 vector (以这个为例), 但是这个 vector 里面的方法例如 push_back 和 pop_back 并没有实例化,因为没有用到或者说没有被调用,创建对象的时候只用到了构造,所以就只实例化的vector的构造出来.

所以在这里写的时候就必须要指定一下,这里的push_back是这个类模板里面的,编译的时候告诉编译器有这个东西,到时候去对于的类模板里面找就好.

按需实例化还涉及到编译器检查的问题, 编译器对这个按需实例化的检查也是在逐渐进化的, 比如说 vs2013 对于涉及到模板的,不管是函数模板还是类模板都不会去检查, 即使不写分号,或者使用不存在的变量或者函数也不会报错, 真的去执行编译的时候发现找不到了才会报错.

vs2019 里面升级了一下, 不依赖模板的会去检查一下,在真的编译之前就会画个红线,

比如这里,这段放在 2013 下面编译器就不会去检查, 但是放在 2019 里因为这个 func() 不依赖模板,也不存在,这里会去画红线检查一下,当然,不写分号 2019里面不管这个 func() 是不是模板都会检查一下.

再看这一段,这个 x 依赖模板,所以不能确定这个 x 是什么, x 这句放 19 里面也不会检查.

关于这个类模板继承呢,还有一招. 这个 stack 是个容器适配器,所以他继承链表也行, 继承vector 也行, 如果我们想要切换的话,是不是会有些麻烦, 每次都要写出来 std::list 或者 std::vector , 如果有很多个需要切换那咋办?

所以这里就可以用个宏:

C++ 复制代码
#define CONTAINER std::vector

namespace slxy
{
	template<T>
	class stack : public CONTANIER<T>
	{
	public:		

		void push(const T& x)
		{
			//push_back(x);  注意这里是不可以这样写的, 按需实例化,vector //是类模板用的时候要指定一下类域

			CONTANIER<T>::push_back(x);
		}


		void pop(const T& x)
		{
			CONTANIER<T>:: pop_back(x);
		}

		//...

	};

}

用一个宏来代替前面那一段,到时候改的时候只需要改宏定义就好了。 很多人用宏就是这么用的,还是很舒服的。

2. 基类和派生类之间的转换

父类和子类的赋值兼容转换

首先一定记住是在公有继承的前提下, 子类的对象可以给给父类的对象, 子类的指针和引用可以给给父类的指针和引用, 权限不变。

这里会发生一个切片或者切割, 比如子类对象给给父类对象,会只有父类那一部分; 指针也是, 只会指向父类父类的那一部分; 引用的话就相当于是父类那一部分的别名。

一定注意切片的发生是不会发生类型转换的,因为不产生临时变量(临时变量具有常性, 可以验证一下,发生类型转换的话,不加 const 引用给不过去,但这里是可以的)

如果一个子类继承两个父类呢? 给给拿个父类就切哪个父类的一部分,子类里面的内存分布是这样的

派生类会依次先放父类的,然后在放派生类自己的,谁先被继承谁放在最前面。 如果把这个 p 给给 p1 的话, 那么 p1 就会指向红色框框的这个父类的这一部分 ; 把 p 给给 p2 的话, 就会指向绿色的 p2 的这一部分。

刚才讲的的子类转父类,反过来的话注意, 父类的对象无论如何都不能给给子类对象, 其实也很好理解,因为不知道子类里面会比父类里面多出来一下什么东西。 但是,父类的指针和引用可以向下转化为子类的指针和引用, 不能隐式转, 要显示写出来强转,这是可以的, 但是,绝对不推荐强转,因为这个父类指针可能指向子类,也可以就是指向一个父类对象,也可可能指向不同的子类对象,强转虽然能过编译,但是会有很大概率越界访问。 C++ 里面提供了一个操作符是 dynamic_cast , 可以把一个父类的指针或者引用安全的转给子类的指针或者引用。 但是的但是, dynamic_cast 使用的前提必须要求是多态类型,也就是这个父类里面要有虚函数,更加细节的问题可以学习完了多态以后去看看C++ 扩展学习类型转化那一章里面的。

3. 继承中的作用域

3.1 隐藏的规则

首先我们要记住的第一个结论,继承中的基类和派生类都有独立作用域。 这一点也很好理解嘛是吧。

派生类和基类中有同名成员,记住是成员,包括变量,派生类成员将屏蔽对基类同名成员的直接访问,这种情况叫隐藏。

翻译一下就是在派生类里面访问基类同名成员访问不到,只会访问到派生类里面的。

C++ 复制代码
class Person
{
protected :
        string _name = "⼩李⼦"; // 姓名 
        int _num = 111;         // ⾝份证号 
};

class Student : public Person
{
public:
        void Print()
        {
                cout<<" 姓名:"<<_name<< endl;
                // cout<<" ⾝份证号:"<<Person::_num<< endl;
                cout<<" 学号:"<<_num<<endl;
        }
protected:
        int _num = 999; // 学号 
};

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

可以看到这个例子,打印出来就是 999 而不是 111. 就近原则嘛。

但是如果想访问父类的也可以,指定一下类域就可以了 。 从这里我们也能看出, 继承是一个形象化的说法, 这里的继承实际是影响的编译时的查找规则, 子类里面有就直接用, 没有就去父类里面查找。

3.2 选择题 考察继承作用域

C++ 复制代码
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: A类和B类中的 func() 构成什么关系 重载 隐藏 还是没关系

答案是隐藏奥, 就是这一小节开头我们说的,基类和派生类都有自己独立作用域, 而重载要求是在同一作用域中的同名函数,构成重载。

当然这里大家看起来还是有点不太对头奥,因为刚才讲隐藏的时候有个地方没讲, 就是 基类和派生类里面构成隐藏甚至需要函数名相同就可以,参数不同没关系, 这两个函数如果一模一样,也是隐藏。

问题2: 上述程序的编译运行结果是什么, 编译报错 运行报错 还是正常运行

这里是编译报错奥, 因为构成隐藏了,子类里面调不到父类的,虽然有,但是掉不到。怎么改呢? 指定类域就好了

C++ 复制代码
b.A::func();

可以看到这选择题都用心险恶奥,还是要打好基础的好。 凭运气拿来的,凭本事丢掉奥,哈哈。

这里还有一点提醒,上面我们也看到了隐藏这个东西是很坑的,所以我们实现继承体系的时候,尽量不要有同名成员。


4. 子类的默认成员函数

默认成员函数就是不写也生成的, 之前我们讲类和对象部分的时候详细说过。

这里我们再来看看继承体系下,子类里面的默认成员函数有什么变化:

4.1 构造

子类里面构造函数初始化父类的成员的时候要调用父类的默认构造, 如果父类没有默认构造,就必须显示显示在初始化列表里面初始化。

这里看起来乱不少, 我们来理一理, 子类构造我们不写, 自己生成子类的默认构造的行为:

1, 内置类型不确定; 2, 自定义类型掉自定义类型的默认构造; 3, 继承的父类看成一个整体, 调用父类的默认构造

还有就是缺省值什么的,也能给,也会走初始化列表,这个和以前就没什么区别了。

总结一下就是, 对于内置类型和自定义类型,子类的默认构造和我们之前讲到的没有区别, 但是对于父类继承下来的部分,是看成一个整体, 这很重要,看成一个整体去调用父类的默认构造去初始化的。

如果父类没有默认构造怎么办? 如果我们不去管还和上面一样的话编不过去奥。 还有一件事, 记住我们是吧父类继承下来的部分看成整体去初始化, 不允许直接初始化父类的成员。 这里必须显示调用父类构造。

还是要走初始化列表显示调构造,但是这里看起来很奇怪,像个匿名对象一样,但是也没办法, 特殊处理嘛,别的也没什么好办法, 记住这里的用法就好。

4.2 拷贝构造

拷贝构造这里也是类似, 分为三个部分, 内置类型, 自定义类型 和 父类 , 和构造一模一样。 赋值重载, 析构也是类似。 只是多出来父类, 别的都和之前一样。 所以 考虑这些函数的时候需要多考虑的就是多出来的父类的那一块

我们之前说这三个都是配套的嘛,需要实现深拷贝和资源申请的时候才需要显示写,别的直接用默认生成的就好。

所以 student 类,在这种情况下, 我们是不用写的。

那如果要写的话,拷贝构造怎么写呢? 拷贝构造也是构造, 所以还是和构造一样, 但是拷贝构造需要传参需要传一个对象过来,那这怎么办? 答案就是赋值兼容转化奥, 传子类对象,初始化父类的时候会自己切片的。

C++ 复制代码
Student(const Student& s)
                : Person(s)
                , _num(s ._num)
       	{
                cout<<"Student(const Student& s)" <<endl ;
        }

这里在复习一个点奥,走初始化列表的顺序是按照对象声明的顺序来的, 和这个在初始化列表里面出现的顺序无关, 建议写成一样的。 内存里面存的顺序也和这个声明的顺序是一致的。

拷贝构造就像我们之前说的,如果想要踏踏实实走,还是要考虑一下自己写,不管有没有申请资源之类的。 因为他毕竟完成的是拷贝的工作, 默认生成的可能完不成我们想要的功能奥。

4.3 赋值重载

赋值重载也是一样,一体化嘛, 默认的也就够了, 如果要深拷贝的话再自己实现。 调父类的赋值重载,这里体现的还是切片。 还要考虑一下自己给自己赋值的情况

但这里还有一个问题, 按照上面我我们这样的写法会爆内存奥,栈溢出了。 可以看到我们一运行,程序就终止了奥,因为 vs下面有这个检查函数, 检查到栈溢出就终止程序了。 看一下调用堆栈,发现一直在调用 operator= , 为啥?其实就是无限递归了。 这又为啥? 这里是隐藏的锅奥。

还记得我们之前的隐藏吗? 父类和子类里面同名函数就构成隐藏,这里很特殊两个都叫 operator= , 解决就是指定类域调用就好了。

4.4 析构

对于析构函数来说也是一样的,前两个规则一样,父类单独看成一部分调父类的。 所以有一种办法就是子类的析构里面显示调一下父类的析构,析构是可以显示调的嘛。 但是其实还有别的方式,

运行一下这段代码

cpp 复制代码
class Person
{
public :
        Person(const char* name = "peter")
                : _name(name )
        {
              cout<<"Person()" <<endl;
        }
    
        Person(const Person& p)
                : _name(p._name)
        {
                cout<<"Person(const Person& p)" <<endl;
        }
    
        Person& operator=(const Person& p )
        {
                cout<<"Person operator=(const Person& p)"<< endl;
                if (this != &p)
                        _name = p ._name;
        
                return *this ;
        }
    
        ~Person()
        {
                cout<<"~Person()" <<endl;
        }
protected :
        string _name ; // 姓名 
};

class Student : public Person
{
public :
        Student(const char* name, int num)
                : Person(name)
                , _num(num )
        {
                cout<<"Student()" <<endl;
        }
        
        Student(const Student& s)
                : Person(s)
                , _num(s ._num)
        {
                cout<<"Student(const Student& s)" <<endl ;
        }
        
        Student& operator = (const Student& s )
        {
                cout<<"Student& operator= (const Student& s)"<< endl;
                if (this != &s)
                {
                        // 构成隐藏,所以需要显⽰调⽤ 
                        Person::operator =(s);
                        _num = s ._num;
                }
                return *this ;
        } 
        
        ~Student()
        {
                cout<<"~Student()" <<endl;
            	A::~Person();
        }
protected :
        int _num ; //学号 
};

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

其实就是我们之前的老师学生类, 运行以后会发现的父类析构函数调了六次。 其实,这个析构函数不需要我们自己显示调用,在子类析构函数结束以后,会自动调用父类的析构,注意是在子类的析构结束以后。这个是规定奥。

对于析构函数来说,后定义的先析构,所以析构的过程要保证先子后父, 显示调父类析构的话这个就不好说, 所以编译器给我们规定子类结束之后自动调父类的析构。 其实就是两个 call 的汇编。

像这里我们多析构的情况,对于没有申请资源的类来说问题不大,但是对于有资源申请的类来说就会有之前我们提到过的多释放的问题,还是要小心的。

还有一件事就是,细心的同学会发现这里我在调用 ~Person() , 的时候加上了 A:: , 也就是指定了类域,如果不指定类域的话调不到这个析构奥, 为什么呢? 其实编译器在处理析构的时候会把析构统一叫 destructor() 的函数,所以这里就构成隐藏了奥。 后面的我们学多态的时候会知道,父类的析构函数推荐写成虚函数,这里就是提一嘴。

总结一下上面的, 其实都是父类单独看一块,去复用父类的,但是方法上面有些不同要特殊注意一下,析构函数不需要因为编译器会自动调用, 后定义的先析构。 子类对象在初始化的时候先父后子,和初始化列表没关系,和这个声明有关系也就是内存中的分布。

一般的派生类都不会我们自己写这些,会让我们自己写的时候的派生类就会复杂一些。

4.5 如何实现一个不能被继承的类

在C++ 11 出来之前,如果一个类不想被别的类继承, 可以把这个类的构造函数私有就可以了。 父类的私有成员继承下来子类中不可见奥,不可见就调不到了。 但是这样有个缺点就是不明显,不定义子类对象的时候不报错。这个也好理解,就是子类对象初始化的时候才会调到构造,子类没有对象的时候和构造就没啥关系。

C++ 11 里面引入了一个关键字叫 final ,加在类名的后面,表示这个类不可被继承

c++ 复制代码
class Base final
{
 	//....   
}

5. 继承和友元

友元关系不能被继承奥,这个其实也很好理解

c++ 复制代码
class Person
{
public:
	friend void Display(const Person& p, const Sudent& s);
protected:
	string _name;
};


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

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

int main()
{
	Person p; Sudent s;

	Display(p, s);

	return 0;
}

这段代码正常编译时编不过去的,报错时不能访问子类里面的保护成员。

// 编译报错:error C2248: "Student::_stuNum": ⽆法访问 protected 成员

这个报错就是因为友元关系没有被继承下来。

但是其实还是有一些奇怪的编译报错,比如说这个缺少分号或者说逗号之类的,自己检查了一下发现没有缺少这些标点符号,那估计就是语法出问题了奥。

这里是为什么呢,就是父类友元声明这个 Display 的时候,这个时候不认识 Student 了, 因为我们说编译器在编译的时候只会去向上查找,提高编译速度嘛。

但是我又不能把这个 Student 放到上面 ,因为它要继承这个 Person, 这里就是出现了我们说叫相互依赖的情况,这时候怎么办呢? 加一个前置声明就好了, 告诉编译器这个 Student 是个类

cpp 复制代码
class Student;

额,差点跑题, 解决友元不能被继承的办法也很简单, 在子类里面继续放友元声明就好了。

6. 继承和静态成员

静态成员也是会被继承的,但是不会再生成新的。 换句话说, 基类里面定义了 static 的静态成员, 整个继承体系里面,只会有一个这样的成员, 不再是父类是父类,子类是子类了。 无论派生出多少个类,都只有一个 static 成员

我们可以通过地址来验证一下:

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

int Person::_count = 0;

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

int main()
{
    Person p;
    Student s;

    // 这⾥的运⾏结果可以看到⾮静态成员_name的地址是不⼀样的 
    // 说明派⽣类继承下来了,⽗派⽣类对象各有⼀份 
    cout << &p._name << endl;
    cout << &s._name << endl;

    // 这⾥的运⾏结果可以看到静态成员_count的地址是⼀样的 
    // 说明派⽣类和基类共⽤同⼀份静态成员 
    cout << &p._count << endl;
    cout << &s._count << endl;

    // 公有的情况下,⽗派⽣类指定类域都可以访问静态成员 
    cout << Person::_count << endl;

    cout << Student::_count << endl;

    return 0;
}

可以看到,静态成员的地址都是一样的,也就证明了只有一份这个事。

还有比较特殊的就是静态成员我们说突破类域就能访问, 也可以通过对象来指定访问。 可以理解为静态成员是个全局的,但是受类域限制。

虽然静态成员可以指定对象来访问,但是一般都是指定类域去访问的。

7. 多继承以及菱形继承问题

7.1 继承模型

之前我们一直讨论的是单继承, C++ 里面是支持多继承的。但是也是会延申处一些问题。

首先单继承: 一个派生类直接继承基类时,称这个继承关系为单继承。

多继承是什么呢?多继承:一个派生类有两个或以上直接基类时称这个继承关系是多继承, 多继承再内存中的模型是,先继承的在前面,后继成的在后面。 派生类放到最后面。 简单说,多继承就是多个直接父类,每个父类之间用 ',' 隔开。

到这里看似一切正常, 多继承其实也是合理的,C++ 面向对象的语言模拟现实世界,现实世界里面是有多继承这个情况的,比如说番茄又属于水果有属于蔬菜,放两边都可已。 多继承是合理的壮大自己。

但是,恶心的来了。 菱形继承:菱形继承是多继承的一种特殊情况,用一张图大家就能懂:

可以看到这样的继承方式就是菱形继承, 菱形继承里面就会引发一系列的问题,很直观的看出,菱形继承会导致数据冗余和二义性。 学生 和 老师类继承了 人, 而研究生这个类同时继承了老师和学生, 那么 这个人里面的成员在这个研究生里面就会有两份,这就是数据冗余。

那能不能避免菱形继承呢?也不可以, 支持多继承,肯定就会出现菱形继承, 像Java里面,直接就不支持多继承,自然也就规避掉了这个问题,说实话,现实里面不要设计出菱形继承,会被喷死的。

话说回来, 二义性是为什么呢? 假设父类里面有一份 _name , 这个名字在学生里面有 在老师里面也有

研究生继承了这两个类,那研究生里面 _name 在访问的时候,访问到的是学生里面的名字还是老师里面的名字呢? 这两个可是不一样的。这就是二义性。 在编译的时候,直接访问二义性的成员是编译不通过的,会报错指向不明确。

解决二义性的办法也很简单,指定类域就可以了

CPP 复制代码
int main()
{
        // 编译报错:error C2385: 对"_name"的访问不明确 
        Assistant a;
        a._name = "peter";

        // 需要显⽰指定访问哪个基类的成员可以解决⼆义性问题,但是数据冗余问题⽆法解决 
        a.Student::_name = "xxx";
        a.Teacher::_name = "yyy";

        return 0;
}

虽然二义性可以通过指定类域访问来解决,但是这里的数据冗余没办法解决。

其实,这里在逻辑上也没解决说实话,名字的个数就不应该有两个, 硬要说的话,小名什么的也说的过去,但是这个性别,年龄怎么说, 这是不合理的。 一个人的信息,要说的话,那都应该是正式信息,就算有多个,那也应该是多个变量,而不是一个变量两个含义。 说实话,当时祖师爷在设计的时候,也没想到菱形继承这个事。

菱形继承这个东西会让对象变大,很恶心, 而且每次访问都要指定类域。 后来的一些语言,比方说java , 看着C++ 踩坑了,后续也就支持多继承了, 需要用到多继承的时候用点别的方法替代,比如说组合,这个后面说。

还是那句话,建议不要设计处菱形继承来。 但是菱形继承有吗?也有, 库里面就有一个很经典的菱形继承就是 io 流的时候

如上图所示,首先,istream 和 ostream 直接继承 ios, iostream 又同时继承 istream 和 ostream ,这就是典型的菱形继承。

7.2 虚继承

很多人说C++ 语法复杂,多继承就是一个体现, 有了多继承,就存在菱形继承, 有了菱形继承就有菱形虚拟继承,底层实现会复杂,而且效率也会有损耗,所以,还是那就话,最好不要设计出菱形继承。

那么,什么是虚继承呢? C++ 里面有一个关键词 virtual ,虚的嘛, 在继承方式之前加上这个关键字的继承叫虚继承:

C++ 复制代码
class Person
{
public:
        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:
        string _majorCourse; // 主修课程 
};

加上 virtual 设计成虚继承,就可以解决菱形继承的数据冗余和二义性的问题了。那么这是怎么解决的呢?其实就是都合成一份了。 父类里面的_name , Student 里面的 _name 和这个 Teacher 里面的_name 就都是同一个了。

自然,到了最后的助手里面,也就是只有一份了。 换句话说, 加上 virtual 以后, _name 就只有Person 里面一份了。

思考一下,下面的这段代码:

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

        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:
        string _majorCourse; // 主修课程
};

int main()
{
        // 思考⼀下这⾥a对象中_name是"张三", "李四", "王五"中的哪⼀个?
        Assistant a("张三", "李四", "王五");

        return 0;
}

之前刚说只有基类里面的一份,所以,name1 和 name2 都不算数, 这里 a 对象里面的应该是王五才对。在构造 Assistant 对象时,像是Student 和 Teacher 里面这些_name 的构造调用会被忽略。

那这里能不能只写一个,就是只初始化Person的,不行奥,必须三个都有,张三 和 李四不走,直接跳了,不起作用,走了就三次初始化name 到底走谁呢?, person 走王五。 所以,这里又难受又奇怪。

库里面的 io 流就是走的加上 virtual 的这个解决办法。

看起来还行,还有比较绕的一个点,加上这个virtual 可以认为对这两个虚继承的类没有影响, 在使用的方面,对研究生这个类是有影响的。 因为解决数据冗余和二义性是把最后这个类里面只用父类里面的一份了。

还有一件事,就是各种对这个virtual 的乱用都是会报错的奥,比如说,一个不加一个不加,或者说基类什么的全加上,等等之类都会报错,比如坐轮椅给轮子, 所以要加对地方。 通俗一点讲就是加在这个菱形继承的腰线上。

还有一点就是这里的菱形继承不是说就只有我们上面提到的,真的是一个菱形,而是逻辑菱形,什么意思:

这个也是菱形继承奥, 那么这里就要思考一下, 这个 virtual 加在哪两个类上面的了。 答案就是加在 B,C 着两个类上。 E 里面谁会有两份, 加载它的直接子类上。 哪个类产生数据冗余和二义性,加在哪里。

注意奥这里D是不能加 virtual 的, 加上就是 virtual 使用不当的说。 就像拄拐给三个拐杖一样,没有意义奥。

综上所述,不要玩菱形继承,会非常恶心难受的,包括还有这个 virtual 加在哪里的问题。

其实,也可以说,多继承是一个C++的一个缺陷之一,就是因为不可避免出现菱形继承,后续很多语言都没有多继承,因为看C++ 踩坑了。

这里再补充一下这个菱形继承的答辩的地方,就是加上virtual以后底层会很复杂。 这个点再后续的扩展学习中会详细补充,在这里简述一下, 首先, 加了虚继承的可以叫公共基类,不是很严谨这里,其实就是有有两份的那个父类。

加上 virtual 以后的对象模型就变成这样了:

这个公共基类被放在了一个单独的地方,通过偏移量来访问,而不是两个子类里面都有了。 放这个公共基类会涉及到一个虚基表的概念,其实就是放到公共基类的地址。 类似后续的虚表,放的是函数指针。

更多细节后续补充。

7.3 多继承指针便宜问题

多继承模型在内存中的分布呢就是先继承的在前面,后继承的在后面,派生类的在后面。

来看下面一道题:

C++ 复制代码
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;
    
    return 0;
}

多继承中指针偏移问题?下⾯说法正确的是( )

A:p1 == p2 == p3 B:p1 < p2 < p3 C:p1 == p3 != p2 D:p1 != p2 != p3

这里涉及到的是赋值兼容转换的问题,子类指针或者引用可以给给父类的指针和引用,指向父类的那一部分,在多继承下也是一样的,

图中就是这个多继承在内存中的分布情况,可以看到 p2 就指向的是 Base2 开始的地方, p1 和 p3 虽然都指向开头,但是一个是 父类的指针一个是子类的指针。

总结一下就是,在内存角度看, 内存的分布是根据声明的顺序分布的,先继承或者说先声明的再前面,还有一个类似的就是初始化列表的初始化顺序。 还要补充的一个点就是在这个 Derive 对象里面,内存分布是由低地址到高地址的。

补充一个 using 的新用法, 可以当成 typedef 来用 using A = B;

关于 IO 库里面菱形虚拟继承,可以去看看cppreference 里面有详细的解释图。

或许看的时候可能会发现有些类名不一样,也是正常的,因为我们现在知道的 istream ostream 等等都是被 typedef 过的。因为编码的问题, 比如说还有什么 wostream 宽字符嘛。

8. 继承和组合

本章开头的时候就提到过了继承和组合, 现在我们详细说明一下。

首先,继承和组合都是类复用的一种方式, 注意一下,这里的继承单指 public 继承奥, 保护和私有继承不常用。 还有多继承也是有需求的,菱形继承知道是坑别用就行。

public 继承呢是一种 is_a 的关系, 而组合式一种 has_a 的关系。

什么意思呢?从软件工程和测试的角度,有一个黑箱和白箱的概念, 继承就是一种白箱, 组合是一种黑盒。

举个例子,大家觉得黑盒测试简单一些,还是白盒测试简单一些? 答案是黑盒更简单一些, 因为黑盒看不见具体实现,只需要从使用的角度来测试,而白箱的话因为看得到代码和实现,更侧重的是从代码的允许逻辑角度测试。

比如白箱的话要设置一个覆盖率,一个 if else if else 每一个逻辑里面都要跑一些测试点。 所以,一般白箱是开发人员自己做,当然,测试做的话也更好。

再举个具体的例子来说,有一个东西叫 UT, 单元测试覆盖度, CI 自动化测试系统会检查这个覆盖度, 如果低于一定的值,办公室里面大电视会变红,差不多 80%, 等着领导点名吧。

除了黑盒和白盒, 还有一个性质叫耦合度,通俗一点说就是两个模块的关联程度。 软工里面一直说是高内聚,低耦合。耦合度低肯定是更好的。

在真实项目的时候,为了提高编译速度,一般都是类什么的模块分文件来,先打成动态库,然后再去编译。

对于继承来说, 子类公有继承父类的保护成员,其实是破坏了一部分的封装性。 实际耦合度低了,代码的可维护性也会强一些。

假设沸羊羊同学完成一个类, 喜羊羊同学在沸羊羊的类的基础上完成另外一个类, 沸羊羊假设有暴露了一百个接口给喜羊羊。 沸羊羊暴露一百个接口给喜羊羊,这里面但凡有一个要改,喜羊羊就要跟着改。 后来喜洋洋气死了,偷摸给沸羊羊来点绊子,沸羊羊被开除了。 新来的了懒羊羊顶替沸羊羊的工作。

懒羊羊害怕他也被开除,和喜羊羊商量怎么办不开除他, 喜羊羊告诉他,很简单,不要给 100 个接口那么多,就给五个左右, 其它的怎么改,我不关心,只要这五个你别动就行。 即使要动了,修改起来也是比较简单的。

其实这就是一种高内聚,低耦合, 开放的接口越少越好。

从这个角度来看的话,明显的是组合更好。 所以,在能使用继承和组合的时候,优先考虑组合。 但是也不是说全部都用组合,可以用 是 还是 有 来区分一下:

比如 奔驰是一辆车 奔驰有一辆车 别摸我是一辆车 别摸我有一辆车, 这样一看明显 has_a 更好。

奔驰有四个轮胎, 奔驰是四个轮胎 那这个情况肯定 is_a 更好。

所以,总结一句话来说,符合什么走什么,都符合推荐走组合,没有那么绝对。

一些? 答案是黑盒更简单一些, 因为黑盒看不见具体实现,只需要从使用的角度来测试,而白箱的话因为看得到代码和实现,更侧重的是从代码的允许逻辑角度测试。

比如白箱的话要设置一个覆盖率,一个 if else if else 每一个逻辑里面都要跑一些测试点。 所以,一般白箱是开发人员自己做,当然,测试做的话也更好。

再举个具体的例子来说,有一个东西叫 UT, 单元测试覆盖度, CI 自动化测试系统会检查这个覆盖度, 如果低于一定的值,办公室里面大电视会变红,差不多 80%, 等着领导点名吧。

除了黑盒和白盒, 还有一个性质叫耦合度,通俗一点说就是两个模块的关联程度。 软工里面一直说是高内聚,低耦合。耦合度低肯定是更好的。

在真实项目的时候,为了提高编译速度,一般都是类什么的模块分文件来,先打成动态库,然后再去编译。

对于继承来说, 子类公有继承父类的保护成员,其实是破坏了一部分的封装性。 实际耦合度低了,代码的可维护性也会强一些。

假设沸羊羊同学完成一个类, 喜羊羊同学在沸羊羊的类的基础上完成另外一个类, 沸羊羊假设有暴露了一百个接口给喜羊羊。 沸羊羊暴露一百个接口给喜羊羊,这里面但凡有一个要改,喜羊羊就要跟着改。 后来喜洋洋气死了,偷摸给沸羊羊来点绊子,沸羊羊被开除了。 新来的了懒羊羊顶替沸羊羊的工作。

懒羊羊害怕他也被开除,和喜羊羊商量怎么办不开除他, 喜羊羊告诉他,很简单,不要给 100 个接口那么多,就给五个左右, 其它的怎么改,我不关心,只要这五个你别动就行。 即使要动了,修改起来也是比较简单的。

其实这就是一种高内聚,低耦合, 开放的接口越少越好。

从这个角度来看的话,明显的是组合更好。 所以,在能使用继承和组合的时候,优先考虑组合。 但是也不是说全部都用组合,可以用 是 还是 有 来区分一下:

比如 奔驰是一辆车 奔驰有一辆车 别摸我是一辆车 别摸我有一辆车, 这样一看明显 has_a 更好。

奔驰有四个轮胎, 奔驰是四个轮胎 那这个情况肯定 is_a 更好。

所以,总结一句话来说,符合什么走什么,都符合推荐走组合,没有那么绝对。

相关推荐
蚰蜒螟1 小时前
走进 Linux 内核:从 touch 命令到磁盘 inode 的完整旅程
java·linux·前端
lqqjuly1 小时前
模型合并与融合:理论、算法与可运行实现—从损失曲面几何到多模型融合
算法
zzqssliu1 小时前
taocarts 跨境独立站 SEO 优化实践(多语言 + 反向海淘场景)
java·javascript·php
memcpy01 小时前
LeetCode 2144. 打折购买糖果的最小开销【贪心】
算法·leetcode·职场和发展
在繁华处1 小时前
Java从零到熟练(十一):Spring框架入门
java·开发语言·spring
小锋java12341 小时前
【技术专题】LangChain4j 开发Java Agent智能体 - 整合SpringBoot4
java·人工智能
十五年专注C++开发1 小时前
cereal 库:C++ 序列化的轻量之选
开发语言·c++·序列化·反序列化·cereal
lqqjuly1 小时前
设计模式:理论、架构与 C++ 实现—SOLID原则到23 种经典模式
c++·设计模式·架构
BestOrNothing_20151 小时前
C++零基础到工程实战(5.2.8)多文件声明定义函数和全局变量
c++·c++多文件编译·.h头文件·.cpp·函数声明定义