1.继承概念
继承 (inheritance) 机制是面向对象程序设计 使代码可以复用 的最重要的手段,它允许程序员在 保 持原有类特性的基础上进行扩展 ,增加功能,这样产生新的类,称派生类。继承 呈现了面向对象 程序设计的层次结构 ,体现了由简单到复杂的认知过程。以前我们接触的复用都是函数复用, 继 承是类设计层次的复用
基本格式:在新的类的后面,冒号+继承方式+基类
父类必须是已经存在的类:
派生类也叫子类,基类也叫父类,子类从父类处“继承”父类的结构。
而继承的方式有public private protected三种方式(此处采用的public)
继承的逻辑:
继承就是将父类的全部拷贝一份,函数是同样的函数(就像类函数一样管理,这样能节省空间),但是变量是新的变量。
假设Person中有一个打印信息的Print函数,Print这种函数就不用自己在student重新写了,但是构造函数等还是得重新写(会在后文详细说明)。
继承的本质还是一种复用
2.继承方式
三种继承方式名称与访问限定符一致。
父类成员在派生类中到底是什么访问方式,取决于该变量在父类的访问限定符和子类的继承方式。
观察此表,可以得出规律:
1. 基类的私有成员,无论派生类以什么方式继承都不可见
不可见的意思就是,派生类无论如何都不能使用这个类,不论你是访问还是写入
不可见的本质就是不想被子类继承。但是派生类中是有这个成员的,只是自己不能访问。
那么子类有没有方法能够访问父类中private下的成员呢?
解决方案:在父类中增加一个函数,而这个函数可以直接访问该被private修饰的成员 。
然后子类可以继承并使用这个函数即可。
关于私有继承(表格最右栏),本来的public成员也变private成员了。那么除了基类成员都是不可见之外,另外六种情况怎么处理呢?我们见规律如下:
2. 基类private成员在派生类中是不能被访问,如果基类成员不想在类外直接被访问,但需要在派生类中能访问(需要能被可见化继承),就定义为protected。可以看出保护成员限定符是因继承才出现的。3. 实际上面的表格我们进行一下总结会发现,基类的私有成员在子类都是不可见。基类的其他成员在子类的访问方式 == Min(成员在基类的访问限定符,继承方式),public > protected> private。
换句话说,根据 权限public大于protected大于private 就能判断此时到底属于什么。
最后,还有两种经常使用的注意事项:
4. 使用关键字 class 时默认的继承方式是 private ,使用 struct 时默认的继承方式是 public , 不过最好显示的写出继承方式 。5. 在实际运用中一般使用都是 public 继承, 几乎很少使用protetced/private继承 ,也不提倡使用 protetced/private 继承,因为 protetced/private 继承下来的成员都只能在派生类的类里面使用,实际中扩展维护性不强。
因此,实践中主要是这两种运用的比较多:用public继承Public 、用public继承protected
3. 基类和派生类对象赋值转换 (不会生成临时变量,直接切片)
派生类对象 可以赋值给 基类的对象 / 基类的指针 / 基类的引用 。这里有个形象的说法叫切片或者切割。寓意把派生类中父类那部分切来赋值过去。但是基类对象不能赋值给派生类对象。这也叫 赋值兼容转换
这里不同于普通的隐式类型转换。指针和引用两种情况并不产生临时变量。因此Person&前面可以不加const(但是直接赋值p = s是有临时变量的)
回顾:char c = 'x'; char& C = c;
这样使用是会报错的,因为引用赋值时会有临时变量,需要使用const修饰。
const char& C = c;
p=s: 将s中的相关的内容拷贝一份过来
Person* ptr = &s ptr只指向基类的部分
Person& ref = s ref只作为派生类中的父类部分的引用。
正是因为没有临时变量,所以改变变量p也会改变s中的内容。改变s中继承的内容也会改变p中的内容。
关于基类赋值给派生类(之后再学习):
基类的指针或者引用可以通过强制类型转换赋值给派生类的指针或者引用。但是必须是基类的指针是指向派生类对象时才是安全的。这里基类如果是多态类型,可以使用 RTTI(RunTime Type Information) 的 dynamic_cast 来进行识别后进行安全转换。
4. 继承中的作用域
在继承体系中 基类 和 派生类 都有 独立的作用域 。通过一个例子来看继承中的查找规则:例如,子类想调用一个Print函数,但是Print函数是实现在父类中的,编译器先直接在派生类类域中查找有无该函数,没有的话就直接去父类中查找,可以直接使用。先找自己,再找父类,再找全局。
4.1 重定义&隐藏
同一个域是不能有同名变量的,但是基类和派生类作为两个域,是可以有重名变量的。子类和父类中有同名成员, 子类成员将屏蔽父类对同名成员的直接访问 ,这种情况叫隐藏,也叫重定义。 (在子类成员函数中,可以 使用 基类 :: 基类成员 显示访问 )
根据查找规则也可以理解,要先去找 自己的类域里面找,找不到再去父类的类域。
这种操作被叫做 重定义 或者 隐藏。
来看两个关于隐藏的题目:
第一题:
答案是B
两者构成隐藏。重载要求在同一个作用域。
如果是成员函数的隐藏,只需要函数名相同就构成隐藏。
主函数是:
答案:构成隐藏,但是编译报错 。
编译器在编译时,先去子类中查找fun,找到了便直接使用,但是使用时发现参数名对不上,所以编译报错。
正确使用方法:
在实际运用中,避免定义同名成员,纯纯给自己找坑。
但是同名成员是不可能完全避免的,因为运算符重载的名字不会改变。
5. 派生类的默认成员函数
取地址重载使用较少,我们对拷贝构造,赋值,默认构造和析构进行梳理:
父类的构造,子类可以用,但是这样足够吗?
C++规定,子类如果要进行构造,拷贝构造,析构,赋值等操作,父类的部分都必须调用父类相关的函数进行操作。
5.1 默认构造
不写的话,编译器会自动生成一个默认构造。
此时子类中有三种变量:
注意,要将父类成员看做整体。
先来观察下如果不在子类中写,编译器默认生成的是什么样子的:
调用了父类的构造和析构。
因为父类会被当作一个整体,直接调用了父类的构造和析构。
在初始化时,也不能直接操作父类的对象,比如直接在初始化列表中走父类的_name:
按照上图的报错,基类是可以整体初始化的。
的确,可以让父类整体走初始化列表:
但是对于编译器自己生成的子类的构造函数,只能调用父类的默认构造。
5.2 拷贝构造
注意拷贝构造必须传引用:
class student : public person {
public:
void func(int i) {
cout << "func(int i)" << endl;
}
student(int grade = 100,int x =30)
://person(40,"qq")
_grade(grade)
,_x(x)
{
cout << "student()" << endl;
}
student(const student& s)
:_grade(s._grade)
,person(s)
{
cout << "student(const student& s)" << endl;
}
private:
size_t _grade;
int _x;
};
再实现一个person的拷贝构造:
难点就是:
s作为一个student类型的变量,是如何给person拷贝的?
根据刚才的对象赋值转换,当Person走拷贝构造时,直接使用s作为参数 ,s就会通过切片的方法传给p , 此时的p引用就不再是指向派生类全部,而是指向派生类中的父类部分(被切割了) 子类可以直接当父类用,会自动进行切片的操作。
5.3 赋值运算符重载:
先在父类中实现一个赋值运算符的现代写法:
class person {
public:
person(int age = 20,string name = "peter",string _add = "武侯区")
:_age(age)
,_name(name)
,_add(_add)
{
cout << "person()" << endl;
}
person(const person& p)
:_age(p._age)
,_name(p._name)
,_add(p._add)
{
cout << "person(const person& p)" << endl;
}
person& operator=( person copy) {
swap(copy._add, this->_add);
swap(copy._name, this->_name);
swap(copy._age, this->_age);
return *this;
}
void func() {
cout << "func()" << endl;
}
protected:
int _age;
string _name;
string _add;
};
同理,由于赋值转换,person对象也可以直接被student对象赋值
然后直接在基类部分复用父类的operator=即可
5.4 析构
先按照和上面同样的处理方法:
但是析构不同的是,~student和~Person都会被改名为destructor() 两者形成隐藏,需要指定~Person()的类域。
这样写的程序虽然能跑,但是观察控制台可知,~student()打印的次数超过预计。
这里的逻辑有不同,Person::~Person()是不需要我们显式写的
对你没看错,又是cpp的特例,会在子类析构后自动调用父类的析构。
并且cpp也希望在析构时,先子后父。因为子类的析构中可能会用到父类的数据。
原因如下:
初始化时,先父后子
(因为子类构造初始化中可能会使用父类成员)
但是c++避开了这个问题,因为初始化列表的顺序不是真正的初始化顺序,而是根据声明的顺序来的。
析构则相反:
析构则遵守先子后父的规则。
又因为父类的显式调用不能保证先父后子,所以
观察派生类的构造函数是否满足上述称述:
是因为父类的初始化如果需要手动实现,只能在初始化列表里写,而初始化列表并不是根据显式写出的顺序来,而是能保证先父后子。
即使屏蔽掉person在初始化列表中的位置,也是先父后子
6. 友元
在类中被声明为友元的外部函数可以访问类里的私有成员
你爸的朋友不是你的朋友,友元函数不能够被继承。
比如:
Display实现如下:
void Display(const Person& p, const Student& s)
{
cout << p._name << endl;
cout << s._stuNum << endl;
}
明显这个是不能跑的,Display是person的友元,能访问person的private 或者 protected
但是不能访问student中的protected或者private
派生类的友元函数更不能访问基类!
7. 继承与静态成员
基类定义了 static 静态成员,则整个继承体系里面只有一个这样的成员 。无论派生出多少个子类,都只有一个static 成员实例 。任何一个static成员都属于整个继承体系。
8.菱形继承及菱形虚拟继承(简易)
上文我们多介绍的是单继承,还有多继承:
比如我们从一个植物的类继承出了水果和蔬菜,又想从水果和蔬菜里面去各继承一部分实现出西红柿。
写出来不会有问题,但是当你在西红柿里访问植物中的变量时就会报错。
因为植物的信息有两份继承到了西红柿里面(蔬菜一份,水果一份),会有歧义。
解决方案一:
声明类域即可:
解决了二义性的歧义问题,但是没有解决数据冗余的问题。
并且此时的a还是有两个name,二义性也没有完全解决。
方案二:虚拟继承
也就是在选择继承方式的时候,在前面加上关键字virtual
虚拟继承只能在此处使用!
关于在哪写关键字vietual:
在哪继承会冗余,就在哪里写。所以当发生上图情况的时候,应当在B和C的位置用virtual
原理:虚基表
虚基表难度较大,博主争取在之后出一篇专门的文章梳理虚基表的内容。
不建议菱形继承,势必涉及到虚拟继承,使用多继承即可。
但是实践中也会有菱形继承:
ios继承给istream和ostream istrem和ostream又继承给iostream
当然,库里面肯定是采用虚拟继承的。
9. 继承与组合
继承的总结和反思1. 多继承就是cpp难度大的一个体现。有了多继承 ,就存在菱形继承,有了菱 形继承就有菱形虚拟继承,底层实现就很复杂。所以一般不建议设计出多继承,一定不要设 计出菱形继承。否则在复杂度及性能上都有问题。2. 多继承可以认为是 C++ 的缺陷之一,很多后来的 OO 语言都没有多继承,如 Java 。3. 继承和组合public继承是一种is-a的关系。也就是说每个派生类对象都是一个基类对象。组合是一种has-a的关系。假设B组合了A,每个B对象中都有一个A对象。下面是一段网上的总结:“ 优先使用对象组合,而不是类继承 。继承允许你根据基类的实现来定义派生类的实现。这种通过生成派生类的复用通常被称为白箱复用 (white-box reuse) 。术语 “ 白箱 ” 是相对可视性而言:在继承方式中,基类的内部细节对子类可见 。继承一定程度破坏了基类的封装,基类的改变,对派生类有很大的影响。派生类和基类间的依赖关系很强,耦合度高。白盒复用和黑盒复用是软件工程的概念,白盒即内部逻辑不可见,黑盒即内部逻辑可见工作中,白盒测试难度远大于黑盒测试,白盒测试一般由开发人员自己完成,黑盒由测试人员完成。对象组合是类继承之外的另一种复用选择。新的更复杂的功能可以通过组装或组合对象来获得。对象组合要求被组合的对象具有良好定义的接口。这种复用风格被称为黑箱复用 (black-box reuse) ,因为对象的内部细节是不可见的。对象只以 “ 黑箱 ” 的形式出现。组合类之间没有很强的依赖关系,耦合度低。优先使用对象组合有助于你保持每个类被封装。实际尽量多去用组合。组合的耦合度低,代码维护性好。不过继承也有用武之地的,有些关系就适合继承那就用继承,另外要实现多态,也必须要继承。类之间的关系可以用继承,可以用组合,就用组合。 ”
博主在阅读完之后,有以下感受:
实践工作中,非常看重:高内聚,低耦合
便于团队协作,不要让新的板块影响已经写好的板块。
更符合is_a就用继承 比如:植物 水果
更符合has_a就用轮胎。 比如 汽车 轮胎
用面向对象的逻辑来决定继承还是组合。
再比如说,用list实现queue,可以说queue包含了一个链表,也可以说queue是一个链表
两种情况都能用的情况下,优先使用组合:has_a
比如list实现queue的时候我们就是使用的典型的has_a