更多优质内容
请关注公众号

面向对象和设计模式(六) 设计原则之单一职责原则 SRP 与 里氏替换原则 LSP-张柏沛IT博客

正文内容

面向对象和设计模式(六) 设计原则之单一职责原则 SRP 与 里氏替换原则 LSP

栏目:其他内容 系列:面向对象与设计模式 发布时间:2023-01-07 17:19 浏览量:1416
本系列文章目录
展开/收起

一、SOLID原则之单一职责原则 SRP(Single Responsibility Principle)

单一职责原则指一个类、模块或者函数只完成一个主要功能。

当然,这只是表面的概念和理解,大家应该都听过。更具体点,就是不要设计大而全的类,要设计粒度小、功能单一的类。如果类的职责不够单一,那么我们就需要将它拆成多个粒度更细,功能更单一的类。

因此,单一职责原则是一个教你什么时候该拆分模块或者类以及拆到什么程度才合适的方法,我个人认为这才是对该原则的本质理解。


下面我们将围绕单一职责原则在类这个层级上的应用,而模块层级的应用也可以依法炮制。

单一职责原则的核心在于如何判断类的职责是否足够单一,或者换个说法,如何判断类应该做拆分。


先给出结论:

1.是否需要拆分、拆分类的哪些部分为一个新类取决于类中的各个部分在业务中是否足够重;

2. 类中代码行数、函数或属性过多,影响到代码的可读性和可维护性,就需要考虑对类进行拆分(前提是这些拆出来的部分确实可以形成独立功能,不依赖原有类的其他逻辑)。

3. 某个类依赖的其他类过多,其他类依赖这个类过多,不符合高内聚、低耦合的设计思想,就需要考虑对类进行拆分(这样可以降低耦合的程度);

4.如果比较难给类起一个合适名字,很难用一个业务名词概括的类,说明这个类的职责不够明确,需要拆成功能更明确的多个类;

5.类中大量的方法都是集中操作类中的某几个属性,就可以考虑将这几个属性连带着操作这几个属性的方法一起拆出去。


拆分为粒度更细的类带来的直接好处是可读性和可扩展性的提升。不过以类拆的越细为横轴,对于扩展性而言是线性提升,对于可读性而言则是先升后降的抛物线。

拆分的依据可以是按业务拆分,将一个负责复杂业务的类拆成一个个更细粒度的类;也可以是按逻辑的处理流程来拆,将一个负责数据加载、op操作、数据格式化的类拆成各司其职的3个类;还有很多其他的拆分方式。而具体按什么拆,怎么拆就是之后我们学习设计模式要了解的内容。


拆分之后的类必定不是独立的,而是相互关联的,毕竟是从一个类上面拆下来的,如果没有任何关联那之前就不会放到同一个类中了。因此拆完之后,还需要用各种组合或依赖的方式将多个类组装起来,共同协作。实际上,重构就是一个拆分和组装类的过程。


下面我们以按业务进行拆分来说明。

如果类中大量的方法都是集中的对类中的某几个属性进行操作说明这几个属性相对应的业务对象的业务场景、业务需求足够重和复杂,应该将这些方法和属性拆出来,方便它日后的扩展。

同理如果一个类的方法和属性过多,只能说明是需求的增长导致方法和属性的增长,既然如此我们不如将这个包含多个业务场景需求的大类拆成职责尽可能独立的多个小类(相关性关联性强的方法和属性拆到同一个小类中)。但是拆完之后并不是说这些小类会绝对独立,不可避免的可能还是会有耦合关系。


下面作者给出了一个例子:

<?php
class UserInfo {
    private $userId;
    private $username;
    private $email;
    private $telephone;
    private $createTime;
    private $lastLoginTime;
    private $avatarUrl;
    private $provinceOfAddress; // 省
    private $cityOfAddress; // 市
    private $regionOfAddress; // 区 
    private $detailedAddress; // 详细地址
// ...省略其他属性和方法...
}


UserInfo 类是用来记录用户的信息的类。那么它是否符合单一职责原则,还要不要给它拆分?

从代码来看,UserInfo 类包含的都是跟用户相关的信息,所有的属性和方法都是和用户这个模型对象相关的,满足单一职责原则;

但是假如我们做的是一个类似于美团这样的外卖和出行服务的平台,地址信息需要一个专门页面来维护,而且还衍生出多地址,不同人不同地址(比如你要给女朋友点外卖,要记录女朋友的电话地址吧),定位服务也会用到地址。此时地址这个信息会作为一个业务中比较重(或者说比较重要)的部分,并且可能被其他模块的服务使用。

那么我们就应该将地址相关的信息和方法抽离出来做成一个 Address 类。


假如我们做的是一个知识付费的博客网站,那么地址信息只是做一个记录,与其他业务以及主业务没有啥关系,此时就没有必要拆出来,乖乖的呆在UserInfo中即可。

又假如我们的网站越做越好,开始推出App,小程序,或者其他子业务的新网站,为了用户一个账号可以在所有产品中登录,就需要继续对 UserInfo 拆分,将跟身份认证相关的信息(比如,email、telephone 等)抽取成独立的类。


所以,不同的应用场景、不同阶段的需求背景下,对同一个类的职责是否单一的判定,可能都是不一样的。


我认为的最佳实践原则应该是:业务发展之初,先写一个粗粒度的类满足业务需求,实现快速上线。随着业务的发展,如果粗粒度的类越来越庞大,功能越来越重,就再将这个粗粒度的类拆分成几个更细粒度的类。

此外在进行拆分的时候我们还可能遇到一个问题:假如有一个类A方法和属性过多,我们要将A类按业务拆成B、C和D 3个小类,每个类都拥有原本A大类中的部分属性。但是也有些属性和方法是 B、C和D类都需要用到的公共属性和方法。这种情况要怎么拆呢。

此时我们可以将A类拆成 B、C、D和A2类,A2类承载A类中最基本和公共的属性与方法,B、C和D类通过组合或依赖A2的方式得到原本A类中的那些公共属性和方法,要调用这些属性和方法时委托A2调用即可。这里我不推荐让B、C、D类继承A2类的方式来解决公共的属性与方法的问题,原因是 B~D 类是不一定与A2类是一种 is-a 关系,但可以肯定的是他们是一种 has-a 的关系,因此用组合+委托而非继承最为保险。这个例子也告诉我们,拆分不一定是水平拆分也肯能是垂直拆分,拆分后的类也不一定会绝对独立,也可能会相互依赖和耦合,相互关联和协作。


最后,类的粒度并非越细越好。

如果类拆分之后让你使用起来不方便就不建议再拆;或者类中有两个具有一致性关联的行为,也不要将两个行为拆到两个类,否则会出现调用了一个行为,但没调用另一个行为导致数据不一致。

总而言之,将类拆成什么样还是要取决于可扩展性、复用性、可读性、可维护性,以及代码高内聚、低耦合这几个终极目标,而非为了拆而拆或为了所谓的设计原则而拆。


二、SOLID原则之里氏替换原则 LSP(Liskov Substitution Principle)

里氏替换原则是指要做到子类对象的方法能够替换上层调用中任何父类对象方法出现的地方,并且保证原来程序的行为不被破坏,从而保证类继承和扩展后程序依旧稳定。


上述定义更具体来说是里氏替换原则的目的,而如果要做到上述的目的,我们需要做到以下几点:

1.子类必须实现父类的所有抽象方法,如果不能实现所有抽象方法,例如父类的有些方法子类不需要,此时应该调整这两个类的关系用组合或依赖来代替继承;

2.子类不能重写父类已经实现的方法(即非抽象方法),因为重写就意味着可能改变父类该方法的行为(例如用途、返回值等);当子类不得不重写父类方法时不能改变父类的行为;

3.当子类不得不重写父类方法时,子类方法的形参要比父类方法的形参宽松,这里说的宽松不是指参数数量的多少(因为数量只能一样),而是指子类方法参数中引入的对象类型得是父类方法参数引入的对象类型的同类或父类或抽象(抽象是指抽象类或接口)而不能是子类;

4.当子类不得不重写父类方法时,子类方法的返回值要比父类严格,指的是子类方法返回的对象类型得是父类方法返回的对象类型的子类;


里氏替换原则的作用在于两点:

1、对继承和多态进行约束,使得子类实现或重写父类方法后更加的安全可靠,或者说使得继承带来的复用性更加安全可靠。

2、指导类与类之间关系,如果两个类之间能做到上述4点(即满足里氏替换原则),则这两个类就能满足继承关系(就能放心的使用继承),否则就只能通过组合或依赖关系代替继承关系。具体说就是,A和B满足is-a关系(父子或兄弟关系),并且A类继承B类是为了能够调用B 的 x() 方法,但A类和B类不能够做到上述4点,就得通过组合的方式将B作为A的属性,委托B调用方法 x()。


下面我们再看看几个违背里氏替换原则原则的例子:

1、子类违背父类声明要实现的功能(重写改变父类行为)

例如:父类中提供的 sortOrdersByAmount() 订单排序函数,是按照金额从小到大来给订单排序的,而子类重写这个 sortOrdersByAmount() 订单排序函数之后,是按照创建日期来给订单排序的。那子类的设计就违背里式替换原则(违背了上述4点中的第2点)。

2、子类违背父类对输入、输出、异常的约定

父类的某个方法约定运行出错的时候返回 null;获取数据为空的时候返回空数组。而子类重载函数之后变为运行出错返回异常,获取不到数据返回 null。

父类的某个方法约定输入数据可以是任意整数,但子类实现的时候,只允许输入数据是正整数,负数就抛出,子类对输入的数据的校验比父类更加严格,那子类就违背了里式替换原则(违背第3点)。

3、子类违背父类注释中所罗列的任何特殊说明

父类中定义的 withdraw() 提现方法的注释写道:“用户的提现金额不得超过账户余额……”。而子类重写 withdraw() 函数之后,针对 VIP 账号实现了透支提现的功能,也就是提现金额可以大于账户余额,那子类的设计是不符合里式替换原则。


最后提一嘴,里氏替换原则其实是一个用的比较少的或者用起来不怎么意识得到的一个原则。里氏替换原则的本质是希望子类在各方面完美继承父类,而实际开发中,多多少少子类会做不到完全不破坏父类原有行为,其实只要有这个意识能够做到大致不破坏就好。




更多内容请关注微信公众号
zbpblog微信公众号

如果您需要转载,可以点击下方按钮可以进行复制粘贴;本站博客文章为原创,请转载时注明以下信息

张柏沛IT技术博客 > 面向对象和设计模式(六) 设计原则之单一职责原则 SRP 与 里氏替换原则 LSP

热门推荐
推荐新闻