天天看点

温故知新,遇见面向对象编程(OOP),如何基于接口而非实现编程,谈抽象类(Abstract)、接口(Interface)的区别和使用场景

什么是抽象类和接口?

温故知新,遇见面向对象编程(OOP),如何基于接口而非实现编程,谈抽象类(Abstract)、接口(Interface)的区别和使用场景

不同的编程语言对接口和抽象类的定义方式可能有些差别,但差别并不会很大。Java/C#这种编程语言,既支持抽象类(Abstract),也支持接口(Interface),所以,为了让你对这两个语法概念有比较直观的认识,我们拿Java这种编程语言来举例讲解。

首先,我们来看一下,在Java这种编程语言中,我们是如何定义抽象类的。

下面这段代码是一个比较典型的抽象类的使用场景(模板设计模式)。

Logger

是一个记录日志的抽象类,

FileLogger

MessageQueueLogger

继承

Logger

,分别实现两种不同的日志记录方式:记录日志到文件中和记录日志到消息队列中。

FileLogger

MessageQueueLogger

两个子类复用了父类

Logger

中的

name

enabled

minPermittedLevel

属性和

log()

方法,但因为这两个子类写日志的方式不同,它们又各自重写了父类中的

doLog()

方法。

// 抽象类
public abstract class Logger {
  private String name;
  private boolean enabled;
  private Level minPermittedLevel;

  public Logger(String name, boolean enabled, Level minPermittedLevel) {
    this.name = name;
    this.enabled = enabled;
    this.minPermittedLevel = minPermittedLevel;
  }
  
  public void log(Level level, String message) {
    boolean loggable = enabled && (minPermittedLevel.intValue() <= level.intValue());
    if (!loggable) return;
    doLog(level, message);
  }
  
  protected abstract void doLog(Level level, String message);
}
// 抽象类的子类:输出日志到文件
public class FileLogger extends Logger {
  private Writer fileWriter;

  public FileLogger(String name, boolean enabled,
    Level minPermittedLevel, String filepath) {
    super(name, enabled, minPermittedLevel);
    this.fileWriter = new FileWriter(filepath); 
  }
  
  @Override
  public void doLog(Level level, String mesage) {
    // 格式化level和message,输出到日志文件
    fileWriter.write(...);
  }
}
// 抽象类的子类: 输出日志到消息中间件(比如kafka)
public class MessageQueueLogger extends Logger {
  private MessageQueueClient msgQueueClient;
  
  public MessageQueueLogger(String name, boolean enabled,
    Level minPermittedLevel, MessageQueueClient msgQueueClient) {
    super(name, enabled, minPermittedLevel);
    this.msgQueueClient = msgQueueClient;
  }
  
  @Override
  protected void doLog(Level level, String mesage) {
    // 格式化level和message,输出到消息中间件
    msgQueueClient.send(...);
  }
}
           
通过上面的这个例子,我们来看一下,抽象类具有哪些特性。我总结了下面三点。
  • 抽象类不允许被实例化,只能被继承。也就是说,你不能new一个抽象类的对象出来(

    Logger logger=new Logger(...);

    会报编译错误)。
  • 抽象类可以包含属性和方法。方法既可以包含代码实现(比如

    Logger

    中的

    log()

    方法),也可以不包含代码实现(比如

    Logger

    中的

    doLog()

    方法)。不包含代码实现的方法叫作抽象方法。
  • 子类继承抽象类,必须实现抽象类中的所有抽象方法。对应到例子代码中就是,所有继承

    Logger

    抽象类的子类,都必须重写

    doLog()

    方法。
刚刚我们讲了如何定义抽象类,现在我们再来看一下,在Java这种编程语言中,我们如何定义接口。
// 接口
public interface Filter {
  void doFilter(RpcRequest req) throws RpcException;
}
// 接口实现类:鉴权过滤器
public class AuthencationFilter implements Filter {
  @Override
  public void doFilter(RpcRequest req) throws RpcException {
    //...鉴权逻辑..
  }
}
// 接口实现类:限流过滤器
public class RateLimitFilter implements Filter {
  @Override
  public void doFilter(RpcRequest req) throws RpcException {
    //...限流逻辑...
  }
}
// 过滤器使用Demo
public class Application {
  // filters.add(new AuthencationFilter());
  // filters.add(new RateLimitFilter());
  private List<Filter> filters = new ArrayList<>();
  
  public void handleRpcRequest(RpcRequest req) {
    try {
      for (Filter filter : filters) {
        filter.doFilter(req);
      }
    } catch(RpcException e) {
      // ...处理过滤结果...
    }
    // ...省略其他处理逻辑...
  }
}
           

上面这段代码是一个比较典型的接口的使用场景。我们通过Java中的

interface

关键字定义了一个

Filter

接口。

AuthencationFilter

RateLimitFilter

是接口的两个实现类,分别实现了对RPC请求鉴权和限流的过滤功能。

代码非常简洁。结合代码,我们再来看一下,接口都有哪些特性。我也总结了三点。
  • 接口不能包含属性(也就是成员变量)。
  • 接口只能声明方法,方法不能包含代码实现。
  • 类实现接口的时候,必须实现接口中声明的所有方法。

前面我们讲了抽象类和接口的定义,以及各自的语法特性。从语法特性上对比,这两者有比较大的区别,比如抽象类中可以定义属性、方法的实现,而接口中不能定义属性,方法也不能包含代码实现等等。

除了语法特性,从设计的角度,两者也有比较大的区别。

抽象类实际上就是类,只不过是一种特殊的类,这种类不能被实例化为对象,只能被子类继承。我们知道,继承关系是一种is-a的关系,那抽象类既然属于类,也表示一种

is-a

的关系。相对于抽象类的

is-a

关系来说,接口表示一种

has-a

关系,表示具有某些功能。对于接口,有一个更加形象的叫法,那就是协议(contract)。

为什么需要抽象类?它能够解决什么编程问题?

抽象类不能实例化,只能被继承。继承能解决代码复用的问题。所以,抽象类也是为代码复用而生的。多个子类可以继承抽象类中定义的属性和方法,避免在子类中,重复编写相同的代码。

不过,既然继承本身就能达到代码复用的目的,而继承也并不要求父类一定是抽象类,那我们不使用抽象类,照样也可以实现继承和复用。从这个角度上来讲,我们貌似并不需要抽象类这种语法呀。那抽象类除了解决代码复用的问题,还有什么其他存在的意义吗?

我们还是拿之前那个打印日志的例子来讲解。我们先对上面的代码做下改造。在改造之后的代码中,Logger不再是抽象类,只是一个普通的父类,删除了Logger中log()、doLog()方法,新增了isLoggable()方法。FileLogger和MessageQueueLogger还是继承Logger父类,以达到代码复用的目的。具体的代码如下:

// 父类:非抽象类,就是普通的类. 删除了log(),doLog(),新增了isLoggable().
public class Logger {
  private String name;
  private boolean enabled;
  private Level minPermittedLevel;

  public Logger(String name, boolean enabled, Level minPermittedLevel) {
    //...构造函数不变,代码省略...
  }

  protected boolean isLoggable() {
    boolean loggable = enabled && (minPermittedLevel.intValue() <= level.intValue());
    return loggable;
  }
}
// 子类:输出日志到文件
public class FileLogger extends Logger {
  private Writer fileWriter;

  public FileLogger(String name, boolean enabled,
    Level minPermittedLevel, String filepath) {
    //...构造函数不变,代码省略...
  }
  
  public void log(Level level, String mesage) {
    if (!isLoggable()) return;
    // 格式化level和message,输出到日志文件
    fileWriter.write(...);
  }
}
// 子类: 输出日志到消息中间件(比如kafka)
public class MessageQueueLogger extends Logger {
  private MessageQueueClient msgQueueClient;
  
  public MessageQueueLogger(String name, boolean enabled,
    Level minPermittedLevel, MessageQueueClient msgQueueClient) {
    //...构造函数不变,代码省略...
  }
  
  public void log(Level level, String mesage) {
    if (!isLoggable()) return;
    // 格式化level和message,输出到消息中间件
    msgQueueClient.send(...);
  }
}
           

这个设计思路虽然达到了代码复用的目的,但是无法使用多态特性了。像下面这样编写代码,就会出现编译错误,因为Logger中并没有定义log()方法。

Logger logger = new FileLogger("access-log", true, Level.WARN, "/users/xxxxxx/access.log");
logger.log(Level.ERROR, "This is a test log message.");
           

你可能会说,这个问题解决起来很简单啊。我们在Logger父类中,定义一个空的log()方法,让子类重写父类的log()方法,实现自己的记录日志的逻辑,不就可以了吗?

public class Logger {
  // ...省略部分代码...
  public void log(Level level, String mesage) { // do nothing... }
}
public class FileLogger extends Logger {
  // ...省略部分代码...
  @Override
  public void log(Level level, String mesage) {
    if (!isLoggable()) return;
    // 格式化level和message,输出到日志文件
    fileWriter.write(...);
  }
}
public class MessageQueueLogger extends Logger {
  // ...省略部分代码...
  @Override
  public void log(Level level, String mesage) {
    if (!isLoggable()) return;
    // 格式化level和message,输出到消息中间件
    msgQueueClient.send(...);
  }
}
           

这个设计思路能用,但是,它显然没有之前通过抽象类的实现思路优雅。我为什么这么说呢?主要有以下几点原因。

  • 在Logger中定义一个空的方法,会影响代码的可读性。如果我们不熟悉Logger背后的设计思想,代码注释又不怎么给力,我们在阅读Logger代码的时候,就可能对为什么定义一个空的log()方法而感到疑惑,需要查看Logger、FileLogger、MessageQueueLogger之间的继承关系,才能弄明白其设计意图。
  • 当创建一个新的子类继承Logger父类的时候,我们有可能会忘记重新实现log()方法。之前基于抽象类的设计思路,编译器会强制要求子类重写log()方法,否则会报编译错误。你可能会说,我既然要定义一个新的Logger子类,怎么会忘记重新实现log()方法呢?我们举的例子比较简单,Logger中的方法不多,代码行数也很少。但是,如果Logger有几百行,有n多方法,除非你对Logger的设计非常熟悉,否则忘记重新实现log()方法,也不是不可能的。
  • Logger可以被实例化,换句话说,我们可以new一个Logger出来,并且调用空的log()方法。这也增加了类被误用的风险。当然,这个问题可以通过设置私有的构造函数的方式来解决。不过,显然没有通过抽象类来的优雅。

为什么需要接口?它能够解决什么编程问题?

抽象类更多的是为了代码复用,而接口就更侧重于解耦。接口是对行为的一种抽象,相当于一组协议或者契约,你可以联想类比一下API接口。调用者只需要关注抽象的接口,不需要了解具体的实现,具体的实现代码对调用者透明。接口实现了约定和实现相分离,可以降低代码间的耦合性,提高代码的可扩展性。

实际上,接口是一个比抽象类应用更加广泛、更加重要的知识点。比如,我们经常提到的“基于接口而非实现编程”,就是一条几乎天天会用到,并且能极大地提高代码的灵活性、扩展性的设计思想。

如何模拟抽象类和接口两个语法概念?

在前面举的例子中,我们使用Java的接口语法实现了一个Filter过滤器。不过,如果你熟悉的是C++这种编程语言,你可能会说,C++只有抽象类,并没有接口,那从代码实现的角度上来说,是不是就无法实现Filter的设计思路了呢?

实际上,我们可以通过抽象类来模拟接口。怎么来模拟呢?

我们先来回忆一下接口的定义:接口中没有成员变量,只有方法声明,没有方法实现,实现接口的类必须实现接口中的所有方法。只要满足这样几点,从设计的角度上来说,我们就可以把它叫作接口。实际上,要满足接口的这些语法特性并不难。

在下面这段C++代码中,我们就用抽象类模拟了一个接口(下面这段代码实际上是策略模式中的一段代码)。

class Strategy { // 用抽象类模拟接口
  public:
    ~Strategy();
    virtual void algorithm()=0;
  protected:
    Strategy();
};
           

抽象类Strategy没有定义任何属性,并且所有的方法都声明为virtual类型(等同于Java中的abstract关键字),这样,所有的方法都不能有代码实现,并且所有继承这个抽象类的子类,都要实现这些方法。从语法特性上来看,这个抽象类就相当于一个接口。

不过,如果你熟悉的既不是Java,也不是C++,而是现在比较流行的动态编程语言,比如Python、Ruby等,你可能还会有疑问:在这些动态语言中,不仅没有接口的概念,也没有类似abstract、virtual这样的关键字来定义抽象类,那该如何实现上面的讲到的Filter、Logger的设计思路呢?

实际上,除了用抽象类来模拟接口之外,我们还可以用普通类来模拟接口。具体的Java代码实现如下所示。

public class MockInteface {
  protected MockInteface() {}
  public void funcA() {
    throw new MethodUnSupportedException();
  }
}
           

我们知道类中的方法必须包含实现,这个不符合接口的定义。但是,我们可以让类中的方法抛出MethodUnSupportedException异常,来模拟不包含实现的接口,并且能强迫子类在继承这个父类的时候,都去主动实现父类的方法,否则就会在运行时抛出异常。我们将构造函数设置成protected属性的,这样就能避免非同包下的类去实例化MockInterface。不过,这样还是无法避免同包中的类去实例化MockInterface。为了解决这个问题,我们可以学习Google Guava中@VisibleForTesting注解的做法,自定义一个注解,人为表明不可实例化。

如何决定该用抽象类还是接口?

实际上,判断的标准很简单。
  • 如果我们要表示一种

    is-a

    的关系,并且是为了解决代码复用的问题,我们就用抽象类;
  • 如果我们要表示一种

    has-a

    关系,并且是为了解决抽象而非代码复用的问题,那我们就可以使用接口。

从类的继承层次上来看,抽象类是一种自下而上的设计思路,先有子类的代码重复,然后再抽象成上层的父类(也就是抽象类)。而接口正好相反,它是一种自上而下的设计思路。我们在编程的时候,一般都是先设计接口,再去考虑具体的实现。

基于接口而非实现编程

什么是基于接口而非实现编程

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

实际上,理解这条原则的关键,就是理解其中的“接口”两个字。还记得我们上一节课讲的“接口”的定义吗?从本质上来看,“接口”就是一组“协议”或者“约定”,是功能提供者提供给使用者的一个“功能列表”。“接口”在不同的应用场景下会有不同的解读,比如服务端与客户端之间的“接口”,类库提供的“接口”,甚至是一组通信的协议都可以叫作“接口”。刚刚对“接口”的理解,都比较偏上层、偏抽象,与实际的写代码离得有点远。如果落实到具体的编码,“基于接口而非实现编程”这条原则中的“接口”,可以理解为编程语言中的接口或者抽象类。

这条原则能非常有效地提高代码质量,之所以这么说,那是因为,应用这条原则,可以将接口和实现相分离,封装不稳定的实现,暴露稳定的接口。上游系统面向接口而非实现编程,不依赖不稳定的实现细节,这样当实现发生变化的时候,上游系统的代码基本上不需要做改动,以此来降低耦合性,提高扩展性。

实际上,“基于接口而非实现编程”这条原则的另一个表述方式,是“基于抽象而非实现编程”。后者的表述方式其实更能体现这条原则的设计初衷。在软件开发中,最大的挑战之一就是需求的不断变化,这也是考验代码设计好坏的一个标准。越抽象、越顶层、越脱离具体某一实现的设计,越能提高代码的灵活性,越能应对未来的需求变化。好的代码设计,不仅能应对当下的需求,而且在将来需求发生变化的时候,仍然能够在不破坏原有代码设计的情况下灵活应对。而抽象就是提高代码扩展性、灵活性、可维护性最有效的手段之一。

如何将这条原则应用到实战中?

假设我们的系统中有很多涉及图片处理和存储的业务逻辑。图片经过处理之后被上传到阿里云上。

为了代码复用,我们封装了图片存储相关的代码逻辑,提供了一个统一的AliyunImageStore类,供整个系统来使用。具体的代码实现如下所示:

public class AliyunImageStore {
  //...省略属性、构造函数等...
  
  public void createBucketIfNotExisting(String bucketName) {
    // ...创建bucket代码逻辑...
    // ...失败会抛出异常..
  }
  
  public String generateAccessToken() {
    // ...根据accesskey/secrectkey等生成access token
  }
  
  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);
  }
  
}
           

整个上传流程包含三个步骤:创建bucket(你可以简单理解为存储目录)、生成access token访问凭证、携带access token上传图片到指定的bucket中。代码实现非常简单,类中的几个方法定义得都很干净,用起来也很清晰,乍看起来没有太大问题,完全能满足我们将图片存储在阿里云的业务需求。

不过,软件开发中唯一不变的就是变化。过了一段时间后,我们自建了私有云,不再将图片存储到阿里云了,而是将图片存储到自建私有云上。为了满足这样一个需求的变化,我们该如何修改代码呢?

我们需要重新设计实现一个存储图片到私有云的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) {
    // ...创建bucket...
    // ...失败会抛出异常..
  }

  private String generateAccessToken() {
    // ...根据accesskey/secrectkey等生成access token
  }
}

// 上传下载流程改变:私有云不需要支持access token
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) {
    // ...创建bucket...
    // ...失败会抛出异常..
  }
}

// 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()方法。

总结一下,我们在做软件开发的时候,一定要有抽象意识、封装意识、接口意识。

在定义接口的时候,不要暴露任何实现细节。接口的定义只表明做什么,而不是怎么做。而且,在设计接口的时候,我们要多思考一下,这样的接口设计是否足够通用,是否能够做到在替换具体的接口实现的时候,不需要任何接口定义的改动。

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

为了满足这条原则,我是不是需要给每个实现类都定义对应的接口呢?在开发的时候,是不是任何代码都要只依赖接口,完全不依赖实现编程呢?

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

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

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

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

实践理解

示例代码

创建一个

netcoreapp3.1

console

示例项目。

dotnet new console -o chouxianglei -f netcoreapp3.1
           
温故知新,遇见面向对象编程(OOP),如何基于接口而非实现编程,谈抽象类(Abstract)、接口(Interface)的区别和使用场景

使用Visual Studio Code编辑它。

class Tesla
{
    public void Forward()
    {
        Console.WriteLine("Tesla is Forward");
    }

    public void Backward()
    {
        Console.WriteLine("Car is Backward");
    }
}

class Benz
{
    public void Forward()
    {
        Console.WriteLine("Benz is Forward");
    }

    public void Backward()
    {
        Console.WriteLine("Car is Backward");
    }
}
           

继承关系

我们发现

Tesla

Benz

两个类都有

Forward

Backward

方法,并且

Backward

方法是一致的,那么我们可以新建一个

Car

基类来实现这个相同的方法,然后让

Tesla

Benz

两个类都派生自

Car

基类。

class Car
{
    public void Backward()
    {
        Console.WriteLine("Car is Backward");
    }
}

class Tesla : Car
{
    public void Forward()
    {
        Console.WriteLine("Tesla is Forward");
    }
}

class Benz : Car
{
    public void Forward()
    {
        Console.WriteLine("Benz is Forward");
    }
}
           

继承升级为虚函数

class Program
{
    static void Main(string[] args)
    {
        Car car = new Tesla();
        car.Forward();
    }
}
           

这时候,如果我们创建一个基于

Tesla

子类的

Car

基类实例,如果直接调用

Forward

方法,运行就会报错,因为

Car

基类里面并没有实现

Forward

方法。

温故知新,遇见面向对象编程(OOP),如何基于接口而非实现编程,谈抽象类(Abstract)、接口(Interface)的区别和使用场景

通常来说,我们可以在基类里面添加一个

Forward

方法,并且通过判断不同入参来做到多态的效果。

class Program
{
    static void Main(string[] args)
    {
        Car car = new Tesla();
        car.Forward("Tesla");
    }
}

class Car
{
    public void Backward()
    {
        Console.WriteLine("Car is Backward");
    }

    public void Forward(string carType)
    {
        if (carType == "Tesla")
        {
            Console.WriteLine("Tesla is Forward");
        }
        else if (carType == "Benz")
        {
            Console.WriteLine("Benz is Forward");
        }
    }
}
           

但是如果这时候,需求发生变动,需要增加一种新的车品牌(Land Rover)进来,那么意味着不仅要增加派生类

LandRover

,还需要修改父类里面的这个

Forward

方法才行,这样就违反了SOLID设计原则中的开闭原则(

Open Close Princliple

),除非修改或者添加功能,否则我们不应该去修改封装的基类代码。

class Car
{
    public void Backward()
    {
        Console.WriteLine("Car is Backward");
    }

    public void Forward(string carType)
    {
        if (carType == "Tesla")
        {
            Console.WriteLine("Tesla is Forward");
        }
        else if (carType == "Benz")
        {
            Console.WriteLine("Benz is Forward");
        }
        else if (carType == "LandRover")
        {
            Console.WriteLine("LandRover is Forward");
        }
    }
}

class LandRover : Car
{
    public void Forward()
    {
        Console.WriteLine("LandRover is Forward");
    }
}
           

为了解决这个问题,这里我们可以通过虚方法(

virtual

)来实现,将

Car

基类中这个

Forward

方法定义成虚方法,然后在子类中重写,这样以后再添加派生类的时候就不需要改基类代码了。

virtual关键字用于修改方法、属性、索引器或事件声明,并使它们可以在派生类中被重写(override)。
注意,这里基类的

Forward

方法使用

virtual

关键词,而派生类的

Forward

方法使用

override

关键词才能重写,否则它还是会执行基类中的逻辑。
class Car
{
    public void Backward()
    {
        Console.WriteLine("Car is Backward");
    }

    public virtual void Forward()
    {
        Console.WriteLine("Car is Forward");
    }
}

class Tesla : Car
{
    public override void Forward()
    {
        Console.WriteLine("Tesla is Forward");
    }
}
           

对应的调用代码为

class Program
{
    static void Main(string[] args)
    {
        Car car = new Tesla();
        car.Forward();
    }
}
           

执行结果为

Tesla is Forward
           

虚函数升级为抽象类

如果所有的派生类都重新实现了

Forward

方法,那么你会发现

Car

基类里面实现永远不会被执行到,这时候我们可以考虑转为抽象类(

abstract

)。

abstract修饰符指示被修改内容的实现已丢失或不完整。abstract修饰符可用于类、方法、属性、索引和事件。在类声明中使用abstract修饰符来指示某个类仅用作其他类的基类,而不用于自行进行实例化。标记为抽象的成员必须由派生自抽象类的非抽象类来实现。
class Program
{
    static void Main(string[] args)
    {
        Car car = new Tesla();
        car.Forward();
    }
}

abstract class Car
{
    public void Backward()
    {
        Console.WriteLine("Car is Backward");
    }

    public abstract void Forward();
}

class Tesla : Car
{
    public override void Forward()
    {
        Console.WriteLine("Tesla is Forward");
    }
}
           

这里不仅需要把

Forward

方法的

virtual

关键词换成

abstract

关键词,还需要把

Car

基类也加上

abstract

修饰符,如果你在方法用了

abstract

,但是没加修饰符,也会报错。

温故知新,遇见面向对象编程(OOP),如何基于接口而非实现编程,谈抽象类(Abstract)、接口(Interface)的区别和使用场景

修改之后,这时候运行也是符合预期的。

Tesla is Forward
           

这里需要注意是,抽象类(

abstract

)是不能实例化的,因为它含有抽象方法,存在没有实现的方法体,这时候如果强行创建实例,就会报错。

class Program
{
    static void Main(string[] args)
    {
        Car car = new Car();
        car.Forward();
    }
}
           
温故知新,遇见面向对象编程(OOP),如何基于接口而非实现编程,谈抽象类(Abstract)、接口(Interface)的区别和使用场景

这时候我们就能理解,抽象类(

abstract

)是专为基类而生的,它的作用是以基类类型来声明变量和方法,完全通过它的派生子类的实现来运行它的实例。

class Program
{
    static void Main(string[] args)
    {
        Car car = new LandRover();
        car.Forward();
    }
}
           

抽象类升级为接口

这时候,需要需求再次发生变更,要求所有的派生类中都要有独立的

Backward

方法,这意味着我们也需要把

Backward

方法改成抽象方法,并且在所有派生类中实现它。

class Program
{
    static void Main(string[] args)
    {
        Car car = new Tesla();
        car.Backward();
        car.Forward();
    }
}

abstract class Car
{
    public abstract void Backward();

    public abstract void Forward();
}

class Tesla : Car
{
    public override void Backward()
    {
        Console.WriteLine("Tesla is Backward");
    }

    public override void Forward()
    {
        Console.WriteLine("Tesla is Forward");
    }
}

class Benz : Car
{
    public override void Backward()
    {
        Console.WriteLine("Benz is Backward");
    }

    public override void Forward()
    {
        Console.WriteLine("Benz is Forward");
    }
}
           

这时候我们会发现,

Car

基类中所有的方法都成了抽象方法,这个基类中已经完全没有实现了,实现全部落到了派生类中去了,那么针对这种情况,我们可以升级为接口(

interface

)。

class Program
{
    static void Main(string[] args)
    {
        ICar car = new Tesla();
        car.Backward();
        car.Forward();
    }
}

interface ICar
{
    void Backward();

    void Forward();
}

class Tesla : ICar
{
    public void Backward()
    {
        Console.WriteLine("Tesla is Backward");
    }

    public void Forward()
    {
        Console.WriteLine("Tesla is Forward");
    }
}
           

对于接口来说,根据接口的命名规则,我们一般用

i

前缀开头,继承它的实现类也可以移除之前的

abstract

关键词了。

参考

  • 08 | 理论五:接口vs抽象类的区别?如何用普通的类模拟抽象类和接口?
  • 09 | 理论六:为什么基于接口而非实现编程?有必要为每个类都定义接口吗?
  • C#中的抽象类abstract与开闭原则
  • virtual(C# 参考)
  • abstract(C# 参考)
  • interface(C#参考)

继续阅读