SOLID之里氏替换原则

2017/05/22 SOLID原则

  这是SOLID原则这一系列的第四篇文章,主要来描述里氏替换(LSP)原则。LSP指定所有引用基类的地方必须能透明地使用其子类的对象。

  里氏替换原则(LSP)适用于继承的层级结构。它指定你应该设计你的类,以便于客户端依赖关系可以替换为子类,而这一切客户端不知道。因此,所有子类必须以与其基类相同的方式运行。子类的具体功能可能不同,但必须符合基类的预期行为。要成为一个真正的行为子类型,子类不仅必须实现基类的方法和属性,而且还符合其隐含的行为。这必须遵从若干规则。   第一条规则是在基类的方法的参数和子类之间的匹配参数之间应该存在矛盾。这意味着子类中的参数必须与基类中的参数相同,或者限制性较小。类似地,基类中的方法返回值和其子类之间必须存在协方差。 这指定子类的返回类型必须与基类返回类型相同或更为限制。   下一条规则关注点是前驱条件和后置条件。类的前置条件是在采取行动之前必须到位的规则。例如,在调用从数据库读取的方法之前,您可能需要满足数据库连接打开的前置条件。后置条件描述进程完成后对象的状态。例如,假定数据库连接在执行SQL语句后关闭。LSP指出,基类的前提条件不能通过子类加强,并且子类中的后置条件不能被削弱。   接下来考虑LSP的不变量?一个不变量描述了一个进程的条件,该进程在进程开始之前是真实的,并且之后保持为真。例如,一个类可能包含一个从一个文件读取文本的方法。如果该方法处理文件的打开和关闭,则不变量可能是该文件在调用之前或之后未打开。为了符合LSP,基类的不变量不能被子类改变。   下一个规则是历史约束。根据其性质,子类包括其超类的所有方法和属性。他们还可以增加其他成员。历史约束说新的或修改的成员不应该以基类不允许的方式修改对象的状态。例如,如果基类表示具有固定大小的对象,则子类不应允许修改此大小。   LSP最后一个规则指定一个子类不应该抛出不被基类抛出的异常,除非它们是基类可能抛出的异常的子类型。   上述规则不能由编译器控制或被面向对象编程语言所限制。相反,您必须仔细考虑类层次结构的设计和将来可能被子类化的类型。如果不这样做,就有可能创建破坏规则并在依赖于它们的类型中创建错误的子类。不符合LSP的一个常见场景是当客户端类检查其依赖关系的类型时。这可能是通过读取人为描述其类型的对象的属性或通过使用反射来获得该类型。通常,switch语句将用于根据依赖关系的类型执行不同的动作。这种额外的复杂性也违反了开放/封闭原则(OCP),因为客户端类将需要被修改,因为引入了进一步的子类。

代码示例

  为了演示LSP的应用,我们可以考虑违反它的代码,并解释如何重构类遵循原则。 以下代码显示了几个类的概要:

public class Project
{
    public Collection<ProjectFile> ProjectFiles { get; set; }
 
    public void LoadAllFiles()
    {
        foreach (ProjectFile file in ProjectFiles)
        {
            file.LoadFileData();
        }
    }
 
    public void SaveAllFiles()
    {
        foreach (ProjectFile file in ProjectFiles)
        {
            if (file as ReadOnlyFile == null)
                file.SaveFileData();
        }
    }
}
 
 
public class ProjectFile
{
    public string FilePath { get; set; }
 
    public byte[] FileData { get; set; }
 
    public void LoadFileData()
    {
        // Retrieve FileData from disk
    }
 
    public virtual void SaveFileData()
    {
        // Write FileData to disk
    }
}
 
 
public class ReadOnlyFile : ProjectFile
{
    public override void SaveFileData()
    {
        throw new InvalidOperationException();
    }
}

  第一个类代表一个包含多个文件的项目类。包括两种方法,用于加载每个项目文件的文件数据,并将所有文件保存到磁盘。 第二类描述一个项目文件。 这具有文件名的属性和加载文件数据的字节数组。 两种方法允许加载或保存文件数据。   以后可能会将第三类添加到其他两个类中,也许当创建一个新的需求时,某些项目文件将是只读的。 ReadOnlyFile类从ProjectFile继承其功能。 但是,由于只读文件无法保存,所以SaveFileData方法已被覆盖,从而抛出无效的操作异常。   ReadOnlyFile类以多种方式违反了LSP。 虽然基类的所有成员都已实现,但客户端无法替代ProjectFile对象的ReadOnlyFile对象。 这在SaveFileData方法中是清楚的,它引入了一个不能被基类抛出的异常。 接下来,基类中的SaveFileData方法的后置条件是文件已在磁盘上更新。 子类不是这样。 最后一个问题可以在Project类的SaveAllFiles方法中看到。 在这里,程序员添加了一个if语句,以确保在尝试保存文件之前该文件不是只读的。 这样就违反了LSP和OCP,因为必须修改Project类以允许检测到新的ProjectFile子类。

代码重构

  有多种方式可以重构代码以符合LSP。 一个如下所示。这里的Project类已被修改为包含两个collection代替之前的一个collection。 一个collection包含项目中的所有文件,另一个只包含对可写文件的引用。LoadAllFiles方法加载所有文件数据到AllFiles collection中。由于WriteableFiles集合中的文件将是相同引用的子集,所以数据将通过这些参数可见。SaveAllFiles方法已被替换为只保存可写文件的方法。   ProjectFile类现在只包含一种加载文件数据的方法。这个方法对于只读文件和可写文件都是具有的。WriteableFile类继承自ProjectFile,并添加了一个保存文件的方法。层次结构的这种颠倒意味着代码现在符合LSP。   下面的是重构后的代码:

public class Project
{
    public Collection<ProjectFile> AllFiles { get; set; }
    public Collection<WriteableFile> WriteableFiles { get; set; }
 
    public void LoadAllFiles()
    {
        foreach (ProjectFile file in AllFiles)
        {
            file.LoadFileData();
        }
    }
 
    public void SaveAllWriteableFiles()
    {
        foreach (WriteableFile file in WriteableFiles)
        {
            file.SaveFileData();
        }
    }
}
 
 
public class ProjectFile
{
    public string FilePath { get; set; }
 
    public byte[] FileData { get; set; }
 
    public void LoadFileData()
    {
        // Retrieve FileData from disk
    }
}
 
 
public class WriteableFile : ProjectFile
{
    public void SaveFileData()
    {
        // Write FileData to disk
    }
}

Search

    Table of Contents