Design Patterns

之前说过了单例模式,这周想说说建造者模式,它是另外一个比较常用的创建型设计模式。

每种设计模式的出现,都是为了解决一些编程不够优雅的问题,建造者模式也是这样。

维基百科解释是:建造者模式,Builder Pattern,又名生成器模式,是一种对象构建模式。它可以将复杂对象的建造过程抽象出来(抽象类别),使这个抽象过程的不同实现方法可以构造出不同表现(属性)的对象。

先上一个例子

借用并改造下《Effective Java》中给出的例子:每种食品包装上都会有一个营养成分表,每份的含量、每罐的含量、每份卡路里、脂肪、碳水化合物、钠等,还可能会有其他N种可选数据,大多数产品的某几个成分都有值,该如何定义营养成分这个类呢?

重叠构造器

因为有多个参数,有必填、有选填,最先想到的就是定义多个有参构造器:第一个构造器只有必传参数,第二个构造器在第一个基础上加一个可选参数,第三个加两个,以此类推,直到最后一个包含所有参数,这种写法称为重叠构造器,有点像叠罗汉。还有一种常见写法是只写一个构造函数,包含所有参数。

代码如下:

public class Nutrition {
    private int servingSize;// required
    private int servings;// required
    private int calories;// optional
    private int fat;// optional
    private int sodium;// optional
    private int carbohydrate;// optional

    public Nutrition(final int servingSize, final int servings) {
        this(servingSize, servings, 0, 0, 0, 0);
    }

    public Nutrition(final int servingSize, final int servings, final int calories) {
        this(servingSize, servings, calories, 0, 0, 0);
    }

    public Nutrition(final int servingSize, final int servings, final int calories, final int fat) {
        this(servingSize, servings, calories, fat, 0, 0);
    }

    public Nutrition(final int servingSize, final int servings, final int calories, final int fat, final int sodium) {
        this(servingSize, servings, calories, fat, sodium, 0);
    }

    public Nutrition(final int servingSize, final int servings, final int calories, final int fat, final int sodium, final int carbohydrate) {
        this.servingSize = servingSize;
        this.servings = servings;
        this.calories = calories;
        this.fat = fat;
        this.sodium = sodium;
        this.carbohydrate = carbohydrate;
    }

    // getter
}

这种写法还可以有效解决参数校验,只要在构造器中加入参数校验就可以了。

如果想要初始化实例,只需要new一下就行:new Nutrition(100, 50, 0, 35, 0, 10)。这种写法,不够优雅的地方是,当caloriessodium值为0的时候,也需要在构造函数中明确定义是0,示例中才6个参数,也能勉强接受。但是如果参数达到20个呢?可选参数中只有一个值不是0或空,写起来很好玩了,满屏全是0和null的混合体。

还有一个隐藏缺点,那就是如果同类型参数比较多,比如上面这个例子,都是int类型,除非每次创建实例的时候仔细对比方法签名,否则很容易传错参数,而且这种错误编辑器检查不出来,只有在运行时会出现各种诡异错误,排错的时候不知道要薅掉多少根头发了。

想要解决上面两个问题,不难想到,可以通过set方法一个个赋值就行了。

set方式赋值

既然构造函数中放太多参数不够优雅,还有缺点,那就换种写法,构造函数只保留必要字段,其他参数的赋值都用setter方法就行了。

代码如下:

public class Nutrition {
    private final int servingSize;// required
    private final int servings;// required
    private int calories;// optional
    private int fat;// optional
    private int sodium;// optional
    private int carbohydrate;// optional

    public Nutrition(int servingSize, int servings) {
        this.servingSize = servingSize;
        this.servings = servings;
    }

    // getter and setter
}

这样就可以解决构造函数参数太多、容易传错参数的问题,只在需要的时候set指定参数就行了。

如果没有特殊需求,到这里可以解决大部分问题了。

但是需求总是多变的,总会有类似“五彩斑斓的黑”这种奇葩要求:

  1. 如果必填参数比较多,或者大部分参数是必填参数。这个时候这种方式又会出现重叠构造器那些缺点。
  2. 如果把所有参数都用set方法赋值,那又没有办法进行必填项的校验。
  3. 如果非必填参数之间有关联关系,比如上面例子中,脂肪fat和碳水化合物carbohydrate有值的话,卡路里calories一定不会为0。但是使用现在这种设计思路,属性之间的依赖关系或者约束条件的校验逻辑就没有地方定义了。
  4. 如果想要把Nutrition定义成不可变对象的话,就不能使用set方法修改属性值。

这个时候就该祭出今天的主角了。

建造者模式

先上代码

public class Nutrition {
    private int servingSize;// required
    private int servings;// required
    private int calories;// optional
    private int fat;// optional
    private int sodium;// optional
    private int carbohydrate;// optional

    public static class Builder {
        private final int servingSize;// required
        private final int servings;// required
        private int calories;// optional
        private int fat;// optional
        private int sodium;// optional
        private int carbohydrate;// optional

        public Builder(final int servingSize, final int servings) {
            this.servingSize = servingSize;
            this.servings = servings;
        }

        public Builder setCalories(final int calories) {
            this.calories = calories;
            return this;
        }

        public Builder setFat(final int fat) {
            this.fat = fat;
            return this;
        }

        public Builder setSodium(final int sodium) {
            this.sodium = sodium;
            return this;
        }

        public Builder setCarbohydrate(final int carbohydrate) {
            this.carbohydrate = carbohydrate;
            return this;
        }

        public Nutrition build() {
            // 这里定义依赖关系或者约束条件的校验逻辑
            return new Nutrition(this);
        }
    }

    private Nutrition(Builder builder) {
        servingSize = builder.servingSize;
        servings = builder.servings;
        calories = builder.calories;
        fat = builder.fat;
        sodium = builder.sodium;
        carbohydrate = builder.carbohydrate;
    }

    // getter
}

想要创建对象,只要调用new Nutrition.Builder(100, 50).setFat(35).setCarbohydrate(10).build()就可以了。这种方式兼具前两种方式的优点:

  • 能够毫无歧义且明确set指定属性的值;
  • build方法或Nutrition构造函数中定义校验方法,可以在创建对象过程中完成校验。

建造者模式的缺点就是代码变多了(好像所有的设计模式都有这个问题),这个缺点可以借助lombok来解决,通过注解@Builder,可以在编译过程自动生成对象的Builder类,相当省事。

再来一个例子

接下来分析下《大话设计模式》中的一个例子,这个例子从代码结构上,和建造者模式有很大的出入,但是作者却把它归为建造者模式。下面我们就来看看究竟:现在需要画个小人,一个小人需要头、身体、左手、右手、左脚、右脚。

代码如下:

public class Person {
    private String head;
    private String body;
    private String leftHand;
    private String rightHand;
    private String leftLeg;
    private String rightLeg;

    // getter/setter
}

public class PersonBuilder {
    private Person person = new Person();

    public PersonBuilder buildHead() {
        person.setHead("头");
        return this;
    }

    public PersonBuilder buildBody() {
        person.setBody("身体");
        return this;
    }

    public PersonBuilder buildLeftHand() {
        person.setLeftHand("左手");
        return this;
    }

    public PersonBuilder buildRightHand() {
        person.setRightHand("右手");
        return this;
    }

    public PersonBuilder buildLeftLeg() {
        person.setLeftLeg("左腿");
        return this;
    }

    public PersonBuilder buildRightLeg() {
        person.setRightLeg("右腿");
        return this;
    }

    public Person getResult() {
        return this.person;
    }
}

但是,如果有个方法忘记调用了,比如画右手的方法忘记调用了,那就成杨过大侠了。这个时候就需要在PersonBuilder之上加一个Director类,俗称监工。

public class PersonDirector {
    private final PersonBuilder pb;

    public PersonDirector(final PersonBuilder pb) {
        this.pb = pb;
    }

    public Person createPerson() {
        this.pb
            .buildHead()
            .buildBody()
            .buildLeftHand()
            .buildRightHand()
            .buildLeftLeg()
            .buildRightLeg();
        return this.pb.getResult();
    }
}

这个时候,对于客户端来说,只需要关注Director类就行了,就相当于在客户端调用构造器之间,增加一个监工,一个对接人,保证客户端能够正确使用Builder类。

细心的朋友可能会发现,我这里的Director类的构造函数增加了一个Builder参数,这是为了更好的扩展,比如,这个时候需要增加一个胖子Builder类,那就只需要定义一个FatPersonBuilder,继承PersonBuilder,然后只需要将新增加的类传入Director的构造函数即可。

这也是建造者模式的另一个优点:可以定义不同的Builder类实现不同的构建属性,比如上面的普通人和胖子两个Builder类。

最后来个总结

有的朋友会说,这两个例子结构差别很大,怎么能是同一个模式?

那我们来看看官方给出的建造者模式的类图:

Builder Patter

这样结构就比较清晰了,两个例子都包含Product类和Builder类(或子类),区别是,第一个例子对象的完整性操作交给了客户端,第二个例子由Director类保障对象的完整。

我们来看看建造者的本质:构建状态完整结构复杂的对象。

  • 结构复杂:如果只有几个属性,通过构造函数就能实现,只有属性多了,结构复杂,建造者模式才能体现价值,建议属性值超过6个时使用建造者模式。
  • 状态完整:状态是否完整可以通过客户端或Director来管理,不会出现因为忘记调用set或其他方法,是对象少定义一个属性。

从某种意义上说,建造者模式是为了弥补构造函数的不足出现的,主要优点是下面3项:

  1. 将一个复杂对象的创建过程封装起来,向客户端隐藏产品内部表现
  2. 允许对象通过多个步骤来创建,并可以改变过程
  3. 产品的实现可以变换,因为客户端只能看到一个抽象的接口

建造者模式作为一种比较实用的设计模式,应用场景主要是下面两个:

  • 当创建复杂对象的算法应该独立于该对象的组成部分以及它们的装配方式时
  • 当构造过程必须允许被构造的对象有不同的表示时

碰到上面两种情况,不要犹豫,果断使用建造者模式就行。

因为疫情的影响,今年的招聘和应聘变得与往年大不一样,原本金三银四的时间可能会延长到5、6月份,这样大家可以有更多的时间准备。但是也有一个不利于应聘者的情况是,随着大家准备的时间延长,基本功是否扎实就会使面试者的表现拉开距离,设计模式就是基本功中的一种。所以准备在最近开启“面向面试之设计模式”的系列,希望能够夯实自己基础的同时,帮助到更多的人。


个人主页: https://www.howardliu.cn
个人博文: 设计模式:建造者模式
CSDN主页: http://blog.csdn.net/liuxinghao
CSDN博文: 设计模式:建造者模式

公众号:看山的小屋