目录
四、派生类的默认成员函数(构造,析构,拷贝构造,operator=...)
继承(inheritance)机制是面向对象程序设计使代码可以复用的最重要的手段,它允许程序员在保持原有类特性的基础上进行扩展,增加功能,这样产生新的类,称派生类。继承呈现了面向对象程序设计的层次结构,体现了由简单到复杂的认知过程。以前我们接触的复用都是函数复用,继承是类设计层次的复用。 (copy的)
访问限定符即声明此类的成员对于类外访问的权限设置,有public访问 protected访问 private访问 (访问限定符)
继承方式分为public继承,protected继承,private继承
上图看上去很多继承方式和访问限定符的组合很复杂,但是事实上是有规律的。
1. 继承方式相当于限定了成员的最高访问权限,若public继承,则基类的成员访问权限在派生类中不变。若protected继承,则限定了派生类继承父类成员时最高访问权限为protected,则原本public的会变为protected访问权限,protected的不变。private继承同理。
2. 总结第一条:基类除了private成员在派生类的访问权限为在基类的访问权限和继承方式中权限更小的那个。
3. 明确什么是不可见:基类的private成员无论什么继承方式,在派生类中均不可见。这里的不可见是指基类的私有成员还是被继承到了派生类对象中,但是语法上限制派生类对象不管在类里面还是类外面都不能去访问它。
4. 基类的private成员在派生类中是不能被访问的,如果基类成员不想在类外直接被访问,但需要在 派生类中能访问,就定义为protected。可以看出protected限定符是因继承才出现的。
5. 使用关键字class时默认的继承方式是private,使用struct时默认的继承方式是public,不过最好显式的写出继承方式。
6. 实际运用中,public继承方式+public,protected访问限定符是最合适的。(绝大多数情况!)
7. 访问限定符这个东西,是为了限定在类外访问此成员时可以访问or不能访问,这里的类外包括子类,因为private成员继承下去之后,子类不能访问(隐身了)。而同类类内的访问和调用不受访问限定符的影响。
基类的所有数据成员,派生类中都会继承,也就是每一个派生类的实例化对象中都有一部分基类的数据成员,再加上派生类自己定义的数据成员组合成派生类对象。
而成员函数:成员函数是存储在代码段的,也仅有一份,是属于基类的,只是public 和 protected成员函数,可以在派生类内调用。并且类外调用基类的成员函数时,成员函数的this指针默认是const 基类*类型!
1. 派生类对象 可以赋值给 基类的对象 / 基类的指针 / 基类的引用。这里有个形象的说法叫切片。寓意把派生类中父类那部分切来赋值过去。
2. 基类对象不能赋值给派生类对象。
3. 派生类对象赋值给基类对象,调用基类的operator=(const 基类& x);正是因为基类引用可以引用派生类对象,这里的赋值才可以的。
4. 派生类对象地址赋值给基类指针,可以理解为指针指向派生类中基类的那一部分。引用可以理解为引用派生类中基类的那一部分。因此,基类指针或引用只能访问那些基类有的,派生类继承了的成员,不能访问派生类自己的。
5. 基类的指针或者引用可以通过强制类型转换赋值给派生类的指针或者引用(必须强制类型转换)。但是必须是基类 的指针是指向派生类对象时才是安全的。
5. 这里实际上意义是非常大的,因为多态的基础就是派生类对基类赋值的支持。
1. 在继承体系中基类和派生类都有独立的作用域。是两个相互独立的类域。
2. 如果基类和派生类中有同名的成员,则派生类成员将屏蔽对基类同名成员的直接访问,这种情况叫隐藏, 也叫重定义。(可以使用 基类::基类成员 指定类域的方式显式访问)
3. 如果是成员函数的隐藏,只需要函数名相同就构成隐藏。
4. 基类和派生类同名的成员函数构成隐藏,但是不构成重载,因为不是同一个作用域。
4. 注意在实际继承体系里面最好不要定义同名的成员。
示例代码:
- class Person
- {
- public:
- void print()
- {
- cout << "name:" << _name << endl;
- cout << "age:" << _age << endl;
- }
- Person& operator=(const Person& p)
- {
- cout << "operator=(const Person& p)\n" << endl;
- _name = p._name;
- _age = p._age;
- pub_num = p.pub_num;
- test_private = p.test_private;
- return *this;
- }
- public:
- int pub_num = 0; // 测试基类派生类同名数据成员
- protected:
- string _name = "yzl";
- int _age = 19;
- private:
- int test_private = 0; // 测试私有成员的继承
- };
-
- class Student : public Person
- {
- public:
- void print(int i = 0) // 构成隐藏
- {
- printf("Student:print\n");
- }
- public:
- int pub_num = 1; // 构成隐藏
- protected:
- int _stuid;
- };
-
- int main()
- {
- Person p;
- Student s;
- s.print();
- cout << endl;
- s.Person::print(); // 指定类域/作用域访问
- cout << s.pub_num << " " << s.Person::pub_num << endl; // 指定类域/作用域访问
- p = s;
- return 0;
- }
(这里Person只是IDE为了容易理解,指出下面这些成员是子类继承的父类的成员)
疑问:派生类继承了基类的数据成员,那么,派生类的构造,析构,拷贝构造,operator=函数应该怎么实现呢?
1. 派生类的构造函数必须调用基类的构造函数初始化基类的那一部分成员。派生类的构造函数的初始化列表中会自动调用基类的默认构造函数,当然也可以显式调用指定构造函数,若基类无默认构造函数,则必须在派生类构造函数初始化列表中显式调用基类构造函数初始化基类部分的数据成员。
2. 派生类的拷贝构造函数必须调用基类的拷贝构造函数完成基类数据成员的拷贝初始化。
经过实验,若子类拷贝构造函数没有在初始化列表中显式调用基类拷贝构造函数,则自动调用默认构造函数。所以,最好是在派生类拷贝构造函数初始化列表中显式调用基类拷贝构造函数(这里发生切片)。 (其实也能理解,毕竟本质上都是构造函数嘛)
3. 派生类的operator=必须要调用基类的operator=完成基类数据成员的赋值。(子类operator=函数体内指定类域方式调用父类operator=)
编译器默认生成的operator=会自动调用基类的operator=完成基类部分的赋值,但是,如我们所知,这样的默认operator=完成的是浅拷贝。若涉及内存管理,则我们自己写operator=时需要主动调用基类的operator=
4. 派生类的析构函数会在被调用完成后自动调用基类的析构函数清理基类成员。因为这样才能保证派生类对象先清理派生类成员再清理基类成员的顺序。
因此,继承体系中,每个类只需要清理自己类的数据。
5. 派生类对象初始化先调用基类构造再调派生类构造。派生类对象析构清理先调用派生类析构再调基类的析构。
6. 基类析构函数因为要被重写,所以,编译器会对析构函数名进行特殊处理,处理成destrutor(),所以父类析构函数不加virtual的情况下,子类析构函数和父类析构函数构成隐藏关系。加了virtual就是重写。
7. 总结一下,子类处理子类的,父类处理父类的。子类需要主动调用父类的对应函数去处理父类部分的数据(除了析构函数)。当然,编译器默认生成的函数会自动调用父类默认函数
- class Person
- {
- public:
- Person(string name = "xxx")
- :_name(name)
- {}
- Person(const Person& p)
- :_name(p._name)
- {}
- Person& operator=(const Person& p)
- {
- if (this != &p)
- _name = p._name;
- return *this;
- }
- ~Person() = default;
- protected:
- string _name;
- };
-
- class Student:public Person
- {
- public:
- Student(string name, int num)
- :Person(name), _num(num)
- {
- }
- Student(const Student& s)
- :Person(s),_num(s._num)
- {}
- Student& operator=(const Student& s)
- {
- if (this != &s)
- {
- Person::operator=(s);
- _num = s._num;
- }
- return *this;
- }
- void print()
- {
- cout << "name: " << _name << ",num: " << _num << endl;
- }
- ~Student() = default;
- protected:
- int _num;
- };
-
-
- int main()
- {
- //Student s("yzl", 22);
- //s.print();
- //Student s2("haha", 66);
- //Student s3(s2);
- //s3.print();
-
- Student s("yzl", 22);
- Student s2("haha", 66);
- s = s2;
- s.print();
-
- return 0;
- }
友元关系不能继承。基类的友元不能访问派生类的私有和保护成员。
基类定义了static静态成员,则整个继承体系里面只有一个这样的成员。无论派生出多少个子 类,都只有一个static成员实例 。 (注意类的静态数据成员所有对象本身就只有一份,存储在静态区,且只能在全局区定义,语法比较特殊)
- class Person
- {
- public:
- Person() { ++_count; }
- protected:
- string _name; // 姓名
- public:
- static int _count; // 统计人的个数。
- };
- class Student : public Person
- {
- protected:
- int _stuNum; // 学号
- };
- class Graduate : public Student
- {
- protected:
- string _seminarCourse; // 研究科目
- };
-
- int Student::_count = 0;
-
- int main()
- {
- Student s1;
- Student s2;
- Student s3;
- Graduate s4;
- cout << " 人数 :" << Person::_count << endl;
- Student::_count = 0;
- cout << " 人数 :" << Person::_count << endl;
- return 0;
- }
单继承:一个子类只有一个直接父类时称这个继承关系为单继承
Person -> Student -> PostGraduate
多继承:一个子类有两个或以上直接父类时称这个继承关系为多继承
class Assistant : public Student, public Teacher
菱形继承:菱形继承是多继承的一种特殊情况。
我们知道,Student中有一份Person的数据,有一份自己的数据组合成Student。Teacher中有一份Person的数据有一份自己的组合成Teacher。此时Assistent继承Student和Teacher为多继承。导致Assistant中有两份Person的数据。导致数据冗余和二义性的问题!
即菱形继承会导致数据冗余和二义性的问题。
- class Person
- {
- public:
- string _name; // 姓名
- };
-
- class Student : public Person
- {
- protected:
- int _stu_id; //学号
- };
-
- class Teacher : public Person
- {
- protected:
- int _work_id; // 职工编号
- };
- class Assistant : public Student, public Teacher
- {
- protected:
- string _majorCourse; // 主修课程
- };
- void Test()
- {
- // 这样会有二义性无法明确知道访问的是哪一个
- Assistant a;
- //a._name = "peter"; // error
- // 需要显示指定访问哪个父类的成员可以解决二义性问题,但是数据冗余问题无法解决
- a.Student::_name = "xxx";
- a.Teacher::_name = "yyy";
- cout << a.Student::_name << " " << a.Teacher::_name << endl;
- }
虚拟继承可以解决菱形继承的二义性和数据冗余的问题。如上面的继承关系,在Student和 Teacher的继承Person时使用虚拟继承,即可解决问题。需要注意的是,虚拟继承不要在其他地 方去使用。
(注意这里使用虚拟继承的地方)
- class Person
- {
- public:
- string _name; // 姓名
- };
-
- class Student : virtual public Person
- {
- protected:
- int _stu_id; //学号
- };
-
- class Teacher : virtual public Person
- {
- protected:
- int _work_id; // 职工编号
- };
- class Assistant : public Student, public Teacher
- {
- protected:
- string _majorCourse; // 主修课程
- };
- 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对象中将数据冗余的A部分数据成员放到的了对象组成的最下面,这个A同时属于B和C,那么B和C如何去找到公共的A呢?
这里是通过了B和C的两个指针数据成员,指针指向的一张表。这两个指针叫虚基表指针,这两个表叫虚基表。虚基表中存的偏移量。通过偏移量可以找到下面的A。
此时,如果单独创建B类对象b,则b的内存视图如下:(A::_a = 1 B::_b = 2)
可以看到,当BC虚拟继承A时,可以解决D多继承BC时导致的A部分数据冗余和二义性。同时创建出的B类对象的存储方式也会改变,即除了B自己定义的数据成员,还有一个指针,指向一个虚基表,虚基表中存储着这个指针距离继承的基类部分数据的偏移量为多少(可能为负值)
C的对象内存模型也同理。当D多继承BC时,就可以把BC中的A部分当作公共数据,仅存储一份。然后设置BC中的指针指向的虚基表中的偏移量即可!
(有一个疑问是:虚基表中为什么把偏移量存储在4-8字节中,前四个字节存储的什么呢?这里在多态中的菱形虚拟多态继承中解释)
如图所示的继承关系事实上也是菱形继承的一种!也会导致A的数据在E中冗余和二义性问题。
那么,正确的virtual虚拟继承的位置应当是B和D处,虚拟继承A,这样E中的数据冗余和二义性问题即可解决。
那么,如果C虚拟继承B,而B普通单继承A,同时D虚拟继承A,可以解决菱形继承的问题吗?
答:不可以... 得出结论,想要解决菱形继承的数据冗余和二义性问题,必须在导致问题的基类的若干个直接父类那里进行虚拟继承。
其实虚拟继承原理很简单.... 派生类虚拟继承基类,则会将基类部分数据存储在其他区域,除了派生类自己的数据以外,再存储一个指向虚基表的指针即可。这个虚基表会存储该指针到基类部分数据的地址之间的偏移量。
这里可以通过测试代码然后看内存视图知晓存储模型
1. public继承是一种is-a的关系。也就是说每个派生类对象都是一种基类对象。如Student是一个人
2. 组合是一种has-a的关系。假设B组合了A,每个B对象中都有一个A对象。
3. 优先使用对象组合,而不是类继承 。
4. 继承允许你根据基类的实现来定义派生类的实现。这种通过生成派生类的复用通常被称为白箱复用(white-box reuse)。术语“白箱”是相对可视性而言:在继承方式中,基类的内部细节对子类可见 。继承一定程度破坏了基类的封装,基类的改变,对派生类有很大的影响。派生类和基类间的依赖关系很强,耦合度高。
5. 对象组合是类继承之外的另一种复用选择。新的更复杂的功能可以通过组装或组合对象来获得。对象组合要求被组合的对象具有良好定义的接口。这种复用风格被称为黑箱复用(black-box reuse),因为对象的内部细节是不可见的。对象只以“黑箱”的形式出现。 组合类之间没有很强的依赖关系,耦合度低。优先使用对象组合有助于你保持每个类的封装性。