设计模式-5章(原型模式)


第五章 原型模式

原型模式(Prototype Pattern)是一种创建型设计模式,它的主要目标是允许通过复制现有对象来创建新对象,而不需要从头开始重新构建。这种模式适用于那些对象的创建过程比较昂贵或复杂的情况,因为它可以减少对象的创建成本,提高性能,并且使系统更加灵活。原型模式的核心思想是基于已有对象的克隆来创建新对象。

以下是原型模式的详细说明:

角色和责任:

  1. 原型类(Prototype)原型类是需要被克隆的对象的抽象表示。通常,这个类需要实现一个克隆方法(Clone),该方法用于创建并返回对象的副本。

  2. 具体原型类(Concrete Prototype):具体原型类是实现原型类接口的具体对象。它实现了克隆方法以创建对象的副本。

  3. 客户端(Client):客户端是使用原型模式的代码部分,它负责创建原型对象并通过克隆操作来创建新对象。

工作原理:

  1. 客户端首先创建一个原型对象,并配置它的初始状态。

  2. 当需要创建新对象时,客户端通过调用原型对象的克隆方法来获取一个新对象的副本。

  3. 克隆方法会复制原型对象的状态并返回一个新对象,这个新对象和原型对象具有相同的属性值。

优点:

  1. 减少对象的创建成本:原型模式避免了重复创建复杂对象的开销,因为它只需要复制现有对象的状态。

  2. 提高性能:与每次创建新对象相比,克隆对象通常更快,因为它避免了初始化和配置的过程。

  3. 灵活性:原型模式允许动态地添加或删除对象的属性,因为它是基于现有对象的克隆,而不是创建新类。

缺点:

  1. 深拷贝问题:如果原型对象包含了引用类型的成员变量,需要确保进行深拷贝,以避免共享相同引用对象的问题。

  2. 克隆方法的实现:在某些情况下,编写克隆方法可能比较复杂,特别是当对象的状态包含复杂的嵌套结构时。

示例:

  1. 创建一个对象Pig实现Cloneable接口
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class Pig implements Cloneable{
    private int age;
    private String name;

    @Override
    protected Object clone() throws CloneNotSupportedException {
        return super.clone();
    }

    @Override
    public String toString() {
        return "Pig{" +
                "age=" + age +
                ", name='" + name + '\'' +
                '}'+super.toString(); //在打印一下地址
    }
}

要想实现拷贝必须实现Cloneable接口,且重写clone方法。

测试:

public class Main {
    public static void main(String[] args) throws CloneNotSupportedException {
        Pig pig = new Pig().builder().age(6).name("小猪佩奇").build();
        //克隆对象
        Pig pig1 = (Pig) pig.clone();
        System.out.println(pig);
        System.out.println(pig1);
    }
}

执行结果:

Pig{age=6, name='小猪佩奇'}com.nxz.designpattern.prototype.Pig@2f0e140b
Pig{age=6, name='小猪佩奇'}com.nxz.designpattern.prototype.Pig@7440e464

可见对象的属性值全部是一样的,但是他们的地址不一样,所以代表的是两个不同的对象。

这里面其实有一个坑,就是如果对象的属性有引用类型,那么克隆的时候也会克隆引用类型。只不过是浅拷贝,如果你想让两个引用类型的值不一样,那么就必须要进行深拷贝

@Data
public class Food implements Cloneable{
    private String foodName;

    @Override
    protected Object clone() throws CloneNotSupportedException {
        return super.clone();
    }
}
@Builder
@AllArgsConstructor
@NoArgsConstructor
@Getter
@Setter
public class Pig implements Cloneable{
    private int age;
    private String name;
    private Food food ;

    @Override
    protected Object clone() throws CloneNotSupportedException {
        return super.clone();
    }

    @Override
    public String toString() {
        return "Pig{" +
                "age=" + age +
                ", name='" + name + '\'' +
                ", food=" + food +
                '}'+super.toString();
    }
}

测试:

public class Main {
    public static void main(String[] args) throws CloneNotSupportedException {
        Food food = new Food();
        food.setFoodName("玉米");
        Pig pig = new Pig().builder()
                .age(6)
                .name("小猪佩奇")
                .food(food)
                .build();
        //克隆对象
        Pig pig1 = (Pig) pig.clone();
        System.out.println(pig);
        System.out.println(pig1);
        System.out.println("修改引用之后");

        pig1.getFood().setFoodName("青菜");
        System.out.println(pig);
        System.out.println(pig1);

    }
}

执行结果:

Pig{age=6, name='小猪佩奇', food=Food(foodName=玉米)}com.nxz.designpattern.prototype.Pig@2f0e140b
Pig{age=6, name='小猪佩奇', food=Food(foodName=玉米)}com.nxz.designpattern.prototype.Pig@7440e464
修改引用之后
Pig{age=6, name='小猪佩奇', food=Food(foodName=青菜)}com.nxz.designpattern.prototype.Pig@2f0e140b
Pig{age=6, name='小猪佩奇', food=Food(foodName=青菜)}com.nxz.designpattern.prototype.Pig@7440e464

可见克隆出来的两个结果的属性都是一样的,包括引用类型,说明执行的是浅拷贝。如果我想让他们的引用类型的值不一样,但是又要用到克隆怎么办呢?

这时候你就要重写克隆相关的代码了:

@Builder
@AllArgsConstructor
@NoArgsConstructor
@Getter
@Setter
public class Pig implements Cloneable{
    private int age;
    private String name;
    private Food food ;

    //重写这个方法
    @Override
    protected Object clone() throws CloneNotSupportedException {
        //先克隆出一个对象
        Pig pig = (Pig) super.clone();
        //深克隆引用
        pig.food = (Food) pig.food.clone();
        return pig;
    }

    @Override
    public String toString() {
        return "Pig{" +
                "age=" + age +
                ", name='" + name + '\'' +
                ", food=" + food +
                '}'+super.toString();
    }
}

执行结果:

Pig{age=6, name='小猪佩奇', food=Food(foodName=玉米)}com.nxz.designpattern.prototype.Pig@2f0e140b
Pig{age=6, name='小猪佩奇', food=Food(foodName=玉米)}com.nxz.designpattern.prototype.Pig@7440e464
修改引用之后
Pig{age=6, name='小猪佩奇', food=Food(foodName=玉米)}com.nxz.designpattern.prototype.Pig@2f0e140b
Pig{age=6, name='小猪佩奇', food=Food(foodName=青菜)}com.nxz.designpattern.prototype.Pig@7440e464

可见深拷贝执行成功。

对于深浅拷贝的说明看后面的正式章节。


对于创建型模式,前面我们已经讲了单例模式、工厂模式、建造者模式,本章讲最后一个:原型模式。

对于熟悉 JavaScript 语言的前端程序员来说,原型模式是一种比较常用的开发模式。这是因为,有别于 Java、C++ 等基于类的面向对象编程语言,JavaScript 是一种基于原型的面向对象编程语言。即便 JavaScript 现在也引入了类的概念,但它也只是基于原型的语法糖而已。不过,如果你熟悉的是 Java、C++ 等这些编程语言,那在实际的开发中,就很少用到原型模式了。

本章的讲解跟具体某一语言的语法机制无关,而是通过一个 clone 散列表的例子带你搞清楚:原型模式的应用场景,以及它的两种实现方式:深拷贝浅拷贝

1. 原型模式的原理与应用

如果对象的创建成本比较大,而同一个类的不同对象之间差别不大(大部分字段都相同),在这种情况下,我们可以利用对已有对象(原型)进行复制(或者叫拷贝)的方式来创建新对象,以达到节省创建时间的目的。这种基于原型来创建对象的方式就叫作原型设计模式(Prototype Design Pattern),简称原型模式。

1.1 那何为“对象的创建成本比较大”?

实际上,创建对象包含的申请内存、给成员变量赋值这一过程,本身并不会花费太多时间,或者说对于大部分业务系统来说,这点时间完全是可以忽略的。应用一个复杂的模式,只得到一点点的性能提升,这就是所谓的过度设计,得不偿失。

但是,如果对象中的数据需要经过复杂的计算才能得到(比如排序、计算哈希值),或者需要从 RPC、网络、数据库、文件系统等非常慢速的 IO 中读取,这种情况下,我们就可以利用原型模式,从其他已有对象中直接拷贝得到,而不用每次在创建新对象的时候,都重复执行这些耗时的操作。

1.2 这么说还是比较理论,接下来,我们通过一个例子来解释一下刚刚这段话。

假设数据库中存储了大约 10 万条“搜索关键词”信息,每条信息包含关键词、关键词被搜索的次数、信息最近被更新的时间等。系统 A 在启动的时候会加载这份数据到内存中,用于处理某些其他的业务需求。为了方便快速地查找某个关键词对应的信息,我们给关键词建立一个散列表索引。

如果你熟悉的是 Java 语言,可以直接使用语言中提供的 HashMap 容器来实现。其中,HashMap 的 key 为搜索关键词,value 为关键词详细信息(比如搜索次数)。我们只需要将数据从数据库中读取出来,放入 HashMap 就可以了。

不过,我们还有另外一个系统 B,专门用来分析搜索日志,定期(比如间隔 10 分钟)批量地更新数据库中的数据,并且标记为新的数据版本。比如,在下面的示例图中,我们对 v2版本的数据进行更新,得到 v3 版本的数据。这里我们假设只有更新和新添关键词,没有删除关键词的行为。

为了保证系统 A 中数据的实时性(不一定非常实时,但数据也不能太旧),系统 A 需要定期根据数据库中的数据,更新内存中的索引和数据。

我们该如何实现这个需求呢?

实际上,也不难。我们只需要在系统 A 中,记录当前数据的版本 Va 对应的更新时间 Ta,从数据库中捞出更新时间大于 Ta 的所有搜索关键词,也就是找出 Va 版本与最新版本数据的“差集”,然后针对差集中的每个关键词进行处理。如果它已经在散列表中存在了,我们就更新相应的搜索次数、更新时间等信息;如果它在散列表中不存在,我们就将它插入到散列表中。

按照这个设计思路,我给出的示例代码如下所示:

package com.nxz.designpattern.prototype;

public class Demo {
    private ConcurrentHashMap<String, SearchWord> currentKeywords = new ConcurrentHashMap<>();
    private long lastUpdateTime = -1; //当前版本记录的更新的时间

    public void refresh() {
        // 从数据库中取出更新时间>lastUpdateTime的数据,放入到currentKeywords中
        List<SearchWord> toBeUpdatedSearchWords = getSearchWords(lastUpdateTime);
        long maxNewUpdatedTime = lastUpdateTime; //找出数据库中最大(最新)的更新时间
        for (SearchWord searchWord : toBeUpdatedSearchWords) {
            //如果数据库中的最后更新时间 > 最大(最新更新时间)
            if (searchWord.getLastUpdateTime() > maxNewUpdatedTime) {
                maxNewUpdatedTime = searchWord.getLastUpdateTime();
            }
            //如果散列表中存在,直接插入,反之更新
            if (currentKeywords.containsKey(searchWord.getKeyword())) {
                currentKeywords.replace(searchWord.getKeyword(), searchWord);
            } else {
                currentKeywords.put(searchWord.getKeyword(), searchWord);
            }
        }
        lastUpdateTime = maxNewUpdatedTime;
    }

    private List<SearchWord> getSearchWords(long lastUpdateTime) {
        // TODO: 从数据库中取出更新时间>lastUpdateTime的数据
        return null;
    }
}

不过,现在,我们有一个特殊的要求:任何时刻,系统 A 中的所有数据都必须是同一个版本的,要么都是版本 a,要么都是版本 b,不能有的是版本 a,有的是版本 b。那刚刚的更新方式就不能满足这个要求了。除此之外,我们还要求:在更新内存数据的时候,系统 A不能处于不可用状态,也就是不能停机更新数据。

那我们该如何实现现在这个需求呢?

实际上,也不难。我们把正在使用的数据的版本定义为“服务版本”,当我们要更新内存中的数据的时候,我们并不是直接在服务版本(假设是版本 a 数据)上更新,而是重新创建另一个版本数据(假设是版本 b 数据),等新的版本数据建好之后,再一次性地将服务版本从版本 a 切换到版本 b。这样既保证了数据一直可用,又避免了中间状态的存在。

按照这个设计思路,我给出的示例代码如下所示:

public class Demo {
    private HashMap<String, SearchWord> currentKeywords = new HashMap<>();
    private long lastUpdateTime = -1;

    public void refresh() {
        HashMap<String, SearchWord> newKeywords = new LinkedHashMap<>();
        // 从数据库中取出所有的数据,放入到newKeywords中
        List<SearchWord> toBeUpdatedSearchWords = getSearchWords();
        for (SearchWord searchWord : toBeUpdatedSearchWords) {
            newKeywords.put(searchWord.getKeyword(), searchWord);
        }
        currentKeywords = newKeywords;
    }

    private List<SearchWord> getSearchWords() {
        // TODO: 从数据库中取出更新时间>lastUpdateTime的数据
        return null;
    }
}

不过,在上面的代码实现中,newKeywords 构建的成本比较高。我们需要将这 10 万条数据从数据库中读出,然后计算哈希值,构建 newKeywords。这个过程显然是比较耗时。为了提高效率,原型模式就派上用场了。

我们拷贝 currentKeywords 数据到 newKeywords 中,然后从数据库中只捞出新增或者有更新的关键词,更新到 newKeywords 中。而相对于 10 万条数据来说,每次新增或者更新的关键词个数是比较少的,所以,这种策略大大提高了数据更新的效率。

按照这个设计思路,我给出的示例代码如下所示:

public class Demo {
    private HashMap<String, SearchWord> currentKeywords = new HashMap<>();
    private long lastUpdateTime = -1;

    public void refresh() {
        // 原型模式就这么简单,拷贝已有对象的数据,更新少量差值(HashMap中实现了Cloneable接口)
        HashMap<String, SearchWord> newKeywords = (HashMap<String, SearchWord>) currentKeywords.clone();
        
        // 从数据库中取出更新时间>lastUpdateTime的数据,放入到newKeywords中
        List<SearchWord> toBeUpdatedSearchWords = getSearchWords(lastUpdateTime);
        long maxNewUpdatedTime = lastUpdateTime;
        //将所有数据放入一个散列表中,统一切换版本
        for (SearchWord searchWord : toBeUpdatedSearchWords) {
            if (searchWord.getLastUpdateTime() > maxNewUpdatedTime) {
                maxNewUpdatedTime = searchWord.getLastUpdateTime();
            }
            if (newKeywords.containsKey(searchWord.getKeyword())) {
                SearchWord oldSearchWord = newKeywords.get(searchWord.getKeyword());
                oldSearchWord.setCount(searchWord.getCount());
                oldSearchWord.setLastUpdateTime(searchWord.getLastUpdateTime());
            } else {
                newKeywords.put(searchWord.getKeyword(), searchWord);
            }
        }
        lastUpdateTime = maxNewUpdatedTime;
        currentKeywords = newKeywords;//赋值引用,统一切换版本
    }

    private List<SearchWord> getSearchWords(long lastUpdateTime) {
        // TODO: 从数据库中取出更新时间>lastUpdateTime的数据
        return null;
    }
}

这里我们利用了 Java 中的 clone() 语法来复制一个对象。如果你熟悉的语言没有这个语法,那把数据从 currentKeywords 中一个个取出来,然后再重新计算哈希值,放入到newKeywords 中也是可以接受的。毕竟,最耗时的还是从数据库中取数据的操作。相对于数据库的 IO 操作来说,内存操作和 CPU 计算的耗时都是可以忽略的。

不过,不知道你有没有发现,实际上,刚刚的代码实现是有问题的。要弄明白到底有什么问题,我们需要先了解另外两个概念:深拷贝(Deep Copy)和浅拷贝(Shallow Copy)。

2. 原型模式的实现方式:深拷贝和浅拷贝

我们来看,在内存中,用散列表组织的搜索关键词信息是如何存储的。我画了一张示意图,大致结构如下所示。从图中我们可以发现,散列表索引中,每个结点存储的 key 是搜索关键词,value 是 SearchWord 对象的内存地址。SearchWord 对象本身存储在散列表之外的内存空间中。

浅拷贝和深拷贝的区别在于,浅拷贝只会复制图中的索引(散列表),不会复制数据(SearchWord 对象)本身。相反,深拷贝不仅仅会复制索引,还会复制数据本身。浅拷贝得到的对象(newKeywords)跟原始对象(currentKeywords)共享数据(SearchWord 对象),而深拷贝得到的是一份完完全全独立的对象。具体的对比如下图所示:

浅拷贝示意图

深拷贝示意图

在 Java 语言中,Object 类的 clone() 方法执行的就是我们刚刚说的浅拷贝。它只会拷贝对象中的基本数据类型的数据(比如,int、long),以及引用对象(SearchWord)的内存地址,不会递归地拷贝引用对象本身。

在上面的代码中,我们通过调用 HashMap 上的 clone() 浅拷贝方法来实现原型模式。当我们通过 newKeywords 更新 SearchWord 对象的时候(比如,更新“设计模式”这个搜索关键词的访问次数),newKeywords 和 currentKeywords 因为指向相同的一组SearchWord 对象,就会导致 currentKeywords 中指向的 SearchWord,有的是老版本的,有的是新版本的,就没法满足我们之前的需求:currentKeywords 中的数据在任何时刻都是同一个版本的,不存在介于老版本与新版本之间的中间状态。

现在,我们又该如何来解决这个问题呢?

我们可以将浅拷贝替换为深拷贝。newKeywords 不仅仅复制 currentKeywords 的索引,还把 SearchWord 对象也复制一份出来,这样 newKeywords 和 currentKeywords 就指向不同的 SearchWord 对象,也就不存在更新 newKeywords 的数据会导致currentKeywords 的数据也被更新的问题了。

那如何实现深拷贝呢?总结一下的话,有下面两种方法。

第一种方法:递归拷贝对象、对象的引用对象以及引用对象的引用对象……直到要拷贝的对象只包含基本数据类型数据,没有引用对象为止。根据这个思路对之前的代码进行重构。重构之后的代码如下所示:

public class Demo {
    private HashMap<String, SearchWord> currentKeywords = new HashMap<>();
    private long lastUpdateTime = -1;

    public void refresh() {
        // Deep copy
        HashMap<String, SearchWord> newKeywords = new HashMap<>();
        for (HashMap.Entry<String, SearchWord> e : currentKeywords.entrySet()) {
            SearchWord searchWord = e.getValue();
            SearchWord newSearchWord = new SearchWord(
                    searchWord.getKeyword(), searchWord.getCount(), searchWord.getLastUpdateTime());
            newKeywords.put(e.getKey(), newSearchWord);
        }
        // 从数据库中取出更新时间>lastUpdateTime的数据,放入到newKeywords中
        List<SearchWord> toBeUpdatedSearchWords = getSearchWords(lastUpdateTime);
        long maxNewUpdatedTime = lastUpdateTime;
        for (SearchWord searchWord : toBeUpdatedSearchWords) {
            if (searchWord.getLastUpdateTime() > maxNewUpdatedTime) {
                maxNewUpdatedTime = searchWord.getLastUpdateTime();
            }
            if (newKeywords.containsKey(searchWord.getKeyword())) {
                SearchWord oldSearchWord = newKeywords.get(searchWord.getKeyword());
                oldSearchWord.setCount(searchWord.getCount());
                oldSearchWord.setLastUpdateTime(searchWord.getLastUpdateTime());
            } else {
                newKeywords.put(searchWord.getKeyword(), searchWord);
            }
        }
        lastUpdateTime = maxNewUpdatedTime;
        currentKeywords = newKeywords;
    }

    private List<SearchWord> getSearchWords(long lastUpdateTime) {
        // TODO: 从数据库中取出更新时间>lastUpdateTime的数据
        return null;
    }
}

第二种方法:先将对象序列化,然后再反序列化成新的对象。具体的示例代码如下所示:

public Object deepCopy(Object object) {
    //转化为字节输出流
    ByteArrayOutputStream bo = new ByteArrayOutputStream();
    //使用对象流来处理
    ObjectOutputStream oo = new ObjectOutputStream(bo);
    oo.writeObject(object);
    ByteArrayInputStream bi = new ByteArrayInputStream(bo.toByteArray());
    ObjectInputStream oi = new ObjectInputStream(bi);
    return oi.readObject();
}

可以用一些第三方的工具来序列化和反序列化。

刚刚的两种实现方法,不管采用哪种,深拷贝都要比浅拷贝耗时、耗内存空间。针对我们这个应用场景,有没有更快、更省内存的实现方式呢?

我们可以先采用浅拷贝的方式创建 newKeywords。对于需要更新的 SearchWord 对象,我们再使用深度拷贝的方式创建一份新的对象,替换 newKeywords 中的老对象。毕竟需要更新的数据是很少的。这种方式即利用了浅拷贝节省时间、空间的优点,又能保证currentKeywords 中的中数据都是老版本的数据。具体的代码实现如下所示在我们这个应用场景下,最快速 clone 散列表的方式。

public class Demo {
    private HashMap<String, SearchWord> currentKeywords = new HashMap<String, SearchWord>();
    private long lastUpdateTime = -1;

    public void refresh() {
        // Shallow copy
        HashMap<String, SearchWord> newKeywords = (HashMap<String, SearchWord>) currentKeywords.clone();
        
        // 从数据库中取出更新时间>lastUpdateTime的数据,放入到newKeywords中
        List<SearchWord> toBeUpdatedSearchWords = getSearchWords(lastUpdateTime);
        long maxNewUpdatedTime = lastUpdateTime;
        
        for (SearchWord searchWord : toBeUpdatedSearchWords) {
            if (searchWord.getLastUpdateTime() > maxNewUpdatedTime) {
                maxNewUpdatedTime = searchWord.getLastUpdateTime();
            }
            if (newKeywords.containsKey(searchWord.getKeyword())) {
                newKeywords.remove(searchWord.getKeyword());
            }
            newKeywords.put(searchWord.getKeyword(), searchWord);
        }
        lastUpdateTime = maxNewUpdatedTime;
        currentKeywords = newKeywords;
    }

    private List<SearchWord> getSearchWords(long lastUpdateTime) {
        // TODO: 从数据库中取出更新时间>lastUpdateTime的数据
        return null;
    }
}

文章作者: 念心卓
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 念心卓 !
  目录