面向对象设计原则,是一种指导思想,在程序设计过程中,要尽量的去遵守这些原则,用于解决面向对象设计中的可维护性,可复用性以及可扩展性。常用的,就是我们日常所说的6大原则,分别是:单一职责(SRP)、里氏替换原则(LSP)、依赖倒置原则(DIP)、接口隔离原则(ISP)、迪米特法则(LOD)、开闭原则(OCP)。下面就来分别说说这些原则:
一、 单一职责(Single Reponsibility Principle,SRP)
一个类只负责一项职责。换种说法,就一个类而言,应该只有一个引起它变化的原因。
这个原则最简单,也是备受争仪,较难运用的一个原则。这个和类的职责有关,主观性比较强,没有一个量化的标准,开发设计人员对职责怎么定义,以及怎么划分类的职责,和每个人的分析设计思想及相关实践经验,都有比较大的关系。对于一个类而言,不能承担太多的职责,职责过多,就会耦合在一起,一个职责变化,会影响其它职责的运作。过多的耦合,还会影响其复用性。对于软件系统而言,小到类的方法,接口的定义,大到模块,类库,也都是一样的。单一职责的指导思想,就是为了实现高内聚,低耦合
。
下面来看一个简单的例子:以一个建筑工为例,这个人比较厉害,泥瓦工,木工,油漆工都能做,代码如下:
public class Builder
{
public void Work()
{
Console.WriteLine("我开始做泥瓦工的活了");
Console.WriteLine("我开始做木工的活了");
Console.WriteLine("我开始做油漆工的活了");
}
}
这个代码简单的不能再简单了,一看就懂。不管做啥,都整到一个方法里面,就是个大杂烩,这里只是显示,没什么逻辑,如果逻辑多的话,只是判断的话,你就要各种 if else ... 了,自己想想吧.....都不敢想了!那就来优化一下,先看张类图
一般情况下,都会想到这种方式,分成三个不同的方法来处理,代码很简单,这里就不贴出来了,但这样同样的有问题,一个人(类)做这么多事情,你不会很“累”吗?说白点,就是职责太多,即要做泥瓦的活,又要做木工的活,如果哪天赶工,临时来个只做木工的或其它工种的,你就要改类,木工的代码也没法复用,这就违背了单一职责。因此,需要对类进行拆分,使其满足单一职责,重构后如图1.2:
各做各的,互不影响,就如同现在建筑工人分工,做什么都很明确。引入到软件设计里面,类的复杂性降低了,可读性也同时提高了,最重要的是职责划分也明确了。当然,也就更容易维护了。
代码如下
public interface IBuilder
{
void Work();
}
public class TilerBuilder : IBuilder
{
public void Work()
{
Console.WriteLine("我是泥瓦工,开始工作了");
}
}
public class WoodBuilder : IBuilder
{
public void Work()
{
Console.WriteLine("我是木工,开始工作了");
}
}
public class PaintBuilder : IBuilder
{
public void Work()
{
Console.WriteLine("我是泥瓦工,开始工作了");
}
}
二、里氏替换原则(Liskov Substitution Principle,LSP)
所有使用基类的地方,都可以使用其子类来代替,而且行为不会有任务变化
面向对象语言的继承是项很牛的设计,普通类间父子继承,抽象类以及接口,它们之间的相互关联与纠缠,看似复杂,实则给我们带来很多好处:代码共享,减少创建类的工作量,提高了代码的复用性;提高了代码的可扩展性与项目的开放性,实现父类方法后,子类可任意扩展,想想一些框架的扩展接口不都是通过继承来完成的么。里氏替换原则就是为良好的继承定义了一个规范
。主要如下:
- 子类必须完全实现父类的属性和方法,如果子类不拥有父类的全部属性或者行为,不能强行继承,要断掉继承。
- 子类可以拥有父类没有的属性或者方法,子类出现的地方,父类不能代替。
一直在纠结举个什么例子,还是拿鸟来说事吧,通俗易懂。先看个反例,鸟类都需要吃东西,都需要喝水,还可以飞,代码如下:
public class Bird
{
public string Name => this.GetType().Name;
public void Eat()
{
Console.WriteLine($"我是{this.Name},我需要吃东西");
}
public void Drink()
{
Console.WriteLine($"我是{this.Name},我需要喝水");
}
public void Fly()
{
Console.WriteLine($"我是{this.Name},我可以飞");
}
}
/// <summary>
/// 现在来了只比较大的鸟,叫鸵鸟,继承了鸟类
/// </summary>
public class Ostrich : Bird
{
//Do nothing
}
调用一下
class Program
{
static void Main(string[] args)
{
try
{
Bird bird = new Ostrich();
bird.Eat();
bird.Drink();
bird.Fly();
}
catch (Exception e)
{
Console.WriteLine(e.Message);
}
Console.Read();
}
}
运行结果:
我是Ostrich,我需要吃东西
我是Ostrich,我需要喝水
我是Ostrich,我可以飞
是不是出问题了,鸵鸟显然是不能飞的,也继承了鸟类,这就违背了里氏替换原则。鸵鸟虽然是鸟类,可不能飞,比较特珠,就需要断掉继承。鸵鸟这是说话了,我不能飞,我也要吃和喝啊,怎么办?那你都不属于动物吗,我们来使用里氏替换原则重构一下:
类图不复杂,很容易理解,抽出一个共同的基类动物,然后继承各自的功能,互不影响,根据需求还可以有自己的方法,孔雀可以开屏了。
到这里,你是不是看出点什么问题了,如果父类有什么改动或需要去除一个方法什么的,这就麻烦了,这就是里氏替换的一个缺陷了:继承是侵入式的,代码灵活性受到限制,增强了耦合性。
代码如下:
public class Animal
{
public string Name => this.GetType().Name;
public void Eat()
{
Console.WriteLine($"我是{this.Name},我需要吃东西");
}
public void Drink()
{
Console.WriteLine($"我是{this.Name},我需要喝水");
}
}
public class Bird : Animal
{
/// <summary>
/// 鸟有自己可以飞的方法
/// </summary>
public void Fly()
{
Console.WriteLine($"我是{this.Name},我可以飞");
}
}
public class Ostrich : Animal
{
//do nothing
}
public class Sparrow : Bird
{
//do nothing
}
public class Peacock : Bird
{
/// <summary>
/// 孔雀可以开屏
/// </summary>
public void Open()
{
Console.WriteLine($"我是{this.Name},我要开屏了,我不是老孔雀");
}
}
调用如下
class Program
{
static void Main(string[] args)
{
try
{
{
Bird bird = new Sparrow();
bird.Fly(); //可以飞
}
{
//Bird bird = new Peacock(); //子类出现的地方父类不能代替
Peacock bird = new Peacock();
bird.Fly();
bird.Open();
}
}
catch (Exception e)
{
Console.WriteLine(e.Message);
}
Console.Read();
}
}
三、依赖倒置原则(Dependence Inversion Principle,DIP)
高层模块不应该依赖低层模块,两者都应该依赖其抽象,不要依赖细节
在C#中,抽象就是指接口或者抽象类,两者都不能直接进行实例化;细节就是实现类,就是实现了接口或继承了抽象类而产生的类就是实现类,可以直接被实例化。所谓的高层与低层,每个逻辑实现都是由原始逻辑组成,原始逻辑就属于低层模块,像我们常说的三层架构,业务逻辑层相对数据层,数据层就属于低层模块,业务逻辑层就属于高层模块,是相对来说的。依赖倒置原则就是程序逻辑在传递参数或关联关系时,尽量引用高层次的抽象,不使用具体的类,即是使用接口或抽象类来引用参数,声明变量以及处理方法返回值等
。这样就要求具体的类就尽量不要有多余的方法,否则就调用不到。说简单点,就是“面向接口编程”。
现在学车很流行,驾校也很多(学习的车是真心的破旧),我当时都是些老捷达,皇冠之类的,根据依赖倒置的原则,我们来实现下这个过程,如图3.1
一个学生的抽象类,一个汽车的接口,分别定义了各自的职能,具体代码如下:
public interface ICar
{
/// <summary>
/// 汽车是可以开动的
/// </summary>
void Run();
}
public class Jetta : ICar
{
public void Run()
{
Console.WriteLine("捷达车开动起来了...");
}
}
public class Crown : ICar
{
public void Run()
{
Console.WriteLine("皇冠车开动起来了...");
}
}
/// <summary>
/// 用的抽象方法,考虑学员会有共性的内容
/// </summary>
public abstract class BaseStudent
{
public string Name { get; set; }
/// <summary>
/// 给个构造函数,用来初始化名子
/// </summary>
/// <param name="name"></param>
protected BaseStudent(string name)
{
this.Name = name;
}
/// <summary>
/// 学员要学习开车
/// 这里用的是虚方法,实现可确定的基本操作
/// 由于每个学员学习过程可能不同,可进行重写操作
/// </summary>
/// <param name="car"></param>
public virtual void LearnDrive(ICar car)
{
Console.WriteLine($"{this.Name}开始学车了");
car.Run();
}
}
public class Student : BaseStudent
{
public Student(string name) : base(name)
{
}
/// <summary>
/// 学员学习开车,只依赖了抽象(ICar接口)
/// </summary>
/// <param name="car"></param>
public override void LearnDrive(ICar car)
{
//加入自己的内容
Console.WriteLine($"{this.Name}有些紧张,调整了下情绪");
base.LearnDrive(car);
}
}
在我们的场景中,代码如下所示
class Program
{
static void Main(string[] args)
{
try
{
//张三开皇冠车都是依赖上层抽象
//不同的学员开不同的车,就很容易处理了...
BaseStudent student = new Student("张三");
ICar car = new Jetta();
student.LearnDrive(car);
//张三开皇冠车
ICar crown = new Crown();
student.LearnDrive(crown);
}
catch (Exception e)
{
Console.WriteLine(e.Message);
}
Console.Read();
}
}
运行结果,就不贴出来了。这里注意到了没有,是不是有些地方很熟悉,这不就是里氏替换原则吗?其实,它们之间是相辅相成的,里氏替换是基础,依赖倒置是方法和手段。刚开始了解设计模式时,我就被这两个原则之间整的有点迷惑了,在代码设计的过程中,它们基本上都是同时出现的。