【C++】继承

目录

1.继承的概念及定义

1.1继承的概念

1.2 继承定义

1.2.1定义格式

1.2.2 继承关系和访问限定符

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

2.基类和派生类对象赋值转换

3.继承中的作用域

4.派生类的六个默认成员函数

构造函数

拷贝构造函数

赋值运算符重载函数

析构函数

继承与友元

继承与静态成员

5.复杂的菱形继承和菱形虚拟继承

菱形继承

菱形虚拟继承

深度解析菱形虚拟继承

6.继承的总结和反思

is-a has-a


今天我们来聊一聊面向对象三大特征之一:继承

在学习继承之前我想问两个问题,面向对象的语言为什么需要继承?继承所带来的好处是什么?

思考下:为什么需要继承呢!

例:使用类定义学生,定义一个学生需要的信息是:姓名,年纪,通讯地址,电话号码...等等

定义老师,定义老师需要的信息是:姓名,年纪,通讯地址电话...等等

可以看到老师和学生我所写的信息都是相同的,因为这是它们共有的属性

它们所特有的属性比如老师的职工号,学生的学号...等等

当我们定义它们时,我们需要将所有的公共信息写两遍,来创建学生和老师,那是不是太麻烦了

基于以上原因,继承就诞生了

我们可以创建三个类

继承演示:

1.person(用来存放公共信息)

2.student(用来存放学生独有属性)

3.teacher(用来存放老师独有属性)

#include <iostream>
using namespace std;

class person
{
protected:
	string _name; //姓名
	int _age; //年纪
};

class student : public person
{
public:
	int stuid; //学号
};

class teacher : public person
{
public:
	int jobid; //职工号
};

下面进入正题

1.继承的概念及定义

1.1继承的概念

继承(inheritance)机制是面向对象程序设计使代码可以复用的最重要的手段,它允许程序员在保持原有类特性的基础上进行扩展,增加功能,这样产生新的类,称派生类。继承呈现了面向对象程序设计的层次结构,体现了由简单到复杂的认知过程。以前我们接触的复用都是函数复用,继承是类设计层次的复用。

#include <iostream>
using namespace std;

class person
{
public:
	void Print()
	{
		cout << _name << endl;
		cout << _age << endl;
	}
protected:
	string _name = "nxbw";
	int _age = 21;
};

class student : public person
{
public:
	int stuid; 
};

class teacher : public person
{
public:
	int jobid;
};

int main()
{
	student s;
	teacher t;
	s.Print();
	t.Print();
	return 0;
}

可以看到person和student已经继承了person的成员变量和成员函数

其实实际上成员函数并没有被s,t对象继承,成员函数在公共代码区中,s和t可以直接去调用Print

转到到汇编可以看到,s和t调用的是同一个函数

所有我们可以理解为person被继承之后,继承person的类继承了成员变量,没有继承成员函数,但获得了成员函数的使用权

1.2 继承定义

1.2.1定义格式

下面我们看到Person是父类,也称作基类。Student是子类,也称作派生类。

1.2.2 继承关系和访问限定符

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

通过继承方式与访问限定符两两组合,确定基类在派生类中的访问权限

记两种特性,来帮你完成以上表格的记忆:

1.基类的私有(private)成员在派生类都是不可见的

不可见:语法上限制访问,派生类不能访问基类私有成员,类外也不能访问

protected:类外不可访问,派生类中可以访问

父类的私有成员在子类中是不可使用的

2.两两组合取小的一个(继承方式 + 基类访问符 = 基类成员在派生类中的访问权限)

权限关系:public > protected > private

如果我们不写继承方式默认继承方式:class(默认私有(private)继承)struct (默认公有(public)继承)

class test1 : person{};

struct test2 : person{};

其实两两组合中常用的

常用的继承方式:public

父类中常用的访问限定符:public,protected

正常情况下以上就够用了

总结:

1. 基类private成员在派生类中无论以什么方式继承都是不可见的。这里的不可见是指基类的私有成员还是被继承到了派生类对象中,但是语法上限制派生类对象不管在类里面还是类外面都不能去访问它。

2. 基类private成员在派生类中是不能被访问,如果基类成员不想在类外直接被访问,但需要在派生类中能访问,就定义为protected。可以看出保护成员限定符是因继承才出现的。

3. 实际上面的表格我们进行一下总结会发现,基类的私有成员在子类都是不可见。基类的其他成员在子类的访问方式 == Min(成员在基类的访问限定符,继承方式),public > protected > private。

4. 使用关键字class时默认的继承方式是private,使用struct时默认的继承方式是public,不过 最好显示的写出继承方式。

5. 在实际运用中一般使用都是public继承,几乎很少使用protetced/private继承,也不提倡 使用protetced/private继承,因为protetced/private继承下来的成员都只能在派生类的类里面使用,实际中扩展维护性不强。

2.基类和派生类对象赋值转换

派生类对象可以赋值给 基类的对象 / 基类的指针 / 基类的引用。这里有个形象的说法叫切片或者切割。寓意把派生类中父类那部分切来赋值过去。

基类对象不能赋值给派生类对象。

class person
{
public:
	void Print()
	{
		cout << _name << ' ';
		cout << _age << endl;
	}
protected:
	string _name = "nxbw";
	int _age = 21;
    string _sex;
};

class student : public person
{
public:
	int stuid;
};

int main()
{
	person p;
	student s;

    s = p; //父类对象赋值给子类对象是不允许的

	return 0;
}

这里伴随着类型转换,父类向子类的转换是不允许的

int main()
{
	person p;
	student s;

	p = s; //子类对象赋值给父类对象是允许的

	return 0;
}

程序可以正常允许,这就说明子类向父类的转换是可以的

根据以上逻辑

派生类可以赋值给基类,以下三种写法都可以使用

person p;
student s = p;  //对象赋值

person& ref = s;
person* ptr = s;

上面三种子给父的方式也叫赋值兼容(将子类中的父类成员切割给父类,或者是将子类中父类的切片给父类)

子类赋值给父类:

int main()
{
	person p;
	student s;

	s._name = "张三";	
	p = s;

	return 0;
}

可以看到子类确实将父类的成员赋值过去了

父类指向子类:

int main()
{	
	person* ptr = &s;
	ptr->_name = "赵五";
	ptr->_age = 100;
	return 0;
}

父类引用子类:


int main()
{
	person& ref = s;
	ref._name = "李四";
	ref._age = 90;

	return 0;
}

可以看到更改了子类对象中父类的对象的取值

总结:

一个子类对象一定是一个父类对象,向上转换(子类向父类转换)是在怎么样都可以的(对象,引用,指针),但是向下转换是不语法不允许的(也可以理解成子类中有的成员父类中没有,所有不能向上转换)

3.继承中的作用域

在继承体系中基类和派生类都有独立的作用域。

子类,父类定义重名成员,就近原则

例:

class A
{
public:
	int _num = 0;
};

class B : public A
{
public:
	int _num = 10;
};

int main()
{
	B b;
	cout << b._num << endl;

	return 0;
}

可以看到这个子类函数条用的是子类中的_num

若想访问父类中的重名变量:基类 + 域作用限定符(::)+ 基类成员变量 + 显示访问

class A
{
public:
	int _num = 0;
};

class B : public A
{
public:
	int _num = 10;
};

int main()
{
	B b;
	cout << b.A::_num << endl;

	return 0;
}

子类,父类中定义重名函数

class A
{
public:
	int fun()
	{
		return 0;
	}
};

class B : public A
{
public:
	int fun()
	{
		return 0;
	}
};

int main()
{
	B b;
	b.fun();

	return 0;
}

运行结果输出的是子类中的成员函数,与成员变量一样

父类和子类中的成员函数不是重载而是隐藏/重定义

隐藏/重定义:子类中的 成员变量/成员函数 将父类中的成员变量给 隐藏/重定义 了

注意:在实际中在继承体系里面最好不要定义同名的成员。

4.派生类的六个默认成员函数

构造函数

派生类不能去显示的在初始化列表中初始化基类或者父类的成员,它只能初始化自己的东西,派生类必须去调用基类的默认构造去初始化基类的成员,如果基类中没有默认构造函数,那么就需要在派生类中显示初始化基类成员

基类中有构造函数的情况

不需要我们在派生类的初始化列表中显示调用

class person
{
public:
	person(int age = 21, string name = "王五")
	{
		_name = name;
		_age = age;
	}
protected:
	int _age;
	string _name;
};

class student : public person
{
public:
	student(int n = 54321)
		:stuid(n)
	{}
private:
	int stuid;
};

int main()
{
	student s;


	return 0;
}

基类中没有默认构造的情况

需要我们在派生类的初始化列表中显示调用

class person
{
public:
	person(int age, string name = "王五")
	{
		_name = name;
		_age = age;
	}
protected:
	int _age;
	string _name;
};

class student : public person
{
public:
	student(int n = 54321, int age = 21)
		:person(age)
		,stuid(n)
	{}
private:
	int stuid;
};

int main()
{
	student s;

	return 0;
}

拷贝构造函数

如果在派生类的拷贝构造中没有显示调用基类构造函数,它就不会去调用基类中的拷贝构造

以下例子是将s1拷贝给s2,s1它只会将派生类中的成员拷贝给s2不会拷贝基类成员,s2中的基类成员会去调用基类的构造函数

class person
{
public:
	person(int age = 21, string name = "王五")
	{
		_name = name;
		_age = age;
	}
	person(const person& q)
	{
		_name = q._name;
		_age = q._age;
	}
protected:
	int _age;
	string _name;
};

class student : public person
{
public:
	student(int n = 54321)
		:person(21,"张三")
		,stuid(n)
	{}
	student(const student& S)
	{
		stuid = S.stuid;
	}
private:
	int stuid;
};

int main()
{
	student s1;
	student s2(s1);

	return 0;
}

可以看到s2的基类部分就是调用的构造函数初始化的

如果在派生类的拷贝构造中显示调用基类构造函数

class person
{
public:
	person(int age = 21, string name = "王五")
	{
		_name = name;
		_age = age;
	}
	person(const person& q)
	{
		_name = q._name;
		_age = q._age;
	}
protected:
	int _age;
	string _name;
};

class student : public person
{
public:
	student(int n = 54321)
		:person(21,"张三")
		,stuid(n)
	{}
	student(const student& s)
        :person(s)
	{
		stuid = s.stuid;
	}
private:
	int stuid;
};

int main()
{
	student s1;
	student s2(s1);

	return 0;
}

这里有一点需要注意的是为什么我们可以将student的对象直接传给person拷贝构造,前面说过派生类的对象可以向上转换,这里将S传给基类的拷贝构造是完全没问题的

在写这段代码时,我不小心将person(S)

放到了函数体中,导致了一个错误

赋值运算符重载函数

class person
{
public:
	person(int age = 21, string name = "王五")
	{
		_name = name;
		_age = age;
	}

	person(const person& q)
	{
		_name = q._name;
		_age = q._age;
	}

	person& operator=(const person& p)
	{
		if (this != &p)
		{
			_name = p._name;
			_age = p._age;

			return *this;
		}
	}
protected:
	int _age;
	string _name;
};

class student : public person
{
public:
	student(int n = 54321, int age = 21, string name = "")
		:person(age, name)
		, stuid(n)
	{}

	student(const student& s1)
		:person(s1)
	{
		stuid = s1.stuid;
	}

	student& operator=(const student& s)
	{
		if (this != &s)
		{
			person::operator=(s);
			stuid = s.stuid;

			return *this;
		}
	}

private:
	int stuid;
};

int main()
{
	student s1(12345, 30, "nxbw");
	student s2;

	s1 = s2;

	return 0;
}

先进行对基类的赋值,然后再给派生类进行赋值

析构函数

class person
{
public:

	~person()
	{
		cout << "~person" << endl;
	}

protected:
	int _age;
	string _name;
};

class student : public person
{
public:
	
	~student()
	{
		person::~person();
        cout << "~student" << endl;
	}

private:
	int stuid;
};

int main()
{
	student s1;

	return 0;
}

这里为什么需要给基类中的析构函数指定作用域呢,由于后面多态的原因,析构函数的函数名被特殊处理成了destructor,如果不指定类域访问就会造成基类中析构函数的隐藏/重定义,可以看到析构了三次,因为在派生类的析构函数中我显示调用了基类析构函数析构了一次,然后编译器又调用了两次析构(子类析构完编译器会再去调用父类的析构,析构一次,这里属于对同一个地址析构两次,可能内置类型做过特殊处理,导致没有报错,如果是自定义类型就会报错),而且析构的时候必须保证先子后父,如果显示调用父类析构,就无法保证先子后父

为什么要保证先子后父?

1.析构时需要符合栈的进出规则,基类部分先构造,派生类部分后构造,根据栈先进后出的原则,所以子类需要先析构,其次是父类

2.子类中可能会用到父类成员,父类中不会用到子类成员(父类中没有子类成员)

class person
{
public:
	person(int age = 21, string name = "王五")
	{
		_name = name;
		_age = age;
	}

	~person()
	{
		delete a;
	}

protected:
	int _age;
	string _name;
	int* a = new int(99);
};

class student : public person
{
public:
	student(int n = 54321, int age = 21, string name = "")
		:person(age, name)
		, stuid(n)
	{}

	~student()
	{
		person::~person();
		*a = 100;
	}

private:
	int stuid;
};

int main()
{
	student s1(12345, 30, "nxbw");

	return 0;
}

结论:不要显示析构,将析构工作交给基类自己做1比较靠谱

继承与友元

这里记住就可以,比较简单,就不举例子了

基类的友元只能基类自己使用,不能继承给派生类,派生类如果想使用基类中的友元,必须自己声明这个友元

继承与静态成员

派生类继承基类之后,它们中都有基类成员,但是它们是不相同的,都有自己独立的空间

静态成员属于基类和派生类,它们使用的是同一个静态成员,基类的静态成员不会单独拷贝一份去派生类,派生类知识得到了这个静态成员的使用权

问题:怎么算基类和派生类一共创建了多少对象?

思路:在父类中创建一共静态变量,然后将这个静态变量放到父类的构造函数中进行计数,最后计算出来的值就是创建对象的总个数

class person
{
public:
	person(int age = 21, string name = "王五")
	{
		_name = name;
		_age = age;

		++count;
	}

	static int count;
protected:
	int _age;
	string _name;
};

int person::count = 0;

class student : public person
{
public:
	student(int n = 54321, int age = 21, string name = "")
		:person(age, name)
		, stuid(n)
	{}

private:
	int stuid;
};

int main()
{
	student s1(12345, 30, "nxbw");
	student s2;
	student s3;
	student s4;
	student s5;

	cout << person::count << endl;
	return 0;
}

5.复杂的菱形继承和菱形虚拟继承

菱形继承

单继承:一个子类只有一个直接父类时称这个继承关系为单继承

多继承:一个子类有两个或以上直接父类时称这个继承关系为多继承

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

菱形继承的问题:从下面的对象成员模型构造,可以看出菱形继承有数据冗余和二义性的问题在Assistant的对象中Person成员会有两份

class person
{
public:
	int _age = 21;
};

class student : public person
{
protected:
	int stuid;
};

class teacher : public person
{
protected:
	int jobid;
};

class Assistant : public student, public teacher
{
protected:
	string _majorCourse;
};

int main()
{
	Assistant s;
	s._age;

	return 0;
}

如果我们直接使用Assistant中的继承的_age的话,编译器就报错,说意义不明确,因为它继承了两个基类,它们中都有成员变量_age编译器不知道我们使用的是哪一个基类中的

解决方法1:使用类名指定访问

int main()
{
	Assistant s;
	s.student::_age = 21;
	s.teacher::_age = 100;

	return 0;
}

这种方法解决了二义性的问题,但没有解决数据冗余的问题

菱形虚拟继承

解决方法2:菱形虚拟继承解决数据冗余和二义性问题

虚拟继承可以解决菱形继承的二义性和数据冗余的问题。如上面的继承关系,在student和 teacher的继承Person时使用虚拟继承,即可解决问题。需要注意的是,虚拟继承不要在其他地 方去使用。

使用方式:在student和teacher的继承方式前加上virtual

class person
{
public:
	person(int age = 21)
	{
		_age = age;
	}
	int _age;
};

class student : virtual public person
{
public:
	student(int n = 54321, int age = 21)
		:person(age)
		, stuid(n)
	{}
protected:
	int stuid;
};

class teacher : virtual public person
{
protected:
	int jobid;
};

class Assistant : public student, public teacher
{
protected:
	string _majorCourse;
};

int main()
{
	Assistant s;
	s._age = 200;

	return 0;
}

使用虚拟继承之后,在Assistant中,_age的变化

可以看到在使用Assistant改变_age成员之后所有类中的_age成员都改变了

这下就解决了数据冗余和二义性问题

深度解析菱形虚拟继承

我们先来看看不使用虚拟继承时的函数模型

class C
{
public:
	int a;
};

class A : public C
{
public:
	int b;
};

class B : public C
{
public:
	int c;
};

class D : public A, public B
{
public:
	int d;
};

int main()
{
	D s;
	s.A::a = 1;
	s.B::a = 2;
	s.b = 3;
	s.c = 4;
	s.d = 5;

	return 0;
}

这里我将类中的类型全部给的int,方便我们查看(如果是类似string类型就会很复杂)

取到s的地址之后,可以看到它构造,A是一个整体放在一起,B是一个整体放在一起,然后是整个类D

这就是没有使用虚拟继承时的一个模型

那么使用了虚拟继承的时候是怎么样的呢!

class A
{
public:
	int a;
};

class B : virtual public A
{
public:
	int b;
};

class C : virtual public A
{
public:
	int c;
};

class D : public B, public C
{
public:
	int d;
};

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

	return 0;
}

虚基表中包含两个数据,第一个数据是为多态的虚表预留的存偏移量的位置(这里我们不必关心),第二个数据就是当前类对象位置距离公共虚基类的偏移量。

可以看一下虚拟继承是怎么处理,数据冗余和二义性的,它没有将B,C继承的基类成员放到B和C中而是将它们继承的变量放到了D中最后的位置,并且给了B和C分别一个指针(虚基表)指向存放与a变量的偏移量 (通过这个指针去寻找变量a)

不只是D类的大模型改了,B和C类的大模型也改变的和D一样了,大模型相同方便处理

偏移量有什么用了?

例:

B* ptr = &d1;
ptr->a;

在切割/切片时,我们可以通过偏移量直接找到变量a的位置,从而访问到a,这就是偏移量的作用

int main()
{
	D d1;
	B d2;
	d1.a = 13;
	d2.a = 10;

	B* ptr = &d1;
	ptr->a++;

	ptr = &d2;
	ptr->a++;

	return 0;
}

通过汇编可以看到它们的工作方式是一样的,只是它们服务的不是同一个变量而已

6.继承的总结和反思

1. 很多人说C++语法复杂,其实多继承就是一个体现。有了多继承,就存在菱形继承,有了菱 形继承就有菱形虚拟继承,底层实现就很复杂。所以一般不建议设计出多继承,一定不要设 计出菱形继承。否则在复杂度及性能上都有问题。

2. 多继承可以认为是C++的缺陷之一,很多后来的OO(面向对象编程语言)语言都没有多继承,如Java。

3. 继承和组合

  • public继承是一种is-a的关系。也就是说每个派生类对象都是一个基类对象。 组合是一种has-a的关系。假设B组合了A,每个B对象中都有一个A对象。
  • 优先使用对象组合,而不是类继承 。
  • 继承允许你根据基类的实现来定义派生类的实现。这种通过生成派生类的复用通常被称为白箱复用(white-box reuse)。术语“白箱”是相对可视性而言:在继承方式中,基类的内部细节对子类可见 。继承一定程度破坏了基类的封装,基类的改变,对派生类有很大的影响。派生类和基类间的依赖关系很强,耦合度高。
  • 对象组合是类继承之外的另一种复用选择。新的更复杂的功能可以通过组装或组合对象来获得。对象组合要求被组合的对象具有良好定义的接口。这种复用风格被称为黑箱复用(black-box reuse),因为对象的内部细节是不可见的。对象只以“黑箱”的形式出现。 组合类之间没有很强的依赖关系,耦合度低。优先使用对象组合有助于你保持每个类被封装。
  • 实际尽量多去用组合。组合的耦合度低,代码维护性好。不过继承也有用武之地的,有些关系就适合继承那就用继承,另外要实现多态,也必须要继承。类之间的关系可以用继承,可以用组合,就用组合。

is-a has-a

is-a表示的是属于的关系,比如:兔子属于一种动物,张三属于人类(继承关系)

例:宝马类属于车类,是is-a关系

// Car和BMW Car和Benz构成is-a的关系
class Car {
protected:
	string _colour = "白色";  // 颜色
	string _num = "陕ABIT00";
	// 车牌号
};
class BMW : public Car {
public:
	void Drive() { cout << "好开-操控" << endl; }
};

has-a表示的是组合,包含关系。比如: 计算机包含屏幕,等一系列组件,那我们就不能说屏幕属于一种计算机(不能说是继承关系)

例:车包含轮胎,是has-a关系

// Tire和Car构成has-a的关系
class Tire{
protected:
string _brand = "Michelin";  // 品牌
size_t _size = 17;  // 尺寸          
};

class Car{
protected:
string _colour = "白色";   //颜色   
string _num = "陕ABIT00";  //车牌号
Tire _t;                   //轮胎
};  

如果两个类是is-a,也可以看作has-a,那么尽量使用has-a(组合)

笔试面试题

1. 什么是菱形继承?菱形继承的问题是什么?

菱形继承是多继承的一种特殊情况,一个派生类继承了两个及以上的基类对象,并且基类对象继承了相同的一个类,这就导致派生类中继承了多个相同的成员变量,因此会导致数据冗余和二义性

2. 什么是菱形虚拟继承?如何解决数据冗余和二义性的?

菱形虚拟继承就是在B和C类的腰部加上virtual,虚拟继承通过为对象模型添加虚基指针和虚基表来解决数据冗余和二义性的问题,将继承的相同成员储存在内存最下面,然后使用存储在对象中的虚基指针指向虚基表来找到与该成员的偏移量,通过偏移量去找到该成员的位置并访问它

3. 继承和组合的区别?什么时候用继承?什么时候用组合?

继承是is-a,表示属于关系,例如:轮胎属于车,这是一种继承关系,组合是has-a,表示包含关系,例如:兔子包含兔腿,是一种组合关系,如果两个类的关系是is-a,那就使用继承,如果两个类的关系是has-a,那就使用组合,如果是is-a又是has-a就使用组合

上一篇:【STM32】STM32简介-一、单片机


下一篇:鸿蒙开发:arkTS FolderStack容器组件-基本概念