Files
CategoryResourceRepost/极客时间专栏/设计模式之美/设计原则与思想:面向对象/07 | 理论四:哪些代码设计看似是面向对象,实际是面向过程的?.md
louzefeng d3828a7aee mod
2024-07-11 05:50:32 +00:00

251 lines
23 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<audio id="audio" title="07 | 理论四:哪些代码设计看似是面向对象,实际是面向过程的?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/fa/c2/fae6a9d658353028b04e2a91ab5037c2.mp3"></audio>
上一节课,我们提到,常见的编程范式或者说编程风格有三种,面向过程编程、面向对象编程、函数式编程,而面向对象编程又是这其中最主流的编程范式。现如今,大部分编程语言都是面向对象编程语言,大部分软件都是基于面向对象编程这种编程范式来开发的。
不过,在实际的开发工作中,很多同学对面向对象编程都有误解,总以为把所有代码都塞到类里,自然就是在进行面向对象编程了。实际上,这样的认识是不正确的。有时候,从表面上看似是面向对象编程风格的代码,从本质上看却是面向过程编程风格的。
所以,今天,我结合具体的代码实例来讲一讲,有哪些看似是面向对象,实际上是面向过程编程风格的代码,并且分析一下,为什么我们很容易写出这样的代码。最后,我们再一起辩证思考一下,面向过程编程是否就真的无用武之地了呢?是否有必要杜绝在面向对象编程中写面向过程风格的代码呢?
好了,现在,让我们正式开始今天的学习吧!
## 哪些代码设计看似是面向对象,实际是面向过程的?
在用面向对象编程语言进行软件开发的时候,我们有时候会写出面向过程风格的代码。有些是有意为之,并无不妥;而有些是无意为之,会影响到代码的质量。下面我就通过三个典型的代码案例,给你展示一下,什么样的代码看似是面向对象风格,实际上是面向过程风格的。我也希望你通过对这三个典型例子的学习,能够做到举一反三,在平时的开发中,多留心一下自己编写的代码是否满足面向对象风格。
### 1.滥用getter、setter方法
在之前参与的项目开发中我经常看到有同事定义完类的属性之后就顺手把这些属性的getter、setter方法都定义上。有些同事更加省事直接用IDE或者Lombok插件如果是Java项目的话自动生成所有属性的getter、setter方法。
当我问起为什么要给每个属性都定义getter、setter方法的时候他们的理由一般是为了以后可能会用到现在事先定义好类用起来就更加方便而且即便用不到这些getter、setter方法定义上它们也无伤大雅。
实际上,这样的做法我是非常不推荐的。它违反了面向对象编程的封装特性,相当于将面向对象编程风格退化成了面向过程编程风格。我通过下面这个例子来给你解释一下这句话。
```
public class ShoppingCart {
private int itemsCount;
private double totalPrice;
private List&lt;ShoppingCartItem&gt; items = new ArrayList&lt;&gt;();
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&lt;ShoppingCartItem&gt; 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<shoppingcartitem>集合容器。外部调用者在拿到这个容器之后是可以操作容器内部数据的也就是说外部代码还是能修改items中的数据。比如像下面这样</shoppingcartitem>
```
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语言那解决这个问题的方法还是挺简单的。我们可以通过Java提供的Collections.unmodifiableList()方法让getter方法返回一个不可被修改的UnmodifiableList集合容器而这个容器类重写了List容器中跟修改数据相关的方法比如add()、clear()等方法。一旦我们调用这些修改数据的方法代码就会抛出UnsupportedOperationException异常这样就避免了容器中的数据被修改。具体的代码实现如下所示。
```
public class ShoppingCart {
// ...省略其他代码...
public List&lt;ShoppingCartItem&gt; getItems() {
return Collections.unmodifiableList(this.items);
}
}
public class UnmodifiableList&lt;E&gt; extends UnmodifiableCollection&lt;E&gt;
implements List&lt;E&gt; {
public boolean add(E e) {
throw new UnsupportedOperationException();
}
public void clear() {
throw new UnsupportedOperationException();
}
// ...省略其他代码...
}
ShoppingCart cart = new ShoppingCart();
List&lt;ShoppingCartItem&gt; items = cart.getItems();
items.clear();//抛出UnsupportedOperationException异常
```
不过这样的实现思路还是有点问题。因为当调用者通过ShoppingCart的getItems()获取到items之后虽然我们没法修改容器中的数据但我们仍然可以修改容器中每个对象ShoppingCartItem的数据。听起来有点绕看看下面这几行代码你就明白了。
```
ShoppingCart cart = new ShoppingCart();
cart.add(new ShoppingCartItem(...));
List&lt;ShoppingCartItem&gt; items = cart.getItems();
ShoppingCartItem item = items.get(0);
item.setPrice(19.0); // 这里修改了item的价格属性
```
这个问题该如何解决呢?我今天就不展开来讲了。在后面讲到设计模式的时候,我还会详细地讲到。当然,你也可以在留言区留言或者把问题分享给你的朋友,和他一起讨论解决方案。
getter、setter问题我们就讲完了我稍微总结一下在设计实现类的时候除非真的需要否则尽量不要给属性定义setter方法。除此之外尽管getter方法相对setter方法要安全些但是如果返回的是集合容器比如例子中的List容器也要防范集合内部数据被修改的危险。
### 2.滥用全局变量和全局方法
我们再来看,另外一个违反面向对象编程风格的例子,那就是滥用全局变量和全局方法。首先,我们先来看,什么是全局变量和全局方法?
如果你是用类似C语言这样的面向过程的编程语言来做开发那对全局变量、全局方法肯定不陌生甚至可以说在代码中到处可见。但如果你是用类似Java这样的面向对象的编程语言来做开发全局变量和全局方法就不是很多见了。
在面向对象编程中常见的全局变量有单例类对象、静态成员变量、常量等常见的全局方法有静态方法。单例类对象在全局代码中只有一份所以它相当于一个全局变量。静态成员变量归属于类上的数据被所有的实例化对象所共享也相当于一定程度上的全局变量。而常量是一种非常常见的全局变量比如一些代码中的配置参数一般都设置为常量放到一个Constants类中。静态方法一般用来操作静态变量或者外部数据。你可以联想一下我们常用的各种Utils类里面的方法一般都会定义成静态方法可以在不用创建对象的情况下直接拿来使用。静态方法将方法与数据分离破坏了封装特性是典型的面向过程风格。
在刚刚介绍的这些全局变量和全局方法中Constants类和Utils类最常用到。现在我们就结合这两个几乎在每个软件开发中都会用到的类来深入探讨一下全局变量和全局方法的利与弊。
**我们先来看一下在我过去参与的项目中一种常见的Constants类的定义方法**
```
public class Constants {
public static final String MYSQL_ADDR_KEY = &quot;mysql_addr&quot;;
public static final String MYSQL_DB_NAME_KEY = &quot;db_name&quot;;
public static final String MYSQL_USERNAME_KEY = &quot;mysql_username&quot;;
public static final String MYSQL_PASSWORD_KEY = &quot;mysql_password&quot;;
public static final String REDIS_DEFAULT_ADDR = &quot;192.168.7.2:7234&quot;;
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 = &quot;rt:&quot;;
// ...省略更多的常量定义...
}
```
在这段代码中我们把程序中所有用到的常量都集中地放到这个Constants类中。不过定义一个如此大而全的Constants类并不是一种很好的设计思路。为什么这么说呢原因主要有以下几点。
首先,这样的设计会影响代码的可维护性。
如果参与开发同一个项目的工程师有很多,在开发过程中,可能都要涉及修改这个类,比如往这个类里添加常量,那这个类就会变得越来越大,成百上千行都有可能,查找修改某个常量也会变得比较费时,而且还会增加提交代码冲突的概率。
其次,这样的设计还会增加代码的编译时间。
当Constants类中包含很多常量定义的时候依赖这个类的代码就会很多。那每次修改Constants类都会导致依赖它的类文件重新编译因此会浪费很多不必要的编译时间。不要小看编译花费的时间对于一个非常大的工程项目来说编译一次项目花费的时间可能是几分钟甚至几十分钟。而我们在开发过程中每次运行单元测试都会触发一次编译的过程这个编译时间就有可能会影响到我们的开发效率。
最后,这样的设计还会影响代码的复用性。
如果我们要在另一个项目中复用本项目开发的某个类而这个类又依赖Constants类。即便这个类只依赖Constants类中的一小部分常量我们仍然需要把整个Constants类也一并引入也就引入了很多无关的常量到新的项目中。
那如何改进Constants类的设计呢我这里有两种思路可以借鉴。
第一种是将Constants类拆解为功能更加单一的多个类比如跟MySQL配置相关的常量我们放到MysqlConstants类中跟Redis配置相关的常量我们放到RedisConstants类中。当然还有一种我个人觉得更好的设计思路那就是并不单独地设计Constants常量类而是哪个类用到了某个常量我们就把这个常量定义到这个类中。比如RedisConfig类用到了Redis配置相关的常量那我们就直接将这些常量定义在RedisConfig中这样也提高了类设计的内聚性和代码的复用性。
**讲完了Constants类我们再来讨论一下Utils类。**首先我想问你这样一个问题我们为什么需要Utils类Utils类存在的意义是什么希望你先思考一下然后再来看我下面的讲解。
实际上Utils类的出现是基于这样一个问题背景如果我们有两个类A和B它们要用到一块相同的功能逻辑为了避免代码重复我们不应该在两个类中将这个相同的功能逻辑重复地实现两遍。这个时候我们该怎么办呢
我们在讲面向对象特性的时候讲过继承可以实现代码复用。利用继承特性我们把相同的属性和方法抽取出来定义到父类中。子类复用父类中的属性和方法达到代码复用的目的。但是有的时候从业务含义上A类和B类并不一定具有继承关系比如Crawler类和PageAnalyzer类它们都用到了URL拼接和分割的功能但并不具有继承关系既不是父子关系也不是兄弟关系。仅仅为了代码复用生硬地抽象出一个父类出来会影响到代码的可读性。如果不熟悉背后设计思路的同事发现Crawler类和PageAnalyzer类继承同一个父类而父类中定义的却是URL相关的操作会觉得这个代码写得莫名其妙理解不了。
既然继承不能解决这个问题我们可以定义一个新的类实现URL拼接和分割的方法。而拼接和分割两个方法不需要共享任何数据所以新的类不需要定义任何属性这个时候我们就可以把它定义为只包含静态方法的Utils类了。
实际上只包含静态方法不包含任何属性的Utils类是彻彻底底的面向过程的编程风格。但这并不是说我们就要杜绝使用Utils类了。实际上从刚刚讲的Utils类存在的目的来看它在软件开发中还是挺有用的能解决代码复用问题。所以这里并不是说完全不能用Utils类而是说要尽量避免滥用不要不加思考地随意去定义Utils类。
在定义Utils类之前你要问一下自己你真的需要单独定义这样一个Utils类吗是否可以把Utils类中的某些方法定义到其他类中呢如果在回答完这些问题之后你还是觉得确实有必要去定义这样一个Utils类那就大胆地去定义它吧。因为即便在面向对象编程中我们也并不是完全排斥面向过程风格的代码。只要它能为我们写出好的代码贡献力量我们就可以适度地去使用。
除此之外类比Constants类的设计我们设计Utils类的时候最好也能细化一下针对不同的功能设计不同的Utils类比如FileUtils、IOUtils、StringUtils、UrlUtils等不要设计一个过于大而全的Utils类。
### 3.定义数据和方法分离的类
我们再来看最后一种面向对象编程过程中常见的面向过程风格的代码。那就是数据定义在一个类中方法定义在另一个类中。你可能会觉得这么明显的面向过程风格的代码谁会这么写呢实际上如果你是基于MVC三层结构做Web方面的后端开发这样的代码你可能天天都在写。
传统的MVC结构分为Model层、Controller层、View层这三层。不过在做前后端分离之后三层结构在后端开发中会稍微有些调整被分为Controller层、Service层、Repository层。Controller层负责暴露接口给前端调用Service层负责核心业务逻辑Repository层负责数据读写。而在每一层中我们又会定义相应的VOView Object、BOBusiness 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.基于贫血模型的开发模式**
关于这一部分我们只讲了为什么这种开发模式是彻彻底底的面向过程编程风格的。这是因为数据和操作是分开定义在VO/BO/Entity和Controler/Service/Repository中的。今天你只需要掌握这一点就可以了。为什么这种开发模式如此流行如何规避面向过程编程的弊端有没有更好的可替代的开发模式相关的更多问题我们在面向对象实战篇中会一一讲解。
## 课堂讨论
今天课堂讨论的话题有两个,你可以选择一个熟悉的来发表观点。
1.今天我们讲到用面向对象编程语言写出来的代码不一定是面向对象编程风格的有可能是面向过程编程风格的。相反用面向过程编程语言照样也可以写出面向对象编程风格的代码。尽管面向过程编程语言可能没有现成的语法来支持面向对象的四大特性但可以通过其他方式来模拟比如在C语言中我们可以利用函数指针来模拟多态。如果你熟悉一门面向过程的编程语言你能聊一聊如何用它来模拟面向对象的四大特性吗
2.看似是面向对象实际上是面向过程编程风格的代码有很多,除了今天我讲到的这三个,在你工作中,你还遇到过哪些其他情况吗?
欢迎在留言区写下你的答案,和同学一起交流和分享。如果有收获,也欢迎你把这篇文章分享给你的朋友。