首页 > 编程笔记 > C#笔记

C# SOLID:面向对象编程五大原则

下面简单介绍一下面向对象编程的五大原则,即 SOLID,其中,每个字母代表一条原则:

适当的应用这些原则,会使得代码拥有良好的扩展性,并易于测试和多人开发。因此,SOLID 广泛运用于测试驱动开发和敏捷开发中。

单一职责原则

单一职责原则(Simple responsibility principle)可能是五大原则中最容易理解的,它希望类型应当只具有一种功能或表示一种概念,这里应将功能理解为改变的原因。

例如,数据库管理类应当只包括对数据库进行 CRUD 动作的方法,不应该包括其他方法,例如权限判断等。

单一职责原则既可以用于类型(类、结构、接口),也可以用于方法。在撰写方法时,如果每个方法只专注于一件事,那么它的命名也就很简单,单元测试非常方便,其他开发者也可以很容易地理解这个方法内部的代码。

开闭原则

开闭原则(Open/closed principle):软件实体(类、模块、函数等)应该对扩展开放,对修改关闭。

通俗来讲,它意味着你应当能在不修改类的前提下扩展一个类的行为,就好像不需要改变体型就可以穿上不同的衣服,即能够按照你的意愿穿上不同的衣服来改变面貌,而不用改造身体。

对扩展开放,对修改关闭了(除非去做手术)。

在面向对象设计中,对扩展开放意味着类或模块的行为能够改变,在需求变化时我们能以新的、不同的方式让模块改变,或者在新的应用中满足需求。

通过继承接口,可以实现遵循开闭原则的类型。简单工厂进化到工厂方法模式就是一个十分经典的例子。

1) 简单工厂

简单工厂(Simple Factory)模式中,可以根据参数的不同返回不同类的实例(这通过 switch 判断实现)。

简单工厂模式专门定义一个具体工厂类来负责创建其他类的实例,被创建的实例具有共同的父类。

简单工厂模式包含如下角色:

其精髓如下:

【实例】借用舰队 Collection 的台词,指定族产品为战列舰,实现两个具体产品长门和陆奥。

抽象产品类型代码如下:
//一个抽象产品
public class BattleShip
{
}

//若干具体产品
public class Nagato : BattleShip
{
    public Nagato()
    {
        Console.WriteLine("我是战列舰长门。请多指教。和敌战列舰的战斗就交给我吧。");
    }
}

public class Mutsu : BattleShip
{
    public Mutsu()
    {
        Console.WriteLine("长门级战舰二号舰的陆奥哟。请多关照。");
    }
}
工厂类型代码如下:
//一个具体工厂
public class Factory
{
    //根据传入值制造产品
    public BattleShip CreateBattleShip(string type)
    {
        //这是简单工厂的问题所在:扩展性差
        switch (type)
        {
            case "mutsu":
                return new Mutsu();
            case "nagato":
                return new Nagato();
            default:
                throw new ArgumentException("该型号的战列舰不可用");
        }
    }
}

简单工厂模式的主要问题:扩展性差。

一个简单的工厂模式的代码写完了,但是以上代码还是有问题。它违背了开闭原则。

如果某天需求变更,突然要加一种新的战列舰比如“大和”,我们的做法是:
在实际应用中,产品很可能是一个多层次的树状结构。但简单工厂模式中只有一个工厂类来对应这些产品。

正如前面提到的,简单工厂模式适用于业务简单的情况或者具体产品很少增加的情况。而对于复杂的业务环境可能就不太适应了。这就应该由工厂方法模式岀场了。

2) 工厂方法模式

工厂方法模式(Factory Method Pattern)定义了一个创建对象的接口,但由子类决定要实例化的类是哪一个。工厂方法让类把实例化推迟到子类。

和简单工厂一样,工厂方法模式也会面对一族产品,所以也会有一个抽象的产品类和若干具体的子类,子类实现父类的抽象方法。

工厂和简单工厂的区别:
在简单工厂中,只有一个具体的工厂(它可以生产所有东西),实例化都是在那一个工厂实现的(通过条件判断)。

工厂方法中有若干个具体工厂,它们对应着所有的产品,每个工厂只能生产对应的一种产品。

实例化是在具体工厂中实现的(即“子类决定要实例化的类”,“推迟到子类”)。

工厂方法模式中,抽象工厂不负责生产,它只是一个接口,它的生产方法是一个抽象方法,对应虚的产品。它是创建对象的“接口”。

工厂方法模式包含如下角色:

代码实现

【实例】沿袭简单工厂的代码,抽象产品类和具体产品类不需要改动。首先,增加一个抽象工厂类。然后,多个具体工厂类继承它,由于不再需要通过 switch讨论,代码如下:
public interface AbstractFactory
{
    //抽象工厂类提供签名
    BattleShip Create();
}

//多个具体工厂
public class MutsuFactory : AbstractFactory
{
    public BattleShip Create()
    {
        return new Mutsu();
    }
}

public class NagatoFactory : AbstractFactory
{
    public BattleShip Create()
    {
        return new Nagato();
    }
}
调用方:
class Program
{
    static void Main(string[] args)
    {
        var f = new NagatoFactory();
        f.Create();

        Console.ReadKey();
    }
}

扩展性

假设增加了一个战列舰。此时我们要做的事情如下:

也就是说,我们不用修改,只需添加。所以工厂方法模式符合开闭原则!

由于使用了面向对象的多态性,工厂方法模式保持了简单工厂模式的优点,而且克服了它的缺点。

在工厂方法模式中,核心的工厂类不再负责所有产品的创建,而是将具体创建工作交给子类去做。

这个核心类仅仅负责给出具体工厂必须实现的接口,而不负责哪一个产品类被实例化这种细节,这使得工厂方法模式可以允许系统在不修改工厂角色的情况下引进新产品。

用工厂方法模式足以应付我们可能遇到的大部分业务需求。但当产品有多于一族时,这种情况下就可使用抽象工厂模式了。

里氏代换原则

这条原则大概是 SOLID 中最难理解的。里氏代换原则 (Liskov Substitution principle) 通俗的来说就是子类型必须能够替换它们的基类型(在任何地方、任何时候)。

但通常来说,未必一定能够做到,例如:

我们本希望子类可以做父类所有的事情,并且还能做父类不能做的事情。但是,现实生活之中,父类能做的事情,未必子类也都可以做。

因此,上面的两个例子都违反了里氏代换原则。

解决的方法有两个(以第一个例子为例):

接口隔离原则

接口隔离原则(Interface segregation principle):客户端不应该依赖它不需要的接口。或者多个专门的接口好于一个通用的接口。

在 .NET 中,所有的集合都继承自 IEnumerable 或它的泛型版本。

如果我们将所有集合可用到的方法都编写在 IEnumerable 中,那么 IEnumerable 将会成为一个非常庞大的胖接口。

例如,某些集合可以从中间插入,或者从中间删除,那么,我们可以将 Add 和 Remove 方法写在 IEnumerable 中。

但是,某些集合,例如队列和栈,它们是不能随便插入删除的。所以,如果队列和栈的实现 Stack 和 Queue 继承自 IEnumerable,就会出现问题。

因此,在 .NET 中,微软将这些不那么通用的方法放到了 IEnumerable 的子类 ICollection 的子类 IList 中,并令那些拥有随便插入删除能力的集合(例如 List)继承自 IList,而 Stack 和 Queue 继承自 ICollection。

总的来说,如果接口过胖,那么解决方式是将一些不那么通用的方法放到接口的子接口中,并令客户端选择性地继承父接口(功能较少的类)或者子接口(功能较多的类),从而保证接口隔离。

依赖倒转原则

依赖倒转原则(Dependency inversion principle)的主要内容是:较为抽象的类定义一组接口,具体的实现类必须遵循这些接口(细节应该依赖于抽象),也可解读为:高层不应该依赖于底层,两者皆应当依赖于抽象(一组接口)。

假设我们要定义一个汽车,它拥有着一系列部件,例如引擎、车轮等。那么,我们应该令所有的引擎继承自 IEngine 接口(例如,ToyotaEngine, BenzEngine 等)。然后,令所有的车轮继承自 IWheel 接口。

这样一来,在构造函数中,我们的丰田汽车就可以由传入一个 ToyotaEngine 和 ToyotaWheel 来组装而成。而奔驰汽车可以由传入一个 BenzEngine 和 BenzWheel 组装而成。

在这个例子中,高层(汽车)依赖于一组抽象:

Car(IEngine engine, IWheel wheel)

而底层(引擎和车轮)直接依赖于对应的接口。因此,两者皆依赖于抽象。而将具体类型的引擎和车轮通过构造函数传递给一个具体类型的汽车这个动作,就叫做(构造函数)依赖注入(Dependency Injection)。

设计模式中,对应的模式是策略模式。如果你的汽车的定义依赖于具体的引擎和车轮,那么,你就无法更改引擎和车轮的类型。

依赖注入使得我们的系统灵活性更高,可以更好地应付用户需求的改变。在需求改变时,大幅降低改代码的风险。

依赖注入降低模块之间的耦合程度,方便单元测试。在实际开发中,有很多使用依赖注入的动机,例如对不同类型的数据库进行处理时,在构造函数中可以传入继承自一组接口的不同类型数据库的实例。

可以使用 AutoFac、structureMap 等工具来管理依赖注入。

所有教程

优秀文章