概述
里氏替换原则(Liskov Substitution Principle,LSP)是指如果对每一个类型为T的对象O,都有类型为T1的对象O1,使得以T定义的所有程序P在所有的对象O替换为O1是,程序P的行为没有发生变化,那么类型T1是类型T的子类。通俗理解是代码中任何父类对象可以出现的地方,子类都可以出现,用子类替换父类,不会导致原有的代码发生异常。里氏替换原则是对开闭原则的补充。
深入理解
从定义可以看出来,里氏替换原则是对实现抽象化的具体步骤的规范,即对有继承关系的父子类的相互约束。当要设计一个子类去扩展一个符合规范的父类时,里氏替换原则要求约束子类的行为,避免继承泛滥,比如子类可以新增新的特性,但不应改变父类的行为,这些行为包括覆盖父类的非抽象方法、降低父类方法的访问权限等;或重构代码时,重构后的父类和子类,里氏替换原则要求父类对象出现的地方,子类对象都可以出现,如果不符合,必然还需要优化或继续重构代码。反之,子类出现的地方,父类不一定可以替换。这里说的子类不要覆盖父类非抽象方法,在实际开发中,有一些情况下父类的非抽象方法是可以覆盖的,即这些父类非抽象方法原本就是设计给子类覆盖用的,这在一些基于事件处理的框架中很常见,这些父类的非抽象方法为使用框架的用户提供了处理事件的入口,比如Netty框架中提供用户对通道事件的处理便是如此。
代码示例
比如一个教学平台原来设计只有教师用户Teacher登录用来备课。现在增加了教学功能,且新增了学生用户Student可以登录平台学习。下面通过代码示例,说明遵循里氏替换原则的重要性和必要性。
- 原有的登录服务和教师服务伪代码
// 1.原来的教师用户类
public class Teacher {
private String username;
private String pwd;
// 教师证书编号
private String certificateNo;
private String teacherName;
public String getUsername() {
return username;
}
public String getPwd() {
return pwd;
}
public String getTeacherName() {
return teacherName;
}
}
// 2.原来的的登录服务接口
public interface LoginService {
void login(Teacher teacher);
}
// 3.原来的用户登录服务
public class LoginServiceImpl implements LoginService {
@Override
public void login(Teacher teacher) {
String username = teacher.getUsername();
String pwd = teacher.getPwd();
// TODO 查询数据库进行验证
}
}
// 4.原来的教师服务接口
public interface TeacherService {
void record(Teacher teacher,String course);
}
// 5.原来的教师服务
public class TeacherServiceImpl implements TeacherService {
@Override
public void record(Teacher teacher, String course) {
// 教师名字
String teacherName = teacher.getTeacherName();
// TODO 教师录制课程
}
}
- 新增加的用户User类和学生Student相关类的伪代码
因为新增了学生用户Student,所有抽象一个用户User类作为学生和教师的父类,然后再重构用户登录服务,下面是新增的用户User类和学生用户Student类
// 6.User类,抽取公共部分
public abstract class User {
private String username;
private String pwd;
public String getUsername() {
return username;
}
public String getPwd() {
return pwd;
}
// 用户角色,有具体子类实现
abstract protected String role();
}
// 7.学生用户
public class Student extends User {
// 学号
private String studentNumber;
private String studentName;
@Override
protected String role() {
return "student";
}
}
// 8.学生服务
public interface StudentService {
void learning();
}
// 学生服务
public class StudentServiceImpl implements StudentService {
@Override
public void learning() {
// TODO 上课学习
}
}
- 重构后的用户登录服务和教师服务相关伪代码
// 1.原来的教师用户类
// 继承User类,删除了父类中有的公共内容
// 只保留教师特有的特性即可
public class Teacher extends User {
// 教师证书编号
private String certificateNo;
private String teacherName;
public String getTeacherName() {
return teacherName;
}
@Override
protected String role() {
return "teacher";
}
}
// 2.原来的的登录服务接口
// 参数使用父类User,让新增加的Student子类可以使用
public interface LoginService {
void login(User user);
}
// 3.原来的用户登录服务
// 重构后,所有User的子类均可以登录,
// User参数替换为Teacher或Student,预期都一致,
// 遵循里氏替换原则
public class LoginServiceImpl implements LoginService {
@Override
public void login(User user) {
String username = user.getUsername();
String pwd = user.getPwd();
// TODO 查询数据库进行验证
}
}
// 4.原来的教师服务接口
// 增加了教学方法
public interface TeacherService {
void record(Teacher teacher,String course);
void teaching(String course);
}
// 5.原来的教师服务
// 增加了教学方法。思考:此处可以使用User替换Teacher吗?
public class TeacherServiceImpl implements TeacherService {
@Override
public void record(Teacher teacher, String course) {
// 教师名字
String teacherName = teacher.getTeacherName();
// TODO 教师录制课程
}
@Override
public void teaching(String course) {
// TODO 教师教学
}
}
重构完成后,登录服务中,User可以被替换为Student或Teacher,满足学生和教师都可以登录的需求,但教师服务中课程录制record方法中的Teacher则不能替换为User,因为这是教师的特性。重构之后,如果增加后续新的用户角色,比如家长、辅导员,则不再需要修改原有的代码即可实现扩展新的用户角色,这样提升了代码的可复用和可扩展性、以及可维护性。
总结
上面的例子看完可能还有些模糊,但只要记住里氏替换原则,一是告诉开发人员,在有需要新特性时才去扩展父类,不要为了继承而继承。另外就是,在重构代码时,重构后的代码要遵循里氏代换原则,这样可以最大程度确保重构后的代码的复用性和可维护性以及可扩展性,让重构真正有意义或满足业务需求。