六大设计原则(C#)
为什么要有设计原则,我觉得一张图片就可以解释这一切
一、单一职责原则(SRP)
对于一个类而言,应该只有一个发生变化的原因。(单一职责不仅仅是指类)
如果一个模块需要修改,它肯定是有原因的,除此原因之外,如果遇到了其他情况,还需要对此模块做出修改的话,那么就说这个模块就兼具多个职责。举个栗子:
此时我们有个动物类Animal,有个Move()会移动的方法
public class Animal { //动物移动的方法 public void Move(String name) { Console.WriteLine($"动物{name}跑"); } } class Program { static void Main(string[] args) { Animal a = new Animal(); a.Move("狗"); Console.ReadKey(); } }
此时如果传入一个鱼进去就不太合适了,因为鱼是不会跑只会游的
a.Move("鱼");
此时我们需要兼顾两个职责,第一个就是普通动物移动的方法,第二个就是鱼类的移动方法。我们修改一下,让这一切变得合理一些
第一种
public class Animal { //动物移动的方法 public void Move(String name) { if (name == "狗") { Console.WriteLine($"动物{name}跑"); } else if (name=="鱼"){ Console.WriteLine($"动物{name}游"); } } }
这种的话其实就是让Move方法兼具普通动物和鱼类移动两个职责(如果你的设计之初就是让Move满足所有动物的移动,此时Move方法还是兼具一个职责)
第二种
public class Animal { //普通动物移动的方法 public void RunMove(String name) { Console.WriteLine($"动物{name}跑"); } //鱼类移动的方法 public void FishMove(String name) { Console.WriteLine($"动物{name}游"); } }
此时RunMove和FishMove方法的职责是单一的,只管普通动物和鱼类的移动,但是Animal类确是兼具了普通动物和鱼类移动两个职责(如果你的设计之初就是让Animal类满足所有动物的移动,此时Animal还是兼具一个职责)
第三种
public class RunAnimal { //普通动物移动的方法 public void Move(String name) { Console.WriteLine($"动物{name}跑"); } } public class FishAnimal { //鱼类移动的方法 public void Move(String name) { Console.WriteLine($"动物{name}游"); } } class Program { static void Main(string[] args) { RunAnimal a = new RunAnimal(); a.Move("狗"); FishAnimal f = new FishAnimal(); f.Move("鱼"); Console.ReadKey(); } }
此时的话RunAnimal类、FishAnimal类和Move方法的职责都是单一的,只做一件事。就拿RunAnimal的Move方法来说,只有普通动物的移动需要做出改变了,才会对Move方法做出修改。
单一职责原则的优点就是高内聚,使得模块看起来有目的性,结构简单,修改当前模块对于其他模块的影响很低。缺点就是如果过度的单一,过度的细分,就会产生出很多模块,无形之中增加了系统的复杂程度。
二、开闭原则(OCP)
软件中的对象(类,模块,函数等等)应该对于扩展时开放的,但是对于修改是封闭的
一个模块写好了,但是如果还想要修改功能,不要对模块本身进行修改,可能会引起很大的连锁反应,破坏现有的程序,应该通过扩展来进行实现。通过扩展来实现的前提,就是一开始把模块抽象出来,而抽象出来的东西要能够预测到足够多的可能,因为一旦确定后,该抽象就不能在发生改变。举个栗子:
现在有个Dog类,Food食物类,还有个Feed类 ,根据传入食物喂养Dog类动物
public class Dog { public void eat(DogFood f) { Console.WriteLine("狗吃" + f.Value); } } public class Feed { //开始喂食 public void StartFeed(List<Dog> d, DogFood f) { foreach (Dog item in d) { item.eat(f); } } } public class DogFood { public String Value { get { return "狗粮"; } } }
如果有一天,我们引入了新的种类Tiger老虎类,和新的食物Meat肉类,此时要修改Feed喂食类了,这就违反了开闭原则,只做扩展不做修改,如果要让Feed类符合开闭原则,我们需要对Dog类和Food类做出一个抽象,抽象出Eat和Food抽象类或者接口,这里我就抽象出两个接口IEat和IFood:
//所有需要进食的动物都要实现此接口 public interface IEat { //此时食物也应该使用接口而不是具体类来接收 //否则只能接收单一的食物,增加食物的话还是需要修改 void eat(IFood food); } //所有食物需要实现此接口 public interface IFood { String Value { get; } }
此时,IEat和IFood是被固定死了,不做修改,这就需要设计之初能够预测到足够多的可能,如果需要添加新的功能(动物或食物),只需要实现对应的接口就行了。
修改原有Dog类和DogFood类实现对应的接口:
public class Dog:IEat { public void eat(IFood food) { Console.WriteLine("狗吃" + food.Value); } } public class DogFood:IFood { public String Value { get { return "狗粮"; } } }
修改Feed喂食类,使用接口来接收和使用,使其满足开闭原则:
public class Feed { //使用接口接收,后续可以传入实现该接口的子类,因为用到了协变,就需要使用IEnumerable来接受 public void StartFeed(IEnumerable<IEat> d, IFood f) { foreach (IEat item in d) { item.eat(f); } } }
这样的话,如果要添加新的功能,就不需要对Feed进行修改,而是添加新的类:
public class Tiger : IEat { public void eat(IFood food) { Console.WriteLine("老虎吃" + food.Value); } } public class Meat : IFood { public string Value { get { return "肉"; } } }
调用:
static void Main(string[] args) { //喂食 Feed f = new Feed(); //狗 List<Dog> dog = new List<Dog>(){ new Dog(), new Dog() }; //狗的食物 DogFood df = new DogFood(); //开始喂食 f.StartFeed(dog,df); //老虎 List<Tiger> t = new List<Tiger>() { new Tiger() }; //肉 Meat m = new Meat(); //开始喂食 f.StartFeed(t,m); Console.ReadKey(); }
遵循开闭原则的好处是扩展模块时,无需对已有的模块进行修改,只需要添加新的模块就行,避免了程序修改所造成的风险,抽象化就是开闭原则的关键。
三、依赖倒置原则(DIP)
依赖倒置原则主程序要依赖于抽象接口,不要依赖于具体实现。高层模块不应该依赖底层模块,两个都应该以来抽象。抽象不应该依赖细节,细节应该依赖抽象。
依赖:依赖其实就是耦合性,如果A类依赖B类,那么当B类消失或修改时,对A类会有很大的影响,可以说是A类的存在完全就是为B类服务,就是说A类依赖B类。
高层模块不应该依赖底层模块,两个都应该依赖抽象:在上一个例子中,作为高层模块Feed喂食类依赖底层模块Dog类和DogFood类(高层和底层就是调用时的关系,因为Feed调用Dog,所以Feed是高层模块),这样的话Feed喂食类只能给Dog类吃DogFood,如果引进了其他动物,Feed类此时是无法完成喂食工作的。后来对Feed类、Dog类和DogFood类做出了修改,让Dog类和DogFood类分别依赖IEat和IFood接口,使Feed类依赖于IEat和IFood接口,这样的话就使得高层模块(Feed)和底层模块(Dog、DogFood)都是依赖于接口。
抽象不应该依赖细节,细节应该依赖抽象:抽象就是抽象类或者接口,细节就是实现抽象类或接口的具体类,这句话的意思其实就是,抽象类或者接口不应该依赖具体的实现类,而应该让具体的实现类去依赖抽象类或者接口。
遵循依赖倒置原则的好处就是降低了模块和模块之间的耦合性,降低了修改模块后对程序所造成的风险。
四、里氏替换原则(LSP)
一个程序中如果使用的是一个父类,那么该程序一定适用于其子类,而且程序察觉不出父类和子类对象的区别。也就是说在程序中,把父类都替换成它的子类,程序的行为没有任何变化。
其实里氏替换原则是很容易理解的,如果想满足里氏替换原则,子类继承父类时,可以有自己的特点,可以重写父类的方法,但是父类中有的方法,子类必须是完全可以实现的。开闭原则中的例子就是符合里氏替换原则,而关于里氏替换原则的反例也有很多,例如:正方形不是长方形、玩具枪不能杀人、鸵鸟不会飞,这里就拿企鹅不会飞来举个反例:
Birds鸟类、Sparrow麻雀类,所有的鸟类都具有一个飞行时间
public abstract class Birds { //所有鸟类都应该具有飞行速度 public abstract double FlySpeed(); } //麻雀类 public class Sparrow : Birds { public override double FlySpeed() { return 10.5; } }
此时我们添加一个Penguin企鹅类,因为企鹅也是鸟,所以也应该继承自Birds鸟类
//企鹅 public class Penguin: Birds { //实现飞的方法 public override double FlySpeed() { return 0; } }
但是由于Penguin并不会飞,所以飞行速度为0,但是也实现了FlySpeed方法,编译也没有报错啊,但是如果此时有一个Fly方法需要根据鸟类的飞行速度来计算飞行300米所需要的时间
public static double Fly(Birds b) { return 300 / b.FlySpeed(); }
那么,将Penguin企鹅类放入时,则会报出异常,因为300/0是不符合逻辑的,也就不满足里氏替换原则,因为此时作为父类Birds,如果传入子类Penguin,程序就会出错。
不满足里氏替换原则的根本还是Penguin企鹅类并没有完全继承Birds鸟类,因为实现不了FlySpeed方法,所以此时解决方案有两种,
一种就是在Fly方法中进行判断:
public static double Fly(Birds b) { //如果传入的类型为鸵鸟,默认返回0 if (b.GetType().Equals(typeof(Penguin))) { return 0; } else { return 300 / b.FlySpeed(); } }
这样的话就会违反开闭原则,而且更改代码会非常麻烦,后续添加功能还需修改。
第二种就是Penguin企鹅类不继承Birds鸟类,因为此时企鹅类继承鸟类在此案例中就是强行继承,虽然现实世界中企鹅也是鸟,但是在编程世界中就行不通了。
总结下来实现里氏替换原则的根本就是,不要强行继承,如果继承就要完全实现。
五、接口隔离原则(ISP)
客户端不应该依赖它不需要的接口;一个类对另一个类的依赖应该建立在最小的接口上
满足接口隔离原则的前提就是,接口不要设计的太过庞大,什么叫庞大呢?比如一个动物接口就非常庞大,因为如果细分的话,就可以分很多种类的动物,此时动物接口就需要考虑率足够多的情况来保证动物接口后续不被修改,那么一开始设计时,就可以将动物接口根据具体的需求(例如动物是否会飞和游泳)细分为水里游的动物、天上飞的动物和地上跑的动物,如果还是过于庞大,就再细分。
就比如开闭原则中的IEat接口,就满足了接口隔离原则,该接口只负责吃的接口,所有需要吃的动物都可以实现该接口,而Feed喂食类依赖IEat接口,此时IEat接口也是最小接口。举个反例:
此时Feed喂食类不在依赖于IEat接口,而是依赖于IAnimal接口,所有动物(Dog、Tiger)都实现IAnimal接口
public interface IAnimal { //所有动物都会吃 void eat(IFood food); //所有动物都会呼吸 void breathe(); //所有动物都会移动 void move(); //........动物的功能肯定不止这么多 } public class Tiger : IAnimal { public void breathe() { Console.WriteLine("老虎会呼吸"); } public void eat(IFood food) { Console.WriteLine("老虎吃" + food.Value); } public void move() { Console.WriteLine("老虎会跑"); } } public class Dog : IAnimal { public void breathe() { Console.WriteLine("狗会呼吸"); } public void eat(IFood food) { Console.WriteLine("狗吃" + food.Value); } public void move() { Console.WriteLine("狗会跑"); } }
那么此时让Feed喂食类依赖IAnimal
public class Feed { //使用接口接收,后续可以传入实现该接口的子类 public void StartFeed(IEnumerable<IAnimal> d, IFood f) { foreach (IAnimal item in d) { item.eat(f); } } }
这样的话就违反了接口隔离原则,因为Feed喂食类只需要调用对象的eat方法,动物的其他的方法都是不调用的,但是却依赖了IAnimal接口,这样的话就显得很臃肿,而且如果以后不传入动物,该工厂也负责喂养机器人吃电池,是不是依赖IAnimal就不合适了(如果非要让机器人实现IAnimal接口是可行的,不过这太不合理了)。
但是Feed如果依赖IEat接口,那么只要能吃东西就可以实现IEat接口,只要能吃东西就可以传入Feed喂食类喂养,此时Feed类依赖的IEat接口为最小接口。当一个类对另一个类的依赖建立在最小接口上时,该类基本上负责调用此接口中的所有内容,不需要接口中有多余的方法。
六、迪米特法则(LoD)(最少知识原则(LKP))
一个对象应当对其他对象有尽可能少的了解,不要和陌生人说话。
首先来说一下什么叫“陌生人”,首先我们有个类A,A本身肯定是A的朋友,A的属性同样也是A的朋友,如果A的方法需要参数是B类型的,那么B也是A的朋友,还有A类中直接创造的对象也是A类的朋友,其他类对于A类来说就是“陌生人”。如果想要满足迪米特法则,就要尽可能少的写public方法和变量,不需要让别的对象知道的方法或者字段就不要公开。
其实迪米特法则的目的也是为了减少模块间的依赖,降低模块间的耦合性,这样才能提高代码的复用率。举个栗子:
动物园中有很多动物,而管理员需要每天记录动物的数量
动物和动物园
//动物 public class Animal { } //动物园 public class Zoo { public List<Animal> animals = new List<Animal>(); }
管理员
//管理员 public class ZooMan { //根据动物园检查动物的数量 public void CheckCount(Zoo z) { //获取所有的动物 List<Animal> animals = z.animals; //获取所有动物的数量 int count = animals.Count; //输出 Console.WriteLine(count); } }
ZooMan管理员与Animal动物类并没有直接的朋友关系,但是却发生了依赖关系,这样的设计显然违反了迪米特法则。我们应该对此程序进行修改,让动物园不对外开放animals属性,将计算动物所有数量的方法交由Zoo动物园来完成,对外提供GetAnimalCount方法获取数量,使得ZooMan管理员与Animal不产生依赖关系,修改如下:
//动物园 public class Zoo { //私有所有的动物,只有当前动物园可以访问 private List<Animal> animals = new List<Animal>(); //但是对外提供获取所有动物数量的方法 public int GetAnimalCount() { return animals.Count; } }
管理员获取数量只需要 int count = z.GetAnimalCount();就可以做到了。
迪米特法则的虽然可以直接避免ZooMan管理员与Animal动物类产生依赖,但是却需要Zoo动物园对外提供一个GetAnimalCount方法,如果盲目的追求迪米特法则时,就会产生很对类似于GetAnimalCount这样的“中间”方法或模块,来传递间接的调用,有可能造成模块间通讯效率降低,不容易协调。
其实这么多原则,遵循与不遵循对于实现特定的功能没有丝毫影响,但程序不可能是一成不变的,重要的是后续需要修改或者添加新的模块时,你的程序能否做到“拥抱变化” ?
如果有错误或者疑问欢迎留言