文章目录
- 继承
- 继承的概念
- 继承方式及权限
- using改变成员的访问权限
- 基类与派生类的赋值转换
- 回避虚函数机制
- 派生类的默认成员函数
- 友元与静态成员
- 多继承
- 菱形继承
- 虚继承
- 组合
继承
继承的概念
继承可以使得子类具有父类的属性和方法或者重新定义、追加属性和方法等。
当创建一个类时,我们可以继承一个已有类的成员和方法,并且在原有的基础上进行提升,这个被继承的类叫做基类,而这个继承后新建的类叫做派生类。基类必须是已经定义而非仅仅声明,因此,一个类不能派生它本身。
继承这种通过生成子类的复用通常被称为 白箱复用(white-box reuse)
。术语 白箱 是相对可视性而言:在继承方式中,父类的内部细节对子类可见。
派生类的作用域嵌套在基类的作用域之内。
class [派生类名] : [继承类型] [基类名]
[继承类型] [基类名]
的组合被称为派生列表,值得注意的是,派生列表仅出现在定义中,而不能出现在声明中:
class A : public B; // ERROR:派生列表不能出现在声明中
正确实现如下:
class Human
{
public:Human(string name = "张三", int age = 18) : _name(name), _age(age){}void Print(){cout << "name:" << _name << endl;cout << "age:" << _age << endl;}protected:string _name;int _age;
};class Student : public Human
{
public:Student(string stuNum = "123456") : Human(), _stuNum(stuNum){}void Print() // 将父类的Print函数重定向成自己的Print函数{Human::Print();cout << "stuNum:" << _stuNum << endl;}protected:string _stuNum; // 增加的成员变量
};int main()
{Human h1;Student s1;h1.Print();cout << endl;s1.Print();return 0;
}
基类和派生类都具有他们各自的作用域,那如果出现同名的成员(如上面的 Print函数
),此时会怎么样呢?这里就要牵扯到一个概念——隐藏。(隐藏而非 重载 ,方法名虽然相同,但处于不同的作用域中。)
隐藏:也叫做重定义,当基类和派生类中出现重名的成员时,派生类就会将基类的同名成员给隐藏起来,然后使用自己的。(但是隐藏并不意味着就无法访问,可以通过声明基类作用域来访问到隐藏成员。)
因此 s1
调用 Print函数
时不会调用父类的,而是调用自己的。
继承方式及权限
继承的方式和类的访问限定符一样,分为
public(公有继承)
、private(私有继承)
、protected(保护继承)
三种。
关于 protected
:
- 对于类的对象来说是不可访问的。
class Human
{
private:int pri;
protected:int pro;
public:int pub;
};
- 对于派生类的成员(数据成员or成员函数)和基类的友元来说是可以访问的。
- 子类/子类的友元不能直接访问父类的受保护成员,只能通过子类对象来访问。 如果子类(及其友元)能直接访问父类的受保护成员,那么
protected
提供的访问保护也就太不安全了。
class Human
{
protected:int age;
};class Student : public Human
{int stu_id;friend void get(Human&); // 不能访问Human::agefriend void get(Student&); // 可以访问Student:age
};
基类成员的访问说明符/派生类的继承方式
- 对基类成员的访问权限只与基类中的访问说明符有关,与派生类的继承方式无关。
class Human
{
private:int pri;
protected:int pro;
public:int pub;friend int f(Human h) { return h.pro; }
};class Student : public Human
{int f1() { return pri; } // ERROR:纵然是public继承也不可以访问private成员int f2() { return pro; } // 正确:protected成员可以被派生类访问
};class Teacher : private Human {int f1() { return pro; } // 正确:protected成员可以被派生类访问,即使继承方式是private
};
- 继承方式控制派生类对象(包括派生类的派生类)对于基类成员的访问权限。
总结来讲父类成员的访问权限决定了子类是否能访问该成员,而继承方式决定了父类成员在子类中的新权限是怎样的:
public
:继承自父类的成员在父类中是什么权限,子类中就是什么权限。protected
:继承自父类的成员其访问权限都变成protected
。private
:继承自父类的成员其访问权限都变成private
。
派生类向基类转换的可访问性
假设 D
继承自 B
:
- 只有当
D
公有地继承B
时,派生类对象才能使用派生类向基类的转换;如果继承方式是受保护的或者私有的,则不能使用该转换。 - 不论
D
以什么方式继承B
,D
的成员函数和友元都能使用派生类向基类的转换。换言之,派生类向其直接基类的类型转换对于派生类的成员和友元来说永远是可访问的。 - 如果继承方式是公有的或者受保护的,则
D的子类
(第二点说的是D
本身)的成员和友元可以使用D
向B
的类型转换;反之,如果继承方式是私有的,则不能使用。
默认的继承方式
默认情况下:
- 使用
class
关键字定义的派生类是私有继承的 - 使用
struct
关键字定义的派生类是公有继承的
using改变成员的访问权限
我们说继承方式决定了派生类的对象及派生类的子类对继承来的成员的访问权限,但这不是绝对的,我们可以通过 using
改变成员的访问权限,但只能改变派生类能访问的成员,即基类中的 protected
和 public
成员。
class Human
{
private:int pri;
protected:int pro;
public:int pub;
};class Teacher : private Human { // 私有继承
private:// 只能被类的成员or友元访问
public:using Human::pri; // 错误:using只能为派生类可访问的成员提供声明using Human::pro; // Teacher的对象、成员、友元、子类都可以访问
protected:using Human::pro; // Teacher的对象、成员、友元可以访问using Human::pub;
};
基类与派生类的赋值转换
我们在 四种强制转换类型中的 dynamic_cast 部分 提到过父类与子类的赋值转换
派生类可以赋值给基类的对象、指针或者引用,这样的赋值也叫做 对象切割 。
当把派生类赋值给基类时,可以通过切割掉多出来的成员如 _stuNum
来完成赋值。
但是 基类对象 不可以赋值给 派生类 ,因为他不能凭空多一个 _stuNum
成员出来。
但是 基类的指针却可以通过强制类型转换赋值给派生类对象 , 如:
int main()
{Human h1;Student s1;Human* hPtrs = &s1; // 指向派生类对象Human* hPtrh = &h1; // 指向基类对象// 传统方法Student* pPtr = (Student*)hPtrs; // 没问题Student* pPtr = (Student*)hPtrh; // 有时候没有问题,但是会存在越界风险// 如果父类之中包含虚函数,可以使用dynamic_castStudent* pPtr = dynamic_cast<Student*>(hPtrh);// 如果确认基类向派生类的转换是安全的,可以使用static_castStudent* pPtr = static_cast<Student*>(hPtrs);return 0;
}
总结来讲:
- 派生类可以赋值给基类的对象、指针或者引用
- 基类对象不能赋值给派生类对象
- 基类的指针可以通过强制类型转换赋值给派生类的指针。但是必须是基类的指针是指向派生类对象时才是安全的,否则会存在越界的风险。但基类如果是多态类型(父类之中包含虚函数),则可以使用
RTTI
的dynamic_cast
来实现 指向基类的基类指针 到 派生类对象 的安全转换。
回避虚函数机制
我们说多态是为了实现子类对于同一操作的不同结果,但有时候,派生类需要调用其父类的虚函数版本,而非自己的虚函数版本:
int main()
{Human h1;Student s1;Human* hPtrs = &s1; // 指向派生类对象hPtrs->print(); // 由于hPtrs指向子类对象,因此调用子类的虚函数hPtrs->Human::print(); // 强行调用Human中的虚函数,而不在意hPtrs的动态类型return 0;
}
派生类的默认成员函数
之前有写过 类的默认六个成员函数
class Human
{
public:Human(){cout << "Human 构造函数" << endl;}~Human(){cout << "Human 析构函数" << endl;}protected:string _name;int _age;
};class Student : public Human
{
public:Student(){cout << "Student 构造函数" << _name << endl;}~Student(){//~Human(); 不需要手动调用父类的析构函数,编译器会在子类析构函数结束后自动调用。cout << "Student 析构函数" << endl;}
protected:string _stuNum;
};int main()
{Student s1;return 0;
}
可以看到,调用派生类的默认成员函数时都会调用基类的默认构造函数。
- 派生类的构造函数必须调用基类的构造函数初始化基类的那一部分成员。如果基类没有默认的构造函数,则必须在派生类构造函数的初始化列表阶段显示调用。
- 派生类的拷贝构造函数必须调用基类的拷贝构造完成基类的拷贝初始化。
- 派生类的
operator=
必须要调用基类的operator=
完成基类的复制。 - 派生类的析构函数会在被调用完成后自动调用基类的析构函数清理基类成员。
在派生类的析构函数中,基类的析构函数会被隐藏,为了实现多态,它们都会被编译器重命名为 destructor
。
友元与静态成员
友元
友元关系是不会继承的(友元关系不具有传递性),可以这样理解,你长辈的朋友并不是你的朋友。
- 基类的友元能访问基类的私有/保护成员,但不能访问子类的私有/保护成员。(当然基类本身也无法访问子类的私有成员。)
- 子类访问父类友元的私有成员就更不用想了:
- 一是友元关系并不对称,A 是 B 的友元,B 不一定是 A 的友元,也就是说父类本身都不一定能访问父类友元的私有成员(父类不一定是其友元的友元),何况子类;
- 二是就算父类是其友元的友元,但友元关系不具有传递性,子类不一定是父类友元的友元。
- 子类的友元无法访问父类的私有/保护成员。
静态成员
无论继承了多少次,派生了多少子类,静态成员在这整个继承体系中有且只有一个。静态成员不再单独属于某一个类亦或者是某一个对象,而是属于这一整个继承体系。
多继承
如果一个子类同时继承两个或以上的父类时,此时就是多继承。
多继承虽然能很好的继承多个父类的特性,达到复用代码的效果,但是他也有着很多的隐患,例如菱形继承的问题,这也就是为什么后期的一些语言如 java
把多继承去掉的原因。
菱形继承
class Human
{
public:int _age;
};class Student : public Human
{
public:int _stuNum;
};class Teacher : public Human
{
public:int _teaNum;
};
这里有着人类、学生类、老师类。在学校中,还存在着同时具有老师和学生这两个属性的人,也就是助教。所以我们可以让他同时继承 teacher类
和 student类
。
class Assistant : public Teacher, public Student
{
};
按照道理来说,各个类的大小应该是这样的。Human
类4个字节,Teacher
和 Student
都是8个字节,而 Assistant
是12个字节。但是实际上 Assistant
却是16字节。
这就是菱形继承的 数据冗余 和 二义性 问题的体现。
这里的 Teacher
和 Student
都从 Human
中继承了相同的成员 _age
。但是 Assistant
再从 Teacher
和 Student
继承时,就分别把这两个 _age
都给继承了过来。
这就是数据冗余问题。
倘若我们想要给那个 _age
赋值:
因为里面存在两个一样的 _age
,因此需要指定作用域:
这也就是二义性问题。
虚继承
想解决二义性很简单,当多个类继承同一个类时,就在继承这个类时,为其添加一个虚拟继承的属性。
class Student : virtual public Human
{
public:int _stuNum;
};class Teacher : virtual public Human
{
public:int _teaNum;
};
这时就可以看到,它只继承了一次。
接下来看看大小:
按照道理来说,a
应该是 12字节
,t
、 s
应该是 8字节
啊?这里就牵扯到了C++的对象模型,先推荐一篇博客: C++中的虚函数(表)实现机制以及用C语言对其进行的模拟实现
这里多出来的 8个字节
,其实是两个虚基表指针(vbptr)。同理,s
、t
多出来的 4字节
,是 一个vbptr
。
因为这里 Human
中的 _age
是 teacher
和 student
共有的,所以为了能够方便处理,在内存中分布的时候,就会把这个共有成员 _age
放到对象组成的最末尾的位置。然后在建立一个虚基表,这个表记录了各个虚继承的类在找到这个共有的元素时,在内存中偏移量的大小,而虚基表指针则指向了各自的偏移量。
这里打个比方:
通过这个偏移量,他们能够找到自己的 _age
的位置。
为什么需要这个偏移量呢?
int main()
{Assistant a;Teacher t = a; Student s = a;return 0;
}
如上,当把对象 a
赋值给 t
和 s
的时候,因为他们互相没有对方的 _stuNum
和 _teaNum
,所以他们需要进行对象的切割,但是又因为 _age
存放在对象的最尾部,所以只有知道了自己的偏移量,才能够成功的在切割了没有的元素时,还能找到自己的 _age
。
组合
那除了继承还有什么好的代码复用方式吗?那答案肯定是有的,就是组合。组合就是将多个类组合在一起,实现代码复用。
继承和组合又有什么区别呢?
- 继承是一种
is a
的关系,基类是一个大类,而派生类则是这个大类中细分出来的一个子类,但是他们本质上其实是一种东西。正如:学生也是人,所以他可以很好的继承人的所有属性,并增加学生独有的属性。 - 组合是一种
has a
的关系,就是一种包含关系。对象a
是对象b
中的一部分,对象b
包含对象a
。
组合这种通过对方开放接口来实现的复用被称为 黑箱复用(black-box reuse)
,因为对象的内部细节是不可见的。对象只以 黑箱 的形式出现。
class Study
{
public:void ToStudy(){cout << "Study" << endl;}
};class Student : public Human
{
public:Study _s;int _stuNum;
};
这里的 Student类
中包含了一个 Study类
,学习是学生日常生活中不可缺少的一部分。
比较组合和继承:
- 组合的依赖关系弱,耦合度低。保证了代码具有良好的封装性和可维护性。 在组合中,几个类的关联不大,我只需要用到你那部分的某个功能,我并不需要了解你的实现细节,只需要你开放对应的接口即可,并且如果我要修改,只修改那一部分功能即可。
- 继承的依赖关系就非常的强,耦合度非常高。 因为你要想在子类中修改和增加某些功能,就必须要了解父类的某些细节,并且有时候甚至会修改到父类,父类的内部细节在子类中也一览无余,严重的破坏了封装性。并且一旦基类发生变化时,牵一发而动全身,所有的派生类都会有影响,这样的代码维护性会非常的差。一个可用的解决方法就是只继承抽象类,因为抽象类通常提供较少的实现。
但是大部分场景下,如果继承和组合都可以选择,那么 优先使用对象组合,而不是类继承 。