面向过程代码特征
- 有哪些看似是面向对象实际是面向过程风格的代码?
- 在面向对象编程中,为什么容易写出面向过程风格的代码?
- 面向过程编程和面向过程编程语言的作用
在实际的开发工作中,很多开发者对面向对象编程都有误解,总以为把所有代码都塞到类里,就是在进行面向对象编程了。实际上,这样的认识是不正确的。有时候,从表面上看似是面向对象编程风格的代码,从本质上看却是面向过程编程风格的。
在用面向对象编程语言进行软件开发的时候,有时候会写出面向过程风格的代码。有些是有意为之,并无不妥;而有些是无意为之,会影响到代码的质量。
滥用 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 方法,这就跟将这两个属性定义为 public 公有属性,没有什么两样了。外部可以通过 setter 方法随意地修改这两个属性的值。除此之外,任何代码都可以随意调用 setter 方法,来重新设置 itemsCount、totalPrice 属性的值,这也会导致其跟 items 属性的值不一致。这明显违反了封装特性。数据没有访问权限控制,任何代码都可以随意修改它,代码就退化成了面向过程编程风格的了。
对于 items 属性,定义了它的 getter 方法和 addItem()方法,并没有定义它的 setter 方法。因为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 方法了(因为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 容器),也要防范集合内部数据被修改的危险。
滥用全局变量和全局方法
全局变量和全局方法在面向过程编程语言(c)中常见,在面向对象编程语言(java)中不多见。
在面向对象编程中,常见的全局变量有单例类对象、静态成员变量、常量等,常见的全局方法有静态方法。单例类对象在全局代码中只有一份,所以,它相当于一个全局变量。静态成员变量归属于类上的数据,被所有的实例化对象所共享,也相当于一定程度上的全局变量。而常量是一种非常常见的全局变量,比如一些代码中的配置参数,一般都设置为常量,放到一个 Constants 类中。静态方法一般用来操作静态变量或者外部数据。你可以联想一下我们常用的各种 Utils 类,里面的方法一般都会定义成静态方法,可以在不用创建对象的情况下,直接拿来使用。静态方法将方法与数据分离,破坏了封装特性,是典型的面向过程风格。
在刚刚介绍的这些全局变量和全局方法中,Constants 类和 Utils 类最常用到。现在就结合这两个几乎在每个软件开发中都会用到的类,来深入探讨一下全局变量和全局方法的利与弊。
Constants 类
一种常见的 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 中,这样也提高了类设计的内聚性和代码的复用性。
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 项目都是基于这种开发模式来开发呢?
缺乏对象之间的协作和消息传递
面向对象设计鼓励对象之间的协作和消息传递,通过对象之间的交互来完成任务。如果代码中的对象之间缺乏交互和消息传递,而更多地依赖于函数之间的调用和数据传递,那么代码更倾向于面向过程的风格。
为什么容易写出面向过程风格的代码?
在生活中,你去完成一个任务,你一般都会思考,应该先做什么、后做什么,如何一步一步地顺序执行一系列操作,最后完成整个任务。面向过程编程风格恰恰符合人的这种流程化思维方式。而面向对象编程风格正好相反。它是一种自底向上的思考方式。它不是先去按照执行流程来分解任务,而是将任务翻译成一个一个的小的模块(也就是类),设计类之间的交互,最后按照流程将类组装起来,完成整个任务,这样的思考路径比较适合复杂程序的开发,但并不是特别符合人类的思考习惯。
面向对象编程要比面向过程编程难一些。在面向对象编程中,类的设计还是挺需要技巧,挺需要一定设计经验的。你要去思考如何封装合适的数据和方法到一个类里,如何设计类之间的关系,如何设计类之间的交互等诸多设计问题。
所以,基于这两点原因,很多工程师在开发的过程,更倾向于用不太需要动脑子的方式去实现需求,也就不由自主地就将代码写成面向过程风格了。
如果开发的是微小程序,或者是一个数据处理相关的代码,以算法为主,数据为辅,那脚本式的面向过程的编程风格就更适合一些。实际上,面向过程编程是面向对象编程的基础,面向对象编程离不开基础的面向过程编程。为什么这么说?因为类中每个方法的实现逻辑,就是面向过程风格的代码。
不管使用面向过程还是面向对象哪种风格来写代码,最终的目的还是写出易维护、易读、易复用、易扩展的高质量代码。只要能避免面向过程编程风格的一些弊端,控制好它的副作用,在掌控范围内为我们所用,就大可不用避讳在面向对象编程中写面向过程风格的代码。
尽管面向过程编程语言可能没有现成的语法来支持面向对象的四大特性,但可以通 过其他方式来模拟,比如在 C 语言中,我们可以利用函数指针来模拟多态。如果你熟悉一 门面向过程的编程语言,你能聊一聊如何用它来模拟面向对象的四大特性吗?
扩展:
使用JavaScript模拟封装特性的方法
方法一:
使用闭包创建私有成员:通过使用闭包,可以创建私有成员变量和方法。在构造函数中定义变量或方法,并将它们作为内部函数返回。这样,外部无法直接访问这些变量或方法,从而实现了封装。
function Person(name) { var privateName = name; // 私有成员 this.getName = function() { return privateName; }; } var person = new Person("John"); console.log(person.getName()); // 输出: John console.log(person.privateName); // undefined,无法直接访问私有成员方法二:
使用命名约定进行属性和方法的封装:在JavaScript中,通过约定将某些属性或方法标记为私有,即表示它们应该被视为私有成员,并避免直接访问。通常使用下划线
_前缀来表示私有成员。function Person(name) { this._privateName = name; // 私有成员 this.getName = function() { return this._privateName; }; } var person = new Person("John"); console.log(person.getName()); // 输出: John console.log(person._privateName); // 不建议直接访问私有成员,但是可以访问ES6之后版本引入了
class关键字和getter/setter语法糖,提供了更接近传统面向对象的封装特性。可以使用class定义类,并使用constructor、get和set关键字定义构造函数、访问器和设置器,来实现更直观的封装。