编程范式-面向对象编程

本系列文章会依次介绍三个主要的编程范式,它们分别是结构化编程(structured programming)、面向对象编程(object-oriented programming)以及函数式编程(functional programming),本文聚焦在面向对象编程。


什么是面向对象编程

我们一般都知道,设计一个优秀的软件,要基于面向对象设计,那首先要搞明白一个问题,什么是面向对象?

一种常见的回答是,“数据与函数的组合”,这种说法虽然被广为引用,但也不是那么贴切,因为它似乎暗示了o.f()与f(o)之间是有区别的,这显然不是事实。面向对象理论是在1966年提出的,当时Dahl和Nygaard主要是将函数调用栈迁移到了堆区域中。数据结构被用作函数的调用参数这件事情远比这发生的时间更早。

一种常用的回答是,”面向对象编程是一种对真实世界建模的方式“,这种回答也只能是避重就轻,对真实世界建模到底是如何进行的?我们为什么要这么多,有什么好处?也许会继续说道,”由于采用面向对象方式构建的软件与真实世界的关系更为紧密,所以面向对象编程可以使软件开发更为容易“,即使这样说,也逃避了一个关键的问题---面向对象编程究竟是什么?

还有一种回答是,通过”封装”、"继承”、"多态”三个特性的有机组合来实现对真实世界的建模,这种回答看起来回答了面向对象究竟是什么,那我们来仔细分析下这些特殊是否是面向对象设计特有的?

面向对象特性

特性1: 封装

通过封装特性,我们把一组相关联的数据+函数打包起来,封装成一个整体,使圈外的代码只能看见函数,数据则完全不可见,比如实际应用中的类中的公共函数和私有成员变量就是这样。

但是这个特性并不是面向对象所特有的,比如C语言也支持完整的封装,来看看一个C语言的常见例子:


在C语言中,使用point.h的程序是没有point结构体成员的访问权限的,他们只知道调用makepoint函数和distinct函数。

在C语言.h头文件中只申明结构体和函数,结构体的内部细节,以及函数的实现方式完全是不可见的。这正是我们所说的完美封装性,虽然c语言大家都认为他是非面向对象的编程语言。

再来看看,大家都认为是面向的对象的语言C++语言,他反而破坏了C的完美封装性。


由于一些技术原因,C++编译器要求类的成员变量必须在类的头文件中进行声明,这样point.h的使用者就知道了point的成员变量x和y了,虽然编译器会对上面两个private变量禁止直接访问,但使用者人任然知道他的存在,如果x和y两个变量名称变了,point.cc也必须要重新编译才能运行,这样的封装性是不完美的。

当然C++的语言层面也引入了public、protect、private等关键词部分维护了封装性,但这些都是为了解决编译器自身的问题而引入的hack-编译器必须在头文件中看到成员变量的定义

进一步看java和c#,则彻底抛弃了头文件与实现的分离的的编程方式,这其实是进一步削弱了封装性,因为在这些语言中我们是无法区分一个类的定义和声明的。


由于上述分析,我们很难说强封装是面向对象编程的必要条件,很多面向对象的编程语言反而对封装没有强制的要求,面向对象编程上面确实要求程序员尽量不要破坏数据的封装性。

结论: 由于面向对象编程语言为我们实现数据和函数封装提供了有效方便的支持,导致封装这个特性和概念经常被引用为面向对象编程定义的一部分,然而这个特性并不是面向对象编程所特有的,反而面向对象编程一定程度上破坏了完美封装性。


特性2: 继承

简而言之,继承的主要作用是让我们可以在某个作用域内对外部定义的某一组变量与函数进行覆盖。

这其实在C语言时代已经出现被广泛使用了,具体来看point.h的扩展版本namedPoint.h


这里NamedPoint数据结构是被当作Point数据结构的一个衍生体来使用的。之所以可以这样做,是因为NamedPoint结构体的前两个成员的顺序与Point结构体的完全一致。简单来说,NamedPoint之所以可以被伪装成Point来使用,是因为NamedPoint是Point结构体的一个超集,同时两者共同成员的顺序也是一样的。


因此,我们可以说,早在面向对象编程语言被发明之前,对继承性的支持就已经存在很久了。当然了,这种支持用了一些投机取巧的手段,并不像如今的继承这样便利易用,而且,多重继承(multiple inheritance)如果还想用这种方法来实现,就更难了。同时应该注意的是,在main.c中,程序员必须强制将NamedPoint的参数类型转换为Point,而在真正的面向对象编程语言中,这种类型的向上转换通常应该是隐性的。


综上所述,我们可以认为,虽然面向对象编程在继承性方面并没有开创出新,但是的确在数据结构的伪装性上提供了相当程度的便利性。


特性3: 多态

多态的核心本质是通过分离做什么和怎么做来达到灵活编程的目的,主要有三个核心条件:

  1. 类之间要有继承关系
  2. 子类要重写父类的方法
  3. 父类引用指向子类对象

在面向对象编程语言出现之前,我们所使用的编程语言支持多态吗,答案是肯定的,看下面的代码

函数getchar()主要从STDIN里面读取数据和putchar()主要将数据写入STDOUT,那STDIN和STDOUT究竟指代的是什么呢,很显然这类函数就是多态了,都依赖具体的类型。这里的STDIN和STDOUT类似于java中的接口,各种设备都有自己的实现,那没有接口的C语言中是如何实现这个概念的,getchar这个动作如何投递到具体的设备驱动中呢,从而读取到内容的? 答案是函数指针,具体来看。

Unix系统要求每个IO设备都要提供open、close、read、write、seek五个标准函数。也就是每个IO设备驱动程序都对这5种函数实现在函数调用上保持一致。

底层操作操作系统提供了一个FILE结构体,包含了相应的5个函数指针,分别指向这些函数。

然后,控制台设备的IO驱动程序就会提供这五个函数的实际定义,将FILE结构体的函数指针指向这些对应的实现函数:


现在,如果STDIN的定义是FILE*,并同时指向了console这个数据结构,那么getchar的实现就是

核心就是,getchar()只是调用了STDIN所指向的FILE数据结构体中的read函数指针指向的函数。

又比如C++中,类中的每个虚函数(virtual function)的地址都被记录在一个vtable的数据结构里,我们对虚函数的每次调用都是先查询这个vtable,其衍生类的构造函数负责将改衍生类的虚函数地址加载到整个对象的vtable中。

如果在一个基类中,有函数被关键词virtual进行修饰, 那么一个虚函数表就会被自动构建起来去保存这个类中虚函数的地址。同时编译器会为这个类的所有对象添加一个隐藏指针vptr指向虚函数表。

如果在派生类中没有重写虚函数, 那么派生类中虚表存储的是父类虚函数的地址;相反地,如果在派生类中重写父类的虚函数,派生类的虚表中将覆盖父类在该虚函数的地址。

每当虚函数被调用时, 虚表会决定具体去调用哪个函数。因此,C++中的动态绑定是通过虚函数表机制进行的。当我们用基类指针指向派生类时,虚表指针vptr指向派生类的虚函数表。 这个机制可以保证派生类中的虚函数被调用到。


这正是面向对象编程中多态的基础,多态其实不过就是函数指针的一种应用。

自从20世纪40年代末期冯诺依曼架构诞生的那天起,程序员们就一直用函数指针模拟多态了,也就是面向对象编程在多态编程方面没有提出任何新的概念。

但用函数指针显示实现多态最大的问题就在于函数指针的危险性。毕竟,函数指针的调用依赖于一系列需要人为遵守的约定。程序员必须严格按照固定的约定来初始化函数指针,并同样严格地按照约定来调用这些指针,只要不遵守这些约定,整个程序就会产生极其难以跟踪和消除的bug

业界一直有一句话:指针已经很危险了,函数指针那就更不可控了。

面向对象编程语言为我们消除了这些风险性,提供了非常多的机制和策略,尽量让程序员原理底层和约定,让多态实现变得非常简单。

总结:虽然面向对象编程语言在多态上没有理论创新,但他让多态变得更加的安全和便于使用了

多态的优势

灵活的插件式架构

再来看刚才的copy程序,如果要新增一种IO设备,当前的Copy程序是不需要做任何更改的,甚至完全不需要重新编译该源代码。



因为copy程序的源代码并不依赖于IO设备驱动程序的具体代码。只要IO设备驱动程序实现了FILE结构体中定义的5个标准函数,该copy程序就可以正常使用它们。简单来说,IO设备变成了copy程序的插件。



为什么UNIX操作系统会将IO设备设计成插件形式呢?因为自20世纪50年代末期以来,我们学到了一个重要经验:程序应该与设备无关。这个经验从何而来呢?因为一度所有程序都是设备相关的,但是后来我们发现自己其实真正需要的是在不同的设备上实现同样的功能。例如,我们曾经写过一些程序,需要从卡片盒中的打孔卡片读取数据,同时要通过在新的卡片上打孔来输出数据。后来,客户不再使用打孔卡片,而开始使用磁带卷了。这就给我们带来了很多麻烦,很多程序都需要重写。于是我们就会想,如果这段程序可以同时操作打孔卡片和磁带那该多好。

插件式架构就是为了支持这种IO不相关性而发明的,它几乎在随后的所有操作系统中都有应用。但即使多态有如此多优点,大部分程序员还是没有将插件特性引入他们自己的程序中,因为函数指针实在是太危险了。而面向对象编程的出现使得这种插件式架构可以在任何地方被安全地使用。


依赖反转下独立开发

如下图所示,main函数调用一些高层函数,高层函数又调用一些中层函数,中层函数又调用一些底层函数,这里源代码层面的依赖不可避免的要跟随程序的控制流。

main函数如果要调用高层函数,不可避免的需要依赖高层函数,需要看到和依赖函数所属的模块,在C中,我们会通过#include来实现,在Java中则通过import来实现,而在C#中则用的是using语句。总之,每个函数的调用方都必须要引用被调用方所在的模块。

显然,这样做就导致了我们在软件架构上别无选择。在这里,系统行为决定了控制流,而控制流则决定了源代码依赖关系。但一旦我们使用了多态,情况就不一样了。如果将上面程序进行重构,如下所示,可见



模块HL1调用了ML1模块中的F()函数,这里的调用是通过源代码级别的接口来实现的。当然在程序实际运行时,接口这个概念是不存在的,HL1会调用ML1中的F()函数。请注意模块ML1和接口I在源代码上的依赖关系(或者叫继承关系),该关系的方向和控制流正好是相反的,我们称之为依赖反转。这种反转对软件架构设计的影响是非常大的。

通过利用面向编程语言所提供的这种安全便利的多态实现,无论我们面对怎样的源代码级别的依赖关系,都可以将其反转。

通过这种方法,软件架构师可以完全控制采用了面向对象这种编程方式的系统中所有的源代码依赖关系,而不再受到系统控制流的限制。不管哪个模块调用或者被调用,软件架构师都可以随意更改源代码依赖关系。这就是面向对象编程的好处,同时也是面向对象编程这种范式的核心本质——至少对一个软件架构师来说是这样的。

在面向对象编程领域中,依赖反转原则(Dependency inversion principle,DIP)是指一种特定的解耦(传统的依赖关系创建在高层次上,而具体的策略设置则应用在低层次的模块上)形式,使得高层次的模块不依赖于低层次的模块的实现细节,依赖关系被颠倒(反转),从而使得低层次模块依赖于高层次模块的需求抽象。

该原则规定:


这种能力有什么用呢?在下面的例子中,我们可以用它来让数据库模块和用户界面模块都依赖于业务逻辑模块而非相反

数据库和用户界面都依赖于业务逻辑这意味着我们让用户界面和数据库都成为业务逻辑的插件。也就是说,业务逻辑模块的源代码不需要引入用户界面和数据库这两个模块。

这样一来,业务逻辑、用户界面以及数据库就可以被编译成三个独立的组件或者部署单元(例如jar文件、DLL文件、Gem文件等)了,这些组件或者部署单元的依赖关系与源代码的依赖关系是一致的,业务逻辑组件也不会依赖于用户界面和数据库这两个组件。

于是,业务逻辑组件就可以独立于用户界面和数据库来进行部署了,我们对用户界面或者数据库的修改将不会对业务逻辑产生任何影响,这些组件都可以被分别、独立地部署。简单来说,当某个组件的源代码需要修改时,仅仅需要重新部署该组件,不需要更改其他组件,这就是独立部署能力。

如果系统中的所有组件都可以独立部署,那它们就可以由不同的团队并行开发,这就是所谓的独立开发能力。

架构视角的面向对象


面向对象编程到底是什么?业界在这个问题上存在着很多不同的说法和意见。然而对一个软件架构师来说,其含义应该是非常明确的:面向对象编程就是以多态为手段来对源代码中的依赖关系进行控制的能力,这种能力让软件架构师可以构建出某种插件式架构,让高层策略性组件与底层实现性组件相分离,底层组件可以被编译成插件,实现独立于高层组件的开发和部署。


相比于结构化编程对程序控制权的直接转移GOTO进行了限制和规范。

面向对象编程对程序控制权的间接转移(函数指针)进行了限制和规范。

展开阅读全文

页面更新:2024-05-14

标签:范式   数据结构   指针   源代码   函数   组件   模块   特性   语言   关系   程序

1 2 3 4 5

上滑加载更多 ↓
推荐阅读:
友情链接:
更多:

本站资料均由网友自行发布提供,仅用于学习交流。如有版权问题,请与我联系,QQ:4156828  

© CopyRight 2008-2024 All Rights Reserved. Powered By bs178.com 闽ICP备11008920号-3
闽公网安备35020302034844号

Top