Java设计模式之——原型模式

原型模式简单介绍

原型模式是一个创建型的模式。原型二字表明了该模式应该有一个样板实例,用户从这个样板对象中复制出一个内部属性一致的对象,这个过程也就是我们俗称的“克隆”。被复制的实例就是我们所称的“原型”,这个原型是可定制的。原型模式多用于创建复杂的或者构造耗时的实例,因为这种情况下,复制一个已经存在的实例可使程序运行更高效。

原型模式的定义

用原型实例指定创建对象的种类,并通过拷贝这些原型创建新的对象。

原型模式的使用场景

  • (1)类初始化需要消耗非常多的资源,这个资源包括数据、硬件资源等,通过原型拷贝避免这些消耗。
  • (2)通过 new 产生一个对象需要非常繁琐的数据准备或访问权限,这时可以使用原型模式。
  • (3)一个对象需要提供给其他对象访问,而且各个调用者可能都需要修改其值时,可以考虑使用原型模式拷贝多个对象供调用者使用,即保护性拷贝。

*需要注意的是,通过实现 Cloneable 接口的原型模式在调用 clone 函数构造实例时并不一定比通过 new 操作速度快,只有当通过 new 构造对象较为耗时时或者说成本比较高时,通过 clone 方法才能够获得效率上的提升。因此,在使用 Cloneable 时需要考虑构建对象的成本以及做一些效率上的测试。当然,实现原型模式也不一定非要实现 Cloneable 接口,也有其他的实现方式,后面会详细介绍

原型模式 UML 类图

这里写图片描述

角色介绍:

  • Client:客户端用户。
  • Prototype:抽象类或者接口,声明具备 clone 能力。
  • ConcretePrototype:具体的原型类。

原型模式的简单实现

下面以简单的文档拷贝为例来演示一下简单的原型模式,我们在这个例子中首先创建了一个文档对象,即 WordDocument,这个文档中含有文字和图片。用户经过了长时间的内容编辑后,打算对文档做进一步的编辑,但是,这个编辑后的文档是否会被采用还不确定,因此,为了安全起见,用户需要将当前文档拷贝一份,然后再在文档副本上进行修改,如此,这个原始文档就是我们上述所说的样板实例,也就是将要被“克隆”的对象,我们称为原型:

//文档类型,扮演的是 ConcretePrototype 角色,而 Cloneable 是代表 Prototype 角色
public class WordDocument implements Cloneable {
    //文本
    private String mText;
    //图片名列表
    private ArrayList<String> mImages = new ArrayList<>();

    public WordDocument() {

    }

    @Override
    public WordDocument clone() {
        try {
            WordDocument doc = (WordDocument) super.clone();
            doc.mText = this.mText;
            doc.mImages = this.mImages;
            return doc;
        } catch (CloneNotSupportedException e) {

        }
        return null;
    }

    public String getmText() {
        return mText;
    }

    public void setmText(String mText) {
        this.mText = mText;
    }

    public ArrayList<String> getmImages() {
        return mImages;
    }

    public void setmImages(ArrayList<String> mImages) {
        this.mImages = mImages;
    }

    public void showDocument() {
        Log.d("WordDocument", "---------- Word Content Start -----------");
        Log.d("WordDocument", "Text :" + mText);
        Log.d("WordDocument", "Images List :" + mImages.toString());
        Log.d("WordDocument", "---------- Word Content End -----------");
    }
}

通过 WordDocument 类模拟了 Word 文档中的基本元素,即文字和图片。WordDocument 在该原型模式示例中扮演的角色是 ConcretePrototype,而 Cloneable 的角色则为 Prototype。WordDocument 中的 clone 方法用以实现对象克隆。注意,这个方法并不是 Cloneable 接口中的,而是 Object 中的方法。Cloneable 也是一个标识接口,它表明这个类的对象是可拷贝的。如果没有实现 Cloneable 接口确调用了 clone() 函数将抛出异常。 在这个示例中,我们通过实现 Cloneable 接口和覆写 clone 方法实现原型模式。

下面看看 Client 端的使用:

        //1、构建文档对象
        WordDocument originDoc = new WordDocument();
        //2、编辑文档,添加图片等
        originDoc.setmText("这是一篇文档");
        originDoc.addImages("图片 1");
        originDoc.addImages("图片 2");
        originDoc.addImages("图片 3");

        originDoc.showDocument();

        //以原始文档为原型,拷贝一份副本
        WordDocument doc2 = originDoc.clone();
        doc2.showDocument();
        //修改文档副本,不会影响原始文档
        doc2.setmText("这是修改过的 doc2 文本");
        doc2.showDocument();

        originDoc.showDocument();

输出 log 如下显示:

这里写图片描述

从上面的代码可以看出 doc2 是通过 originDoc.clone() 创建的,并且 doc2 第一次输出的时候和 originDoc 输出是一样的,即 doc2 是 originDoc 的一份拷贝,它们的内容是一样的,而 doc2 修改了文本内容以后并不会影响 originDoc 的文本内容,这就保证了 originDoc 的安全性。还需要注意的是,通过 clone 拷贝对象时并不会执行构造函数!因此,如果在构造函数中需要执行一特殊的初始化操作的类型,在使用 Cloneable 实现拷贝时,需要注意构造函数不会执行的问题。

浅拷贝和深拷贝

上述原型模式的实现实际上只是一个浅拷贝,也称为影子拷贝,这份拷贝实际上并不是将原始文档的所有字段都重新构造了一份,而是副本文档的字段引用原始文档的字段,如下图所示:

这里写图片描述

我们知道 A 引用 B 就是说两个对象指向同一个内存地址,当修改 A 时 B 也会改变,B 修改时 A 同样会改变。我们直接看下面的例子,将内容修改为如下:

        //1、构建文档对象
        WordDocument originDoc = new WordDocument();
        //2、编辑文档,添加图片等
        originDoc.setmText("这是一篇文档");
        originDoc.addImages("图片 1");
        originDoc.addImages("图片 2");
        originDoc.addImages("图片 3");

        originDoc.showDocument();

        //以原始文档为原型,拷贝一份副本
        WordDocument doc2 = originDoc.clone();
        doc2.showDocument();
        //修改文档副本,不会影响原始文档
        doc2.setmText("这是修改过的 doc2 文本");
        //新加入代码
        doc2.addImages("哈哈.jpg");
        doc2.showDocument();

        originDoc.showDocument();

再次运行程序,输出结果如下图所示:

这里写图片描述

正如我们红色标记出显示的那样,最后我们答应的修改后的 doc2 和 未修改之前 originDoc 中居然出现了相同的数据。我们在 doc2 中添加了一张名为 “哈哈.jpg”的照片,但是,同时也显示在了 originDoc 中,这是怎么一回事呢?学习过 C++ 的朋友都会有比较深刻的体会,这是因为上文中的 WordDocument 的 clone 方法中只是简单地进行了浅拷贝,引用类型的新对象 doc2 的 mImages 只是单纯地指向了 this.mImages 引用,并没有重新构造一个 mImages 对象,然后将原始文档中的图片添加到新的 mImages 对象中,这样就导致 doc2 中的 mImages 与原始文档中的是同一个对象,因此,修改了其中一个文档中的图片,另一个文档也会受影响。doc2 的 mImages 添加了新的图片,实际上也就是往 originDoc 里添加了新的图片,所以,originDoc 里面也有 “哈哈.jpg”图片文件。那如何解决这个问题呢?答案就是采用深拷贝,即在拷贝对象时,对与引用型的字段也要采用拷贝的形式,而不是单纯引用的形式。 clone 方法修改如下:

    @Override
    public WordDocument clone() {
        try {
            WordDocument doc = (WordDocument) super.clone();
            doc.mText = this.mText;
            doc.mImages = (ArrayList<String>) this.mImages.clone();
            return doc;
        } catch (CloneNotSupportedException e) {

        }
        return null;
    }

如上述代码所示,将 doc.mImages 指向 this.mImages 的一份拷贝,并不是 this.mImages 本身,这样在 doc2 添加图片时并不会影响 originDoc,运行效果如下图所示:

这里写图片描述

原型模式是非常简单的一个模式,它的核心问题就是对原始对象进行拷贝,在这个模式的使用过程中需要注意的一点就是:深、浅拷贝的问题。在开发过程中,为了减少错误,这里建议大家在使用该模式时尽量使用深拷贝,避免操作副本时影响原始对象的问题。

原型模式实战

比如说有这么一个例子:在开发中,我们有时候会满足一些需求,就是有的对象中的内容只允许客户端程序读取,而不允许修改。我们的应用中一般都有用户登录模块,就拿这个模块来说吧,在用户登录之后,我们会通过一个 LoginSession 保存用户的登录信息,这些用户信息可能在 APP 的其他模块被用来做登录校验、用户个人信息显示等。但是,这些信息在客户端程序是不允许修改的,而需要在其他模块被调用,因此,需要开放已登录用户信息的访问接口。我们先来简单实现一下,然后在找出其中的问题:

初始代码如下所示:

//用户实体类
public class User {
    public int age;
    public String name;
    public String phoneNum;
    public Address address;

    @Override
    public String toString() {
        return "User{" +
                "age=" + age +
                ", name='" + name + '\'' +
                ", phoneNum='" + phoneNum + '\'' +
                ", address=" + address +
                '}';
    }
}

//用户地址类,存储地址的详细信息
public class Address {
    public String city;     //城市
    public String district; //区
    public String street;   //街道

    public Address(String city, String district, String street) {
        this.city = city;
        this.district = district;
        this.street = street;
    }

    @Override
    public String toString() {
        return "Address{" +
                "city='" + city + '\'' +
                ", district='" + district + '\'' +
                ", street='" + street + '\'' +
                '}';
    }
}

//登录接口
public interface Login {
    void login();
}

//登录实现
public class LoginImpl implements Login {
    @Override
    public void login() {
        //登录到服务器,获取到用户信息
        User loginedUser = new User();
        //模仿服务器返回的用户信息并设置给 loginedUser对象
        loginedUser.age = 22;
        loginedUser.name = "aKaiC";
        loginedUser.address = new Address("北京市", "朝阳区", "华贸天地");
        //登录校验完成以后,将登录的用户信息存储到 Session 中
        LoginSession.getLoginSession().setsLoginSession(loginedUser);
    }
}

//登录 Session
public class LoginSession {
    static LoginSession sLoginSession = null;
    private User loginedUser;

    private LoginSession() {
    }

    ;

    public static LoginSession getLoginSession() {
        if (sLoginSession == null) {
            synchronized (LoginSession.class) {
                if (sLoginSession == null) {
                    sLoginSession = new LoginSession();
                }
            }
        }
        return sLoginSession;
    }

    //设置已登录的用户信息,不对外开放
    void setsLoginSession(User user) {
        loginedUser = user;
    }

    public User getLoginedUser() {
        return loginedUser;
    }
}

上述代码非常简单,就是在用户登录之后通过 LoginSession 的 setLoginedUser 函数将登录用户的信息设置到 Session 中,这个 setLoginedUser 方法是包级私有的,因此,外部模块无法调用,我们如果要更新用户信息只需要调用 LoginImple 的 login 方法即可(当然这个 login 方法并不是很完善,大家能理解这个意思即可),也就是外部客户端程序不能修改已登录的用户信息。

现在我们就来找找上面我们程序中的 bug,现在假设有这么一种可能:比如说由于你的效率比较高,在分配给你的模块完成后,后台服务器那块功能还未完成不能进行测试?你也闲着无聊就开始了使用假数据进行测试的方案。由于不能采用网络请求,那么就不能通过自动调用 Login 接口的 login 方法来更新数据,然而聪明的你为了方便,变写出了如下代码来更新数据:

        User newUser = LoginSession.getLoginSession().getLoginedUser();
        newUser.address = new Address("上海市", "XX区", "XXXX");

经过测试,确实可以更改用户的地址信息,你也就放心啦!刚好这个时候朋友喊你出去抽烟,你代码没有恢复就跑出去了,回来由于一忙也把这茬事给忘了,等到后台布置完成后,开始真正的测试了,你会发现在网络请求未成功之前打出的 Log 信息却包含了“”上海市”, “XX区”, “XXXX””这些内容(这个例子并不是很恰当,现实开发中可能根本不存在,这里只是简单的举个例子),也就是在网络请求未成功的情况下修改了用户的 address 字段!你感觉自己设置的用户信息更新只限于与 LoginSession 类在同一个包下的限制瞬间被打破了,这样以来不管客户端代码是无意间写错了代码导致用户信息被修改,还是对代码理解有误导致的问题,最终结果都是用户信息被修改了,这时你就慌神了,开始各种百度找问题,于是就有了下面一个结论:“必须使用原型模式来进行保护性拷贝,也就是说在 LoginSession 的 getLoginUser() 函数中返回的是已登录用户的拷贝,当更新用户地址的网络请求完成时,再通过包私有的 LoginSession 中的 setLoginedUser 更新用户信息”于是就在 User 类中覆写了 clone 方法:

//用户实体类
public class User implements Cloneable {
    public int age;
    public String name;
    public String phoneNum;
    public Address address;

    @Override
    public String toString() {
        return "User{" +
                "age=" + age +
                ", name='" + name + '\'' +
                ", phoneNum='" + phoneNum + '\'' +
                ", address=" + address +
                '}';
    }

        @Override
    public User clone() {
        User user = null;
        try {
            user = (User) super.clone();
        } catch (CloneNotSupportedException e) {
            e.printStackTrace();
        }
        return user ;
    }
}

并且在 LoginSession 中将 getLoginedUser 函数修改如下:

    public User getLoginedUser() {
        return loginedUser.clone();
    }

这就使得在任何时候调用 getLoginedUser 函数获取到的用户对象都是一个拷贝对象,即使客户端代码不小心修改了这个拷贝对象,也不会影响最初的已登录用户对象,对已登录用户信息的修改只能通过 setLoginedUser 这个方法,即使我们还是采用 new Address() 更新的也仅仅是拷贝对象,只有与 LoginSession 在同一个包下的类才能够访问这个包级私有方法,因此,确保了它的安全性。

总结

原型模式本质上就是对象拷贝,与 C++ 中的拷贝构造函数有些类似,它们之间容易出现的问题也都是深拷贝、浅拷贝。使用原型模式可以解决构建复杂对象的资源消耗问题,能够在某些场景下提升创建对象的效率。还有一个重要的途径就是保护性拷贝,也就是某个对象对外可能是只读的,为了防止外部对这个只读对象修改,通常可以通过返回一个对象拷贝的形式实现只读的限制。

优点与缺点:

  • 优点

原型模式是在内存中二进制流的拷贝,要比直接 new 一个对象性能好很多,特别是要在一个循环体内产生大量的对象时,原型模式可以更好滴体现其优点。

  • 缺点

这既是它的优点也是缺点,直接在内存中拷贝,构造函数是不会执行的,在实际开发中应该注意这个潜在问题,优点就是减少了约束,缺点也是减少了约束,需要大家在实际应用是考虑。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值