前言
设计模式是什么?
设计模式是一套理论,由软件界的先辈们总结出的一套可以反复使用的经验,设计模式可以提高代码的可重用性,增强系统的可维护性,以及解决一系列的复杂问题,比如在面临不可控的需求变化时进行指导
设计模式不是工具,他是软件开发的哲学,它能指导你如何去设计一个优秀的架构,编写一段健壮的代码,解决一个复杂的需求
本书想告诉你的是,技术也可以很有乐趣,也可以让你不用皱着眉头思考,等待你的只是静静地看,慢慢地思考,本书的内容会润物细无声地融入你的思维中。
单一职责原则
单一职责原则的定义是 There should never be more than one reason for a class to change,简单来说就是应该有且只有一个原因引起类的变更,英文名称为Single Responsibility Principle,简称 SRP
在基于RBAC模型(Role-Based Access Control,基于角色的访问控制,通过分配和取消角色来完成用户权限的授予和取消,使动作主体(用户)与资源的行为(权限)分离)的模块,设计一个简单的用户信息类:IUserInfo,原先将setUserID,getUserID,mapUser,addOrg等都聚合在IUserInfo类内,但是用户的属性和用户的行为没有分开,设计的冗余,之后把用户的信息抽取成一个BO(Bussiness Object,业务对象),把行为抽取成一个Biz(Business Logic,业务逻辑)对象;IUserBO的职责就是收集和反馈用户的属性信息;IUserBiz负责用户的行为,完成用户信息的维护和变更;以上把一个接口拆分成两个接口的动作,就是依赖了单一职责原则
单一职责的好处:降低一个类的复杂性,实现什么职责都有清晰明确的定义;提高一个类的可读性,降低类的复杂性,从而提高可维护性;降低变更的风险,如果接口的单一职责做得好,一个接口修改只对相应的实现类有影响,对其他的接口无影响,这对系统的扩展性,维护性都有非常大的帮助
单一职责原则最难划分的就是职责。一个职责一个接口,但问题是“职责”没有一个量化的标准,一个类到底要负责那些职责?这些职责该怎么细化?
在我看来单一职责要求的是接口,职责与职责之间需要配合,而不是一个任务需要两个接口同时处理,应就是避免并行职责,要求串行职责处理
注意 单一职责原则提出了一个编写程序的标准,用“职责”或“变化原因”来衡量接口或类设计得是否优良,但是“职责”和“变化原因”都是不可度量的,因项目而异,因环境而异。
生搬硬套单一职责原则会引起类的剧增,给维护带来非常多的麻烦,而且过分细分类的职责也会人为地增加系统的复杂性。本来一个类可以实现的行为硬要拆成两个类,然后再使用聚合或组合的方式耦合在一起,人为制造了系统的复杂性。所以原则是死的,人是活的,这句话很有道理。
接口一定要做到单一职责,类的设计尽量做到只有一个原因引起变化
里氏替换原则
继承能够实现代码共享,如每个子类都拥有父类的方法和属性;提高代码的可扩展性,通过实现父类的接口就行了
但是继承也有缺点,因为子类必须继承父类所有的属性和方法,还增加了耦合性,当父类的常量,变量和方法被修改时,需要考虑子类的修改,而且在缺乏规范的环境下,这种修改可能带来大段的代码需要重构
里氏替换原则(Liskov Substitution Principle,LSP)就是来降低继承带来的负面作用的经验。
里氏替换原则要求,子类对象必须能够替换所有父类对象而不会导致程序错误。
程序中使用父类对象的地方,应该能够透明地使用子类对象,而不会改变程序的正确性。
为了满足里氏替换原则,子类在继承父类时需要遵循一下几个要点:
1.方法签名相同或更宽松:子类的方法应该保持与父类相同的签名,或在某些情况下允许更宽泛的参数和返回类型。
2.行为一致性:子类方法应该遵循父类方法的预期行为,不应改变方法的基本功能。例如,定义了一个抽象类:枪,枪的基本方法应该时射击,但是我用玩具木枪来继承该抽象类,但是木枪一般情况下不会射击,并且也不能强行要求父类来判断是否为玩具,所以面临这种情况,只能把玩具木枪的父类更改了,否则违背LSP
3.不抛出新的异常:子类不应该抛出父类方法未声明的异常类型,这可能迫使调用调用者处理未预期的情况
4.保持不变性和约束:子类不应通过增加额外的前置条件来限制父类的行为,也不应放宽继承自父类的后置条件,比如子类对于父类的重载,参数由long long 变为int 或者int 变成 long long 这样都是会变化业务逻辑的
依赖倒置原则
High level modules should not depend upon low level modules.Both should depend upon abstractions.Abstractions should not depend upon details.Details should depend upon abstractions
1.高层模块:主要负责复杂逻辑,业务规则的部分。例如,业务逻辑层。
2.底层模块:执行具体任务,细节实现的部分。例如,数据访问层,工具类
3.抽象:接口或抽象类,定义高层和底层模块之间的契约
通过引入抽象层,高层模块和底层模块都依赖于抽象,而不是直接依赖彼此
依赖倒置原则强调
高层模块不应该依赖于底层模块,两者都应该依赖于抽象;
抽象不应该依赖于细节,细节应该依赖于抽象
1.依赖倒置原则要求程序的架构应该基于抽象(如接口或抽象类)而不是具体的实现类。
2.高层模块和底层模块都应该依赖于抽象,细节也应该依赖于抽象
依赖倒置原则的好处
1.降低耦合度:高层模块无需了解低层模块的具体实现,降低了模块之间的耦合
2.增加灵活性与可扩展性:可以轻松替换低层模块的实现,而不影响高层模块
3.提高可测试性:通过依赖抽象,可以方便地使用模拟(Mock)对象替代具体实现,简化单元测试
4.促进代码复用:抽象接口可以被多个模块重用,减少代码冗余
5.提升系统的可维护性:变更某个模块的实现不会影响其他模块,只需关注抽象接口
一个不使用DIP的简单demo:
假设有一个FileLogger类用于日志记录,一个UserService类需要使用日志功能
class FileLogger{
public:
void log(const std::string& message){
std::cout << message << std::endl;
}
}
class UserService{
private:
FileLogger logger;
public:
void createUser(const std::string& username)
{
logger.log("User" +username + "created");
}
}
在这个类中,高层模块UserService直接依赖于低层模块FileLogger的具体实现。所以如果未来需要更改日志记录的方式(如记录到数据库或远程服务器),需要修改UserService的代码,违背了开闭原则
难以进行单元测试:无法轻松替换FileLogger为模拟对象,倒置UserService的测试收到限制
通过引入抽象层(接口或纯虚类),使高层模块依赖于抽象,而不是具体实现
1.创建抽象接口:定义高层模块需要的功能,而不涉及具体的实现
2.让低层模块实现接口:具体实现抽象接口
3.高层模块依赖接口:通过依赖注入(构造函数注入、Setter 注入或接口注入)将具体实现注入到高层模块中
class ILogger{
public:
virtual void log(const std::string& message) = 0;
virtual ~ILogger() =default;
};
// 低层模块 - 文件日志实现
class FileLogger : public ILogger{
public:
void log(const std::string& message) override{
}
};
// 低层模块 - 控制台日志实现
class ConsoleLogger : public ILogger {
public:
void log(const std::string& message) override {
std::cout << "Logging to console: " << message << std::endl;
}
};
// 高层模块
class UserService{
private:
std::shared_ptr<ILogger> logger;
public:
UserService(std::shared_ptr<ILogger> logger_) : logger(std::move(logger_)){}
void createUser(const std::string& username) {
// 创建用户逻辑
std::cout << "User " << username << " created." << std::endl;
logger->log("User " + username + " created.");
}
}
int main() {
// 可以选择不同的日志实现
std::shared_ptr<ILogger> fileLogger = std::make_shared<FileLogger>();
// std::shared_ptr<ILogger> consoleLogger = std::make_shared<ConsoleLogger>();
UserService userService(fileLogger);
userService.createUser("Alice");
return 0;
}
依赖倒置原则的本质就是通过抽象(接口或抽象类)使各个类或模块的实现彼此独立,不互相影响,实现模块间的松耦合。
我们怎么在项目中使用这个规则呢?只要遵循以下的几 个规则就可以: ● 每个类尽量都有接口或抽象类,或者抽象类和接口两者都具备 这是依赖倒置的基本要求,接口和抽象类都是属于抽象的,有了抽象才可能依赖倒置。 ● 变量的表面类型尽量是接口或者是抽象类 很多书上说变量的类型一定要是接口或者是抽象类,这个有点绝对化了,比如一个工具 类,xxxUtils一般是不需要接口或是抽象类的。还有,如果你要使用类的clone方法,就必须 使用实现类,这个是JDK提供的一个规范。 ● 任何类都不应该从具体类派生 如果一个项目处于开发状态,确实不应该有从具体类派生出子类的情况,但这也不是绝 对的,因为人都是会犯错误的,有时设计缺陷是在所难免的,因此只要不超过两层的继承都 是可以忍受的。特别是负责项目维护的同志,基本上可以不考虑这个规则,为什么?维护工 作基本上都是进行扩展开发,修复行为,通过一个继承关系,覆写一个方法就可以修正一个 很大的Bug,何必去继承最高的基类呢?(当然这种情况尽量发生在不甚了解父类或者无法 获得父类代码的情况下。) ● 尽量不要覆写基类的方法 如果基类是一个抽象类,而且这个方法已经实现了,子类尽量不要覆写。类间依赖的是 抽象,覆写了抽象方法,对依赖的稳定性会产生一定的影响。
讲了这么多,估计大家对“倒置”这个词还是有点不理解,那到底什么是“倒置”呢?我们 先说“正置”是什么意思,依赖正置就是类间的依赖是实实在在的实现类间的依赖,也就是面 向实现编程,这也是正常人的思维方式,我要开奔驰车就依赖奔驰车,我要使用笔记本电脑 就直接依赖笔记本电脑,而编写程序需要的是对现实世界的事物进行抽象,抽象的结果就是 有了抽象类和接口,然后我们根据系统设计的需要产生了抽象间的依赖,代替了人们传统思 维中的事物间的依赖,“倒置”就是从这里产生的
接口隔离原则
接口(interface),是定义了一组方法的集合,用于描述类的行为契约,在C++中,接口通常通过纯虚类来实现
客户端(Client),是使用接口的类或模块
胖接口(Fat Interface),是包含了过多不相关方法的接口,导致客户端被迫依赖它们不需要的方法
专一接口(Specific Interface),拆分后的更小,更专一的接口,每个接口只包含特定的行为
接口隔离原则要求,客户端不应该依赖它不需要的接口,类间的依赖关系应该建立在最小的接口上。所以接口隔离原则的核心目标是避免“胖接口”,即不包含过多不相干或不必要的接口。通过将一个胖接口拆分为多个更为专一的接口,可以减少类之间的不必要依赖,提高系统的灵活性和可维护性。
接口隔离原则的两个主要方面:
1.细化接口:将大而全的接口拆分为多个小而专一的接口。
2.客户端依赖专一接口:确保客户端类只依赖于它们实际需要的接口,而非整个胖接口。
接口隔离的前提是保证接口的纯洁性
接口隔离要求接口要尽量小,但是小不能违反单一职责原则。比如手机可以简单由通话管理和通信传输,但是通话管理是否需要根据使用的是4G还是5G再次细分?是不需要的,因为通话的管理就是一个职责,再次细分只会增加冗余
因此,接口需要提高高内聚,也就是提高接口,类,模块的处理能力,减少对外的交互,比如让手机建立连接,手机类就没必要关心是如何建立连接的,而是关系有没有建立连接,那么高内聚就要求连接管理者内部把所有一切都解决了然后给出结果
接口的设计粒度越小,系统越灵活,这是不争的事实。但是,灵活的同时也带来了结构 的复杂化,开发难度增加,可维护性降低,这不是一个项目或产品所期望看到的,所以接口 设计一定要注意适度,这个“度”如何来判断呢?
● 一个接口只服务于一个子模块或业务逻辑;
● 通过业务逻辑压缩接口中的public方法,接口时常去回顾,尽量让接口达到“满身筋骨 肉”,而不是“肥嘟嘟”的一大堆方法;
● 已经被污染了的接口,尽量去修改,若变更的风险较大,则采用适配器模式进行转化 处理;
● 了解环境,拒绝盲从。每个项目或产品都有特定的环境因素,别看到大师是这样做的 你就照抄。千万别,环境不同,接口拆分的标准就不同。深入了解业务逻辑,最好的接口设 计就出自你的手中!
迪米特法则
迪米特法则(Law of Demeter,LoD),也称最少知识原则(Least Knowledge Principle,LKP)。
迪米特法则:一个对象应该对其他对象由最少的了解,通俗的讲,一个类应该对自己需要耦合或调用的类知道的最少,被耦合的类内部有多复杂都和我没关系,我只需要你提供的这么多public方法,我就调用这么多,其他的我一概不关心
每个对象都必然会与其他对象有耦合关系,两个对象之间的耦合就称为朋友关系,这种关系的类型有很多,例如组合,聚合,依赖等。
迪米特法则有一个英文解释是:Only talk to your immediate friends(只与直接的朋友通信)。
朋友类的定义是这样的:出现在成员变量、方法的输入输出参数中的类称为成员朋友类,而出现在方法体内 部的类不属于朋友类
体育委员按照老师的要求对女生进行了清点,并得出了数量。我们回过头来思考一下这 个程序有什么问题,首先确定Teacher类有几个朋友类,它仅有一个朋友类—— GroupLeader。为什么Girl不是朋友类呢?Teacher也对它产生了依赖关系呀!朋友类的定义是 这样的:出现在成员变量、方法的输入输出参数中的类称为成员朋友类,而出现在方法体内 部的类不属于朋友类,而Girl这个类就是出现在commond方法体内,因此不属于Teacher类的 朋友类。迪米特法则告诉我们一个类只和朋友类交流,但是我们刚刚定义的commond方法却 与Girl类有了交流,声明了一个List\<Girls>动态数组,也就是与一个陌生的类Girl有了交流, 这样就破坏了Teacher的健壮性。方法是类的一个行为,类竟然不知道自己的行为与其他类 产生依赖关系,这是不允许的,严重违反了迪米特法则
迪米特法则就是对这个距离进行描述,即使是朋友类之间 也不能无话不说,无所不知。两者的朋友关系太亲密了,耦合关系变得异常牢固。将三个步骤的访问权限修改为private,同时把InstallSoftware中的方法installWizad移动到 Wizard方法中。通过这样的重构后,Wizard类就只对外公布了一个public方法,即使要修改 first方法的返回值,影响的也仅仅只是Wizard本身,其他类不受影响,这显示了类的高内聚 特性
一个类公开的public属性或方法越多,修改时涉及的面也就越大,变更引起的风险扩散也就越大。因此,为了保持朋友类间的距离,在设计时需要反复衡量:是否还可以再减少public方法和属性,是否可以修改为private、package-private(包类型,在类、方法、变量前不加访问权限,则默认为包类型)、protected等访问权限,是否可以加上final关键字等
迪米特法则的核心观念就是类间解耦,弱耦合。只有弱耦合了以后,类的复用率才可以提高,但是其要求的结果可能会产生大量的中转或跳转类,导致系统的复杂性提高,同时也为维护带来了困难。
开闭原则
开闭原则定义:一个软件实体如类、模块和函数应该对扩展开放,对修改关闭。(Software entities like classes,modules and functions should be open for extension but closed for modifications)
开闭原则要求一个软件实体应该通过拓展来实现变化,而不是通过修改已有的代码来实现变化。
一个软件产品只要在生命期内,都会发生变化,既然变化是一个既定的事实,我们就应该在设计时尽量适应这些变化,以提高项目的稳定性和灵活性,真正实现拥抱变化。开闭原则告诉我们应尽量通过扩展软件的行为来实现变化,而不是通过修改已有的代码来完成变化。
开闭原则对扩展开放,对修改关闭,并不意味着不做任何修改,低层模块的变更,必然要有高层模块进行耦合,否则就是一个孤立无意义的代码片段。
变化有三种类型:
1.逻辑变化。
只变化一个逻辑,而不涉及其他模块,比如原有的一个算法是a*b+c,现在需要修改为 a*b*c,可以通过修改原有类中的方法的方式来完成,前提条件是所有依赖或关联类都按照 相同的逻辑处理。
2.子模块变化。
一个模块变化,会对其他的模块产生影响,特别是一个低层次的模块变化必然引起高层模块的变化,因此在通过扩展完成变化时,高层次的模块修改是必然的
3.可见视图变化。
可见视图是提供给客户使用的界面,如JSP程序、Swing界面等,该部分的变化一般会引 起连锁反应。如果仅仅是界 面上按钮、文字的重新排布倒是简单,最司空见惯的是业务耦合变化,什么意思呢?一个展 示数据的列表,按照原有的需求是6列,突然有一天要增加1列,而且这一列要跨N张表,处 理M个逻辑才能展现出来,这样的变化是比较恐怖的,但还是可以通过扩展来完成变化,这 就要看我们原有的设计是否灵活
开闭原则是最基础的一个原则,前五章节介绍的原则都是开闭原则的具体形态, 也就是说前五个原则就是指导设计的工具和方法,而开闭原则才是其精神领袖
1.开闭原则对测试的影响
2.开闭原则可以提高复用性
3.开闭原则可以提高可维护性
如何使用开闭原则
1.抽象约束。
抽象是对一组方法和属性的通用描述,没有具体的实现,比如纯虚类。抽象有非常多的可能性,因此,通过通过约束一组可能变化的行为的同时能够实现对扩展开放,其包含三层含义,第一,通过接口或抽象类约束扩展,不允许出现接口或抽象类中不存在的public方法;第二,参数类型,引用对象尽量使用接口或抽象类而不是实现类,第三,抽象层尽量保持稳定,一旦确定就不允许修改
2.元数据(metadata)控制模块行为
元数据就是用来描述环境和数据的数据,也就是配置参数。
3.制定项目章程
章程指定了团队所有成员都必须遵守的约定,对项目来说,约定优于配置
4.封装变化
对变化的封装包含两层含义:第一,将相同的变化封装到一个接口或抽象类中;第二, 将不同的变化封装到不同的接口或抽象类中,不应该有两个不同的变化出现在同一个接口或 抽象类中。封装变化,也就是受保护的变化(protected variations),找出预计有变化或不稳 定的点,我们为这些变化点创建稳定的接口,准确地讲是封装可能发生的变化,一旦预测到 或“第六感”发觉有变化,就可以进行封装,23个设计模式都是从各个不同的角度对变化进行 封装的,我们会在各个模式中逐步讲解