设计原则与思想:面向对象 I

261 阅读14分钟

一、面向对象编程解决了哪些问题?

理解面向对象编程及面向对象编程语言的关键就是理解其四大特性:封装、抽象、继承和多态。光了解定义是不够的,还要知道每个特性存在的意义和目的,以及它们能够解决哪些编程问题。

1. 封装(Encapsulation)

1.1 封装的定义

封装也叫作信息隐藏或者数据访问保护,通过暴露有限的访问接口,授权外部仅能通过类提供的方式(或函数)来访问内部信息或者数据。

对于封装特性,需要编程语言本身提供一定的语法机制来支持。这个语法极致就是访问权限控制,比如Golang是通过首字母大小写判断是否对其他包可见。

1.2 封装的意义

如果对类中属性的访问不做限制,那任何代码都可以访问、修改类中的属性,虽然这样看起来更加灵活;但从另一方面来说,过度灵活也意味着不可控,属性可以随意被以各种奇葩的方式修改,而且修改逻辑散落在代码的各个角落,影响了代码的可读性、可维护性。

类仅仅通过有限的方法暴露必要的操作,也能提高类的易用性。如果将类属性都暴露给类的调用者,调用者想要正确地操作这些属性,就势必要对业务细节有足够的了解,这对调用者来说也是一种负担。相反,将属性封装起来,暴露少许的几个必要方法给调用者,调用者就不需要了解太多背后的业务细节,用错的概率就减少很多。(好比安卓的 3-4 个按键 VS iphone 的一个 home 键)

2. 抽象(Abstraction)

2.1 抽象的定义

抽象讲的是如何隐藏方法的具体实现,让调用者只需要关心方法提供了哪些功能,并不需要知道这些功能是如何实现的。

在面向对象编程中,常借助编程语言提供的接口(interface关键字)或者抽象类(Java提供的abstract关键字语法)这两种语法机制,来实现抽象这一特性。

抽象这个特性是非常容易实现的,并不需要非得依赖接口或抽象类这些特殊语法机制来支持。并不是说一定要为实现类抽象出接口,才叫作抽象。因为,类的方法是通过编程语言中的“函数”这一语法机制来实现的。通过函数包裹具体的实现逻辑,本身就是一种抽象。调用者在使用函数的时候,并不需要去研究函数内部的实现逻辑,只需要通过函数的命名、注释或者文档,了解其提供了什么功能,就可以直接使用了。

抽象这个概念是一个非常通用的设计思想,并不单单用在面向对象编程中,也可以用来指导架构设计等。这个特性也并不需要编程语言提供特殊的语法机制来支持,只需要提供“函数”这一非常基础的语法机制,就可以实现抽象特性。因此,有时候并不被看作面向对象编程的特性之一。

2.2 抽象的意义

面对复杂系统的时候,人脑能承受的信息复杂程度是有限的,所以必须忽略掉一些非关键性的实现细节。抽象作为一种只关注功能点而不关注实现的设计思路,正好帮我们的大脑过滤掉许多非必要的信息。

抽象作为一个非常宽泛的设计思想,在代码设计中,起到非常重要的指导作用。比如基于接口而非实现编程、开闭原则(对外扩展开放、对修改关闭)、代码解耦(降低代码的耦合性)等。

换一个角度,在定义(或者是命名)类的方法的时候,也要有抽象思维,不要在方法定义中,暴露太多的实现细节,以保证在某个时间点需要改变方法的实现逻辑的时候,不用去修改其定义。

3. 继承(Inheritance)

3.1 继承的定义

继承是用来表示类之间的 is-a 关系,从继承关系上来讲,继承可以分为两种模式,单继承和多继承。单继承表示一个子类只继承一个父类,多继承表示一个子类可以继承多个父类。

为了实现继承这个特性,编程语言需要提供特殊的语法机制来支持,比如 Java 使用 extends 关键字来实现继承,有些语言只支持单继承,不支持多重继承,而有些语言支持多重继承。

3.2 继承的意义

继承最大的一个好处就是代码复用。假如两个类有一些相同的属性和方法,我们就可以将这些相同的部分,抽取到父类中,让两个子类继承父类。这样,两个子类就可以重用父类中的代码,避免代码重复写多遍。但这一点也不是继承所独有的,也可以通过其他方式来解决这个代码复用的问题,比如利用组合关系而不是继承关系。

两个类是 is-a 关系,使用继承关联这两个类,反应真实世界中的这种关系,非常符合人类的认知,而且,从设计的角度来说,也有一种结构美感。

过度使用继承,继承层次过深过复杂,就会导致代码可读性、可维护性变差。为了了解一个类的功能,不仅仅要查看这个类的代码,还需要根据继承关系一层一层地往上查看“父类、父类的父类……”的代码。还有,子类和父类高度耦合,修改父类的代码,会直接影响到子类。

所以,继承这个特性是一个非常有争议的特性(Golang 没有继承语法机制)。有人觉得继承是一种反模式,应该尽量少用,甚至不用。“多用组合少用继承”也是是一种设计思想。

4. 多态(Polymorphism)

4.1 多态的定义

多态是指,之类可以替换父类在实际的代码运行过程中,调用子类的方法实现

多态这种机制也需要编程语言提供特殊的语法机制来实现。常见的就是“继承加方法重写”这种实现方式。此外,还有两种比较常见的实现方式,一个是利用接口语法,另一个是利用 duck-typing 语法(Golang 支持)。

4.2 多态的意义

多态特性能提高代码的可拓展性和复用性。多态也是很多设计模式、设计原则、编程技巧的代码实现基础。例如:策略模式、基于接口而非实现编程、依赖倒置原则、里氏替换原则等。

继承是在做共性抽取的事情;多态是在解决差异性的事情。

二、面向对象 VS 面向过程

1. 什么是面向过程编程与面向过程编程语言?

  • 面向对象编程是一种编程范式或编程风格。它以类或对象作为组织代码的基本单元,将封装、抽象、继承和多态四个特性,作为代码设计和实现的基石。

  • 面向对象编程语言是支持类或对象的语法机制,能方便地实现面向对象编程四大特性的编程语言。

  • 面向过程编程是一种编程范式或编程风格。它以过程(不妨理解为方法、函数、操作)作为组织代码的基本单元,以数据(成员变量、属性)与方法相分离为最主要的特点。面向过程风格是一种流程化的编程风格,通过拼接一组顺序执行的方法来操作数据完成一项功能。

  • 面向过程编程语言首先是一种编程语言。它最大的特点是不支持类和对象两个语法概念,不支持丰富的面向对象编程特性(封装、继承、多态),仅支持面向过程变成。

小结

面向过程和面向对象最基本的区别就是,代码的组织方式不同。面向过程风格的代码被组织成了一组方法集合及其数据结构,方法和数据结构的定义是分开的。面向对象风格的代码被组织成一组类,方法和数据结构被绑定一起,定义在类中。

2. 面向对象编程相比面向过程编程有哪些优势?

2.1 OOP 更加能够应对大规模复杂程序的开发

需求足够简单,整个程序的处理流程只有一条主线,这就非常适合采用面向过程这种面条式的编程风格来实现。

对于大规模复杂程序的开发来说,整个程序的处理流程错综复杂,并非只有一条主线。使用面向过程编程这种流程化、线性的思维方式,去翻译这个网状结构,就会很吃力,面向对象的编程风格的优势就比较明显了。

面向对象编程是以类为思考对象,先去思考如何给业务建模,如何将需求翻译成类,如何给类之间建立交互关系,完成这些工作完全不需要考虑错综复杂的处理流程,有了类的设计之后,然后再像搭积木一样,按照处理流程,将类组装起来形成整个程序。

此外,面向对象编程还提供了一种更加清晰的、更加模块化的代码组织方式。类是一种非常好的组织函数和数据结构的方式,是一种将代码模块化的有效手段。

2.2 OOP 风格的代码更易复用、易拓展、易维护

  • 从封装特性理解

面向对象编程通过类这种组织代码的方式,将数据和方法绑定在一起,通过访问权限控制,只允许外部调用者通过类暴露的有限方法访问数据,而不会像面向过程编程那样,数据可以被任意方法随意修改。

  • 从抽象特性理解 函数本身就是一种抽象,隐藏了具体的实现。面向对象编程还提供了其他抽象特性的实现方式,比如基于接口实现的抽象,在不改变原有实现的情况下,轻松替换新的实现逻辑,提高了代码的可拓展性。

  • 从继承特性理解 如果两个类有一些相同的属性和方法,可以将这些相同的代码,抽取到父类中,让两个子类继承父类。子类也可以重用父类中的代码,避免了代码重复写多遍,提高了代码的复用性。

  • 从多态特性理解 在需要修改一个功能实现的时候,可以通过实现一个新的子类的方式,在子类中重写原来的功能逻辑,用子类替换父类。在代码运行过程中,调用子类新的功能逻辑,而不是在原有代码上做修改。遵从了“对修改关闭,对拓展开放”的设计原则,提高代码的拓展性。

3. 为什么说面向对象编程语言比面向过程编程语言更高级?

面向对象是一种人类的思维方式,使用二进制指令、汇编语言和面向过程语言的时候,我们是在思考,如何设计一组指令,告诉机器去执行这组指令,操作某些数据,帮助我们完成某个任。面向对象编程,我们是在思考如何给业务建模,如何将真实的世界影射为类或者对象,聚焦到业务本身,而不是思考如何和机器打交道。

4. 有哪些看似是面向对象实际是面向过程风格的代码?

  • 滥用 getter, setter 方法 暴露不应该暴露的 setter 方法,明显违反了面向对象的封装特性。数据没有访问权限控制,任何代码都可以随意修改它,代码就退化成了面向过程编程风格的了。

尽管 getter 方法相对 setter 方法要安全些,但如果返回的是引用类型(比如 Golang 的指针、slice、map等),也要防范集合内部数据被修改的危险

  • 滥用全局变量 将程序中所有用到的常量,都集中地放到一个 Constants 类中,不是一种很好的设计思路 :
  1. 影响代码的可维护性

    Constants 类只会越变越大,代码行数越来越多,代码冲突概率也在增加。

  2. 增加代码的编译时间

    Constants 类越变越大,依赖这个类的代码只会更多。Constants 类一发生变化,所有依赖这个类的文件需要重新编译,在大项目中,编程时间还是挺长的,而且在开发过程中,经常需要运行单元测试,都会触发重新编译。

  3. 影响代码的复用性 在另一个项目中,复用本项目开发的某个类,而这个类又依赖 Constants 类,即便是只依赖 Constants 类中的一小部分常量,也要将 Constants 这个类一并引入。

改进方法:

  1. Constants 类拆解为功能更加单一的多个类,例如:MySQLContstants, ReisConstants。
  2. 并不单独设计 Contstants 常量类,而是哪个类用到了某个常量,就把这个常量定义到这个类中。

如果某一个常量被引用的特别多,那还是建议使用方法 1,一次修改,全部生效;反过来,只是很少地方使用,使用方法 2,提高类的内聚性和复用性。

  • 滥用全局方法 最常见的就是 utils 类或包了,这是彻彻底底面向过程的编程风格,但也不是说,要杜绝 utils 类或包的使用了。utils 可以解决代码复用问题,但要避免滥用。

设计 utils 的时候,最好也能细化一下,针对不同的功能,设计不同的 utils 类或包,例如:FileUtils, IOUtils, StringUtils 等等。不要设计一个过于大而全的 Utils.

  • 定义数据和方法分离的类 数据定义在一个类中,方法定义在另一个类中,最明显的例子:基于 MVC 三层架构做 Web 方面的后端开发。这种开发模式叫做基于贫血模型的开发模式。

贫血模型流行的原因:实现简单和上手快。

贫血模型(Anemic Domain Model由 MatinFowler提出)又称为失血模型,是指domain object仅有属性的getter/setter方法的纯数据类,将所有类的行为放到service层。

5. 在面向对象编程中,为什么容易写出面向过程风格的代码?

面向过程编程风格恰恰符合人的流程化思维方式。面向对象编程风格是一种自底向上的思考方式,不是先去按照执行流程来分解任务,而是讲任务翻译成一个个小模块,设计类之间的交互,最后按照流程将类组装起来,完成整个任务。

面向对象编程要比面向过程编程难一些。在面向对象编程中,类的设计还是挺需要技巧,挺需要设计经验。需要思考如何封装合适的数据和方法到一个类里,如何设计类之间的关系,如何设计类之间的交互等诸多设计问题。