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界同样是收到批评的。
最后, 软件工程中, 是否真正需要灵活到如此程度, 以至于大部分人难以(或者不愿意去)理解的事物, 我就不知道了……
回复 更多评论