万字解析C++——继承


继承

继承的概念

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

class Person
{
public:
	string _name;//姓名
	int _age;//年龄
};

class Student :public Person
{
public:
	string _Student_id;//学号
	string _major;//专业
};

class Teacher :public Person
{
public:
	string _Work_id;//工号
	string collage;//学院
};

比如以上三个类,学生和老师都属于人,所以我们可以单独写一个“人”的类,然后让学生和老师去继承人的类,这样既可以避免代码的冗杂,而且在对人的类进行修改时也可以更方便 

继承的定义

我们来单独看“Student”这个类

我们把继承其他类产生的类叫做派生类,将被继承的类叫做基类,而public叫做继承的方式,其意义我们会在接下来说到


继承关系和访问限定符

我们在类里已经了解到,类的访问限定符分为以下三种:

  • public:      公有
  • protected:保护
  • private:     私有

关于公有和私有我们已经接触到了很多,但是保护我们从未使用过,这是因为,保护只有在继承中才能体现出其作用

类的继承方式和访问限定符一样,也分为公有,保护,私有三种

继承下的访问限定符

我们来看以下的一段代码

class base
{
public:
	int _pub=1;
protected:
	int _pro=2;
private:
	int _pri=3;
};

class derive:public base
{
public:
	void func()
	{
		cout << _pub << endl;
		cout << _pro << endl;
		cout << _pri << endl;//error
	}
};

int main()
{
	base b;
	cout << b._pub << endl;
	cout << b._pro << endl;//error
	cout << b._pri << endl;//error

	derive d;
	cout << d._pub << endl;
	cout << d._pro << endl;//error
	cout << d._pri << endl;//error
}

运行之后看看编译器怎么说

很多,但是我们将出错的片段在代码中标记出来
这些错误可以分一个类

  • 在类中,基类的private不可以访问
  • 在类外,基类和派生类的protected和private都不可以访问

这便是继承下访问限定符的特点 

  • public:在类外都可以随意访问
  • protected:在继承的派生类中可以访问,在类外无法访问
  • private:只有在基类中才可以访问,在派生类和类外都无法访问

用权限的角度来想,权限的关系可以写作:public>protected>private

继承关系

解释了protected之后,那继承关系public是什么意思呢?
我们不妨把public换成protected试一下

class A
{
public:
	int _a = 1;
};

class B:protected A
{
public:
	void func()
	{
		cout << _a << endl;
	}
};

int main()
{
	A a;
	B b;
	cout << a._a << endl;
	cout << b._a << endl;//error
}

我们还是看一下错误报告

奇了怪了,为什么对对象A来访问成员_a没有报错,而对对象B来访问成员_a就会报错呢?

此时,我们便可以猜想出来,protected的作用是将类A中的成员全部变成protected

当然,这种说法并非完全正确,此时我们便开始了解继承关系的原则

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

关系太复杂?我们总结以下

  1. 基类private成员在派生类中无论以什么方式继承都是不可见的。这里的不可见是指基类的私有成员还是被继承到了派生类对象中,但是语法上限制派生类对象不管在类里面还是类外面都不能去访问它
  2. 派生类在继承基类后,在基类的访问限定符为继承关系与基类的访问限定符的最小权限,比如上面所说在基类是public,继承关系是protected,那么取最小值protected;同理,如果在基类是private,继承关系是protected,那么取最小值private
  3. 如果没有显式写继承关系,那么class默认为private继承,struct默认为public继承,但是尽管如此,还是写出继承方式为好
  4. 在实际运用中一般使用都是public继承,几乎很少使用protetced/private继承,也不提倡使用protetced/private继承,因为protetced/private继承下来的成员都只能在派生类的类里面使用,实际中扩展维护性不强

这里我们要特别注意第一点,基类中private的成员并非没有被派生类继承,只是在派生类不可见,实际上还是被继承到了派生类中


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

一般来说,对不同的类型进行相互赋值时会产生类型转换,而对于自定义类型若没有特定的函数,则无法进行类型转换

class A
{
public:
	int _a=1;
};

class B
{
public:
	int _b=1;
};

int main()
{
	//发生了类型转换,由double类转换为int类
	int i = 1.0;

	//就算类中成员类型相同,也无法进行类型转换
	A a;
	B b = a;
}

而在继承中,我们却惊奇发现,派生类可以对基类进行赋值

class A
{
public:
	int _a=1;
};

class B:public A
{
public:
	int _b=1;
};

int main()
{
	//派生类可以对基类进行赋值
	B b;
	A a = b;
}

这是为什么?难道是继承默认生成了类型转换函数吗?

我们都知道,如果不同的类型进行引用赋值,编译器会报错,因为在类型转换时会先生成一个常量,再用这个常量进行赋值,而引用是不可以引用一个常量的,所以会产生报错

具体解析可以看这一篇关于引用的文章

而我们试试引用赋值

int main()
{
	//一般来说,引用不能作用于两个不同的类型
	double d;
	int& i = d;

	//但是在继承关系中可以
	B b;
	A& a = b;
}

 此时,这段代码在第二个引用处居然没报错,这说明其实完全没有产生类型转换

切片/切割

我们来分析以下具体的赋值原理

假如我们有这以下的一个基类和一个派生类

class Person
{
protected :
    string _name; // 姓名
    string _sex; // 性别
    int _age; // 年龄
};
class Student : public Person
{
public :
    int _No ; // 学号
};

用更直观的方式,以图片来表示:

我们通过派生类对基类进行赋值时,采用的是类似于切片的方式,将派生类的一部分直接切割给基类而不仅仅是引用,基类的对象/指针/引用都是通过这种切片的方式进行赋值

不过要注意的是,基类对象不可以赋值给派生类


继承中的作用域

隐藏关系

如果基类和派生类中对同一个变量进行了两次定义,那程序会产生错误吗?

class A
{
public:
	int _a=1;
	char _repeat = 'A';
};

class B:public A
{
public:
	int _b=1;
	char _repeat = 'B';
};

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

我们发现,代码正常运行了,而且运行的结果是输出‘B’

那我们如何访问基类中的_repeat呢?

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

我们修改一下主函数,直接了当指出访问A中的_repeat,便可以输出‘A’

原理

如果派生类和基类中没有命名相同的成员,那么不会有任何影响,自然不必多说,但是如果有相同命名的成员,那么编译器会隐藏基类中的同名成员,在没有特指的情况下只会访问到派生类的成员

特别特别特别的,对于函数,如果有着同名函数而参数不同,一般情况下会构成函数重载,但是在继承下,就算参数不同,只要函数名相同,就会产生隐藏关系

class A
{
public:
	void func(char c)
	{
		cout << "func A" << endl;
	}
};

class B:public A
{
public:
	void func(int a)
	{
		cout << "func B" << endl;
	}
};

int main()
{
	B b;
	b.func('a');
}

就算传入的是字符,也没有调用类A中的函数,因为并没有构成重载关系,A中的同名函数被隐藏了

 友元和静态成员

继承与友元

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

继承与静态成员

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

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;
}

派生类的默认成员函数 

我们知道,六个默认成员函数,如果我们不写编译器会自动生成,那么在派生类中这些函数是如何生成的呢?

构造函数的定义

如果我们在基类和在派生类中都不显式定义构造函数那倒好说,但是一旦我们定义了,那麻烦就来了

class A
{
public:
	A(int a)
	{
		_a = a;
	}
protected:
	int _a;
};

class B:public A
{
public:
	B(int a, int b)
	{
		_a = a;
		_b = b;
	}
protected:
	int _b;
};

按照我们惯常的思路,我们只需要在派生类的构造函数中对_a和_b进行赋值就好了,但是很遗憾,这种方式并行不通

这是因为, 派生类的构造函数必须调用基类的构造函数初始化基类的那一部分成员。如果基类没有默认的构造函数,则必须在派生类构造函数的初始化列表阶段显示调用。

简单来说,只要基类中有显式定义的构造函数,那么派生类中必须要在构造函数中调用基类的构造函数

class A
{
public:
	A(int a)
	{
		_a = a;
	}
protected:
	int _a;
};

class B:public A
{
public:
	//正确的写法
	B(int a, int b)
		:A(a)
	{
		_b = b;
	}
protected:
	int _b;
};

拷贝构造与赋值

和构造函数相同,拷贝构造函数和赋值函数也必须要显示调用基类的拷贝构造函数和赋值函数

class A
{
public:
	A(int a)
	{
		_a = a;
	}
	A(const A& a)
	{
		_a = a._a;
	}
	A& operator=(const A& a)
	{
		_a = a._a;
		return *this;
	}
protected:
	int _a;
};

class B:public A
{
public:
	//正确的写法
	B(int a, int b)
		:A(a)
	{
		_b = b;
	}
	B(const B& b)
		:A(b._a)
	{
		_b = b._b;
	}
	A& operator=(const B& b)
	{
		A::operator=(b);
		_b = b._b;
	}
protected:
	int _b;
};

基类和派生类函数调用顺序

简单用一张图来概括,在调用构造函数时,先调用基类构造函数,再调用派生类构造函数;而再调用析构函数时,先调用派生类析构函数,再调用基类析构函数
因为在调用析构函数时,如果先调用了基类析构函数,将基类进行了清理,再对派生类进行清理,则会产生基类那部分区域的二次清理,为了避免这种情况我们会先调用派生类的析构函数

class A
{
public:
	A(int a)
	{
		cout << "construct A" << endl;
		_a = a;
	}
	~A()
	{
		cout << "destory A" << endl;
	}
protected:
	int _a;
};

class B:public A
{
public:
	B(int a, int b)
		:A(a)
	{
		cout << "construct B" << endl;
		_b = b;
	}
	~B()
	{
		cout << "destory B" << endl;
	}
protected:
	int _b;
};

int main()
{
	B* b = new B(1,1);
	delete b;
}

析构函数的隐藏关系

因为后续一些场景析构函数需要构成重写,重写的条件之一是函数名相同(这个在多态中会详细说明)。那么编译器会对析构函数名进行特殊处理,处理成destrutor(),所以父类析构函数不加virtual的情况下,子类析构函数和父类析构函数构成隐藏关系。


菱形继承

在C++中有许多因为年代久远而遗留下来的缺陷,菱形继承就是一大毒点,在Java等后来产生的语言里,为了避免菱形继承而废除了多继承这一语法,但是既然C++产生了这个坑,那就只能想办法将这个坑填上了

单继承与多继承

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

对于多继承,和初始化列表一样,我们只需要在继承的类中加上逗号即可

菱形继承

如果单看单继承和多继承,倒没有什么太大的毒点,但是如果我们如果考虑多继承的一种新的情况:

一瞬间肃然起敬

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

 我们进行分析时会发现,最后一个派生类Assistant继承了两个Person类,而问题也因此而产生了

  1. 二义性
    int main()
    {
    //编译器不知道应该访问Student中的_name还是Teacher中的_name,这便是二义性的问题
    	Assistant a;
    	a._name = "Ehundred";
    }
  2. 数据冗杂
    因为Person存了两份,导致Person那一部分的空间消耗翻了个倍

对于第一个问题,我们很好解决,我们只需要指明哪一个name即可

int main()
{
//指明之后程序便不会再报错,二义性的问题也消失了
	Assistant a;
	a.Student::_name = "Ehundred";
}

而对于第二个问题是没有很好的办法解决的,因为一个类所占用的空间是无法改变的
但是,为了解决这一个问题.C++创建了一个新语法——虚拟继承

虚拟继承

虚拟继承的定义

关键字virtual

class Person
{
public:
	string _name; // 姓名
};
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; // 主修课程
};

我们在继承方式前加上关键字virtual之后,就代表着这一继承方式变成了虚拟继承,虚拟继承不再是单独为基类开辟一块空间,而是用指针等方式存储基类的地址,让所有的派生类指向相同的基类

int main()
{
	Assistant a;
	a.Student::_name = "Ehundred";
	a.Teacher::_name = "Oyangbiao";
	cout << a.Student::_name << endl;
}

通过虚拟继承,我们改变了Teacher中的_name,Student中的_name也随之改变

虚拟继承的原理

我们来通过地址分别对比虚拟继承和菱形继承的存储方式

//菱形继承
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;
}

在菱形继承中,我们可以很清晰看到数据冗余,基类A被存储了两次

我们再来看虚拟继承

//虚拟继承
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的值,而是存储了一个意义不明的值,我们将16进制转化为10进制,我们发现,B中存储了数据20,而C中存储了数据12
  • 这些数据并非存储错误,我们看D下方,存储了A的数据"2",而A的地址恰好与B的地址相差了20个单位,与C的地址恰好相差了12个单位,说明在这个地址中记录的是地址偏移量,通过偏移量可以访问到A的数据所在的地址
  • 而为什么B和C中存储的地址第一行都是0?为什么从第二行才开始记录偏移量?这一行为并非无意义,在继承中无法体现出他的价值,而在往后多态他的意义才会出现

简单来说,虚拟继承只是用一种手段来实现只产生一次A空间,而这种手段可以多样,并非仅仅局限于VS中所用的方法

但是,既然菱形继承是一个坑,那我们只需要尽量去避免使用菱形继承便可以了,毕竟谁会喜欢用一个风险高收益小的东西呢?


继承与组合

继承与组合的区别

class A
{
public:
	int a;
};

//组合
class B
{
public:
	A a;
};

//继承
class C:public A
{
};

继承和组合在定义上的区别便是以上,在使用上有什么区别呢?

  1. 继承可以访问protected成员,而组合只能访问public成员
  2. 继承可以直接使用基类的成员变量,而组合必须要通过访问类变量的方式来访问基类的成员变量
  3. public继承是一种is-a的关系。也就是说每个派生类对象都是一个基类对象
    组合是一种has-a的关系。假设B组合了A,每个B对象中都有一个A对象

虽然在功能上没有太大的差别,但是在设计程序中一般会采取一个原则:

低耦合 高内聚

耦合度即使代码之间相互的依赖程度,我们在设计程序时会将程序分为几个类模块,模块之间的相互影响程度很低,这样如果我们想改变一个类,另一个类不需要随之改变

而组合便是更满足这一要求,因为如果采用继承,基类变量的改变会直接影响派生类,派生类中的所有该变量都要为止修改,可维护性极差,所以我们一般会采取组合的方式来构建类与类之间的联系


 

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值