简洁代码之道:用多态替代条件语句及避免全局可变状态

前言

本文是我看了 谷歌简洁代码演讲系列 中的 多态和条件语句 的总结。大部分的条件语句是可以用多态代替的,本文将围绕以下问题开展:

  • 为什么要用多态替代条件语句
  • 多态和条件语句的使用场景
  • 如何用多态代替条件语句
  • 在哪里决定要创建什么子类
  • 什么情况下使用多态

为什么要用多态替代条件语句

  • 没有 if 语句的函数更容易阅读。
  • 没有 if 语句的函数更容易测试。
  • 多态的系统更容易维护。

多态和条件语句的使用场景

使用多态的场景

  • 当对象要根据不同的状态表现不同的行为时。
  • 当你需要在很多地方检查相同的条件时。

使用条件语句的场景

  • 主要用于原始对象的比较:<,>,==,!=。
  • 其他

这篇文章主要着重于如何避免 if 语句。

如何避免使用 if 语句

  • 不要返回 null,而是返回一个空的对象,例如说空的链表。
  • 不要返回错误码,而是直接在运行时抛出异常。

如何用多态代替条件语句

如果你有一个条件语句,它根据对象的类型选择不同的行为。那么如何用多态来替代它呢?下面,我们来看一个例子。

条件语句实现的类

Class Update {
    execute() {
        if (FLAG_i18n_ENABLE) {
            //DO A;
        } else {
            //DO B;
        }
    }
    render() {
        if (FLAG_i18n_ENABLE) {
            //render A;
        } else {
            //render B;
        }
    }
}

上面的类根据 FLAG_i18n_ENABLE 来执行不同的操作。可能你以前就是这么写代码的,觉得这样写一点问题都没有。那么,我现在问你,怎么测试上面的类?

下面我们来看看一般的测试方法。

void testExecuteDoA() {
    FLAG_i18n_ENABLE = true;
    Update u = new Update();
    u.execute();
    assertX();
}
void testExecuteDoB() {
    FLAG_i18n_ENABLE = false;
    Update u = new Update();
    u.execute();
    assertX();
}

看完上面的代码,你可能也觉得似曾相识,也觉得没什么问题。

实际上,这样写的类有以下几个问题:

  • 大量的条件语句判断让代码可读性急剧下降。就好像你在高速公路上行驶的时候,开的正开心,前面一个此路不通的公示牌出现,于是你不得不走别的路。看代码也是同样道理,太多的分支语句会让读者晕头转向。
  • 条件语句的存在让测试更加困难。在写测试的时候,你不得不去考虑它的状态码。上面的类只有两个状态,如果有五个状态呢。光是搞清楚状态之间关系就已经够呛了。

多态实现的类

那么,如何用多态来重写上面的类呢?

我们可以分为两步来操作:

  • 让 Update 成为抽象类,方法也抽象。
  • 在子类中的重载方法实现条件语句的分支操作。

代码如下

abstract class Update {
    abstract execute();
    abstract render();
}

class I18NUpdate extends Update {
    execute() {
        //Do A;
    }
    render() {
        //render A;
    }
}

class NonI18NUpdate extends Update {
    execute() {
        //Do B;
    }
    render() {
        //render B;
    }
}

测试方法:

void testExecuteDoA() {
    Update u = new I18NUpdate();
    u.execute();
    assertX();
}
void testExecuteDoB() {
    Update u = new NonI18NUpdate();
    u.execute();
    assertX();
}

用多态实现的类,通过继承抽象类,重写抽象方法的方式,避免使用了条件语句。在测试的时候,不需要关心它的状态码,子类本身就已经承载了状态信息。所以你可以看到,在测试的时候,代码非常的清晰易懂。

总结

使用多态实现的类有两个好处:

  • 我们可以通过增加新的子类来添加新的行为,而且不会影响到原来的代码。
  • 不同的操作和概念在不同的类中,容易理解和阅读。

在哪里决定要创建什么子类

咋一看上面的标题有点绕,我们来详细讨论一下问题的来源。上面我们讲了如何多态代替条件语句,但是有一个问题是无法回避的:我们怎么判断要创建哪一种子类?

实际上,我们还是要依靠 FLAG_i18n_ENABLE 来决定实例化哪个子类,也就是说,我们仍然要用到条件语句。看到这里你可能就觉得:说到底还是要靠条件语句,博主忽悠人。此言差矣,这么说,条件语句的使用是必要不充分的。写程序你肯定要用到条件语句,但是用的太多会有前面我们说到的问题。把多态和条件语句结合,才是正道。

我们回到前面的问题,既然条件语句无法避免,因为我们要根据条件决定使用哪个子类。我们先来对类做一个粗略的划分。

  • 负责业务逻辑的类:例如说我们上面的 Update
  • 负责创建类的类:例如说工厂模式中的 Factory

通过上面的划分,我们可以把子类创建交给工厂类。

class Consumer {
    Cosumer(Update u) {...}
}

class Factory {
    Consumer build() {
        Update u = FLAG_i18n_ENABLE
                    ? new I18NUpdate()
                    : new NonI18NUpdate;
        return new Consumer(u);
    }
}

现在我们可以回答上面的问题了:在工厂类中根据条件决定要创建哪个子类。这样处理有以下的好处:

  • 条件语句集中在了一个地方。
  • 没有多余的重复,除了工厂类,其他地方不需要用到条件语句。
  • 分离了职责和全局状态。
  • 相同的代码集中在一个地方。
  • 独立测试变得简单,而且能同时进行。
  • 在子类中可以清楚地看到实现的不同。

什么情况下使用多态

多态虽好,可不要滥用。 – Yonah潇

  • 类的行为根据状态进行变化的时候。
  • 同样的条件语句在多个地方出现的时候。

最后,我只说一句:该用条件语句的时候不要强行用多态。

参考资料
多态和条件语句

前言

本文是我看了 谷歌简洁代码演讲系列 中的 全局状态与单例模式 之后的总结。本文的主题是:避免全局可变状态。下面我们将围绕几个问题开展讨论:

  • 什么是全局状态
  • 如何设计好的单例模式
  • 如何设计好的 API

全局状态

什么是全局状态

Talk is cheap, show me the code. – Linus

我们来用一个例子说明什么是全局状态。

class X {
    X() {...}

    public int doSomething() {...}
}

int a = new X().doSomething()
int b = new X().doSomething()

现在问题来了,a 等于 b 吗?事实上有两种可能的情况。

第一种情况:X 类不受全局状态的影响,此时 a == b

a==ba==b
当 X 被实例化时,它可能会同时创建多个其它对象,当它执行 doSomething() 的时候,得到的结果是一样。说明它是无状态的,每次执行都像 1+1=2 一样有一个确定的值。

第二种情况:X 类受到全局状态的影响,此时 a != b

a!=ba!=b
如果 X 在执行 doSomething() 的时候,其中的 Z 变量受到全局状态 GS 的影响,此时 a==b 可能就不成立了。因为程序的执行依赖全局状态,同样的方法可能会得到不同的结果。

全局状态的缺点

全局状态相当常见,因为写起来方便。“啊,有一个新的功能要加入,我们加一个全局变量,再加一个条件语句跳转到新的方法就行了。”然而,这种方便却让程序变得难以维护和测试。所以,有一定经验的程序员都会认为全局状态令人讨厌,会避免使用它。

下面我们来列举全局状态的罪状:

  • 多次执行同一方法会产生不同的结果
    • 测试无法给出一个可靠的结果
    • 测试的顺序会影响到结果
    • 不能并行进行测试
  • 很难确定设置状态的位置

总之,从测试的角度来看,全局状态是很可怕的东西。

全局状态和全局变量的区别

  • 全局状态不仅包括了全局变量,还包括系统的环境变量,以及人为的命令等。
  • 全局变量是在程序生命周期中全局可访问的变量,常用来表示全局状态。

单例模式

有些程序员讨厌全局状态,却喜欢单例模式。但是,从某种意义上说,单例模式是另一种全局状态。当然我不是一棍子打死单例模式,应该说,写得不好的单例模式起到的作用就如同全局状态,让程序难以维护和测试。下面我们来讨论什么是好的单例模式,什么是坏的单例模式

坏的单例模式

下面是典型的单例模式实现。

class AppSetting {
    private static AppSetting instance = new AppSetting();
    private Object state1;
    private Object state2;
    private Object state3;
    private AppSetting() {...}

    public static AppSetting getInstance() {
        return instance;
    }
}

我们先来思考一个问题:这个类包括了多少个全局变量?你可能觉得只有一个 instance,事实上一共有4个。只要instance 一直存在,它的成员变量也会一直存在。也就是说一共有四个全局变量:state1, state2, state3, instance

class App {
    int method() {
        return AppSetting.getInstance().doX();
    }
}

void testApp {
    ???
}

想想我们怎么测试上面的代码。单例模式下,你没有缝隙进入到 method() 函数中测试。

上面的单例模式存在一个很大的测试问题:测试无法覆盖所有的状态。因为状态是私有,同时它单例的。如果我们要测试三个状态怎么办,一个解决办法是在测试的时候把状态改为公有的。这看起来有点诡异,我们一方面又想用单例封装状态,一方面却在测试的时候要去修改代码让它的状态公有。可以说,这种单例模式给测试带来了极大的麻烦。

好的单例模式

那么,什么是好的单例模式呢?看下面的代码。

class AppSetting {
    private Object state1;
    private Object state2;
    private Object state3;
    public AppSetting() {...}
}

第一眼看到这个代码,你可能觉得这哪里是单例模式,明明就是个普通的类。

是的,它的确是个普通的类。在这里我们让它不再着重于类自身的单例。什么意思?想想单例模式的本质是什么,单例模式主要是类保证在程序的生命周期内只有一个实例,其它对象访问到的是同一个实例。我们来看看,这种模式对测试带来了怎样的便利。

class App {
    AppSetting settings;
    App(AppSetting settings) {
        this.settings = settings;
    }

    int method() {
        return settings.doX();
    }
}

void testApp() {
     new App(new AppSetting(...)).method();
}

每个测试我们可以提供一个不同的 AppSetting 来进行测试,相比上面的单例模式,测试得到了更多的控制。我们可以通过不同的 AppSetting 的构造函数,改变程序的状态来进行测试。

看到这里,你可能有一个疑问:这样子写的代码根本就不是单例模式。的确,从类的实现上,AppSetting 的确不是单例模式的。这里我们强调的是逻辑上的单例,而不是代码实现上的单例。怎么理解?

首先,单例模式的传统实现是由类来管理这个唯一的实例,也就是我们上面说的“坏的单例模式”,而“好的单例模式”则是由程序来控制类的唯一实例,例如说,Spring IoC 容器中的 Bean,在容器的生命周期中,Bean 默认是单例的。(详细的解释可以看这篇文章 控制反转(IoC)与依赖注入(DI))简单说,就是把单例类管理唯一实例的功能转移给外部容器,当你使用了 IoC 框架之后,你会发现,单例模式的实例完全可以通过容器管理,而不用我们写“坏的单例模式”。

设计好的 API

全局状态同样会影响到 API 的好坏。

坏的 API

我们来看一个坏的 API。

testCharge() {
    Database.connect();
    OfflineQueue.start();
    CreditCardProcessor.init();
    CreditCard cc = new CreditCard("123");
    cc.charge(100);
}

如果你对单例模式的坏处还没完全理解,或者你也喜欢写这样的代码,那么刷新编程观的时候到了。

上面是一个信用卡测试消费的例子。在实例化 CreditCard 之前要有三个初始化操作(明显都是单例模式)。现在问题来了:如果你是新来的测试人员,让你去测试 CreditCard,你看了 API 文档之后,兴冲冲地写下一些代码。

testCharge() {
    CreditCard cc = new CreditCard("123");
    cc.charge(100);
}

现在满怀期待的运行,结果却是熟悉的 NullPointerException。为什么?新来的你当然不知道创建 CreditCard 之前要先连接数据库,启动离线队列,初始化信用卡处理器。所以你只能去问开发人员。现在你知道问题出在哪了吗?

坏的单例模式让测试人员很难测试代码,因为你看了 API 之后只知道要实例化 CreditCard,然后调用 charge,完全不知道Database 之类的全局状态是什么鬼。不要以为这只会为难到测试人员,六个月之后你就能体验到测试人员的痛苦。因此,全局状态让 API 有了误导性,让你以为做了正确的操作。

当然,文档写的清楚可以解决这个问题,然而好的文档可遇不可求,所以我们要有更好的解决办法。

好的 API

设计好的 API,可以从代码层面上解决上面的问题,所谓代码就是最好的注释。

testCharge() {
    db = new Database(...);
    queue = new OfflineQueue(db);
    ccProc = new CreditCardProcessor(queue);
    CreditCard cc = new CreditCard("123", ccProc);
    cc.charge(100);
}

上面的代码通过让依赖参数化完美地解决上面的问题。现在,我们还是那个新来的测试人员,我们开始写测试代码。API 告诉我们,实例化 CreditCard 需要 CreditCardProcessor 作为参数,CreditCardProcessor需要OfflineQueueOfflineQueue 需要 Database。于是我们可以很清楚的写下上面的测试代码,不需要文档的辅助,我们也知道如何正确的使用 CreditCard。这就是好的 API 设计。

如果我们要让上面的配置类单例化,只需要使用 IoC 容器进行管理即可,通过依赖注入的方式,可以使代码更加清晰,易测试。

总结

  • 全局状态是大多数测试问题的根源。
  • 全局状态无法被测试控制,无法控制意味着无法进行彻底的测试。
  • 单例模式是封装了全局状态的常用形式。这也是我们不提倡使用单例模式的原因,推荐用容器管理的单例模式。
  • 全局状态会让 API 具有误导性。

参考资料
全局状态与单例模式



展开阅读全文

没有更多推荐了,返回首页