结构型模式-享元模式与组合模式

2020-03-08 1357点热度 0人点赞 0条评论
设计模式系列文章:https://zhum.in/blog/category/notes/design-pattern
Demo code:https://github.com/njZhuMin/BlogSampleCode

享元模式

面向对象可以很好地解决一些灵活性或可扩展性问题,但在很多情况下需要在系统中增加类和 对象的个数。当对象数量太多时,将导致运行代价过高,带来性能下降等问题。享元模式正是为解决这类问题而诞生的。

享元模式 (Flyweight Pattern) 又称为轻量级模式,是对象池的一种实现。例如通过引入线程池,可以避免不停的创建和销毁多个链接对象,减小了性能的损耗。享元模式的宗旨是共享细粒度对象,将多个对同类对象的访问集中管理,不必为每个访问者都创建一个单独的对象,以此来降低内存的消耗。

享元模式把一个对象的状态分成内部状态和外部状态,内部状态即是不变的,外部状态是变化的。通过共享不变的部分,达到减少对象数量并节约内存的目的。

享元模式有三个参与角色:

  • 抽象享元角色 (Flyweight):享元对象抽象基类或者接口,同时定义出对象的外部状态和内部状态的接口或实现;
  • 具体享元角色 (ConcreteFlyweight):实现抽象角色定义的业务。该角色的内部状态处理应该与环境无关,不能出现会有一个操作改变内部状态,同时修改了外部状态;
  • 享元工厂 (FlyweightFactory):负责管理享元对象池和创建享元对象。

享元模式的内部状态和外部状态

享元模式的定义为我们提出了两个要求:细粒度和共享对象。

因为要求细粒度对象,所以不可避免地会使对象数量多且性质相近,此时我们就将这些对象的信息分为两个部分:内部状态和外部状态。

内部状态指对象共享出来的信息,存储在享元对象内部并且不会随环境的改变而改变;外部状态指对象得以依赖的一个标记,是随环境改变而改变的、不可共享的状态。

应用场景

生活中的一些 “共享” 场景:

  • 图书馆借书
  • 共享单车
  • 共享车位

以共享单车举例,其内部状态有:是否损坏,颜色,款式,编码;外部状态:是否解锁,是否已被借出。

当系统中,多个访问者都需要访问相同的信息时,可以把这些信息(固定的状态属性)封装到一个对象中,然后对该对象进行缓存,这样,就可以将同一个对象提供给多个访问者,避免大量的重复创建同一个对象,消耗大量内存空间。

享元模式可以看作是工厂模式的一个改进机制。享元模式同样要求创建一个或一组对象,并且通过工厂方法生成对象。只不过在享元模式中,为工厂方法增加了缓存这一功能。

享元模式主要用于以下场景:

  1. 常应用于系统底层的开发,以便解决系统的性能问题。
  2. 系统中需要提供大量相似对象、需要缓冲池的场景。

这里看一个简易的数据库连接池的实现:

public class SimpleConnectionPool {
    private static Vector<Connection> pool;
    private static final int MAX_SIZE = 100;

    private final String url = "jdbc:mysql://localhost:3306/test";
    private final String username = "root";
    private final String password = "root";
    private final String driverClassName = "com.mysql.jdbc.Driver";

    public SimpleConnectionPool() {
        pool = new Vector<Connection>(MAX_SIZE);
        try {
            Class.forName(driverClassName);
            for (int i = 0; i < MAX_SIZE; i++) {
                Connection conn = DriverManager.getConnection(url, username, password);
                pool.add(conn);
            }
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

    public synchronized Connection getConnection() {
        if (pool.size() > 0) {
            Connection conn = pool.get(0);
            pool.remove(conn);
            return conn;
        }
        return null;
    }

    public synchronized void release(Connection conn) {
        pool.add(conn);
    }
}

对于外部使用者来说,其可见的连接对象的状态操作是创建和释放,可用和不可用。但是从连接池内部的角度来看,这些连接对象的内部状态(如用户名、密码、连接 URL 等信息)是不变的,只是对应从缓存中取出和放回并标记其可用状态(外部状态)。这就是享元模式封装对象从而实现复用的核心思想。

而对于大量连接对象创建这个耗时的行为,我们也可以在系统启动时就完成,在查询时节省宝贵的初始化时间。

JDK 中的享元模式

Java 中将 String 类定义为 final 类型,是不可改变的。 JVM 中字符串一般保存在字符串常量池中,java 会确保一个字符串在常量池中只有一个拷贝。这个字符串常量池在 JDK 6.0 以前是位于永久代中的常量池,而在 JDK 7.0 中,JVM 将其从永久代移除,放置于堆内存中。

看测试代码:

public class StringTest {
    public static void main(String[] args) {
        String s1 = "hello";    // 创建 "hello" 常量,s1 -> &"hello"(#0)
        String s2 = "hello";    // "hello" 常量已存在,s2 -> &"hello"(#0)
        System.out.println(s1 == s2);   // true

        String s3 = "he" + "llo";       // 编译期推断出常量已存在,s3 -> &"hello"(#0)
        System.out.println(s1 == s3);   // true

        // 编译时创建常量 "he",运行时在堆上创建 "llo" 对象
        // 相加结果得到新的 "hello" 对象保存在堆上,和常量池中的 "hello" 不是同一个
        String s4 = "he" + new String("llo");   // s4 -> &"hello"(#1)
        System.out.println(s1 == s4);   // false

        String s5 = new String("hello");    // 运行时在堆上创建 "hello" 对象
        System.out.println(s1 == s5);       // false,s5 -> &"hello"(#2)
        System.out.println(s4 == s5);       // false

        String s6 = s5.intern();    // 取 s5 的常量地址,即常量池中的 &"hello"(#0)
        System.out.println(s1 == s6);   // true

        String s7 = "h";        // 创建常量 "h"
        String s8 = "ello";     // 创建常量 "ello"
        // 编译期只认识常量,由于是变量运算,等到运行时才执行结果
        String s9 = s7 + s8;    // 运行时创建 "hello" 对象,s9 -> &"hello"(#3)
        System.out.println(s1 == s9);   // false
    }
}

再举个大家都非常熟悉的例子,Integer 对象也用到了享元模式,其中暗藏玄机:

public class IntegerTest {
    public static void main(String[] args) {
        Integer a1 = Integer.valueOf(127);
        Integer a2 = 100;
        System.out.println(a1 == a2);   // true

        Integer a3 = Integer.valueOf(128);
        Integer a4 = 500;
        System.out.println(a3 == a4);   // false
    }
}

为什么同样的 valueOf 方法行为却不一样呢,这是因为 Integer 底层使用了享元模式缓存了他认为我们最常用的对象,范围是-128 至 127,上限可以通过 java.lang.Integer.IntegerCache.high 参数设置:

public final class Integer extends Number implements Comparable<Integer> {
    public static Integer valueOf(int i) {
        if (i >= -128 && i <= Integer.IntegerCache.high) {
            return Integer.IntegerCache.cache[i + 128];
        }
        return new Integer(i);
    }
}

同样的 Long 类型也应用了类似的缓存机制,只不过范围是固定的:

public static Long valueOf(long l) {
    int offset = true;
    if (l >= -128L && l <= 127L) {
        return Long.LongCache.cache[(int)l + 128];
    }
    return new Long(l);
}

Apache Commons Pool 中的享元模式

对象池化的基本思路是:将用过的对象保存起来,等下一次需要这种对象的时候,再取出来重复使用,从而在一定程度上减少频繁创建对象所造成的开销。用于充当保存对象的 “容器” 对象,被称为 “对象池”(Object Pool,或简称 Pool) 。

Apache Commons Pool 实现了对象池的功能,定义了对象的生成、销毁、激活、钝化等操作及其状态转换,并提供几个默认的对象池实现。

其中有几个重要的对象:

  • PooledObject(池对象):用于封装对象(如线程、数据库连接、 TCP 连接),将其包裹成可被池管理的对象。
  • PooledObjectFactory(池对象工厂):定义了操作 PooledObject 实例生命周期的一些方法,PooledObjectFactory 必须实现线程安全
  • ObjectPool(对象池):ObjectPool 负责管理 PooledObject,如借出对象,返回对象,校验对象,有多少激活对象,有多少空闲对象。
private final Map<IdentityWrapper<T>, PooledObject<T>> allObjects;

小结

享元模式的优点:减少对象的创建,降低内存中对象的数量,降低系统的内存占用,提高效率。
享元模式的缺点:关注对象的内部、外部状态,关注线程安全问题。

享元模式与代理模式:

  • 两种模式都持有对象的引用。
  • 静态代理一般是一对一的,目的是增强功能;享元模式是一对多的,目的是统一控制对象的访问资源。

享元模式与单例模式:
享元模式一般配合工厂模式来使用,而这个享元工厂类一般实现为到单例。否则缓存对象存在多个地方,很难控制。

组合模式

组合模式 (Composite Pattern) 也称为整体-部分 (Part-Whole) 模式,它的宗旨是通过将单个对象(叶子节点)和组合对象(树枝节点)用相同的接口进行表示,使得客户对单个对象和组合对象的使用具有一致性。

组合模式一般用来描述整体与部分的关系,它将对象组织到树形结构中以表示 “部分-整体” 的层次结构,最顶层的节点称为根节点,根节点下面可以包含树枝节点和叶子节点,树枝节点下面又可以包含树枝节点和叶子节点。

组合模式包含三个角色:

  1. 抽象根节点 (Component):定义系统各层次对象的共有方法和属性,可以预先定义一些默认行为和属性;
  2. 树枝节点 (Composite):定义树枝节点的行为,存储子节点,组合树枝节点和叶子节点形成一个树形结构;
  3. 叶子节点 (Leaf):叶子节点对象,其下再无分支,是系统层次遍历的最小单位。

组合模式在代码具体实现上,有两种不同的方式,分别是透明组合模式安全组合模式

应用场景

当子系统与其内各个对象层次呈现树形结构时,可以使用组合模式让子系统内各个对象层次的行为操作具备一致性。

组合模式主要应用场景:

  1. 希望客户端可以忽略组合对象与单个对象的差异
  2. 对象层次具备整体和部分,呈树形结构。

在我们生活中的组合模式也非常常见,比如树形菜单,操作系统目录结构,公司组织架构等。

透明组合模式

透明组合模式是把所有公共方法都定义在 Component 中,这样做的好处是客户端无需分辨叶子节点 (Leaf) 和树枝节点 (Composite),它们具备完全一致的接口。

看一个透明实现的 Directory 类的案例,把所有可能用到的方法都定义到这个最顶层的抽象类中,但是不写任何逻辑处理的代码。

public abstract class Directory {
    protected String name;
    public Directory(String name) {
        this.name = name;
    }

    public void add(Directory dir) {
        throw new UnsupportedOperationException("add not supported");
    }

    public void remove(Directory dir) {
        throw new UnsupportedOperationException("remove not supported");
    }

    public void ls() {
        throw new UnsupportedOperationException("ls not supported");
    }

    public void tree() {
        throw new UnsupportedOperationException("tree not supported");
    }
}

这里有些同学会感到疑惑,为什么不用抽象方法?因为定义了抽象方法,其子类就必须实现,这样便体现不出各子类的细微差异。因此,子类继承此抽象类后,只需要重写有差异的方法来覆盖父类的对应方法即可。

public class File extends Directory {
    public File(String name) {
        super(name);
    }
    @Override
    public void tree() {
        System.out.println(this.name);
    }
}

public class Folder extends Directory {
    private List<Directory> dirs;
    private Integer dirLevel;

    public Folder(String name, Integer dirLevel) {
        super(name);
        this.dirLevel = dirLevel;
        this.dirs = new ArrayList<Directory>();
    }

    @Override
    public void add(Directory dir) {
        this.dirs.add(dir);
    }
    @Override
    public void remove(Directory dir) {
        this.dirs.remove(dir);
    }
    @Override
    public void ls() {
        for (Directory dir : this.dirs) {
            System.out.println(dir.name + (dir instanceof Folder ? "/" : ""));
        }
    }
    @Override
    public void tree() {
        System.out.println(this.name + "/");
        for (Directory dir : this.dirs) {
            if (this.dirLevel != null) {
                for (int i = 0; i < this.dirLevel; i ++) {
                    System.out.print("  ");
                }
                for (int i = 0; i < this.dirLevel; i ++) {
                    if (i == 0) {
                        System.out.print("+");
                    }
                    System.out.print("-");
                }
            }
            dir.tree();
        }
    }
}

public class Test {
    public static void main(String[] args) {
        System.out.println("[Transparent Composite]");

        Folder home = new Folder("Home", 1);
        File a = new File("a.txt");
        File b = new File("b.doc");
        home.add(a);
        home.add(b);

        Folder apps = new Folder("Apps", 2);
        File chrome = new File("Chrome.exe");
        File firefox = new File("Firefox.exe");
        apps.add(chrome);
        apps.add(firefox);

        File word = new File("Word.exe");
        File excel = new File("Excel.exe");
        File ppt = new File("Ppt.exe");
        Folder office = new Folder("Office", 3);
        office.add(word);
        office.add(excel);
        office.add(ppt);

        apps.add(office);
        home.add(apps);

        System.out.println("[List home folder]");
        home.ls();

        System.out.println("[Tree home folder]");
        home.tree();

        // Transparent implement
        // Not safe: caller can call unsupported method defined in Interface
        chrome.ls();
        // Exception in thread "main" java.lang.UnsupportedOperationException: ls not supported
    }
}

透明组合模式的缺点是,叶子节点 (Leaf) 会继承得到一些它所不需要的方法,这与接口隔离的设计原则相违背。

安全组合模式

安全组合模式是只规定系统各个层次的最基础的一致行为,而把组合(树节点)本身的方法放到实现子类中。

还是举文件系统的例子,文件系统有两大层次:文件夹,文件。其中,文件夹能容纳其他层次,为树枝节点;文件为最小单位,为叶子节点。

由于目录系统层次较少,且树枝节点(文件夹)结构相对稳定,而文件可以有很多类型,所以选择使用安全组合模式来实现目录系统,可以避免为叶子类型(文件)引入冗余方法。

public abstract class Directory {
    protected String name;
    public Directory(String name) {
        this.name = name;
    }

    public abstract void tree();
}

public class File extends Directory {
    public File(String name) {
        super(name);
    }
    @Override
    public void tree() {
        System.out.println(this.name);
    }
}

public class Folder extends Directory {
    private List<Directory> dirs;
    private Integer dirLevel;

    public Folder(String name, Integer dirLevel) {
        super(name);
        this.dirLevel = dirLevel;
        this.dirs = new ArrayList<Directory>();
    }
    public void add(Directory dir) {
        this.dirs.add(dir);
    }
    public void remove(Directory dir) {
        this.dirs.remove(dir);
    }
    public void ls() {
        for (Directory dir : this.dirs) {
            System.out.println(dir.name + (dir instanceof Folder ? "/" : ""));
        }
    }

    @Override
    public void tree() {
        System.out.println(this.name + "/");
        for (Directory dir : this.dirs) {
            if (this.dirLevel != null) {
                for (int i = 0; i < this.dirLevel; i ++) {
                    System.out.print("  ");
                }
                for (int i = 0; i < this.dirLevel; i ++) {
                    if (i == 0) {
                        System.out.print("+");
                    }
                    System.out.print("-");
                }
            }
            dir.tree();
        }
    }
}

public class Test {
    public static void main(String[] args) {
        System.out.println("[Safe Composite]");

        Folder home = new Folder("Home", 1);
        File a = new File("a.txt");
        File b = new File("b.doc");
        home.add(a);
        home.add(b);

        Folder apps = new Folder("Apps", 2);
        File chrome = new File("Chrome.exe");
        File firefox = new File("Firefox.exe");
        apps.add(chrome);
        apps.add(firefox);

        File word = new File("Word.exe");
        File excel = new File("Excel.exe");
        File ppt = new File("Ppt.exe");
        Folder office = new Folder("Office", 3);
        office.add(word);
        office.add(excel);
        office.add(ppt);

        apps.add(office);
        home.add(apps);

        System.out.println("[List home folder]");
        home.ls();

        System.out.println("[Tree home folder]");
        home.tree();

        // Safe implement
        // Safe: caller cannot call unsupported method as is defined in subclass
        // chrome.ls();
    }
}

我们可以看到,由于子节点自身的实现并不在接口中公开保留,File 对象就无法调用到 Folder 对象才有的 ls 方法,从而在调用端保证了安全性。

安全组合模式的好处:

  • 接口定义职责清晰,符合设计模式单一职责原则和接口隔离原则;

缺点:

  • 客户需要区分树枝节点 (Composite) 和叶子节点 (Leaf),这样才能正确处理各个层次的操作;
  • 客户端无法依赖抽象 (Component),违背了设计模式依赖倒置原则。

组合模式在源码中的应用

HashMap

组合模式在源码中应用也是非常广泛的。首先我们来看一个非常熟悉的 HashMap.putAll()方法:

public class HashMap<K, V> extends AbstractMap<K, V> implements Map<K, V>, Cloneable, Serializable {
    public void putAll(Map<? extends K, ? extends V> m) {
        this.putMapEntries(m, true);
    }

    final void putMapEntries(Map<? extends K, ? extends V> m, boolean evict) {
        int s = m.size();
        if (s > 0) {
            // ...
            Iterator var8 = m.entrySet().iterator();
            while(var8.hasNext()) {
                Entry<? extends K, ? extends V> e = (Entry)var8.next();
                K key = e.getKey();
                V value = e.getValue();
                this.putVal(hash(key), key, value, false, evict);
            }
        }
    }
}

我们看到 putAll() 方法传入的是 Map 对象,Map 就是一个抽象构件(同时这个构件中只支持键值对的存储格式),而 HashMap 是一个中间构件。 HashMap 中的 Node 节点就是叶子节点。

HashMap 中的存储方式是一个静态内部类的数组 Node<K,V>[] tab

static class Node<K, V> implements Entry<K, V> {
    final int hash;
    final K key;
    V value;
    HashMap.Node<K, V> next;

    Node(int hash, K key, V value, HashMap.Node<K, V> next) {
        this.hash = hash;
        this.key = key;
        this.value = value;
        this.next = next;
    }
}

ArrayList

同理,我们常用的 ArrayList 对象中的 addAll()方法,其参数也是 ArrayList 的父类 Collection:

public class ArrayList<E> extends AbstractList<E> implements List<E>, RandomAccess, Cloneable, Serializable {
    public boolean addAll(Collection<? extends E> c) {
        Object[] a = c.toArray();
        ++this.modCount;
        int numNew = a.length;
        if (numNew == 0) {
            return false;
        } else {
            Object[] elementData;
            int s;
            if (numNew > (elementData = this.elementData).length - (s = this.size)) {
                elementData = this.grow(s + numNew);
            }

            System.arraycopy(a, 0, elementData, s, numNew);
            this.size = s + numNew;
            return true;
        }
    }
}

MyBatis

再举一个 MyBatis 中非常经典的案例,MyBatis 解析各种 Mapping 文件中的 SQL,设计了一个非常关键的类叫做 SqlNode 。 XML 中的每一个 Node 都会解析为一个 SqlNode 对象,最后把所有的 SqlNode 拼装到一起,从而生成完整的 SQL 语句。

它的顶层设计非常简单:

public interface SqlNode {
    boolean apply(DynamicContext context);
}

apply()方法会根据传入的参数 context,解析该 SqlNode 所记录的 SQL 片段,并调用 DynamicContext.appendSql()方法拼接 SQL 片段。当 SQL 节点下的所有 SqlNode 完成解析后,可以通过 DynamicContext.getSql()获取一条完成的 SQL 语句。

组合模式小结

既然组合模式分为两种实现,那么不同的实现方式会更适合某些特定场景,即具体情况具体分析。

  1. 透明组合模式:适用于系统中绝大多数层次具备相同的公共行为时;代价是为剩下少数层次节点引入不需要的方法。
  2. 安全组合模式:适用于系统中各个层次差异性行为较多或者树节点层次相对稳定(健壮)时。

下面我们再来总结一下组合模式的优缺点。

优点:

  1. 清楚地定义分层次的复杂对象,表示对象的全部或部分层次
  2. 让客户端忽略系统层次的差异,方便对整个层次结构进行控制
  3. 简化客户端代码,符合开闭原则

缺点:

  1. 限制类型时会较为复杂
  2. 使设计变得更加抽象

SilverLining

也可能是只程序猿

文章评论