详细解释“基于对象”

发表于2016-09-08
评论2 4k浏览

“基于对象”的特点

  什么是“基于”对象呢?就是关注“对象之间”的关系,而不是关注对象和类的关系。“面向对象编程”(OOP)的概念已经诞生了很多年,在业界可谓深入人心。像著名的编程言C++/JAVA/C#都是按照这个概念去设计的。但是面向对象编程概念,在实践中,也受到了大量的挑战,很多人认为面向对象编程有很多缺点,其中就不乏重量级的人物如Linux的作者。

  在所有的挑战和质疑之中,大部分都是指向面向对象编程的复杂性的。面向对象编程的三个特点封装、继承、多态,都要比单纯的结构化编程,让使用者学习更多的关键字,理解更多的隐喻,遵守更多不成文的规范,这确实是提高了编程的复杂度。由于面向对象编程的设计目标,就是要应对复杂的业务需求,所以大部分的概念和设置,都无可避免的带上了试图解决灵活度的问题,而在灵活性和本身复杂度之间,要取得平衡确实不容易。

 


面向对象编程的复杂性

 

  但是,在现代的编程语言发展至今,有一个明显的趋势,就是动态化和脚本化。我们可以发现最新兴起的语言,绝大多数都脚本语言,比如Ruby/Lua/Python,而C++语言的新规范,也更多的倾向动态类型推断和lamda表达式(动态函数)。所以面向对象编程概念的发展,也进入了一个更动态化,更脚本化的新时代——基于对象。

  现在应用最广的基于对象的语言,应该是JavaScriptLua。其中JS语言的发展尤其快,已经从浏览器脚本,发展成一门通用的脚本语言,通过node.js框架在服务器端也占据了一席之地,并且随之HTML5在手机端的流行,JS更是成为了前端编程的必备武器。

  要了解什么是基于对象,我们可以和传统的面向对象编程三特征:封装、继承、多态,来做对比,获得最直观的印象。

 

封装

  在面向对象编程经典概念中,封装的含义是,把函数和属性,都封装在一个叫“类”的盒子里面,然后我们通过实例化“类”得到“对象”,通过“对象”来实现我们的业务逻辑。

  在我们的观念中,函数和变量,是构成代码的两个基本概念,而“类”则是一个用来封装这两个基本概念的“新概念”。反而“对象”很好理解,就是一个自定义数据类型的变量而已。“类”是比较静态的概念,是运行时的“对象”的模板。

  一般来说,“类”的定义是编译时就固定了的,所以“对象”在运行期的行为和属性,其结构也是固定在“类”这个框框里面的。“类”就好像图纸,而“对象”就好像照图纸建造的房子,为了获得复杂的房子,我们的图纸必定会变得很复杂,因为图纸必须包含全部的建筑信息,而不能在房子建成后再修改其结构。


 
类是图纸,对象是房子

 

  然而,在基于对象的概念里,有两个本质上的区别:

  第一个区别是:只有“对象”而没有“类”的概念,所以也没有各种“类型”的变量。所有的对象,都统一是“Object”类型。所以如果你要新建一个“对象”,是无需指定类型的,只要好像新建一个int变量一样就好了,也就是没有所谓“实例化”的过程。

  当然这样新建的对象是一个空白的对象,没有任何功能。由于“基于对象”里的“对象”,基本上都是可以“动态”变化内容的(就是可以运行时添加成员),所以我们创建出空白对象之后,就会“动态的”(运行时)用代码给它添加各种成员属性,从而让它变得有用起来。

  第二个区别是:函数(方法)也是变量function关键字就是函数变量的类型。这意味着,函数和变量的概念统一起来了。函数也可以像变量一样,被赋值、被用作参数、被作为对象的成员所携带。由于函数也是变量,所以“对象”就没有必要把自己的成员分为“方法”和“属性”两种,统统都可以看成是“成员属性”即可。想要类似以前的“类”上携带“方法”的效果,只需要简单的为“对象”添加一个函数类型的成员变量即可。

  使用基于对象的模型编程,我们会用动态的组装对象(初始化或赋值),为对象安插上数据变量和函数变量作为成员,代替“方法”和“属性”的作用。这个过程取代了先定义“类”,然后“实例化”对象的做法。当然这种简化也会带来其它问题,我们后面会说。



 初始化变量时构建对象。

 

  这两个区别,全面的简化了传统面向对象编程中概念的数量。因为我们不再需要构建复杂的自定义“类型”系统,没有了“类”(class)关键字,也没有和类相关的“方法”和普通函数差异的概念。这无疑大大减轻了学习的负担,同时也大大增加了运行时的灵活性。


灵活性:在使用时组装

 

继承

  传统面向对象编程中,“继承”这个特性是一个大杀器。威力巨大,副作用也巨大,据说现在都流行不要用这个特性了——说继承会破坏“封装”,让父类的信息被泄露到子类去。而且有的语言(C++程序员请冷静)支持多重继承,这更是让程序变得超级复杂。不过,从本源来说,继承是为了把一种对象的模板(父类),复制到另外一种对象的模板(子类)里的技术。

  由于两种模板的定义可能是随心所欲的,所以要真正的能让它们结合的既好用,又不会出错,其实是挺困难的。所以我们为继承的特性创造出一大堆的关键字,比如virtual, overwrite,  super然并卵的是,这些关键字往往除了让语言级别考试作为题目以外,实际运用中往往没有特别大的作用。程序员们一般都希望能以最简单(这样不容易出错)的某个惯例来编写代码,有些精心设置的特性几乎从来不会使用(比如操作符重载,C++程序员请继续冷静)。

  基于对象的语言,在继承上的实现,就和以前的概念有很大不同。基于对象的继承,是根据一种叫原型链的方法来实现的。意思就是,父类和子类的关系,并非是“类”定义的关系,而是对象之间的关系。因为根本没有“类”这个东西,所以父类的特性,是由一个父类地位的对象所携带,然后作为一个特殊成员“原型”(也就是一个prototype的属性),被子类对象所引用的。当然父类对象还可以自己再链接着另外一个父类对象,这样就形成了一条对象的链。

 


JS原型链

 

  在运行的时候,如果调用一个对象任何方法或者成员,是找不到的时候,就会顺着这条原型链挨个查找,看看链上的对象有没有对应的方法和成员属性,如果找到了就访问它。这样就实现了子类对象拥有父类对象的能力。虽然这样做多少有点简单粗暴,但是足够清晰。

  原型链这种继承方式,在使用上和传统的继承差别似乎不大,但是它使用的是一种“默认委托”来实现。从本质上来说,子对象和其原型对象实际上通过组合,而不是严格意义的“继承”来结合的,这正是很多面向对象编程规范苦口婆心教育大家的“尽量用组合,少用继承”。

  需要注意的是,原型链下的继承方法,如果里面有用到this关键字,指的是那个方法所对应的对象,也就是有可能是原型链上的原型对象,而不是最终的子对象。也就是说,在“父类”方法中的this,不是多态的,不能代表最终的“子类”对象。

 


两个对象组合实现继承

 

  用原型链来实现继承,从性能上来说是比不上传统的继承的,因为有可能要遍历原型链上的所有对象。从理解上来说,其实也显得没必要的复杂。因为,如果对象都是动态的,如果我需要某个对象的能力,可以直接把那个对象的成员方法拆下来,装在自己身上。甚至可以动态的拆装多个所需对象的结构,组合成一个独特的新对象。——这些工作完全不需要修改类定义文件,不需要编译,而仅仅是写几行脚本即可。

 

多态

  基于对象的编程方案,其实最有价值的,最能提升开发效率的,就在于对“多态”的增强。

 


多态常用于接口

 

  我们如果Java编程,由于函数不可以脱离类存在,如果使用某些框架来开发,你会发现你要实现大量的接口。每个接口或多或少的几个方法,可能会让你自己的类变得面目全非。这些需要实现的接口,本身的命名和语义,都是按照框架本身的含义来设计的,而你自己的类,一般和框架的整个语境不同,强迫你的类加入一些奇怪的函数,确实是严重的“污染”了整个代码的可读性。

  如果你想每个接口都单独实现一个类,那么你马上就会陷入“类爆炸”——你的项目中有大量的类型,而绝大多数其实都是为了接口而制造的。对于代码维护者来说,看着这么大堆的代码就头晕。所以有人说Java编程就是在表演一套复杂的仪式,来完成一个很简单的操作。其中就有接口-实现类的“功劳”。

  对于基于对象的语言来说,接口其实什么都不是,因为函数本身是一种类型,所以函数这种类型,本身就是一个“超级接口”。所有的回调、事件都可以绑定到任何函数上。函数作为一个变量,也可以作为其他函数的参数传来传去。这样你就再也不需要定义任何形式的函数接口了,你只需要用fun()的写法,把一个变量当作函数调用即可。因为没有需要绑定的接口,所以也避免了因为大量的回调接口造成的“类爆炸”。而且这种做法写起来也很简单,如果你想让你的代码使用一个函数参数,直接声明一个就好,不需要去表演一整套的“定义接口——完成实现——多态调用”的过程,这极大的提升了开发的效率。

  我们在jquerynode.js以及很多AJAX异步框架中,能明显的体验到基于对象对‘多态’优化带来的好处。这一类框架,由于是针对异步操作程序的,所以回调函数是最常见的东西。整个业务流程,都是由大量的回调函数“串接”起来的。如果没有函数变量类型,将要有无数个接口类型需要声明。

 

 

  虽然基于对象有节省大量“声明、定义”的优点,但这个反过来说也可能是个缺点。以为所有的函数变量都只有一个类型,因此你无法在编译器作任何函数形式的检查,也无法预防运行期发生不合适的函数变量被调用的问题。另外一个缺点,就是表现力(可读性)上面的,类型系统的好处是:类型本身就是一个自定义的语义,可以赋予良好的可读意义,但是一个简单的function类型,就没有任何可读语义。这对于比较复杂的系统,其实是挺大的问题,因为各种函数对象传来传去,如果没有类型信息用来协助理解,代码阅读起来会非常困难。

 

优秀的组合:组件化

  面向对象和基于对象,都有各自的优点和缺点,但是有没有可以兼顾两方面的方案呢?很多项目都在这个方面做了一些尝试,一般来说这类尝试都走向一种叫组件化的方向。

  所谓组件化,通常是指,把我们业务中常见的逻辑单元,都先抽象成一种叫“组件”的对象,不同的逻辑特性构成不同类型的“组件”。而这些组件对象,都是可以在运行时,遵循基于对象的原则,可以灵活的组合成真正需要的逻辑对象。

 


组件既是对象,也构成对象

 

  由于“组件”本身是一个接口(类),所以各种各样的“组件”还是有各自的面向对象的“类型信息”的,这就能利用上面向对象的静态检查、可读性等优点。

  而由于“组件”是可以灵活的组合的,所以避免了复杂的继承结构,而能提供千变万化的对象。这些对象变化恰恰是能很好的应对业务逻辑上的复杂变化的。为了让组合后的对象能简便的调用“组件”提供的能力,往往需要使用一些“反射”类的特性,用来让业务逻辑对象能直接“拥有”那些反射的方法和属性。这种做法虽然性能可能稍微低一点,但是却能得到非常大的灵活性。

  现在非常流行的游戏引擎UnityC#语言方案里面,就是一个组件化的编程的优秀实践。在Unity里面,所有在游戏中存在的东西,不管是可见还是不可见,都叫做GameObject,而所有这些GameObject通通都可以被放在一个容器Scene(场景)中。游戏就是由一个个的场景组成的,非常容易理解。GameObject在游戏中可以表示任何东西,这个就是“基于对象”中的对象。比如我们常见的有“摄像机”(没有它游戏就不会显示哦,而且一个场景里面可以有多个摄像机,这样可以做类似监控录像的功能),“地面”,“玩家”、“建筑物”、“子弹”等等,都是GameObject。在Unity的编程环境中,也对应的存在一个这样GameObject的类型(class)。然而,这个GameObject类型本身却没有多少功能(方法和属性),基本上都是一些创建、删除、查找子对象、查找组件的方法。而真正为这个GameObject提供能力的,是它所包含的各种各样的组件(Component

 


每个GameObject都带有多个Component

 

  一般一个在游戏里面的角色,都会包含如下一些组件:一个代表此对象在3D场景中位置和方向的Transform类型组件(Position位置、Rotation方向、Scale比例三个属性都由xyz三个float数表示);一个表示自己3D外观的MeshFilter组件;一个用于记录如何渲染自己的MeshRenderer组件;如果需要碰撞检测功能,还会带有RigidbodyBoxCollider组件;更重要的是,如果你想为这个角色带上一些自定义的功能,可以带上一个或者多个Script组件。如果你有多个游戏对象,都具有同样的行为,比如一大群各种各样的怪物,你完全可以只编写一个Script对象,然后加到所有这个怪物身上,他们就会具有一样的行为了。

  而一个Camera(摄像机)对象除了会带有Transform组件(你可以通过它来控制摄像机的位置和方向),还会有Camera组件控制景深、背景颜色等等,另外还会有GUI Layer组件用于显示UI面板工具栏,甚至还有Audio Listener组件用于播放声音(当然你可以同时附加一个Audio Source组件,这样组合起来就可以播放背景音乐了)。可以说几乎所有的游戏的功能,都是通过各种各样不同类型(class)的组件对象,通过组合的形式放在GameObject类型(class)的对象里实现的。



  所有这些组件,都会成为GameObject的一个属性存在,比如gameObject.transform 就能获得Transfrom组件,从而读取或修改GameObject对象的位置。而且这个组合的过程,你可以通过Unity的图形编辑器来实现,不需要编写一个真正的类定义的文件。这里就用到了一些动态反射的机制:访问一个属性的请求,自动转换成访问一个组件对象。

  GameObjectComponent都是可编程的类型(class),但是那些一个个由不同组件组合成的GameObject,如果想变成一个可复制的模板,则需要建立一个叫Prefab(预制件)的对象。这里的Prefab负责了“类型-继承”的能力。由于Unity不为每个GameObject建立独立的类型(class),所以我们无法通过一个类型(class)去实例化多个具备同样功能的对象(object);但是使用“基于对象”的概念,我们可以从一个对象模版(Prefab)去克隆(复制)出多个同样功能的对象: GameObject.Instantiate(Object original) ——我们只要传入一个Prefab对象,就能得到其复制品对象。而Prefab对象是无需存在于游戏中的场景里的,从某种意义上说,可以看成是“类”(class)的替代品。


 

  在游戏开发中,我们每天都要设计、修改大量不同特征的游戏对象,如果为了每个对象都去编写其类定义代码文件,将会是很枯燥和繁琐的事情,甚至于最后为了起类的名字都会想破头。但是如果我们用了基于对象的方法,我们可以专心于处理一个个游戏中独特的对象。对于那些大量重复的对象,同样可以使用复制的方法来控制。这对于快速开发游戏,应对大量不同的需求变更(游戏策划是最爱变更需求的人了),是非常有好处的。

  虽然每个GameObject都可以带有不同的Script(脚本)组件,但只要每个脚本有Update()方法,Unity就能在每次渲染时调用这个Update()方法。值得注意的是这个Update()方法并不是定义在Script组件类型MonoBehaviour里的,而完全只是一个单独的公开方法。Unity引擎会使用反射的方法来找到这个方法来调用。Unity使用反射而不是多态的方式来设计这个Update()接口的原因,实际上也是基于对象的思想:方法应该是动态的,是一个属性。当然啦,Unity本身除了C#,也支持JS脚本,在JS里面,根本就没有“虚方法”(继承得来的方法)这个概念。

  在Unity里面的C#编程方案里面,既存在传统的面向对象概念,如C#的类型系统,也使用了基于对象的架构:GameObject-Componet模型。因为有C#的类型概念,所以各种组件实际上是有自己的类型(class)的,这些类定义可以帮助使用者快速浏览和掌握引擎提供的各种方法和属性,并且提供足够的编译期检查。开发者自己的代码,也可以采用类型系统来编写严格定义的类,用来构建严谨和良好可读的复用代码。而那些变化非常频繁的代码和模型,则可以使用基于对象的GameObject-Componet的组件模型,灵活的组合出各种业务对象,降低开发成本。所以这种设计既能兼顾传统面向对象的优点,又能用上基于对象的好处,是一个非常值得学习的思路。

 

总结

  “基于对象”是“面向对象”一次动态化变迁,它依赖于现代语言的动态特性,让方法和属性统一起来;用组合取代继承;以函数对象查找取代多态的方法调用。这些变化让面向对象复杂的仪式化定义代码变得灵活轻巧,更加适合那些需求多变的业务领域。而组件化编程思想则把“面向对象”的严谨和代码可读性结合到“基于对象”上,从而兼顾两者的优点,从而渐渐成为现代编程理念的一个潮流。

  欢迎关注我的公众号:“韩大”(handa1740168)和我直接交流。


如社区发表内容存在侵权行为,您可以点击这里查看侵权投诉指引