设计原则:里氏替换

611 阅读4分钟

在程序设计领域, SOLID(单一功能、开闭原则、里氏替换、接口隔离以及依赖反转)是由 Robert Martin 在21世纪早期 引入的记忆术首字母缩略字,指代了面向对象编程和面向对象设计的五个基本原则。

“SOLID”中的 L 指代了里氏替换原则,英文是 Liskov Substitution principle

在面向对象的程序设计中,里氏替换原则是对子类型的特别定义,它由 Barbara Liskov 在1987年在一次会议上名为“数据的抽象与层次”的演说中首先提出。内容可以描述为:“派生类(子类)对象可以在程序中代替其基类(超类)对象。”,原文为:

image.png

Barbara Liskov 与 Jeannette Wing 在1994年发表论文并提出以上的 Liskov代换原则

在 1996 年,Robert Martin 在他的 SOLID 原则中,重新描述了这个原则,英文原话是这样的: FUNCTIONS THAT USE POINTERS OR REFERENCES TO BASE CLASSES MUST BE ABLE TO USE OBJECTS OF DERIVED CLASSES WITHOUT KNOWING IT.

综上所述:子类对象(object of subtype/derived class)能够替换程序(program)中父类对象(object of base/parent class)出现的任何地方,并且保证原来程序的逻辑行为(behavior)不变及正确性不被破坏。

如何理解里氏替换?

经过上面的描述,XDM可能还是会觉得有点懵,不知道什么是里氏替换原则,我们一起来看下例子:

public abstract class Action {
    //...
    
    public Response handle(Request request) {    
        //... handle request and response
    }
}

public class CustomizeAction extends Action {
    
    public Response handle(Request request) {
        String userId = request.get("userId");
        String token = request.get("token");
        
        // create auth key
        if(StringUtil.isNotEmpty(userId) && StringUtil.isNotEmpty(token)) {
            // use userId and token create authKey
            String authKey = ...;
            request.put("authKey", authKey);
        }
        
        return super.handle(request);
    }
}

public class ActionContext {
    
     public void resolve(Action action, Request request) {
        Response response = action.handle(request);
        
        // do something on response...
    }
}

public class Main {
    public static void main(String[] args) {
        Request request = createRequest(args);
        Action action = ActionSupport.getAction(request);
        
        ActionContext context = new ActionContext();
        context.resolve(action, request);
    }
}

在上面的代码中,子类 CustomizeAction 的设计完全符合里式替换原则,可以替换父类出现的任何位置,并且原来代码的逻辑行为不变且正确性也没有被破坏。

或许,你会有疑问,这不就是面对对象程序的多态吗?多态与里氏替换原则确实不是一回事,我们先带着这个疑问,继续往下看。

上面的例子,我们改造一下:

public class CustomizeAction extends Action {
    
    public Response handle(Request request) {
        String userId = request.get("userId");
        String token = request.get("token");
        
        if(StringUtil.isEmpty(userId) || StringUtil.isEmpty(token)) {
            throw new UnAuthException();
        }
        
        // create auth key
        // use userId and token create authKey
        String authKey = ...;
        request.put("authKey", authKey);
        
        return super.handle(request);
    }
}

如果 userId 或者 token 获取失败时,我们认为是没有进行权限认证。改造之前,不进行处理;改造之后,会向上层抛出 UnAuthException 异常。改造之后的代码,违反了里氏替换原则,因为向上层抛异常,上层并没有进行显式的异常捕获处理,子类 CustomizeAction 替换父类 Action 传递给方法 resolve 后,程序的逻辑行为与预期发生偏差。

上面的疑问,在这里做一下解答,为什么多态和里氏替换原则不是一回事呢?

多态和里氏替换原则关注的角度不一样,多态是面向对象编程的特性,也是一种面向对象编程的语法,同时也是一种代码实现的思路;而里氏替换原则是一种设计原则,用来指导继承关系中子类该如何设计的,子类的设计要保证在替换父类的时候,不改变原有程序的逻辑以及不破坏原有程序的正确性。

里氏替换原则如何落地?

里氏替换原则,有一个更为落地的方针:按照协议来设计

子类在设计的时候,要遵循父类的行为规约,子类只允许修改函数内部实现的具体逻辑,但不能改变函数原本的行为约定。约定,我们可以简单概括一下:

  • 函数声明要实现的功能; 假如父类方法约定实现的逻辑时删除列表List里面的空字符串,但是子类实现删除空字符串的同时并对列表的元素进行了排序,这就违反了里氏替换原则;
  • 对输入、输出、异常的约定; 如上面的例子,异常的抛出改变了父类程序的行为约定,违反了里氏替换原则;
  • 注释中所罗列的任何特殊说明 (如业务的特殊说明)。