物件导向设计原则:单一职责原则,定义、解析与实践

系列文章

浅谈物件导向 SOLID 原则对工程师的好处与如何影响能力再谈 SOLID 原则,Why SOLID?物件导向设计原则:单一职责原则,定义、解析与实践物件导向设计原则:开放封闭原则,定义、解析与实践

单一职责原则(Single responsibility principle)

定义:

A class should have only one reason to change.
以一个类别来说,应该只有一个引起它变化的原因。

”等等,这是在说人话吗?还是我理解能力不够好?“

这是我第一次读到 SRP 原则定义的反应,当时觉得 SOLID 每个原则都是文字天书。若你也有跟我一样的反应,不要紧张,大家都经历过这个过程。我将会在本文中以自己的体悟来讲解观念,实务经验演示如何实践 SOLID 原则。

单一职责原则是 SOLID 原则中看起来最容易明白,却也是最容易让人混淆的原则。因为很多人并不清楚 职责 是什么,甚至误以为一个类别只能做一件事。接下来的文章中会依序讲解原则的目的;解决什么问题;如何实践。

目的:

提高程式码的内聚性,让程式码更易于管理和重複使用。

解析:


什么是内聚?

在英文辞典中 内聚(Cohesion) 的同义词为一致性、凝聚、结合等等,描述相关的事务如何联繫在一起。在软体开发中,高质量程式码通常是高内聚性的,内聚 程式码的特徵为:

每个程式码片段都只关注一件事情

当每段程式码只关注一件事情时,程式码会更容易被理解和处理,且相较 低内聚 的程式码来说更容易编写。

什么情况会造成低内聚?

当程式码包含一个以上「互不相关」的逻辑或意图时,程式码的内聚性就会降低。
(一般而言,低内聚的程式码代表高耦合[^1])

程式码的内聚性一但降低,阅读与维护程式码的难度就会提升。当你必须从一个大函式里面修改其中一小段逻辑,若不先花时间读懂函式中每段程式码之间的关係就直接修改程式码,很容易破坏函式原先可正常运作的程式码。为了避免对程式码造成破坏,开发人员开发过程总是变成:花费 80% 时间阅读程式码,真正编写程式码的时间却只有 20%。对于维护一个系统来说,这种情况除了相当浪费成本以外,也相当折磨开发人员的心情,更糟的是,每次回来维护又要重新读一次程式码。

单一职责原则对专案的重要性?

单一职责原则乍看之下好像很简单,但实践过程其实困难重重。现实状况常常是:专案起初几个版本的程式码意图都相当简单明了,但是当需求随着时间增长再加上开发时程短促,让开发人员不断在原本的程式码上堆叠新的程式码。最后 旧程式码与新程式码纠缠在一起,使得程式码的意图和边界渐渐变得模糊且互相耦合。若在意图模糊的程式码上继续扩充或修改,则会使程式码的意图逐渐流失并且扩大影响範围,最后变成 技术债 折磨维护专案的人员。

因此,单一职责原则指导开发人员在建立新功能时,不应该把意图不同的程式码摆放在一起。

让每段程式码的意图保持清晰,确保程式码的意图不会随着需求或时间增长而流逝。

为什么意图如此重要呢?

维护专案最怕的就是修改程式码时 不知道当初的开发者为什么要这样设计程式。要是冒然修改程式码,就容易使功能发生错误。这种不确定的感觉会变成开发人员心中的恐惧,对无知的恐惧常在开发人员的心中作祟:「如果程式码能好好运作,就别碰了吧!」这也是为什么专案中经常会存在一段丑陋的程式码,却没人去整理的原因。意图模糊的程式码一但被留下来就会成为专案长久的痛处,只要新需求和这些程式码相关,开发时程就会变得缓慢且难以估计。

图一:一个函式包含多个意图,并且难以辨认每段程式码之间的意图与界线。

若程式码的意图有被保留下来,这些程式码就有机会被改善。保持程式码意图的方法就是尽量隔离意图不同的程式码,避免意图不相同的程式码耦合在一起,造成程式码的意图与界线都变得模糊。

隔离意图也是解除耦合

单一职责原则并不只有保持程式码意图这项优点而已,因为隔离意图的过程中,也会解除不经意耦合的程式码。
开发的过程中常常 **为了共用某些变数或逻辑,将意图不相同的程式码安排在一起;或单纯只是因为处理的资料相同而被摆放在一起。**虽然程式码可以运作,却也造成不同意图的程式码互相耦合。耦合的程式码对维护专案来说是相当致命的。

意图不相同的程式码,通常也意味着修改的时机与频率不相同。

新旧程式码因为共用变数或逻辑而被安排在一起,常常会因为需求异动,只需要调整 其中一小段程式码。但是开发人员却需要花费很多时间阅读与 当前需求 不相关的程式码,只怕程式码的异动会造成其他程式码无法正常运行。

以(图ㄧ)为例,不同意图的程式码共用一个 Foreach 迴圈,让开发人员很难判断修改任一变数后,会不会造成其他程式码发生错误;如果将每个意图隔离开来,每段程式码只需要维护自己的小迴圈,即可减少开发人员阅读程式码的时间与发生错误的机率。

实践


接下来的章节将进入实作练习「如何导入单一职责原则」的阶段。练习过程中,会先建立一个功能,并且随着新需求不断加入新的程式码。最后再藉由单一职责原则,隔离不同意图的程式码,使每段程式码的意图得以保持清晰、且不互相耦合。

如何隔离意图?

隔离意图前,须先学会找出可能发生「意图纠缠」的地方

导入单一职责原则的过程中,较困难的部分是如何发现意图不同的程式码。可能有多个需求都是在处理同一种资料,开发人员也习惯性地将处理相同资料的程式码摆放在一起。如此一来,不同意图的程式码就容易堆叠在一起。因此,除了把处理相同资料的程式码摆放在一起外,还必须做到隔离不同意图的程式码。

接下来以一个简单的範例来「意图纠缠」的程式码是如何产生的:

範例:学生列表

某系统最原始的版本中,有一个「学生列表」的功能,其需求为:显示某班级的所有学生。

某班级的学生列表

其程式码分为 StudentController StudentModel 两部份。
Controller 负责接收 HTTP 参数,并返回学生资料。Model 负责从资料库捞取学生资料。

class StudentController extends Controller{    /** var StudentModel **/    private $model;    public function studentList()    {        $classId = $this->input->get('classId');        return $this->model->studentList($classId);    }}class StudentModel extends Model{    private $db;        public function studentList($classId)    {        $this->db->select('*');        $this->db->from('students');        $this->db->where('students.classId', $classId);        return $this->db->get()->resultArray();    }}

新需求:已完成作业的学生列表

随着新需求新增,老师想要有一个「已完成作业的学生列表」的画面。
因此开发人员在 Controller 新增一个 studentListByHomeworkStatus() 函式,并且调整 StudentModel 的 studentList() 函式以便捞取对应的查询条件:

class StudentController extends Controller{    /** var StudentModel **/    private $model;    /** 显示某班级的所有学生 **/    public function studentList($classId) {/** ...省略 */}    /** 已完成作业的学生列表 **/    public function studentListByHomeworkStatus()    {        $classId = $this->input->get('classId');        $homeworkId = $this->input->get('homeworkId');        return $this->model->studentList($classId, $homeworkId);    }}class StudentModel extends Model{    private $db;    public function studentList($classId, $homeworkId = null)    {        $this->db->select('*');        $this->db->join('homeworks', 'students.id = homeworks.studentId');        $this->db->from('students');        if ($homeworkId != null) {            $this->where('homework.id', $homeworkId);            $this->where('homeworks.status', 'done');        }                $this->db->where('students.classId', $classId);        return $this->db->get()->resultArray();    }}

临时需求:尚未缴交 108 学年度脚踏车证费用的学生列表

突然有临时的需求,校务人员需要汇出「尚未缴交 108 学年度脚踏车证费用的学生列表」,于是开发人员又做了以下变动:

class StudentController extends Controller{    /** var StudentModel **/    private $model;    /** 显示某班级的所有学生 **/    public function studentList($classId) {/** ...省略 */}    /** 已完成作业的学生列表 **/    public function studentListByHomeworkStatus() {/** ...省略 */}       /** 尚未缴交 108 学年度脚踏车证费用的学生列表 **/    public function studentListThatNotPaidBicyclePassFee()    {        $classId = $this->input->get('classId');        $bicyclePassYear = 108;        return $this->model->studentList($classId, null, $bicyclePassYear);    }}class StudentModel extends Model{    private $db;    public function studentList($classId, $homeworkId = null, $bicyclePassYear = null)    {        $this->db->select('*');        $this->db->join('homeworks', 'students.id = homeworks.studentId');        $this->db->leftJoin('bicyclePass', 'students.id = bicyclePass.studentId');        $this->db->from('students');        if ($homeworkId != null) {            $this->where('homework.id', $homeworkId);            $this->where('homeworks.status', 'done');        }                if ($bicyclePassYear != null) {            $this->db->where('bicyclePass.year', $bicyclePassYear);            $this->db->where('bicyclePass.payStatus', false);        }                $this->db->where('students.classId', $classId);        return $this->db->get()->resultArray();    }}

新需求只会不断地出现

随着时间的推移,功能也会不断出现新需求,需求几乎是无限上纲的。例如:以性别捞取学生、以户籍地址捞取学生、捞取没缴午餐费的学生、捞取午餐吃素的学生、已经缴交学杂费捞取学生 ...等。

当这些需求都被写在同一个功能里面时,就能发现 studentList() 函式中充满意图不同的程式码:

图二:studentList 函式中充满意图不同的程式码

从(图二)可看到 studentList() 总共包含了 8 个意图的程式码,其中 6.捞取没缴午餐费的学生7.捞取午餐吃素的学生 还共用同一段 Join 逻辑,产生了不经意的耦合。

这样的程式码会有下列问题:

不易阅读与维护:为了避免改坏其他意图的程式码,每次进来改程式码都要先读过所有与 当前需求 不相关的程式码。额外的工作:不同意图的程式码被耦合在一起,造成部分意图被迫执行不同意图的程式码。除了让功能变得不稳定以外,日积月累还有可能成为系统的效能瓶颈。studentList() 範例中,部分意图被迫执行其他意图的 Join 逻辑。修改不能局部化:每个意图共用同一个函式,当某个意图不小心写入严重错误 Bug,会连同其他意图的功能也跟着发生错误。不同的变动率:每个意图会以不同的时机与频率修改程式码,让原本正常运作的功能变得不稳定,随时会被改成坏掉的。

学生列表的範例相当简单,看起来影响不大,很容易解决。但实际上 意图交缠 的问题常常出现在系统各处,而且每个问题的耦合程度与複杂度都不相同。通常等你意识到程式码很难修改时,耦合的问题也已经很严重了。

因此,每个开发人员都应该学会如何隔离意图。

隔离意图:功能插件化 的思维

解决意图耦合最快的方式就是将 功能插件化,藉由 增加新的程式码 来扩充系统的功能,而 不是藉由修改原本已经存在的程式码 来扩充系统的功能,其原理为:

将「核心的逻辑」与「附加功能的逻辑」隔离开来,让附加功能扩充核心功能的逻辑。

实务上可以从观察程式码中发现,会随着需求增长的程式码,通常是附加功能的逻辑;不会随着需求被改变的程式码,通常是核心逻辑。

这种开发思维对不熟悉物件导向的人来说应该觉得很奇怪,但是将 功能插件化 早在软体开发领域随处可见,应用层面从程式开发、框架、系统层级都有:

JavaScript 透过注册 event 事件,扩充浏览器行为。MVC 框架透过继承 Controller 或 Model 扩充框架的行为,以便完成功能。浏览器透过安装扩充套件,扩充浏览器行为。手机透过安装 APP,扩充手机行为。

以「学生列表」功能为例,核心的逻辑为:捞取学生列表;附加功能的逻辑为:其他完成新需求的程式码。

接下来将 导入介面(Interface) 让「学生列表」功能插件化,隔离意图不同的程式码。

Note:

因本篇文章探讨的是物件导向设计原则,故以介面(Interface)来实践功能插件化,但并不表示功能插件化只可以透过介面或物件导向的方式实践。

导入介面(Interface)

实践 功能插件化一共有 4 步骤:

找出核心逻辑开放扩充点,供核心逻辑随时可以使用插件。当有需求时,按照扩充点的定义,实作新的插件以便完成需求。将新的插件注入核心逻辑中。

1. 找出核心逻辑,并开放扩充介面

各种「学生列表」功能中,最常被执行的功能为 StudentModel->studentList(),因此我们可以断定核心逻辑应该在这个函式里面,并做了些调整:

// ConditionPlugin:扩充 DB 查询条件的介面interface ConditionPlugin {    public function setWhereCondition($db);}class StudentModel extends Model{    private $db;    /** @var ConditionPlugin */    private $plugin = null;    /** 开放从外面注入扩充逻辑 */    public function setConditionPlugin(ConditionPlugin $plugin) {        $this->plugin = $plugin;    }    public function studentList($classId)    {        $this->db->select('*');        $this->db->from('students');        $this->db->where('students.classId', $classId);        // 执行扩充逻辑        if ($this->plugin) {            $this->plugin->setWhereCondition($this->db);        }        return $this->db->get()->resultArray();    }}

这个步骤中,首先要找出核心逻辑。您可以发现 studentList() 只被保留了最核心的逻辑,也就是随着需求与时间不变的逻辑。其他的逻辑暂时被忽略了,它们都是附加功能的逻辑,等等会再提及。

找出最核心的逻辑后,下个步骤是开放扩充点。範例中我做了四件事,让 studentList() 开放了扩充点:

新增一个 ConditionPlugin 介面,这个介面接收一个 $db 参数,用来动态调用 $db 物件StudentModel 新增私有属性:$pluginStudentModel 新增公开方法:setConditionPlugin(),其参数型别为 ConditionPlugin 介面。供外部可以注入插件。StudentModel->studentList() 方法中,调用外部注入插件($this->plugin)的setWhereCondition() 方法来扩充核心逻辑的行为。

其中,ConditionPlugin 是插件需要实作的介面,实作的内容即为:扩充核心逻辑,以便完成需求。只要类别有实作 ConditionPlugin 介面,都可以透过 setConditionPlugin 函式将插件注入到 StudentModel 中。这样的作法是利用物件导向 多型 的特性,让程式码可以随着 $plugin 变数运作时的真实物件,会引发不同的动作,达到扩充核心逻辑的效果。

接下来我们将依照 ConditionPlugin 介面的定义,实作各种「学生列表」功能的插件:

2. 实作插件介面,并于注入插件

为了缩短範例的长度,此步骤只挑出「尚未缴交 108 学年度脚踏车证费用的学生列表」的需求来讲解,其余的需求则先带过:

// 1. 新增一个类别(插件)并实作 ConditionPlugin 介面:/** *「学生列表」插件:捞取指定学年度与符合付款状态的学生 */class StudentListPluginThatNotPaidBicyclePassFee implements ConditionPlugin{    private $bicyclePassYear;    private $payStatus;    public function __construct($bicyclePassYear, $payStatus)    {        $this->bicyclePassYear = $bicyclePassYear;        $this->payStatus = $payStatus;    }    // 2. 将原本放在 `StudentModel->studentList()` 的逻辑搬移至此    public function setWhereCondition($db)    {        $db->leftJoin('bicyclePass', 'students.id = bicyclePass.studentId');        $db->where('bicyclePass.year', $this->bicyclePassYear);        $db->where('bicyclePass.payStatus', $this->payStatus);    }}// 3. 修改 StudentController,从 Controller 配置 StudentModel 的扩充插件class StudentController extends Controller{    /** var \StudentModel **/    private $model;    /** 显示某班级的所有学生 **/    public function studentList($classId) { /** ...省略 */}    /** 已完成作业的学生列表 **/    public function studentListByHomeworkStatus(){ /** ...省略 */ }    /** 尚未缴交 108 学年度脚踏车证费用的学生列表 **/    public function studentListThatNotPaidBicyclePassFee()    {        $classId = $this->input->get('classId');        $bicyclePassYear = 108;        $payStatus = false;        $ConditionPlugin = new StudentListPluginThatNotPaidBicyclePassFee($bicyclePassYear, $payStatus);        $this->model->setConditionPlugin($ConditionPlugin);        return $this->model->studentList($classId);    }}

这个步骤中,我们建立了一个名称为 StudentListPluginThatNotPaidBicyclePassFee 的插件,这个插件里面的逻辑,其实就是把原本写在 StudentModel->studentList() 的逻辑搬移过来而已。其他的「学生列表」功能也要以此类推,把当初写在 StudentModel->studentList() 的逻辑搬移到自己的插件中。这么一来,就已经把核心逻辑与附加逻辑拆开了。

最后在每个需求的 Controller 层,透过 StudentModel 的公开方法setConditionPlugin() 将插件注入 StudentModel 里面。StudentModel 在捞取学生时就可以透过被注入的插件来扩充核心逻辑。

3. 每个插件都只负责一个职责

上一步骤中,将每个附加逻辑与核心逻辑隔离后,即可产生新的结构:
图三:每个插件都只负责一个职责

(图三) 中,每个插件都只负责执行一个需求的程式码;StudentModel->studentList() 函式则专注于捞取学生列表。兜了这么大一圈,这才是 单一职责原则 要我们做的事情:

隔离 核心逻辑 与 附加功能逻辑当使用 核心逻辑 的情境不同时,就应该隔离该使用情境的程式码每个类别最多只负责一个情境的程式码,避免造成耦合,或意图模糊

单一职责原则,其实是以更高一层的角度在看程式码。写程式码的时候,应该时时刻刻注意当前的程式码会不会跟 当前需求 不相关的程式码写在一起。若有的话表示 核心逻辑 和 附加功能逻辑 可能已经混在一起了。这时就可以考虑导入单一职责原则,隔离 核心逻辑 和 附加逻辑,并且确保每个类别只负责一个需求的程式码,避免程式码的耦合越来越深。

所以单一职责的「职责」到底是什么?

很多人被单一职责原则的名字给混淆了,以为一个类别只可以做一件事情。但事实上「一次只做一件事」是函式层级的原则。

单一职责原则在类别层级中,用来划分介面和型别的边界[^2],将不同意图、不同使用情境、不同需求、不同修改时机的功能划分为各自独立的「职责」,最后由类别来实现这些被独立的职责。因此当一个职责的需求异动时,也表示只有 负责实现该职责的类别 需要被异动(修改局部化)。

为了让类别容易被维护,一个类别应该尽可能减少负责的职责,这就是单一职责原则想传达的概念:

「A class should have only one reason to change.」
以一个类别来说,应该只有一个引起它变化的原因。

「only one reason to change」,其实就是在说一个类别应该只负责 一个意图 或 一个使用情境,也就是上述的「职责」。

这意味着系统功能会由许多小巧且高内聚的类别组成,且每个类别只专注于实现单一的职责。

单一职责做得很好时,每个类别都只有一个唯一的目的。因此需要进行功能修改的时候可以更容易地专注在一个或特定几个类别。不但加快找查程式码的速度,也让系统的修改可以局部化,降低维护系统的困难度。
因此在一个高内聚性的系统中,程式码可读性及复用的可能性都会提高,儘管程式複杂,但容易被管理。

注脚


耦合:将许多功能封装在同一个类别、介面、方法,但这些功能彼此的意图却不相同。边界:明确定义一个类别、函式要实作的功能目标与涉及範围。

关于作者: 网站小编

码农网专注IT技术教程资源分享平台,学习资源下载网站,58码农网包含计算机技术、网站程序源码下载、编程技术论坛、互联网资源下载等产品服务,提供原创、优质、完整内容的专业码农交流分享平台。

热门文章