3、错误的面向对象

1、滥用 getter、setter 方法

在之前参与的项目开发中,我经常看到有同事定义完类的属性之后,就顺手把这些属性的 getter、setter 方法都定义上
有些同事更加省事,直接用 IDE 或者 Lombok 插件(如果是 Java 项目的话)自动生成所有属性的 getter、setter 方法

当我问起,为什么要给每个属性都定义 getter、setter 方法的时候,他们的理由一般是
为了以后可能会用到,现在事先定义好,类用起来就更加方便,而且即便用不到这些 getter、setter 方法,定义上它们也无伤大雅

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

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

1.1、示例

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) {
        items.add(item);
        itemsCount++;
        totalPrice += item.getPrice();
    }

    // ... 省略其他方法 ...
}

1.2、解释

在这段代码中,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() 方法的具体代码实现如下

// 位于 ShoppingCart 类中
public void clear() {
    items.clear();
    itemsCount = 0;
    totalPrice = 0.0;
}

1.3、更多问题

你可能还会说,我有一个需求,需要查看购物车中都买了啥,那这个时候,ShoppingCart 类不得不提供 items 属性的 getter 方法了,那又该怎么办才好呢?
如果你熟悉 Java 语言,那解决这个问题的方法还是挺简单的
我们可以通过 Java 提供的 Collections.unmodifiableList() 方法,让 getter 方法返回一个不可被修改的 UnmodifiableList 集合容器
而这个容器类重写了 List 容器中跟修改数据相关的方法,比如 add()、clear() 等方法
一旦我们调用这些修改数据的方法,代码就会抛出 UnsupportedOperationException 异常,这样就避免了容器中的数据被修改,具体的代码实现如下所示

public class ShoppingCart {

    // ...  省略其他代码 ...

    public List<ShoppingCartItem> getItems() {
        return Collections.unmodifiableList(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 的价格属性

2、滥用全局变量和全局方法

在面向对象编程中

  • 常见的全局变量有:单例类对象、静态成员变量、常量等
    单例类对象在全局代码中只有一份,所以它相当于一个全局变量
    静态成员变量归属于类上的数据,被所有的实例化对象所共享,也相当于一定程度上的全局变量
    常量是一种非常常见的全局变量,比如一些代码中的配置参数,一般都设置为常量,放到一个 Constants 类中
  • 常见的全局方法有:静态方法
    静态方法一般用来操作静态变量或者外部数据
    你可以联想一下我们常用的各种 Utils 类,里面的方法一般都会定义成静态方法,可以在不用创建对象的情况下,直接拿来使用
    静态方法将方法与数据分离,破坏了封装特性,是典型的面向过程风格

在刚刚介绍的这些全局变量和全局方法中,Constants 类和 Utils 类最常用到
现在我们就结合这两个几乎在每个软件开发中都会用到的类,来深入探讨一下全局变量和全局方法的利与弊

2.1、Constants 类

public class Constants {

    private 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 中,这样也提高了类设计的内聚性和代码的复用性

2.2、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 层负责数据读写

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

实际上,这种开发模式叫作基于贫血模型的开发模式,也是我们现在非常常用的一种 Web 项目的开发模式
看到这里,你内心里应该有很多疑惑吧?既然这种开发模式明显违背面向对象的编程风格,为什么大部分 Web 项目都是基于这种开发模式来开发呢?
关于这个问题,我今天不打算展开讲解
因为它跟我们平时的项目开发结合得非常紧密,所以更加细致、全面的讲解,我把它安排在面向对象实战环节里了,希望用两节课的时间,把这个问题给你讲透彻

posted @ 2023-06-24 16:30  lidongdongdong~  阅读(21)  评论(0编辑  收藏  举报