C++中的继承

2025博客之星年度评选已开启 10w+人浏览 1.6k人参与

一、继承的概念及定义

1. 引入继承

例如我们要定义学生、教师、图书管理员……一系列的类,会发现他们都有一些共有属性例如:名字、年龄、地址、电话……等。

每一个类都有的公有属性,每一次书写是不是有点太麻烦了,即代码过于冗余了?

解决方案:继承——复用!

2. 继承的概念

继承(inheritance)机制是面向对象程序设计使代码可以复用的最重要的手段,它允许程序员在保持原有类特性的基础上进行扩展,增加功能,这样产生新的类,称派生类

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

#include<iostream>
using namespace std;

//父类Person
class Person
{
public:
	void Print()
	{
		cout << "name:" << _name << endl;
		cout << "age:" << _age << endl;
	}
protected:
	string _name = "peter"; // 姓名
	int _age = 18;  // 年龄
};

//子类Student
class Student : public Person
{
protected:
	int _stuid; // 学号
};

//子类Teacher
class Teacher : public Person
{
protected:
	int _jobid; // 工号
};

// 继承后父类的Person的成员(成员函数+成员变量)都会变成子类的一部分。这里体现出了
//Student和Teacher复用了Person的成员。下面我们使用监视窗口查看Student和Teacher对象,可
//以看到变量的复用。调用Print可以看到成员函数的复用。
int main()
{
	Student s;
	Teacher t;
	s.Print();
	t.Print();
	return 0;
}

继承后父类的Person的成员(成员函数+成员变量)都会变成子类的一部分。这里体现出了Student和Teacher复用了Person的成员。下面我们使用监视窗口查看Student和Teacher对象,可以看到变量的复用。调用Print可以看到成员函数的复用。

在这里插入图片描述

理解继承的对象模型:

  • 例如B继承A:B的成员有两个部分,一部分是自己,一部分是父类。它是把父类成员当成一个子对象去处理的,所以父类那一部分成员的必须去调用父类的构造去初始化,调用父类的析构去清理资源。即是把父类一个整体当做子类的一个成员。
  • 对象模型只与成员变量有关系,与成员函数没关系,编译好的成员函数都是放在代码段的只有一份。

3. 继承的定义

3.1 定义格式

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

在这里插入图片描述

3.2 继承关系和访问限定符

在这里插入图片描述

访问限定符和继承方式都一样有public、protected、private三种,两两组合继承基类成员访问方式的变化共有9种。

在没有继承之前可以认为访问限定符protected、private是一样的,但现在有了继承他们就不一样了

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

继承基类成员访问方式的变化共有9种:

在这里插入图片描述

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

//基类
class Person
{
private:
	string _name = "peter";
	int _age = 18;
};
//派生类
class Student : public Person
{
public:
	//父类私有成员,子类用不了(无论什么方式继承)
	void func()
	{
		cout << "name:" << _name << endl;
		cout << "age:" << _age << endl;
	} 
};

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

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

4、使用关键字class是默认的继承方式是private,使用struct时默认的继承方式是public,和默认的访问限定符一样。建议:最好显示的写出继承方式。

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

6、小结:

  • 私有不可见
  • 基类在子类的访问方式取小的
  • 一般都是public继承

二、基类和派生类对象赋值转换

  • 我们知道一般不同的类型之间赋值都不是直接赋值的,他们之间都会先进行类型转换!
  • 派生类对象 可以赋值给 基类的对象 / 基类的指针 / 基类的引用。这里有个形象的说法叫切片或者切割。寓意把派生类中父类那部分切来赋值过去
    在这里插入图片描述
//基类
class Person
{
private:
	string _name = "peter";
	int _age = 18;
};
//派生类
class Student : public Person
{
protected:
	int _stuid; // 学号
};

int main()
{
	Student s;
	//子类对象可以赋值给父类对象/指针/引用
	Person p = s;
	Person *pp = &s;
	Person &rp = s;
	return 0;
}
  • 这里子类赋值给基类是发生了赋值兼容(也可叫做切割或切片)与前面不同,前面不同类型之前赋值都会发生类型转换中间会产生一个临时变量(临时变量具有常性),而子类赋值给基类不会产生一个临时变量!
//基类
class Person
{
private:
	string _name = "peter";
	int _age = 18;
};
//派生类
class Student : public Person
{
protected:
	int _stuid; // 学号
};

int main()
{
	//不同类型赋值,在赋值之前会进行类型转换,类型转换会产生一个临时变量,临时变量具有常性
	int i = 0;
	//double& d = i;//报错,因为不同类型之前会发生类型转换,并且引用权限不能放大
	const double& d = i;

	//子类赋值给基类是发生了赋值兼容(也可叫做切割或切片),没有发生类型转换
	Student s;

	//证明:子类赋值给基类是赋值兼容(也可叫做切割或切片),没有类型转换中间没有产生临时变量
	Person& rp = s;
	return 0;
}
  • 理解什么是切割——把一个子类对象认为是一个特殊的父类对象,子类赋值给基类就是把子类里面父类的成员切割出来拷贝给基类。
  • 注意:基类对象不能赋值给派生类对象。
//基类
class Person
{
private:
	string _name = "peter";
	int _age = 18;
};
//派生类
class Student : public Person
{
protected:
	int _stuid; // 学号
};

int main()
{
	//基类对象不能赋值给派生类对象
	Student s;
	Person p = s;//报错
	return 0;
}
  • 基类的指针或者引用可以通过强制类型转换赋值给派生类的指针或者引用。但是必须是基类的指针是指向派生类对象时才是安全的。这里基类如果是多态类型,可以使用RTTI(Run-Time Type Information)的dynamic_cast 来进行识别后进行安全转换。
//基类
class Person
{
private:
	string _name = "peter";
	int _age = 18;
};
//派生类
class Student : public Person
{
protected:
	int _stuid; // 学号
};

int main()
{
	Student s;
	//基类指针指向派生类对象
	Person *pp = s;
	//基类指针可以通过强制类型转换赋值给派生类指针
	Student *ps = (Student*)pp;
	return 0;
}

小结:向上转换都是可以的(子类赋值给基类是向上转换),向下转换对象是不可以的指针和引用要通过一些特殊条件才可以(基类赋值给子类是向下转换)

三、继承中的作用域

  1. 我们都知道,定义了一个类,这个类就会有一个类域
  2. 在继承体系中基类派生类都有独立的类域,有独立的类域就可以有同名的成员
  3. 子类和父类中有同名成员,子类成员将屏蔽父类对同名成员的直接访问,这种情况叫隐藏,也叫重定义(在子类成员函数中,可以使用基类::基类成员显示访问
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 s;
	s.Print();
	return 0;
}

在这里插入图片描述

  1. 需要注意的是如果是成员函数的隐藏,只需要函数名相同就构成隐藏。
//父类的func与子类的func构成什么关系?
//注意不构成重载关系(重载有一个限定是在同一个作用域),构成隐藏/重定义>关系!
//父子类域中,成员函数名相同就构成隐藏!
class Person
{
public:
	void func()
	{
		cout << "Person::func" << endl;
	}
protected:
	string _name = "小李子"; // 姓名
	int _num = 111;		// 身份证号
};
class Student : public Person
{
public:
	void func(int i)
	{
		cout << "Student::func" << endl;
	}
protected:
	int _num = 999; // 学号
};

int main()
{
	Student s;
	//s.func();//报错,子类的func隐藏了父类的func,要调用子类的func必须传参
	s.func(1);
	return 0;
}
  1. 注意在实际中在继承体系里面最好不要定义同名的成员。

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

6个默认成员函数,“默认”的意思就是指我们不写,编译器会帮我们自动生成一个,那么在派生类中,这几个成员函数是如何生成的呢?

  1. 派生类的构造函数必须调用基类的构造函数初始化基类的那一部分成员。如果基类没有默认的构造函数,则必须在派生类构造函数的初始化列表阶段显示调用。
  2. 派生类的拷贝构造函数必须调用基类的拷贝构造完成基类的拷贝初始化。
  3. 派生类的operator=必须要调用基类的operator=完成基类的赋值。、
  4. 派生类的析构函数会在被调用完成后自动调用基类的析构函数清理基类成员。因为这样才能保证派生类对象先清理派生类成员再清理基类成员的顺序。
  5. 派生类对象初始化先调用基类构造再调用派生类构造。
  6. 派生类对象析构清理先调用派生类析构再调用基类的析构。
  7. 因为后续一些场景析构函数需要构成重写,重写的条件之一是函数名相同。那么编译器会对析构函数名进行特殊处理,处理成destrutor(),所以父类析构函数不加virtual的情况下,子类析构函数和父类析构函数构成隐藏关系。
  8. tip:继承的基类成员按照继承的顺序会放在自己成员的前面
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
{
	//总结:
	// 1.设计理念就是各自干各自的活,
	//   即继承的基类成员构造/拷贝构造/赋值重载就去调用基类构造/拷贝构造/赋值重载,
	//   派生类自己成员构造/拷贝构造/赋值重载就去自己构造/拷贝构造/赋值重载

	//2.构造/拷贝构造/赋值重载一般需要我们显示调用,析构不需要编译器会帮我们自动调用

	//3.构造先父后子,析构先子后父
public:
	//派生类的构造函数必须调用基类的构造函数初始化基类的那一部分成员。
	//如果基类没有默认的构造函数,则必须在派生类构造函数的初始化列表阶段显示调用。
	//派生类对象先初始化基类的那一部分成员,最后在初始化自己的成员。即先调用基类的构造再调用派生类构造
	//注意:初始化的顺序跟初始化列表的顺序无关,跟成员声明的顺序有关(保证先父后子)。
	//tip:继承的基类成员会放在自己成员的前面
	Student(const char * name = "张三", int id = 0)
		:Person(name)
		,_id(id)
	{}

	//派生类的拷贝构造必须调用基类的拷贝构造完成基类的拷贝初始化
	Student(const Student& s)
		:Person(s)//注意:必须显示调用基类的拷贝构造,否则它调用的就不是拷贝构造而是调用默认构造了
		,_id(s._id)
	{}

	//派生类的operator=必须调用基类的operator=完成基类的赋值
	Student& operator=(const Student& s)
	{
		//不能自己给自己赋值
		if (this != &s)
		{
			//注意:派生类的operator=与基类的operator=构成隐藏关系
			Person::operator=(s);
			_id = s._id;
		}
		return* this;
	}

	//派生类的析构函数
	~Student()
	{
		//1.由于后面多态的原因,析构函数的函数名被特殊处理了,统一处理成destrutor,
		//所以子类析构函数和父类析构函数构成隐藏关系。
		//2.注意不能在子类析构函数中显示调用父类的析构函数,会造成多次析构,因为编译器会帮我们自动调用基类的析构。

		//因为显示调用父类析构,无法保证先子后父的析构顺序(派生类对象析构清理先调用派生类析构再调基类的析构,即先子后父)
		//所以在子类析构函数中编译器会帮我们自动调用父类的析构,这样就保证了先子后父的析构顺序
		delete _ptr;
	}
protected:
	int _id;
	int* _ptr = new int;
};

int main()
{
	//构造
	Student s1;

	//拷贝构造
	Student s2(s1);

	//赋值重载
	Student s3("李四", 1);
	s1 = s3;
	return 0;
}

小结:

  • 在继承中派生类的成员分为两部分,所以默认成员函数设计理念就是各自干各自的活
  • 构造/拷贝构造/赋值重载一般需要我们显示调用,析构不需要编译器会帮我们自动调用
  • 构造先父后子,析构先子后父
    在这里插入图片描述

五、继承与友元

友元关系不能继承, 也就是说基类友元不能访问子类私有和保护成员

class Student;
class Person
{
public:
	friend void Display(const Person& p, const Student& s);
protected:
	string _name; // 姓名
};

class Student : public Person
{
protected:
	int _stuNum; // 学号
};

void Display(const Person& p, const Student& s)
{
	//友元关系不能继承
	//Display是父类的友元,不是子类的友元
	cout << p._name << endl;//可以访问父类的成员
	//cout << s._stuNum << endl;//报错,不能访问子类的成员
}

int main()
{
	Person p;
	Student s;
	Display(p, s);
	return 0;
}

如果基类的友元也想成为子类的友元,那就在子类中友元声明即可。

六、继承与静态成员

基类定义了static静态成员,则整个继承体系里面只有一个这样的成员。无论派生出多少个子类,都只有一个static实例。

以前的继承,继承的成员是独立,即子类实例化时继承的基类那一部分成员也有自己的空间。但static静态成员的继承没有自己的空间,而是和父类共用一个空间。

静态成员属于父类和子类共享,在派生类中不会单独拷贝一份,继承的是使用权。可以将静态成员理解为生活中你家的厕所,只有一个,大家一起使用。

静态成员在继承中的应用场景:如果我们要统计子类student的个数,可以在父类Person中定义一个静态变量_count,每当调用一次基类的构造函数时_count就会++(子类的构造一定会调用父类的构造函数)。

class Person
{
public:
	Person() { ++_count; }
protected:
	string _name; // 姓名
public:
	static int _count; // 统计人的个数。
};
int Person::_count = 0;
class Student : public Person
{
protected:
	int _stuNum; // 学号
};

int main()
{
	Student s1;
	Student s2;
	Student s3;
	cout << " 人数 :" << Person::_count << endl;
	Student::_count = 0;
	cout << " 人数 :" << Person::_count << endl;

	cout << &Person::_count << endl;
	cout << &Student::_count << endl;
	return 0;
}

七、复杂的菱形继承及菱形虚拟继承

1. 菱形继承

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

在这里插入图片描述

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

在这里插入图片描述

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

在这里插入图片描述

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

在这里插入图片描述

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

int main()
{
	// 菱形继承会有二义性无法明确知道访问的是哪一个
	Assistant a;
	//a._name = "peter";//二义性报错,Student和Teacher都有_name不知道访问
	
	// 我们可以通过显示指定访问哪个父类的成员可以解决二义性问题,但是数据冗余问题无法解决
	a.Student::_name = "xxx";
	a.Teacher::_name = "yyy";
	return 0;
}

tip:虽然我们可以通过显示指定访问哪个父类的成员可以解决二义性问题,但是数据冗余问题无法解决

tip:继承的基类成员按照继承的顺序会放在自己成员的前面,即在对象模型中谁先继承谁就在前面

2. 菱形虚拟继承

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

虚拟继承即在腰部的位置加上关键字virtual即可。

class Person
{
public:
	string _name; // 姓名
};

//虚拟继承在腰部的位置加上关键字virtual
class Student : virtual public Person
{
protected:
	int _num; //学号
};
class Teacher : virtual public Person
{
protected:
	int _id; // 职工编号
};

class Assistant : public Student, public Teacher
{
protected:
	string _majorCourse; // 主修课程
};

int main()
{
	// 菱形虚拟继承解决了菱形继承的二义性
	Assistant a;
	a._name = "peter";
	return 0;
}

3. 虚拟继承解决数据冗余和二义性的原理

如下,我们给出了一个简易的菱形继承:

class A
{
public:
	int _a;
};

//class B : public A
class B : virtual public A
{
public:
	int _b;
};

//class C : public A
class C : virtual public A
{
public:
	int _c;
};

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

int main()
{
	D d;
	d.B::_a = 1;
	d.C::_a = 2;
	d._b = 3;
	d._c = 4;
	d._d = 5;
	return 0;
}

F10启动调试,通过内存窗口观察对象的模型

下图是菱形继承的内存对象成员模型:这里可以看到数据冗余

在这里插入图片描述

下图是菱形虚拟继承的内存对象成员模型:这里可以分析出D对象中将A放到了对象组成的最下面,这个A同时属于B和C,那么B和C如何去找到公共的A呢?这里是通过B和C的两个指针,指向的一张表。这两个指针叫虚基表指针,这两个表叫虚基表。虚基表中存的偏移量(相对距离),通过偏移量可以找到下面的A。

在这里插入图片描述

问题:为什么D中的B和C部分要去找属于自己的A,即偏移量存在的意义是什么?

  • 普通场景:如D对象去访问_a就不需要偏移量去找,直接去最后找即可
  • 特殊场景:如D对象切割的时候,就需要使用偏移量去找属于自己公共的基类A在这里插入图片描述
  • 编译器访问成员的时候,它是不知道你对象的模型到底是什么,所以他就需要统一处理,虚拟继承对象只要访问公共的基类成员,都是先取到偏移量,计算公共的基类成员在对象中的地址,再访问

问题:为什么不直接存A的偏移量?

  • 除了存偏移量,还有其他值要存。如上图的虚基表第一个值为0因为他要为其他值做预留,第二个值才是偏移量。
  • 所以为了节省空间,走了一个间接即使用一个指针指向一张虚基表(一个指针4bit,一个虚基表8bit)。
  • 实例化的不同D对象的虚基表指针指向同一个虚基表!

问题:我们说菱形虚拟继承解决了菱形继承的数据冗余和二义性问题。但从上面的两个对象内存成员模型看,二义性它是解决了,但不是说也解决了数据冗余吗,那为什么菱形虚拟继承对象却比菱形继承对象大(菱形虚拟继承对象模型的大小是24,菱形继承对象大小是20)?

  • 菱形虚拟继承是解决了数据冗余的问题,但是它也付出了代价,这个代价就是存了一个指针,这个指针指向一个虚基表。
  • 上面例子我们解决数据冗余的代价大于节省,所以让你认为没有解决数据冗余,但只要A对象逐渐变大,我们就可以感受到。
  • 数据冗余解决的就是在菱形继承中公共的基类不重复开空间,只有一个公共的基类。

小结:
  • 在腰线位置进行虚拟继承,底部位置进程多继承
  • 菱形虚拟继承之后对象模型就变了,它把基类放到了最后,不放到腰线的两个类里面
  • 腰线的两个类通过偏移量找到公共的基类(偏移量存在的意义)
  • 虚拟继承解决了数据冗余和二义性的问题(即只存在一个公共的基类在对象模型的最后)
    在这里插入图片描述
  • 在实际中多继承谨慎使用,避免搞出菱形继承!
  • 了解:库中就存在菱形继承
    在这里插入图片描述

八、继承的总结和反思

  1. 很多人说C++语法复杂,其实多继承就是一个体现。有了多继承,就存在菱形继承,有了菱形继承就有菱形虚拟继承,底层实现就很复杂。所以多继承谨慎使用,避免搞出菱形继承。菱形继承在复杂度及性能上都有性能的损失。
  2. 多继承可以认为是C++的缺陷之一,很多后来的OO语言都没有多继承,如Java。
  3. 继承和组合
class A
{
	//……
};

//继承
//继承是白箱复用:
//	1.在继承方式中,基类的内部细节对子类可见。在public继承下,基类的保护和公有成员子类都可以直接用;
//	2.派生类和基类间的依赖关系很强,耦合度高。(基类保护和公有成员变了,派生类一般也要随之而变)
class B : public A
{};

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值