面向對象設計討論:有狀態類還是無狀態類?這是個難題
譯文相信大家都清楚何謂面向對象編程。不過有時候我們還需要花點時間決定為特定類賦予怎樣的屬性。很明顯,如果類屬性分配有誤,那么我們很可能遇到嚴重的后續問題。在這里我們將共同探討哪些類應該具備狀態,而哪些類應為無狀態。
對象的狀態意味著什么
在我們討論有狀態類與無狀態類之前,首先應該對對象的狀態擁有深入理解。正如字典中所言,狀態是指“某人或某物在特定時間點下所處之特定狀況。”
當我們著眼于編程并考量對象在特定時間點下的狀態時,相關范疇就縮小到了給定時間中對象的屬性或者成員變量值。那么對象的屬性由誰決定?答案是類。誰又來決定類中的屬性與成員?答案是編寫該類的程序員。誰又是程序員?就是各位正在閱讀本篇文章的朋友們。那么我們是否真的精于決斷每個類各自需要怎樣的屬性?
答案恐怕是否定的。至少我見過的不少印度程序員就僅僅為了薪酬而加入編程行業,他們明顯缺少做出正確屬性選擇的能力。首先,這類知識沒辦法從學校里直接學到。具體來講,我們需要投入大量時間來積累經驗,并借此摸索出正確選擇——這更像是一種藝術而非技術。工程技術往往擁有嚴格的規則,但藝術卻沒有。即使是經歷了十五年的編程從業時光,我在考慮某個類需要怎樣的屬性甚至如何為該類選擇名稱時,仍然需要費一番心思。
那么我們能否通過規則限定屬性的具體需求?換言之,對象狀態當中應當包含哪些屬性?或者說,對象是否應當永遠優先選擇無狀態?下面一起來看。
實體類/業務對象
編程領域充斥著大量諸如實體類乃至業務對象等的名稱,旨在體現類的某種明確狀態。如果我們選擇Employee類作為示例,那么其作用就是包含某位員工的狀態。那么具體狀態內容是什么?EmpID、Company、Designation、JoinedDate等等……正如教材上所言,這種類應當為有狀態,毫無疑問。
但我們應該如何進行薪酬計算?
我們是否該在Employee類中添加CalculateSalary() 方法?
是否應該使用SalaryCalculator 類,該類又是否應當包含Calculate()方法?
如果存在SalaryCalculator類:
- 其是否應該包含諸如BasicPay、DA HRA等屬性?
- 或者Employee對象是否應當作為私有成員變量通過構造方法注入至SalaryCalculator?
- 或者SalaryCalculator是否應當顯示Employee公共屬性(Java中的 Get&Set Employee 方法)?
輔助/操作/修改類
這些類負責執行特定任務。SalaryCalculator就屬于其中之一。這些類擁有多種命名方式,用于體現其行為并通過前綴或者后綴進行表達,例如:
- SomethingCalculator 類,例如: SalaryCalculator
- SomethingHelper 類,例如: DBHelper
- SomethingController類,例如: DBController
- SomethingManager類
- SomethingExecutor類
- SomethingProvider類
- SomethingWorker類
- SomethingBuilder類
- SomethingAdapter類
- SomethingGenerator類
人們可以通過不同前綴或后續表達類狀態,在這里我們就不過多討論了。
我們能否向這些類中添加一項狀態? 我建議大家以無狀態方式處理這些類。下面來看具體理由。
混合類
根據維基百科給出的面向對象編程內的封裝定義,其概念為“……將數據與函數打包成單一組件。”這是否意味著全部用于操作該對象的方法都應該被打包進實體類當中?我認為不是。實體類應當使用有狀態訪問方法,例如GetName()、SetName()、GetJoiningDate以及GetSalary() 等等。不過 CalculateSalary()應被排除在外。為什么?
根據單一責任原則:“一個類應當只出于單一理由進行變更。”如果我們將 CalculateSalary()方法添加到Employee類當中,那么該類則由于以下兩種理由而發生變更:
Employee類狀態變更:當新屬性被添加到Employee當中時。
計算邏輯中出現變更。
下面讓我們再明確地整理一遍。假設我們擁有2個類。Employee類與SalaryCalculator類。那么二者該如何彼此對接?實現方式多種多樣。其一為在GetSalary方法中創建一個SalaryCalculator類對象,并調用Calculate()以設置Employee類的薪酬變量。在這種情況下,該類將同樣表現為實體類與輔助類的特性,我們將其稱為混合類。我個人不建議大家使用這種混合類。
基本原則:“一旦大家發現自己的類可能已經轉化為混合類,請考慮對其進行重構。如果大家發現自己的類不屬于以上任何一種類別,請馬上停止后續編程工作。”
輔助/操作類中的狀態
有狀態的輔助類會帶來哪些問題?在給出答案之前,讓我們首先通過以下示例了解SalaryCalculator類能夠包含的不同狀態值組合:
場景一——基本值
- class SalaryCalculator
- {
- public double Basic { get; set; }
- public double DA { get; set; }
- public string Designation { get; set; }
- public double Calculate()
- {
- //Calculate and return
- }
- }
缺點
這時Basic薪酬有可能為“Accountant”則Designation可能為“Director”,二者完全不能匹配。在這種情況下,我們無法通過任何強制性方式確保SalaryCalculator獨立運作。
同樣的,如果其執行于線程環境下,亦會導致運行失敗。
場景二——對象即狀態
- class SalaryCalculator
- {
- public Employee Employee { get; set; }
- public double Calculate()
- {
- //Calculate and return
- }
- }
缺點
如果兩個線程共享SalaryCalculator對象,而每個線程對應不同的員工,那么整個執行順序有可能導致以下邏輯錯誤:
- 線程1設置employee1對象
- 線程2設置employee2對象
- 線程1調用Calculate 方法并為employee2獲取Salary
可以看到其中Employee關聯性可通過構造方法進行注入,并使得該屬性為只讀。接下來我們需要為每個Employee對象創建SalaryCalculator 對象。因此,***不要通過這種方式設計輔助類。
場景三——無狀態
- class SalaryCalculator
- {
- public double Calculate(Employee input)
- {
- //Calculate and return
- }
- }
這是一種近乎***的情況。不過需要考慮的是,如何全部方法都不使用任何成員變量,那么我們該如何保證其屬于無狀態類。
正如SOLID第二原則所言:“開放擴展,封閉修改。”什么意思?具體來講,當我們編寫一個類時,必須保證其徹底完成,即不要再對其進行后續修改。但與此同時,其也應具備通過子類與覆蓋實現擴展的能力。那么,我們的類最終應該如下所示:
- interface ISalaryCalculator
- {
- double Calculate(Employee input);
- }
- class SimpleSalaryCalculator:ISalaryCalculator
- {
- public virtual double Calculate(Employee input)
- {
- return input.Basic + input.HRA;
- }
- }
- class TaxAwareSalaryCalculator : SimpleSalaryCalculator
- {
- public override double Calculate(Employee input)
- {
- return base.Calculate(input)-GetTax(input);
- }
- private double GetTax(Employee input)
- {
- //Return tax
- throw new NotImplementedException();
- }
- }
正如我之前所反復強調,編程應該面向接口進行。在以上代碼片段當中,我出于篇幅的考慮而略去了接口實現方法。另外,計算邏輯應當始終處于受保護函數之內,從而保證繼承類能夠在必要時對其進行調用。
以下為Calculator類的正確消費方式:
- class SalaryCalculatorFactory
- {
- internal static ISalaryCalculator GetCalculator()
- {
- // Dynamic logic to create the ISalaryCalculator object
- return new SimpleSalaryCalculator();
- }
- }
- class PaySlipGenerator
- {
- void Generate()
- {
- Employee emp = new Employee() { };
- double salary =SalaryCalculatorFactory.GetCalculator().Calculate(emp);
- }
- }
其中Factory類負責封裝決定使用哪個子類的邏輯。其既可如上所述選擇有狀態,亦可選擇動態反映機制。對該類進行變更的惟一理由就是創建對象,因此我們并沒有違背“單一責任原則”。
在使用混合類的情況下,大家可能從Employee.Salary 屬性或者Employee.GetSalary() 處調用計算邏輯,如下所示:
- class Employee
- {
- public string Name { get; set; }
- public int EmpId { get; set; }
- public double Basic { get; set; }
- public double HRA { get; set; }
- public double Salary
- {
- //NOT RECOMMENDED
- get{return SalaryCalculatorFactory.GetCalculator().Calculate(this);}
- }
- }
總結
“思考時不編程,編程時不思考。”這項原則讓為我們帶來充足的考量空間,從而正確把握類的有狀態與無狀態決定——以及在有狀態時讓其顯示哪種狀態。
實體類應該有狀態。
輔助/操作類應當無狀態。
確保輔助類無狀態。
如果存在混合類,確保其不會違背單一責任原則。
在編程之前花點時間進行類設計。把類設計成果交給其他同事審查,并考量其反饋意見。
認真選擇類名稱。這些名稱將幫助我們決定其狀態。命名工作并沒有固定限制,以下是我個人的一些建議:
- 實體類應當在名稱中體現對象類型,例如: Employee
- 輔助/工作類名稱應當反映出其作用。例如: SalaryCalculator、PaySlipGenerator等
- 永遠不要在類名稱中使用動詞,例如: CalculateSalary{}類
原文標題:Object-Oriented Design Decisions: Stateful or Stateless Classes
【51CTO譯稿,合作站點轉載請注明原文譯者和出處為51CTO.com】