设计模式之美学习(五):哪些代码设计看似是面向对象,实际是面向过程的?

常见的编程范式或者说编程风格有三种,面向过程编程、面向对象编程、函数式编程,而面向对象编程又是这其中最主流的编程范式。现现在,大部分编程语言都是面向对象编程语言,大部分软件都是基于面向对象编程这种编程范式来开发的。前端

不过,在实际的开发工做中,总觉得把全部代码都塞到类里,天然就是在进行面向对象编程了。实际上,这样的认识是不正确的。有时候,从表面上看似是面向对象编程风格的代码,从本质上看倒是面向过程编程风格的。mysql

哪些代码设计看似是面向对象,实际是面向过程的?

在用面向对象编程语言进行软件开发的时候,咱们有时候会写出面向过程风格的代码。有些是有意为之,并没有不妥;而有些是无心为之,会影响到代码的质量。算法

1. 滥用 gettersetter 方法sql

它违反了面向对象编程的封装特性,至关于将面向对象编程风格退化成了面向过程编程风格。经过下面这个例子来给你解释一下这句话。编程

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();
  }
  // ...省略其余方法...
}复制代码

在这段代码中,ShoppingCart 是一个简化后的购物车类,有三个私有(private)属性:itemsCounttotalPriceitems。对于 itemsCounttotalPrice 两个属性,咱们定义了它们的 gettersetter 方法。对于 items 属性,咱们定义了它的 getter 方法和 addItem() 方法。代码很简单,理解起来不难。那你有没有发现,这段代码有什么问题呢?小程序

咱们先来看前两个属性,itemsCounttotalPrice。虽然咱们将它们定义成 private 私有属性,可是提供了 publicgettersetter 方法,这就跟将这两个属性定义为 public 公有属性,没有什么两样了。外部能够经过 setter 方法随意地修改这两个属性的值。除此以外,任何代码均可以随意调用 setter 方法,来从新设置 itemsCounttotalPrice 属性的值,这也会致使其跟 items 属性的值不一致。后端

而面向对象封装的定义是:经过访问权限控制,隐藏内部数据,外部仅能经过类提供的有限的接口访问、修改内部数据。因此,暴露不该该暴露的 setter 方法,明显违反了面向对象的封装特性。数据没有访问权限控制,任何代码均可以随意修改它,代码就退化成了面向过程编程风格的了。安全

看完了前两个属性,咱们再来看 items 这个属性。对于 items 这个属性,咱们定义了它的 getter 方法和 addItem() 方法,并无定义它的 setter 方法。这样的设计貌似看起来没有什么问题,但实际上并非。前后端分离

对于 itemsCounttotalPrice 这两个属性来讲,定义一个 publicgetter 方法,确实无伤大雅,毕竟 getter 方法不会修改数据。可是,对于 items 属性就不同了,这是由于 items 属性的 getter 方法,返回的是一个 List集合容器。外部调用者在拿到这个容器以后,是能够操做容器内部数据的,也就是说,外部代码仍是能修改 items 中的数据。好比像下面这样:编程语言

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

你可能会说,清空购物车这样的功能需求看起来合情合理啊,上面的代码没有什么不妥啊。你说得没错,需求是合理的,可是这样的代码写法,会致使 itemsCounttotalPriceitems 三者数据不一致。咱们不该该将清空购物车的业务逻辑暴露给上层代码。正确的作法应该是,在 ShoppingCart 类中定义一个 clear() 方法,将清空购物车的业务逻辑封装在里面,透明地给调用者使用。ShoppingCart 类的 clear() 方法的具体代码实现以下:

public class ShoppingCart {
  // ...省略其余代码...
  public void clear() {
    items.clear();
    itemsCount = 0;
    totalPrice = 0.0;
  }
}复制代码

你可能还会说,我有一个需求,须要查看购物车中都买了啥,那这个时候,ShoppingCart 类不得不提供 items 属性的 getter 方法了,那又该怎么办才好呢?

若是你熟悉 Java 语言,那解决这个问题的方法仍是挺简单的。咱们能够经过 Java 提供的 Collections.unmodifiableList() 方法,让 getter 方法返回一个不可被修改的 UnmodifiableList 集合容器,而这个容器类重写了 List 容器中跟修改数据相关的方法,好比 add()clear() 等方法。一旦咱们调用这些修改数据的方法,代码就会抛出 UnsupportedOperationException 异常,这样就避免了容器中的数据被修改。具体的代码实现以下所示。

public class ShoppingCart {
  // ...省略其余代码...
  public List<ShoppingCartItem> getItems() {
    return Collections.unmodifiableList(this.items);
  }
}

public class UnmodifiableList<E> extends UnmodifiableCollection<E>
                          implements List<E> {
  public boolean add(E e) {
    throw new UnsupportedOperationException();
  }
  public void clear() {
    throw new UnsupportedOperationException();
  }
  // ...省略其余代码...
}

ShoppingCart cart = new ShoppingCart();
List<ShoppingCartItem> items = cart.getItems();
items.clear();//抛出UnsupportedOperationException异常复制代码

不过,这样的实现思路仍是有点问题。由于当调用者经过 ShoppingCartgetItems() 获取到 items 以后,虽然咱们无法修改容器中的数据,但咱们仍然能够修改容器中每一个对象(ShoppingCartItem)的数据。听起来有点绕,看看下面这几行代码你就明白了。

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

总结一下,在设计实现类的时候,除非真的须要,不然,尽可能不要给属性定义 setter 方法。除此以外,尽管 getter 方法相对 setter 方法要安全些,可是若是返回的是集合容器(好比例子中的 List 容器),也要防范集合内部数据被修改的危险。

2. 滥用全局变量和全局方法

在面向对象编程中,常见的全局变量有单例类对象、静态成员变量、常量等,常见的全局方法有静态方法。单例类对象在全局代码中只有一份,因此,它至关于一个全局变量。静态成员变量归属于类上的数据,被全部的实例化对象所共享,也至关于必定程度上的全局变量。而常量是一种很是常见的全局变量,好比一些代码中的配置参数,通常都设置为常量,放到一个 Constants 类中。静态方法通常用来操做静态变量或者外部数据。你能够联想一下咱们经常使用的各类 Utils 类,里面的方法通常都会定义成静态方法,能够在不用建立对象的状况下,直接拿来使用。静态方法将方法与数据分离,破坏了封装特性,是典型的面向过程风格。

在刚刚介绍的这些全局变量和全局方法中,Constants 类和 Utils 类最经常使用到。如今,咱们就结合这两个几乎在每一个软件开发中都会用到的类,来深刻探讨一下全局变量和全局方法的利与弊。

咱们先来看一下,在我过去参与的项目中,一种常见的 Constants 类的定义方法。

public class Constants {
  public static final String MYSQL_ADDR_KEY = "mysql_addr";
  public static final String MYSQL_DB_NAME_KEY = "db_name";
  public static final String MYSQL_USERNAME_KEY = "mysql_username";
  public static final String MYSQL_PASSWORD_KEY = "mysql_password";
  
  public static final String REDIS_DEFAULT_ADDR = "192.168.7.2:7234";
  public static final int REDIS_DEFAULT_MAX_TOTAL = 50;
  public static final int REDIS_DEFAULT_MAX_IDLE = 50;
  public static final int REDIS_DEFAULT_MIN_IDLE = 20;
  public static final String REDIS_DEFAULT_KEY_PREFIX = "rt:";
  
  // ...省略更多的常量定义...
}复制代码

在这段代码中,咱们把程序中全部用到的常量,都集中地放到这个 Constants 类中。不过,定义一个如此大而全的 Constants 类,并非一种很好的设计思路。为何这么说呢?缘由主要有如下几点。

首先,这样的设计会影响代码的可维护性。

若是参与开发同一个项目的工程师有不少,在开发过程当中,可能都要涉及修改这个类,好比往这个类里添加常量,那这个类就会变得愈来愈大,成百上千行都有可能,查找修改某个常量也会变得比较费时,并且还会增长提交代码冲突的几率。

其次,这样的设计还会增长代码的编译时间。

Constants 类中包含不少常量定义的时候,依赖这个类的代码就会不少。那每次修改 Constants 类,都会致使依赖它的类文件从新编译,所以会浪费不少没必要要的编译时间。不要小看编译花费的时间,对于一个很是大的工程项目来讲,编译一次项目花费的时间多是几分钟,甚至几十分钟。而咱们在开发过程当中,每次运行单元测试,都会触发一次编译的过程,这个编译时间就有可能会影响到咱们的开发效率。

最后,这样的设计还会影响代码的复用性。

若是咱们要在另外一个项目中,复用本项目开发的某个类,而这个类又依赖 Constants 类。即使这个类只依赖 Constants 类中的一小部分常量,咱们仍然须要把整个 Constants 类也一并引入,也就引入了不少无关的常量到新的项目中。

那如何改进 Constants 类的设计呢?这里有两种思路能够借鉴。

第一种是将 Constants 类拆解为功能更加单一的多个类,好比跟 MySQL 配置相关的常量,咱们放到 MysqlConstants 类中;跟 Redis 配置相关的常量,咱们放到 RedisConstants 类中。固然,还有一种以为更好的设计思路,那就是并不单独地设计 Constants 常量类,而是哪一个类用到了某个常量,咱们就把这个常量定义到这个类中。好比,RedisConfig 类用到了 Redis 配置相关的常量,那咱们就直接将这些常量定义在 RedisConfig 中,这样也提升了类设计的内聚性和代码的复用性。

讲完了 Constants 类,咱们再来讨论一下 Utils 类。首先,想问你这样一个问题,咱们为何须要 Utils 类?Utils 类存在的意义是什么?

在讲面向对象特性的时候,讲过继承能够实现代码复用。利用继承特性,咱们把相同的属性和方法,抽取出来,定义到父类中。子类复用父类中的属性和方法,达到代码复用的目的。可是,有的时候,从业务含义上,A 类和 B 类并不必定具备继承关系,好比 Crawler 类和 PageAnalyzer 类,它们都用到了 URL 拼接和分割的功能,但并不具备继承关系(既不是父子关系,也不是兄弟关系)。仅仅为了代码复用,生硬地抽象出一个父类出来,会影响到代码的可读性。若是不熟悉背后设计思路的同事,发现 Crawler 类和 PageAnalyzer 类继承同一个父类,而父类中定义的倒是 URL 相关的操做,会以为这个代码写得莫名其妙,理解不了。

既然继承不能解决这个问题,咱们能够定义一个新的类,实现 URL 拼接和分割的方法。而拼接和分割两个方法,不须要共享任何数据,因此新的类不须要定义任何属性,这个时候,咱们就能够把它定义为只包含静态方法的 Utils 类了。

实际上,只包含静态方法不包含任何属性的 Utils 类,是不折不扣的面向过程的编程风格。但这并非说,咱们就要杜绝使用 Utils 类了。实际上,从刚刚讲的 Utils 类存在的目的来看,它在软件开发中仍是挺有用的,能解决代码复用问题。因此,这里并非说彻底不能用 Utils 类,而是说,要尽可能避免滥用,不要不加思考地随意去定义 Utils 类。

在定义 Utils 类以前,你要问一下本身,你真的须要单独定义这样一个 Utils 类吗?是否能够把 Utils 类中的某些方法定义到其余类中呢?若是在回答完这些问题以后,你仍是以为确实有必要去定义这样一个 Utils 类,那就大胆地去定义它吧。由于即使在面向对象编程中,咱们也并非彻底排斥面向过程风格的代码。只要它能为咱们写出好的代码贡献力量,咱们就能够适度地去使用。

除此以外,类比 Constants 类的设计,咱们设计 Utils 类的时候,最好也能细化一下,针对不一样的功能,设计不一样的 Utils 类,好比 FileUtilsIOUtilsStringUtilsUrlUtils 等,不要设计一个过于大而全的 Utils 类。

3. 定义数据和方法分离的类

最后一种面向对象编程过程当中,常见的面向过程风格的代码。那就是,数据定义在一个类中,方法定义在另外一个类中。

传统的 MVC 结构分为 Model 层、Controller 层、View 层这三层。不过,在作先后端分离以后,三层结构在后端开发中,会稍微有些调整,被分为 Controller 层、Service 层、Repository 层。Controller 层负责暴露接口给前端调用,Service 层负责核心业务逻辑,Repository 层负责数据读写。而在每一层中,咱们又会定义相应的 VOView Object)、BOBusiness Object)、Entity。通常状况下,VOBOEntity 中只会定义数据,不会定义方法,全部操做这些数据的业务逻辑都定义在对应的 Controller 类、Service 类、Repository 类中。这就是典型的面向过程的编程风格。

实际上,这种开发模式叫做基于贫血模型的开发模式,也是咱们如今很是经常使用的一种 Web 项目的开发模式。

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

能够联想一下,在生活中,你去完成一个任务,你通常都会思考,应该先作什么、后作什么,如何一步一步地顺序执行一系列操做,最后完成整个任务。面向过程编程风格偏偏符合人的这种流程化思惟方式。而面向对象编程风格正好相反。它是一种自底向上的思考方式。它不是先去按照执行流程来分解任务,而是将任务翻译成一个一个的小的模块(也就是类),设计类之间的交互,最后按照流程将类组装起来,完成整个任务。咱们在上一节课讲到了,这样的思考路径比较适合复杂程序的开发,但并非特别符合人类的思考习惯。

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

因此,基于这两点缘由,不少工程师在开发的过程,更倾向于用不太须要动脑子的方式去实现需求,也就情不自禁地就将代码写成面向过程风格的了。

面向过程编程及面向过程编程语言就真的无用武之地了吗?

前面咱们有讲到,若是咱们开发的是微小程序,或者是一个数据处理相关的代码,以算法为主,数据为辅,那脚本式的面向过程的编程风格就更适合一些。固然,面向过程编程的用武之地还不止这些。实际上,面向过程编程是面向对象编程的基础,面向对象编程离不开基础的面向过程编程。为何这么说?咱们仔细想一想,类中每一个方法的实现逻辑,不就是面向过程风格的代码吗?

除此以外,面向对象和面向过程两种编程风格,也并非非黑即白、彻底对立的。在用面向对象编程语言开发的软件中,面向过程风格的代码并很多见,甚至在一些标准的开发库(好比 JDKApache CommonsGoogle Guava)中,也有不少面向过程风格的代码。

无论使用面向过程仍是面向对象哪一种风格来写代码,咱们最终的目的仍是写出易维护、易读、易复用、易扩展的高质量代码。只要咱们能避免面向过程编程风格的一些弊端,控制好它的反作用,在掌控范围内为咱们所用,咱们就大可不用避讳在面向对象编程中写面向过程风格的代码。

重点回顾

1. 滥用 gettersetter 方法

在设计实现类的时候,除非真的须要,不然尽可能不要给属性定义 setter 方法。除此以外,尽管 getter 方法相对 setter 方法要安全些,可是若是返回的是集合容器,那也要防范集合内部数据被修改的风险。

2.Constants 类、Utils 类的设计问题

对于这两种类的设计,咱们尽可能能作到职责单一,定义一些细化的小类,好比 RedisConstantsFileUtils,而不是定义一个大而全的 Constants 类、Utils 类。除此以外,若是能将这些类中的属性和方法,划分归并到其余业务类中,那是最好不过的了,能极大地提升类的内聚性和代码的可复用性。

  1. 基于贫血模型的开发模式

讲了为何这种开发模式是不折不扣的面向过程编程风格的。这是由于数据和操做是分开定义在 VO/BO/EntityControler/Service/Repository 中的。

参考:哪些代码设计看似是面向对象,实际是面向过程的?

本文由博客一文多发平台 OpenWrite 发布!

更多内容请点击个人博客沐晨

相关文章
相关标签/搜索