在讲述具体的设计模式之前,有必要讲解一下类的设计原则。类有六大设计原则,如下图所示:
2.1 单一职责
很多没有经验的开发人员喜欢将不相干的属性和行为塞到一个类中,导致这个类急剧膨胀,成为了一个万能类。问他们这样设计的原因:两个字,简单。又或者项目时间紧,无法细分,等项目完成后,没有时间重构,留给后人一堆坑。第一种情况是开发人员能力欠缺,根本就没有这种思维。第二种情况受项目所限,但以我参与的众多项目来看,这些都是借口,是缺乏责任心的表现。如何才能脱颖而出,靠的就是能力+责任心,两者缺一不可。
啰嗦了这么多,言归正传。该如何解决超类(超累?)问题了?我们的小伙伴“单一职责”该粉墨登场了。
2.1.1 定义
定义:一个类只负责一个功能领域中的职责,或者说,一个类只有一个引起它变化的原因。
这里提到了只负责一个功能领域,那如果多个功能领域了?很简单嘛,分为多个类,每个类只负责一个功能领域。事实真的就是这样简单吗?呵呵,不一定,这需要有丰富经验的设计人员才能准确把握功能领域。
2.1.2 案例分析
考虑这样一种场景:用户登录。
菜鸟给出的方案大概是这样的:
功能都能正常运行,逻辑也没有问题,但问题出在哪儿呢?要想回答这样一个问题,可以试着考虑以下几个方面:
- 这个类涉及到了几个职责?
- 如果新增一种场景:用户注册,该如何设计?
- 引起这个类变化的原因有哪些?
如果能成功回答出以上问题,那问题的答案自然迎刃而解。
给出的一个参考答案为:
-
这个类涉及了3个职责:登录(业务层)、判断用户是否存在(数据访问层)、数据库连接(数据库操作)
-
用户注册,则在原来的类中添加register、addUser、add方法
-
引起这个类变化的原因有3处:业务变化(如增加新的业务)、数据访问层变化(如增加新的数据访问层方法)、数据库操作变化。
由此可见,这种设计方案导致一个类职责过多,职责之间的耦合度太高,不便于代码复用。为此,将职责分离,每个职责由一个类封装,重构后的方案采用3个类。
userManager:业务类
userService:数据访问类
DbHelper:数据库操作类
2.1.3 结论
单一职责原则告诉我们:一个类不能太“累”!在软件系统中,一个类(大到模块,小到方法)承担的职责越多,它被复用的可能性就越小,而且一个类承担的职责过多,就相当于将这些职责耦合在一起,当其中一个职责变化时,可能会影响其他职责的运作,因此要将这些职责进行分离,将不同的职责封装在不同的类中,即将不同的变化原因封装在不同的类中,如果多个职责总是同时发生改变则可将它们封装在同一类中。
单一职责原则是实现高内聚、低耦合的指导方针,它是最简单但又最难运用的原则,需要设计人员发现类的不同职责并将其分离,而发现类的多重职责需要设计人员具有较强的分析设计能力和相关实践经验。
2.2 开闭原则
2.2.1 定义
定义:一个软件实体应该对扩展开放、对修改关闭。即软件实体尽量在不修改原有代码的基础上进行扩展。
在实际的项目开发中,唯一不变的东西就是“需求在变”。因此,在设计一个类或者一个子系统时,既要考虑相对稳定的部分,又要考虑到变的部分。相对稳定的部分可以抽象出来,形成抽象类;而变的部分可以以子类化的形式去抵御。这样,当需求改变时,只要派生出子类即可,不用改变原有的架构,从而满足开闭原则。而我见过的很多项目中,都是修改具体的实现,缝缝补补。最可怕的是有些代码耦合度太高,很小的一个需求,要修改N处代码,而且还影响到了其它业务,给开发人员和测试人员带来了极大的困扰。
2.2.2 案例分析
考虑这样一种场景:实现一个绘图模块,具有画线和画矩形功能。
菜鸟给出的方案大概是这样的:
咦,很简单嘛,这有啥问题?如果,现在增加了一个需求,需要这个绘图模块具有画圆功能呢,如果画直线中添加了一个参数颜色呢?菜鸟说:这还不简单,重载画直线函数,再增加一个画圆功能函数不就可以了。如果又增加一个需求:每个画图函数中增加日志功能呢?
可见,菜鸟的设计方案永远都是缝缝补补。这会带来哪些危害呢?
-
编译时间增长,因为所有引用的源文件都用重新编译。
-
测试复杂度增加,每改一点,都要将相关的业务都要重新测一遍。
-
代码耦合度增加,不便于复用。
既然有这么多问题,有什么解决方案呢?嗯,改开闭原则出场了。重构后的方案如下图:
还记得前面说的吗?提取出不变的部分和可变部分。不变的部分就是形状,不管是直线、矩形还是圆,都可以抽象成形状。可变的部分就是形状有多种,每个形状的绘制方式和需要的参数是不一样的。现在来看看:如果增加一个绘制椭圆的需求,很简单,只要从Shape中派生一个椭圆类即可,CDrawEngine和Shape都不需改变。记住:是派生子类,没有修改原来结构的任何代码。
那我们再看看新增的这样一个需求,要求每个绘图函数都要有日志功能呢?有3种方案:
-
从每个绘图类派生带日志功能的类
这种方案确实满足了开闭原则,但带来了一个问题:类的数目极具膨胀。试想想,每增加一个特性,就派生出N个类。
-
在绘图类中重载带日志功能的绘图函数
这种方案不满足开闭原则,但相对于直接修改绘图函数,还是比较有灵活性的。比如有两个业务要调用同一个绘图函数,一个要求带日志,一个不要带日志。
-
直接修改原来的绘图函数
这种方案不满足开闭原则,仅仅适合于需求稳定的场景。如原有的项目工程量很大,用上面两种方案都会导致很多上层代码需要修改。
2.2.3 结论
任何软件都需要面临一个很重要的问题,即它们的需求会随时间的推移而发生变化。当软件系统需要面对新的需求时,我们应该尽量保证系统的设计框架是稳定的。如果一个软件设计符合开闭原则,那么可以非常方便地对系统进行扩展,而且在扩展时无须修改现有代码,使得软件系统在拥有适应性和灵活性的同时具备较好的稳定性和延续性。随着软件规模越来越大,软件寿命越来越长,软件维护成本越来越高,设计满足开闭原则的软件系统也变得越来越重要。
为了满足开闭原则,为系统定义一个相对稳定的抽象层,而将不同的实现行为移至具体的实现层中完成。如果需要修改系统的行为,无须对抽象层进行任何改动,只需要增加新的具体类来实现新的业务功能即可,实现在不修改已有代码的基础上扩展系统的功能,达到开闭原则的要求。
特别提醒:开闭原则需要尽量遵守,但也要看项目的实际情况,具体问题具体分析。
2.3 里氏替换原则
2.3.1 定义
定义:所有引用基类(父类)对象的地方必须能无副作用地被其子类对象替换。
举个例子:基类BaseClass,其子类为SubClass,方法method(BaseClass),则可以将SubClass的对象sub传入method,并且不会出错或者任何副作用。方法method2(SubClass),则不能将BaseClass的对象base传入method2。举一个我们生活的例子:我喜欢明星,因此我喜欢李连杰;反过来,我喜欢李连杰,并不代表我喜欢明星,尽管杜XX也是明星。
2.3.2 案例分析
考虑这样一种场景: 张三开车。
菜鸟给出的方案可能是这样的:
有什么问题呢?在回答这个问题之前,思考这样一个问题:如果又增加一种车呢?在菜鸟给出的方案中,我们只能在Driver类中再增加一个方法,这违背了我们前面讲的开闭原则。里氏替换原则隐含地告诉我们:要面向抽象编程。下面给出一个重构后的方案:
里氏替换原则可不仅仅表达上面那一点意思,还要继续深入讨论:
考虑这样一种场景:某个工厂能生产宝马车、大众车、奥迪车。
菜鸟给出的解决方案可能是这样的:
重构后的方案是这样的:
由此可以推导这样一个结论:在面向对象编程中,成员变量、方法参数或者返回值尽量是抽象类形式。
再次继续深入,下面讨论的是里氏替换原则最核心的内容啦(汗)
所有引用基类(父类)对象的地方必须能无副作用地被其子类对象替换。
考虑这样一段代码
class BaseClass{ public: int add(int a, int b){return a+b;}} class SubClass: public BaseClass{ public: int add(int a, int b){return a-b;} void print(){};} void method(const BaseClass& base){ base.add(1,2);} BaseClassbase;SubClasssub;method(base);method(sub);
可以看出:子类SubClass覆盖了父类BaseClass中的add方法,并且改变了add方法的原意,导致了业务出错。这段代码明显违背了里氏替换原则,给系统造成了潜在的风险。
2.3.3 结论
子类的所有方法必须在父类中声明,或子类必须实现父类中声明的所有方法。根据里氏代换原则,为了保证系统的扩展性,在程序中通常使用父类来进行定义,如果一个方法只存在子类中,在父类中不提供相应的声明,则无法在以父类定义的对象中使用该方法。
我们在运用里氏代换原则时,尽量把父类设计为抽象类或者接口,让子类继承父类或实现父接口,并实现在父类中声明的方法,运行时,子类实例替换父类实例,我们可以很方便地扩展系统的功能,同时无须修改原有子类的代码,增加新的功能可以通过增加一个新的子类来实现。里氏代换原则是开闭原则的具体实现手段之一。
里氏替换原则通俗的来讲就是:子类可以扩展父类的功能,但不能改变父类原有的功能。它包含以下4层含义:
- 子类可以实现父类的抽象方法,但不能覆盖父类的非抽象方法。
- 子类中可以增加自己特有的方法。
- 当子类的方法重载父类的方法时,方法的前置条件(即方法的形参)要比父类方法的输入参数更宽松。
- 当子类的方法实现父类的抽象方法时,方法的后置条件(即方法的返回值)要比父类更严格。
网友评论