设计模式学习笔记 - 面向对象 - 4.什么代码看似面向对象,实际是面向过程的

概述

在实际开发中,很多人对面向对象编程有误解,认为把代码都塞到类里,自然就是面向对象编程了。实际上,这样是不正确的。有时候,表面上看似面向对象编程风格的代码,本质上确实面向过程编程风格的。


1.哪些代码看似是面向对象,实际是面向过程?

在使用面向对象编程进行开发时,有时会写出面向过程风格的代码。下面通过三个典型例子,给你展示下,什么的代码看似是面向对象风格,实际上是面向过程风格。希望能通过这三个典型例子的学习,能做到举一反三。

1.1 滥用 getter、setter 方法

日常开发过程中,绝大部分情况下,我们定义完类的属性后,就顺手会把这些属性的 getter、setter 方法都定义上。还有些同学更加省事,直接用 Lombok 插件自动生成所有属性的 getter、setter 方法。

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

public class ShoppingCart {
   
    private int itemsCount;
    private double totalPrice;
    private List<ShoppingCartItem> items = new ArrayList<>();

    public int getItemsCount() {
   
        return itemsCount;
    }

    public void setItemsCount(int itemsCount) {
   
        this.itemsCount = itemsCount;
    }

    public double getTotalPrice() {
   
        return totalPrice;
    }

    public void setTotalPrice(double totalPrice) {
   
        this.totalPrice = totalPrice;
    }

    public List<ShoppingCartItem> getItems() {
   
        return items;
    }

    public void addItem(ShoppingCartItem item) {
   
        this.items.add(item);
        itemsCount++;
        totalPrice += item.getPrice();
    }
    // 省略其他方法...
}

这段代码中,ShoppingCart 是一个简化后的购物车类,有三个私有属性:itemsCount、totalPrice、items。对于 itemsCount、totalPrice,我们定义了他们的 getter、setter 方法。对于 items 属性,我们定义了它的 getter 方法和 addItem() 方法。代码很简单,理解起来也不难。你有没有发现这段代码有什么问题吗?

先看下 itemsCount 和 totalPrice,虽然吧被定义成私有的,但是却提供了 public 的 getter、setter 方法,这就跟将这两个属性定义为 public 公有属性没有什么两样。外部可以通过 setter 方法随意修改这两个属性的值,这也会导致其跟 items 属性的值不一致。

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

再看下 items 这个属性,它定义了 getter 方法和 addItem 方法,并没有定义它的 setter 方法。这看起来没有什么问题,实际上是这样吗?

对于 items 属性的 getter 方法,它返回的是一个 List 集合容器。外部调用者在拿到这个容器后,可以操作容器内部的数据,也就是说,外部代码还是能修改 items 中的数据的。

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

可能你会说,清空购物车这样的功能看起来很合理啊,上面的代码没什么不妥的。你说的没错,需求是合理的,但是这样的代码写法,会导致 itemsCount、totalPrice、items 三者的数据不一致。我们不应该将清空购物车的逻辑暴露给上层代码。正确的做法是,在 ShoppingCart 类中定义一个 clear() 方法,将清空购物车的逻辑封装在里面,给调用者调用。

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

若有需求,需要查看购物车中都买了啥,这个时候,该怎么做呢?如果你熟悉 Java 语言,那解决这个问题的方法还是挺简单的,可以通过 Java 提供的 Collections.unmodifiableList(items) 让 getter 返回一个不可被修改的 UnmodifiableList 集合容器。

Collections.unmodifiableList() 返回的集合容器类重写了 List 容器中跟修改数据相关的方法,比如 add()、clear() 等方法。一旦调用这些修改方法,代码就会抛出 UnsupportedOperationException 异常。

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

static class UnmodifiableList<E> extends UnmodifiableCollection<E>
                                  implements List<E> {
   
	public void add(int index, E element) {
   
		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();
List<ShoppingCartItem> items = cart.getItems();
ShoppingCartItem item = items.get(0);
item.setPrice(19.0); // 这里修改了item的价格属性

这个问题具体该如何解决,可以放到后面设计模式章节在详细分析。

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

1.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 = "mysql_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 = "127.0.0.1";
	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 类存在的意义是什么?

实际上,Utils 类的出现是基于这样一个问题背景:如果我们有两个类 AB,它们要用到一块相同的功能,为避免代码重复,我们该怎么办呢?

我们前面讲到,继承可以实现代码复用。利用继承特性,我们把相同的属性和方法抽取出来,定义到父类中。子类复用父类中的属性和方法。但是,有时候,从业务上看 AB 类并不一定具有继承关系,比如 CrawlerPageAnalyzer 类,它们都用到了 URL 拼接和分割的功能,但并不具有继承关系(既不是父子,也不是兄弟关系)。仅仅为了代码复用,生硬地抽出一个父类,会影响到代码的可读性。

这个时候,就可以把它定义为只包含静态方法的 Utils 类了。实际上,只包含静态方法不包含任意属性的 Utils 类,是彻彻底底的面向过程编程风格。但并不是说,我们要杜绝使用 Utils 类。从刚刚的例子来讲,Utils 类在开发过程还是挺有用的,能解决代码复用的问题。

所以并不是说完全不能用 Utils 类,而是要尽量避免滥用。

在定义 Utils 类之前,要问一下自己,是否真的需要定义这样一个 Utils 吗?是否可以把 Utils 类中的某些方法定义到其他类中呢?如果回答这些问题之后,你还是觉得确实有必要去定义这样一个 Utils 类,那就大胆地去定义吧。只要能为我们写出好的代码贡献力量,我们就可以适度地去使用。

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

1.3 定义数据和方法分离的类

这种风格的代码是将数据定义在一个类中,将方法定义在另一个类中。你可能会好奇,这么明显的面向风格的代码,谁会这么写呢?

实际上如果你是基于 MVC 三层结构做 Web 方面的后端开发,这样的代码,你可能天天在写。

传统的 MVC 结构分为 Model层、Controller 层、View 层这三层。不过在前后端分离,三层架构稍微有了调整,被分为 Controller 层、Service 层、Repository 层。

  • Controller 层负责暴露接口给前端调用。
  • Service 层负责核心业务逻辑
  • Repository 层负责数据读写

而在每一层中,又会相应的定义 VO、BO、Entity。一般情况下,VO、BO、Entity 中只会定义数据,不会定义方法,所有操作这些数据的业务逻辑都定义在对应的 Controller 类、Service 类、Repository 类中。这就是典型的面向过程的编程风格。

实际上,这种开发模式叫做基于贫血模型的开发模式,也是我们现在非常常用的一种 Web 项目的开发模式。

关于为什么这种开发模式明显违背了面向对象的编程风格,为什么大部分 Web 项目都是基于这种开发模式来开发的,我们会在后面的章节进行讲解。

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

在生活中,当你去完成一个任务时,你一般都会思考,应该先做什么、后做什么,如何一步步地顺序执行一系列操作,最后完成整个任务。面向过程编程风格恰恰符合人的这种流程化思维方式。而面向对象编程风格正好相反。它是一种自底向上的思考方式。它不是先去按照执行流程来分解任务,而是将任务翻译成一个个小的模块(也就是类),设计类之间的交互,最后按照流程将类组装起来,完成整个任务。

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

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

3. 面向过程编程及面向过程编程语言真的就没用了?

如果要开一个微信小程序,或者是一个数据处理相关的代码,以算法为主,数据为辅,那脚本式的面向过程编程风格就更加适合一些。当然,面向过程编程的用武之地还不止这些。实际上,面向过程编程是面向对象编程的基础,面向对象编程离不开基础的面向过程编程。为什么这么说?你想想,类中的每个方法的实现逻辑,不就是面向过程风格的代码吗。

此外,面向对象和面向过程这两种编程风格,也不是非黑即白、完全对立的。在面向对象编程语言开发的软件中,面向过程风格的代码并不少见,甚至在一些标准的开发库(比如 JDK、Apach Commons、Google Guava)中,也有很多面向过程风格的代码。

不管是使用面向对象还是面向过程编程来编写代码,我们最终的目的是写出易维护、易读、易复用、易扩展的高质量代码。只要我们能避免面向过程编程风格的一些弊端,控制好它的副作用,在掌握范围内为我们所用,就就不用避讳地在面向对象编程写面向过程风格的代码。

回顾

三种违反面向对象风格的典型代码

  • 滥用 getter、setter 方法
    在设计类的时候,除非必要,否则尽量不要给属性定义 setter 方法。此外,尽管 getter 方法相对 setter 方法要安全一些,但是如果返回的是集合容器,那也要防范集合内部数据被修改的风格。
  • Constants 类型、Utils 类
    这两种类的设计,尽量能做到职责单一,定义一些细化的小类,比如 RedisConstants、FileUtils,而不是定义定义一个大而全的 Constants 类、Utils 类。此外,如果能将这些中的属性和方法,划分归并到其他业务类中,那是最好不过了,能极大地提高类的内聚性和代码的可复用性。
  • 基于贫血模型的开发模式
    这一部分,目前知识讲了为什么这种开发模式是面向过程编程风格的。这是因为数据和操作是分开定义在 VO/BO/Entity 和 Controller/Service/Repository 中的。至于为什么这种开发按模式如此流程?如何规避面向过程编程的弊端?有没有更好的开发模式?相关问题在后续章节讲解。

相关推荐

  1. 什么面向对象

    2024-02-23 05:22:02       37 阅读
  2. 什么面向对象

    2024-02-23 05:22:02       22 阅读
  3. 面向对象——设计模式

    2024-02-23 05:22:02       44 阅读
  4. 面向对象设计模式

    2024-02-23 05:22:02       27 阅读
  5. 设计模式艺术》笔记 - 面向对象设计原则

    2024-02-23 05:22:02       56 阅读

最近更新

  1. docker php8.1+nginx base 镜像 dockerfile 配置

    2024-02-23 05:22:02       94 阅读
  2. Could not load dynamic library ‘cudart64_100.dll‘

    2024-02-23 05:22:02       100 阅读
  3. 在Django里面运行非项目文件

    2024-02-23 05:22:02       82 阅读
  4. Python语言-面向对象

    2024-02-23 05:22:02       91 阅读

热门阅读

  1. json字符串的处理

    2024-02-23 05:22:02       37 阅读
  2. springsecurity框架笔记

    2024-02-23 05:22:02       41 阅读
  3. el-upload组件实现上传拖拽排序图片顺序

    2024-02-23 05:22:02       44 阅读
  4. 深入探讨YUV图像处理:理论原理与OpenCV实践

    2024-02-23 05:22:02       39 阅读
  5. @Conditional注解

    2024-02-23 05:22:02       50 阅读
  6. OpenAI Sora文本生成视频注册教程

    2024-02-23 05:22:02       79 阅读
  7. layui-tab加载echarts宽度丢失

    2024-02-23 05:22:02       52 阅读
  8. IMAP4揭秘:实现高效、灵活的电子邮件管理

    2024-02-23 05:22:02       45 阅读
  9. 力扣刷题记录:46_全排列(中)

    2024-02-23 05:22:02       58 阅读
  10. 解决windows无法访问wsl下docker服务

    2024-02-23 05:22:02       49 阅读
  11. 力扣49.字母异位词分组

    2024-02-23 05:22:02       52 阅读
  12. winform布局

    2024-02-23 05:22:02       54 阅读
  13. Python基础20 面向对象(3)多态、封装、反射

    2024-02-23 05:22:02       51 阅读