继承是死的吗?详细研究装饰模式

引入面向对象编程时,继承是用于扩展对象功能的主要模式。今天,遗产通常被认为是一种设计气味。事实上,已经证明使用继承扩展对象通常会导致爆炸的类层次结构(请参阅Exploding class hierarchy部分)。此外,Java和C#等几种流行的编程语言不支持多重继承,这限制了这种方法的好处。

装饰器模式为扩展对象功能提供了一种灵活的继承替代方法。此模式的设计方式是多个装饰器可以堆叠在一起,每个装饰器都添加新功能。与继承相反,装饰器可以对给定接口的任何实现进行操作,这消除了对整个类层次结构进行子类化的需要。此外,使用装饰器模式可以获得干净且可测试的代码(请参阅可测试性其他优势部分)。

可悲的是,今天的大部分软件开发人员对装饰模式的理解有限。这部分是由于缺乏教育,但也是因为编程语言没有跟上面向对象设计原则的发展,以鼓励开发人员学习和使用这些模式。

在本文中,我将讨论使用装饰器模式而不是继承的好处,并建议装饰器模式应该在面向对象的编程语言中具有本机支持。事实上,我认为干净且可测试的代码应该比继承更多地出现装饰器模式。

爆炸类层次结构

当将新功能添加到给定类层次结构所需的类数量呈指数增长时,就会出现爆炸式类层次结构。为了说明,让我们考虑以下界面:

public interface IEmailService

{

    void send(Email email);

    Collection<EmailInfo> listEmails(int indexBegin, int indexEnd);

    Email downloadEmail(EmailInfo emailInfo);

}

如果对电子邮件服务器的请求失败,则EmailService的默认实现引发异常。我们希望扩展EmailService实现,以便在放弃之前重试失败的请求几次。我们还希望能够选择实现是否是线程安全的。

我们可以通过向EmailService类本身添加可选的重试和线程安全功能来实现这一点。该类将接受构造函数中启用/禁用每个功能的参数。但是,此解决方案违反了单一责任原则(因为EmailService将承担额外责任)和开放 - 封闭原则(因为类本身必须进行修改才能进行扩展)。此外,EmailService类可能是我们无法修改的第三方库的一部分。

扩展类而不修改它的常用方法是使用继承。通过继承,派生类继承其父级的属性和行为,并可以选择性地扩展或覆盖其某些功能。在EmailService示例中,我们可以创建三个子类,一个添加重试,一个添加线程安全,另一个添加两个功能。类层次结构如下所示:

请注意,ThreadSafeEmailServiceWithRetries也可以继承自EmailServiceWithRetriesThreadSafeEmailService(如果支持多重继承,则可以继承)。但是,类的数量和结果的功能将是类似的。

除了重试和线程安全之外,我们还想扩展我们的电子邮件服务API,以便可以选择启用日志记录。我们再一次使用继承来扩展类层次结构,如下所示:

请注意,添加对日志记录的支持所需的其他类的数量等于现有层次结构中的类的总数(在本例中为4)。要确认此行为,我们将缓存添加到层次结构并检查结果。新的层次结构如下所示:

正如您所看到的,类层次结构呈指数级增长,并且很快变得难以管理。此问题称为爆炸类层次结构

装饰模式的救援

装饰器模式使用组合而不是继承来扩展对象功能。它消除了爆炸类层次结构的问题,因为每个新特征只需要一个装饰器。为了说明,让我们为重试功能创建一个装饰器。为简单起见,使用了具有三次重试的简单for循环。该EmailServiceRetryDecorator如下:

public class EmailServiceRetryDecorator implements IEmailService

{

    private final IEmailService emailService;

    public EmailServiceRetryDecorator(IEmailService emailService) {

        this.emailService = emailService;

    }

    @Override

    public void send(Email email) {

        executeWithRetries(() -> emailService.send(email));

    }

    @Override

    public Collection<EmailInfo> listEmails(int indexBegin, int indexEnd) {

        final List<EmailInfo> emailInfos = new ArrayList<>();

        executeWithRetries(() -> emailInfos.addAll(emailService.listEmails(indexBegin, indexEnd)));

        return emailInfos;

    }

    @Override

    public Email downloadEmail(EmailInfo emailInfo) {

        final Email[] email = new Email[1];

        executeWithRetries(() -> email[0] = emailService.downloadEmail(emailInfo));

        return email[0];

    }

    private void executeWithRetries(Runnable runnable) {

        for(int i=0; i<3; ++i) {

            try {

                runnable.run();

            } catch (EmailServiceTransientError e) {

                continue;

            }

            break;

        }

    }

}

请注意,EmailServiceRetryDecorator的构造函数引用了IEmailService,它可以是IEmailService的任何实现(包括装饰器本身)。这完全将装饰器与IEmailService的特定实现分离,并提高了它的可重用性和可测试性。同样,我们可以为线程安全,日志记录和缓存创建装饰器。生成的类层次结构如下:

如上面的类图所示,每个要素只需要一个类,结果类层次结构简单且可伸缩(线性增长)。

装饰者队列

乍一看,似乎只有一个功能可以使用装饰器模式添加到给定的实现中。然而,因为装饰器可以堆叠在彼此之上,所以可能性是无穷无尽的。例如,我们可以动态创建我们使用继承创建的EmailServiceWithRetriesAndCaching的等效,如下所示:

IEmailService emailServiceWithRetriesAndCaching = new EmailServiceCacheDecorator(

  new EmailServiceRetryDecorator(new EmailService()));

此外,通过更改装饰器的顺序或在多个级别使用相同的装饰器,我们可以动态创建难以使用继承创建的新实现。例如,我们可以在重试之前和之后添加日志记录,如下所示:

IEmailService emailService = new EmailServiceLoggingDecorator(new EmailServiceRetryDecorator(

        new EmailServiceLoggingDecorator(new EmailService())));

通过这种组合,将记录重试之前和之后的请求状态。这提供了详细日志记录,可用于调试目的或创建丰富的仪表板。

可测性

装饰器相对于继承的另一个主要好处是可测试性。为了说明,我们考虑为重试功能编写单元测试。

我们使用继承创建的EmailServiceWithRetries无法独立于其父类(EmailService)进行测试,因为没有机制用存根(也称为模拟)替换父类。此外,由于EmailService对后端服务器执行网络调用,因此其所有子类都难以进行单元测试(因为网络调用通常很慢且不可靠)。在这种情况下,通常使用集成测试而不是单元测试。

在另一方面,由于EmailServiceRetryDecorator取到参考IEmailService在其构造中,装饰物体可以很容易地与一存根实现(即模拟)代替。这使得可以隔离地测试重试功能,这对于继承是不可能的。为了说明,让我们编写一个单元测试来验证至少执行了一次重试(在本例中我使用了Mockito框架来创建存根)。

//创建第一次失败然后成功的模拟

// Create a mock that fails the first time and then succeed

IEmailService mock = mock(IEmailService.class);

when(mock.downloadEmail(emailInfo))

        .thenThrow(new EmailServiceTransientError())

        .thenReturn(email);

EmailServiceRetryDecorator decorator = new EmailServiceRetryDecorator(mock);

Assert.assertEquals(email, decorator.downloadEmail(emailInfo));

与依赖于EmailService和远程服务调用的实现的集成测试相比,此测试简单,快速且可靠。

其他福利

除了简化类层次结构和提高可测试性之外,装饰器模式还鼓励开发人员编写符合SOLID设计原则的代码事实上,使用装饰器模式,新功能被添加到新的焦点对象(单一责任原则),而无需修改现有类(开放 - 封闭原则)。此外,装饰器模式鼓励使用依赖性反转(它具有诸如松散耦合和可测试性的许多好处),因为装饰器依赖于抽象而不是结构。

缺点

即使装饰器模式比替代方案(继承或修改现有类)具有许多优点,但它具有一些减缓其采用的缺点。

这种模式的一个已知缺点是装饰接口中的所有方法都必须在装饰器类中实现。实际上,不添加任何其他行为的方法必须实现为转发方法以保持现有行为。相反,继承只需要子类来实现改变或扩展基类行为的方法。

为了说明转发方法的问题,让我们考虑以下IProcess接口并为它创建一个装饰器。

public interface IProcess

{

    void start(String args);

    void kill();

    ProcessInfo getInfo();

    ProcessStatus getStatus();

    ProcessStatistics getStatistics();

}

如果进程无法启动,则start方法的默认实现会抛出FailedToStartProcessException。我们想扩展默认实现,以便在放弃之前重试启动过程三次。使用装饰器模式,实现将如下所示:

public class RetryStartProcess implements IProcess

{

    private IProcess process;

    public RetryStartProcess(IProcess process) {

        this.process = process;

    }

    @Override

    public void start(String args) {

        for(int i=0; i<3; ++i) {

            try {

                process.start(args);

            } catch (FailedToStartProcessException e) {

                continue;

            }

            break;

        }

    }

    @Override

    public void kill() {

        process.kill();

    }

    @Override

    public ProcessInfo getInfo() {

        return process.getInfo();

    }

    @Override

    public ProcessStatus getStatus() {

        return process.getStatus();

    }

    @Override

    public ProcessStatistics getStatistics() {

        return process.getStatistics();

    }

}

请注意,此实现包含相当数量的样板代码。实际上,相关实现的唯一部分是start方法的实现。对于具有许多方法的接口,这种锅炉板可被视为生产力和维护开销。

装饰模式的另一个缺点是缺乏流行度,特别是在初级开发人员中。事实上,不那么受欢迎通常意味着更难以理解哪些可能导致更慢的开发时间。

装饰模式的原生支持

如果装饰器模式受益于面向对象编程语言中的本机支持(类似于今天提供的继承),则可以克服上一节中讨论的两个缺点。实际上,通过这种本机支持,不需要转发方法,并且装饰器模式将更容易使用。此外,对装饰器模式的本机支持肯定会增加其受欢迎程度和使用率。

编程语言如何对设计模式的采用产生影响的一个很好的例子是在C#中引入了对Observer模式的本机支持(也称为事件)。今天,C#开发人员(包括初级开发人员)自然地使用Observer模式在松散耦合的类之间传递事件。如果C#中不存在事件,许多开发人员会在类之间引入直接依赖关系来传递事件,这将导致代码重用性降低并且难以测试。类似地,对装饰器模式的本机支持将鼓励开发人员创建装饰器而不是修改现有类或不恰当地使用继承,这将导致更好的代码质量。

以下实现说明了在Java中对装饰器模式的本机支持:

public class RetryStartProcess decorates IProcess

{

    @Override

    public void start(String args) {

        for(int i=0; i<3; ++i) {

            try {

                decorated.start(args);

            } catch (FailedToStartProcessException e) {

                continue;

            }

            break;

        }

    }

}

请注意,使用decorates关键字代替implements,并使用装饰字段来访问装饰对象。为此,装饰器的默认构造函数将需要一个IProcess参数(将在语言级别处理,就像今天处理的无参数默认构造函数一样)。正如您所看到的,这样的原生支持将使装饰器模式免费,并且易于实现为继承(如果不是更容易)。

抽象室内设计师

如果像我一样,你经常使用装饰器模式并且通常最终会为每个接口添加许多装饰器,那么可以使用一种解决方法来减少转发方法的样板(同时直到装饰器的本机支持)模式变得可用)。解决方法包括创建一个抽象装饰器,它将所有方法实现为转发方法,并从中派生(继承)所有装饰器。因为转发方法是从抽象装饰器继承的,所以只需要重新实现装饰方法。此解决方法利用对继承的本机支持,并使用它来实现装饰器模式。以下代码说明了这种方法。

public abstract class AbstractProcessDecorator implements IProcess

{

    protected final IProcess process;

    protected AbstractProcessDecorator(IProcess process) {

        this.process = process;

    }

    public void start(String args) {

        process.start(args);

    }

    public void kill() {

        process.kill();

    }

    public ProcessInfo getInfo() {

        return process.getInfo();

    }

    public ProcessStatus getStatus() {

        return process.getStatus();

    }

    public ProcessStatistics getStatistics() {

        return process.getStatistics();

    }

}

public class RetryStartProcess extends AbstractProcessDecorator

{

    public RetryStartProcess(IProcess process) {

        super(process);

    }

    @Override

    public void start(String args) {

        for(int i=0; i<3; ++i) {

            try {

                process.start(args);

            } catch (FailedToStartProcessException e) {

                continue;

            }

            break;

        }

    }

}

这种方法的一个缺点是装饰器将无法从其他类继承(对于不支持多重继承的语言)。

何时使用继承

虽然我认为应尽可能选择装饰模式而不是继承,但在某些情况下继承更为充分。装饰器难以实现的常见情况是派生类需要访问父类中的非公共字段或方法。因为装饰器只知道公共接口,所以它们无权访问特定于一个或另一个实现的字段或方法。

根据经验,如果您的子类仅依赖于其父类的公共接口,则可以提示您可以使用装饰器。实际上,如果静态分析工具建议在这种情况下用装饰器替换继承,那将是很好的。

小贴士

在可能的情况下,装饰器模式应优先于继承。

装饰器模式消除了继承遇到的类层次结构爆炸的问题。实际上,使用装饰器模式,生成的类层次结构很简单并且线性扩展。

装饰器可以独立于装饰对象进行测试,但子类不能单独测试其父级。对于继承,如果父类难以进行单元测试(例如执行远程调用),则其派生类会继承此问题。但是,因为装饰器只依赖于装饰对象的接口(通过装饰器类的构造函数注入),装饰器可以独立进行单元测试。

使用装饰器模式鼓励开发人员编写符合SOLID设计原则的代码。

面向对象编程语言中对装饰器模式的本机支持将使这种模式更易于使用并增加其采用。

另外本人从事在线教育多年,将自己的资料整合建了一个公众号,对于有兴趣一起交流学习java的可以微信搜索:“程序员文明”,里面有大神会给予解答,也会有许多的资源可以供大家学习分享,欢迎大家前来一起学习进步!

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 218,204评论 6 506
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 93,091评论 3 395
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 164,548评论 0 354
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 58,657评论 1 293
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 67,689评论 6 392
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,554评论 1 305
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 40,302评论 3 418
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 39,216评论 0 276
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,661评论 1 314
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,851评论 3 336
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,977评论 1 348
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,697评论 5 347
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 41,306评论 3 330
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,898评论 0 22
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 33,019评论 1 270
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 48,138评论 3 370
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,927评论 2 355

推荐阅读更多精彩内容