物件導向 vs 程序式導向-平庸與高級工程師開發速度差異的秘密
近年來幾乎所有的專案都是採用物件導向語言進行開發,相信大家對於物件導向也不陌生,但對於初學者來說物件導向卻有一定的門檻,剛接觸時可能會有以下疑惑:
- 物件導向與程序式導向之間最大的差異在哪裡?
- 物件導向為什麼可讀性較高?
- 物件導向為什麼比較好維護?
- 物件導向為什麼比較好擴充?
- 如何評估撰寫的程式碼是否達到物件導向的目標?
此篇文章以我學習物件導向4年的經驗來針對上述問題進行回答,希望初學者在看完此文章後能夠以系統化的方式寫出物件導向的程式碼,出社會後也不會被一些只會嘴砲的前輩唬的一愣一愣的,因為有可能帶你的前輩自己也沒辦法明確的回答上述問題,希望此篇能夠幫助想要學物件導向的初學者,讓你鞏固物件導向的基本功與概念,最後下方也會附上物件導向相關書籍的連結,都是這幾年看過無數次的經典好書。如果有不清楚、用字錯誤歡迎下方留言。
預估閱讀時間:15~60分鐘 (根據物件導向掌握能力而定)
此篇文章分為以下部分進行比較:
- 思維之差異
- 維護性之差異
- 可讀性之差異
- 擴充性之差異
- 可測試性之差異
- 如何評估程式碼是否達到物件導向的目標
- 物件導向特性封裝、繼承跟多型之間的權重
- 結論
思維之差異
大家都知道,常見的物件導向語言有Java、C#、C++、Ruby等,而程序式導向語言有FORTRAN、VB、C等,但事實上物件導向是一種設計方式,跟語言沒有太大的關係,也就是說你可以用Java寫出程序式導向,或是用C寫出物件導向。
- OO = Design Thinking + Design Principle
- OO != Language
從上述我們知道了物件導向是一種設計方式,基本上與語言無關,那究竟是什麼設計方式呢?物件導向和程序式導向設計方式的差異又在哪裡呢?
首先,我們先來談談程序式導向的設計方式,通常步驟如下:
- 設計問題領域 (Problem Domain)的資料結構
- 設計函數來操作此資料結構以達到需求
假設我們要設計一個程式來計算長方形的面積,根據上述步驟,我們會設計出以下的資料結構:
struct Rectangle {
int width;
int height;
};
此資料結構有兩個member variable,分別是width和height。
接著我們設計出以下函數:
double calculateArea(Rectangle* rectangle) {
int width = rectangle->width;
int height = rectangle->height;
return width * height;
}
此函數接收rectangle struct,並且抓取rectangle的width和height進行運算相乘。
看到這邊你可會想說,這樣有什麼不好?我們先來看一張圖:
上述的圖為Interaction Diagram,用來視覺化calculateArea函數與rectangle資料結構在Runtime時彼此之間的互動。
我們看到以下結果:
- calculateArea跟rectangle存取width變數,rectangle回傳width
- calculateArea跟rectangle存取height變數,rectangle回傳height
- calculateArea透過width、height進行運算之後,回傳area
從上述我們可以得知一個結論,calculateArea函數知道rectangle資料結構所有資訊,它知道rectangle有一個整數型態的變數width、一個整數型態的變數height。
你可能會覺得這個結論有什麼了不起?相信大家都聽過耦合這個名詞,當兩個元件耦合性越低時,彼此就越獨立,也就是說修改A元件不會影響B元件、修改B元件不會影響A元件,反之當兩個元件耦合性越高時,彼此關係就越緊密,也就是說當修改A元件時會影響B元件、當修改B元件會影響A元件。
我們通常怎麼評估耦合性的高低呢?方法很簡單,當一個元件知道依賴元件越多的資訊時,耦合性越高,反之,耦合性越低 (好的系統,模組之間知道對方的資訊越少越好)。
看到這邊,我們針對剛剛的設計,來評估耦合性,你覺得是高還是低呢?答案是非常高!!!為什麼?因為calculateArea函數知道rectangle資料結構所有的資訊,所以耦合性非常高!!!
耦合性過高的痛苦:假設rectangle資料結構中的height, width要從整數型態變成浮點數型態,calculateArea也需要跟著修改,假設這個專案有100個函數操作rectangle資料結構,我們就有100個地方需要跟著修改!!!100個地方進行修改,你覺得你有把握不會有漏網之魚嗎?而且通常複雜專案不會只有一個資料結構這麼簡單,每個函數可能操作多個資料結構,而這些資料結構都有可能會變動!!!
針對上述結果,經由學術抽象化後,得出了程序式導向的最大缺點:
程序式導向設計會導致Global Control,也就是說所有的行為都由一個函數控制,此函數控制子函數,各個函數在針對資料結構進行操作,各個函數跟所有資料結構高度耦合,導致修改其中資料結構或是函數,其他模組也會受到影響,嚴重影響專案的維護性。
從上述圖片可以我們可以得知,Main Function, Sub Function1, 3, 4會因為耦合到同一個資料結構而互相影響,Main Function, Sub Function2, 5, 6也是一樣,導致專案難以維護,可讀性差、擴充性差、難以評估修改影響範圍等。
Global Control也會違反Dependency Inversion Principle,因為低階函數修改會影響高階函數。
看到這邊你是否開始了解為什麼平庸與高級工程師之間的生產力會有這麼大的差距了?如果是平庸工程師你就會有100個地方需要修改,反之,高級工程師透過物件導向技術只會有1個地方需要修改!!!物件導向不是理論打打嘴砲而已,而是真的能幫助我們提高生產力,並且讓我們早點下班。
緊接著將會教你如何用物件導向技術達到Local Consequence Principle,翻成白話文就是只有一個地方要修改 :))人的生命有限,要改100個地方還是1個地方取決於你自己。
了解了程式式導向的設計方式後,緊接著我們來談物件導向的設計方式,通常步驟如下:
- 設計問題領域中能代表概念的類別
- 思考有哪些行為需要完成 (What)
- 思考這些行為要放到哪個類別
假設一樣我們要設計一個程式來計算長方形的面積,依照上述步驟,我們會設計出以下類別:
class Rectangle {
}
接著思考需要哪些行為,根據此範例,我們需要一個計算長方形面積的行為:calculateArea。
接著思考此行為要放到哪個類別中,根據此範例,我們把calculateArea行為,放到Rectangle類別,結果如下:
class Rectangle {
public double calculateArea() {
// empty implementation
}
}
有了類別與行為,並且行為也放到適當的類別後,我們此刻才會思考,計算rectangle面積需要什麼資料結構、如何實作,而需要什麼資料結構與如何實作稱之為實作細節。
根據此範例,我們需要width、height兩個整數型態的變數,而這些變數則變成類別的Field。
class Rectangle {
private final int width;
private final int height;
Rectangle(int width, int height) {
this.width = width;
this.height = height;
}
public double calculateArea() {
return this.width * this.height;
}
}
上述的步驟,就是常見的物件導向分析與設計(Object Oriented Analysis and Design),透過分析問題領域 (Problem Domain)找出類別來代表問題的概念 (Analysis),再把行為分配到適當的類別上面 (Design)。
接著我們針對此設計來評估耦合性,以下為客戶端使用此類別的方式:
class Main {
public static void main(String[] args) {
Rectangle rectangle = new Rectangle(2, 2);
calculateArea(rectangle);
}
private static void calculateArea(Rectangle rectangle) {
System.out.println(rectangle.calculateArea());
}
}
從上述我們看到calculateArea函數只知道rectangle能幫助它達到怎麼樣的行為(calculateArea),至於此行為怎麼實作,calculateArea函數完全不知情!!!因此客戶端與rectangle類別之間的耦合性是非常低的!!!假設rectangle中的Field皆要從整數型態修改成浮點數型態,我們只需要修改一個地方,也就是Rectangle類別本身!!!
假設現在專案中有100個函數呼叫rectangle類別的calculateArea,當Field修改時,影響範圍也只有rectangle類別本身,和產生rectangle物件的程式碼 ,只有2個地方需要修改!!!
針對上述結果,經由學術抽象化後,得知物件導向有幾個最重要的特性:
物件導向是一種Decentralized Control的設計方式,每個物件控制內部的狀態,修改物件內部的實作細節,對於專案中的其他物件不會有任何影響,除非是介面改動,能夠達到Loose Couple、Local Conseqeuce的效果。
物件是一種Data Abstraction,使用它的客戶端只會知道此物件能為它提供哪些行為,至於行為需要哪些Field、怎麼實作,客戶端完全不知道。
以下為Data Abstraction的定義,經過上述的解釋,你應該能輕易理解,因此不再進行敘述
Data abstraction refers to providing only essential information to the outside world and hiding their background details, i.e., to represent the needed information in program without presenting the details.
Data Abstraction也就是為什麼我們在剛學習物件導向時,都會被教導Field要宣告成private的原因,因為客戶端只能呼叫物件提供的行為,對於行為使用哪些Field、演算法完成,客戶端完全不知情。
根據Data Abstraction的定義,即便你的Field宣告成private,但如果提供getter和setter讓客戶端存取,基本上還是違反Data Abstraction,微軟常推薦使用C#的property來存取物件的Field,基本上對於維護性完全沒幫助,而大家很愛使用這類型的語法只是因為大部分人都習慣程序式導向,而getter、setter、property只是用物件導向語法做出程序式導向平常在做的事情罷了。 (本質上跟C語言struct一樣)
以下程式碼為很常見的錯誤寫法:
class Rectangle { // 此物件跟資料結構沒兩樣,只是語法不同而已
private int width; // 就算用C# property也是一樣
private int height;
Rectangle(int width, int height) {
this.width = width;
this.height = height;
}
public int getWidth() { // 不應該有getter
return this.width;
}
public int getHeight() {
return height;
}
public void setHeight(int height) { // 不應該有setter
this.height = height;
}
public void setWidth(int width) {
this.width = width;
}
}
我們針對物件導向與程序式導向進行了深度的比較後,得到一個重要的結論,兩種設計方式,思考問題的順序不同!!!
- OO: Behavior -> State
- Procedure: State -> Behavior
在物件導向中,我們透過客戶端需要哪些行為去進行設計 (What),設計完成後才會思考實作 (How),而在程序式導向中,我們會先思考資料結構本身 (How),接著透過函數去操作資料結構 (What)
實作細節之所以稱之為實作細節,是因為此細節是非常容易變動,而物件導向的設計方式完美的隱藏實作細節,也就是我們所說的封裝 (接下來會提到),讓元件之間的耦合性降低,而程序式導向過度注重實作細節,並且暴露實作細節在各個操作它的函數,導致元件之間的耦合性提高。
維護性之差異
說到維護性,怎樣才算是好維護呢?通常我們希望能達到以下幾點目標:
- 可讀性高,能清楚理解程式碼要解決什麼問題,通常會把相關的元件放在同一個模組,也就是所謂的高內聚力 (High Cohesion)。
- 修改容易,我們能清楚知道哪個地方需要修改,並且修改時不會影響其他元件,也就是所謂的低耦合 (Loose Coupling)。
- 好擴充,我們只需新增新的類別就能達到擴充的效果 (Open Closed Principle)。
- 好測試,當元件之間的耦合性很低時,我們能夠輕易的針對不同元件進行單元測試。
接下來針對物件導向與程序式導向,比較兩者在各個特性之間的差異。
修改之差異
在閱讀過兩者思維的比較後,你應該能很明顯的感覺出修改時,兩者之間的差別,以下為兩者的技術總結:
- 物件導向使用封裝 (Encapsulate)技術,將實作細節與Field封裝在行為內部,客戶端只能操作物件提供之行為,不會知道內部實作細節與Field。
- 程序式先導向著重在先設計資料結構,讓函數操作資料結構,與物件導向相反,客戶端會明確知道資料結構有哪些資訊,導致耦合性過高。
在修改時,物件導向低耦合特性能幫助我們滿足Local Consequence Principle,反之則會有Shotgun Surgery Code Smell (變動範圍很多,甚至無法評估),這就是物件導向維護性比程序式導向還要好的原因。
可讀性之差異
所謂的可讀性,其實沒有你想的那麼難以評估,方式非常簡單,在你有適當的Context時 (了解專案的問題領域),當看程式碼時,能否在10秒內明確知道程式要解決的問題?如果可以代表可讀性高,反之則則低。
兩者之間其實都能寫出可讀性高的程式碼,可讀性的高低在於程式碼的函數、變數命名,是否能達到Intention Revealing目標。
// 物件導向
class Rectangle {
private final int width;
private final int height;
Rectangle(int width, int height) {
this.width = width;
this.height = height;
}
public double calculateArea() {
return this.width * this.height;
}
}// 程序式導向
double calculateArea(Rectangle* rectangle) {
int width = rectangle->width;
int height = rectangle->height;
return width * height;
}
以上兩著皆有表達出程式要解決的意圖,因此可讀性都不錯,接下來針對可讀性進行補充,所謂的可讀性,必須要能夠讓閱讀你程式的人,能夠Chunking,不需要在意實作細節,或是一堆程式的指令。
人腦一次記憶的資訊非常有限,最多不過5~7筆資訊,因此我們需要依賴Chunking來幫助我們處理複雜的資訊,所謂的Chunking為把多筆資訊Group成一個Entity,當我們看此Entity時,不需要理解底下的資訊,假設有100筆資訊,我們透過Chunking可能變成5個Entity,如此一來我們便可以處理非常複雜的專案。Design Pattern、Intention Revealing、Encapsulation都是Chunking的一種展現。
以下為可讀性差的物件導向寫法:
class Main {
public static void main(String[] args) {
Rectangle r = new Rectangle(2, 2);
double result = r.getHeight() * r.getWidth();
double result2 = r.getHeight() * 2 + 2 * r.getWidth();
double result3 = result + result2;
}
}
在你了解物件後,你就會知道上述程式碼,不是物件導向 (儘管使用Java撰寫),因為rectangle物件沒有提供適當的行為給客戶端,也沒有適當的封裝,這種寫法為程序式寫法,函數操作資料結構進行運算。這就是開頭說的用Java不一定能寫出物件導向程式,物件導向程式為Design Thinking + Design Principle,與語言本身無關。
印入眼簾的則是一連串的指令,讀者完全沒辦法理解程式要解決什麼問題,你能在10秒內看出實際上是把長方形的面積與周長相加嗎?如果可以那可能是你對於面積計算與周長計算非常熟悉。
試著了解以下程式碼在做什麼,你有辦法在10秒內知道上述程式碼在解決什麼問題嗎?結果可想而知,可惜的是,這種程式碼在業界是常態,有這種程式碼還能不加班嗎?
以下為可讀性高的物件導向寫法:
class Main {
public static void main(String[] args) {
Rectangle rectangle = new Rectangle(2, 2);
double area = rectangle.area();
double perimeter = rectangle.perimeter();
double areaPlusPerimeter = area + perimeter;
}
}
上述程式碼相信不用10秒,5秒內應該能知道程式碼在解決什麼問題,物件也有妥善的封裝,客戶端只知道rectangle類別提供area、permeter等行為。
在閱讀此章節後,相信之後你能寫出可讀性高的程式,善用封裝把狀態與複雜實作細節Chunking在一起,提供客戶有用的行為,行為的命名要能夠Intention Revealing,如此一來能夠大幅度增加程式碼可讀性。
擴充性之差異
所謂的擴充性,指的是要進行修改時,能不能以影響範圍最少的方式擴充,最好被擴充的類別也不需要修改。
在講結論之前,我們先看兩段程式碼
第一段:
interface Shape {
int area();
int perimeter();
}
class Rectangle implements Shape {
private final int width;
private final int height;
Rectangle(int width, int height) {
this.width = width;
this.height = height;
}
@Override
public int area() {
return this.width * this.height;
}
@Override
public int perimeter() {
return 2 * this.width * 2 * this.height;
}
}
class Square implements Shape {
private final int edge;
Square(int edge) {
this.edge = edge;
}
@Override
public int area() {
return this.edge * this.edge;
}
@Override
public int perimeter() {
return this.edge * 4;
}
}class Main {
public static void main(String[] args) {
List<Shape> shapes = Arrays.asList(new Square(1), new Rectangle(2, 3));
int sumOfAreas = shapes
.stream()
.map(Shape::area)
.reduce(0, (a, b) -> a + b);
int sumOfPerimeters = shapes
.stream()
.map(Shape::perimeter)
.reduce(0, (a, b) -> a + b);
int sumOfAreasPlusSumOfPerimeters = sumOfAreas + sumOfPerimeters;
}
}
第二段:
interface Shape {
}
class Rectangle implements Shape {
private final int width;
private final int height;
Rectangle(int width, int height) {
this.width = width;
this.height = height;
}
public int getWidth() {
return this.width;
}
public int getHeight() {
return height;
}
}
class Square implements Shape {
private final int edge;
Square(int edge) {
this.edge = edge;
}
public int getEdge() {
return edge;
}
}
class Main {
public static void main(String[] args) {
List<Shape> shapes = Arrays.asList(new Square(1), new Rectangle(2, 3));
int sumOfAreas = 0;
for (Shape shape: shapes) {
if (shape instanceof Square) {
Square square = (Square)shape;
sumOfAreas += square.getEdge() * square.getEdge();
} else if (shape instanceof Rectangle) {
Rectangle rectangle = (Rectangle)shape;
sumOfAreas += rectangle.getWidth() * rectangle.getHeight();
}
}
int sumOfPerimeters = 0;
for (Shape shape: shapes) {
if (shape instanceof Square) {
Square square = (Square)shape;
sumOfPerimeters += square.getEdge() * 4;
} else if (shape instanceof Rectangle) {
Rectangle rectangle = (Rectangle)shape;
sumOfPerimeters += rectangle.getWidth() * 2 + rectangle.getHeight() * 2;
}
}
int sumOfAreasAndPerimeters = sumOfAreas + sumOfPerimeters;
}
}
在看完上述介紹之後,你覺得哪一段才是物件導向程式碼呢?答案應該很明顯,第一段符合物件導向的目標,第二段則是程序式導向,再次證明就算用物件導向語言 (Java)也不一定能寫出物件導向系統,物件導向為Design Thinking + Design Principle。
我針對上述兩段程式碼畫出UML來進行討論:
第一段
第二段
假設今天我們想要增加一個Circle圖形,你覺得會發生什麼事情呢?
- 根據第一段的UML我們知道,我們只需要新增Circle類別,並且實作Shape介面即可完成擴充,因此滿足Local Consequence Principle、Open Closed Principle。
- 根據第二段的UML我們知道,我們需要新增Circle類別,修改計算sumOfAreas、sumOfPerimeters的程式碼,針對這些函數加上新的條件判斷式 (shape instanceof Circle),需要修改三個地方,違反Local Consequence Principle、Open Closed Principle。
你可能會想1個 vs 3個好像還好,但事實上,在真正的專案中,使用Shape的客戶端不會只有兩個地方,如果有100個客戶端呼叫Shape,為了新增一個Circle你需要在100個地方進行修改!!!又回到了100 vs 1的無限迴圈中。而我比較懶,可不想浪費時間修改100個地方。
為什麼物件導向只需要修改一個地方,而程序式導向要修改100個地方呢?答案很簡單,因為Shape繼承架構在物件導向寫法只會出現一次 (實作類別和介面),Shape繼承架構在程序式導向寫法則會出現在100個客戶端,為什麼會重複呢?因為物件導向寫法客戶端只知道有行為area、perimeter,而程序式導向寫法客戶端必須判斷資料結構型態 (shape instanceof XXX),判斷的程式碼會重複出現在每個客戶端。
而讓客戶端呼叫一樣的行為 (area、perimeter),並且不知道是哪個接受者進行運算 (Rectangle、Circle、Square),也稱之為多型 (Polymorphsim),多型與封裝是物件導向讓專案好維護、好擴充的最大武器。
我們針對物件導向與程序式導向擴充性上進行了深度的比較後,得到一個重要的結論:
- 物件導向透過多型的方式讓客戶端不需要知道接收者是誰,因此不需要判斷,也不會有重複的問題,可讀性也高。
- 程序式導向透過函數操作資料結構,與資料結構耦合過高,因此知道Shape的繼承架構,導致重複、難以擴充,instruction step by step的特性也讓我們難以Chunking導致可讀性降低。
可測試性之差異
測試性之比較很簡單,一句話打死它,就是耦合性越低的元件越好單元測試。
- 當物件導向設計良好時,彼此之間耦合性很低,因此很好單元測試。
- 當物件導向設計不良,導致變成程序式寫法時,元件之間的耦合性很高,假設有10個類別,所有類別皆耦合在一起,導致難以進行單元測試。
之後會針對單元測試寫一篇文章進行深度探討,你將會理解為什麼單元測試能夠:
- 加速開發
- 提高軟體設計
- 降低軟體缺陷
- 支持設計逐步演進
- 支持敏捷開發流程
- 支持DevOps
如何評估是否符合物件導向的目標
以下為非常簡單的評估標準,沒有什麼秘密,也沒有什麼美學問題,請不要聽信前輩說什麼物件導向需要有天份才能開發這種話。
Minimum Getter and Setter:好的物件導向幾乎不需要getter與setter,因為物件會為客戶端提供介面,客戶端沒有理由抓取Field進行運算,如果有則是Feature Envy Code Smell。
Minimum Type Casting:好的物件導向應該用多型的方式取代Type Casting,順便達到Open Closed Principle的效果,如果針對整個繼承架構Casting,會導致Switch Case Code Smell。
One Place to Change:好的物件導向透過封裝、多型,當修改時,影響的範圍只有一個地方,既有的單一類別或是擴充新的類別,如果需要修改多個地方,甚至是整個系統,代表白用了物件導向語言的特性。
Good Abstraction:好的物件導向應該提供適當的抽象化,不管是抽象類別名稱、函數名稱、變數名稱,皆應該以What命名,而不是How,像是範例中客戶端只知道Shape有area、perimeter等資訊,不知道還有Circle、Rectangle、Square,還有這些子類別的實作細節 (包含運算與Field)。
物件導向特性封裝、繼承、多型之間的權重
介紹也快要到了尾聲,歡樂的時間總是過得特別快,各位應該還沒有睡著吧?
物件導向三大特性的權重為
- Encapsulate
- Polymorphism
- Inheritance
為什麼?很簡單,封裝幫助我們達到Local Consequence,這是物件導向與程序式導向最大的差異,而多型讓我們達到Open Closed Principle。
反之繼承如果使用不當則會變回程序式導向寫法,這就是為什麼這篇文章我沒有提到繼承的原因,初學者很容易濫用繼承,導致SuperClass與SubClass耦合性過高,破壞物件導向原本要達到的目標 (Local Consequence)。
繼承只有在使用多型,或是Template Method來封裝抽象演算法步驟時才需要使用。其餘時刻應該使用Object Composition來達到Reuse的效果。
下篇文章將會把使用今天介紹到的知識,逐步應用到簡單的小型專案。
結論
- 物件導向與程序式導向之間最大的差異在哪裡? Behavior vs Data structure mutation
- 物件導向為什麼可讀性較高? Chunking by encapsulation
- 物件導向為什麼比較好維護? Use encapsulation to achieve local consequence principle
- 物件導向為什麼比較好擴充? Use polymorphism to achieve open closed principle
- 如何評估撰寫的程式碼是否有達到物件導向? Minimum getter and setter、Minimum Type Casting、One Place to Change、Good Abstraction。
參考書籍:
- Head First Object-Oriented Analysis and Design by David West, Brett McLaughlin, Gary Pollice
- Practical Object-Oriented Design in Ruby: An Agile Primer by Sandi Metz
- Applying UML and Patterns: An Introduction to Object-Oriented Analysis and Design and Iterative Development by Craig Larman
- Agile Principles, Patterns, and Practices in C# by Uncle bob
- Smalltalk Best Practice Patterns by Kent beck
- Dependency Injection Principles, Practices, and Patterns by Mark Seemann, Steven van Deursen