再談物件導向設計原則: 單一職責原則,定義、解析與實踐

定義:


A class should have only one reason to change.
以一個類別來說,應該只有一個引起它變化的原因。
”等等,這是在說人話嗎?還是我理解能力不夠好?“
這是我第一次讀到 SRP 原則定義的反應,當時覺得 SOLID 每個原則都是文字天書。若你也有跟我一樣的反應,不要緊張,大家都經歷過這個過程。我將會在本文中以自己的體悟來講解觀念,實務經驗演示如何實踐 SOLID 原則。
單一職責原則是 SOLID 原則中看起來最容易明白,卻也是最容易讓人混淆的原則。因為很多人並不清楚 職責 是什麼,甚至誤以為一個類別只能做一件事。接下來的文章中會依序講解原則的目的;解決什麼問題;如何實踐。

目的:

提高程式碼的內聚性,讓程式碼更易於管理和重複使用。

解析:

什麼是內聚?

在英文辭典中 內聚(Cohesion) 的同義詞為一致性、凝聚、結合等等,描述相關的事務如何聯繫在一起。在軟體開發中,高質量程式碼通常是高內聚性的內聚 程式碼的特徵為:
每個程式碼片段都只關注一件事情
當每段程式碼只關注一件事情時,程式碼會更容易被理解和處理,且相較 低內聚 的程式碼來說更容易編寫。

什麼情況會造成低內聚?

當程式碼包含一個以上「互不相關」的邏輯或意圖時,程式碼的內聚性就會降低。
(一般而言,低內聚的程式碼代表高耦合^1)
程式碼的內聚性一但降低,閱讀與維護程式碼的難度就會提升。當你必須從一個大函式裡面修改其中一小段邏輯,若不先花時間讀懂函式中每段程式碼之間的關係就直接修改程式碼,很容易破壞函式原先可正常運作的程式碼。為了避免對程式碼造成破壞,開發人員開發過程總是變成:花費 80% 時間閱讀程式碼,真正編寫程式碼的時間卻只有 20%。對於維護一個系統來說,這種情況除了相當浪費成本以外,也相當折磨開發人員的心情,更糟的是,每次回來維護又要重新讀一次程式碼。

單一職責原則對專案的重要性?

單一職責原則乍看之下好像很簡單,但實踐過程其實困難重重。現實狀況常常是:專案起初幾個版本的程式碼意圖都相當簡單明瞭,但是當需求隨著時間增長再加上開發時程短促,讓開發人員不斷在原本的程式碼上堆疊新的程式碼。最後 舊程式碼與新程式碼糾纏在一起,使得程式碼的意圖和邊界漸漸變得模糊且互相耦合。若在意圖模糊的程式碼上繼續擴充或修改,則會使程式碼的意圖逐漸流失並且擴大影響範圍,最後變成 技術債 折磨維護專案的人員。
因此,單一職責原則指導開發人員在建立新功能時,不應該把意圖不同的程式碼擺放在一起。
讓每段程式碼的意圖保持清晰,確保程式碼的意圖不會隨著需求或時間增長而流逝。

為什麼意圖如此重要呢?

維護專案最怕的就是修改程式碼時 不知道當初的開發者為什麼要這樣設計程式。要是冒然修改程式碼,就容易使功能發生錯誤。這種不確定的感覺會變成開發人員心中的恐懼,對無知的恐懼常在開發人員的心中作祟:「如果程式碼能好好運作,就別碰了吧!」這也是為什麼專案中經常會存在一段醜陋的程式碼,卻沒人去整理的原因。意圖模糊的程式碼一但被留下來就會成為專案長久的痛處,只要新需求和這些程式碼相關,開發時程就會變得緩慢且難以估計。
圖一:一個函式包含多個意圖,並且難以辨認每段程式碼之間的意圖與界線。
若程式碼的意圖有被保留下來,這些程式碼就有機會被改善。保持程式碼意圖的方法就是盡量隔離意圖不同的程式碼,避免意圖不相同的程式碼耦合在一起,造成程式碼的意圖與界線都變得模糊。

隔離意圖也是解除耦合

單一職責原則並不只有保持程式碼意圖這項優點而已,因為隔離意圖的過程中,也會解除不經意耦合的程式碼。
開發的過程中常常 為了共用某些變數或邏輯,將意圖不相同的程式碼安排在一起;或單純只是因為處理的資料相同而被擺放在一起。雖然程式碼可以運作,卻也造成不同意圖的程式碼互相耦合。耦合的程式碼對維護專案來說是相當致命的。
意圖不相同的程式碼,通常也意味著修改的時機與頻率不相同
新舊程式碼因為共用變數或邏輯而被安排在一起,常常會因為需求異動,只需要調整 其中一小段程式碼。但是開發人員卻需要花費很多時間閱讀與 當前需求 不相關的程式碼,只怕程式碼的異動會造成其他程式碼無法正常運行。
(圖ㄧ)為例,不同意圖的程式碼共用一個 Foreach 迴圈,讓開發人員很難判斷修改任一變數後,會不會造成其他程式碼發生錯誤;如果將每個意圖隔離開來,每段程式碼只需要維護自己的小迴圈,即可減少開發人員閱讀程式碼的時間與發生錯誤的機率。

實踐

接下來的章節將進入實作練習「如何導入單一職責原則」的階段。練習過程中,會先建立一個功能,並且隨著新需求不斷加入新的程式碼。最後再藉由單一職責原則,隔離不同意圖的程式碼,使每段程式碼的意圖得以保持清晰、且不互相耦合。

如何隔離意圖?

隔離意圖前,須先學會找出可能發生「意圖糾纏」的地方

導入單一職責原則的過程中,較困難的部分是如何發現意圖不同的程式碼。可能有多個需求都是在處理同一種資料,開發人員也習慣性地將處理相同資料的程式碼擺放在一起。如此一來,不同意圖的程式碼就容易堆疊在一起。因此,除了把處理相同資料的程式碼擺放在一起外,還必須做到隔離不同意圖的程式碼。
接下來以一個簡單的範例來「意圖糾纏」的程式碼是如何產生的:

範例:學生列表

某系統最原始的版本中,有一個「學生列表」的功能,其需求為:顯示某班級的所有學生。
某班級的學生列表
其程式碼分為 StudentControllerStudentModel 兩部份。
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 邏輯,產生了不經意的耦合。
這樣的程式碼會有下列問題:
  1. 不易閱讀與維護:為了避免改壞其他意圖的程式碼,每次進來改程式碼都要先讀過所有與 當前需求 不相關的程式碼。
  2. 額外的工作:不同意圖的程式碼被耦合在一起,造成部分意圖被迫執行不同意圖的程式碼。除了讓功能變得不穩定以外,日積月累還有可能成為系統的效能瓶頸。
    • studentList() 範例中,部分意圖被迫執行其他意圖的 Join 邏輯。
  3. 修改不能局部化:每個意圖共用同一個函式,當某個意圖不小心寫入嚴重錯誤 Bug,會連同其他意圖的功能也跟著發生錯誤。
  4. 不同的變動率:每個意圖會以不同的時機與頻率修改程式碼,讓原本正常運作的功能變得不穩定,隨時會被改成壞掉的。
學生列表的範例相當簡單,看起來影響不大,很容易解決。但實際上 意圖交纏 的問題常常出現在系統各處,而且每個問題的耦合程度與複雜度都不相同。通常等你意識到程式碼很難修改時,耦合的問題也已經很嚴重了。
因此,每個開發人員都應該學會如何隔離意圖。

隔離意圖:功能插件化 的思維

解決意圖耦合最快的方式就是將 功能插件化,藉由 增加新的程式碼 來擴充系統的功能,而 不是藉由修改原本已經存在的程式碼 來擴充系統的功能,其原理為:
將「核心的邏輯」與「附加功能的邏輯」隔離開來,讓附加功能擴充核心功能的邏輯。
實務上可以從觀察程式碼中發現,會隨著需求增長的程式碼,通常是附加功能的邏輯不會隨著需求被改變的程式碼,通常是核心邏輯
這種開發思維對不熟悉物件導向的人來說應該覺得很奇怪,但是將 功能插件化 早在軟體開發領域隨處可見,應用層面從程式開發、框架、系統層級都有:
  • JavaScript 透過註冊 event 事件,擴充瀏覽器行為。
  • MVC 框架透過繼承 Controller 或 Model 擴充框架的行為,以便完成功能。
  • 瀏覽器透過安裝擴充套件,擴充瀏覽器行為。
  • 手機透過安裝 APP,擴充手機行為。
以「學生列表」功能為例,核心的邏輯為:撈取學生列表;附加功能的邏輯為:其他完成新需求的程式碼。
接下來將 導入介面(Interface) 讓「學生列表」功能插件化,隔離意圖不同的程式碼。
Note:
因本篇文章探討的是物件導向設計原則,故以介面(Interface)來實踐功能插件化,但並不表示功能插件化只可以透過介面或物件導向的方式實踐。

導入介面(Interface)

實踐 功能插件化一共有 4 步驟:
  1. 找出核心邏輯
  2. 開放擴充點,供核心邏輯隨時可以使用插件。
  3. 當有需求時,按照擴充點的定義,實作新的插件以便完成需求。
  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 新增私有屬性:$plugin
  • StudentModel 新增公開方法: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() 函式則專注於撈取學生列表。兜了這麼大一圈,這才是 單一職責原則 要我們做的事情:
  1. 隔離 核心邏輯附加功能邏輯
  2. 當使用 核心邏輯 的情境不同時,就應該隔離該使用情境的程式碼
  3. 每個類別最多只負責一個情境的程式碼,避免造成耦合,或意圖模糊
單一職責原則,其實是以更高一層的角度在看程式碼。寫程式碼的時候,應該時時刻刻注意當前的程式碼會不會跟 當前需求 不相關的程式碼寫在一起。若有的話表示 核心邏輯附加功能邏輯 可能已經混在一起了,這時就可以考慮導入單一職責原則,隔離 核心邏輯附加邏輯,並且確保每個類別只負責一個需求的程式碼,避免程式碼的耦合越來越深。

所以單一職責的職責到底是什麼?

很多人被單一職責原則的名字給混淆了,以為一個類別只可以做一件事情。但事實上「一次只做一件事」是函式層級的原則。
單一職責原則在類別層級中,用來劃分介面和型別的邊界^2,將不同意圖、不同使用情境、不同需求、不同修改時機的功能劃分為各自獨立的「職責」,最後由類別來實現這些被獨立的職責。因此當一個職責的需求異動時,也表示只有 負責實現該職責的類別 需要被異動(修改局部化)。
為了讓類別容易被維護,一個類別應該盡可能減少負責的職責,這就是單一職責原則想傳達的概念:
「A class should have only one reason to change.」
以一個類別來說,應該只有一個引起它變化的原因。
「only one reason to change」,其實就是在說一個類別應該只負責 一個意圖一個使用情境,也就是上述的「職責」。
這意味著系統功能會由許多小巧且高內聚的類別組成,且每個類別只專注於實現單一的職責。
單一職責做得很好時,每個類別都只有一個唯一的目的。因此需要進行功能修改的時候可以更容易地專注在一個或特定幾個類別。不但加快找查程式碼的速度,也讓系統的修改可以局部化,降低維護系統的困難度。
因此在一個高內聚性的系統中,程式碼可讀性及復用的可能性都會提高,儘管程式複雜,但容易被管理。

[^1]: 耦合:將許多功能封裝在同一個類別、介面、方法,但這些功能彼此的意圖卻不相同。
[^2]: 邊界:明確定義一個類別、函式要實作的功能目標與涉及範圍。

留言