漫谈面向对象

作者: Sheepy | 来源:发表于2015-12-11 15:40 被阅读2075次

    什么是面向对象

    这年头但凡是写过几行代码的,想必都不会对面向对象(Object-Oriented, OO)这四个字感到陌生。但什么才是面向对象,不知道又有多少人真正去思考过。有人以为和女朋友一起敲代码就是面向对象编程了(大雾);有人觉得使用C++/Java/C#等面向对象语言就是在面向对象了;也有人听说继承、封装、多态是面向对象三大特征,好家伙,继承不就是各种子类么?封装不就是各种private么?多态是啥好像很厉害,哦原来只要子类重写父类方法然后调用的时候向上转型就好了嘛……都是小意思啦我整天都在面向对象。

    少年,你的思想很危险啊……

    首先,和对象一起敲代码那是“真·结对编程”,显然是比面向对象编程更高级的技能,如果连这都做到了,人生赢家啊您,我无话可说,您已经完全不用管究竟什么是面向对象了……至于剩下的两类言论么,我们还可以好好探讨一下。

    编程语言和编程思想

    在编程语言的信仰之争中有一句经典废话,此言一出,万籁俱静,与女神的“呵呵”具有同等的杀伤力,它就是:语言只是工具,思想才是重点!当你在键盘上打出这几个字的时候,一股优越感直冲头顶百会穴,你掐灭了烟邪魅一笑,心道哥说得这么有道理你们这帮低级码农无言以对了吧。

    好吧我确实无言以对,我们只是在客观地讨论语言特性,讨论其设计以及应用,您瞬间上升到哲学层面了,这怎么玩?

    说语言是工具当然没错,但哪怕是工具,一个合格的匠人也该对自己手头的工具有充分的了解才能保证物尽其用。匠人们在茶余饭后讨论讨论哪个工具趁手些,哪个工具虽然上手难用些容易伤到自己,但一旦耍熟练了却又好用得紧之类的话题自然也无可厚非。但语言仅仅是工具么?显然不是的,对于热爱编程的人来说,语言更是一种“玩具”,如剑之于嗜剑如痴的剑客,如车之于爱车如命的车手。男儿至死是少年啊,对于心爱之物,有一点超出理智的热衷又怎么了?

    当然,喜欢某种语言无可厚非,但因此固步自封,藐视其他一切语言,不去尝试其他可能却是十分愚蠢的——你哪怕要喷一种语言,也至少得去用一用研究一下吧?而往往等你真正去深入一门语言,甚至了解它的演化历史之后,又会对它生出喜爱之情。

    有点扯远了啊,虽然我觉得“语言只是工具,重要的是思想”这句话是不完全正确的,因为语言在很大程度上是会影响使用者的编程思维的。然而对于那些以为使用面向对象语言就是在面向对象的朋友们,用这句话告诫他们却是十分合适的。一般来说,在使用面向对象语言的时候我们总会偏向于用面向对象的思维进行程序设计,而在使用像C语言这样的结构化语言(或者称面向过程语言)的时候,我们会偏向于进行结构化的设计。然而万事无绝对,如果你在使用面向对象语言的时候写了一堆class,而每个class内部的属性和方法都乱七八糟,明明应该A干的事情却给放到了B中,那显然是称不上面向对象的;而如果在使用C语言的时候使用了诸如struct这样的数据结构,合理地在其内部封装了一些表示状态的变量和指向某类操作的函数指针,那这怎么就不是面向对象了呢?事实上Objective-C中的类啊对象啊其实都只是struct的类型别名。

    (继承,多态),封装

    对于所谓的面向对象三大基本特征呢,我也有一点自己的看法。大家总把这三者放在一起讲,其实非常别扭。继承与多态应该是同一个层级的东西,因为在大多数面向对象语言中,只要出现了多态,多半会出现继承(反之并不成立)。因为多态其实很简单,语意上是指接口的多种不同实现方式,在面向对象语言中我们最常用的是子类型多态:允许将子类当作父类使用,在调用同一个方法时会产生不同效果(注:鸭子类型的多态和利用范型实现的参数多态根本就不需要继承)。

    而封装呢,显然是一个更大的话题,它的字面意思类似于“打包”,但又不是简单地把一堆东西放一起就可以了。我们可以说封装一个类,也可以说封装一个模块。既然要把东西“封”起来,那自然密不透风,也就是要隐藏实现的细节,这就是一层“抽象屏障”,而封起来之后和外界的联系很少,这就要保证模块(或类)之间的松耦合;再者,既然“装”到了一起,那整个模块显然可以看作是一个整体,可以对外提供统一的接口,而作为一个整体,内部的状态和过程应该是有紧密联系的,也就要保证模块内部的高聚合。所以个人认为,在谈到封装的时候,我们的理解不应该仅仅局限于对访问权限的控制(使用private关键字),而至少应该包括如下几点:

    • 构建抽象屏障,隐藏实现细节
    • 保证不同模块之间的松耦合
    • 保证每个模块内部的高聚合
    • 对外提供简单易用的统一接口

    虽然大家都嚷嚷着继承封装多态是面向对象三要素,但众所周知,在实际项目中,滥用继承会让模块间的耦合变紧,使整个项目变得很难维护,在某种程度上看,继承跟封装甚至是有一点对立的。所以业界有个好评如潮的最佳实践,甚至有人把它当做一个设计原则,那就是:少用继承,多用组合。这些其实都是有道理的,不过我个人觉得,如果真正做好了封装这一点,那就已经比很多号称精通面向对象编程和各种设计模式的人要强了。

    一个面向对象程序设计实例

    上面说了这么多,我觉得还是举个小例子说明一下面向对象是一种通用的设计思想,而不是C++、Java等面向对象语言所独有的。我们先来看一张经常拿来做例子的表格:

    OO.png

    我用Swift来演示,会有两个版本来实现同样的功能。第一个版本使用protocol、class等面向对象的语言特性,第二个版本只使用函数,但两个版本都使用了面向对象的设计思维。

    第一个:

    protocol Animal {
        func speak()
        func run()
        func eat()
    }
    
    class Cat: Animal {
        func speak() {
            print("喵~")
        }
        func run() {
            print("不跑,猫步走起")
        }
        func eat() {
            print("优雅地吃饭")
        }
    }
    
    class Dog: Animal {
        func speak() {
            print("单身汪的日常,汪汪~")
        }
        func run() {
            print("撒腿狂奔")
        }
        func eat() {
            print("狼吞虎咽")
        }
    }
    
    func actAsAnimal(animal: Animal) {
        animal.speak()
        animal.run()
        animal.eat()
    }
    

    像java、C#这样的语言是不允许声明全局函数的,所以我这边这个actAsAnimal函数可能在有些人眼中显得不那么面向对象,那你可以再新建个class,然后把这个函数放进去。我就不放了,因为在这个例子中这样做实在很多余。好了,我们看看调用效果:

    let cat = Cat()
    actAsAnimal(cat)
    print("-----------")
    let dog = Dog()
    actAsAnimal(dog)
    
    输出:
    喵~
    不跑,猫步走起
    优雅地吃饭
    -----------
    单身汪的日常,汪汪~
    撒腿狂奔
    狼吞虎咽
    
    

    这里其实就用到了多态,但却没有用到继承,而是用了协议(protocol),相当于别的语言中的接口(interface)。这有什么好处呢?在这个小例子中自然是看不出什么好处的,长远来看,由于CatDog是完全解耦的,而且它们的具体实现跟Animal也没太大关系,这样不管是调试还是日后进行扩展,都会方便很多。

    接下来第二个版本,我用纯函数的方式来实现差不多的效果:

    typealias Animal = (String) -> ()
    func animal(name: String) -> Animal {
        func cat(action: String) {
            switch action {
            case "speak":
                print("喵~")
            case "run":
                print("不跑,猫步走起")
            case "eat":
                print("优雅地吃饭")
            default:
                print("喵星人的注视")
            }
        }
        
        func dog(action: String) {
            switch action {
            case "speak":
                print("单身汪的日常,汪汪~")
            case "run":
                print("撒腿狂奔")
            case "eat":
                print("狼吞虎咽")
            default:
                print("汪星人的注视")
            }
        }
        
        func unknownAnimal(action: String) {
            print("I can not \(action)")
        }
        
        switch name {
        case "cat":
            return cat
        case "dog":
            return dog
        default:
            return unknownAnimal
        }
    }
    
    func actAsAnimal(animal: Animal) {
        animal("speak")
        animal("run")
        animal("eat")
    }
    

    现在我们调用一下看看:

    let cat = animal("cat")
    actAsAnimal(cat)
    print("-------")
    let dog = animal("dog")
    actAsAnimal(dog)
    
    输出:
    喵~
    不跑,猫步走起
    优雅地吃饭
    -------
    单身汪的日常,汪汪~
    撒腿狂奔
    狼吞虎咽
    

    输出完全一致,而且调用起来也跟第一个版本差不太多,如果不看我的实现的话,你会想到这是用纯函数实现的么?所以你们觉得这是不是面向对象呢?我觉得是的,更具体一点说,这其实是消息传递的编程风格,我自定义一个表示发送消息的操作符,大家可能看得更清楚些:

    //消息传递符号
    infix operator <- {
        //左结合
        associativity left
        //乘除优先级150,加减优先级140
        precedence 130
    }
    
    func <-(lhs: Animal, rhs: String) {
        lhs(rhs)
    }
    

    这样一来我们就可以把actAsAnimal函数改成这样:

    func actAsAnimal(animal: Animal) {
        animal <- "speak"
        animal <- "run"
        animal <- "eat"
    }
    

    speak为例解释一下:把animal看成一个对象,然后给他发送一条消息"speak",然后animal就会在内部处理"speak"这个消息,会根据它真实的“类型”(其实catdog是两个不同的函数)执行不同的操作,如果是cat就打印“喵~”,是dog就打印“单身汪的日常,汪汪~”。

    当然这里我说的消息传递是指一种广义上的编程风格,跟OC中的消息传递稍微有点不同,OC中的消息传递是基于一个类似于字典的数据结构和函数指针实现的。另外,我觉得OC中实现继承的方式其实就是组合,挺有趣的,下次我准备写写这方面的东西。

    关于并发和函数式编程(FP)

    近些年函数式编程又火了起来,很大程度上是因为面向对象这种编程范式在并发领域实在显得过于繁琐,毕竟可变的状态在并发时非常难以控制,而没有赋值没有变量的函数式编程就没有这种烦恼。函数式编程不像面向对象编程那么直观,也不能用一堆隐喻来帮助你理解它,所以有人觉得它比较高深莫测。

    中二的我认为啊,这两种范式几乎代表了两种不同的世界观,面向对象的世界中,充斥着一个个对象,它们是离散的。在这个世界中,本身是不存在“时间”这个概念的,对象会以不同的状态来对应现实中的不同时间点,你需要管理每个对象的状态。而在并发的情况下,就如同出现了多个“平行世界”,而要命的是这些“平行时间”并不完全平行,有一些人(线程共享变量)可能同时生活在许多个世界,他们会被不同世界影响,也会影响不同世界的历史进程,显然这很不安全容易乱套,你需要制定一些规则(同步机制,譬如各种锁),来保护这些“跨世界人员”不被多个世界同时影响从而精神错乱。而且你又必须小心翼翼确保各个世界都能正常发展,不能因为不恰当的保护机制致使某些推动历史进程的关键人物被关进了小黑屋永无出头之日(死锁),要知道好几个世界都在等着他带领人民走向新时代啊!

    而在函数式编程的世界中,如果你使用“流”来模拟时间(一种延时序列,参见《SICP》),那世界连同时间都是一个整体,一切都已注定,一切都无法改变。你就像摊开了一张恢宏的历史画卷,每个时间点的世界都在其中,你的眼睛看到哪儿,历史就发展到哪儿。就算出现了平行时空,它们也是互不干涉的。

    唉说多了感觉有点民科啊,总之呢,不同的编程范式有不同的适用场景,不能说哪种范式就是错的,毕竟光还存在波粒二象性呢。大体上来说,在需要频繁改变状态的时候,用面向对象好一些;在需要避免改变状态的时候,用函数式编程好一些。不要把某种范式神话,但也不要把它妖魔化。

    多谢看完我的一通胡言乱语,由于水平有限,如有错漏,欢迎指教。

    相关文章

      网友评论

      • 31b2de936492:不错,很受启发
      • b4b8afd1aa9f:应该展开再讲讲stream的,看sicp的时候感觉跟corouter有点像,最近了解到一个术语FRP,发现这里面其实大有文章所做。
        另外,多态和继承的关系没那么密切的,参见《冒号课堂》一书。
        Sheepy:@枕头 刚刚我去看了下《冒号课堂》多态的部分,我不太习惯这种对话的形式,没太仔细看。不过也发现我在文中说的确实有疏漏,我只是说到了子类型多态,鸭子类型的多态和利用范型实现的参数多态根本就不需要继承。原文已改,多谢指出 ^ ^
        Sheepy:@枕头 stream我一直想写的,不过这篇主要是随便写点oop的东西,而且写到后来写累了,stream就一笔带过了。FRP的东西我之前也觉得跟stream可能有密切联系,稍微看了下RxSwift(一个FRP框架)的源码,发现还是没这么简单,有时间再研究下吧。多态么其实不是一个局限于oop的概念,我了解的广义上的多态是指同一个函数接受不同类型参数时的不同行为,不过在oop中我感觉还是跟继承关系挺密切的。你提到的资料我会去看的,多谢多谢。
      • dongjunkun:我觉得面相对象的核心是抽象,抽象的过程就要能看穿任何事物的本质,而不是只看到事物的表面,举个例子,如果一个程序员看到两个不同型号的鼠标,就固执的认为就是两个不同的东西,那他就不具备面相对象的编程思维,很多外表上截然不同的事物都有着惊人相同或相似的本质,为什么有些人能够触类旁通举一反三,而有些人太执着于事物的表象,举三反一都不会,说的不对的地方请多多指教
        dongjunkun:@Sheepy 不要执着于鼠标,这里代之两个不同的对象,对面向对象的理解会直接导致程序员对代码的看法,其实也不必执着于代码,代码只是将程序员的想法表现出来,若他思维局限,写出来的代码想必也不会有很好的扩展性和可维护性
        Sheepy:@dongjunkun 你说如果一个程序员看到两个不同型号的鼠标就认为是两个不同的东西的话,他就不具备面向对象思维……这不科学啊-_-||这简直是哲学问题不是技术问题,具不具备面向对象思维,还是要看他代码的……
        Sheepy:@dongjunkun 嗯,很赞同抽象的重要性,其实我甚至觉得编程的核心就是抽象。不过对于你举的例子么,不同型号的鼠标确实不是同一个东西吧,至少不是同一个“对象”。你的意思应该是说它们是同一个类的不同实例,有很多相同的特性,只是“型号”这个属性的值不同罢了,是吧?可能我太咬文嚼字了,而且突然发现一本正经地用各种隐喻讨论面向对象有点羞耻啊……隐喻毕竟是个用来引导初学者的东西。
      • 曾樑:不能都照搬一种固定模式
        Sheepy:@曾樑 你是说不能每次都用一种编程范式么?说实话么我觉得如果真能用好的话,搬就搬了,主要有的人只是人云亦云跟着喊喊口号,明明自己根本不了解也用不好就到处吹嘘这种范式多好多好。早年我也是这样,但这样不好。所以会想写点这方面的东西,既是梳理一下自己现在的理解,也是想触发一些新人朋友的思考。

      本文标题:漫谈面向对象

      本文链接:https://www.haomeiwen.com/subject/tdsyhttx.html