【C++】深入理解继承

今天我们来介绍一下C++的继承。

继承 是 C++三大特性继承,多态,封装之一,所以理解继承是很有必要的。

继承的概念及定义

继承的概念

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

继承的定义

1. 定义格式

在这里插入图片描述

2. 继承关系和访问限定符

在这里插入图片描述

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

类成员/继承方式 | public继承 | protected 继承 | private 继承
-------- | -----
基类的public 成员 | $1600
手机 | $12
导管 | $1

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

总结:

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

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

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

但是,基类对象不能直接赋值给派生类对象。

在这里插入图片描述

值得注意的是,基类的指针可以通过强制类型转换赋值给派生类的指针。但是必须是基类的指针是指向派生类对象时才是安全的。这里基类如果是多态类型,可以使用RTTI(Run-Time Type Information)的dynamic_cast 来进行识别后进行安全转换。

class Person
{
protected :
	string _name; // 姓名
	string _sex; // 性别
	int _age; // 年龄
};
class Student : public Person
{
public :
	int _No ; // 学号
};
void Test ()
{
	Student sobj ;
	// 1.子类对象可以赋值给父类对象/指针/引用
	Person pobj = sobj ;
	Person* pp = &sobj;
	Person& rp = sobj;
	//2.基类对象不能赋值给派生类对象
	sobj = pobj;
	// 3.基类的指针可以通过强制类型转换赋值给派生类的指针
	pp = &sobj
	Student* ps1 = (Student*)pp; // 这种情况转换时可以的。
	ps1->_No = 10;
	pp = &pobj;
	Student* ps2 = (Student*)pp; // 这种情况转换时虽然可以,但是会存在越界访问的问题
	ps2->_No = 10;
}

继承中的作用域

  1. 在继承体系中基类和派生类都有独立的作用域。
  2. 子类和父类中有同名成员,子类成员将屏蔽父类对同名成员的直接访问,这种情况叫隐藏,也叫重定义。(在子类成员函数中,可以使用 基类::基类成员 显示访问)
  3. 需要注意的是如果是成员函数的隐藏,只需要函数名相同就构成隐藏。
  4. 注意在实际中在继承体系里面最好不要定义同名的成员。
  • 同名成员情况:
// Student的_num和Person的_num构成隐藏关系,可以看出这样代码虽然能跑,但是非常容易混淆
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; // 学号
};
void Test()
{
Student s1;
s1.Print(); //999
};
  • 同名函数情况
// B中的fun和A中的fun不是构成重载,因为不是在同一作用域
// B中的fun和A中的fun构成隐藏,成员函数满足函数名相同就构成隐藏。
class A
{
public:
	void fun()
	{
	   cout << "func()" << endl;
    }
};
class B : public A
{
public:
	void fun(int i)
	{
		A::fun();
		cout << "func(int i)->" <<i<<endl;
	}
};
void Test()
{
	B b;
	b.fun(10);
};

派生类的默认成员函数

“默认”的意思就是指我们不写,编译器会变我们自动生成一个,那么在派生类中默认成员函数有哪些,这些成员函数又是怎样生成的呢?

默认成员函数有六个,其中四个比较常用到:
在这里插入图片描述

总的来说,在继承中,先初始化基类,再初始化子类。先析构子类,再析构基类。
在这里插入图片描述


1. 构造函数
  • 不写构造函数

在派生类中,如果我们不写构造函数,那么编译器会默认生成,但是对于不同类型的成员,该函数会有不同的处理策略:

派生类成员处理方式
继承的父类成员作为一个整体去调用父类的构造函数函数初始化
自己的内置类型成员不处理(除非有缺省值)
自己的自定义类型成员调用它的默认构造函数

举个例子:

#include<iostream>
using namespace std;

class Person
{
public :
	Person(const char* name="小明") 
		:_name(name)
     {}
protected:
	string _name;

};

class Student : public Person
{
public :

private:
	int _id;
	string _address;
};

此时我们直接创建一个子类对象:

在这里插入图片描述
我们发现:继承的父类成员_name显然调用了父类的构造函数,_id 作为内置类型数据 不处理,而_address 作为自定义类型成员 使用了string 自己的默认构造函数。


  • 写构造函数
#include<iostream>
using namespace std;

class Person
{
public :
	Person(const char* name="小明") 
		:_name(name)
     {}
protected:
	string _name;

};

class Student : public Person
{
public :
    Student(const char* name, int id, const char* address)
		:Person(name)
		, _id(id)
		, _address(address)
	{}
private:
	int _id;
	string _address;
};

我们使用初始化列表 写一个构造函数。

在这里插入图片描述

如果我们在使用初始化列表的时候不显式初始化_name,那么_name 会被如何初始化?

很显然,根据之前的表格,继承的父类成员 ,如果我们在初始化列表中不处理,那么会被作为一个整体去调用父类的构造函数函数初始化。

Student(const char* name, int id, const char* address)
		: _id(id)
		, _address(address)
	{}

在这里插入图片描述
同理,如果我们在初始化列表中不显式初始化_address,那么_address会调用string的默认构造函数,被初始化为""。


2. 拷贝构造
  • 不写拷贝构造

在派生类中,如果我们不写构造构造函数,那么编译器会默认生成,但是对于不同类型的成员,该函数会有不同的处理策略:

派生类成员处理方式
继承的父类成员作为一个整体去调用父类的拷贝构造函数函数初始化
自己的内置类型成员值拷贝
自己的自定义类型成员调用它的拷贝构造

举个例子:

#include<iostream>
using namespace std;

class Person
{
public :
	
	Person(const char* name="小明") 
		:_name(name)
     {}
	Person(const Person& p)
		:_name(p._name)
	{}

protected:
	string _name;

};

class Student : public Person
{
public :
	Student(const char* name, int id, const char* address)
		:Person(name),
		_id(id),
		_address(address)
		
	{}
	
private:
	int _id;
	string _address;
};

在这里插入图片描述

  • 写拷贝构造
class Person
{
public :
	
	Person(const char* name="小明") 
		:_name(name)
     {}
	Person(const Person& p)
		:_name(p._name)
	{
		cout << "Person(const Person& p)" << endl;
	}

protected:
	string _name;

};

class Student : public Person
{
public :
	Student(const char* name, int id, const char* address)
		:Person(name),
		_id(id),
		_address(address)
		
	{}

	//我们自己实现子类构造函数
	Student(const Student& s)
		:Person(s)   //切片
		, _id(s._id)
		, _address(s._address)
	{}
private:
	int _id;
	string _address;
};

在这里插入图片描述
这里我们在实现拷贝构造函数的时候,我们在初始化父类继承的成员的时候,即Person(s),这里实际上运用的是 切片 ,也就是把子类对象赋值给父类。

那么。如果我们在拷贝构造函数的初始化列表中不显式初始化_name,那么结果又是怎样的?

Student(const Student& s)
		: _id(s._id)
		, _address(s._address)
	{}

在这里插入图片描述
显然,如果我们不显式调用父类的构造函数,那么编译器会自动调用父类的构造函数,但是没这样做我们无法得到我们想要的结果。


3. 赋值重载
  • 不写赋值重载

在派生类中,如果我们不写赋值重载,那么编译器会默认生成,但是对于不同类型的成员,该函数会有不同的处理策略:

派生类成员处理方式
继承的父类成员作为一个整体去调用父类的拷贝赋值
自己的内置类型成员值拷贝
自己的自定义类型成员调用它的拷贝赋值

举个例子:

class Person
{
public :
    
	Person(const char* name="小明") 
		:_name(name)
     {}
    
    //父类赋值拷贝函数
	Person& operator=(const Person& p)
	{
		cout << "Person& operator=(const Person& p)" << endl;
		if (this != &p)
			_name = p._name;

		return *this;
	}

protected:
	string _name;

};

class Student : public Person
{
public :
	Student(const char* name, int id, const char* address)
		:Person(name),
		_id(id),
		_address(address)
		
	{}

	
private:
	int _id;
	string _address;
};

在这里插入图片描述


  • 写赋值拷贝
class Person
{
public :
	
	

	Person(const char* name="小明") 
		:_name(name)
     {}

	Person& operator=(const Person& p)
	{
		cout << "Person& operator=(const Person& p)" << endl;
		if (this != &p)
			_name = p._name;

		return *this;
	}

protected:
	string _name;

};

class Student : public Person
{
public :
	Student(const char* name, int id, const char* address)
		:Person(name),
		_id(id),
		_address(address)
		
	{}
	Student& operator=(const Student& s)
	{
		if (this != &s)
		{
			_id = s._id;
			_address = s._address;
			Person::operator=(s);
		}
		return *this;
	}

	
private:
	int _id;
	string _address;
};

在这里插入图片描述
这里在写拷贝构造函数的时候,有一个地方要注意,就是在调用父类的operator =赋值的时候,要注意其与子类operator 是隐藏关系,所以调用父类的赋值拷贝要加类限定符。


4.析构函数
  • 不写析构函数

在派生类中,如果我们不写析构函数,那么编译器会默认生成,但是对于不同类型的成员,该函数会有不同的处理策略:

派生类成员处理方式
继承的父类成员调用父类的析构
自己的内置类型成员不处理
自己的自定义类型成员调用它的析构函数

  • 写析构函数

这里要注意两点:

  1. 我们在析构时候是写~Student{} 和~Person{},但是实际上 子类析构函数 却 会和 父类析构函数 构成 隐藏关系 。 这是因为编译器会对析构函数数组进行的处理,所有的类的析构函数都会被处理为统一的名字。(具体原理暂时不做讲解,涉及多态的重写)
  2. 子类的析构函数在执行结束之后,自动调用父类的析构函数。 这是为了保证子类先析构,父类再析构的顺序。

所以,针对我们之前的例子,我们只需要写:

~Student()
{
}

原因在于 _id 是内置类型成员,不需要析构,_adress 是自定义成员,会自动调用string类的析构函数,而_name ,会在子类析构完成之后,自动由父类析构处理掉。

继承与友元

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

#include<iostream>
using namespace std;

class Student;
class Person
{
public:
	friend void Display(const Person& p, const Student& s);
public :
	
	Person()
	{}

	Person(const char* name="小明") 
		:_name(name)
     {}

	Person& operator=(const Person& p)
	{
		cout << "Person& operator=(const Person& p)" << endl;
		if (this != &p)
			_name = p._name;

		return *this;
	}

protected:
	string _name;

};

class Student : public Person
{
public :

private:
	int _id;
	string _address;
};
void Display(const Person& p, const Student& s)
{
	cout << p._name << endl;
	//cout << s._id << endl;


}

对于上例,我们将Disoplay()声明为基类的友元,由此我吗可以访问_name,但是依旧不可以访问_id和_adress 。要想访问,我们再再子类中声明一次友元即可。

继承与静态成员

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

同时静态成员的初始化要在类外。

我们写一个计算对象数量的小程序。

#include<iostream>
using namespace std;


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;
	Student::_count = 0;
	cout << " 人数 :" << Person::_count << endl;
}
int main()
{
	TestPerson();
}

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

菱形继承

继承一般分两类:
1.单继承
在这里插入图片描述

2.多继承
在这里插入图片描述

而 我们要讲的菱形继承 是 多继承的一种特殊情况
在这里插入图片描述

观察下面的程序:

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

void Test ()
{
// 这样会有二义性无法明确知道访问的是哪一个
Assistant a ;
a._name = "peter";
// 需要显示指定访问哪个父类的成员可以解决二义性问题,但是数据冗余问题无法解决
a.Student::_name = "xxx";
a.Teacher::_name = "yyy";
}

我们会发现,_name 在Student 和 Teacher 中都存在,那么在Assistant 继承的时候,就有两份_name,这就导致了访问父类成员的二义性,同时具有数据冗余的问题。

菱形虚拟继承

要同时解决两个问题,我们需要用到虚拟继承。

我们写一个例子:

下面是一个普通的菱形继承:

#include<iostream>
using namespace std;
class A
{
public:
	int _a;
};
// class B : public A
class B :  public A
{
public:
	int _b;
};
// class C : public A
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;
}

我们来看一下,在d中它们是如何存储的。
在这里插入图片描述
我们可以很直观的看见成员的分布。显然_a有两份。

我们将其改为虚拟继承:

#include<iostream>
using namespace std;
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;
}

我们再来看一下d中的内存分布:
在这里插入图片描述
下图是菱形虚拟继承的内存对象成员模型:这里可以分析出D对象中将A放到的了对象组成的最下面,这个A同时属于B和C。

那么B和C如何去找到公共的A呢?这里是通过了B和C的两个指针,指向的一张表。这两个指针叫虚基表指针,这两个表叫虚基表。虚基表中存的偏移量。通过偏移量可以找到下面的A。

也就是说 地址 00 2b 7b 40 和 00 2b 7b 48 两个位置 指向的是偏移量。我们验证一下:

在这里插入图片描述
那么我们保留这两个指针有何意义吗?当然有:

比如当我们 在切片的时候,我们得在d中找到B/C 类中A才能赋值过去。

D d;
B b=d;
C c=d;

这里有几点还要再强调一下;

  1. 我们在访问虚基类对象成员的时候(本例中为_a),都是取偏移量计算_a的位置,也就是说,在B,C,D类中访问_a都是通过偏移量
  2. B,C,D的对象,指针,引用,访问_a都要取偏移量计算_a的位置。
  3. 虚继承之后,解决菱形继承,但是对象模型更加复杂,其次访问虚基类对象要付出更大的代价。
    原理图

总结与反思

  1. 很多人说C++语法复杂,其实多继承就是一个体现。有了多继承,就存在菱形继承,有了菱形继承就有菱形虚拟继承,底层实现就很复杂。所以一般不建议设计出多继承,一定不要设计出菱形继承。否则在复杂度及性能上都有问题。
  2. 多继承可以认为是C++的缺陷之一,很多后来的OO语言都没有多继承,如Java。
  3. 继承和组合
    public继承是一种is-a的关系。也就是说每个派生类对象都是一个基类对象。
    组合是一种has-a的关系。假设B组合了A,每个B对象中都有一个A对象。
    优先使用对象组合,而不是类继承 。继承允许你根据基类的实现来定义派生类的实现。这种通过生成派生类的复用通常被称为白箱复用(white-box reuse)。术语“白箱”是相对可视性而言:在继承方式中,基类的内部细节对子类可见 。继承一定程度破坏了基类的封装,基类的改变,对派生类有很大的影响。派生类和基类间的依赖关系很强,耦合度高。对象组合是类继承之外的另一种复用选择。新的更复杂的功能可以通过组装或组合对象来获得。对象组合要求被组合的对象具有良好定义的接口。这种复用风格被称为黑箱复用(black-box reuse),因为对象的内部细节是不可见的。对象只以“黑箱”的形式出现。 组合类之间没有很强的依赖关系,耦合度低。优先使用对象组合有助于你保持每个类被封装。实际尽量多去用组合。组合的耦合度低,代码维护性好。不过继承也有用武之地的,有些关系就适合继承那就用继承,另外要实现多态,也必须要继承。类之间的关系可以用继承,可以用组合,就用组合。
评论 4
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

Ornamrr

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值