测试积点老人 发表于 2018-12-18 16:13:37

面向对象缺陷分析

本帖最后由 测试积点老人 于 2018-12-18 16:15 编辑

教科书上说,面向对象三大特性是封装、继承、多态。其中封装和多态并非面向对象原创,比如说USB口可以插入U盘、手机、ipod等设备,这就是多态。插入之后电脑内部怎么运转不用管,看显示器操作就行,这就是封装。在软件开发领域,面向对象出现之前,封装和多态虽然没有具体概念,但是其思想却已经广为应用。我认为,最能代表面向对象的,也是最值得喷的特性,就是继承。


将世界看成对象的集合据说,面向对象是比较适合人的思维的,因为人在看世界的时候就是一个个对象,因此面向对象有利于程序的构建。这个观点是非常形而上(美其名曰哲学)的,其正确性先不去讨论。为了模拟对象,class关键字引入各种面向对象语言,把属性与操作(方法)封装于其中。在现实世界中对象似乎有一定层次关系,比如鸡鸭牛羊属于动物,动物属于生物,生物属于对象……以此为据,面向对象先驱们设计了继承这一思想,让class之间也形成继承关系,这样既有助于理解类的概念,又让代码有层次性,而且还提高了代码的复用性——少写了不少代码,提高生产力。自此,以class、继承为基础的面向对象思想、语言、设计方法大行其道,直至今日。


继承的问题直觉上看,继承确实符合人认识事物的思维。但是在实践中,我发现继承有时不那么好用。比如番茄属于植物,属于食物,然而植物和食物之间的关系不是继承也不是并列,而是有交集。在食物类中,番茄同时具有水果和蔬菜的特性,即便现实世界也很难区分。这就是继承的问题所在:面向对象语言中的继承体系是比较严格的树形关系,而现实世界或者软件需求却不一定是树形,很可能关系错综复杂。我在“设计模式的本质思想”一文中提到一个例子,现在将这个例子简化一下。有个Bird类,类内有Sing(鸣叫)和Move(移动)两个方法,具体操作什么由子类实现。先设计基类,如下//纯面向对象语言java登场
abstract class Bird {
      abstract void Sing();
      abstract void Move();
}
Java号称纯面向对象语言,一切都是对象。看看又是abstract又是class,真有面向对象风格,基类设计的没太大问题。然后我们实现(泛化)一个麻雀类,叫声是"Jijijiji",移动方式是"Fly"(不要联想JJFly),一个鸽子类,叫声是"gugugu",移动方式是"Fly"。如下class Sparrow extends Bird{
      @Override
      void Sing() {
                System.out.println("jiji");
      }
      @Override
      void Move() {
                System.out.println("Fly");
      }
      
}
class Dove extends Bird{
      @Override
      void Sing() {
                System.out.println("gugugu");
      }
      @Override
      void Move() {
                System.out.println("Fly");
      }
}
至此,一个简单的鸟继承体系完成。在应用层使用Bird对象的时候不必管具体子类会怎么实现,似乎很好地实现了模块化。不过仔细一看有重复代码,Fly那一部分似乎是重复的。那我们提取一个FlyBird类,移动方式是"Fly",叫声由子类实现,麻雀和鸽子都继承自FlyBird。看起来不错。现在我们在加入小鸡类,叫声是"jijiji"而移动方式是"walk",叫声那部分代码可能也会重复。有兴趣的同志可以试试,麻雀、鸽子、小鸡这三个类只用单继承,很难避免代码重复。出现这一问题的原因是,类之间的关系很复杂,只靠简单的树形继承无法表达这些关系。
解决方案——设计模式诞生设计模式远远没有大家想的和他自己鼓吹的那么高大上,其出现主要还是为了解决上面这个问题。看看如何用策略模式解决这一问题//策略模式的思想是组合代替继承
//先声明两个接口
interface Move{
      void move();
}
interface Sing{
      void sing();
}
//基类的行为不直接定义,而通过接口实现
class Bird {
      Bird(Move m, Sing s){
                this.m = m;
                this.s = s;
      }
      private Move m;
      private Sing s;
      
      public void Sing(){
                s.sing();
      }
      public void Move(){
                m.move();
      }
}
//实现飞行动作
class Fly implements Move{
      public void move(){
                System.out.println("Fly");
      }
}
//实现Jiji叫声
class Jiji implements Sing{
      public void sing() {
                System.out.println("jijiji");
      }
}
//麻雀类
class Sparrow extends Bird{
      Sparrow(){
                super(new Fly(), new Jiji());
      }
}按照这种方式,用组合代替继承,合理的避免了重复代码,软件再扩大时也比较容易管理。确实是个不错的解决方案。
其他面向对象解决方式多重继承单一继承的树形关系不是难以模拟需求吗?我用多重继承,实现FlyBird/WalkBird,以及JijiBird/GuguBird,麻雀继承自FlyBird and JijiBird(别想歪了),鸽子继承自GuguBird and FlyBird。理论上在这个问题中,多重继承也能合理的组织类的关系,但是多重继承问题很多,最主要的,继承自两个类,两个类的属性和方法保存一份还是两份?保存谁的?如果问题再复杂点,多重继承恐怕也很难满足需求。方法2,将所有操作,比如Fly/Walk/Jiji/Gugu,都放在基类中实现,子类只负责调用。当子类没有新属性的时候这不失为一种解决办法。看起来很扯淡,其实是有应用的,比如基类是单链表,子类封装起来调用插入删除方法可以形成栈与队列。其他语言的解决方式
C语言#include <stdio.h>
struct Bird{
      void *property;
};
//函数指针模拟虚函数
typedef void (*MoveFun)(Bird*);
typedef void (*SingFun)(Bird*);
void Fly(Bird*){
      printf("Fly\n");
}

void Gugu(Bird*){
      printf("Gugu\n");
}

int main(){
      //下面三行初始化一个鸽子
      Bird b;
      MoveFun doveMove = Fly;
      SingFun doveSing = Gugu;
      
      doveMove(&b);
      doveSing(&b);
}我学C++面向对象经历了这么个过程:class不就是struct加函数么。。。原来面向对象虚函数可以实现应用实现分离,不错。。。C语言加函数指针也能实现虚函数。。。有些东西不太好设计,还好我有设计模式。。。这TM不就是struct加函数指针么!!!在此稍微总结一下,通过策略模式和C语言的两个例子,我们发现组合通常比继承来的爽快,能够更简单更直接地解决问题。然而面向对象语言通常不把类内方法当成可以赋值的变量(函数不是first-class),而是写了之后就固定了,和类型严格绑定,这可能是造成继承不灵活的根本原因。在python语言中,类内的方法强制带self参数,可以调用类外的函数模拟对类内函数的赋值,实为面向对象史上一大进步(此前对python有误解,现已修改)。即使应用如上的策略模式,运行时如果不允许替换方法,面向对象的缺陷还是很大,比如小鸡长成大鸡,叫声从Jijiji变成Gugugu,恐怕要重新设计了(简单的处理方式是有的,比如把两个接口对象改成public随意改动,不过这肯定会因为太不OO而被否决)。然而用C语言就不需要所谓的设计。

Lua语言function Fly()
      print("Fly")
end
function Gugu()
      print("Gugu")
end

function GetDove()
      dove = {}
      dove.Move = Fly
      dove.Sing = Gugu
      return dove
end

dove = GetDove()
dove.Move()
dove.Sing()lua不支持面向对象,然而用table可以完美的模拟面向对象做得到和做不到的事。table只是一个key-value的集合,不去刻意区分属性和方法,不管属性还是方法都能随意替换。同时Lua是弱类型语言,如果把dove对象的Sing改成Jiji,那么dove就变成了一个麻雀,这就是传说中的“鸭子类型”。

继承的适用范围实践来看,如果你想通过继承实现开闭原则——新需求只需要在已有类的基础上添加而不需要改动已有类——简直是痴人说梦。如果在设计之初就考虑应对变化,那么往往也在浪费时间,你设想的变化通常不会出现,出现的都是你没想到的。继承为主的面向对象思想,不仅不适合变化快速繁多的场合,反而对需求变化不太大的模块更能胜任:GUI中的控件(同类控件除了绘制和鼠标消息基本没什么变化的需求,一个控件写好了到处通用N个版本也不会改)、基本数据结构和算法(基本没有需求变化)、编译原理的词法分析模块(至多对内部算法优化,对外接口不会有变动) 。

总结为了弥补继承的缺陷,编程语言引入各种概念,多重继承(c++、python)、Mixin(模拟多重继承)、delegate(C#,不过delegate作为lambda比C的函数指针要强)、functor(C++)、interface(Java and others)以及各种设计模式。C语言和lua的table有更简单更直接的实现方式,不过人家不支持面向对象,请读者不要在这扯皮说C也有面向对象思想精髓之类。
在此引用宏哥一句话:OO就是把屁股朝天拉屎, 不拉在裤子上的就叫做"OO设计师"。整个OO的设计精髓就在于不要把屎拉裤子上。问题是, 为什么要朝天拉屎呢 --为了OO。
页: [1]
查看完整版本: 面向对象缺陷分析