C++多继承有什么坏处,Java的接口为什么可以摈弃这些坏处?

这是个历史遗留问题。

The History of Object Oriented ProgrammingObject-oriented programming

最初,人们并不知道“继承”究竟应该是什么。对这种新生事物,要求人们一下子就在头脑里有个清晰图景显然是不可能的。

关于面向对象,一直以来就有两个主要派别:Class-based vs prototype-based

后来的其他各种流派,都离不开这两个派别的核心思路,只是具体细节上略有不同而已。

其中,前者认为,面向对象就是个分类问题——圆形是个图形,方形也是个图形,所以圆形和方形都应该从“图形”这个类继承。

类似的,蝙蝠既是可以飞行的动物,也是哺乳动物,所以它就应该从“可飞行动物类”和“哺乳动物类”继承——这样才可以“既能飞行又能哺乳”。

——换句话说,Class-based这个思路很容易直接导向一个误区,那就是把“继承”看得过重:这很容易理解,既然是“同类”,那么理所当然的就可以享用“同类祖宗”的共同遗产,对吧。

——但是,如此一来,就不可避免的导致很多含糊不清的问题。比如,蝙蝠究竟是用飞行动物的嘴吃饭呢,还是用哺乳动物的嘴吃饭?吃下的饭,是给哺乳动物的胃消化呢,还是给飞行动物的胃消化?(熟悉编程的朋友恐怕马上就要想到未初始化、未重置、访问错误的内存区域等等“恶心而又可怕”的东西了)


C++和Java都是class-based派别的支持者。

理所当然的,基于这种语言一贯的、对程序员的无条件信任,C++选择了支持多继承,虽然这个东西已经暴露出来很多很多的问题,但它毕竟在很多时候还是有用的;而Java则禁止了多继承——毕竟它已经暴露了太多太多的问题,禁用它至多也就是实现繁琐一些、性能差一些而已。

乍看之下,class-based这个思路很好很解决问题;所以Object C、C++甚至后来的Java全都选择了这条路。

但是,它“默认让派生类取得基类所有遗产”的行为还是造成了很多很多的问题——这种行为不可避免的导致派生类和基类代码产生耦合;尤其在多继承时,尤其是菱形继承这种最恶劣的情况下,你甚至都不知道它会和基类的哪段代码/哪些数据结构产生耦合!


长期实践下来,prototype-based派别的观点就在实践中越发显示出了它的正确性——相比之下,class-based派就有点像缺乏考虑、就着比喻做设计的一群大老粗了:只是比喻总是比学术语言更生动、更容易流行,这才让它一度占据上风而已。

prototype-based派别认为,面向对象其实就是一组实现了特定协议(或者叫接口)的object——在它里面压根就不存在类,只有prototype和object。

按照这一派的思路发展下去,我们真正应该关心的是“对象可以提供什么样的服务(或者说,像XXX一样的服务)”:重要的是接口!压根就不需要考虑/支持继承这种矫揉造作的东西!

——分类?呵呵,正方形是长方形吗?在想清楚前别说话!

这就绕开了class-based需要面对的、棘手的“正方形是不是一种长方形”问题——程序语言里面的class并不是日常语言中的“类”,它的精确表述是“is-a”,和口语的“类”八竿子打不着(事实上,自从class-based派同意“类不是类而是is-a”开始,他们已经向prototype派投降了)。

和外行的想象相反,class-based和prototype-based并没有因此而打得头破血流。

事实上,几乎从最初的几个版本开始,C++/Java就引入了prototype流派的思想,这就是所谓的“interface”,或者说,其实严格来说并不是继承的“接口继承”——当然,基于一贯的、对程序员的信任,C++允许你的interface里面存在实现代码甚至数据成员:只要你确切知道它会被如何使用。这种做法就使得接口继承里面的继承二字又找回了一定的存在感,然后就把多重继承之类问题又找回来了。

不过,class-based思路真正的问题还在于继承带来的强耦合,以及“鼓吹继承”给它的程序员甚至设计者所带来的思想包袱(想想本来已经通过interface解决、但又被随意“魔改”的interface找回的菱形继承问题吧)。


为什么prototype-based派可以绕开继承带来的诸多副作用呢?

很简单,因为prototype派压根就不存在继承。

它就是声明自己支持某个“协议/接口/prototype(反正就这意思,你叫它什么都可以)”,然后想办法真的去支持这个协议就完了。

——至于如何支持呢?你可以自己从头写;但也完全可以在自己的object中隐藏一个支持该协议的、来自系统或第三方的object,然后把相关调用转发给它(这个转发在相关语言里,常常可以通过显式声明自动完成:换句话说,继承在这种语言里不过是个语法糖而已;而且这种语法糖思路确保你不可能弄出多继承来)。

既然prototype只是允许一个对象声明它兼容某个prototype而已,并不会越俎代庖的把这个prototype的默认实现/标准基类等等东西塞进你的代码——那么,这个prototype究竟是怎么搞出来的,当然就由你完全控制了:哪怕你往里面塞一万个同样支持这个prototype的object进去,只要你自己头脑清醒、知道什么时候应该把调用转给这一万个object中的哪一个,它就是完全合法并且井井有条的。

换句话说,既然prototype-based放弃了直接从“父类”拿到“祖传代码”这点实惠,那么它自然就绕开了“继承父类代码”带来的诸多弊端。

而且,prototype扔掉“通过class继承拿到的祖传代码”,这看似是个绝大的浪费;但事实上,你仍然可以通过“把拉来的订单转交给父亲/母亲开的公司”、从而不浪费可以从父母那里拿来的好处——这个转交过程是完全可控的,绝不存在任何含糊之处。

与之相比,class based鼓吹的继承就麻烦多了——你必须理解父/母亲开的公司的运作机制,不然就很容易在“继承”时搞错;更可怕的,当你同时从父母那里继承两家公司时,你喊“会计,记账”,你并不知道哪家公司的会计会把账务记到哪本帐上。

你说我可以虚继承,把两家公司的会计团队合并起来?
倘若两家都是同样性质的公司,那的确没什么问题……
但倘若你得到的是一家房地产公司和一家IT企业,让你搞出了个X氏企业集团,同时支持房地产业务和IT业务——两者内部管理逻辑截然不同;强行把帐做到一起给你个“统一的管理接口”?我看你还怎么管理这个混账团队

醒醒吧。你真正需要的,是把这两家公司当两家公司经营,并不是通过什么神秘的巫术仪式合并它们:一旦两家公司有各自使用各自基类数据的理由/特殊逻辑,合并就成了混账

换句话说,既然语言允许,那么子类和父类当然就可能存在深度耦合;然后,当孙类从两个子类多重继承时,它们的共同父类就可能成了某种“合并不是,不合并也不是”的尴尬存在

千万不要以为编程语言提供的什么东西真就那么智能。
除非你完全明白自己在做什么、而且也确信接手自己代码的人也知道你在做什么、或者让你接手别人代码时你也总能头脑清醒的把每一个流程的来龙去脉都搞清楚……否则,还是离这类含糊/微妙的东西越远越好。
否则,并不是“多重继承搞出了二义性、咱只要闭着眼睛通过算法把二义性消除了就一定能解决问题”:你真正需要思考需要解决的问题实在太多了;在这种领域,语言越“智能”,你和你的团队需要理解和理顺的规则/逻辑就越多,反而越容易出错。

多重继承的确可以很方便的解决一些问题;但为了这个方便,付出的代价往往过于高昂。

prototype就是“不去支持继承这种含糊不明的东西,从而把语言逻辑搞简单”的典范:这种思路使得“如何组合使用object、如何对外提供prototype抽象”等诸多细节完全由程序员控制,再不存在任何含糊之处。
当然,它也因此再也不能像C++那样,通过“变多重继承魔术”神奇的得到某些功能了——现在你得自己明确写出来。

——这个思想一旦被引入class-based学派,就成了“优先使用组合而不是继承”。

至于晚近出现的一些语言,比如go,直接就走了prototype-based的道路。

就这样,通过引入interface,C++/Java就允许了程序员们把这种语言变成“看似class-based,实质是一堆空壳子”的存在,从而暗地里实现语言向prototype的转化。

换句话说,一旦通过prototype规避掉“实现继承”,“实现继承”带来的坏处自然就烟消云散了:多重继承这种由“实现继承”发展而的“恶性肿瘤”,自然也就失去了存在基础。

——当然,前提是,千万不要把interface又搞得像个类一样。

来源:知乎 www.zhihu.com

作者:知乎用户(登录查看详情)

【知乎日报】千万用户的选择,做朋友圈里的新鲜事分享大牛。
点击下载

此问题还有 27 个回答,查看全部。
延伸阅读:
动不动就 32GB 以上内存的服务器真需要关心内存碎片问题吗?
怎么样才算是精通 C++?