跳至主要內容

你写的真的是面向对象的代码吗

AruNi_Lu设计模式设计原则与思想约 2837 字大约 9 分钟

本文内容

前言

虽然现在大部分人都是使用的面向对象编程的语言来编写代码,但是你觉得这样写出来的代码就是面向对象的吗?

其实,可能会因为一些无意的操作,导致我们编写出面向过程编程风格的代码。

1. 有哪些代码看似面向对象,实际是面向过程的?

1.1 滥用 getter、setter 方法

很多程序员在项目开发时,定义完一个类的属性之后,马上就会顺手把这些属性的 getter、setter 方法都自动生成上(或者直接使用 Lombok 插件)。

但是,我们真的需要吗?相反,这还 违反了 面向对象编程的 封装 特性,相当于将面向对象编程风格退化成了面向过程编程风格。

举个例子,下面是一个简易购物车的类:

public class ShoppingCart {
  private int itemsCount;
  private double totalPrice;
  private List<ShoppingCartItem> items = new ArrayList<>();
  
  public int getItemsCount() {
    return this.itemsCount;
  }
  
  public void setItemsCount(int itemsCount) {
    this.itemsCount = itemsCount;
  }
  
  public double getTotalPrice() {
    return this.totalPrice;
  }
  
  public void setTotalPrice(double totalPrice) {
    this.totalPrice = totalPrice;
  }

  public List<ShoppingCartItem> getItems() {
    return this.items;
  }
  
  public void addItem(ShoppingCartItem item) {
    items.add(item);
    itemsCount++;
    totalPrice += item.getPrice();
  }
  // ...省略其他方法...
}

代码很简单,来看看这段代码有什么问题。

先看前两个属性 itemsCounttotalPrice,虽然它们都是 private 私有属性,但是 提供了 public 的 getter、setter 方法,这样外部就可以通过这些方法来修改这两个属性的值了。而且外部还可以随意调用 setter 方法,重新设置这两个属性的值,这样也会导致其跟 items 属性的值不一致。

而面向对象封装的定义是:隐藏内部数据,外部仅通过有限的接口进行访问、修改内部数据。所以当 暴露了不该暴露的 setter 方法时,就会导致数据没有任何的访问权限,任何代码都可以随意修改它,这就违反了面向对象的封装特性了,代码也就退化成了面向过程编程风格的了。

除了这两个基本属性的问题,另一个 引用类型的属性 其实也有大问题。items 属性是一个 List 类型的集合,如果给他提供 getter 方法,那么 外部在获取这个 List 集合后,是可以操作这个集合的内部数据的。比如:

ShoppingCart cart = new ShoppCart();
...
cart.getItems().clear(); // 清空购物车

这样 外部就拥有了操作内部数据的能力,无疑是很危险的操作,可能会导致数据不一致。正确的做法应该是在内部提供一个操作内部数据的方法,比如 clear(),外部只能通过这个方法清空购物车,而不是自己拥有这个能力。

如果有一个需求就是要查看购物车里都有啥,那么这时除了提供一个返回 items 的 getter 方法外,还有其他的好办法吗?

我们可以让 getter 方法返回一个 不可被修改的 UnmodifiableList 集合容器,而这个容器类重写了 List 容器中跟修改数据相关的方法,比如 add()clear() 等方法。一旦调用这些修改数据的方法,代码就会抛出 UnsupportedOperationException 异常,这样就避免了容器中的数据被修改。

不过这样还是有一个问题,虽然不能直接修改集合了,但是可以修改集合里面的具体数据,例如:

ShoppingCart cart = new ShoppingCart();
cart.add(new ShoppingCartItem(...));
List<ShoppingCartItem> items = cart.getItems();
ShoppingCartItem item = items.get(0);
item.setPrice(19.0);    // 这里修改了item的价格属性

因为在 Java 中参数是值传递,所以将集合对象返回的时候,实际上是返回了该集合的地址,我们 只限制了对集合的操作会抛出异常,但是并没有限制集合里面的数据,所以能通过该地址直接修改里面的数据。

其实要解决这个问题,可以在返回集合时,采用 深拷贝克隆,这样就算修改了数据,实际的数据也不会受影响。

这个例子扩展得有点开,简单总结来说,就是需要我们合理的编写 getter、setter 方法,在可能出现问题的地方多做一些处理。而不是一股脑的就把所有不需要的方法也生成了。

1.2 滥用全局变量和全局方法

在面向对象编程中,常见的 全局变量单例类对象、静态成员变量、常量 等,常见的 全局方法有静态方法

常量 是一种非常常见的全局变量,比如一些代码中的配置参数,一般都设置为常量,放到一个 Constants 类中

静态方法一般用来操作静态变量或者外部数据。常用的各种 Utils 类,里面的方法一般都会定义成静态方法,可以在不用创建对象的情况下直接拿来使用。而静态方法将方法与数据分离,破坏了封装特性,是典型的面向过程风格。

这些全局变量和全局方法中,最常使用的无非就是 Constants 类和 Utils 类,下面来探讨一下它们的利弊。

Constants 类

有些程序员在编写 Constants 类的时候,会把所有的常量都扔到一个类中,统称为常量类。这样在常量多起来的情况下,会严重影响代码的 可维护性。因为在查找或修改某个常量时,都要到这一个类中进行。

除此之外,如果要在另一个项目中,复用本项目的某个类,而这个类又依赖于 Constants,即使这个类只依赖了一小部分常量,仍需要将整个 Constants 类一并引入,这就 引入了很多无关常量到项目中,导致代码的 复用性变差

一个解决方法是把 Constants 类 拆分为功能更单一的多个类,分开存放常量。或者,如果 该常量只在某个类中使用,不会在别处使用,那么可以考虑 把该常量就定义到该类中

Utils 类

Utils 类的出现是基于这样一个问题背景:

如果 有多个类共同使用到了某个相同的功能,为了避免代码重复,我们不会将该功能重复地实现多次。要解决这个问题,可以用之前讲的 继承,把共同的属性或方法抽取出来放到父类中,子类就可以复用了。

但是,有时候 这些类之间并没有继承关系,比如 Crawler 类和 PageAnalyzer 类,一个负责爬取页面,一个负责页面分析,它们都要使用 URL 拼接和分割的功能,但是这两个类并没有什么关系,总不能仅仅为了代码复用,而硬生生地抽象出一个父类来。

因为拼接和分割 URL 的功能不需要共享任何数据,不需要定义任何属性。所以,可以把它放到一个 Utils 工具类 中,其他地方都能使用该工具类中的方法(静态方法)。

其实这种 只包含静态方法而不包含任何属性的 Utils 类,是彻彻底底的 面向过程的编程风格,因为它把方法单独分离出来了。

但是,并不代表我们就要杜绝使用 Utils 类,因为它确确实实能解决代码复用的问题,所以不是说只能面向对象编程,而是要合理设计。

另外,类比 Constants 类的设计,在设计 Utils 类的时候,最好也能针对不同的功能,设计不同的 Utils 类,而不要设计一个过大的 Utils 类。

1.3 定义数据和方法分离的类

回想一下,我们在平时的开发中,有出现过数据定义在一个类中、方法定义在另一个类中吗?其实,如果是使用 MVC 架构 做程序开发时,这样的代码你天天都在写。

在 MVC 架构中,Controller 层负责暴露接口给前端调用,Service 层负责处理业务逻辑,Repository 层负责与数据库打交道。同时,我们还会定义一些 VO(View Object)、BO(Business Object)、Entity,这些类中只会定义数据,而操作这些数据的业务逻辑都在对应的 Controller 类、Service 类、Repository 类中。所以这就是一种典型的 面向过程 的编程风格。

实际上这种开发模式叫做 基于贫血模型 的开发模式,这也是我们平时经常使用的一种 Web 项目的开发模式。那既然这种开发模式明显违背了面向对象的编程风格,为什么还如此常用呢?(后续文章会讲解)

2. 为什么这么容易写出面向过程的代码?

我们在进行面向对象编程时,为什么很容易写出这种面向过程风格的代码呢?

可以联想一下实际生活中,如果要完成一项任务,我们一般都会思考先做什么、后做什么,如何一步步的顺序执行,最后完成整个任务,也就是讲整体拆解成局部步骤。所以说面向过程编程风格很符合人的这种流程化思维方式。

而面向对象编程风格恰恰相反,它不是先去按照执行流程来拆解任务,而是将任务抽象成一个个的类,设计类之间怎么进行交互,最后再按照流程将类组装起来,完成整个任务,这样的思考路程是比较复杂的,不是很符合人的思考习惯。而且在设计类、封装方法、设计类之间的关系时,都是比较困难的。

所以为了简单、快捷,很多人就不由自主的写出了面向过程风格的代码了。

3. 总结

在我们实际编码过程中,会不经意的设计出 违反面向对象编程风格的代码,例如:

  • 滥用 getter、setter 方法;
  • Constants 类、Utils 类的设计问题;
  • 基于贫血模式的开发模式(MVC)。

由于面向过程编程风格的开发更符合我们人类的思考方式,编写起来比较简单、易于理解,学习成本也比较低,所以人们会更加倾向于这样的开发方式。

所以说,我们在面向对象编程的时候,不一定非得百分百遵守面向对象,而是要灵活、合理的与面向过程相结合,写出 可维护、易扩展、复用性高 的代码。

上次编辑于: