更新於 2024/01/27閱讀時間約 7 分鐘

封裝與依賴隔離

物件導向設計的一個重點就是封裝,這有很多層面上的意義,但基本上就是控制物件的成員變數和方法的存取權。物件導向的封裝還跟繼承機制有關,這使得有一些時候我們逼不得已必須把函式定義在類別上,這種做法使得物件的功能變得難以拆解。封裝應該是模組的職責,並不需要再給物件相同的能力。


一般的模組系統就是把相關的程式碼蒐集起來放在一起,並根據粒度組織成階層結構,越內層的模組越細緻複雜且功能越多。一般會根據任務把相關的程式碼組成一個模組,例如連線相關的模組、數值計算相關的模組、資料庫管理相關的模組等,如此要找程式碼時就比較容易。更細緻的模組則會作為子模組放在相應的模組裡,例如連線相關的模組可能包含socket的模組、http protocol的模組等。這種透過階層結構管理程式碼的方式就是模組化設計。模組化設計透過組織相關聯的程式碼將知識連結起來,讓它們形成一個概括的概念,並透過模組的階層關係把更複雜的結構分解成較小的相對分離的部分,使得知識更容易學習與理解。物件導向把這種模組化的思維應用在物件上,與此物件所表達的概念相關的操作就被實作成它的方法。這也使得物件常常被用做功能導向的管理器,例如ConnectionManager。相對地,trait/typeclass只有在編譯機制上把函式與類型關聯起來,它們的實作仍可以擺在任何地方,唯有透過模組系統,才能有效的把相關的程式碼組織起來。


模組還有控制存取權的功能,也就是決定誰可以存取哪個函式或是類型,這讓它具有封裝的能力。透過存取權的控制,我們可以保證某些不安全的函式或成員變數只能被特定位置的函式使用,因此讓一些非法的情境實質上不可能發生。例如我們必須限制類別class Interval { float min; float max; }的成員的存取,因為它們必須符合min <= max的限制,應提供其他方法安全地存取它們。這種方式可以用來保證資料的完整性,它相較於利用代數資料類型更具彈性,事實上物件導向傾向使用這種方式而非代數資料類型,這可以稱作是對資料完整性的封裝。這種存取權的控制可以讓一些較複雜的操作不會暴露在外面,畢竟它們不能被存取,當我們在外面時就不需要理解它在幹嘛,因而做到對複雜度的封裝。它也能用來控制函式之間的依賴關係,讓內部的功能強大且情境複雜的函式不會被外部使用,從而降低誤用函式的風險,並減少修改此函式所需考慮的問題。而最外層的存取權一般用作函式庫的介面,它不能隨意更改,否則會產生未知的影響範圍。


模組控制存取權的方法是藉由定義一個包括函式、類型、介面等實體的範圍,並選擇要公開哪個實體,範圍外部的實體只能使用已公開的實體。這種控制存取權的功能與資訊安全無關,它是用來控制實體間的依賴關係,只要一個實體被公開,它就有可能被外部依賴,因此我們不能隨意修改這個實體的承諾。反之,透過限制可能的依賴關係與範圍,可以讓我們減低一些顧慮。同樣的概念也可以應用到函式裡,我們可以藉由限制變數的作用域,防止變數被其他地方的程式碼誤用,並讓我們可以專注在較小的範圍裡。例如當我們需要較複雜的方法準備一個值傳給函式時,與其寫成let a = …; let b = …; let param = prepare(a, b);,不如把a, b限制在範圍內:let param; { let a = …; let b = …; param = prepare(a, b); },如果是rust則可以寫成let param = { let a = …; let b = …; prepare(a, b) }以避免變數修改。我們也可以藉由直接抽取成函式做到一樣的結果,但它還需要將範圍內所使用的外部變數捕捉起來,並把需要輸出的變數回傳到範圍外。


有些人建議應該要把複雜函式的每個步驟都拆解成多個小函式以減少縮排,但這樣做會讓每個小函式都暴露在外面,如果它所在的模組或類型很大的話,就會造成一定程度的困擾。這這種基於實作且偏向特定情境的函式本來就應該限制在非常小的作用域裡,就像上面的準備參數值的例子一樣。我把這種存取範圍超出預期的狀況稱為「依賴洩漏」,定義函式時應該在意函式可以被誰存取,這會決定函式的重要性與使用情境,不合適的存取範圍會讓實體之間的依賴關係被污染。相反地,我們可以藉由「依賴隔離」解決這個問題。我們可以藉由模組的存取權控制把小函式限縮在一個範圍,並只開放組合起來的函式給外部使用。然而這只適用於像是rust的mod或是ts的namespace這類的小範圍模組系統,否則像是python這種依賴於檔案系統的模組系統,這種做法就必須把這個函式的定義獨立成一個檔案,這非常冗余。另外一種方法是藉由定義區域函式以限制它的作用域,也就是直接把小函式放在被分解的函式的定義範圍裡,這同樣可以達到隔離依賴範圍的目的。在Haskell這種特別依賴高階函式的程式語言中,很常會需要定義輔助函式作為參數,因此它特別提供where語句定義區域函式。更廣義的,在設計介面/特性時也應該要考慮方法之間的依賴關係,如果它包含太多方法,就會造成依賴此介面的實體依賴太多不必要的方法,因此應該盡量把方法分離成較小的介面,這稱做「介面隔離」。Iterator pattern或是pipeline pattern本質上也受惠於依賴隔離,它們透過把計算過程分解成各個簡單且獨立的部分,讓整體的邏輯變得容易理解。如果用迴圈寫一樣的邏輯,會發現當我們在閱讀時必須時刻記住這個變數是什麼意思,因為後面的程式碼有可能會用到它,而Iterator pattern把這類的依賴關係隔離開來,讓我們更能專注在每一步操作本身的意義。


控制存取權一般有兩種形式,一個是在定義函式、類型等實體時宣告是否公開,另一種則是統一在一個地方羅列需要公開的實體。像是物件導向的public/private、rust的pub、TypeScript的export就是第一種形式,而c++的標頭檔、Haskell的export list和ocaml的sig/mli則是第二種形式。第一種形式比較容易閱讀理解,而第二種讓我們必須把同樣的實體寫兩遍並放在不同地方,如果沒有language server輔助會很麻煩,而且會讓判斷實體的依賴範圍變得不直接。模組裡函式、類型的存取範圍控制應該寫在宣告附近,不同的範圍會影響它的意義與重要性,因而改變我們對這些實體的態度。


在物件導向裡,類別的封裝除了把存取控制邊界限制在物件本身,還包含一種特殊的控制方式。它可以宣告方法為protected,讓外部實體無法存取,但繼承此類別的子類別可以存取。這讓類別具有感染性,如果一個函式需要使用這個方法就只能加到這個類別上。這種特殊的控制權限非常微妙,一個方法是否該宣告成protected很難判斷,你怎麼知道只有它的子類會用到這個方法,就算是真的有必要這麼做嗎。如果你把一些關鍵方法宣告成protected,可能會造成未來在實作一些函式時也必須塞到這個類別裡面,因而讓這個類別過度膨脹,破壞單一職責的原則。事實上宣告成protected的目的常常是為了保證資料完整性,讓外部實體無法隨意修改資料或使用危險的方法,但又希望它能被子類使用或改寫。最好的方法應該是把它實作成函式,並在一個模組內就把問題處理掉並封裝起來。

分享至
成為作者繼續創作的動力吧!
© 2024 vocus All rights reserved.