关于C++面向对象设计的访问性问题详解

前言

最近在看Scott Meyers大神的《Effective C++》和《More Effective C++》,虽然这两本书都是古董级的教参了(当然针对C++11/C++14作者所更新的《Modern Effective C++》英文已经发售了,不过还没中文翻译版本),但是现在看来仍然收益匪浅,而且随着对这个复杂语言了解的深入和实践项目经验的增加,很多东西和作者产生了一种共鸣,以前种种疑惑突然有种拨云雾而见天日、豁然开朗的感觉,也难怪被列为合格C++程序员之必读书目。其实C++确实是个可怕的语言,于是市面上针对这个语言的教参也是聆郎满目层出不穷,当然水平也是参差不齐,像上面所说的Meyers三部曲能够历久弥新,也凸显了这些经典教参的真正价值。

至于最近回归C++本质,主要是觉得现在后台开发的RPC、MQ、分布式系统虽然被称的神乎其神的,但是作为成熟的组件绝大多数公司都可以是直接拿来主义,当然也不可否认其使用经验的可贵,因为最近线上使用这些组件还是遇到或多或少不少问题的,以后可以少走些坑,然而这种东西也是可遇难求的;反而C++语言本身的使用占用了程序员绝大多数的工作内容,从而直接影响到项目的质量和后续的可维护性。在此,侯捷老师的 勿在浮沙筑高台 仍如警世名言响彻在耳,一个合格的程序员其扎实的基本功是多么重要。

C++面向对象的东西太多了:public、protected、private访问和继承,virtual和多态、多继承,外加const、缺省参数、名字查找等,光这些元素的排列组合就可以导出很多种情况,看似灵活多变,但不是每种情况都值得去尝试的。

一、public继承

public继承意味着是”is-a”的关系,每个派生类型对象也是一个基类类型对象,基类支持的操作派生类都支持,只不过派生类比基类更具体化一些而已,否则的话应该将派生类不支持的特性给踢出去,比如:

class Bird { ... };
class FlyingBird: public Bird {
public:
 virtual void fly(); ...
};
class Penguin: public Bird { ... };

所以,总体来说public继承是相对比较严格的契约关系。当然public继承是一个比较笼统的概念,细分下来还包括接口继承、实现继承、接口和实现继承。

如果基类声明了一个pure virtual函数,则其目的是让派生类只继承该函数接口;如果基类声明了一个impure virtual函数,就是让派生类继承该函数的接口和其缺省实现;如果某个成员函数是non-virtual函数,则意味着它不打算在派生类中有不同的行为,即派生类继承该函数接口及一份强制性实现。

对于pure virtual函数的接口声明,基本没有什么意义,而non-virtual成员也显而易见。不过对于impure virtual虚函数,看似提供了缺省实现使用起来会比较方便,而且派生类可以覆盖其实现也比较灵活,但是如果直接使用这种方式,那么如果基类产生了新的派生类,但是恰好派生类忘记对这个impure virtual函数进行override,而其缺省实现又不满足新派生类的行为,那么新派生类对象的调用将会引发问题。所以如果想继承接口,同时又提供缺省实现,那么比较好的方式是将这两个功能进行分离,用一个pure virtual函数提供接口,再用一个non-virtual protected函数提供缺省实现,而让派生类手动确认是否使用该默认行为。

class Airplane {
public: virtual void fly(const Airport& dest) = 0;
protected: void defaultFly(const Airport& dest){ ... }
};

除了上面的方式处理impure virtual的缺省实现,其实也可以将其转换为:仍然使用pure virtual函数声明接口,不同同时也提供其缺省定义,这样派生类在override这个pure virtual接口的时候既可以完全重新定义fly的行为,也可以直接一条语句用基类名字直接调用基类的缺省实现(Airplane::fly),其好处是不用引入一个新的函数名字,缺点是缺省实现成了public的了。

说到此处,应该对C++中接口继承的行为得以了解了。

二、虚函数外的其他选择

前面我们说到了《C++之virtual函数访问性》中谈及了NVI手法,算是对public virtual的一个强有力的替换工具,不过我们知道其本身也用到了虚函数。虚函数具有运行时开销,而且其实现也是编译时间确定运行时候选择,在有些情况下其灵活性还是受限。

相比于虚函数依据派生类型进行行为的定制化之外,Strategy策略模式显得更为的灵活。通过在对象内部保存函数指针(或者更泛化的boost::function函数对象),其行为可以依据具体对象差异化而非派生类型差异化,甚至通过Set接口其行为还可以在运行时候进行变更。虽然Meyers说明如果使用非成员函数,默认将不能访问类的私有成员,否则就需要对封装性进行一定程度的妥协松懈,但是通过boost::function+boost::bind这个强有力的工具,使用继承体系中的成员函数也是十分方便的。

此处本人感觉,虽然设计模式被奉为C++开发的经典,但是随着Modern C++在标准上引入更多的特性和功能,C++的开发将必定变的更加友好直观,也不被过于墨守那古典23式了,毕竟绝大多数的设计模式都通过继承来实现的,不可避免的增加了程序开发和维护的复杂度。

三、继承体系来的其他问题

好了,轻松愉快的东西结束了,下面是C++史上的黑暗时刻了。

3.1 继承而来名字的可见性

C++具有一套名字查找的规则,总体来说就是从局部到外围,从派生类到基类,从内层名字空间到全局名字空间的查找顺序。

由于到此为止我们没有说明函数重载的情况,所以你此时仍然安之若素:对于public non-virtual函数我们不去重写,对于virtual函数我们可以override,这一切安好,但是一旦考虑到相同函数名的重载问题,C++有一套理论就会让你晕乎了:C++防止在应用程序库或者应用框架中建立的新的派生类被附带从疏远的基类中继承而来的重载函数,所以在继承的时候C++不会将基类的名字自动导入到派生类中。

好了,这就说明,之前继承而来的接口,其实也是在使用的时候在派生类作用域中没有找到该符号,而在基类中找到该符号后满足调用的,而如果你在基类中定义了其某个重载版本(无论是virtual还是non-virtual)的时候,C++在名字查找的时候就在你的派生类作用域中找到该名字了,然后进行类型检查和重载,但是重载的版本只限于在派生类中出现的版本,基类的版本不参与重载!!!

所以,在派生类中想增加还是改写无论virtual还是non-virtual函数的重载版本,第一件事是使用using声明将基类符号的所有版本声明到派生的名字作用域中,然后再干其他的。

3.2 绝不重新定义继承而来的non-virtual函数

C++的non-virtual函数都是编译期静态绑定的,其名字查找从其指针的静态类型开始。

任何情况下,都不要重新定义一个继承而来的non-virtual函数,否则其调用的版本决定于其指针静态类型,这与public继承is-a的一致性关系相互违背。

3.3 绝不重新定义继承来的缺省参数值

因为上面说到我们不应该重新定义一个继承而来的non-virtual函数,所以到这里我们可以说:绝对不要重新定义一个继承而来带缺省参数值的virtual函数的参数默认值。其原因是:virtual函数是动态绑定的,而缺省参数是静态绑定的。

所以如果基类和派生类的参数默认值不一致,则使用引用、指针调用发生参数默认值静态绑定和调用函数体动态绑定将会非常的诡异,所以需要避免这种情况。还有就是如果虚函数参数再基类指定的参数缺省值,而派生类override的时候没有指明参数缺省值,此时如果客户端以派生类对象方式调用该函数,则发生的是静态绑定,需要显示指定参数值;而如果客户端以指针、引用的新式调用该函数,则发生的是动态绑定,可以不指定其带有缺省值的参数。

class Shape {
public: virtual void draw(ShapeColor color = Red) const = 0; ...
};
class Circle: public Shape {
// 如果以对象模式调用draw,必须指定color参数而不能使用缺省参数
public: virtual void draw(ShapeColor color) const; ...
};

解决这个问题的一个方式是使用NVI手法,其public non-virtual接口提供默认默认值(且不会被派生类重写),而private virtual不使用默认默认的特性以规避这种可能的不一致性。

3.4 private继承

private继承没有”is-a”的契约关系了,在使用上一个巨大的差异是:编译器不再会自动将一个派生类对象转换为一个基类对象了,这意味着原本接收基类对象的函数参数将不再能够为其传递派生类对象作为实参了(对象、引用、指针类型都不允许,编译器会报基类S是派生类T不可访问的基类);同时由基类继承而来的所有成员,在派生类中都会变成private的访问权限。

private继承意味着只有实现部分被继承,接口部分被全部略去了,所以private继承应当是采用基类的某些功能帮助派生类完善其功能,从某种情况下说具有”has-a”的符合类型,所以除了考虑到派生类需要访问基类protected成员和virtual的因素被牵扯进来,否则应该尽量使用组合类型来代替private继承,而且即使如此,也可以使用下面的手法瞒天过海:

class Timer {
public: virtual void onTick() const; ...
};
class Widget {
private:
 class WidgetTimer: public Timer {
 public: virtual void OnTick() const; ...
 };
 WidgetTimer timer;
};

关于protected继承,连Meyers大神都没用过,那么我又何必废脑经去考虑他……

参考

Effective C++

总结

以上就是这篇文章的全部内容了,希望本文的内容对大家的学习或者工作能带来一定的帮助,如果有疑问大家可以留言交流,谢谢大家对我们的支持。

(0)

相关推荐

  • 分享一下8年C++面向对象设计的经验体会

    六年前,我刚热恋"面向对象"(Object-Oriented)时,一口气记住了近十个定义.六年后,我从几十万行程序中滚爬出来准备写点心得体会时,却无法解释什么是"面向对象",就象说不清楚什么是数学那样.软件工程中的时髦术语"面向对象分析"和"面向对象设计",通常是针对"需求分析"和"系统设计"环节的."面向对象"有几大学派,就象如来佛.上帝和真主用各自的方式定义了这个

  • c++ 面向对象的类设计

    类的设计在于用恰到好处的信息来完整表达一个职责清晰的概念,恰到好处的意思是不多也不少,少了,就概念就不完整:多了,就显得冗余,累赘,当然特例下,允许少许的重复,但是,这里必须要有很好的理由.冗余往往就意味着包含了过多的信息,概念的表达不够精准,好比goto,指针,多继承这些货色,就是因为其过多的内涵,才要严格限制其使用.好像,more effective c++上说的,class的成员函数,应该是在完整的情况下保持最小化.但是,这里我们的出发点,是成员数据的完整最小化. 最小化的好处是可以保持概

  • 剖析C++的面向对象编程思想

    面向对象的程序设计 面向对象编程(Object Oriented Programming,OOP,面向对象程序设计) 的主要思想是把构成问题的各个事务分解成各个对象,建立对象的目的不是为了完成一个步骤,而是为了描叙一个事物在整个解决问题的步骤中的行为. 面向过程就是分析出解决问题所需要的步骤,然后用函数逐步实现,再依次调用就可以了. 面向对象和面向过程是两种不同的编程思想,没有哪一种绝对完美,要根据具体需求拟定开发方案.例如,开发一个小型软件或应用程序,工程量小,短时间内即可完成,完全可以采用面

  • 关于C++面向对象设计的访问性问题详解

    前言 最近在看Scott Meyers大神的<Effective C++>和<More Effective C++>,虽然这两本书都是古董级的教参了(当然针对C++11/C++14作者所更新的<Modern Effective C++>英文已经发售了,不过还没中文翻译版本),但是现在看来仍然收益匪浅,而且随着对这个复杂语言了解的深入和实践项目经验的增加,很多东西和作者产生了一种共鸣,以前种种疑惑突然有种拨云雾而见天日.豁然开朗的感觉,也难怪被列为合格C++程序员之必读书

  • c# 类成员的可访问性代码详解

    以上就是本次介绍的关于c# 类成员的可访问性的全部知识点内容,感谢大家的学习和对我们的支持.

  • Python面向对象总结及类与正则表达式详解

    Python3 面向对象 -------------------------------------------------------------------------------- 一丶面向对象技术简介 •类(Class): 用来描述具有相同的属性和方法的对象的集合.它定义了该集合中每个对象所共有的属性和方法.对象是类的实例. •方法:类中定义的函数. •类变量:类变量在整个实例化的对象中是公用的.类变量定义在类中且在函数体之外.类变量通常不作为实例变量使用. •数据成员:类变量或者实例变

  • Spring Boot实现数据访问计数器方案详解

    目录 1.数据访问计数器 2.代码实现 2.1.方案说明 2.2.代码 2.3.调用 1.数据访问计数器   在Spring Boot项目中,有时需要数据访问计数器.大致有下列三种情形: 1)纯计数:如登录的密码错误计数,超过门限N次,则表示计数器满,此时可进行下一步处理,如锁定该账户. 2)时间滑动窗口:设窗口宽度为T,如果窗口中尾帧时间与首帧时间差大于T,则表示计数器满.   例如使用redis缓存时,使用key查询redis中数据,如果有此key数据,则返回对象数据:如无此key数据,则查

  • Java并发编程加锁导致的活跃性问题详解方案

    目录 死锁(Deadlock) 死锁的解决和预防 1.超时释放锁 2.按顺序加锁 3.死锁检测 活锁(Livelock) 避免活锁 饥饿 解决饥饿 性能问题 上下文切换 什么是上下文切换? 减少上下文切换的方法 资源限制 什么是资源限制 资源限制引发的问题 如何解决资源限制的问题 我们主要处理锁带来的问题. 首先就是最出名的死锁 死锁(Deadlock) 什么是死锁 死锁是当线程进入无限期等待状态时发生的情况,因为所请求的锁被另一个线程持有,而另一个线程又等待第一个线程持有的另一个锁 导致互相等

  • JS 面向对象之继承---多种组合继承详解

    这一次要讲 组合.原型式.寄生式.寄生组合式继承方式. 1. 组合继承:又叫伪经典继承,是指将原型链和借用构造函数技术组合在一块的一种继承方式. 下面来看一个例子: function SuperType(name) { this.name = name; this.colors = ["red", "blue", "green"]; } SuperType.prototype.sayName = function() { alert(this.n

  • 原生js中ajax访问的实例详解

    原生js中ajax访问的实例详解 form表单中 登录名: 失去光标即触发事件 function createXmlHttp() { var xmlHttp; try { // Firefox, Opera 8.0+, Safari xmlHttp = new XMLHttpRequest(); } catch (e) { try {// Internet Explorer xmlHttp = new ActiveXObject("Msxml2.XMLHTTP"); } catch (

  • Go语言异步API设计的扇入扇出模式详解

    目录 前言 扇入/扇出服务 Go 语言实现扇入/扇出模式 前言 扇出/扇入模式是更高级 API 集成的主要内容.这些应用程序并不总是表现出相同的可用性或性能特征. 扇出是从电子工程中借用的一个术语,它描述了输入的逻辑门连接到另一个输出门的数量.输出需要提供足够的电流来驱动所有连接的输入.在事务处理系统中,用来描述为了服务一个输入请求而需要做的请求总数. 扇入是指为逻辑单元的输入方程提供输入信号的最大数量.扇入是定义单个逻辑门可以接受的最大数字输入数量的术语.大多数晶体管-晶体管逻辑 (TTL)

  • iOS小技能之字典转模及对象相等性示例详解

    目录 前言 I 字典转模型 1.1 字典转模型的实现步骤 1.2 字典转模型的过程 II 对象的相等性 & 本体性 2.1 相等性检查 2.2 Foundation 框架中,自己实现的相等性检查 2.3 字符串驻留 III 代码重构(前提是已经实现了基本功能) see also 前言 字典转模型 /** 通常实现字典实例化模型,都实现了以下模型的实例化方法*/ //使用字典实例化模型 - (instancetype) initWithDictionary :(NSDictionary *) ap

  • SpringBoot Actuator未授权访问漏洞修复详解

    目录 1.写在前面 2.问题描述 3.安全问题 4.禁止方法 5.完全禁用Actuator 1.写在前面 目前SpringBoot得框架,越来越广泛,大多数中小型企业,在开发新项目得时候.后端语言使用java得情况下,首选都会使用到SpringBoot. 在很多得一些开源得框架中,例如: ruoyi若以,这些. 不知道是出于什么原因?我们都会在这些框架中得pom文件中找到SpringBoot Actuator的依赖. 嘿,这Actuator估计很多人都没有真真实实使用过,但是就会出现在pom文件

随机推荐