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

news/2024/6/29 12:16:12 标签: 面向对象, 面向过程

概述

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


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 中的。至于为什么这种开发按模式如此流程?如何规避面向过程编程的弊端?有没有更好的开发模式?相关问题在后续章节讲解。

http://www.niftyadmin.cn/n/5387534.html

相关文章

Matlab|基于支持向量机的电力短期负荷预测【最小二乘、标准粒子群、改进粒子群】

目录 主要内容 部分代码 结果一览 下载链接 主要内容 该程序主要是对电力短期负荷进行预测&#xff0c;采用三种方法&#xff0c;分别是最小二乘支持向量机&#xff08;LSSVM&#xff09;、标准粒子群算法支持向量机和改进粒子群算法支持向量机三种方法对负荷进行…

Docker介绍与使用

Docker介绍与使用 目录&#xff1a; 一、Docker介绍 1、Docker概述与安装 2、Docker三要素 二、Docker常用命令的使用 1、镜像相关命令 2、容器相关命令 三、Docker实战之下载mysql、redis、zimg 一、Docker介绍 Docker是一个开源的应用容器引擎&#xff0c;让开发者可以打包…

Docker后台启动镜像,如何查看日志信息

执行 docker run -d -p 9090:8080 core-backend-image 命令后&#xff0c;Docker 会在后台运行一个新的容器实例&#xff0c;并映射宿主机的 9090 端口到容器的 8080 端口。要查看启动的容器日志&#xff0c;您需要先获取容器的 ID 或名称&#xff0c;然后使用 docker logs 命令…

js之函数与变量提升

在JavaScript中&#xff0c;变量提升&#xff08;Hoisting&#xff09;是指在执行代码之前&#xff0c;变量和函数声明会被提前到其作用域的顶部的过程。这是由于JavaScript的解释性质造成的&#xff1a;在执行代码之前&#xff0c;引擎会先读取并将所有的变量和函数声明“提升…

代码随想录三刷day06

提示&#xff1a;文章写完后&#xff0c;目录可以自动生成&#xff0c;如何生成可参考右边的帮助文档 文章目录 前言一、力扣203. 移除链表元素二、力扣707. 设计链表三、力扣 前言 递归法相对抽象一些&#xff0c;但是其实和双指针法是一样的逻辑&#xff0c;同样是当cur为空的…

Imagewheel私人图床搭建结合内网穿透实现无公网IP远程访问教程

文章目录 1.前言2. Imagewheel网站搭建2.1. Imagewheel下载和安装2.2. Imagewheel网页测试2.3.cpolar的安装和注册 3.本地网页发布3.1.Cpolar临时数据隧道3.2.Cpolar稳定隧道&#xff08;云端设置&#xff09;3.3.Cpolar稳定隧道&#xff08;本地设置&#xff09; 4.公网访问测…

Java 构造函数与修饰符详解:初始化对象与控制权限

Java 构造函数 Java 构造函数 是一种特殊的类方法&#xff0c;用于在创建对象时初始化对象的属性。它与类名相同&#xff0c;并且没有返回值类型。 构造函数的作用: 为对象的属性设置初始值执行必要的初始化操作提供创建对象的多种方式 构造函数的类型: 默认构造函数: 无参…

嵌入式学习-qt-Day2

嵌入式学习-qt-Day2 一、思维导图 二、作业 1.使用手动连接&#xff0c;将登录框中的取消按钮使用qt4版本的连接到自定义的槽函数中&#xff0c;在自定义的槽函数中调用关闭函数 2.将登录按钮使用qt5版本的连接到自定义的槽函数中&#xff0c;在槽函数中判断ui界面上输入的账…