从界面编程到实施
你写了一些代码,进行了测试,并运送到了生产中。然后你就再也不用修改这些代码了。这不是很好吗?
但是把梦想放在一边......你必须修改,甚至多次修改已经写好的代码。而这在我看来是软件开发中的一个大挑战:更新已经写好的代码。
如果你没有花时间,提前考虑你的代码可能的变化方式,你很快就会开始出现僵硬和脆弱的代码问题。
在这篇文章中,我将讨论一个软件设计原则,它建议对一个接口而不是一个实现进行编程,以帮助你写的代码在将来可以更容易地被修改。
目录
1.列表渲染器
理解软件设计原则的好处的一个好方法是跟随一个例子并展示可见的好处。
首先,我将向你展示一个不适应变化的实现。然后,通过应用程序的界面原则,重新设计组件,并展示新设计的优势。
例如,你的任务是实现一个类ListRenderer 。该类只有一个方法listRender.render(names) ,将一个名字的数组渲染成一个无序的HTML列表。
下面是你如何实现ListRenderer 这个类。
class ListRenderer {
render(names: string[]): string {
let html = '<ul>';
for (const name of names) {
html += `<li>${name}</li>`;
}
html += '</ul>';
return html;
}
}
现在让我们使用列表渲染器来渲染一些名字。
const renderer = new ListRenderer();
renderer.render(['Joker', 'Catwoman', 'Batman']);
// =>
// <ul>
// <li>Joker</li>
// <li>Catwoman</li>
// <li>Batman</li>
// </ul>
如果你想简单地渲染一个名字的列表,上面的实现是一个很好的解决方案。但是如果你需要进一步改变这段代码,例如增加排序功能,该怎么办?
2.对一个实现进行编程
正如我在帖子介绍中提到的,当出现新的需求时,很有可能要对已经写好的代码进行修改。
比方说,有一个新的需求,要对呈现的名字按字母排序。
你可以通过创建一个新的分类器类来实现这个需求,例如:SortAlphabetically 。
class SortAlphabetically {
sort(strings: string[]): string[] {
return [...strings].sort((s1, s2) => s1.localeCompare(s2))
}
}
其中s1.localCompare(s2) 是一个字符串方法,用于比较s1 是按字母顺序排在s2 之前还是之后。
然后将SortAlphabetically 集成到ListRender ,在渲染前实现名字的排序。
class ListRenderer {
sorter: SortAlphabetically;
constructor() {
this.sorter = new SortAlphabetically();
}
render(names: string[]): string {
const sortedNames = this.sorter.sort(names)
let html = '<ul>';
for (const name of sortedNames) {
html += `<li>${name}</li>`;
}
html += '</ul>';
return html;
}
}
现在集成了新的排序逻辑后,列表中的名字按字母顺序排序。
const renderer = new ListRenderer();
renderer.render(['Joker', 'Catwoman', 'Batman']);
// =>
// <ul>
// <li>Bane</li>
// <li>Catwoman</li>
// <li>Joker</li>
// </ul>
现在让我们仔细看看排序器的实例化行:
this.sorter = new SortAlphabetically();
这是对实现的编程,因为ListRenderer 使用了分拣机的具体实现,而且正是SortAlphabetically 实现。
对一个实现进行编程是一个问题吗?答案取决于你的代码在未来会如何变化。
如果你确信列表呈现器将只按字母顺序对名字进行排序--而且这个要求在将来很可能不会改变--那么对具体的排序实现进行编程是好的。
2.1 改变实现方式
但是,如果排序实现在未来可能会改变,或者你需要根据运行时的值进行不同的排序,你可能会在编程到实现上遇到困难。
例如,你可能想根据用户的选择,以升序或降序对名字进行排序。
当使用编程来实现时,你将开始用排序的实现细节来臃肿你的主要组件。这很快就会使你的代码难以推理,难以改变。
class ListRenderer {
sorter: SortAlphabetically | SortAlphabeticallyDescending;
constructor(ascending: boolean) {
this.sorter = ascending ?
new SortAlphabetically() :
new SortAlphabeticallyDescending();
}
render(names: string[]): string {
// ...
}
}
在上面的例子中,ListRenderer 可以对名字进行升序或降序排序。使用哪种排序方式取决于ascending 参数。
你可以看到ListRenderer 变得多么复杂--它因排序的实现细节而变得臃肿。
如果以后你想添加更多的排序实现怎么办?通过在ListRenderer 中引用新的排序实现,使得这个类被它不需要知道的细节搞得过于复杂。
这样的设计破坏了2个重要的软件设计原则。
首先,这种设计破坏了单一责任原则。ListRenderer 应该只负责渲染名字,但现在另外还负责实例化和选择其正确的排序实现。
其次,通过直接修改ListRenderer 来增加新的排序方式,打破了开放/封闭原则。
如何设计可改变的依赖性实现?欢迎对接口进行编程!
3.对接口进行编程
如果你想让ListRenderer 更具扩展性并与具体的排序实现解耦,那么你需要使用向接口编程的方法。
以下是你需要做的事情,以改进具有不同排序机制的渲染器的设计。
- 定义代表排序行为的接口(或抽象类)
Sorter - 让
ListRender依赖于Sorter接口,而不是具体的实现 (SortAlphabetically和SortAlphabeticallyDescending) - 使具体的排序实现 (
SortAlphabetically和SortAlphabeticallyDescending) 实现Sorter接口。 - 将一个
ListRenderer实例与正确的排序实现组成:new ListRenderer(new SortAlphabetically())或new ListRenderer(new SortAlphabeticallyDescending())
向接口编程的主要思想是引入一个稳定的构造--接口(或抽象类)--并依赖它。你还从主类中提取了实例化和选择实现的逻辑。
3.1 实践中的接口编程
好了,让我们看看如何把对接口的编程付诸实践,以改进列表渲染器的设计。
- 定义接口
Sorter应该是比较容易的。
ts
interface Sorter {
sort(strings: string[]): string[]
}
Sorter 接口只包含一个方法 ,对一个字符串数组进行排序。这个接口并不关心 方法是如何工作的:只关心它接受一个字符串数组,并应该返回一个排序后的字符串数组。sort() sort()
- 让
ListRender使用Sorter接口也很容易。只要删除对具体实现的引用,只使用Sorter接口。
class ListRenderer {
sorter: Sorter;
constructor(sorter: Sorter) {
this.sorter = sorter;
}
render(names: string[]): string {
const sortedNames = this.sorter.sort(names)
let html = '<ul>';
for (const name of sortedNames) {
html += `<li>${name}</li>`;
}
html += '</ul>';
return html;
}
}
现在,ListRenderer 并不依赖于排序的具体实现。这使得该类易于推理,并与排序逻辑解耦。它依赖于一个非常稳定的东西:Sorter 接口。
sorter: Sorter 在ListRenderer 中的存在,就是所谓的对接口进行编程。
- 最后,使具体的排序类实现
Sorter接口也是相对容易的。
class SortAlphabetically implements Sorter {
sort(strings: string[]): string[] {
return [...strings].sort((s1, s2) => s1.localeCompare(s2))
}
}
class SortAlphabeticallyDescending implements Sorter {
sort(strings: string[]): string[] {
return [...strings].sort((s1, s2) => s2.localeCompare(s1))
}
}
4.效益与增加的复杂性
你可能会说,对接口进行编程比对实现进行编码需要更多的活动部件。你是对的。
最大的好处,你可能已经看到了,就是ListRenderer 类使用了一个抽象的接口Sorter 。
因此,ListRenderer 与任何具体的分类实现解耦:SortAlphabetically 或SortAlphabeticallyDescending 。
现在你可以提供不同的排序实现。在一种情况下,你可以使用按字母顺序递增的排序。
const names = ['Joker', 'Catwoman', 'Batman'];
const rendererAscending = new ListRenderer(
new SortAlphabetically()
);
rendererAscending.render(names);
// =>
// <ul>
// <li>Batman</li>
// <li>Catwoman</li>
// <li>Joker</li>
// </ul>
在另一种情况下,你可以很容易地组成ListRenderer ,对名字进行降序排序,而无需修改ListRenderer 的源代码。
const names = ['Joker', 'Catwoman', 'Batman'];
const rendererDescending = new ListRenderer(
new SortAlphabeticallyDescending()
);
rendererDescending.render(names);
// =>
// <ul>
// <li>Joker</li>
// <li>Catwoman</li>
// <li>Batman</li>
// </ul>
但是你也可以根据一个运行时的值来选择具体的排序方式。
const sorting =
someRuntimeBoolean ?
new SortAlphabetically() :
new SortAlphabeticallyDescending();
const renderer = new ListRenderer(
sorting
);
// ...
5.总结
根据接口编程是一个有用的原则,可以设计一个可以及时改变的依赖,或者可以根据运行时的值选择不同的依赖实现。
按接口编程通常要求主类(如:ListRenderer )依赖一个接口(如:Sorter ),而具体的实现(如:SortAlphabetically 和SortAlphabeticallyDescending )要实现该接口。
主类的客户端(例如:ListRenderer )可以提供它所需要的任何依赖性实现,而不需要修改主类的源代码(例如:ListRenderer )。
请注意,按照接口编程是以增加复杂性为代价的。使用它必须是一个明智的、慎重的选择。
然而,如果你确信你的代码的某一部分不会改变,那么对一个实现进行编程是一个很好的、便宜的选择
例如,如果ListRenderer ,总是按字母升序对名字进行排序:那么就在第2节开头介绍的设计中停止。不要增加不必要的复杂性。