OOP

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

四大特性封装、抽象、继承、多态,有些人也认为只有三大特性,把抽象排除在外。

面向对象编程语言(OOPL,Object Oriented Programming Language)是支持类或对象的语法机制,并有现成的语法机制,能方便地实现面向对象编程四大特性(封装、抽象、继承、多态)的编程语言。

按照严格的定义,很多语言都不是面 OOPL,比如 JavaScript 不支持封装和继承,Go 摒弃了继承。但是按照不严格的定义,很多语言都可以说是 OOPL,只要某种编程语言支持类或对象的语法概念,并且以此作为组织代码的基本单元,那就可以被粗略地认为它就是 OOPL 了。

OOP 一般使用 OOPL 来进行,但是,不用 OOPL,我们照样可以进行 OOP。反过来讲,即便我们使用 OOPL,写出来的代码也不一定是 OOP 风格的,也有可能是面向过程编程风格的。

面向对象分析(OOA,Object Oriented Analysis)和面向对象设计(OOA,Object Oriented Design)的产出是类的设计,包括程序被拆解成哪些类、类有哪些属性方法、类于类之间的关系。

四大特性

封装(Encapsulation)

也叫信息隐藏或数据访问保护。封装的意义:

  • 提高代码的可维护性。如果对类中的属性访问不做限制,那么则类不可控,属性被随意修改,修改的逻辑散落在代码各个角落。

  • 类通过仅暴露必要的操作,可以提高类的易用性,调用者不需要了解过多的细节。

抽象(Abstraction)

封装讲的是如何隐藏信息、保护数据;而抽象讲的是隐藏方法的具体实现,调用者只关系方法提供了哪些功能,并不需要知道这些功能是怎么实现的。

通常使用接口类或抽象类两种语法机制实现。

上文讲到有时候抽象被排除在四大特性之外。原因如下: 实现抽象不一定需要接口类或抽象类,因为类的方法是通过编程语言的函数这一语法机制本省就是抽象,使用者在调用函数的时候,并不需要知道内部逻辑。

抽象的意义:

  • 抽象和封装都是处理复杂性的有效手段,面对复杂系统,人脑有限,所以必须忽略掉非关键的细节。

  • 提高代码的可扩展性、维护性,修改实现不需要修改定义。

继承(Inheritance)

继承表示类之间 is-a 的关系。继承分为单继承和多继承。

继承最大的好处是代码复用。不过,过度使用继承会导致代码的可读性,可维护性变差,父类和子类耦合度过高。所以继承这个特性非常有争议,我们应该尽量少用。

多态(Polymorphism)

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

多态的实现一般需要三种语法机制:

  • 父类对象引用可以指向子类对象。

  • 支持继承。

  • 子类可以重写(override)父类方法。

多态可以提高代码的可扩展性和复用性。

面向过程

What?

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

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

OOP 有哪些优势?

  • OOP 能够应对大规模复杂程序开发。对于简单程序,面向过程、面向对象差别不大,有时面向过程更简单。

  • OOP 更易复用、易扩展、易维护。由上文的四大特性可见。

  • OOP 语言更加人性化,更接近于人的思维,而机器语言、汇编、面向过程是按照计算机的思维方式。

本质是面向过程的代码

在实际开发中,不是把代码塞到类里,就表示在面向对象编程了。很多情况下,有很多表面上像面向对象的代码,本质上时面向过程的风格。

滥用 getter、setter 方法

我们经常会遇到定义完类后,随手就把所有 属性的 getter、setter 加上去,或者用 lombok 等。这样违反了面向对象封装的特性。如下面的例子:

@Data
public class ShoppingCart {
  private int itemsCount;
  private double totalPrice;
  private List<ShoppingCartItem> items = new ArrayList<>();
  
  public void addItem(ShoppingCartItem item) {
    items.add(item);
    itemsCount++;
    totalPrice += item.getPrice();
  }
  // ...省略其他方法...
}

上面的代码有很多问题:

  • items、totalPrice 定义为私有属性,但是又提供 getter、setter 方法,本质上就是 public 属性了。任何地方都可以修改这两个值,会造成和 items 的数据不一致问题。

  • items 提供了 getter 方法,返回的是 List,外部拿到这个容器后,可以修改容器内数据。解决方案:可以使用Collections.unmodifiableList()

  • 就算用不可变容器,还是有问题,因为用户拿到容器后,再拿容器内的某个元素,还是可以修改元素的某个字段值。

总结一下:

  • 能不暴露 setter 方法就尽量不要暴露。

  • getter 方法如果返回的是容器或者类,也要注意内部的数据被修改。

滥用全局变量和全局方法

全局变量:单例类对象、静态成员变量、常量,比如 Constants;全局方法:静态方法,比如 Utils。

全局变量

我们会见到把程序中所有的常量都集中方法一个 Constants 类中,这种大而全的常量类并不好,原因是:

  • 可维护性差,一个项目有很多工程师开发,那么就都可能修改这个类,这个类就会很大,查找修改某个常量也会比较耗时,还可能引起代码冲突。

  • 增加编译时间,依赖这个 Constants 的类很多,所以只要这个常量类有修改,很多类都需要重新编译。

  • 复用性差,如果有另一个项目也依赖 Constants 类,但是只依赖一小部分,这样就引入了很多无关的常量。

解决方案:

  • 把大的 Constants 拆分,如 MySQLConstants、RedisConstants 等。

  • 不设计 Constants 类,哪个类使用了常量,就把这个常量定义在这个类中。

全局方法

为什么会出现 Utils 类呢?因为存在一种情况,两个类有一段逻辑是重复的,虽然继承可以解决代码复用问题,但是这两个类又有没 is-a 的关系,这个时候就出现了 Utils 类。

我们并不是要测地杜绝 Utils 类,只是不要滥用。另外,Utils 也可以细化。

数据和方法分离

传统的 MVC 是 Model、Controller、View。前后端分离后,后端的三层结构变为 Controller、Service、Repository,在每一层都有对应的 VO、BO、Entity,其实这就是数据和方法分离。比如,BO 只定义了数据,而操作数据的方法都在 Service 中,这就是典型的面向过程的编程风格。

这种开发模式叫做基于贫血模式的开发方式。

面向过程的取舍

虽然上文讲了 OOP 的各种优势,也讲了哪些代码表面上是面向对象,本质上是面向过程。但是面向过程并不是完全无用武之地。

若是开发微小程序,或者做一些数据处理(算法为主、数据为辅),那么面向过程更加适合。

面向对象和面向过程可以并存的,甚至在一些标准库(JDK、Apache Commons、Guava)中都可以见到面向过程风格的代码。

所以,我们最终的目的是写出易维护、易读、易复用、易扩展的高质量代码。

贫血模型

MVC 架构指的是 Model、View、Controller,表示展示层、逻辑层、数据层,现在多是前后端分离,后端分为 Repository、Service、Controller 三层。

一般在写代码的时候,Entity 和 Repository 类组成数据层,Bo 和 Service 类组成业务逻辑层,Vo 和 Controller 类组成接口层。可以发现,Entity、Bo、Vo 是一个纯粹的数据结构,不包含任何业务逻辑,业务逻辑放在另一个类中,这是一种典型的面向过程的编程风格。像 Bo 这种只包含数据,不包含业务逻辑的类叫做贫血模型(Anemic Domain Model)。

充血模型(Rich Domain Model)刚好相反,数据和业务被封装在同一个类中。基于充血模型的 DDD 开发与贫血模型的主要差别在于 Service 层,DDD 的 Service 层包含Service 类和 Domain 类,Domain 类包含数据和业务逻辑,而 Service 类很单薄。

为什么贫血模型这么受欢迎

  • 系统业务简单,贫血模型足以应付,不需要费心思设计充血模型。

  • 充血模型更有难度。

  • 思维已固化,转型有成本。

那什么项目应该考虑用充血模型呢

业务复杂的系统,比如包含各种利息计算模型、还款模型等复杂业务的金融系统。

Last updated