1. 概念
继承(英语:inheritance)是面向对象软件技术当中的一个概念。如果一个类别B“继承自”另一个类别A,就把这个B称为“A的子类”,而把A称为“B的父类别”也可以称“A是B的超类”。继承可以使得子类具有父类别的各种属性和方法,而不需要再次编写相同的代码。在令子类别继承父类别的同时,可以重新定义某些属性,并重写某些方法,即覆盖父类别的原有属性和方法,使其获得与父类别不同的功能。另外,为子类追加新的属性和方法也是常见的做法。 一般静态的面向对象编程语言,继承属于静态的,意即在子类的行为在编译期就已经决定,无法在执行期扩展。–维基百科:继承 (计算机科学)
子类也叫派生类(derived),父类也叫基类(base)(下文多以父子类表述)。继承呈现了面向对象程序设计的层次结构,体现了由简单到复杂的认知过程。以前我们接触的复用都是函数复用,而继承便是类设计层次的复用。
2. 定义
2.1 格式
class [子类/派生类] : [继承方式] [父类/基类] { public: //... };
为了不增加关键字,C++也使用了三个访问限定符以定义继承方式:
- public继承(公有继承)
- protected继承(保护继承)
- private继承(私有/隐藏继承)
2.3 继承父类成员访问方式
在父类的内部,有三种限制成员被访问的限定符,而子类继承父类,也有三种方式,那么一共有九种子类继承父类成员的方式。
实际上,绝大部分情况下都是public继承,因为C++是第一个吃螃蟹的人,有些路只有走过了才知道,所以这里只要了解即可。
父类当中被不同访问限定符修饰的成员,以不同的继承方式继承到子类当中后,该成员最终在子类当中的访问方式将会发生变化。
类成员/继承方式 | public继承 | protected继承 | private继承 |
父类的public成员 | 子类的public成员 | 子类的protected成员 | 子类的private成员 |
父类的protected成员父类的 | 子类的protected成员 | 子类的protected成员 | 子类的private成员 |
父类的private成员 | 在子类中不可见 | 在子类中不可见 | 在子类中不可见 |
不用记忆表格,根据权限的大小,有这样的排序:public > protected > private。规则:
子类继承父类成员的访问方式=父类成员访问限定符和继承方式中小的那一个。比如父类的成员变量都是public,子类继承关系是protected,那么子类访问继承父类成员的访问方式就是protected。由此可见,如果有任意一方的限定符是private,那么访问方式都是private,这是符合常理的。我们通常把“无法访问”某个成员变量称作这个成员“不可见”。
什么是“子类继承父类成员的访问方式”?
子类的成员有两种,一种是继承父类的,一种是自己定义的。继承父类的成员也是有访问限制的。
class father { public: int _a; private: int _b; }; class child : public father { public: int _c; private: int _d; };
虽然从代码上看,子类里只有我们自己定义的成员变量,但它也有继承父类的成员表变量,但是父类的成员变量在子类中要被继承方式限制:
class child : public father { | | | // 父类的成员变量(隐式) public: <------| int _a; | private: <------| int _b; // 子类的成员变量 public: int _c; private: int _d; };
那么child类(子类)要访问继承的_a的权限是public,访问继承的_b的权限是private。
我们要明确一点,即使子类继承的成员变量是private的,它也是实际存在的,只是被访问限定符限制了。
虽然上面解释了很多,但是实际上绝大部分情况只使用public继承方式,因为因为使用protected和private继承下来的成员都只能在子类的类里面使用,扩展维护性不强。
默认继承方式
在使用继承的时候可以不指定继承方式:
- 子类使用关键字class时默认的继承方式是private。
- 子类使用struct时默认的继承方式是public。
虽然继承有这样的默认继承方式,但强烈建议显式地写出继承方式。
3. 赋值兼容转换
我们接触的到的类型转换有两种方式:
- 显式强制类型转换,例如
int a = 97; char b = (char)a; // 将a显式地强转为char型
- 隐式类型转换,例如
float a = 1.11; int b = a; // a被隐式地转换为int型
那么类之间可以发生类型转换吗?
3.1 切片
那么类作为一种类型,也是可以发生类型转换的,但是类发生类型转换必须是显式地、单向地从子类转换到父类。
切片:子类对象赋值给父类对象、引用或指针,叫做切片或切割。
对于以下子类和父类:
//父类 class Person { protected: string _name; //姓名 string _sex; //性别 int _age; //年龄 }; //子类 class Student : public Person { protected: int _stuid; //学号 };
下面的操作都是切片:
Student s; Person p = s; //子类对象赋值给父类对象 Person* ptr = &s; //子类对象赋值给父类指针 Person& ref = s; //子类对象赋值给父类引用
切片/切割可以这样理解:子类把它继承的父类的那一部分切割出来给父类类型的对象,指针或引用变量。
因为子类不仅有继承自父类的成员,还有自己定义的成员,所以能够在子类中完整地取出父类的所有成员,但是子类就不一定了。这也是只能父类向子类单向转换的原因。
4. 作用域
在继承体系中的父类和子类都有独立的作用域。若子类和父类中有同名成员,子类成员将屏蔽父类对同名成员的直接访问,这种情况叫隐藏,也叫重定义。
- 成员变量:子类找变量,先从子类找,找不到再去父类找(protected)。也可以指定类域访问。
- 成员函数:函数名相同,相当于protected,根据参数列表选择。
总之就是就近原则,如果是子类对象,那么找成员先从子类的大括号里(作用域)找,找不到再去父类的作用域找。
4.1 成员变量
#include <iostream> #include <string> using namespace std; //父类 class Person { public: int _num = 666 }; //子类 class Student : public Person { public: void fun() { cout << _num << endl; } private: int _num = 111; }; int main() { Student s; s.fun(); //111 return 0; }
输出
111
即使父类的_num是public,s.func()也是在子类作用域中查找_num的。
指定类域访问父类中的_num:
void fun() { cout << _num << endl; cout << Person::_num << endl; // 指定父类类域的_num }
输出
111
666
4.2 成员函数
如果子类和父类都有一个同名函数:
#include <iostream> using namespace std; //父类 class Person { public: void fun(int x) { cout << x << endl; } }; //子类 class Student : public Person { public: void fun(int x) { cout << x << endl; } }; int main() { Student s; s.fun(1); // 直接调用子类的成员函数fun s.Person::fun(20); // 指定调用父类的成员函数fun return 0; }
【注意】
父类和子类中的fun函数不构成函数重载,因为它们不在一个作用域中。它们的函数名相同,构成隐藏。实际上,即使在不同的作用域中,但对于父子类,尽量不要设置同名的函数。
我们可以显式的指定类域访问成员函数,也可以让编译器通过函数参数列表选择父类或子类中的同名函数:
#include <iostream> using namespace std; //父类 class Person { public: void fun(int x) { cout << x << endl; } }; //子类 class Student : public Person { public: void fun(float x) { cout << x << endl; } }; int main() { Student s; s.fun(1); s.fun(2.2); // 根据参数列表选择父子类中的fun return 0; }
输出
1
2.2
5. 子类的默认成员函数
默认成员函数就是我们不显示地写编译器也会自动生成的函数:
- 初始化和清理:
- 构造函数
- 析构函数
- 拷贝赋值
- 拷贝构造函数
- 赋值运算符重载
- 取地址运算符重载(用于取普通和const对象的地址,很少自己实现)
5.1 构造和析构
下面主要就析构函数和构造函数进行讨论。在之前学习类和对象部分我们知道,当出现多个对象时,析构和构造的顺序是符合栈的特性的。例如:
#include <iostream> using namespace std; class A { public: A(int a = 0) { _a = a; cout << "A()" << endl; } ~A() { cout << "~A()" << endl; } private: int _a; }; class B { public: B(int b = 0) { _b = b; cout << "B()" << endl; } ~B() { cout << "~B()" << endl; } private: int _b; }; int main() { A a(1); B b(2); return 0; }
输出
A()
B()
~B()
~A()
不论对象是相同类型还是不同类型,都是先构造的后析构。
对于父子类对象,也是一样的,都遵守先构造的后析构,但有些细节需要注意。
把上面的代码修改:将B类作为A类的子类,然后在main函数中只实例化子类的对象,看看会发生什么:
#include <iostream> using namespace std; class A { public: A(int a = 0) { _a = a; cout << "A()" << endl; } ~A() { cout << "~A()" << endl; } private: int _a; }; class B : public A { public: B(int b = 0) { _b = b; cout << "B()" << endl; } ~B() { cout << "~B()" << endl; } private: int _b; }; int main() { //A a(1); B b(2); return 0; }
输出
A()
B()
~B()
~A()
为什么实例化的是子类对象,还会调用父类的析构和构造函数呢?
- 子类在构造对象时,会先调用父类的构造函数初始化父类的那一部分,然后才会调用子类自己的构造函数;
- 子类在析构对象时,会先调用子类自己的析构函数清理子类的那一部分,然后才会调父类的析构函数。
这也是符合“先构造的后析构”这一规则的(栈的特性),父类的那一部分先构造,父类的那一部分后析构。
如果父类没有默认构造函数呢?
有自定义的构造函数,编译器就不会生成默认构造函数。必须在子类构造函数的初始化列表显式地调用父类自定义的构造函数。
但是不能在初始化列表直接初始化父类成员:
class C : public A { public: C(int a, int c) :_a(a) , _c(c) {} public: int _c; };
因为父类成员是看作一个整体的,我们可以用一个父类的匿名对象,让这个对象的构造函数构造它。
#include <iostream> using namespace std; class A { public: A(int a = 0) { _a = a; cout << "A()" << endl; } ~A() { cout << "~A()" << endl; } private: int _a; }; class C : public A { public: C(int a, int c) :A(a) , _c(c) {} public: int _c; }; int main() { //A a(1); B b(2); C c(1, 2); return 0; }
5.2 拷贝构造
- 子类自己的成员,普通类一样。(复习:内置类型值拷贝,自定义类型调用它的拷贝构造);
- 继承的父类成员,必须调用父类的拷贝构造初始化。
primer称之为合成版本的构造函数。
5.3 析构函数
同上。
- 自己的成员,内置不处理,自定义调用各自的析构;
- 父类成员,调用父类的析构。
如果在子类中显示地调用父类析构,这很好理解,既然本来编译器就要调用父类析构,那么显式地调用无非就是把编译器做的事让自己做了,真的是这样吗?
#include <iostream> using namespace std; class A { public: A(int a = 0) { _a = a; cout << "A()" << endl; } ~A() { cout << "~A()" << endl; } private: int _a; }; class B : public A { public: B(int b = 0) { _b = b; cout << "B()" << endl; } ~B() { A::~A(); // 在子类中显式地调用父类析构函数 cout << "~B()" << endl; } private: int _b; }; int main() { A a(1); B b(2); return 0; }
输出
A()
A()
B()
~A()
~B()
~A()
~A()
可以发现,父类的析构函数多调用了一次,原因是我们自己显式地在子类调用父类的析构函数,不会影响编译器后续的行为,它依然会隐式地在子类析构函数中先调用父类的析构函数。
这会发生子类对象中父类的一部分多次析构,因为例子中自定义的析构函数并未做清理工作,所以程序不会崩溃。
编译器先调用父类的析构函数这一行为不会受到影响的原因是:
- 由于多态的需要,析构函数的名字会统一处理为destructor(),编译器看到的名字就是这一坨,所以才构成隐藏(protected)。
- 因为对象是在栈帧中的,后进先出。构造顺序:父->子,则析构顺序子->父。如果自己写,顺序可能就不确定。所以默认子类自动调用父类析构,这样才能保证析构顺序是:子->父。
5. 注意事项
那么对于默认函数有以下几点:
- 子类的构造函数被调用时,会自动调用父类的构造函数初始化父类的那一部分成员,如果父类当中没有默认的构造函数,则必须在子类构造函数的初始化列表当中显示地调用父类的构造函数。
- 子类的拷贝构造函数必须调用父类的拷贝构造函数完成父类成员的拷贝构造。
- 子类的赋值运算符重载函数必须调用父类的赋值运算符重载函数完成父类成员的赋值。
- 子类的析构函数会在被调用完成后自动调用父类的析构函数清理父类成员。
- 子类对象初始化时,会先调用父类的构造函数再调用子类的构造函数。
- 子类对象在析构时,会先调用子类的析构函数再调用父类的析构函数。
总地来说,就是子类中的成员分为两部分:
- 自己成员,跟普通类一样的步骤;
- 继承自父类的成员,用父类的函数。(比以前多的步骤)
对于子类的默认成员,需要注意:
- 子类和父类的赋值运算符重载函数因为函数名相同构成隐藏,因此在子类当中调用父类的赋值运算符重载函数时,需要使用作用域限定符进行指定调用。
- 由于多态的某些原因(后面会学习),任何类的析构函数名都会被编译器统一处理为destructor();。因此,子类和父类的析构函数也会因为函数名相同构成隐藏,若是我们需要在某处调用父类的析构函数,那么就要使用作用域限定符进行指定调用。
- 在子类的拷贝构造函数和operator=当中调用父类的拷贝构造函数和operator=的传参方式是一个切片行为,都是将子类对象直接赋值给父类的引用。
6. 继承和友元
一句话:友元关系不能被继承。
父类的友元,不能访问子类的私有或保护成员。解决:在子类也增加一个友元。
例如:
#include <iostream> using namespace std; class B; // 在A类前声明B类 class A { public: friend void Show(A& a, B& b); //在A类中声明Show友元 A(int a = 0) { _a = a; cout << "A()" << endl; } ~A() { cout << "~A()" << endl; } private: int _a; }; class B : public A { public: B(int b = 0) { _b = b; cout << "B()" << endl; } ~B() { cout << "~B()" << endl; } private: int _b; }; void Show(A& a, B& b) { cout << a._a << endl; // 可以访问 cout << b._b << endl; // 不能访问 } int main() { A a; B b; Show(a, b); return 0; }
无法编译(g++):
在子类中也声明一下友元:
#include <iostream> using namespace std; class B; class A { public: friend void Show(A& a, B& b);// 在父类中声明友元 A(int a = 0) { _a = a; } ~A() {} private: int _a; }; class B : public A { public: friend void Show(A& a, B& b);// 在子类中声明友元 B(int b = 0) { _b = b; } ~B() {} private: int _b; }; void Show(A& a, B& b) { cout << a._a << endl; // 可以访问 cout << b._b << endl; // 可以访问 } int main() { A a(1); B b(2); Show(a, b); return 0; }
输出
1
2
7. 继承和静态成员
一句话:父类和子类的静态成员在内存中只有一份。在父类中定义一个公有的静态成员变量_sA,然后在父类和子类的构造函数中对_sA累加。打印最后_sA的结果,以及父类和子类中_sA的地址。
#include <iostream> using namespace std; class A { public: A(int a = 0) { _a = a; _sA++; } ~A() {} private: int _a; public: static int _sA; }; class B : public A { public: B(int b = 0) { _b = b; _sA++; } ~B() {} private: int _b; }; int A::_sA = 99; int main() { A a; B b; cout << A::_sA << endl; cout << B::_sA << endl; cout << &A::_sA << endl; cout << &B::_sA << endl; return 0; }
输出
102
102
0x1041e4000
0x1041e4000
结果显示,父子类是共用一个静态成员变量的,因为地址相同。即使它是子类无法访问的,因为这是语法限制子类不能访问。
也就是说,不论有多少个子类继承自同一个父类,父类中的静态成员只有一个。
这也是类的静态成员需要在类外部定义的原因,因为静态成员是公有的,隔开
8. 继承方式
根据父子间的继承关系,主要可以分为三种继承方式:
- 单继承:一个子类只有一个直接父类时称这个继承关系为单继承。
- 多继承:一个子类有两个或两个以上直接父类时称这个继承关系为多继承。
- 菱形继承:一个子类有两个或两个以上直接父类,且只有一个间接父类时,称这个继承关系为菱形继承。
如何定义一个不能被继承的类?
- 和防止拷贝一样,把声明公有,不定义,然后把父类的构造函数私有。
这样会报错:私有造成子类不可见,子类对象实例化,无法调用构造函数。但这是正常的报错,目的就是提醒程序员这是一个无法继承的类,但是这种方式不太规范,而且只会在实例化对象的时候才会报错。
C++11新增的关键字final
(最终类),修饰不能被继承的类,语法上在编译前就限制了它的继承。
8.1 菱形继承的缺点
下面主要对菱形继承展开讨论。
菱形继承主要有两个缺陷:
- 子类对象访问父类成员有二义性;
- 代码冗余。
我们可以这样实现菱形继承:
#include <iostream> #include <string> using namespace std; 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; //主修课程 }; int main() { Assistant a; a._name = "xiaoming"; //二义性:无法明确知道要访问哪一个_name return 0; }
产生二义性的原因就是Student和Teacher都是Person的子类,各自都继承了Person的成员变量,所以通过例子中的方法访问子类中的父类成员变量编译器是无法知道是哪个子类要访问的。
解决:显式地指定类域访问父类成员:
a.Student::_name = "小明同学"; a.Teacher::_name = "大红老师";
然而,虽然可以解决二义性,但是目前为止仍未解决代码冗余的问题,因为父类的成员变量只要被子类对象继承了,父类的那部分代码始终会有多份,即使子类限制了访问代码中父类的那一部分。
8.2 菱形虚拟继承
虚拟继承可以解决菱形继承的二义性和数据冗余问题。在Student、Teacher和Person的继承关系中,使用虚拟继承:
在图示中,就将菱形继承的靠近最初的父类两个的类写成虚拟继承:
#include <iostream> #include <string> using namespace std; 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; //主修课程 }; int main() { Assistant a; a._name = "小明"; //无二义性 return 0; }
此时就可以直接访问Assistant对象的_name成员了,并且之后就算我们指定访问Assistant的Student父类和Teacher父类的_name成员,访问到的都是同一个结果,解决了二义性的问题。
cout << a.Student::_name << endl; // 小明 cout << a.Teacher::_name << endl; // 小明
而我们打印Assistant的Student父类和Teacher父类的_name成员的地址时,显示的也是同一个地址,解决了数据冗余的问题。
cout << &a.Student::_name << endl; // 0x1042e4000 cout << &a.Teacher::_name << endl; // 0x1042e4000
8.3 理解多继承对象模型(原理)
首先写一个不使用虚拟继承的菱形继承,只要每个类中有一个成员变量即可:
#include <iostream> using namespace std; 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; }
可以查看它们的分布:
根据它们在内存中的关系,可以有这样的内存模型:
从这里可以看出继承造成代码冗余和二义性的原因,因为D类对象中有两份_a,它们分别属于继承的父类B和C。
那么对于类型虚拟继承:
#include <iostream> using namespace std; 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; }
这里的指针是什么?
如果仔细看的话,有虚拟继承的内存分布和没有虚拟继承的有些区别,后者的所有成员变量是连续的空间,而前者不是连续的,但是菱形腰部的类(就像B类和C类)的成员变量(比如_b和_c),它们上面都有一个值。
这个值对应这模型图中的指针,它保存着当前值相对于菱形顶部(A类)的成员变量的地址的偏移量。
举个例子,比如图中_b的值是3,那么找到它上面保存的地址的那块空间,看看里面是什么:
换做_c对应的4上面的地址,保存的也是_c相对于_a的地址的偏移量。
即使将子类转换成父类,发生了切片,这个模型也是不会改变的,只是不属于父类的成员变量不会被包括在内。
9. 总结
实际上,C++的之所以语法繁杂,就是因为它是“第一个吃螃蟹的人”,因为有很多东西不知道要不要,那就都要。所以有很多语法虽然我们要学,但是实际上并不实用,就像继承方式绝大多数情况都是public、继承本身就是破坏封装的行为。
- (公有)继承是一种从属关系、is a的关系。就像:学生是人,花是植物。
- 组合就是在一个类中使用另一个类,是has a的关系。就像:车和轮胎的关系。
组合是本节新的概念,但我们都不陌生,类似于一种嵌套方式。
如果两个类既是继承关系(is a),也是组关系(has a),那么优先选择组合关系。
就像,铁锅和铁:
- 继承:铁锅是铁做的;
- 组合:铁锅里有铁。
为什么要优先选择组合关系?
这取决于一种非常重要的“设计模式”:黑箱复用。
那么相对地,也存在白箱复用。「黑」和「白」是相对于对象可视性而言的,也就是父类的内部细节对子类是否可见,即子类能否访问父类内部的成员。
简要地说:
- 白箱:子类可以访问父类的成员,任意一方修改数据,都很有可能(取决于限定方式)影响另一方;
- 黑箱:子类不能访问父类的成员,各自修改数据不会影响对方。
什么是低耦合?耦合性,用来描述模块间关联程度的度量。
举个例子,如果在工程中改变了一个位置的变量,其他许多无关的地方也随之改变,这样的程序耦合度高;反之耦合度低。
类继承就是一种白箱复用,它是让程序耦合度变高的操作,因为子类可以访问到父类的成员;父类的成员修改了,子类也会因此被影响。
例如,父类有10个成员,1个public,9个protected。
- 如果子类以public方式继承,那么它依旧可以访问到父类中的9个protected成员。
- 如果使用组合,从语法上就限制了类之间访问成员的权限,它们的作用域不重叠,一个地方修改也不会影响其他地方,只需要访问少量的公有成员即可(相较于这个例子而言,公有是少量的)。