Skip to main content
  1. Posts/

OODP4 依赖倒置原则(DIP)

·228 words·2 mins·
Eric Linus
Author
Eric Linus
北京邮电大学软件工程专业本科生,主要语言C++,对系统编程,数据库和AI系统交叉感兴趣。熟悉C++/Python/C#/Java/Rust。Github:@n00bme0w
Table of Contents

设计代码结构时,高层模块不应该依赖低层模块,二者都应该依赖其抽象。抽象不应该依赖于细节,细节应该依赖于抽象。
#

错误做法
#

我们看一个简单的例子,设计一个关系存储的模块,共有三种关系(Relationship),父,子,同辈。

于是我们给出Relationship枚举和Person类

public enum Relationship
{
    Parent,
    Child,
    Sibling
}

public class Person
{
    public string Name;
}

开始设计低层模块,记录关系的Relationship类,我们随意的使用tuple来记录各种关系,并简单给出添加关系的api,同时将私有的relations成员通过公有的Relations函数暴露出去


public class Relationships
{
    private List<(Person, Relationship,Person)> relations = [];

    public void AddParentAndChild(Person parent, Person child)
    {
        relations.Add((parent, Relationship.Parent, child));
        relations.Add((child, Relationship.Child, parent));
    }

    public List<(Person, Relationship, Person)> Relations => relations;
}

于是我们在高层模块中使用直接使用底层模块,即在Demo构造函数中直接接受Relationships作为参数,当我们想要查询John的孩子是谁时,我们遍历通过Relations得到的关系元组进行查询输出即可。


public class Demo
{
    public Demo(Relationships relationships)
    {
        var relations = relationships.Relations;
        foreach (var r in relations.Where(x => x.Item1.Name == "John" &&
                    x.Item2 == Relationship.Parent))
        {
            Console.WriteLine($"John has a child {r.Item3.Name}");
        }
    }

    static void Main(string[] args)
    {
        var parent = new Person(){Name = "John"};
        var child1 = new Person(){Name = "Jane"};
        var child2 = new Person(){Name = "Jonny"};
        var relations = new Relationships();
        relations.AddParentAndChild(parent, child1);
        relations.AddParentAndChild(parent, child2);
        new Demo(relations);
    }
} 

反思环节
#

注意到,当我们这样做时,由于高层直接依赖于低层,低层是“不可拆换的”。

例如,如果我们不想再使用元组列表作为我们的数据结构,而是想要切换成元组数组,或者直接换成树结构。我们的代码就必须大量重写,不仅要完全重写低层记录关系的类,还得将上层Demo类里直接调用低层类的部分全部重写。

如果这是一个很大的项目架构,这个低层模块可能被很多个高层模块调用,这将带来巨大的维护代价。

从DIP中总结,即是高层模块直接依赖低层模块(Demo类直接使用Relationships类),抽象依赖于细节(“从关系中查找特定人的孩子”的抽象依赖于了元组和遍历这样的实现细节)

正确操作
#

高层模块不应依赖于低层模块,二者都应该依赖于抽象。那么我们需要一个抽象放在Demo和Relationships之间。

于是我们给出接口IRelationshipBrowser

public interface IRelationshipBrowser
{
    IEnumerable<Person> FindAllChildOf(string person);
}

这个接口即为抽象的一种形式,它代表着“关系浏览器”的抽象概念,而不涉及任何它的实现,只说明它应该有找到某人所有孩子的功能。

于是我们现在可以让高层依赖抽象

这里参数的类型的正是抽象,而不是底层模块本身。与此同时你可以看到API比原来好看的多。

public Demo(IRelationshipBrowser browser) 
{
    foreach (var r in browser.FindAllChildOf("John"))
    {
        Console.WriteLine($"John has a child {r.Name}");
    }
    
}

低层模块的实现也依赖接口的抽象,即低层模块必须实现该接口的功能。

/// public class Relationships : IRelationshipBrowser { private List<(Person, Relationship,Person)> relations = [];

public void AddParentAndChild(Person parent, Person child)
{
    relations.Add((parent, Relationship.Parent, child));
    relations.Add((child, Relationship.Child, parent));
}

public IEnumerable<Person> FindAllChildOf(string person)
{
    var relations = Relations;
    return relations.Where(x => x.Item1.Name ==person && x.Item2 == Relationship.Parent).Select(x => x.Item3);
    // low level
}

public List<(Person, Relationship, Person)> Relations => relations;

}


这里函数的实现使用了LINQ来简化。

注意到此时相比于之前的代码,当我们想要换一种Relationships,如用树來实现同样的功能时,我们不再需要更改上层模块了。此时只需要写一个类,比如说TreeRelationships,让它实现IRelationshipBrowser接口,在所有上层调用中就可以直接使用TreeRelationships类了(因为上层模块接受所有实现了IRelationshipBrowser的类作为参数)。从直观理解上,上层模块只关心下层提供什么样的服务,所以应只依赖于抽象,这样下层即使更改了服务的提供商,只要能提供目标服务,也能正确地运行。