深入浅出之C++中的继承

1、继承的概念和定义

  在进入本章内容之前,我们需要先明确什么是继承?继承的作用是什么?
  继承是为了完成类级别的代码复用,继承表示的是父子类的关系。
在这里插入图片描述

1.1 继承的概念

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

  比如我们定义了一个Doctor类和Nurse类,但是这两个类里面大部分内容都是相同的,只有个别信息是每个类独有的:

class Doctor								class Nurse
{
    
    											{
    
    
private:									private:
	string _name;								string _name;
	int _age;									int _age;
	string _tel;								string _tel;
	string _address;							string _address;
	...											...
	//特有的信息								//特有的信息
	int _Docid									int _Nurid
};											};

  上面两个类大部分信息都是重复的,基于这部分内容,我们想起以前我们在排序的时候,各种排序都需要交换元素swap函数,我们对swap函数提取出来,实现函数的复用,那么多个类之间也有重复的东西,能不能把他提出来呢?从而实现类的复用呢?
  答:我们把类公共的东西给抽象出来,形成一个特有的类Person,然后让Doctor和Nurse去继承Person。

  继承的语法就是在类的后面+:public(继承方式)+类(基类类名)
  Person就是父类/基类,Doctor和Nurse就是子类/派生类,所以继承的本质就是完成类级别的复用。
  继承后父类的Person的成员(成员函数+成员变量)都会变成子类的一部分

class Person		
{
    
    					
private:			
	string _name;	
	int _age;		
	string _tel;	
	string _address;
	...				
};					
class Doctor:public Person			   class Nurse:public Person
{
    
    								       {
    
    
private:								 private:
	//只写自己特有的						   //只写自己特有的
};									    };

1.2 继承的定义

//在上面的例子中,Person是父类/基类,Student和Teacher是子类/派生类。
//     派生类     继承方式    基类
class Doctor:   public    Person
{
    
    
protected:
	int _Docid; // 学号
};

1.3 继承关系和访问限定符

public继承 public访问
继承方式 protected继承 访问限定符 protected继承
private继承 private访问

  继承中存在三种继承方式和三种访问限定符,这两个东西组合决定了父类中的成员继承到子类当中去以后,它的访问方式是什么样的?无论成员变量还是成员函数,在基类里有一个访问方式,这个访问方式有三种,然后派生类继承基类,这个继承的方式又有三种方式,3*3=9,总共9种关系。

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

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

总结:
a. 基类private成员在派生类中无论以什么方式继承都是不可见的。这里的不可见是指基类的私有成员还是被继承到了派生类对象中,但是语法上限制派生类对象不管在类里面还是类外面都不能去访问它。
b. 基类private成员在派生类中是不能被访问,如果基类成员不想在类外直接被访问,但需要在派生类中能访问,就定义为protected。可以看出保护成员限定符是因继承才出现的。
c. 实际上面的表格我们进行一下总结会发现,基类的私有成员在子类都是不可见。基类的其他成员在子类的访问方式 == Min(成员在基类的访问限定符,继承方式),public > protected > private。
d. 使用关键字class时默认的继承方式是private,使用struct时默认的继承方式是public,不过最好显示的写出继承方式。
e. 在实际运用中一般使用都是public继承,几乎很少使用protetced/private继承,也不提倡使用protetced/private继承,因为protetced/private继承下来的成员都只能在派生类的类里面使用,实际中
扩展维护性不强。

2、继承中父子类的赋值转换

  我们的内置类型中,相近类型可以相互赋值,存在隐式类型转换,就算不是相邻类型,也可以通过强制类型转换赋值;那么父子类之间的对象可以相互赋值码?-可以!基类和派生类对象赋值转换,也叫切片。我们该怎么去研究切片呢?我们在下面的代码中,定义了一个子类和一个父类对象,然后相互赋值,查看结果。

//父类
class Person
{
    
    
public:
	string _name; // 姓名
	string _sex; // 性别
	int _age; // 年龄
};
//子类
class Student : public Person
{
    
    
public:
	int _Stuid; // 学号
};
int main(){
    
    
	//我们定义一个子类对象和一个父类对象
	Person p;
	Student s;

	//下面的1 2 3种情况都称为切割/切片
	//1、子给父 赋值可以
	p = s;
	//s = p;//报错,父给子赋值不可以
	//s = (Student)p;//强转也不可以,即把父类对象强转为子类类型,也是报错
	//子给父可以叫做切片(切割),父类有的成员变量我子类都有,我子类可以进行切割,把父类中的成员变量依次赋值过去

	//2、父类型的指针指向子类型的对象
	//即存在一个Persont*类型的指针ptr,ptr指向Student,但是ptr指向的是切割出来的部分,即在父类中存在的部分(即子类中继承下来的父类的成员变量那一部分)
	Person* ptr = &s;

	//3、父类型的引用指向子类型对象
	//引用就是别名,但是引用的类型是Person,所以ref代表的也是父类中存在的那部分的别名(即子类中继承下来的父类的成员变量那一部分)
	Person& ref = s;

	return 0;
}

下图给出了Person类和Student类在继承关系存在前后对象的内存模型:
在这里插入图片描述
针对上面三种情况下的切片,我们给出内存模型加深理解:
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

综上所述:
a:派生类对象可以赋值给 基类的对象 / 基类的指针 / 基类的引用。这里有个形象的说法叫切片或者切割。寓意把派生类中父类那部分切来赋值过去。
b:基类对象不能赋值给派生类对象
c:基类的指针可以通过强制类型转换赋值给派生类的指针。但是必须是基类的指针是指向派生类对象时才是安全的。

3、继承中的作用域

  在没有学继承以前我们知道,任何变量、函数、类都有自己的作用域,在我们的继承体系中,也是有作用域的。

(1. 在继承体系中基类和派生类都有独立的作用域。
(2. 子类和父类中有同名成员,子类成员将屏蔽父类对同名成员的直接访问,这种情况叫隐藏,也叫重定义。(在子类成员函数中,可以使用 基类::基类成员 显示访问)
(3. 需要注意的是如果是成员函数的隐藏,只需要函数名相同就构成隐藏。
(4. 注意在实际中在继承体系里面最好不要定义同名的成员。

//父类
class Person
{
    
    
protected:
	string _name = "宋江"; // 姓名
	int _num = 001; // 号码
};
//子类
class Student : public Person
{
    
    
public:
	void Print()
	{
    
    
		cout << " 姓名:" << _name << endl;
		cout << " 学号:" << _num << endl;
	}
	//添加域作用限定符访问父中的_num
	void Print2()
	{
    
    
		cout << " 姓名:" << _name << endl;
		cout << " 学号:" << Person::_num << endl;
	}
protected:
	int _num = 002; // 学号
};
int main()
{
    
    
	//我们在基类Person中定义了一个_num变量,同样在派生类Student中也定义了一个_num变量
	//我们定义一个Student类的变量s,可以编译通过吗?--经过编译发现可以编译通过!
	//因为Person构成一个作用域,Student构成一个作用域,两个作用域是独立的,可以定义同名变量
	Student s;
	//那么我们访问一下_num,看访问到的是父类中的还是子类中的?--通过编译发现访问到的是子类中的
	s.Print();
	//有一个全局_num一个局部_num,我们在局部里访问,访问到的肯定是局部的,有一个就近原则
	
	//那么如果我们想访问父类中的_num应该怎么办呢?--添加域作用限定符
	s.Print2();
	
	return 0;
}
图1 未加域作用限定符
图2 加域作用限定符后

4、继承中派生类(子类)的默认成员函数

  我们在学类的时候,学习了类的6个默认成员函数,对这方面不太了解的小伙伴可以去查看小编的以往文章类和对象(中)。这里我们对于类的6个默认成员函数就不过多赘述,在这里我们要研究的是,在派生类中,这几个成员函数是如何生成的呢?(我们只研究构造、拷贝构造、赋值重载和析构函数)
  首先,我们定义一个父类,父类中,我们写好构造、拷贝构造、赋值重载和析构函数。

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; // 姓名
};

  其次,我们定义一个子类,依次来观察派生类中这四个函数的使用。

4.1 构造函数

A:按照传统方式初始化_name
  按照我们传统的理解,我们的Student类型的s变量有两个成员,一个是从父类继承下来的_name,一个是自身的_name。
  我们想初始化,直接就对_name和_name初始化,但是发现编译错误:“Student”: 非法的成员初始化:“_name”不是基或成员。
  说白了就是不让我们这样初始化_name,所以在派生类中对成员的初始化还是跟之前有些不同的。

class Student : public Person
{
    
    
public:
	Student(const char* name, int num)
		: _name(name)
		, _num(num)
	{
    
    
		cout << "Student()" << endl;
	}
protected:
	int _num; //学号
};
int main()
{
    
    
	Student s1("xd", 23);
	return 0;
}

在这里插入图片描述
B:那么我们不初始化_name可以吗?只初始化_num
  编译通过,经过调试发现,编译器调用了Person的构造函数,但是我们没有调用,所以是系统自动调用Person的默认构造函数。也并不是什么情况下都会去调用,如果不去调用父类的构造函数,就要自己提供一个默认构造函数。
  这样理解:派生类的构造函数不是把父类的那些成员当成一个个体,而是把父类当作一个整体对象,就像是Person是在Student中的一个自定义类型的对象。

class Student : public Person
{
    
    
public:
	Student(const char* name, int num)
		: _num(num)
	{
    
    
		cout << "Student()" << endl;
	}
protected:
	int _num; //学号
};
int main()
{
    
    
	Student s1("xd", 23);
	return 0;
}

在这里插入图片描述

C:我们还是想把从父类继承来的成员调用父类的构造函数初始化,此时要把父类看成一个整体显示调用

class Student : public Person
{
    
    
public:
	Student(const char* name, int num)
		: Person(name)
		, _num(num)
	{
    
    
		//a.先调用父类构造函数,父类构造函数去初始化继承的父类部分;b.然后再初始化自己的成员
		cout << "Student()" << endl;
	}
protected:
	int _num; //学号
};
int main()
{
    
    
	Student s1("xd", 23);
	return 0;
}

在这里插入图片描述
4.2 拷贝构造

  有了对构造函数的理解,拷贝构造就很好写了,其原理和构造函数是一样的。

class Student : public Person
{
    
    
public:
	//s就是已经切片好的,里面的成员就是继承下来的父类的成员,没有自己的成员
	Student(const Student& s)
		: Person(s)//s传递给Person& p是一个切片行为
		, _num(s._num)
	{
    
    
		//a.先调用父类构造函数,父类构造函数去初始化继承的父类部分;b.然后再初始化自己的成员
		cout << "Student(const Student& s)" << endl;
	}
protected:
	int _num; //学号
};
int main()
{
    
    
	Student s1("xd", 23);
	Student s2(s1);
	return 0;
}

4.3 赋值重载

  赋值重载也是跟构造函数一样的,不过这里我们显示调用的时候需要考虑隐藏的问题!!!

class Student : public Person
{
    
    
public:
	Student& operator = (const Student& s)
	{
    
    
		//a.先调用父类构造函数,父类构造函数去初始化继承的父类部分;b.然后再初始化自己的成员
		cout << "Student& operator= (const Student& s)" << endl;
		if (this != &s)
		{
    
    
			//operator =(s);
			//引发栈溢出,导致程序崩溃,因为想去掉父类的operator=,但是调成了子类的,因为父类和子类的operator=函数名相同,构成了隐藏。
			Person::operator =(s);
			_num = s._num;
		}
		return *this;
	}
protected:
	int _num; //学号
};
int main()
{
    
    
	Student s1("xd", 23);
	Student s3("zzd", 18);
	s3 = s1;
	return 0;
}

4.4 析构函数

  析构函数有一些特殊,我们分情况讨论:
A:按照前面的想法,这里我们也先调用父类的析构,然后再清理自己的

class Student : public Person
{
    
    
public:
	~Student()
	{
    
    
		//报错:Person没有合适的默认构造函数可用
		~Person();
		cout << "~Student()" << endl;
	}
protected:
	int _num; //学号
};
int main()
{
    
    
	Student s1("xd", 23);
	return 0;
}

在这里插入图片描述
为什么报错?
  答:编译器认为子类的析构函数和父类的析构函数构成隐藏,但是我们看到的这两个类的析构函数名字不一样,为什么还构成隐藏了呢?

为什么构成隐藏?
  答:这是因为编译器的一些隐晦处理,因为多态的一些原因,任何类的析构函数名都会被统一处理成destructor(),两个地方的析构函数名都是destructor(),编译器就会认为父类和子类的析构函数构成隐藏,就无法直接去调用父类的析构函数,需要加域作用限定符。

B:+域作用限定符
  但是这样的话,父类就会被析构两次,以说析构函数是一个特殊,他跟前面的不一样。
  构造函数先调用父类的,再调用子类的,如果调用正确,析构函数应该先调用子类的,再去调用父类的,因为栈里面的对象初始化顺序符合先进后出,所以中间有一次析构是多余的。
在这里插入图片描述

class Student : public Person
{
    
    
public:
	~Student()
	{
    
    
		Person::~Person();
		cout << "~Student()" << endl;
	}
protected:
	int _num; //学号
};
int main()
{
    
    
	Student s1("xd", 23);
	return 0;
}

  经过调试我们发现,如果自己显示的调用父类的析构函数,就不能保证先子再父的析构顺序,我们在子类的析构函数完成后自动的调用一次父类的析构函数。
  也就意味着,析构函数跟前面的不一样,前面的需要我们自己显示的去调用父类的构造、拷贝构造、赋值重载,析构函数这里做了一个特殊,我们不需要显示的去调用父类的析构函数,为了保证析构的顺序是先子后父,在子类的析构函数调用完成后会自动调用父类的析构函数。

C:3、所以在这里我们不需要显示调用父类的析构,会自动调用

class Student : public Person
{
    
    
public:
	~Student()
	{
    
    
		cout << "~Student()" << endl;
	}
protected:
	int _num; //学号
};
int main()
{
    
    
	Student s1("xd", 23);
	return 0;
}

在这里插入图片描述
4.5 总结:

总结:
(1. 派生类的构造函数必须调用基类的构造函数初始化基类的那一部分成员。如果基类没有默认的构造函数,则必须在派生类构造函数的初始化列表阶段显示调用。
(2. 派生类的拷贝构造函数必须调用基类的拷贝构造完成基类的拷贝初始化。
(3. 派生类的operator=必须要调用基类的operator=完成基类的复制。
(4. 派生类的析构函数会在被调用完成后自动调用基类的析构函数清理基类成员。因为这样才能保证派生类对象先清理派生类成员再清理基类成员的顺序。
(5. 派生类对象初始化先调用基类构造再调派生类构造。
(6. 派生类对象析构清理先调用派生类析构再调基类的析构。

5、继承与友元的关系

  友元的概念相信各位读者都不陌生,所谓友元简单来讲就是朋友的关系,因为我是你的朋友,所以我可以访问你的一些信息,这些信息是其他人所不能访问到的。那么问题来了?派生类在继承基类的时候,友元是不是也继承过来了呢?
  答案是否定的,具体我们下面用代码来详细说明。
在这里插入图片描述

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)
{
    
    
	cout << p._name << endl;
	cout << s._stuNum << endl;//编译不通过,报错
}
int main()
{
    
    
	Person p;
	Student s;
	Display(p, s);
	return 0;
}

在这里插入图片描述
  对于上面的代码,Display是父类的友元,所以Display可以访问Person中的private和protected修饰的成员;但是其子类Student虽然继承Person,但是Display不能去访问Student类中的成员。所以我们得出:友元关系不能继承,也就是说基类友元不能访问子类私有和保护成员。

  如果Display想访问Student类中的成员,就再写一个友元关系,即Display即是Person的友元,也是Student的友元。简单来说,就是父亲的朋友介绍给你认识,你要重新声明一下,说明父亲的朋友你认识了。


```c
class Student;
class Person
{
    
    
public:
	//定义友元
	friend void Display(const Person& p, const Student& s);
protected:
	string _name; // 姓名
};
class Student : public Person
{
    
    
public:
	//再写一个友元关系
	friend void Display(const Person& p, const Student& s);
protected:
	int _stuNum; // 学号
};
void Display(const Person& p, const Student& s)
{
    
    
	cout << p._name << endl;
	cout << s._stuNum << endl;//编译通过
}
int main()
{
    
    
	Person p;
	Student s;
	Display(p, s);
	return 0;
}

6、继承与静态成员的关系

  通过上面的代码我们了解了友元关系是无法继承的,那么静态成员呢?我们定义的静态变量和静态函数会不会在子类中的对象中呢?我们同样通过代码来研究。

class Person
{
    
    
public:
	Person() {
    
     ++_count; }
protected:
	string _name; // 姓名
public:
	static int _count; // 统计人的个数。
};
int Person::_count = 0;
class Student : public Person
{
    
    
protected:
	int _stuNum; // 学号
};
class Graduate : public Student
{
    
    
protected:
	string _seminarCourse; // 研究科目
};
void TestPerson()
{
    
    
	Student s1;
	Student s2;
	Student s3;
	Graduate s4;
	cout << " 人数 :" << Person::_count << endl;
	cout << " 人数 :" << Student::_count << endl;
	cout << " 人数 :" << Graduate::_count << endl;
	cout << &Person::_count << "\n" << &Student::_count << "\n" << &Graduate::_count << endl;
	//上面三个访问到的count都是同一个
}
int main()
{
    
    
	TestPerson();
	return 0;
}

在这里插入图片描述
  通过运行结果,我们发现,我们不管通过子类还是父类去访问静态成员变量count,访问的结果都是一样的,打印地址发现他们的地址相同,所以这三个count是同一个,因此得出结论:基类定义了static静态成员,则整个继承体系里面只有一个这样的成员。无论派生出多少个子类,都只有一个static成员实例,静态成员不属于任何对象。

7、菱形继承

  多继承是C++中很难理解的一个点,因为有了多继承就有许多复杂的情况,针对这些复杂的情况又会引入一些复杂的东西。总结来说,多继承的存在会导致菱形继承,而菱形继承存在的问题是:可能继承了两份原始父类中的东西。

7.1 单继承

  一个子类只有一个直接父类时称这个继承关系为单继承。
在这里插入图片描述
7.2 多继承

  一个子类有两个或以上直接父类时称这个继承关系为多继承。
在这里插入图片描述
7.3 菱形继承

  菱形继承是多继承的一种特殊情况。
在这里插入图片描述
7.4 菱形继承存在的问题

  菱形继承主要存在两个问题:数据冗余和二义性
  什么是数据冗余?什么是二义性?我们用具体的代码示例来给大家讲解。

class A
{
    
    
public:
	int _id; // 学号
};
class B : public A
{
    
    
protected:
	int _age; // 年龄
};
class C : public A
{
    
    
protected:
	string _sex; // 性别
};
class D : public B, public C
{
    
    
protected:
	string _address; // 地址
};
int main()
{
    
    
	// 这样会有二义性无法明确知道访问的是哪一个
	D val;
	val._id;//报错:对“_id”的访问不明确
	//因为_id有两份,到底访问那一份要说清楚,这就是二义性

	return 0;
}

  通过上面的代码,我们发现,本来应该有一份_id,但是现在却有两份,这就是数据冗余,而我们访问_id的时候,会报错,对“_id”的访问不明确,这就是二义性,访问哪一个_id不明确。其对象成员模型如下:从图中也可以看出菱形继承导致的数据冗余和二义性。
在这里插入图片描述
7.5 菱形继承问题的解决

法一:通过使用域作用限定符
  我们可以通过使用域作用限定符来解决二义性,表明具体要访问的是哪一个的成员变量。还是上面的ABCD类的菱形继承。代码如下:
  指定访问冗余成员的作用域可以解决二义性,但是还是没有解决数据冗余的问题!!

int main()
{
    
    
	D val;
	val.B::_id = 23;
	val.C::_id = 24;

	return 0;
}

法二:虚继承
  C++不能容忍数据冗余和二义性,提出了新方案–虚继承(virtual),这个虚继承在腰部,虚继承可以很好的解决数据冗余和二义性。
  经过虚继承后,这三份_id就是同一份_id了,我们也不需要再加域作用限定符,直接val._name = 22;就可以了
在这里插入图片描述

class A
{
    
    
public:
	int _id; // 学号
};
class B : virtual public A
{
    
    
protected:
	int _age; // 年龄
};
class C : virtual public A
{
    
    
protected:
	string _sex; // 性别
};
class D : public B, public C
{
    
    
protected:
	string _address; // 地址
};
int main()
{
    
    
	D val;
	val._id = 22;

	return 0;
}

8、虚继承

  在菱形继承的腰部位置添加virtual就是虚继承。那么普通的菱形继承和虚继承的菱形继承有什么区别,虚继承是怎么解决菱形继承带来的数据冗余和二义性的呢?我们通过实际的代码和VS下的内存窗口来分析。这里我们还是用简化版的菱形继承类,前面的ABCD类。

8.1 普通的菱形继承

class A
{
    
    
public:
	int _a;
};
class B : public A
{
    
    
public:
	int _b;
};
class C : 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;
}

  我们调试整个代码,对象的模型和内存地址如图所示,在普通继承中,根据继承的BC的先后顺序,B在C的上面,可以很直观的看到菱形继承导致的数据冗余和二义性,_a有两个,B类中有一个,其值为1,C类中也有一个,其值为2。
  在其内存地址上,严格按照父类、子类的顺序,父类又按照继承顺序依次从上往下。
在这里插入图片描述
在这里插入图片描述

8.2 虚继承的菱形继承

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 d;
	d.B::_a = 1;
	d.C::_a = 2;
	d._b = 3;
	d._c = 4;
	d._d = 5;
	return 0;
}

  虚继承的菱形继承内存模型和内存地址发生了巨大的变化。
  我们仍然进入调试,首先A类中的_a在地址中处于最底部,而B类还是根据继承关系在最顶部。
在这里插入图片描述
  我们继续向下调试,当C类中的_a赋值成2时,原来位于最底部的01 00 00 00变成02 00 00 00,这时说明了两个_a属于同一个_a,这表明了虚继承解决了数据冗余和二义性的问题。
在这里插入图片描述
  我们继续向下,发现程序分为4部分,其对象模型如下:
在这里插入图片描述
在这里插入图片描述
  以上就是虚继承的内容,关于虚继承在B类和C类中为什么放了两个其他地址,以及B类和C类怎么找到A类中的_a问题,我们在后续的篇章中继续研究,本章内容我们给大家详细讲解了C++中有关继承的相关知识,干活多多,各位读者可以好好阅读,有不懂的可以私信小编,小编定会知无不言言无不尽。
在这里插入图片描述

猜你喜欢

转载自blog.csdn.net/weixin_43202123/article/details/120683639