常见的编程范式或者说编程风格有三种,面向过程编程、面向对象编程、函数式编程,而面向对象编程又是这其中最主流的编程范式。现如今,大部分编程语言都是面向对象编程语言,大部分软件都是基于面向对象编程这种编程范式来开发的。
不过,在实际的开发工作中,总以为把所有代码都塞到类里,自然就是在进行面向对象编程了。实际上,这样的认识是不正确的。有时候,从表面上看似是面向对象编程风格的代码,从本质上看却是面向过程编程风格的。
哪些代码设计看似是面向对象,实际是面向过程的?
在用面向对象编程语言进行软件开发的时候,我们有时候会写出面向过程风格的代码。有些是有意为之,并无不妥;而有些是无意为之,会影响到代码的质量。
1. 滥用 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
语言,那解决这个问题的方法还是挺简单的。我们可以通过 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
方法要安全些,但是如果返回的是集合容器(比如例子中的 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
类,比如 FileUtils
、IOUtils
、StringUtils
、UrlUtils
等,不要设计一个过于大而全的 Utils
类。
3. 定义数据和方法分离的类
最后一种面向对象编程过程中,常见的面向过程风格的代码。那就是,数据定义在一个类中,方法定义在另一个类中。
传统的 MVC
结构分为 Model
层、Controller
层、View
层这三层。不过,在做前后端分离之后,三层结构在后端开发中,会稍微有些调整,被分为 Controller
层、Service
层、Repository
层。Controller
层负责暴露接口给前端调用,Service
层负责核心业务逻辑,Repository
层负责数据读写。而在每一层中,我们又会定义相应的 VO
(View Object
)、BO
(Business Object
)、Entity
。一般情况下,VO
、BO
、Entity
中只会定义数据,不会定义方法,所有操作这些数据的业务逻辑都定义在对应的 Controller
类、Service
类、Repository
类中。这就是典型的面向过程的编程风格。
实际上,这种开发模式叫作基于贫血模型的开发模式,也是我们现在非常常用的一种 Web
项目的开发模式。
在面向对象编程中,为什么容易写出面向过程风格的代码?
可以联想一下,在生活中,你去完成一个任务,你一般都会思考,应该先做什么、后做什么,如何一步一步地顺序执行一系列操作,最后完成整个任务。面向过程编程风格恰恰符合人的这种流程化思维方式。而面向对象编程风格正好相反。它是一种自底向上的思考方式。它不是先去按照执行流程来分解任务,而是将任务翻译成一个一个的小的模块(也就是类),设计类之间的交互,最后按照流程将类组装起来,完成整个任务。我们在上一节课讲到了,这样的思考路径比较适合复杂程序的开发,但并不是特别符合人类的思考习惯。
除此之外,面向对象编程要比面向过程编程难一些。在面向对象编程中,类的设计还是挺需要技巧,挺需要一定设计经验的。你要去思考如何封装合适的数据和方法到一个类里,如何设计类之间的关系,如何设计类之间的交互等等诸多设计问题。
所以,基于这两点原因,很多工程师在开发的过程,更倾向于用不太需要动脑子的方式去实现需求,也就不由自主地就将代码写成面向过程风格的了。
面向过程编程及面向过程编程语言就真的无用武之地了吗?
前面我们有讲到,如果我们开发的是微小程序,或者是一个数据处理相关的代码,以算法为主,数据为辅,那脚本式的面向过程的编程风格就更适合一些。当然,面向过程编程的用武之地还不止这些。实际上,面向过程编程是面向对象编程的基础,面向对象编程离不开基础的面向过程编程。为什么这么说?我们仔细想想,类中每个方法的实现逻辑,不就是面向过程风格的代码吗?
除此之外,面向对象和面向过程两种编程风格,也并不是非黑即白、完全对立的。在用面向对象编程语言开发的软件中,面向过程风格的代码并不少见,甚至在一些标准的开发库(比如 JDK
、Apache Commons
、Google Guava
)中,也有很多面向过程风格的代码。
不管使用面向过程还是面向对象哪种风格来写代码,我们最终的目的还是写出易维护、易读、易复用、易扩展的高质量代码。只要我们能避免面向过程编程风格的一些弊端,控制好它的副作用,在掌控范围内为我们所用,我们就大可不用避讳在面向对象编程中写面向过程风格的代码。
重点回顾
1. 滥用 getter
、setter
方法
在设计实现类的时候,除非真的需要,否则尽量不要给属性定义 setter
方法。除此之外,尽管 getter
方法相对 setter
方法要安全些,但是如果返回的是集合容器,那也要防范集合内部数据被修改的风险。
2.Constants
类、Utils
类的设计问题
对于这两种类的设计,我们尽量能做到职责单一,定义一些细化的小类,比如 RedisConstants
、FileUtils
,而不是定义一个大而全的 Constants
类、Utils
类。除此之外,如果能将这些类中的属性和方法,划分归并到其他业务类中,那是最好不过的了,能极大地提高类的内聚性和代码的可复用性。
- 基于贫血模型的开发模式
讲了为什么这种开发模式是彻彻底底的面向过程编程风格的。这是因为数据和操作是分开定义在 VO/BO/Entity
和 Controler/Service/Repository
中的。