"封裝"是面向對象思想中最基礎的概念,實質上是將相關的函數和對象放一起,對外有函數作為操作通道,對內則以變量作為操作原料。
問題1 將數據結構和函數放在一起是否真的合理
函數是做事情的,其有輸入、執行邏輯、輸出;而數據結構是用來表達數據的,可作為輸入或輸出。
兩者本質上是不同的東西,面向對象思想將他們放到一起,使得函數的作用被限制在某一個區域里,這樣做雖然能夠很好地將操作歸類,但是這種歸類方法是根據"作用領域"來歸類的,在現實世界中可以,但在程序的世界中,有可能有些不妥。
不妥的理由可以用如下兩個情形是試着說明:
情形1:
並行計算時,由於執行部分和數據部分被綁定在一起,這就使得這種方案制約了並行程度。在為了更好地實現並行的時候,業界的工程師們發現了一個新的思路:函數式編程。將函數作為數據來使用,這樣就能保證執行的功能在時序上的正確性了。但你不覺得,只要把數據表達和執行部分分開,形成流水線,這不就能夠非常方便地將並行數提高了么?
舉例:
在數據和函數沒有分開時,程序的執行流程是這樣:
A.function1() -> A.function2() -> A.function3() 最后得到經過處理的A
當處於並發環境時,假設有這么多任務同時到達
A.f1() -> A.f2() -> A.f3() 最后得到經過處理的A
B.f1() -> B.f2() -> B.f3() 最后得到經過處理的B
C.f1() -> C.f2() -> C.f3() 最后得到經過處理的C
D.f1() -> D.f2() -> D.f3() 最后得到經過處理的D
E.f1() -> E.f2() -> E.f3() 最后得到經過處理的E
F.f1() -> F.f2() -> F.f3() 最后得到經過處理的F
...
假設並發數是3,那么完成上面類似的很多個任務,時序就是這樣
| time | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 |
|------|-----|-----|-----|-----|-----|-----|-----|-----|-----|-----|-----|-----|
| A | A.1 | A.2 | A.3 | | | | | | | | | |
| B | B.1 | B.2 | B.3 | | | | | | | | | |
| C | C.1 | C.2 | C.3 | | | | | | | | | |
| D | | | | D.1 | D.2 | D.3 | | | | | | |
| E | | | | E.1 | E.2 | E.3 | | | | | | |
| F | | | | F.1 | F.2 | F.3 | | | | | | |
| G | | | | | | | G.1 | G.2 | G.3 | | | |
| H | | | | | | | H.1 | H.2 | H.3 | | | |
| I | | | | | | | I.2 | I.2 | I.3 | | | |
| J | | | | | | | | | | J.1 | J.2 | J.3 |
| K | | | | | | | | | | K.1 | K.2 | K.3 |
| L | | | | | | | | | | L.1 | L.2 | L.3 |
當數據和函數分開時,並發數同樣是3,就能形成流水線了,有沒有發現吞吐量一下子上來了?
| time | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10| 11| 12|
|------|---|---|---|---|---|---|---|---|---|---|---|---|
| f1() | A | B | C | D | E | F | G | H | I | J | K | L |
| f2() | Z | A | B | C | D | E | F | G | H | I | J | K |
| f3() | Y | Z | A | B | C | D | E | F | G | H | I | J |
你要是粗看一下,誒?怎么到了第13個周期K才剛剛結束?上面一種方案在第12個周期的時候就結束了?不能這么看的哦,其實在12個周期里面,Y、Z也已經交付了。因為流水線吞吐量的提升是有過程的,我截取的片段應該是機器在持續運算過程中的一個片段。
我們不能單純地去看ABCD,要看交付的任務數量。在12個周期里面,大家都能夠完成12個任務,在11個周期里面,流水線完成了11個任務,前面一種只完成了9個任務,流水線的優勢在這里就體現出來了:每個時間段都能穩定地交付任務,吞吐量很大。而且並發數越多,跟第一種方案比起來的優勢就越大,具體的大家也可以通過畫圖來驗證。
情形2:
函數就是一個執行黑盒,只要滿足函數調用的充要條件(給夠參數),就是能夠確定輸出結果的。面向對象思想將函數和數據綁在一起,這樣的封裝擴大了代碼重用時的粒度。如果將函數和數據拆開,代碼重用的基本元素就由對象變為了函數,這樣才能更靈活更方便地進行代碼重用。
誰都經歷過重用對象時,要把這個對象所依賴的所有東西都要移過來,哪怕你想用的只是這個對象里的一個方法,然而很有可能你的這些依賴是跟你所需要的方法無關的。
但如果是函數的話,由於函數自身已經是天然完美封裝的了,所以如果你要用到這個函數,那么這個函數所有的依賴你都需要,這才是合理的。
可見:數據部分就是數據部分,執行部分就是執行部分,不同類的東西放在一起是不合適的!
問題2 是否所有的東西都需要對象化
面向對象語言一直以自己做到"一切皆對象"為榮,但事實是:是否所有的東西都需要對象化?
iOS開發中,有一個類叫做NSNumber,它封裝了所有數值:double,float,unsigned int, int...等等類型,在使用的時候它弱化了數值的類型,使得非常方便。但問題也來了,計算的時候是不能直接對這個對象做運算的,你得把它們拆成數值,然后進行運算,然后再把結果變成NSNumber對象,然后返回。這是第一點不合理。第二點不合理的地方在於,運算的時候你不知道原始數據的類型是什么,拆箱裝箱過程中難免會導致內存的浪費(比如原來uint8_t的數據變成unsigned int),這也十分沒有必要。
還有就是file descriptor,它本身是一個資源的標識號,如果將資源抽象成對象,那么不可避免的就會使得這個對象變得非常龐大,資源有非常多的用法,你需要將這些函數都放到對象里去。在真正傳遞資源的時候,其實我們也只是關心資源標識而已,其它的真的無需關心。
我們已經有函數作為黑盒了,拿着數據塞到黑盒里就夠了。
問題3 類型爆炸
由於數據和函數綁定到了一起,在邏輯上有派生關系的兩種對象往往可以當作一種,以派生鏈最上端的那個對象為准。單純地看這個現象直覺上會覺得非常棒,父親有的兒子都有。但在實際工程中,派生是非常不好控制的,它導致同一類類型在工程中泛濫:ViewController、AViewController、BViewController、ThisViewController、ThatViewController...
你會發現,一旦把執行和數據拆解開,就不需要這么多ViewController了,派生只是給對象添加屬性和方法。但事實上是這樣:
struct A { Class A extends B
struct B b; {
int number; int number;
} {
前者和后者的相同點是:在內存中,它們的數值部分的布局是一模一樣的。不同點是:前者更強烈地表達了組合,后者更強烈地表達的是繼承。而一個准則是:組合高於繼承。
上兩者的表達在內存中沒有任何不同,但在實際開發階段中,后者會更容易把項目引入一個壞方向。
總結
為什么面向對象會如此流行?業界關於這個談論的最多的是以下幾點:
- 它能夠非常好地進行代碼復用
- 它能夠非常方便地應對復雜代碼
- 在進行程序設計時,面向對象更加符合程序員的直覺
第一點在理論上確實成立,但實際往往卻是在面向對象的大背景下,寫一段便於復用的代碼比面向過程背景下難多了。
關於第二點,你不覺得正是面向對象,才把工程變復雜的么?如果層次清晰,調用規范,無論面向對象還是面向過程,處理復雜業務都是一樣好,等真的到了非常復雜的時候,對象間錯綜復雜的關系只會讓你處理起來更加頭疼,不如面向過程來得簡潔。
關於第三點,這其實是一個障眼法,因為無論面向什么的設計,最終落實下來,還是要面向過程的,面向對象只是在處理調用關系時符合直覺,在架構設計時,理清需求是第一步,理清調用關系是第二步,理清實現過程是第三步。面向對象讓你在第二步時就產生了設計完成的錯覺,只有再往下落地到實現過程的時候,你才會發現第二步中都有哪些錯誤。
綜上所述,觀點是:面向對象是在架構設計時非常好的思想,但如果只是簡單映射到程序實現上來,引入的缺點會讓我們得不償失。
