面向对象设计原则
设计目标:
- 开闭原则
- 里氏替换原则
- 迪米特原则
设计方法:
- 单一职责原则
- 接口分隔原则
- 依赖倒置原则
- 组合/聚合复用原则
开闭原则(OCP)
对扩展开放、对修改关闭
即在设计一个软件时,在原有的模块不修改的基础上扩展其功能。
- 对扩展开放——代码的功能是可扩展的。
- 对修改关闭——软件系统功能上的稳定性,持续性要求模块是修改关闭的
常用方法:
- 对软件系统中不变的部分加以抽象
例如使用接口来抽象,新加的部分或者功能可以通过定义新接口来实现,或者来继承这个接口
这里不写示例,示例详见虚函数多态部分,那个其实完全符合这个原则。
里氏替换原则(LSP)
所有引用基类的地方必须能透明地使用其派生类的对象
子类可以扩展父类的功能,但不能改变父类原有的功能。
其实也很简单,可以说成功实现违反这个原则的也算高手了。
- 比如派生类类型使用if树来判断
- 如果使用基类的地方换成派生类导致程序失效,说明不符合原则
从这几点可以看出来什么呢,比如说多态或者说面向对象的作用就是提供单个接口来实现多个事项,这一点用ifelse树当然也能实现(但是你要是真用了我会生气的),所以优先先排除ifelse。
至于后面一点可能理解起来比较难,我们先看Barbara Liskov 和 Jeannette Wing自己怎么说的:
If S is a declared subtype of T, objects of type S should behave as objects of type T are expected to behave, if they are treated as objects of type T
如果S是T的子类型,对于S类型的任意对象,如果将他们看作是T类型的对象,则对象的行为也理应与期望的行为一致。
翻译过来就是上面说的那一项的意识。
什么叫替换?为什么必须行为一致?
不知道你心里有没有这种感觉,自从举了if树的例子之后会感觉面向对象似乎用处并不大,因为所有东西都可以被if树替换。
我们不妨先从现实例子来理解:
设立电脑类,为电脑类赋予“编程(编译并运行程序)”功能
设立程序员类,程序员类继承于电脑类,因为程序员当然也会“编程”,为程序员类添加电脑类没有的功能,比如“设计”、“聊天”、“点外卖”等等
设立“招聘”函数,需要招聘编程人才,于是将基类电脑类对象输入,招聘函数成功招聘了“电脑”为员工一号!将程序员类对象输入,招聘函数成功招聘了“程序员”为员工二号!
???
目前看来至少程序运行还算一切正常
那么设立一个新的函数,叫“联网”函数,目的是将输入的参数联网。
函数成功将电脑类对象连上网了,同样也成功将程序员对象连上网了。
越来越不对劲了。。。
因为公司不可能招聘电脑,人也不可能联网
一切的祸害根源,在于在现实世界中,其实“程序员类”将“电脑类”的功能重写了。
比如联网,程序员类将联网功能函数重写成“使用电子产品联网”而不是”把自己联网“
这样的例子还有很多,比如”鲸鱼“不是”鱼类“、”指针“不是”针类“。
而设计原则始终是由人来遵循的原则,是属于“设计技巧”,对于程序来说不需要遵循,也就是说不遵循里氏替换原则的程序运行起来即有可能出现错误,也有可能不出现错误——就好比上述的例子,公司成功招聘了电脑作为员工,但如果有女同事想让新员工电脑成为她的男友,那程序就可能崩溃了。
这一切都取决于原程序是怎样想的,至少里氏替换原则成功实现了这三点:
- 里氏替换原则是实现开闭原则的重要方式之一。
- 它克服了继承中重写父类造成的可复用性变差的缺点。
- 它是动作正确性的保证。即类的扩展不会给已有的系统引入新的错误,降低了代码出错的可能性。
实现方法就是规范继承关系:
所以说上述的例子应该改为“电脑继承于工具类”、“程序员继承于人类”看起来才会更好一些。
这样一来,招聘公司才会从“人类”中寻找“程序员”来招聘。
迪米特原则(LoD)
只和朋友聊天,别和陌生人聊天
朋友就是对象本身、对象引用的对象、对象创造的对象。也就是说出现在方法体内部的类,不属于朋友类。
目的是降低类之间的耦合。
“消息——订阅模式”是最能体现这个原则的。//这是以我目前肤浅的知识存储量得出的
这样一种方式就是违背LoD原则的一种方式:
class ObjectCentre{
public:
void traverse(){
list<Object> AllObject=new list<Object>;
for(int i=0;i<10;i++){
AllObject.add(new Object());
}
}
}
对象中心类中大量依赖了Object类,这样当然不会出现编译错误,但是造成的后果便是使得代码结构复杂,耦合性高——不用理会我写的示例代码有什么意义
class ObjectCentre{
private:
list<Object> AllObject;
public:
void traverse(list<Object> _AllObject){
this.AllObject=_AllObject;
}
}
这样一来,Object就于ObjectCentre成为了“朋友”
用一个更直观的例子来解释,就是说迪米特法则不提倡方法类之间的直接交流,更提倡方法类之间能存在一个中介类,例如银行卡和银行,最好能有一个”交易中介“来专门负责处理银行卡和银行之间的业务交互。
形如:
objectA.getObjectB().doSomething();
objectA.getObjectB().getObjectC().doSomething
假设这个类叫”ME“会发现这行代码代表的类中其实仅仅只有A的朋友关系。B只和A有朋友关系,所以”ME“直接通过朋友A中的方法与B进行交流了,就好比”ME“拿起朋友A的手机找B聊天,这样当然是不允许的。
解决方法就是把”doSomething“放到朋友A去做,也就是委托A去帮忙办事,这样朋友A就会正常找到B去办事了。
单一职责原则
永远不要让一个类存在多个改变的理由
指一个类/接口/方法有且只有一个职责
- 如果一个类的方法用了外部类库,但是用户没用上这个方法,则使用本库的用户这下也不得不包含这个未使用的外部类库
- 如果其中一个方法被更改,则另一个方法的用户也会被影响,违反开闭原则
降低了类的复杂度、提高类的可读性,提高系统的可维护性、降低变更引起的风险
接口分隔原则(ISP)
不能强迫用户去依赖那些他们不使用的接口
如果接口方法过多,那最好分成好几个接口
单一职责原则和接口分隔原则区别——前者强调的是单一职责,针对的是程序细节,后者强调约束接口,针对抽象、整体框架。
依赖倒置原则(DIP)
上层模块不应该依赖于底层模块,它们都应该依赖于抽象。
抽象不应该依赖于细节,细节应该依赖于抽象
上层模块一般指实现复杂逻辑的类,底层模块一般指实现一些基本的或者初级的操作。
在一般的程序设计中,高层模块总是依赖于底层模块。
这里的“逻辑复杂、基本操作”其实并不是硬性指代码复杂度,而是用于称呼我们经常需要变更的代码区域就可以叫做“初级操作”——毕竟大类建立的初衷就是搭建复杂工程。
比如我们要建立一个图书馆系统,就避免不了建立数据库,而“将图书数据存入数据库”这个操作则属于“基础操作”,因为我们有可能时不时需要变更需求,比如说图书数据今天只要存书名和编号,可能明天就要多存一个存入人姓名。但是不幸的是这个操作与图书馆整个系统是高度耦合的:
public class BookService
{
private Database database;
public BookService()
{
database = new Database();
}
public void AddBook(Book book)
{
// 调用底层模块的方法来将图书存储到数据库中
database.Save(book);
}
}
public class Database
{
public void Save(Book book)
{
// 将图书保存到数据库的具体实现
}
}
如果更改Save为(Book book,string name)那么整个图书馆系统里面的代码也需要做相应更改,这样一来是不是觉得还不如不用面向对象,反正改了代码两边都得变,相当于“儿子犯法全家坐牢”,这样子对于面向对象来说违背了开闭原则。
于是用依赖倒置原则来更改代码:
public interface IDatabase
{
void Save(Book book);
}
public class BookService
{
private IDatabase database;
public BookService(IDatabase database)
{
this.database = database;
}
public void AddBook(Book book)
{
// 调用抽象接口的方法来将图书存储
database.Save(book);
}
}
public class Database : IDatabase
{
public void Save(Book book)
{
// 将图书保存到数据库的具体实现
}
}
也可以说在原来的基础上增加了多态(原谅我前几天复习完虚函数),这样更改小模块就只需要增加模块或者继承原有的这个模块就行。
聚合复用原则
尽量使用组合/聚合,不要使用类继承。
聚合表示“拥有”,组合表示更强烈的“拥有”,就像一台电脑拥有CPU、内存、硬盘,没了这些电脑都不能运行,这就是组合;电脑可以连接外设键盘、鼠标,这就叫做聚合。
聚合复用就是指复用代码的时候尽量挑选聚合/组合方式,而不是直接通过类继承。
类继承之间的关系是“Is-A”,聚合之间的关系是“Has-A”
继承的缺点:
- 继承复用破坏封装,父类的一切实现都暴露给子类,叫做“白箱”复用
- 父类发生改变子类也不得不发生改变
- 继承是静态的,编译时进行,不灵活
一个很经典的例子:“一个人不能同时成为学生、雇员、经理”
这是不满足聚合复用原则的典型例子,因为人是后三者的父类,导致人只能成为三者之一。
应该设立一个抽象类来当雇员、经理、学生的父类,可以在”人“类中多次实例化这个抽象类来让人来胜任多种身份。
在其他人的博客上看过一个更能体现原理的例子,那就是汽车的颜色不同与车型号不同,这个时候不应该拿车颜色种类乘以车型号量来设置类数量,而是利用聚合复用原则来让汽车具有多种属性。在这个例子中,如果要新增汽车功能,那么类的增加将是一个很大的量级,而且所有类都要更改,如果使用聚合复用原则优化之后,只需要新建几个新的功能类即可。