引入面向对象编程时,继承是用于扩展对象功能的主要模式。今天,遗产通常被认为是一种设计气味。事实上,已经证明使用继承扩展对象通常会导致爆炸的类层次结构(请参阅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也可以继承自EmailServiceWithRetries或ThreadSafeEmailService(如果支持多重继承,则可以继承)。但是,类的数量和结果的功能将是类似的。
除了重试和线程安全之外,我们还想扩展我们的电子邮件服务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的可以微信搜索:“程序员文明”,里面有大神会给予解答,也会有许多的资源可以供大家学习分享,欢迎大家前来一起学习进步!