从设计模式看面向对象的程序结构组织(Structural Patterns)

面向对象的程序结构组织

一、程序的基本组织结构

首先我们要明确两种基本的组织程序的方式,一种是继承,一种是持有。注意,在传统讨论面向对象程序的时候,我们经常会在术语之间掺杂着一些目的性的定义,例如关联、聚合、合成等等。这种定义的基本特证是,在基本的结构关系之外,还包含一些场景意图(intent),本文暂时不讨论这种定义的目的,也不准备将这些定义进行关系映射。

所谓继承,即是一个类继承另一个类,一个接口继承另一个接口等等此类,其具体细节往往是由编程语言来定义的。所谓持有则表示一个对象的属性中包含了另一个对象。继承和持有的区别是微妙的,继承是一系列的规则,从继承的结果来看——至少是从数据抽象上来看,子类持有了父类的数据对象,所以粗略地说,继承是遵循一系列默认规则下的持有。

再次声明,以上讨论仅仅是纯粹的程序结构组织。

二、持有的分类

一个类的对象持有了另一个类的对象,例如我们说,A类的对象持有了B类对象,那么接下来只能有两种情况:A类与B类实现了共同的接口,及A类与B类没有实现共同的接口。注意,A类和B类可以实现多个接口,即它们可以在实现相同接口的同时,实现不同的接口。

三、组合、装饰与代理模式

首先看持有的模式,也就是说,一个类,比如说Foo,持有了一个它实现了的接口。

    interface Worker{}
    // Foo实现了一个Worker, 并且持有了一个Worker。
    class Foo implements Worker{
    
        private Worker worker;
        
        public Foo(Worker worker){
            this.worker = worker;
        }
    }

这个时候可能发生递归的情况。因为Foo持有了Worker接口,这意味着Foo可能持有它自己。

在结构设计模式类型(Structural Patterns)中,有三个设计模式符合这种情况,分别是组合模式(Composite)装饰器模式(Decorator )代理模式(Proxy )。从外观上看,这三个模式实际上没有什么区别,都是一个类持有了它自己实现的接口。

我们分别看三种模式的介绍。

1. 组合模式

在《Design Pattern》中,组合模式被描述为,用来将一系列的表现类的对象组合成一个树形式。如果你使用过图形编辑软件的话,可能会更好理解一点,我们假设每一个图形,不管它是圆,还是方,都作为一个对象来实现。当你把一个圆和一个方组合起来时,所形成的整体图形仍然是一个图形。

    interface Shape{ 
        void draw();
    }
    
    class Circle implements Shape{
        public void draw(){
            //...draw a circle
        }
    }
    
    class Square implements Shape{
        public void draw(){
            //...draw a Square
        }
    }
    
    // 当你想同时操作圆和方的时候
    class CompositShape{
        private List shapeList = new List<>();
        
        private addShape(Shape shape){
            shapeList.add(shape);
        }
        
        public void draw(){
            shapeList.forEach(item->item.draw());
        }
    }

组合模式的一大特点就是,组合类通常持有多个被组合的对象,主要职责是将调用请求分发到它所持有的对象

2. 装饰器模式

装饰器模式里,装饰器持有一个它自己实现了的接口。它要求你为里面的对象提供额外的功能。例如,我们可以实现一个装饰器,以记录一个方法的访问次数。

    interface Worker{
        void doWork();
    }
    
    // Counter是一个装饰器。
    class Counter implements Worker{
    
        private Worker worker;
        // 这里使用int会有并发问题,但暂时忽略
        priavet int count;
        
        public Counter(Worker worker){
            this.worker = worker;
        }
        
        public void doWork(){
            count++;
            worker.doWork();
        }
    }

在这种定义下,装饰器负责将调用分发给它持有的类,另一方面,一个特定的装饰类还需要实现一些特定的装饰功能。从这个角度看来,我们可以说装饰器模式是组合模式的增强型。

3. 代理模式

代理模式是极度需要被澄清的一个模式,因为在现有被命名为代理的实现中,有许多实际上是装饰器模式。

我们来看一下《设计模式》最初的定义:提供一个占位对象,以对它代理的对象提供访问控制。

    interface FileTool{
        String readFileAsString();
    }
    
    // 当你直接使用FooFile时,会直接创建一个InputStream,占用系统资源。
    class FooFile implements FileTool{
        private InputStream in;
        public FooFile(String filePath) throws FileNotFoundException{
            in = new FileInputStream(filePath);
        }
        public String readFileAsString(){
            //.....
        }
    }
    // 但你不想这么干,你只想在调用具体方法时候才初始化InputStream
    class FooFileProxy implements FileTool{
        private FooFile fooFile;
        private String filePath;
        
        public FooFileProxy(String filePath){
            this.filePath = filePath;
        }
        
        public String readFileAsString(){
            if(!securityCheck()){
                throw new IllegalAccessException();
            }
            
            if(fooFile == null){
                fooFile = new FooFile(filePath);
            }
            
            return fooFile.readFileAsString();
        }
        
        private boolean securityCheck(){
            //..一些自定义的安全检查
        }
    }

上面介绍了一种利用代理模式实现懒加载的方式。可以看到,代理对象持有了一个被代理的对象,在这个例子中,代理对象明确知道(且在编译期就已经知道)自己代理的目标是什么类型,也就是所谓的静态代理。代理对象的职责是,将一个请求分发到它代理的对象,然而代理比一般的组合对象或装饰器有更多的权限,他有权拒绝分发这个请求,或自行决定分发到哪些对象,这就是所谓的访问控制。

在理解动态代理的过程当中,我们首先要忘记具体面向对象语言对于代理的实现,以及那些辅助代理实现的工具类。前面说过,就设计模式的定义来看,这些命名为代理的工具类,既可以实现为装饰器模式,也可以实现为代理模式。

4. 组合、装饰、代理的异同

首先我们介绍一下设计模式的作者对这些模式异同的分析。

组合和装饰模式拥有相似的结构图,其本质原因是它们都依赖于自身组合的递归,这使他们能够复合无穷多次。例如,装饰对象使你能在不继承的情况下向一个类添加职责,由于递归的存在,你完全可以用一个装饰器装饰另一个装饰器,有多少个装饰器,就添加了多少新的职责。相反,组合对象拥有不同的意图,它关注把许多许多相关的对象组装起来,它不关心是否添加新的职责,只关心表现型。这些意图不太一样,但互相补充。

就代理模式与装饰器模式而言,代理模式同样也不关心动态地添加或删除某些类的职责,它也不要求设计是递归组合的,代理仅仅为客户端对象提供一个占位,然后由这个代理对象来负责对实际对象的控制的访问。使用代理的原因有很多,例如对象在远程服务器上,不方便直接访问,或者有严格的访问权限控制,或者已经被持久到数据库去了。

总结起来就是,组合模式除了分发请求,不关心其它的事情,而你如果在组合的基础上点缀了一些新功能,则是装饰模式的内容,进一步,如果这个装饰是访问控制,则跳到了代理模式的领域。当然,这中间还有一些更细微的区别,例如组合模式一般持有一个类型的对象容器,代理模式不要求递归的结构等等。

这些讨论无疑能带来很多启发,但同样拥有一些问题。最主要的问题是,基于意图的区别定义对于设计模式的实现者来说,几乎是一个灾难。由于意图的存在,设计模式对于实现的要求近乎苛斥,这给封闭了实现者对灵活性的要求。

例如,如果同时使用装饰器模式和组合模式,就得这么做——

    interface Worker{
        void doWork();
    }
    
    // WorkerGroup是一个组合器
    class WorkerGroup implements Worker{
        private List workers;
        
        public doWork(){
            workers.forEach(worker->worker.doWork());
        }
    }
    
    // Counter是一个装饰器。
    class Counter implements Worker{
    
        private Worker worker;
        // 这里使用int会有并发问题,但暂时忽略
        priavet int count;
        
        public Counter(Worker worker){
            this.worker = worker;
        }
        
        public void doWork(){
            count++;
            worker.doWork();
        }
    }

它杜绝了下面这种可能——

    interface Worker{
        void doWork();
    }
    
    // WorkerGroup是一个组合器
    class WorkerGroup implements Worker{
        private List workers;
        private int count = 0;
        public doWork(){
            // 装饰功能和分发请求同时发生,但类被命名为Group或Composit。
            count++;
            workers.forEach(worker->{worker.doWork());
        }
    }

即,你只需要在代码里多写了一行,它就会从一个组合模式变为装饰器模式。这种不稳定结构同样体现在Java对动态代理的实现中。从命名上看,动态代理本来的意图是实现代理模式,但问题在于,java本身对这件事情并没有限制。你可以使用Proxy.newProxyInstance来创建很多所谓的代理对象,然后这些对象的方法调用只是被一个InvokeHanlder拦截了。自然,在方法拦截里,我们可以实现访问控制的功能,明白无误地实现一个代理功能。同时,也可以添加别的职责(而且相当方便),例如计数或者日志等等,即把所谓的动态代理变成动态装饰。当一个自由且方便的功能被开发者所掌握时,你很难通过“意图”这种虚构的东西去说服他不要这么做,或者换一种更麻烦的方式去做,即使这种意图在逻辑上不存在什么明显的缺陷。

现在,至少我们可以说,JDK中的代理和设计模式中的代理不完全是一回事。

四、适配、享元与桥接

我们之前提到了代理模式,它实现了持有类的接口,但可以是非递归的。也就是说,它可以不必明确地持有它自己,而是持有相关接口的其它子类。这个区别是微妙的,而且也只是非必须——他可以这么做,也可以不这么做。

但对于适配、桥接与享元来说,持有是非常间接的。

1. 适配器模式

适配器模式将一个接口转换为另一个接口。这件事听起来非常玄乎,但实际上并非如此。转换器仅仅是持有一个待转换的接口对象,然后在自己的类上声明了要转换的目标。

    interface Translator{
        String translate(String message);
    }
    
    interface MessageResolver{
        String resolve(String message);
    }
    
    // 实现了MessageResolver接口,说明它的适配的目标接口
    class TranslatorMessageResolverAdapter implements MessageResolver{
        // 持有一个待转换的接口
        Translator translator;
        
        public TranslatorMessageResolverAdapter(Translator translator){
            this.translator = translator;
        }
        
        public resolve(String message){
            return this.translator.translate(message);
        }
    }

要知道,从设计模式来说,适配器也不是一个接口的实现类持有了另一个接口的实现类这么简单。它要求两个接口从本质上实现的是同一个功能,例如上面的例子中,无论是Translator还是MessageResolver,提供的功能都是对消息的解析。这个问题很好理解,由于依赖接口不依赖实现,以及开闭原则的广泛应用,你的应用程序一开始依赖了MessageResolver,但现在要求切换到Translator,就会出现两套不同的接口,但提供相同功能的情况。

这引出了另一个问题,即避免频繁切换库会对你的程序结构带来不好的影响,因而在这种场景下,有时候应该主动实现适配器功能——为你的应用程序设计一套自己的接口,然后用适配器模式去接入第三方库以维持自身应用程序层的稳定。这个做法回到了接口最初设计的目的,并且我们会发现,这种模式与桥接模式非常接近, 具体在下文会讲解。

2. 桥接模式

我们先来逐渐还原一个桥接模式的经典场景。

首先需要一个应用程序使用的抽象。我们在这里使用了window。一个窗口是一个应用程序对象,它可以在屏幕的一定区域内绘制图像。

    interface Window{
        void drawText();
        void drawRect();
    }

显然,Window的实现应该是平台相关的,但我们在应用程序不关心实现的细节。于是我们实现了两个平台类。


class XWindow implements Window{
    public void drawLine(){
        //..平台相关的实现
    }
}

class PMWindow implements Window{
    public void drawLine(){
        //..平台相关的实现
    }
}

但如果我们扩展接口,使其拥有画线的功能。

    interface Window{
        void drawLine();
    }
    
    interface BorderWindow{
        void drawLine();
    }

现在我们发现我们需要同步继承两个类。

class XBorderWindow extend XWindow implements BorderWindow{
    public drawBorder(){
        // 一个边框是四条线,简化了参数的传递
        drawLine();
        drawLine();
        drawLine();
        drawLine();
    }
}

class PMBorderWindow extend PMWindow implements BorderWindow{
    public drawBorder(){
        drawLine();
        drawLine();
        drawLine();
        drawLine();
    }
}

代码好像有一点多余。虽然XBorderWindow和PMBorderWindow都实现了BorderWindow,但它们实现的方法是完全一样的。

这个时候,桥接器的作用就体现了出来。

换一种思路我们把window定义成非抽象的。

class Window{

    WindowImpl windowImpl;
    
    public Window(WindowImpl windowImpl){
        this.windowImpl = windowImpl;
    }
    
    public void drawLine(){
        this.windowImpl.drawLine();
    }
    
}

令人困惑的是,这里的WindowImpl虽然名字里带有实现,但其实是一个接口,他描述了一个Window实现应该具有的基本功能。这是设计模式里令人困惑的一点,但同样的,在具体的实践里,我们避免了这种命名方式,我们把桥接模式的桥视作SPI(Service Provider Interface),服务提供者接口。而命名方式通常是PlatformWindow之类的。

另外值得一提的是,桥接模式解决的所谓抽象与实现的分离,这里面的抽象并不是指抽象类或者接口,而是指应用程序层的一层抽象。例如Window.drawLine方法我们认为它对具体的调用者来说是具体实现,而相对于特定平台来说是抽象。

class Window{
    
    // 这样是不是更好理解?
    PlatformWindow platformWindow;
    
    public Window(WindowImpl windowImpl){
        this.windowImpl = windowImpl;
    }
    
    public void drawLine(){
        this.windowImpl.drawLine();
    }
    
}

此时,当我们要实现一个BorderWindow的时候就非常方便。

class BorderWindow extends Window{
    public drawBorder(){
        drawLine();
        drawLine();
        drawLine();
        drawLine();
    }
}

聪明的读者已经发现了,PlatformWindow即是应用程序本身声明的一个依赖接口。其它库需要实现这个接口,来实现与应用程序对接,而实现的方式通常都是适配。

也就是说,桥接模式实际上就是适配器的一个拓展,并且在大多数情况下,也很难与适配器模式区分。用设计模式作者的话说,适配器关心的是那些“不小心”(unforeseen)没有适配的过程,而桥接是一种应用程序主动要求(understandsup-front)服务提供者适配的过程。我个人建议实际编程的过程中不必过度关注这种意细节,只要你了解这两种模式实际上是如何运作的即可。

3. 享元模式

享元模式相对而言只是持有一个或一组非自身接口。然而,它持有的元素对应用中一定范围的对象应该是共享的。享元这种用法在今天已经不怎么流行了,我们更愿意用来描述这种技术,例如字符串池等等。

事实也是如此,池的用法比所谓的享元更好理解,尽管它们只是同一个事物的不同侧面而已。享元强调的是一个客户端持有了一个池中的对象,而池则描述了池中的对象应该怎么被客户端持有。前者是一个简单的事实,这个事实很难再进一步复杂化。而通过池的观点,则可以开发出多种高效的应用于高并发环境下的缓存技术。

至于享元模式本身,实在没什么值得多说的。

结语

全文完。

以下是关于设计模式的更多内容。

了解更多关于创建类模式的内容:

  1. 深入理解创建类设计模式(Creational Patterns)
    2.对象的有序行为(Behavioral Patterns)

你可能感兴趣的:(从设计模式看面向对象的程序结构组织(Structural Patterns))