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

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

在用面向对象编程语言进行软件开发的时候,我们有时候会写出面向过程风格的代码。有些是有意为之,并无不妥;而有些是无意为之,会影响到代码的质量。下面通过三个典型的代码案例,展示一下,什么样的代码看似是面向对象风格,实际上是面向过程风格的。

滥用getter、setter方法。

有些人定义完类的属性之后,就顺序把这些属性的getter、setter方法都定义上,甚至直接用IDE自动生成所有属性的getter、setter方法。理由一般是为了以后可能会用到,现在事先定义好,类用起来也就更加方便,而且即便用不到这些getter、setter方法,定义它们也无伤大雅。

实际上这是非常不推荐的写法。它违反了面向对象编程的封装特性,相当于将面向对象编程分格退化成了面向过程编程分格。举个例子:

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)属性:itemsCount、totalPrice、items。对于 itemsCount、totalPrice 两个属性,我们定义了它们的 getter、setter 方法。对于 items 属性,我们定义了它的 getter 方法和 addItem()方法。那上面代码有什么问题呢?

我们先来看前两个属性,itemsCount和totalPrice 。虽然我们将它们定义成private私有属性,但是提供了public的getter、setter方法,这就跟将这两个属性定义为public公有属性,没有什么两样。外部可以通过setter方法随意的修改这两个属性的值。除此之外,任何代码都可以随意调用setter方法,来重新设置itemsCount、totalPrice属性的值,这就会导致其跟items属性的值不一致。

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

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

对于itemsCount和totalPrice这两个属性来说,定义一个public的getter方法,确实无伤大雅,毕竟getter方法不会修改数据。但是,对于items属性就不一样了,这是因为items属性的getter方法,返回的是一个List集合容器。外部调用者在拿到这个容器之后,是可以操作容器内部数据的,也就是说,外部代码还是能修改items中的数据的。比如:

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

这样的代码写法,会导致 itemsCount、totalPrice、items 三者数据不一致。我们不应该将清空购物车的业务逻辑暴露给上层代码。正确的做法应该是,在 ShoppingCart 类中定义一个 clear() 方法,将清空购物车的业务逻辑封装在里面,透明地给调用者使用。ShoppingCart 类的 clear() 方法的具体代码实现如下:

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

如果有一个需求是需要查看购物车中都买了啥,那这个时候,ShoppingCart类不得不提供 items 属性的 getter 方法了,那又该怎么办才好呢?

我们可以通过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 异常

不过,这样的实现思路还是有点问题。因为当调用者通过 ShoppingCart 的 getItems() 获取到 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方法要安全写,但如果返回的是集合容器,也要防范集合内部数据被修改的危险

滥用全局变量和全局方法

如果你是用类似 C 语言这样的面向过程的编程语言来做开发,那对全局变量、全局方法肯定不陌生,甚至可以说,在代码中到处可见。但如果你是用类似 Java 这样的面向对象的编程语言来做开发,全局变量和全局方法就不是很多见了。

  • 在面向对象编程中,常见的全部变量有单例类对象、静态成员变量、常量等。常见的全局方法有静态方法
  • 单例类对象在全局代码中只有一份,所以,它相当于一个全局变量
  • 静态成员变量归属于类上的数据,被所有的实例化对象所共享,也相当于一定程度上的全局变量
  • 常量是一种非常常见的全局遍历,比如一些代码中的配置参数,一般都设置为常量,放在一个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 类,并不是一种很好的设计思路。为什么这么说呢?原因主要有以下几点。

(1)这样的设计会影响代码的可维护性

如果参与开发同一个项目的工程师有很多,在开发过程中,可能都要涉及修改这个类,比如往这个类里添加常量,那这个类就会变得越来越大,成百上千行都有可能,查找修改某个常量也会比较费时,而且还会增加提交代码冲突的概率

(2)这样的设计还会增加代码的编译时间

当 Constants 类中包含很多常量定义的时候,依赖这个类的代码就会很多。那每次修改Constants 类,都会导致依赖它的类文件重新编译,因此会浪费很多不必要的编译时间。

不要小看编译花费的时间,对于一个非常大的工程项目来说,编译一次项目花费的时间可能是几分钟,甚至几十分钟。而我们在开发过程中,每次运行单元测试,都会触发一次编译的过程,这个编译时间就有可能会影响到我们的开发效率。

(3)这样的设计还会影响代码的复用性

如果我们要在另一个项目中,复用本项目开发的某个类,而这个类又依赖Constants类。即便这个类只依赖Constants类中一小部分常量,我们仍然需要把整个Constants类也一并引入,也就引入了很多无关常量到新的醒目中。

那怎么改进呢?有两种方法

(1)将 Constants 类拆解为功能更加单一的多个类,比如跟 MySQL 配置相关的常量,我们放到 MysqlConstants 类中;跟 Redis 配置相关的常量,我们放到RedisConstants 类中。

(2)更推荐的是并不单独设计Constants常量类,而是哪个类用到了某个常量,就把这个常量定义到这个类中。比如,RedisConfig 类用到了 Redis 配置相关的常量,那我们就直接将这些常量定义在RedisConfig 中,这样也提高了类设计的内聚性和代码的复用性。

第二个例子,研究下Utils类。

这里有个问题,我们为什么需要Utils类?它存在的意义是什么?

  • 实际上,Utils类的出现是为了解决这样的一个问题:如果我们有两个类A和B,它们要用到一块相同的功能逻辑,为了避免代码重复,我们不应该在两个类中,将这个相同的功能逻辑,重复的实现两遍。这个时候我们应该怎么办呢?
  • 面向对象中有一个叫做继承特性,可以实现代码复用,我们把相同的属性和方法,抽取处理,定义到父类中,子类复用父类中的属性和方法,达到代码复用的目的。但是,有的时候,从业务含义上,A 类和 B 类并不一定具有继承关系,比如 Crawler类和 PageAnalyzer 类,它们都用到了 URL 拼接和分割的功能,但并不具有继承关系(既不是父子关系,也不是兄弟关系)。仅仅为了代码复用,生硬地抽象出一个父类出来,会影响到代码的可读性。如果不熟悉背后设计思路的同事,发现 Crawler 类和 PageAnalyzer类继承同一个父类,而父类中定义的却是 URL 相关的操作,会觉得这个代码写得莫名其妙,理解不了。
  • 既然继承不能解决问题,我们可以定义一个新的类,实现 URL 拼接和分割的方法。而拼接和分割两个方法,不需要共享任何数据,所以新的类不需要定义任何属性,这个时候,我们就可以把它定义为只包含静态方法的 Utils 类了。
  • 实际上,只包含静态方法不包含任何属性的Utils类,是彻彻底底的面向过程的编程分格。但并不是说,我们就要杜绝使用Utils类了。实际上,从刚刚讲到的Utils类存在的目的来看,它在软件开发中还是挺有用的,能解决代码复用的问题。所以,这里并不是说完全不能用Utils类,而是要尽量避免滥用,不要不加思考的随意去定义Utils类。
  • 除此之外,类比 Constants 类的设计,我们设计 Utils 类的时候,最好也能细化一下,针对不同的功能,设计不同的 Utils 类,比如 FileUtils、IOUtils、StringUtils、UrlUtils 等,不要设计一个过于大而全的 Utils 类。

定义数据和方法分离的类

我们再来看最后一种面向对象编程过程中,常见的面向过程分隔的代码。那就是,数据定义在一个类中,方法定义在另一个类中。你可能会觉得,这么明显的面向过程风格的代码,谁会怎么写呢?实际上,如果你是基于MVC三层结构做的Web方面的后端开发,这样的代码你可能天天在写。

  • 传统的 MVC 结构分为 Model 层、Controller 层、View 层这三层。不过,在做前后端分离之后,三层结构在后端开发中,会稍微有些调整,被分为 Controller 层、Service 层、Repository 层。 Controller 层负责暴露接口给前端调用,Service 层负责核心业务逻辑,Repository 层负责数据读写。
  • 而在每一层中,我们又会定义相应的 VO(View Object)、BO(Business Object)、Entity
  • 一般情况下,VO、BO、Entity 中只会定义数据,不会定义方法,所有操作这些数据的业务逻辑都定义在对应的 Controller 类、Service 类、Repository 类中。

这就是典型的面向过程的编程风格。实际上,这种开发模式叫作基于贫血模型的开发模式,也是我们现在非常常用的一种 Web项目的开发模式。

既然这种开发模式明显违背面向对象的编程风格,为什么大部分 Web 项目都是基于这种开发模式来开发呢?

贫血模型流行的原因:实现简单和上手快。

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

我们在进行面向对象编程的时候,很容易不由自主地就写出面向过程风格的代码,或者说感觉面向过程风格的代码更容易写。这是为什么呢?

  • 可以联想一下,在生活中,你去完成一个任务,你一般都会思考,应该先做什么、后做什么,如何一步一步地顺序执行一系列操作,最后完成整个任务。面向过程编程风格恰恰符合人的这种流程化思维方式。而面向对象编程风格正好相反。它是一种自底向上的思考方式。
  • 它不是先去按照执行流程来分解任务,而是将任务翻译成一个一个的小模块(也就是类),设计类之间的交互,最后按照流程将类组装起来,完成整个任务。这样的思考路径比较适合复杂程序的开发,但并不是特别符合人类的思考习惯。
  • 除此之外,面向对象编程要比面向过程编程难一些。在面向对象编程中,类的设计还是挺需要技巧,挺需要一定设计经验的。你要去思考如何封装合适的数据和方法到一个类里,如何设计类之间的关系,如何设计类之间的交互等等诸多设计问题。

所以,很多工程师在开发的过程,更倾向于用不太需要动脑子的方式去实现需求,也就不由自主地就将代码写成面向过程风格的了。

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

  • 如果我们开发的是微小程序,或者是一个数据处理相关的代码,以算法为主,数据为辅,那脚本式的面向过程的编程风格就更适合一些。当然,面向过程编程的用武之地还不止这些。实际上,面向过程编程是面向对象编程的基础,面向对象编程离不开基础的面向过程编程。为什么这么说?我们仔细想想,类中每个方法的实现逻辑,不就是面向过程风格的代码吗?
  • 除此之外,面向对象和面向过程两种编程风格,也并不是非黑即白、完全对立的。在用面向对象编程语言开发的软件中,面向过程风格的代码并不少见,甚至在一些标准的开发库(比如 JDK、Apache Commons、Google Guava)中,也有很多面向过程风格的代码。
  • 不管使用面向过程还是面向对象哪种风格来写代码,我们最终的目的还是写出易维护、易读、易复用、易扩展的高质量代码。只要我们能避免面向过程编程风格的一些弊端,控制好它的副作用,在掌控范围内为我们所用,我们就大可不用避讳在面向对象编程中写面向过程风格的代码。

总结

三种容易违反面向对象编程风格的典型代码设计

(1) 滥用 getter、setter 方法:在设计实现类的时候,除非真的需要,否则尽量不要给属性定义 setter 方法。除此之外,尽管 getter 方法相对 setter 方法要安全些,但是如果返回的是集合容器,那也要防范集合内部数据被修改的风险。

(2)Constants 类、Utils 类的设计问题:对于这两种类的设计,我们尽量能做到职责单一,定义一些细化的小类,比如RedisConstants、FileUtils,而不是定义一个大而全的 Constants 类、Utils 类。除此之外,如果能将这些类中的属性和方法,划分归并到其他业务类中,那是最好不过的了,能极
大地提高类的内聚性和代码的可复用性。

(3)基于贫血模型的开发模式

  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值