类的封装与继承
扫描二维码
随时随地手机看文章
1 从过程到对象——类概念的引入
真实世界是由“对象”组成的,无论是动物、植物、工厂还是机器等,都是根据它们的特征,细分出来的对象类别。尽管在软件设计时,更多时候我们面对的是经过高度抽象化的模型,但最终需要解决的还是真实世界中的问题。因此,如果能够在软件设计中按照对象来进行建模,将更加契合真实世界的情况,有利于解决高度复杂的实际问题。典型的过程化程序设计语言,如C语言,其程序设计更倾向于面向过程,以函数为基本单位。这在自顶向下设计方法深入人心的今天,往往有些力不从心,因为它很难恰如其分地模拟真实世界。
对于C++语言来说,设计的基本单位是类。类是逻辑上相关的函数与数据的封装,它是对所要处理的问题的抽象描述。引入了类概念的面向对象程序设计语言C++具有更高的代码集成度,从而更适合用于大型复杂程序的开发。而由类产生的基类、继承、派生、模板等概念,更是极大地丰富了软件工程师解决问题的手段。如此强大的概念,如若使用不当,必然带来许多意想不到的隐患。为此MISRA C++:2008中专门讨论了与类使用相关的问题,简单举例如下。
规则10-1-3(强制): 同一层级的某个基类不允许既是虚基类又是非虚基类。
这是因为,如果一个基类在多重继承层次中既是虚类型,又是非虚类型,则在派生出来的相应对象中将至少有2个该基类的子对象拷贝。这可能与开发人员的理解不一致。为了更好说明这个问题,请看下面的程序:
上述程序中,由于B1、B2是对A的public virtual继承,而B3是对A的public继承。因此,对于C而言,将保有A的2个子对象拷贝,造成不必要的冗繁,并隐含造成开发人员误解的危险因素。所以,虽然这段程序在语法上是没有错误的,但是出于程序安全性角度的考虑,这种使用方法被MISRA C++:2008所禁止。
我们知道,通过将数据(属性)和函数(行为)封装在称为对象的包中,可以实现数据和函数的紧密联系,构成对象对信息的隐藏性。这样,尽管对象知道怎样通过定义好的接口实现相互的通信,但是对象通常并不知道其他对象是怎样实现的,对象的细节隐藏在对象的内部。而同一类对象则具有相同的特点,新建立的对象通过继承现有类的特征而派生出来,同时可以包含各自独有的特点。
也就是说,“类”很好地解决了2个问题:程序模块化封装的实现,以及合理提高代码的利用率。对于软件设计者之外的用户而言,每一个对象都是给出了特定接口的“黑盒子”;而对于特定的数据结构,经过单一定义之后,就可以借用继承主体、修改细节的手段,来实现重复利用。如此高效的统筹兼顾,源于“类”这个崭新概念的引入。然而这种高效也需要严格的规范来保证,否则会带来意想不到的隐患。为此MISRA C++:2008从类、派生类、成员访问的控制、特殊的成员函数以及模板这几个方面进行了详细的讨论,并出于安全角度考虑,提出了一系列规则。下面就结合MISRA C++:2008中的相关规则,对这2个问题作进一步阐述。
2 统——数据与代码的封装
对象的独立性是通过封装实现的,这是指将抽象得到的数据成员和代码成员相结合,形成一个统一的有机整体,也就是说,将数据与操作数据的行为进行有机的结合、统一。
通过封装,一部分成员作为类与外部的接口,其他成员则被很好地隐蔽起来,以实现对数据访问权限的合理控制,使程序中不同部分之间的相互影响减小到最低。这样可以达到增强安全性和简化程序编写工作的目的。但是在进行封装时,疏忽一些细节可能会得到与程序设计者初衷相去甚远的结果,看下面的例子。
规则9-3-1(强制): 常量类型的成员函数不允许返回非常量类型的指针或对类数据的引用。
当对象被声明为常量型的类时,只有该类的常量成员函数能被人们调用。当调用常量成员函数时,人们一般认为将不会改变对象的状态。然而,当常量类型的函数返回1个指向类数据的非常量指针或者对类数据的引用时,理论上将允许改变对象的状态。这是程序设计者不希望看到的。
作为保护数据、实现模块化编程的手段,一个完全无法被外部访问的“封装”是没有意义的。因此在利用封装来限制对对象的修改操作时,必须留出必要的“接口”。这些接口通常必须以对象的成员函数的形式给出,否则可能会破坏封装的效果。再看下面的例子。
规则9-3-2(强制): 成员函数不允许返回对于类数据的非常量的旬柄。
利用类的成员函数构建类的访问接口时,可以就对象状态是如何被修改的保留更多的控制能力,同时可以实现在对类进行维护时不会受到用户的影响。返回类数据的句柄,将使得用户可以不经过类的接口而对类的状态进行修改,从而破坏了封装。
而合理的做法如下所述。
规则9-3-3(强制): 将成员函数声明为static或者const类型。
这是因为,将成员函数声明为static或者const类型,可以限制对于其非静态数据成员的访问,从而避免无意识下对数据进行的修改。
每一个对象都有和简单变量类似的建立过程,我们希望也能够像对待普通变量那样,当通过声明语句分配内存空间之后,立即写入特定的数据。但由于对象的复杂性以及封装需求决定了直接赋值不可行,为此C++严格规定了初始化程序的接口形式,并有一套自动的调用机制。这里所说的初始化程序就是构造函数,这个特殊的成员函数以及与之对应的析构函数,需要在封装时给予特别的注意。
规则12-1-1(强制): 对象的动态类型不允许在其构造函数或者析构函数体内被调用。
在对象的构造和析构过程中,它最终的类型可能会与完整构造的对象不一样。在构造函数或者析构函数中使用对象的动态类型,将可能与开发人员的预期不一致。对象的动态类型使用在如下的结构中:
◆典型的具有虚函数或者其基类中具有虚函数;
◆dynamic_cast;
◆对于虚函数的虚调用。
此规则同样禁止由构造函数和析构函数产生的对纯虚函数的调用。那样的调用将导致未定义的行为。下面来看一个较为特殊的函数——拷贝构造函数,以结束对封装的讨论。
拷贝构造函数是一种特殊的构造函数,其形参是本类的对象的引用。其作用是使用1个已经存在的对象(由拷贝构造函数的参数指定的对象)去初始化1个新的同类的对象。
规则12-8-1(强制): 拷贝构造函数只允许对基类以及它所在类的非静态成员进行初始化。
如果编译器接口发现1个对拷贝构造函数的调用是冗余的,它将忽略该函数调用。即使拷贝构造函数在构造对象之外还有其他功能,也不例外。这称作拷贝省略。因此当修改程序状态的次数不能确定时,保证不使用拷贝构造函数修改程序的状态,就显得极为重要。相关例程如下:
上述例子里,在所有函数调用之后,m_static的数值由使用的是何种编译器来决定,不是明确的值。这种不确定因素很可能带来严重的安全隐患,显然不是我们希望看到的。
3 筹——概念与代码的重复利用
运筹学中一个经典的例子是:用2个锅同时煎鸡蛋,每个鸡蛋要煎2面,每煎1面1分钟,问煎好3个鸡蛋最少要多少时间?对这个简单例子的解决过程反映了我们的思考习惯:面对新事物新问题时,首先考虑的是如何充分利用现有的工具和概念,如果需要的话,在此基础上作尽可能小的改动。继承与派生就是这种思想在C++中的体现。
按照真实世界的情况,在软件设计中引入了类的概念。同时我们注意到人们的特定思维习惯:当提到两厢小轿车时,遵循着“交通工具→汽车→轿车→两厢小轿车”的具象化过程,而不是从螺丝钉开始想象。对于C++而言,面对新对象,首先想到的不是从成员开始重新构建它,而是去寻找这个新对象与已有对象类别的相似之处,看能不能最大限度利用已经给出定义的类来描述这个新对象。为新对象创建的特殊类,具有一般类的全部属性与服务,称作特殊类对一般类的继承。1个类可以单独存在,但是当利用继承机制使用该类时,该类就成为给其他类提供属性和行为的基类,或者成为继承其他类的属性和行为的派生类。
合理使用继承可以显著提高代码的利用率。规则10-1-2(强制): 只有在菱形结构中才允许将基类声明为虚基类。
虚基类会引入许多未定义和潜在的容易令人混淆的特性。因此,只有当该基类在菱形继承结构中作为公共基类时,才可以将其声明为虚基类。
上述例程中,对于C而言,有两个父类B1、B2,有1个祖父类A,从而A、B1、B2、C构成了典型的菱形结构。使用了虚基类的菱形结构里,对象的内存布局中只有1个A,即祖父类的部分只有1份,且放在最后面,排放顺序是B1+B2+C+A。如果没有用虚继承机制,那么在C对象的内存布局中会出现2份A部分,这也就是所谓的V型继承。相应的对象布局为A+B1+A+B2+C。在V型继承中不能直接从C(即孙子类)直接转型到A(即祖父类)因为在对象的布局中有2份祖父类的实体,分别从B1、B2而来。编译器在决议时会存在二义性,它不知道转型后到底用哪一份实体。可以通过先转型到某一父类,然后再转型到祖父类来解决。但使用这种方法时,如果改写了祖父类的成员变量的内容,runtime不会同步2个祖父类实体的状态,因此可能会有语义错误。
多继承结构允许1个对象继承来自不同对象的特征,但也会带来新的问题。我们看下面的规则。规则10-2-1(推荐): 多继承层级中,可访问的实体名称应当是相互独立、不同的。如果名称含混不清,编译器将报告名称冲突,同时不会武断生成不符合预期的代码。但是这种含混不清对于开发者来说,并不容易察觉。当成员函数是虚函数时,还有一个需要特别注意的地方:通过explicitly引用基类来解决名称含混的问题,将会去除函数的“虚”特性。对于本条规则也有例外的情况,比如:相关的重载函数应当看作具有相同的入口。相关说明程序如下:
上述程序定义D时,无法分辨成员中的count和foo()到底来自B1还是B2,造成了不必要的困扰。代码重用的目的是按不同方式重复使用代码来实现类、结构、函数等,这就要求代码必须是通用的,且通用代码不受使用数据类型和操作的影响,即无论使用什么数据类型通用代码都是不变的。于是C++提出了类模板的概念:类模版可以为类声明1种模式,使得类中的某些数据成员、某些成员函数的参数、某些成员函数的返回值能取任意类型。MISRA C++:2008就模板的使用也给出了详细的规则。
规则14-5-2(强制): 当具有单参数的模版构造函数时,必须声明拷贝构造函数。
与开发人员预期的不同,模版的构造函数不会禁止编译器生成拷贝构造函数。这样当成员函数要求进行深拷 贝的时候,可能会导致不正确的拷贝语句被执行。这样的问题往往在程序设计初期不会引起重视,等到面对莫名其妙的问题时,再回过头来寻找原因,只能一筹莫展。如果在程序设计时就遵循MISRA C++:2008中相关的规则,自然可以避免这样的困扰。
4 小 结
本文是学习MISRA C++系列连载讲座之三。从“统筹兼顾”的角度和大家一起学习讨论了MISRA C++:2008中关于类、派生类、成员访问的控制、特殊的成员函数以及模版的相关规则。其中有意思的例子还有很多,限于篇幅,就不一一展开叙述了。请继续关注本系列讲座的第4讲:异常机制的使用。