一、面向对象编程解决了哪些问题?
理解面向对象编程及面向对象编程语言的关键就是理解其四大特性:封装、抽象、继承和多态。光了解定义是不够的,还要知道每个特性存在的意义和目的,以及它们能够解决哪些编程问题。
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 类中,不是一种很好的设计思路 :
-
影响代码的可维护性
Constants 类只会越变越大,代码行数越来越多,代码冲突概率也在增加。
-
增加代码的编译时间
Constants 类越变越大,依赖这个类的代码只会更多。Constants 类一发生变化,所有依赖这个类的文件需要重新编译,在大项目中,编程时间还是挺长的,而且在开发过程中,经常需要运行单元测试,都会触发重新编译。
-
影响代码的复用性 在另一个项目中,复用本项目开发的某个类,而这个类又依赖 Constants 类,即便是只依赖 Constants 类中的一小部分常量,也要将 Constants 这个类一并引入。
改进方法:
- Constants 类拆解为功能更加单一的多个类,例如:MySQLContstants, ReisConstants。
- 并不单独设计 Contstants 常量类,而是哪个类用到了某个常量,就把这个常量定义到这个类中。
如果某一个常量被引用的特别多,那还是建议使用方法 1,一次修改,全部生效;反过来,只是很少地方使用,使用方法 2,提高类的内聚性和复用性。
- 滥用全局方法 最常见的就是 utils 类或包了,这是彻彻底底面向过程的编程风格,但也不是说,要杜绝 utils 类或包的使用了。utils 可以解决代码复用问题,但要避免滥用。
设计 utils 的时候,最好也能细化一下,针对不同的功能,设计不同的 utils 类或包,例如:FileUtils, IOUtils, StringUtils 等等。不要设计一个过于大而全的 Utils.
- 定义数据和方法分离的类 数据定义在一个类中,方法定义在另一个类中,最明显的例子:基于 MVC 三层架构做 Web 方面的后端开发。这种开发模式叫做基于贫血模型的开发模式。
贫血模型流行的原因:实现简单和上手快。
贫血模型(Anemic Domain Model由 MatinFowler提出)又称为失血模型,是指domain object仅有属性的getter/setter方法的纯数据类,将所有类的行为放到service层。
5. 在面向对象编程中,为什么容易写出面向过程风格的代码?
面向过程编程风格恰恰符合人的流程化思维方式。面向对象编程风格是一种自底向上的思考方式,不是先去按照执行流程来分解任务,而是讲任务翻译成一个个小模块,设计类之间的交互,最后按照流程将类组装起来,完成整个任务。
面向对象编程要比面向过程编程难一些。在面向对象编程中,类的设计还是挺需要技巧,挺需要设计经验。需要思考如何封装合适的数据和方法到一个类里,如何设计类之间的关系,如何设计类之间的交互等诸多设计问题。