OO為什么被人吹捧?
可以說, 是它開創了一個歷史,人們開始普遍采用 "對抽象的、多態的事物編程"。
在那之前, 無論是OP還是OB, 都只能完成模塊化, 方便分工開發與測試。很少使用多態技術。
OB編程(OP就省了…… OP->OB依然能完成一定復用,但方式不同):
void f_ob(T v) { v.f(); }
f是針對一個具體的T進行編程。
f的行為和T的行為緊緊綁定在一起。
f想要表現出不同行為, 必須要T表現出不同行為。
但無論T如何改變,T和f都只具有一種行為,最后一次改變后具有的行為。
OO編程:
void f_oo(I& i) { i.f(); }
f現在的行為, 僅僅與i所表現出的契約綁定在一起。
而i可以有各種各樣的滿足契約的方式。
這樣的一大好處就是, 對不同的D : I, f可以被復用。
得到這一好處的前提就是, D 滿足 I與f之間的契約。
GP編程:
template<typename T>
void f_gp(T v) { v.f(); }
同樣提供了多態行為:以不同的T帶入f, 就能得到不同的行為。
使得f能被復用。STL中的組件, 大部分是通過這種方式被復用的。
并且,STL組件之間, 也大部分是通過這種方式復用的。
1.
無論是GP還是OOP都是組合組件的方式。
它們(典型地)通過concepts 和 interface來抽象出組件多態行為。
對這種抽象事物編程得到的結果(函數/類模板,包),可以被復用(軟件工程一大目標)。
-------- -------- -------- -------- -------- -------- -------- --------
上面提到契約。
無論是OP、OB、OO、GP, 組件間要能協同工作, 都是需要契約的。
OP:
free 可以接受空指針, 而printf 不行。
前者是free對調用者定下的約束, 后者也是printf對調用者定下的約束
—— 要使用我就必須這樣做。
malloc 失敗返回空指針, 而new 拋異常, 否則返回可用動態內存。
這是它們對調用者的承諾。
—— 使用我, 你能得到什么。
T* p = new T;
if (!p) 這種代碼只在古董編譯器上才可能有意義。
"加上NULL判斷更好" , 也只有"C++方言"的古董學者才說得出來。
new[] 要 delete[] , new 要 delete, 這也是契約。
"因為char是基礎類型,所以可以new[] , delete", 只有不懂軟件契約的白癡學者才說得出來。
OB:
要將CString s 轉換為 TCHAR*, 一定要用 s.GetBuffer 而不是 (TCHAR*)&s[0]
CString 的約束。
basic_string.c_str() 一定以'\0'結尾, 而data() 則不是。
basic_string 的承諾。
OOP:
我用的OOP庫、框架不多…… 舉不出什么例子。
但它的契約和通常OB"非常類似" : 完成什么、需要調用什么、調用順序、 參數合法性。
GP:
GP總共有哪些契約形式, 我總結不出來。
但至少有一條 —— 它不再對T有完全限定, 而只作最小限定。
還是上面的代碼:
void f_oo(I& i ) { i.f(); }
D d;
f(d); // 要求 D : I
template<typename T>
void f_gp(T v) { v.f(); }
要求 v.f(); 合乎語法 :比如, 它既可以是non-static member function, 也可以是static member function。
并且僅僅要求這一點。
2.
契約是普遍存在的, 不僅僅是GP、 其他范式都有。
它是合作與復用的前提。
-------- -------- -------- -------- -------- -------- -------- --------
3.
為什么GP飽受爭議, 而OO沒有?
我覺得, 是因為從OB到OO過度比較自然。
要求一個I需要怎樣, 一個I需要使用者怎樣的時候,
通常也會有類似的需求 —— 要求一個C怎樣, 一個C需要使用者怎樣。
注意, 這只是 client 與 I, 以及 client 與 C之間的契約。
在這個層面上, 不需要學習新的思想。
而下面會提到OO引入的新的契約形式。
而GP的契約形式, 都是一些全新的形式。
其中最主要的形式是: T 是否具有一個叫f的函數(操作符也可以理解為一種調用函數的方式)。
在C++中, 還有必須有某個名字的嵌套類型, 通過T::f強制static member function等形式。
OO比較流行、支持OO的語言比較多,是目前主流。
C++,Java,C#的開發者總共占了多少比例?
GP支持的語言其實也多。
但拋開C++(或者算上C++用戶中理解GP的人)怎么都比不上上面3個巨頭。
批評自己不熟悉的事物是不嚴謹的。
遇見STL編譯錯誤, 要么就去學習GP的方式, 要么就拋棄STL。
抱怨STL是種傻逼行為 —— 明明是自己不會用, 要怪只能怪自己。
-------- -------- -------- -------- -------- -------- -------- --------
4.
如同GP、 OO同樣需要學習、 需要文檔、 否則會充滿陷阱。
上面提到的client 與 I的契約形式通常和 clinet 與 C之間的形式相同。
使得OO的一個方面可以"溫固"。
而client 與 D之間的契約? 這個層面不"知新"是不行的。
并且這個層面上的契約常常是出bug的地方 —— 因為這是語法檢查不了的, 必須有程序員自己去滿足語意。
舉個例子 :
看見一個虛函數,它是否可以被覆蓋? 覆蓋它的實現是否需要同時調用基類實現?
除非是約定俗成的一些情況, 比如 OnNotify、OnXXXEvent這種名字比較明顯。
其他情況, 不去看文檔, 依然是不知道的。
我剛剛就犯了一個錯。
python 中 繼承HTMLParser , 覆蓋__init__ 時, 必須調用基類實現。
我確實不知道構造函數也可以被完全覆蓋……(原諒我…… 第1次使用python……)
在C++中, 基類的構造函數是無論如何都會被調用的。
我沒有調用基類的__init__, 然后報了一個錯。
好在報錯清晰, 源代碼的位置都有, 源代碼也可見, 源代碼也清晰。問題很容易就找出來了。
如果
4.1.
它不是構造函數, 只是一個普通的虛函數?
名字也看不出什么蹊蹺?
4.2.
文檔也沒有記載是否需要調用基類?
(HTMLParser中的文檔沒有說要調用__init__, 這應該是python的慣例, 所以就沒有記載了)
沒有嚴格的錯誤檢查?
沒有完整的、信息豐富的call stack trace?
等發現錯誤時, 也許都離題萬里了。
4.3.
沒有清晰的源代碼(其實到了要查看源代碼的時候, 已經是……)?
這些問題在OO中同樣是存在的, 只是它沒引起編譯錯誤, 或者說編譯錯誤比較明顯, 容易修改。
而更多的語意檢查, OO中 client 與 D之間的層次, 依然要靠程序員的學識 —— 主要是查閱文檔的習慣。
對比GP
4.1之前的4.0(OnNotify, OnXXXEvent之流), 同樣存在一些約定俗成。
對4.1, 同樣是查文檔。
對4.2, 沒有文檔同樣犯錯。
C++中, 一部分錯誤可以提前到編譯時(C++只在持編譯時支持ducking type)
對4.3
同樣需要去查看源代碼。
GP的代碼比OOP難看?
同上面, 只是因為不熟悉、不信任、不需要這種抽象方法, 這些契約的形式。
STL中的契約形式其實并不多。
[first,last) 左閉右開區間;
iterator的幾個種類;
(adaptable)binary(unary)_function;boost只是將其提升到N-nary,還省去了adaptable的概念;
相等、等價、嚴格弱序。
多嗎?
而且, 我不覺得為了比較要去實現一個IComparable 有何美感可言……
-------- -------- -------- -------- -------- -------- -------- --------
5. 這些新的契約形式、 抽象方式, 有沒有其優勢?
當然有 —— 最小依賴帶來的靈活性。
上面也提到, GP只依賴于其需要的契約, 對其不需要的, 通通不關心。
現在詳細解釋上面
D d;
f_oop(d); // D : I
T v;
f_gp(v); // v.f
為什么后者比前者靈活。因為后者只依賴所需要的東西。
5.1
template<typename T>
void f123_gp(T v) { v.f1(); v.f2(); v.f3(); }
可以使用
struct T123_1 { void f1(); void f2(); void f3(); }
struct T123_2 { void f1(); void f2(); void f3(); }
void f123_oop(I& i) { i.f1(); i.f2(); i.f3(); }
必須有一個
struct I { virtual void f1(); virtual void f2(); virtual void f3(); };
然后是:
D1 : I; D2 : I; ...
5.2
如果現在需要實現另一個函數, 它對T的需求更少 :
template<typename T>
void f12_gp(T v) { v.f1(); v.f2(); }
T123_1, T123_2 可以使用
新增一個:
struct T12_1 { void f1(); void f2(); }
依然可以使用
OOP就必須重構了:
struct I12 { virtual void f1(); virtual void f2(); };
struct I123 : I12 { virtual void f3(); }
struct D12 : I12 {};
5.3
再看 :
template<typename T>
void f23_gp(T v) { v.f2(); v.f3(); }
T123_1, T123_2 依然可以使用
T12_1 不行。
但新增的
struct T23_1 { void f2(); void f3(); }; 可以使用
OOP又必須重構:
總體趨勢是這樣的, OOP需要極端靈活的時候, 就會變成這樣:
一個接口, 一個函數。
struct I1 { virutal void f1(); };
struct I2 { virutal void f2(); };
struct I3 { virutal void f3(); };
現在接口設計是極端靈活了。
但使用接口時, 依然逃不過2種都不太優雅的作法:
1. 接口組合
struct I12 : I1, I2;
struct I23 : I2, I3;
struct I31 : I3, I1;
2. 接口查詢
不組合出那些中間接口, 但運行時作接口查詢:
void f12(I1* i1) {
i1->f1();
if (I2* i2 = dynamic_cast<I2*>(i1) {
i2->f2();
}
else { 將一部分編譯時錯誤留到了運行時。 }
}
這不是故意找茬, 而是將STL中的iterator換個簡單的形式來說明而已。
也許絕大部分情況下, 是不需要靈活到每個接口一個函數, 而是一個接口3、4個相關的函數。通常它們會被一起使用。
即使沒有上面如此極端, 假設IPerfect1、IPerfect2都是設計得十分合理的, 3、4個函數的接口, 通常這3、4個函數要么必須一起提供, 要么都不提供, 單獨提供是不符合語意的, 提供太多又是不夠靈活的。
這需要經驗, 相當多的經驗。 但總是可以完成的事情。
但組合接口, 依然是OOP的痛處。
我記不清C#和Java中的interface是否繼承自多個interface。
如果不行, 它們就可能需要運行時接口查詢。
而C++, 要在這種"組合接口"與接口查詢之前作一個選擇。
反觀GP, 它一開始就不是以接口為單位來提供抽象,而是按需而定。
所以, 它既不需要仔細的拆分接口, 也不需要組合接口。
STL中數據、容器、算法相互無關、可任意組合。
應該是前無古人的突破。
后面有沒有來者? 上面已經說了, OOP要達到這種靈活性, 同樣也有其代價。
并且, OOP代價體現在丑陋, 而不是難以理解。
靈活的事物肯定比不那么靈活的事物難理解,抽象總比具體難理解。
所以抽象出一個合理的、廣泛接受的語意很重要。
* 就是解引用, ++ 就是前迭代, -- 就是后迭代。
支持--就是雙向, 支持 + n 就是隨機。
GP也不會胡亂發明一些語意不清晰的概念。
window w;
control c;
w += c; 這種代碼在GP界同樣是收到批評的。
最后, 軟件工程中, 是否真正需要靈活到如此程度, 以至于大部分人難以(或者不愿意去)理解的事物, 我就不知道了……
回復 更多評論