[5分钟]菜鸟修研之设计模式:六大设计原则
[TOC]
笔者作为一个菜鸟,会尝试以简单的代码和容易理解的语句去解释这几种原则的特性和应用场景。
这六种原则分别为单一职责原则、接口隔离原则、里氏替换原则、迪米特法则、依赖倒置原则、开闭原则。
单一职责原则
单一职责原则(SRP:Single responsibility principle),规定一个类中应该只有一个原因引起类的变化。
单一职责原则的核心就是解耦和增强内聚性。
问题:
// 假设此类是数据库上下文
public class DatabaseContext { }
public class Test
{
private readonly DatabaseContext _context;
public Test(DatabaseContext context)
{
_context = context;
}
// 用户登录
public void UserLogin() { }
// 用户注销
public void UserLogout() { }
// 新增一个用户
public void AddUser() { }
// 修改一个用户的信息
public void UpdateUser() { }
// 删除一个用户
public void DeleteUser() { }
}
Test 负责 职责 P1(用户登录和退出)和 P2(用户账号管理) 两个职责,当由于职责 P1 的需求发生变化而需要修改类时, 有可能会导致正常职责 P2 的功能发生故障。
上面的代码中,两个职责被耦合起来,担任了多种功能。
一个类中应该只有一个原因引起类的变化,也就要求一个类只应该负责一个功能,类中地代码是紧密联系的。
上面的示例代码非常简单,我们可以很自然地将一个个类分为两个部分。
// 假设此类是数据库上下文
public class DatabaseContext { }
public class Test1
{
private readonly DatabaseContext _context;
public Test1(DatabaseContext context)
{
_context = context;
}
// 用户登录
public void UserLogin() { }
// 用户注销
public void UserLogout() { }
}
public class Test2
{
private readonly DatabaseContext _context;
public Test2(DatabaseContext context)
{
_context = context;
}
// 新增一个用户
public void AddUser() { }
// 修改一个用户的信息
public void UpdateUser() { }
// 删除一个用户
public void DeleteUser() { }
}
因此,单一职责原则的解决方法,是将不同职责封装到不同的类或模块中。
接口隔离原则
接口隔离原则(ISP:Interface Segregation Principle) 要求对接口进行细分,类的继承建立在最小的粒度上,确保客户端继承的接口中,每一个方法都是它需要的。
笔者查阅了国外一些资料,大多将接口隔离原则定义为:
“Clients should not be forced to depend upon interfaces that they do not use.”
意思是不应强迫客户依赖于它不使用的方法。
对于此原则的解释,这篇文章讲的非常透彻:
https://stackify.com/interface-segregation-principle/
这就要求我们拆分臃肿的接口成为更小的和更具体的接口,使得接口负责的功能更加单一。
目的:通过将软件分为多个独立的部分来减少所需更改的副作用和频率。
笔者想到从两方面论述:
其一,在描述多种动物时,我们可能会将不同种类的动物分类。但是这还不够,例如在鸟类中,我们印象中鸟的特征是鸟会飞,但是企鹅不会飞~。
那么还要对物种的特征进行细分,例如血液是什么颜色的、有没有脊椎等。
其二,我们可以通过下面代码表达:
// 与登录有关
public interface IUserLogin
{
// 登录
void Login();
// 注销
void Logout();
}
// 与用户账号有关
public interface IUserInfo
{
// 新增一个用户
void AddUser();
// 修改一个用户的信息
void UpdateUser();
// 删除一个用户
void DeleteUser();
}
上面的两个接口,各种实现不同的功能,彼此没有交叉,完美。
接下来我们看看两个继承了 IUserLogin 接口的代码
// 对用户登录注销进行管理,资源准备和释放
public class Test1 : IUserLogin
{
public void Login(){}
public void Logout(){}
}
public class Test2 : IUserLogin
{
public void Login()
{
// 获取用户未读消息
}
public void Logout()
{
}
}
对于 Test1 ,根据登录和注销两个状态,进行不同操作。
但是,对于 Test2,它只需要登录这个状态,其它情况不关它事。那么 Logout()
对他来说,完全没有用,这就是接口污染。
上面的代码就违法了接口隔离原则。
但是,接口隔离原则有个缺点,就是容易过多地将细分接口。一个项目中,出现成千上万个接口,将是维护地灾难。
因此接口隔离原则要灵活使用,就 Test2 来说,多继承一个方法无伤大碍,不用就是了。ASP.NET Core 中就存在很多这样的实现。
public void Function()
{
throw new NotImplementedException();
}
示例地址:https://github.com/dotnet/aspnetcore/search?q=throw+new+NotImplementedException%28%29%3B&unscoped_q=throw+new+NotImplementedException%28%29%3B
《设计模式之禅》第四章中,作者对接口隔离原则总结了四个要求:
接口尽量小:不出现臃肿(Fat)的接口。
接口要高内聚:提高接口、类、模块的处理能力。
定制服务:小粒度的接口可以组成大接口,灵活定制新的功能。
接口的设计有限度:难以有固定的标准去衡量接口的粒度是否合理。
另外还有关于单一职责原则和接口隔离原则的关系和对比。
单一职责原则是从服务提供者的角度去看,提供一个高内聚的、单一职责的功能;
接口隔离原则是从使用者角度去看,也是实现高内聚和低耦合。
接口隔离原则的粒度可能更小,通过多个接口灵活组成一个符合单一职责原则的类。
我们也看到了,单一职责原则更多是围绕类来讨论;接口隔离原则是对接口来讨论,即对抽象进行讨论。
开闭原则
开闭原则(Open/Closed Principle)规定 :
“软件中的对象(类,模块,函数等等)应该对于扩展是开放的,但是对于修改是封闭的”
--《Object-Oriented Software Construction》作者 Bertrand Meyer
开闭原则意味着一个实体是允许在不改变它的源代码的前提下变更它的行为。类的改动是通过增加代码实现,而不是修改源代码。
开闭原则 有 梅耶开闭原则、多态开闭原则。
-
梅耶开闭原则
代码一旦完成,一个类的实现只应该因错误而修改,新的或者改变的特性应该通过新建不同的类实现。
特点:继承,子类继承父类,拥有其所有的方法,并且拓展。
-
多态开闭原则
此原则使用接口而不是父类来允许不同的实现,您可以在不更改它们的代码的情况下轻松替换它们。
现在大多数情况下,开闭原则指的是多态开闭原则。
多态开闭原则笔者在查阅资料是,发现这个接口指的不是 Interface
,指的是抽象方法、虚方法。
问:面向对象的三大特性是什么?答:封装、继承、多态。
对,多态开闭原则就是指这个多态。不过,原则要求不应对方法进行重载(重写)、隐藏。
这是一个示例:
// 实现登录注销
public class UserLogin
{
public void Login() { }
public void Logout() { }
public virtual void A() {/* 做了一些事*/}
public virtual void B() {/* 也做了一些事*/ }
}
public class UserLogin1 : UserLogin
{
public void Login(string userName) { } // 应不应该对父类的方法进行重载?
public override void A() { } // √
public override void B() { } // √
public new void Logout() { } // 也许行?
}
多态开闭原则的好处是,引入了抽象,使得两个类松耦合,而且可以使得在不修改代码的前提下,使用子类替换父类(里氏替换原则)。
有时,会看到这样的题目:接口和抽象类的区别?
笔者隐约记得有过一条这样的解释:接口是为了实现共同的标准;抽象是为了代码的复用。
当然,接口和抽象,都可以实现里氏替换。
通过开闭原则,我们可以了解到多态,也了解接口和抽象的应用场景。
还有一个问题是,开闭原则要求是要修改或添加功能时,通过子类来实现,而不是修改原有代码。那么是否可以和应该对父类的代码进行重载和隐藏?
而开闭原则的核心是构造抽象,从而通过子类派生来实现拓展。貌似没有说到这方面。
笔者觉得不太应该。。。
先结合下面的里氏替换原则,我们再讨论这个问题?
里氏替换原则
里氏替换原则(LSP:Liskov Substitution Principle)要求:凡是父类出现的地方,子类都可以出现。
这就要求了子类必须与父类具有相同的行为。只有当子类能够替换任何父类的实例时,才会符合里氏替换原则。
里氏替换原则的约束:
- 子类必须实现父类的抽象方法,但不能重写父类中已实现的方法。
- 子类中可以增加方法拓展功能。
- 当子类覆盖或实现(虚拟方法/抽象方法)父类的方法时,方法的输入参数限制更加宽松并且返回值要比父类方法更加严格。
所以,我们看到开闭原则中的示例,子类应不应该重载父类的方法?应不应该使用 new 关键字隐藏父类的方法?为了确保子类继承后,还具有跟父类一致的特性,不建议这样做呢,亲。
实现了开闭原则,自然可以使用里氏替换原则。
依赖倒置原则
依赖倒置原则(Dependence Inversion Principle)要求程序要依赖于抽象接口,不要依赖于具体实现。
我们可以从代码中,慢慢演进和推导理论。
// 实现登录注销
public class UserLogin
{
public void Login(){}
public void Logout(){}
}
public class Test1 : UserLogin { }
public class Test2
{
private readonly UserLogin userLogin = new UserLogin();
}
public class Test3
{
private readonly UserLogin _userLogin;
public Test3(UserLogin userLogin)
{
_userLogin = userLogin;
}
}
上面代码中,Test1、Test2、Test3 都依赖 UserLogin 。先不说上面代码有什么毛病,根据依赖倒置原则,应该是这样编写代码的
// 与登录有关
public interface IUserLogin
{
void Login(); // 登录
void Logout(); // 注销
}
// 实现登录注销
public class UserLogin1 : IUserLogin
{
public void Login(){}
public void Logout(){}
}
// 实现登录注销
public class UserLogin2 : IUserLogin
{
public void Login(){}
public void Logout(){}
}
public class Test4
{
private readonly IUserLogin _userLogin;
public Test4(IUserLogin userLogin)
{
_userLogin = userLogin;
}
}
依赖倒置原则,在于引入一种抽象,这种抽象将高级模块和底层模块彼此分离。高层模块和底层模块松耦合,底层模块的变动不需要高层模块也要变动。
依赖导致原则有两个思想:
- 高层模块不应该依赖于底层模块,两者都应该依赖于抽象。
- 抽象不应该依赖细节,细节应该依赖于抽象。
因为依赖于抽象,底层模块可以任意替换一个实现了抽象的模块。
里氏替换原则是要求子类父类行为一致,子类可以替换父类。
依赖倒置原则,每个方法的行为是可以完全不一样的。
迪米特法则
迪米特法则(Law of Demeter)要求两个类之间尽可能保持最小的联系。
例如 对象A 不应该直接调用 对象B,而是应该通过 中间对象C 来保持通讯。
请参考 https://en.wikipedia.org/wiki/Law_of_Demeter
优势:松耦合,较少了依赖。
缺点:要编写许多包装代码,增加复杂读,模块之间的通讯效率变低。
笔者找了很多资料,发现都是 java 的。。。
最终发现有个是以 C# 讲述的文章 https://www.cnblogs.com/zh7791/p/7922960.html
一般来说,较少会提到迪米特原则,代码符合依赖倒置原则和里氏替换原则等,也就算是符合迪米特法则了。
文章评论