⾯向对象

编程范式

  1. ⾯向过程
  2. ⾯向对象
  3. 函数式编程

什么是⾯向对象编程?

⾯向对象编程的英⽂缩写是 OOP,全称是 Object Oriented Programming。对应地,⾯向对象编程语⾔的英⽂缩写是 OOPL,全称是 Object Oriented Programming Language。

⾯向对象编程中有两个⾮常重要、⾮常基础的概念,那就是类(class)和对象(object)。这两个概念最早出现在1960 年,在 Simula 这种编程语⾔中第⼀次使⽤。⽽⾯向对象编程这个概念第⼀次被使⽤是在 Smalltalk 这种编程语⾔中。Smalltalk 被认为是第⼀个真正意义上的⾯向对象编程语⾔。

如果⾮得给出⼀个定义的话,我觉得可以⽤下⾯两句话来概括

  • ⾯向对象编程是⼀种编程范式或编程⻛格。它以类或对象作为组织代码的基本单元,并将封装、抽象、继承、多态四个特性,作为代码设计和实现的基⽯ 。
  • ⾯向对象编程语⾔是⽀持类或对象的语法机制,并有现成的语法机制,能⽅便地实现⾯向对象编程四⼤特性(封装、抽象、继承、多态)的编程语⾔。

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

什么是⾯向对象编程语⾔?

⾯向对象编程语⾔是⽀持类或对象的语法机制,并有现成的语法机制,能⽅便地实现⾯向对象编程四⼤特性(封装、抽象、继承、多态)的编程语⾔。

如何判定⼀个编程语⾔是否是⾯向对象编程语⾔?

如果按照严格的的定义,需要有现成的语法⽀持类、对象、四⼤特性才能叫作⾯向对象编程语⾔。如果放宽要求的话,只要某种编程语⾔⽀持类、对象语法机制,那基本上就可以说这种编程语⾔是⾯向对象编程语⾔了,不⼀定⾮得要求具有所有的四⼤特性。

⾯向对象编程和⾯向对象编程语⾔之间有何关系?

⾯向对象编程⼀般使⽤⾯向对象编程语⾔来进⾏,但是,不⽤⾯向对象编程语⾔,我们照样可以进⾏⾯向对象编程。反过来讲,即便我们使⽤⾯向对象编程语⾔,写出来的代码也不⼀定是⾯向对象编程⻛格的,也有可能是⾯向过程编程⻛格的。

什么是⾯向对象分析和⾯向对象设计?

简单点讲,⾯向对象分析就是要搞清楚做什么,⾯向对象设计就是要搞清楚怎么做。两个阶段最终的产出是类的设计,包括程序被拆解为哪些类,每个类有哪些属性⽅法、类与类之间如何交互等等。

⾯向对象四⼤特性

封装(Encapsulation)

封装也叫作信息隐藏或者数据访问保护。类通过暴露有限的访问接⼝,授权外部仅能通过类提供的⽅式来访问内部信息或者数据。它需要编程语⾔提供权限访问控制语法来⽀持,例如 Java 中的 private、protected、public 关键字。封装特性存在的意义,⼀⽅⾯是保护数据不被随意修改,提⾼代码的可维护性;另⼀⽅⾯是仅暴露有限的必要接⼝,提⾼类的易⽤性。

抽象(Abstraction)

封装主要讲如何隐藏信息、保护数据,那抽象就是讲如何隐藏⽅法的具体实现,让使⽤者只需要关⼼⽅法提供了哪些功能,不需要知道这些功能是如何实现的。抽象可以通过接⼝类或者抽象类来实现,但也并不需要特殊的语法机制来⽀持。抽象存在的意义,⼀⽅⾯是提⾼代码的可扩展性、维护性,修改实现不需要改变定义,减少代码的改动范围;另⼀⽅⾯,它也是处理复杂系统的有效⼿段,能有效地过滤掉不必要关注的信息。

继承(Inheritance)

继承是⽤来表示类之间的 is-a 关系,分为两种模式:单继承和多继承。单继承表示⼀个⼦类只继承⼀个⽗类,多继承表示⼀个⼦类可以继承多个⽗类。为了实现继承这个特性,编程语⾔需要提供特殊的语法机制来⽀持。继承主要是⽤来解决代码复⽤的问题。

多态(Polymorphism)

多态是指⼦类可以替换⽗类,在实际的代码运⾏过程中,调⽤⼦类的⽅法实现。多态这种特性也需要编程语⾔提供特殊的语法机制来实现,⽐如继承、接⼝类、duck-typing。多态可以提⾼代码的扩展性和复⽤性,是很多设计模式、设计原则、编程技巧的代码实现基础。

Clean Architecture ⾥⾯指出多态是函数指针的⼀种应⽤。并⽤getchar()举了例⼦。然后⽤多态的实现引出了“依赖反转”的例⼦。

⾯向对象编程相⽐⾯向过程编程有哪些优势?

  • 对于⼤规模复杂程序的开发,程序的处理流程并⾮单⼀的⼀条主线,⽽是错综复杂的⽹状结构。⾯向对象编程⽐起⾯向过程编程,更能应对这种复杂类型的程序开发。
  • ⾯向对象编程相⽐⾯向过程编程,具有更加丰富的特性(封装、抽象、继承、多态)。利⽤这些特性编写出来的代码,更加易扩展、易复⽤、易维护。
  • 从编程语⾔跟机器打交道的⽅式的演进规律中,我们可以总结出:⾯向对象编程语⾔⽐起⾯向过程编程语⾔,更加⼈性化、更加⾼级、更加智能。

Constants、Utils 类设计问题

定义⼀个如此⼤⽽全的 Constants 类,并不是⼀种很好的设计思路。为什么这么说呢?原因主要有以下⼏点。

  • ⾸先,这样的设计会影响代码的可维护性。如果参与开发同⼀个项⽬的⼯程师有很多,在开发过程中,可能都要涉及修改这个类,⽐如往这个类⾥添加常量,那这个类就会变得越来越⼤,成百上千⾏都有可能,查找修改某个常量也会变得⽐较费时,⽽且还会增加提交代码冲突的概率。
  • 其次,这样的设计还会增加代码的编译时间。当 Constants 类中包含很多常量定义的时候,依赖这个类的代码就会很多。那每次修改 Constants 类,都会导致依赖它的类⽂件重新编译,因此会浪费很多不必要的编译时间。不要⼩看编译花费的时间,对于⼀个⾮常⼤的⼯程项⽬来说,编译⼀次项⽬花费的时间可能是⼏分钟,甚⾄⼏⼗分钟。⽽我们在开发过程中,每次运⾏单元测试,都会触发⼀次编译的过程,这个编译时间就有可能会影响到我们的开发效率。
  • 最后,这样的设计还会影响代码的复⽤性。如果我们要在另⼀个项⽬中,复⽤本项⽬开发的某个类,⽽这个类⼜依赖 Constants 类。即便这个类只依赖 Constants 类中的⼀⼩部分常量,我们仍然需要把整个Constants 类也⼀并引⼊,也就引⼊了很多⽆关的常量到新的项⽬中。

第⼀种是将 Constants 类拆解为功能更加单⼀的多个类,⽐如跟 MySQL 配置相关的常量,我们放到MysqlConstants 类中;跟 Redis 配置相关的常量,我们放到 RedisConstants 类中。当然,还有⼀种我个⼈觉得更好的设计思路,那就是并不单独地设计 Constants 常量类,⽽是哪个类⽤到了某个常量,我们就把这个常量定义到这个类中。⽐如,RedisConfig 类⽤到了 Redis 配置相关的常量,那我们就直接将这些常量定义在RedisConfig 中,这样也提⾼了类设计的内聚性和代码的复⽤性。

实际上,Utils 类的出现是基于这样⼀个问题背景:如果我们有两个类 A 和 B,它们要⽤到⼀块相同的功能逻辑,为了避免代码重复,我们不应该在两个类中,将这个相同的功能逻辑,重复地实现两遍。这个时候我们该怎么办呢?

我们在讲⾯向对象特性的时候,讲过继承可以实现代码复⽤。利⽤继承特性,我们把相同的属性和⽅法,抽取出来,定义到⽗类中。⼦类复⽤⽗类中的属性和⽅法,达到代码复⽤的⽬的。但是,有的时候,从业务含义上,A 类和 B 类并不⼀定具有继承关系,⽐如 Crawler 类和 PageAnalyzer 类,它们都⽤到了 URL 拼接和分割的功能,但并不具有继承关系(既不是⽗⼦关系,也不是兄弟关系)。仅仅为了代码复⽤,⽣硬地抽象出⼀个⽗类出来,会影响到代码的可读性。如果不熟悉背后设计思路的同事,发现 Crawler 类和 PageAnalyzer 类继承同⼀个⽗类,⽽⽗类中定义的却是 URL 相关的操作,会觉得这个代码写得莫名其妙,理解不了。

实际上,只包含静态⽅法不包含任何属性的 Utils 类,是彻彻底底的⾯向过程的编程⻛格。但这并不是说,我们就要杜绝使⽤ Utils 类了。实际上,从刚刚讲的 Utils 类存在的⽬的来看,它在软件开发中还是挺有⽤的,能解决代码复⽤问题。所以,这⾥并不是说完全不能⽤ Utils 类,⽽是说,要尽量避免滥⽤,不要不加思考地随意去定义Utils 类。

在定义 Utils 类之前,你要问⼀下⾃⼰,你真的需要单独定义这样⼀个 Utils 类吗?是否可以把 Utils 类中的某些⽅法定义到其他类中呢?如果在回答完这些问题之后,你还是觉得确实有必要去定义这样⼀个 Utils 类,那就⼤胆地去定义它吧。因为即便在⾯向对象编程中,我们也并不是完全排斥⾯向过程⻛格的代码。只要它能为我们写出好的代码贡献⼒量,我们就可以适度地去使⽤。

除此之外,类⽐ Constants 类的设计,我们设计 Utils 类的时候,最好也能细化⼀下,针对不同的功能,设计不同的 Utils 类,⽐如 FileUtils、IOUtils、tringUtils、UrlUtils 等,不要设计⼀个过于⼤⽽全的 Utils 类。

抽象类和接⼝

抽象类

  • 抽象类不允许被实例化,只能被继承。也就是说,你不能 new ⼀个抽象类的对象出来(Logger logger = new Logger(…); 会报编译错误)。
  • 抽象类可以包含属性和⽅法。⽅法既可以包含代码实现(⽐如 Logger 中的 log() ⽅法),也可以不包含代码实现(⽐如 Logger 中的 doLog() ⽅法)。不包含代码实现的⽅法叫作抽象⽅法。
  • ⼦类继承抽象类,必须实现抽象类中的所有抽象⽅法。对应到例⼦代码中就是,所有继承 Logger 抽象类的⼦类,都必须重写 doLog() ⽅法。

接⼝特性

  • 接⼝不能包含属性(也就是成员变量)。
  • 接⼝只能声明⽅法,⽅法不能包含代码实现。
  • 类实现接⼝的时候,必须实现接⼝中声明的所有⽅法。

前⾯我们讲了抽象类和接⼝的定义,以及各⾃的语法特性。从语法特性上对⽐,这两者有⽐较⼤的区别,⽐如抽象类中可以定义属性、⽅法的实现,⽽接⼝中不能定义属性,⽅法也不能包含代码实现等等。除了语法特性,从设计的⻆度,两者也有⽐较⼤的区别。

抽象类实际上就是类,只不过是⼀种特殊的类,这种类不能被实例化为对象,只能被⼦类继承。我们知道,继承关系是⼀种 is-a 的关系,那抽象类既然属于类,也表示⼀种 is-a 的关系。相对于抽象类的 is-a 关系来说,接⼝表示⼀种 has-a 关系,表示具有某些功能。对于接⼝,有⼀个更加形象的叫法,那就是协议(contract)。

抽象类和接⼝能解决什么编程问题

抽象类更多的是为了代码复⽤,⽽接⼝就更侧重于解耦。接⼝是对⾏为的⼀种抽象,相当于⼀组协议或者契约,你可以联想类⽐⼀下 API 接⼝。调⽤者只需要关注抽象的接⼝,不需要了解具体的实现,具体的实现代码对调⽤者透明。接⼝实现了约定和实现相分离,可以降低代码间的耦合性,提⾼代码的可扩展性。

抽象类是对成员变量和⽅法的抽象,是⼀种 is-a 关系,是为了解决代码复⽤问题。接⼝仅仅是对⽅法的抽象,是⼀种 has-a 关系,表示具有某⼀组⾏为特性,是为了解决解耦问题,隔离接⼝和具体的实现,提⾼代码的扩展性。

抽象类和接⼝的应⽤场景区别

什么时候该⽤抽象类?什么时候该⽤接⼝?实际上,判断的标准很简单。如果要表示⼀种 is-a 的关系,并且是为了解决代码复⽤问题,我们就⽤抽象类;如果要表示⼀种 has-a 关系,并且是为了解决抽象⽽⾮代码复⽤问题,那我们就⽤接⼝。

基于接⼝⽽⾮实现编程

  1. 实际上,“基于接⼝⽽⾮实现编程”这条原则的另⼀个表述⽅式,是“基于抽象⽽⾮实现编程”。后者的表述⽅式其实更能体现这条原则的设计初衷。在软件开发中,最⼤的挑战之⼀就是需求的不断变化,这也是考验代码设计好坏的⼀个标准。**越抽象、越顶层、越脱离具体某⼀实现的设计,越能提⾼代码的灵活性,越能应对未来的需求变化。好的代码设计,不仅能应对当下的需求,⽽且在将来需求发⽣变化的时候,仍然能够在不破坏原有代码设计的情况下灵活应对。**⽽抽象就是提⾼代码扩展性、灵活性、可维护性最有效的⼿段之⼀。
  2. 我们在定义接⼝的时候,⼀⽅⾯,命名要⾜够通⽤,不能包含跟具体实现相关的字眼;另⼀⽅⾯,与特定实现有关的⽅法不要定义在接⼝中。
  3. “基于接⼝⽽⾮实现编程”这条原则,不仅仅可以指导⾮常细节的编程开发,还能指导更加上层的架构设计、系统设计等。⽐如,服务端与客户端之间的“接⼝”设计、类库的“接⼝”设计。

是否需要为每个类定义接⼝?

做任何事情都要讲求⼀个“度”,过度使⽤这条原则,⾮得给每个类都定义接⼝,接⼝满天⻜,也会导致不必要的开发负担。⾄于什么时候,该为某个类定义接⼝,实现基于接⼝的编程,什么时候不需要定义接⼝,直接使⽤实现类编程,我们做权衡的根本依据,还是要回归到设计原则诞⽣的初衷上来。只要搞清楚了这条原则是为了解决什么样的问题⽽产⽣的,你就会发现,很多之前模棱两可的问题,都会变得豁然开朗。

从这个设计初衷上来看,如果在我们的业务场景中,某个功能只有⼀种实现⽅式,未来也不可能被其他实现⽅式替换,那我们就没有必要为其设计接⼝,也没有必要基于接⼝编程,直接使⽤实现类就可以了

除此之外,越是不稳定的系统,我们越是要在代码的扩展性、维护性上下功夫。相反,如果某个系统特别稳定,在开发完之后,基本上不需要做维护,那我们就没有必要为其扩展性,投⼊不必要的开发时间。

为什么不推荐使⽤继承?

在⾯向对象编程中,有⼀条⾮常经典的设计原则,那就是:组合优于继承,多⽤组合少⽤继承。为什么不推荐使⽤继承?组合相⽐继承有哪些优势?如何判断该⽤组合还是继承?今天,我们就围绕着这三个问题,来详细讲解⼀下这条设计原则。

继承是⾯向对象的四⼤特性之⼀,⽤来表示类之间的 is-a 关系,可以解决代码复⽤的问题。虽然继承有诸多作⽤,但继承层次过深、过复杂,也会影响到代码的可维护性。在这种情况下,我们应该尽量少⽤,甚⾄不⽤继承。

组合相⽐继承有哪些优势?

我们知道继承主要有三个作⽤:表示 is-a 关系,⽀持多态特性,代码复⽤。⽽这三个作⽤都可以通过其他技术⼿段来达成。⽐如 is-a 关系,我们可以通过组合和接⼝的 has-a 关系来替代;多态特性我们可以利⽤接⼝来实现;代码复⽤我们可以通过组合和委托来实现。所以,从理论上讲,通过组合、接⼝、委托三个技术⼿段,我们完全可以替换掉继承,在项⽬中不⽤或者少⽤继承关系,特别是⼀些复杂的继承关系。

如何判断该⽤组合还是继承?

尽管我们⿎励多⽤组合少⽤继承,但组合也并不是完美的,继承也并⾮⼀⽆是处。从上⾯的例⼦来看,继承改写成组合意味着要做更细粒度的类的拆分。这也就意味着,我们要定义更多的类和接⼝。类和接⼝的增多也就或多或少地增加代码的复杂程度和维护成本。所以,在实际的项⽬开发中,我们还是要根据具体的情况,来具体选择该⽤继承还是组合。

如果类之间的继承结构稳定(不会轻易改变),继承层次⽐较浅(⽐如,最多有两层继承关系),继承关系不复杂,我们就可以⼤胆地使⽤继承。反之,系统越不稳定,继承层次很深,继承关系复杂,我们就尽量使⽤组合来替代继承。

除此之外,还有⼀些设计模式会固定使⽤继承或者组合。⽐如,装饰者模式(decorator pattern)、策略模式(strategy pattern)、组合模式(composite pattern)等都使⽤了组合关系,⽽模板模式(template pattern)使⽤了继承关系。

尽管有些⼈说,要杜绝继承,100% ⽤组合代替继承,但是我的观点没那么极端!之所以“多⽤组合少⽤继承”这个⼝号喊得这么响,只是因为,⻓期以来,我们过度使⽤继承。还是那句话,组合并不完美,继承也不是⼀⽆是处。只要我们控制好它们的副作⽤、发挥它们各⾃的优势,在不同的场合下,恰当地选择使⽤继承还是组合,这才是我们所追求的境界。