为什么我们在程序开发设计中要基于接口而非实现编程?

简介: 为什么我们在程序开发设计中要基于接口而非实现编程?

为什么我们要基于接口而非实现编程?

如何解读原则中的“接口”二字?

是否需要为每个类定义接口?

针对以上问题,下面我们来一个一个的聊一聊。

在软件开发领域,遵循“面向接口编程而非面向实现编程”的原则是提升代码质量的关键策略。这一原则强调的是,应当依赖于定义良好的接口,而不是具体的实现逻辑。这样做的目的是为了提高代码的灵活性和可维护性,降低因实现变化而导致的修改成本。

为了让你理解透彻,并真正掌握这条原则如何应用,今天,我会结合一个有关图片存储的实战案例来讲解。除此之外,这条原则还很容易被过度应用,比如为每一个实现类都定义对应的接口。针对这类问题,我也会告诉你如何来做权衡,怎样恰到好处地应用这条原则。

如何解读原则中的“接口”二字?

“基于接口而非实现编程”这条原则的英文描述是:“Program to an interface, not an implementation”。我们理解这条原则的时候,千万不要一开始就与具体的编程语言挂钩,局限在编程语言的“接口”语法中(比如 Java 中的 interface 接口语法)。这条原则最早出现于 1994 年 GoF 的《设计模式》这本书,它先于很多编程语言而诞生(比如 Java 语言),是一条比较抽象、泛化的设计思想。

要真正领会这一原则,关键在于理解“接口”的含义。从本质上来说,“接口”是一组规范或协议,它定义了功能提供者与使用者之间的交互方式。在不同的应用背景下,“接口”可以有不同的含义,例如服务端与客户端之间的通信接口、类库提供的API,或者通信协议等。这些理解更偏向于高层次和抽象层面,与具体的编码活动有一定的距离。然而,在具体的编码实践中,“依托抽象接口而非具体实现进行编程”中的“接口”可以被理解为编程语言中的接口或抽象类。

正如我们之前提到的,这一原则能够有效提升代码品质,这是因为它实现了接口与实现的分离,将不稳定的实现细节封装起来,而对外提供稳定的接口。当实现发生变化时,依赖于接口的上层系统代码基本上不需要做出修改,从而降低了系统的耦合度,增强了系统的可扩展性。

实际上,“依托抽象接口而非具体实现进行编程”这一原则也可以表述为“依托抽象而非具体实现进行编程”。后者更能体现这一原则的核心意图。在软件开发过程中,需求的不断变化是一个主要挑战,也是衡量代码设计优劣的一个重要标准。越高层次、越抽象、越不依赖于某一具体实现的设计,越能够提升代码的适应性和灵活性,以应对未来的需求变化。优秀的代码设计不仅能够满足当前的需求,而且在将来需求发生变化时,也能够在不破坏现有代码结构的前提下灵活适应。而抽象是提升代码的可扩展性、灵活性和可维护性的有效手段之一。

为了将这一原则应用到实际场景中,我们可以通过一个具体的案例来进行说明。假设在我们的系统中,有大量的图片处理和存储业务逻辑。处理后的图片被上传至阿里云。为了代码的复用性,我们封装了图片存储相关的逻辑,并创建了一个统一的AliyunImageStore类供整个系统使用。具体的代码实现如下:

public class AliyunImageStore {
  // ...省略属性、构造函数等...
  public void createBucketIfNotExisting(String bucketName) {
    // ...创建存储桶的代码逻辑...
    // ...失败时抛出异常...
  }
  public String generateAccessToken() {
    // ...生成访问令牌...
  }
  public String uploadToAliyun(Image image, String bucketName, String accessToken) {
    // ...上传图片至阿里云...
    // ...返回图片在阿里云上的地址(url)...
  }
  public Image downloadFromAliyun(String url, String accessToken) {
    // ...从阿里云下载图片...
  }
}
// AliyunImageStore类的使用示例
public class ImageProcessingJob {
  private static final String BUCKET_NAME = "ai_images_bucket";
  // ...省略其他无关代码...
  public void process() {
    Image image = ...; // 处理图片,并封装为Image对象
    AliyunImageStore imageStore = new AliyunImageStore(/* 省略参数 */);
    imageStore.createBucketIfNotExisting(BUCKET_NAME);
    String accessToken = imageStore.generateAccessToken();
    imagestore.uploadToAliyun(image, BUCKET_NAME, accessToken);
  }
}


整个上传流程包括三个步骤:创建存储桶(可以理解为一个存储目录)、生成访问令牌、携带访问令牌上传图片至指定的存储桶中。代码实现简洁明了,类中的方法定义清晰,使用起来也很方便,看起来似乎没有问题,完全符合我们将图片存储在阿里云的业务需求。

然而,在软件开发的世界里,唯一不变的就是变化本身。随着时间的推移,我们可能会建立自己的私有云,不再使用阿里云存储图片,而是将图片存储在自己的私有云上。面对这样的需求变化,我们应该如何调整代码呢?

我们需要重新设计实现一个存储图片到私有云的 PrivateImageStore 类,并用它替换掉项目中所有的 AliyunImageStore 类对象。这样的修改听起来并不复杂,只是简单替换而已,对整个代码的改动并不大。不过,我们经常说,“细节中的魔鬼”。这句话在软件开发中特别适用。实际上,刚刚的设计实现方式,就隐藏了很多容易出问题的“魔鬼细节”,我们一块来看看都有哪些。

新的 PrivateImageStore 类需要设计实现哪些方法,才能在尽量最小化代码修改的情况下,替换掉 AliyunImageStore 类呢?这就要求我们必须将 AliyunImageStore 类中所定义的所有 public 方法,在 PrivateImageStore 类中都逐一定义并重新实现一遍。

而这样做就会存在一些问题,我总结了下面两点。

首先,AliyunImageStore 类中有些函数命名暴露了实现细节怎么办?。

比如,uploadToAliyun() 和 downloadFromAliyun()。如果开发这个功能的同事没有接口意识、抽象思维,那这种暴露实现细节的命名方式就不足为奇了,毕竟最初我们只考虑将图片存储在阿里云上。而我们把这种包含“aliyun”字眼的方法,照抄到 PrivateImageStore 类中,显然是不合适的。如果我们在新类中重新命名 uploadToAliyun()、downloadFromAliyun() 这些方法,那就意味着,我们要修改项目中所有使用到这两个方法的代码,代码修改量可能就会很大。

其次,将图片存储到阿里云的流程,跟存储到私有云的流程,可能并不是完全一致的怎么办?

比如,阿里云的图片上传和下载的过程中,需要生产 access token,而私有云不需要 access token。一方面,AliyunImageStore 中定义的 generateAccessToken() 方法不能照抄到 PrivateImageStore 中;另一方面,我们在使用 AliyunImageStore 上传、下载图片的时候,代码中用到了 generateAccessToken() 方法,如果要改为私有云的上传下载流程,这些代码都需要做调整。

那这两个问题该如何解决呢?解决这个问题的根本方法就是,在编写代码的时候,要遵从“基于接口而非实现编程”的原则,具体来讲,我们需要做到下面这 3 点。

函数的命名不能暴露任何实现细节。比如,前面提到的 uploadToAliyun() 就不符合要求,应该改为去掉 aliyun 这样的字眼,改为更加抽象的命名方式,比如:upload()。

封装具体的实现细节。比如,跟阿里云相关的特殊上传(或下载)流程不应该暴露给调用者。我们对上传(或下载)流程进行封装,对外提供一个包裹所有上传(或下载)细节的方法,给调用者使用。

为实现类定义抽象的接口。具体的实现类都依赖统一的接口定义,遵从一致的上传功能协议。使用者依赖接口,而不是具体的实现类来编程。

我们按照这个思路,把代码重构一下。

public interface ImageStore {
  String upload(Image image, String bucketName);
  Image download(String url);
}
public class AliyunImageStore implements ImageStore {
  // ...省略属性、构造函数等...
  public String upload(Image image, String bucketName) {
    createBucketIfNotExisting(bucketName);
    String accessToken = generateAccessToken();
    // ...上传图片至阿里云...
    // ...返回图片在阿里云上的地址(url)...
  }
  public Image download(String url) {
    String accessToken = generateAccessToken();
    // ...从阿里云下载图片...
  }
  private void createBucketIfNotExisting(String bucketName) {
    // ...创建存储桶...
    // ...失败时抛出异常...
  }
  private String generateAccessToken() {
    // ...生成访问令牌...
  }
}
// 上传下载流程变化:私有云不需要访问令牌
public class PrivateImageStore implements ImageStore {
  public String upload(Image image, String bucketName) {
    createBucketIfNotExisting(bucketName);
    // ...上传图片至私有云...
    // ...返回图片的url...
  }
  public Image download(String url) {
    // ...从私有云下载图片...
  }
  private void createBucketIfNotExisting(String bucketName) {
    // ...创建存储桶...
    // ...失败时抛出异常...
  }
}
// ImageStore的使用示例
public class ImageProcessingJob {
  private static final String BUCKET_NAME = "ai_images_bucket";
  // ...省略其他无关代码...
  public void process() {
    Image image = ...; // 处理图片,并封装为Image对象
    ImageStore imageStore = new PrivateImageStore(...);
    imagestore.upload(image, BUCKET_NAME);
  }
}


除此之外,很多人在定义接口的时候,希望通过实现类来反推接口的定义。先把实现类写好,然后看实现类中有哪些方法,照抄到接口定义中。如果按照这种思考方式,就有可能导致接口定义不够抽象,依赖具体的实现。这样的接口设计就没有意义了。不过,如果你觉得这种思考方式更加顺畅,那也没问题,只是将实现类的方法搬移到接口定义中的时候,要有选择性的搬移,不要将跟具体实现相关的方法搬移到接口中,比如 AliyunImageStore 中的 generateAccessToken() 方法。

总结一下,我们在做软件开发的时候,一定要有抽象意识、封装意识、接口意识。在定义接口的时候,不要暴露任何实现细节。接口的定义只表明做什么,而不是怎么做。而且,在设计接口的时候,我们要多思考一下,这样的接口设计是否足够通用,是否能够做到在替换具体的接口实现的时候,不需要任何接口定义的改动。

好,下一个问题:是否需要为每个类定义接口?

根据以上内容,你可能会有这样的疑问:为了满足这条原则,我是不是需要给每个实现类都定义对应的接口呢?在开发的时候,是不是任何代码都要只依赖接口,完全不依赖实现编程呢?

做任何事情都要讲求一个“度”,过度使用这条原则,非得给每个类都定义接口,接口满天飞,也会导致不必要的开发负担。至于什么时候,该为某个类定义接口,实现基于接口的编程,什么时候不需要定义接口,直接使用实现类编程,我们做权衡的根本依据,还是要回归到设计原则诞生的初衷上来。只要搞清楚了这条原则是为了解决什么样的问题而产生的,你就会发现,很多之前模棱两可的问题,都会变得豁然开朗。

前面我们也提到,这条原则的设计初衷是,将接口和实现相分离,封装不稳定的实现,暴露稳定的接口。上游系统面向接口而非实现编程,不依赖不稳定的实现细节,这样当实现发生变化的时候,上游系统的代码基本上不需要做改动,以此来降低代码间的耦合性,提高代码的扩展性。

从这个设计初衷上来看,如果在我们的业务场景中,某个功能只有一种实现方式,未来也不可能被其他实现方式替换,那我们就没有必要为其设计接口,也没有必要基于接口编程,直接使用实现类就可以了。

除此之外,越是不稳定的系统,我们越是要在代码的扩展性、维护性上下功夫。相反,如果某个系统特别稳定,在开发完之后,基本上不需要做维护,那我们就没有必要为其扩展性,投入不必要的开发时间。

总的来说

“基于接口而非实现编程”,这条原则的另一个表述方式,是“基于抽象而非实现编程”。后者的表述方式其实更能体现这条原则的设计初衷。我们在做软件开发的时候,一定要有抽象意识、封装意识、接口意识。越抽象、越顶层、越脱离具体某一实现的设计,越能提高代码的灵活性、扩展性、可维护性。

我们在定义接口的时候,一方面,命名要足够通用,不能包含跟具体实现相关的字眼;另一方面,与特定实现有关的方法不要定义在接口中。

“基于接口而非实现编程”这条原则,不仅仅可以指导非常细节的编程开发,还能指导更加上层的架构设计、系统设计等。比如,服务端与客户端之间的“接口”设计、类库的“接口”设计。


目录
相关文章
|
5月前
|
存储 C++
【C++】——基础编程
【C++】——基础编程
|
4月前
|
XML JavaScript 前端开发
编程接口
**XML DOM 提供编程接口,将XML文档转换为节点对象树,通过JavaScript等语言操作。属性如 nodeName, nodeValue, parentNode, childNodes 和 attributes 访问节点详情。方法如 deleteNode 用于修改或删除节点。**
|
5月前
|
存储 编译器 C++
嵌入式中C++ 编程习惯与编程要点分析
嵌入式中C++ 编程习惯与编程要点分析
51 1
|
10月前
|
机器学习/深度学习 人工智能 IDE
编程基础
编程基础
71 2
|
5月前
|
SQL 前端开发 Java
Java后端接口编写流程
Java后端接口编写流程
111 0
|
5月前
|
数据可视化 前端开发 JavaScript
iVX,重新定义编程:人人都可掌握的可视化编程
iVX,重新定义编程:人人都可掌握的可视化编程
149 0
|
算法 网络协议 开发者
|
设计模式 开发框架 前端开发
手把手教你封装一个健壮的MVP框架,面向接口开发。
在我们的日常开发中,我们都知道 Android 端的开发框架有 MVC,MVP,MVVM,说起这几个框架,大家也肯定都有自己的看法,甚至很多同学也都封装过。
94 0
|
缓存 算法 Java
C++ 编程基础总结
C++ 编程基础总结
316 0
|
前端开发 索引
小程序开发中注意的细节
1.绑定变量的语法,各不相同。 //绑定style中的height变量 //绑定class中的变量 //事件绑定是不使用双花括号,事件中如果传递的值是变量却需要用双花括号 1) { ...
1239 0