C++因继承引发的隐藏与重写
在区分隐藏和重写之前,先来理一理关于继承的东西。。。
【继承】
继承是面向对象复用的重要手段。通过继承定义一个类,继承是类型之间的关系建模,共享公有的东西,实现各自本质不同的东西。简单的说,继承就是指一个对象直接使用另一对象的属性和方法。C++中的继承关系就好比现实生活中的父子关系,继承一套房子通常比白手起家自己挣要容易得多。所以原始类被称为父类或基类,继承类称为子类或派生类,而子类又可以当成父类,可再被其它类继承。这种关系和java是一样道理,不过C++多了一个麻烦的地方就是它还支持多继承,于是就引发出很多坑人的地方。
继承的方式:
(1) 公有继承(public)
基类的公有和保护成员被子类继承时,它们都保持原有的状态,而基类的私有成员同样被继承下来,只是在子类表现为私有,子类不能访问。
(2)私有继承(private)
它的特点是基类的公有和保护成员被子类继承时,都会成为子类的私有成员;基类的私有成员也被继承下来,但不能被该子类访问。
(3)保护继承(protected)
它的特点是基类的公有成员和保护成员被子类继承时,都会成为子类的保护成员,子类的子类可通过保护成员函数或友元访问;基类的私有成员被继承下来仍然是私有的,依旧不能被子类访问。
private能够对外部和子类保密,即除了成员所在的类本身可以访问之外,别的都不能直接访问。protected能够对外部保密,但允许子类直接访问这些成员。不难看出protected限定符是因为继承才能表现出作用。
各种继承方式下各种成员关系变化如下图
继承方式就像一张‘网’,被继承后都成了跟‘网’权限“相同的”和比“网” ’“小”的。
总结一下:
1. public继承是一个接口继承,保持is-a原则,每个父类可用的成员对子类也可用,因为每个子类对象也都是一个父类对象。
2. protetced/private继承是一个实现继承,基类的部分成员并未完全成为子类接口的一部分,是 has-a 的关系原则,所以非特殊情况下不会使用这两种继承关系,在绝大多数的场景下使用的都是公有继承。
3. 不管是哪种继承方式,在派生类内部都可以访问基类的公有成员和保护成员,但是基类的私有成员存在但是在子类中不可见(不能
访问)。
4. 使用关键字class时默认的继承方式是private,使用struct时默认的继承方式是public,不过最好显示的写出继承方式。
赋值兼容规则
要点:
1. 子类对象可以赋值给父类对象(支持对象切片)
2. 父类对象不能赋值给子类对象
3. 父类的指针/引用可以指向子类对象
4. 子类的指针/引用不能指向父类对象(可通过强制类型转换完成)
例子:
1 #include <iostream> 2 #include <string> 3 using namespace std; 4 5 class Person 6 { 7 public: 8 void Show() 9 { 10 cout<<_name<<endl; 11 } 12 protected: 13 string _name; 14 }; 15 16 struct Student : public Person 17 { 18 public: 19 void Print() 20 { 21 cout<<_name<<endl; 22 } 23 24 //private: 25 public: 26 string _id; 27 }; 28 29 void test1() 30 { 31 Person p; 32 Student s; 33 34 p = s; // 不是隐式类型转换 -- 切片处理-编译器天然支持 -- is-a 35 Person* pP = &s; //通过 36 Person& p1 = s; //通过 37 //上面都属于一种向上类型的转换,编译器默认支持。 38 //s = p; //报错,类型不匹配 39 Student* p3 = (Student*)&p; //编译通过,通过p3进行操作会出错 40 //p3->_id = 10; //该句执行完虽给p3->_id赋了值,但在整个程序结束时程序会崩掉,因为强转为了子类的指针,那么编译器就会按子类 41 //的大小对该指针作解释,这样就多‘占用’一块内存,而它可能是用来执行其他的活动的,但p3->id却指向这块非法的内存。 42 Student& r3 = (Student&)p; //编译通过,用r3进行操作出错 43 //Student& r3 = p; //出错 44 //r3._id = 10; //运行出错,同上面道理 45 46 }
注:p = s操作时会将子类对象独有的(非继承的部分)函数和变量自动“切去”,子类只留下继承来的基类原有的“切片”来对基类的对象进行赋值。
多继承
多继承是指 一个子类有两个或以上直接父类时称这个继承关系为多继承。这种继承方式使一个子类可以继承多个父类的特性。多继承可以看作是单继承的扩展。派生类具有多个基类,派生类与每个基类之间的关系仍可看作是一个单继承。
多继承下派生类的构造函数与单继承下派生类构造函数相似,但是注意几点:
①子类构造函数必须同时负责该派生类所有基类构造函数的调用。
②派生类的参数个数必须包含完成所有基类初始化所需的参数个数。
③子类继承来的部分在内存中是按照声明定义的顺序存放的
棱形继承& 虚继承
棱形继承如下图
#include <iostream> #include <string> 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; }; void test4() { D d; d.B::_a = 1; //添加域作用限定符指向修改_a d.C::_a = 2; //同理 //cout<<sizeof(D)<<endl; //cout<<sizeof(B)<<endl; }
棱形继承
从图来看它带来两个问题:①数据冗余 ②二义性
D的对象模型里面保存了两份A,当我们想要改动从A里继承的_a时就会出现指向不明确问题,并且还存在数据冗余的问题,明明可以只要一份就好,但却保存了两份,浪费空间。
所以想要改动_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; };
虚继承
关于虚继承:
-
虚继承即让B和C在继承A时 加上
virtural
关键字,注意不是D继承B、C使用虚继承。 -
虚继承解决了在菱形继承体系里面子类对象包含多份父类对象的数据冗和浪费空间的问题。
-
虚继承和虚函数虽然用的是同一个关键字,然而它俩之间没半毛钱关系。
- 虚继承体系比较复杂,在实际应用我们通常不会定义如此复杂的继承体系。应尽量避免定义菱形结构的虚继承体系结构,因为使用虚继承解决数据冗余问题也带来了性能上的损耗
既然说虚继承解决数据冗余问题,那怎么还说虚继承带来了损耗?
还是针对上面代码,先来看看使用虚继承和不使用虚继承 类D的大小,
//普通继承 cout<<sizeof(D)<<endl; //结果: 20 //使用虚继承 cout<<sizeof(D)<<endl; //结果: 24
虚继承后的结果确实大了,这就比较奇怪了,到底大在了啥地方。看看内存
从上图不难看出两点:
①对象d确实存放了继承来的各个成员(_a, _b, _c, _d),同时初始基类属性_a被提出来放在了高地址处
②多出来了两个值,68 58 2d 01 和 60 58 2d 01 似乎找到了变大原因,刚好多了4个字节。看看里面存放的东西
d._a = 1; d._b = 2; d._c = 3; d._d = 4;
一个是数值12,一个是20。这时候看看D的对象模型,发现d._b(2
)
的地址和 d._a(1)
地址之差是20,d._c(3)
的地址和 d._a(1)
地址之差是12。大胆猜测一下,这也许就是某个偏移量啊。事实上,确实是的。每一个继承自父类对象的实例中都存有一个指针,这个指向指向虚基表的其中一项,里面存的是一个偏移量。对象的实例可以通过自身的地址加上这个偏移量找到存放继承自父类对象属性的地址。
虚继承虽然解决了冗余和二义性,但是增大性能的开销,尽量避免使用它。
【隐藏和重写】
因为有类的继承机制,在父类和子类之间就会隐藏和重写这两个东西。而隐藏(重定义)和重写因字面上又有着相近的意思,导致它们比较容易被我们搞混淆。但其实重写是建立在隐藏的基础上的,它比隐藏多了一些限制。
对于隐藏,它的出现是在父子类拥有相同的成员(成员函数,成员属性)的时候,此时父类的成员就会被隐藏起来(还存在),子类的成员得到访问或调用
举个例子,
class Person { public: void Show() { cout<<"Person::"<<_name<<endl; } protected: int _id; string _name; }; struct Student : public Person { public: void Show() { _id = 10; //Person::_id = 10; 要访问加上类作用符 cout<<"Student::"<<_name<<endl; } public: int _id; }; void test1() { Person p; Student s; s.Show(); }
结果:
注意到此时只为子类的_id赋了值,并未给继承来的_id赋值,如果需要访问,则要加上类作用符“::”。通常不建议定义同名的成员。
再看看隐藏了函数的情况,
class AA { public: void f() { cout<<"AA::f()"<<endl; } }; class BB : public AA { public: void f(int a) { cout<<"BB::f()"<<endl; } }; void test2() { AA aa; BB bb; aa.f(); bb.f(); //会报错 }
这个例子,乍一看,感觉AA::f() 和BB::f(int)两个函数构成了重载,但其实BB类对象bb将它继承下来,它们是构成了隐藏。因为两个函数压根就不在同一作用域,所以就不存在重载这么一说了,此时bb.f()调用的是自己的 f(int) ,但因未传参数,所以这个程序就会报错。
虚函数和重写
虚函数:类的成员函数前面加virtual关键字,则这个成员函数称为虚函数。
作用:基类的指针在操作它的多态类对象时,会根据不同的类对象,调用其相应的函数,这个函数就是虚函数。实际上就是用来实现多态的
关于虚函数
1. 基类中定义了虚函数,在派生类中该函数始终保持虚函数的特性。
2. 只有类的成员函数才能定义为虚函数。
3. 静态成员函数不能定义为虚函数。
4. 如果在类外定义虚函数,只能在声明函数时加virtual,类外定义函数时不能加virtual。
5. 构造函数不能为虚函数,虽然可以将operator=定义为虚函数,但是最好不要将operator=定义为虚函数,因为使用时容易引起混淆。
7. 不要在构造函数和析构函数里面调用虚函数,在构造函数和析构函数中,对象是不完整的,可能会发生未定义的行为。
8. 最好把基类的析构函数声明为虚函数。(why?继承时,如果派生类有资源需要自己手动释放,我们可以通过基类的指针或引用去释放子类的资源。防止内存泄露。)
重写就用到了虚函数。对于重写(覆盖),它是指在父类中有一个虚函数,然后子类重写出一个和它形式(函数名,参数列表,返回值)完全相同的一个虚函数,
class Human { public : virtual void Drink() { cout<<"喝白开水"<< endl; } protected : string _name ; // 姓名 }; class Boss: public Human { public : virtual void Drink() //virtual 不写同样构成覆盖 { cout<<"喝咖啡"<<endl; } protected : int _position; //职称 };
重写
总的来说隐藏与重写有这么几个特点:
-
隐藏或重写的两成员函数都不在同一作用域
-
如果子类的函数与父类的函数同名,但是参数列表不同。此时,不论有无virtual关键字,父类的函数在子类中都将被隐藏(注意与重载区别)
-
如果子类的函数与父类的函数同名,并且参数列表也相同,但父类函数没有virtual关键字。此时,父类的函数在子类中将被隐藏(注意与覆盖区别,覆盖基类必须有virtual)
- 重写的访问修饰符可以不同
- 基类和派生类中只有不构成重写就构成重定义(隐藏)