String类的设计一点都不容易,先不论C++,那怕是其他语言,在面对string的时候,一不小心也会掉坑,好比java,好比C#,一开始假设utf16是定长编码,后来Unicode发展到两个字节就装不下一个码位,字符串在java下,就有点尴尬了。就算是昧着良心用utf32编码,码元与码位终于一一对应了,也会遇到物理字符与逻辑字符不对应的时候,好像有些语言的字符要用两个unicode值来表示(很奇怪),有些语言的一个小写字符对应着好几个大写字符。即便是字符串选定了一种编码方式,始终还是要解决与其他编码的交互问题,这些交互接口也不容易设计。另外,每次从长字符串中截取字符串都要重新new出来一条新的字符串,难免有一点点浪费,当然,现在计算机性能过剩,这纯粹是强迫症。
而到了c++下,设计字符串所遇到的问题,就远比想象中复杂,无中生有的又凭空多出来很多不必要的要求,内存资源管理(这个在C++几乎是无解),异常安全(往字符串添加新内容,假如内存分配失败,必须保持原有值的完整性),还有性能要求(截取字符串避免生成新的字符串)。很多很多的要求,导致语言层面上压根就没法也不可能提供原生的字符串支持,而这一点上又引出来新的问题,代码里面,逻辑意义上看,就不止存在一种字符串类型。好在,大C++拥有丰富多彩的feature,应该足以实现字符串类型了,这也是大C++的设计哲学,既然语言上没法实现的东西,就提供用以支持这种东西的feature,用户要怎么实现就怎么实现,选择权交到用户手里。
所以,C++的库要怎么做出来一道string,这道菜的味道如何,就很让人好奇。一路考察下来,让人大跌眼镜,竟然没有一个c++库能提供品质优良字符串, 其抽象充其量也就是比字符数组好一点点,完全就没有Unicode编码的抽象。Stl的字符串更让人发指,竟然有几个模板参数,本来多类型的字符串问题就更是雪上加霜了,另外stl的string还不能作为dll函数的参数类型。其实,很多时候,猿猴的要求真的不高,只要求一种utf8编码的string,带有格式化,还有一些split,trim,FindOneOf,toupper等常用字符串处理的操作就行了,只可惜,没有一个c++库能基本满足这样的基本要求。其实,这些要求,具体到C++下,要基本满足,也的确很困难。
除了c++,很多语言的string类型都是原子属性,一个string值,但凡一点风吹草动,都要生成新的string值,原有的值必须保持不变。此外,其官方也提供了类似于StringBuffer或者StringBuilder用以构造很长很长,以弥补这种动不动就生成新String的性能问题。这两种类型泾渭分明。而c++的string,似乎是把这两种类型糅合在一块了,由此带来语义上的不清晰,也造成很多不必要的麻烦,因为绝大多数场合下,只需要使用string的原子属性,可变的string只是用来保存字符缓冲而已。知道吗,stl的string有一百多个成员函数,很多都是不必要的重载,不过是为了避免字符串的复制而已。
所以,首先要对只读的string做抽象,也即是string_view,只需两个成员字段,字符串的起始地址以及缓冲长度,并且不要求以0结束,它有一个很好的特性,字符串的任何一部分,也都是字符串,甚至,必要时,一个字符,通过取地址,也可以看做是长度为1的string_view。任何连续的内存字符块,都可以看做是string_view。其不必涉及内存的分配,显得非常的轻量级,可以在程序中到处使用,只需注意到字符缓冲的生命周期,就不必担心会引来什么问题。在string_view上,可以做trim,比较,查找,反向查找等操作,除了读取单个字节的迭代器,还提供两套迭代器,用以取到unicode码位值(uin32),和用以访问逻辑字符,其值也为stirng_view。
剩下来就是可写可修改的string,要求以0结束,也即是stl的string,因为很多函数都在string_view上,所以这里基本上都只是插入、添加、删除、替换的操作,要注意的是,中括号操作符不能返回字符引用,因为那样完全没有任何意义,就算是保留中括号返回字符值,意义也很小。Trim、查找、比较等操作,必须通过其成员函数view来返回代表自己的string_view。String的很多成员函数,大多数参数类型就是string_view,因此也没有像是在stl下垃圾string的那么多乱七八糟的重载。很简明的设计,性能与简单的良好统一,不知为何,stl要到c++17的时候,才会加入stirng_view这么重要的类型,即便是如此,stl的string既有代码已成定局,也没办法用string_view来简化它的一百多个的成员函数了
近两年来在写C++的运行时环境,反射、运行时类型信息、内存管理、并行、字符串、协程、ORM等等,基本上重写了一套标准库以及运行库。对于在c++下使用字符串,深有体会。一开始呕心沥血,殚精竭虑,支持多种编码方式(Utf8、Utf7、GB2312、Utf16LE,Utf16BE等)的字符串类型,以及在此之上的对这些字符串提供格式化、字符串解析、json、xml、文件读写BOM等等功能,必须承认,大C++真是变态,像是这样变态无聊的概念都可以支持,还可以实现得很好,用起来确实也方便。可是,每次面临字符串操作的时候,都会心里发毛,都会嘀咕此时此刻,纠结的是哪门子的编码,也搞得很多代码必须以template的形式,放在头文件上,不放在头文件,就必须抽象出来一个通用的动态字符串类型,代表任意编码的一种字符串类型,代码里面引入各种各样臆造的复杂性。终于受不了啦,最后搞成统一用utf8编码,重构了几千行代码(十几个文件),然后,整个字符串世界终于清静了,接口api设计什么的,也一下子清爽了很多。整个程序内部,就应该只使用同一种编码的字符串。stl的带有多个模板的string设计,就是无病呻吟,画蛇添足。
为什么选择Utf8编码,首先,非unicode编码的字符串是不能考虑的;其次,utf16也是变长的编码方式,而且还有大小端的区别,所以也不能考虑;utf32又太占用内存了。想来想去,终于下定决心,utf8简直就是唯一的选择了。虽然可能有这样那样的小问题,比如说,纯中文文本,utf8占用多50%内存(相比于Utf16),windows下utf8有点不友好。但其实都不是问题,也都可以解决。比如说,windows下,所有的涉及字符串与系统的api交互,先临时转换成utf16,然后再调用api。api的返回结果为utf16,再转换为utf8。好像有一点性能上的损失,其实没啥大不了的。windows对于多字节也是这样支持的,完全就感受不到性能上的影响。总之,utf8简直就是程序处理的唯一字符串编码。
吐槽一下std的字符串,以及与此相关的一切概念,iostream,locale等等东西,垃圾设计的典范。接口不友好,功能弱,而且还性能差,更关键的是其抽象上的泄漏。一整天就只会在引用计数,写时复制,短字符串优化上做文章,时间精力都不用在刀刃上。C++17终于引入string_view的类型,情况稍微有些改善。由于字符串使用上不方便,也因此损失了一大片的用户,阵地一再失守。整体上讲,stl的设计,自然是有精心的考虑,但是,作出这些抽象的标准会上一大群的老爷子们,大概率上讲,应该是没有用stl正儿八经地开发工业级上的代码,臆造抽象,顾虑太多,表面上看起来好像是那么一回事,真正用起来的时候,就不太对劲,会有这样那样的不足,很不方便。
简单说一下U8String的设计思路。U8String用以管理字符串编码缓存的生命周期,追加缩短替换字符串,支持通过下标可以读取字节char,但是不支持将字节写入到某个索引上的位置,当然支持往字符串中插入unicode编码的字符。至于字符串的比较、查找、Trim、截取子字符串这些常用操作,就全部压在U8View上。如果U8String要使用这些,要先通过view的函数,获取自己字节缓存下的视图。U8View表示一段连续的字符编码内存,U8View的任意一部分也是U8View,不要求以0结束。只要求U8View的生存周期不能比其宿主(U8String,字符数组,U8原生字符串)长命。事实上,很多api的字符串参数,其实只是要求为U8View就行了,不需要是什么const string&类型。此外,还提供U8PointPtr的指针类型,用以遍历U8View,其取值为unicode编码值,也就是wchar_t类型。另外,既然有U8View,自然也就有ArrayView,代表连续内存块的任意类型。
自然,库中必须提供格式化Fmt以及解析字符串Scanf的函数。StrFmt用以生成新的U8String,而Fmt格式化函数中传入字符串的话,就将格式化结果追加到字符串后面。Fmt可以格式化数据到控制台,文本文件,日志等等输出结果上。StrFmt的实现只是简单地调用Fmt并返回U8String。有了Fmt和Scanf,操作字符串就很方便很灵活了,同时也消除很多很多有关字符串相关的处理函数。Fmt不仅仅能格式化基本类型,自定义类型,还能格式化数组,vector,list,pair,tuple等模板类型的数据。库中也提供了类似于iostream重载<<和>>的操作符。大C++提高的feature,造出来的string类型,使用上的方便,一点都不逊色于其他任何语言的原生string类型。当然,std的那个string,简直就是废物。
不管怎么说,本人还是很喜欢C++的,用c++写代码很舒畅,可比用C#、haskell、lisp、scala时要开心很多。C++发展到C++11,基本功能也都完备了,当然,C++14、C++17自然功能更加强大,特别是实现模板库的时候,就更方便了,也确实很吸引人。自然,C++也非十全十美,也有很多的不足,比如不能自定义操作符,不提供非侵入式的成员函数,缺乏延迟求值的语言机制,引用的修改绑定(只要不绑定到nullptr就好了),成员函数指针的无端限制。但是,世界上又哪里存在完美的language呢,特别是对于这种直接操纵内存的底层语言来说。至于rust,叫嚣着要取代c++,就它那副特性,还远着呢。
大家都知道,大C++里面可以私有继承,之后基类的一切,在子类中就成为private的了,不对外开放了。现在流行接口,组合优化继承,所以private继承这玩意,日渐式微,很久以前就很少使用了,嗯,不要说private,就算是大c++,也是江河日下。不过,存在即合理,c++语法里面的任何东西,都有其价值,平时可以用不到,但是关键时刻用一下,确实很方便,当然多数情况下,也可以其他途径来完成,但是,就是没那么舒服。
废话就不说了,直入正题吧。
假设,现在有接口,假设是IUnknown,里面有那三个著名的纯虚函数,QueryInterface, AddRef, Release,好像是这三个哥俩。
然后,有一个类,就叫ClassA,实现了IUnknown接口,其实就是继承IUnknown,也就是说,重写了那三个纯虚函数。此外,ClassA还有一大堆自己的东西,比如public的字段或者成员函数。
现在,有ClassB,想基于ClassA来做一些事情,但是又不想让用户看到ClassA里面那些乱七八糟的玩意,因此,这种情况下,用private似乎很合适。代码如下:
struct IUnknown
{
public:
virtual HRESULT QueryInterface(REFIID riid,void** ppvObject) = 0;
virtual ULONG AddRef() = 0;
virtual ULONG Release() = 0;
};
struct ClassA : IUnknown
{
virtual HRESULT QueryInterface(REFIID riid, void** ppvObject) override { ... }
virtual ULONG AddRef() override { ... }
virtual ULONG Release() override { ... }
...
};
struct ClassB : private ClassA
{
...
};
这里,内存的使用上非常紧凑,可以说,没有多余的地方。但是,这里的private,不仅仅会private ClassA的一切,就连IUnknown也被private,这有时候就不符合要求了,因为这里意图是,private ClassA,但是又想public IUnknown,也就是说,对外界来说,ClassB不是ClassA,虽然其内部基于ClassA实现,但是,又希望ClassB是IUnknown。对此,有几种解决做法,但是都不能让人满意。
方法1、让ClassB再次实现IUnknown接口,如下所示:
struct ClassB : private ClassA, public IUnknown
{
virtual HRESULT QueryInterface(REFIID riid, void** ppvObject) override { ... }
virtual ULONG AddRef() override { ... }
virtual ULONG Release() override { ... }
};
其好处是,ClassB的实例可以无缝用于IUnknown的一切场合,不管是引用或者指针,const非const。但是,代价也是很大的,首先要针对IUnknown的每个虚函数,都要一一手写,再次转发给private的基类,其次,ClassB比ClassA多了一个虚函数表指针,大小就比原来多了一个指针的大小,这就不是零惩罚了,这是最不该。
方法2,还是保持私有继承,再在ClassB中添加几个函数,用以返回IUnknown,代码如下
struct ClassB : private ClassA
{
//也可以using ClassA的三个IUnknown里面的函数
const IUnknown* GetUnknown()const { return this; }
IUnknown* GetUnknown()const { return this; }
};
避开了方法1的不足,但是就不能无缝用于IUnknown下,每次使用必须调用一下GetUnknown(),对于引用的情况下,还必须加多一个星号*,也是挺不方便的。对了,这里就算添加了类型函数重载,也即是operator IUnknown,编译器也拒绝将ClassB无缝转换成IUnknown。
方法3,用包含,不用私有继承。如下:
struct ClassB
{
ClassA mA;
operator const IUnknown&()const { return *this; }
operator IUnknown&() { return *this; }
};
这样子,ClassB的实例可以无缝用于IUnknown引用下的情况。对于指针的话,可以仿造方法2那样子,写两个函数进行调用。貌似综合起来,方法3的整体分数最高。
就个人而言,更偏向于,直接就让ClassB public继承ClassA好了,少了那么多鬼怪,虽然出现很多不必要的函数,其实也没什么不好。
本人对于c++的认识,多年下来,经历了以下几个阶段,
1、 c++很好很强大,盲目追求运行性能,简直巴普洛夫条件反射,贡献了一大坨垃圾代码;
2、 c++的面向对象对持很垃圾,什么鬼,代码很容易就耦合,于是迷上对象+消息发送的面向对象;
3、 c++太复杂了,template太抽象,天外飞仙,搞不懂,二进制复用又差。整个c++就是垃圾,简直程序设计语言里面的败类,C语言多好啊,小巧精致简单清晰;
4、 使用其他语言做开发,java、C#、F#、elisp、scheme、python、haskell、javascript、php等等一大坨语言,感概每一种语言都比垃圾C++不要好太多,发誓不再用c++写哪怕一行的代码;
5、 某一天,突然有点理解了这种语言,一切变得清晰了,原来c++也相当不错,也可以做一些事情,看开之后,感觉开发效率也跟上来了,做同样的事情,用c++实现不会比C#、python等慢。
相比于其他语言,c++的独特优势在于
预编译期的伪图灵完备,这一点,好多语言还是有的,并且更超级好,比如rust,scheme
编译期间的C++是功能完备的解释器,其输出结果是正常运行的c++代码,结合宏,可以制造很多其他语言必须在语法层面上支持的语法糖。这个解释器的奇妙之处在于它运行于编译期,一旦错误的模板代码要进入运行期,就会出现编译错误,而不需要进入运行时的代码,即便天大错误,也都不要紧,而一旦这段代码要进入运行时,那么模板错误就逃不过编译期解释器的法眼。
生成各种内存布局的便利语法糖和自由的内存操控;不同类型的对象,只要其内存布局一致,通过强制转换,就可按同一类型来处理,这一点作死能力,绝不被有gc的语言支持。内存的无节操玩弄,结合template,分分钟就能仿真出来其他必须语言层面上提供的数据结构,类型安全、运行性能、易用性,一点都不逊色,好比string,委托,元组,列表,可空类型;
C++的专有特性,raii、多继承和全局变量。特别是全局变量,结合它的构造函数特点和类型推导,所能玩出来的丰富新花样,其他语言很难做到。全局变量是连接运行期和编译期的桥梁。如果没有全局变量,本座应该不会再次对c++产生热情。奇怪的是,至今为止,c++的基础库都不怎么挖掘全局变量的潜能。当然,对全局变量的使用,肯定是把它当做常量来用,全局变量有唯一的内存地址,就起到原子的作用,但它又可打包了丰富的静态类型信息。
以上的独特,造就了c++层出不穷的新意,而卓越的运行性能,只是其微不足道的优点。虽然说,语言不重要,思想才重要,软件架构才重要,但是c++所能承载的思想,以及其到达的抽象高度,的确就真的大大降低框架的复杂性,诚然,c++的基础库开发要面临无穷无尽的细节纠结,其实,这也反映了c++编译器掌控细节的能力,因此,我们又可以让编译器自动完成很多很多细节重复,从而大幅度地减轻代码数量,还无损其运行性能。又由于c++完备强大的静态类型特性,在用动态语言风格的简洁来编写代码的同时,又无损其快速方便地代码重构。笔者的基础库项目,几十次大规模的重构,借助单元测试,保证了重构顺利快速的完成,深感c++在重构上的便利,这些代码,包括不到1千行却功能完整的xml库(还支持对象与xml数据的直接互相转换);不到1千行却一点都不逊色于boost的spirit组合子解释器(编译速度却快了很多,语法上简洁很多,更能方便地解释各种语法);才1千多行的异步io框架;输入输出,文件操作,数据库,协程等代码都简洁异常,所有这些代码都支持动态库上的二进制复用,让人很惊诧于c++的光怪陆离的强大。
当然,c++的缺陷也震撼人心,
1、 语言特性太过繁杂抽象微妙,比如template、多继承、运算符重载、类型转换、兼容性考虑的很多糟糕语言特性,所以对使用者的节制力要求很高,要求他们时刻清楚自己在干什么,琐碎上的思考太多;
2、 缺乏统一的二进制标准,基础库都用源代码的形式共享,这让原本就龟速的编译速度更加地令人大大感动;
3、 缺乏高标准的基础库,stl和boost更在某些技术运用的展示上更起到很坏的影响;
4、 缺乏某些延迟求值的机制,缺乏必要的函数式语言机制,所以c++始终就无法成为堂堂正正的现代化高级语言!
就这样吧。
终于写到c++的非侵入式接口了,兴奋,开心,失望,解脱,…… 。在搞了这么多的面向对象科普之后,本人也已经开始不耐烦,至此,不想做太多阐述。
虽然,很早就清楚怎么在c++下搞非侵入式接口,但是,整个框架代码,重构了十几次之后,才终于满意。支持给基本类型添加接口,好比int,char,const char*,double;支持泛型,好比vector,list;支持继承,基类实现的接口,表示子类也继承了对该接口的实现,而且子类也可以拒绝基类的接口,好比鸭子拒绝基类鸟类“会飞”,编译时报错;支持接口组合;……,但是,这里仅仅简单介绍其原理,并不涉及C++中各种变态细节的处理,C++中,但凡是要正儿八经的稍微做点正事,就要面临无穷无尽的细节纠结。
先看看其使用例子:
1、自然是定义一个接口:取之于真实代码片段
struct IFormatble
{
static TypeInfo* GetTypeInfo();
virtual void Format(TextWriter& stream, const FormatInfo& info) = 0;
virtual bool Parse(TextReader& stream, const FormatInfo& info)
{
PPNotImplement();
}
};
2、接口的实现类,假设为int添加IFormatble的接口实现,实际代码肯定不会这样对一个一个的基本类型来写实现类的代码。这里只是为了举例说明。类的名字就随便起好啦,
struct ImpIntIFormatble : IFormatble
{
int* mThis; //这一行是关键
virtual void Format(TextWriter& stream, const FormatInfo& info)override
{}
virtual bool Parse(TextReader& stream, const FormatInfo& info)override
{}
};
这里的关键是,实现类的字段被规定死了,最多只能包含3个指针成员字段,且第1个字段一定是目的类型指针,第二是类型信息对象(用于泛型),第三是额外参数,次序不能乱。成员字段如果不需要用到第二第三个成员字段数据,可以省略不写,好比这里。所有接口实现类必须遵守这样的内存布局;
3、装配,将接口的实现类装配到现有的类上,以告诉编译器该类对于某个接口(这里为IFormatble)的实现,用的是第2步的实现类ImpIntIFormatble;
PPInterfaceOf(IFormatble, int, ImpIntIFormatble);
4、将实现类注册到类型信息的接口实现列表中,这一步可以省略,只是为了运行时的接口查询,相当于IUnknown的Query。这一行代码是在全局对象的构造函数中执行的,放在cpp源文件中
RegisterInterfaceImp<IFormatble, int>();
然后就可以开开心心地使用接口了,比如
int aa = 20;
TextWriter stream();
FormatInfo info();
TInterface<IFormatble> formatable(aa); //TInterface这个名字过难看,也没办法了
formatable->Format(stream, info);
double dd = 3.14;
formatable = TInterface<IFormatble>(dd); //假设double也实现IFormatble
formatable->Format(stream, info);
是否有点神奇呢?其实也没什么,不过就是在trait和内存布局上做文章,也就只是用了类型运算的伎俩。考察ImpIntIFormatble的内存布局,对于普遍的c++编译器来说,对象的虚函数表指针(如果存在的话),都放在对象的起始地址上,后面紧跟对象本身的成员数据字段,因此,ImpIntIFormatble的内存布局相当于,
struct ImpIntIFormatble
{
void* vtbl;
int* mThis;
};
注意,这里已经没有继承了。这就是,实现了IFormatble 接口的ImpIntIFormatble对象的内存表示。因此,可以想象,所有的接口实现类的内存布局都强制规定为以下形式:
struct InterfaceLayout
{
const void* mVtbl;
const void* mThis; //对象本身
const TypeInfo* mTypeInfo; //类型信息
const void* mParam; //补充参数,一般很少用到
};
当然,如果编译器的虚函数表指针不放在对象起始地址的话,就没法这么玩了,那么非侵入式接口也无从做起。然后,就是TInterface了,继承于InterfaceLayout
template<typename IT>
struct TInterface : public InterfaceLayout
{
typedef IT interface_type;
static_assert(is_abstract<IT>::value, "interface must have pure function");
static_assert(sizeof(IT) == sizeof(void*), "Can't have data");
public:
interface_type* operator->()const
{
interface_type* result = (interface_type*)(void*)this;
return result;
}
};
不管怎么说都好,TInterface对象的内存布局与接口实现类的内存布局一致。因此操作符->重载函数才可以粗暴的类型转换来顺利完成。然后构造TInterface对象的时候就是强制获取ImpIntIFormatble对象的虚函数表(也就是其起始地址的指针数据)指针赋值给InterfaceLayout的mVtbl,进而依次把实际对象的指针放在mThis上,获取到类型信息对象放在mTypeInfo中,如果有必要搭理mParam,也相应地赋值。
然后,就是template<typename Interface, typename Object>struct InterfaceOf各种特化的运用而已,就不值一提了。
由于c++的abi没有统一标准,并且,c++标准也没有规定编译器必须用虚函数表来实现多态,所以,这里的奇技淫巧并不能保证在所有平台上都能够成立,但是,非侵入式接口真是方便,已经是本座写c++代码的核心工具,一切都围绕着非侵入式接口来展开。
原本打算长篇大论,也只有草草收场。之后,本座就解放了,会暂时离开cppblog很久,计划中的内容,消息发送,虚模板函数,字符串,输入输出,格式化,序列化, locale,全局变量,模板表达式,组合子解析器,allocator,智能指针,程序运行时,抽象工厂访问者等模式的另类实现,以求从全新的角度上来表现C++的强大,也只能中断了。
如果说,类的设计思路,是以数据为基础的纵向组织结构,只有唯一的分类方式,有相同基类的,就意味着其相似性,共同点都体现在基类上;那么,接口就是以功能以性质从横向上,来看待类的相似性,并且存在无数的横向视角(否则就失去意义)。
静态面向对象语言,这里不考虑template,c++的template是鸭子类型,本质上,c++编译期就是一个功能完备的动态语言。代码上的复用就只能以基类为粒度来进行,比如,函数int fn(Base* bb),只有Base的子类,才有资格成为函数fn的会员。函数fn之所以声明其变量bb的类型为Base,就是为了使用类型Base里面的一些东西,一般就是成员函数(对于清教徒来说,不是一般,而是必然)。假如,函数fn的实现中,就用到Base的几个成员函数,比如说f1,f2,…,fn。换句话说,虽然fn(Base* bb)表面上要求一定要Base的子孙后代才能担当重任,但实际上,只要别的class,不必跟Base有半毛钱关系,只要这个class里面支持f1,f2,…,fn这些操作,那么原则上他就有资格到fn里面一游。天下唯有德者居之,不必讲究什么贵族。但是,在没有接口的等级森严的封建社会里面,就算你有惊天之地之能,就因为你没有某种高贵的血统,所以你就不行。
在单根类的王国中,所有对象都源于Object,也可以通过反射,通过函数名字运行时获取串f1,f2,fn等成员函数,然后再人肉编译器关于参数信息和返回值类型,以摆脱Base的类型桎梏,但是,估计也只有在最特殊的时候,才会这样玩。这样玩,简直置编译器的类型检查于不顾,静态语言就是要尽可能的挖掘编译器类型检查的最后一丝潜力。
接口的出现,就在纵向的类型关系上撕开一道道口子,从而尽最大限度释放对象的能力。时代不同了,现在接口IBase里面声明f1,f2,fn等函数,然后函数fn的入参为IBase,也即是 int fn(IBase* bb),以明确表示fn里面只用到IBase的函数,语义的要求上更加精准。然后,任何class,只要其实现了接口IBase,就有资格被fn接纳,不必再是Base之后了。所以说,要面向接口编程,就是要面向功能来搬砖,选择的样本空间就广阔了很多。接口是比具体类型要灵活,但不意味着所有的地方就必须只出现接口,class类型就没用了,当然不是,有些地方就很有必要用具体类型,比如说string类型,比如说复数这些,就必须明确规定具体类型,无须用到接口的灵活性。总之,还是那句话,没有银弹,具体问题具体分析。
使用对象,其实就是在使用对象的成员函数,那么,接口也可以看成是成员函数的粒度管理工具。所以,接口就表示了一批成员函数,需要用一批成员函数的时候,用接口最为方便。坊间有一些犯virtual恐惧症的c++猿猴,高高兴兴地用一批function代替接口,罔顾其性能(时间空间)的损失、使用上的不便,哎!面向对象是强有力的抽象工具,比之于面向过程,函数式,有着独特的优点,反正代码构架上,优先使用面向对象,绝不会错。而面向对象,就必然回避不了接口。
坊间支持面向对象语言中对接口的支持,当以rust,scala的trait机制最为令人喜欢,非侵入式啊,自然狗语言的也还好,但是,本人最反感,反正,狗语言上一切独有特性,本人都本能地毫无理由排斥。自然,java、C#或者c++的多继承,最为笨拙,呆板。
java、C#里面,类能够实现的接口,在类的定义中,就已经定下来了。类一旦定义完毕,与该类相关的接口就定下来,铁板一块,密不透风,不能增不能减也不能改。你明明看到一个类就已经实现了某个接口的所有方法(函数名字和签名一模一样),但就是因为该类没有在定义中明确说明实现该接口,所以编译器就死活不承认该类实现这个接口。只能用适配器模式,也即是新造一个class,实现该接口,包含旧类的对象,将接口的所有方法都委托给对象的相应函数来做。java的繁文缛节就是这样来的,规规矩矩,毕恭毕敬,一步一个脚印。更麻烦的是,每次传递参数都要new一个适配器对象来满足参数的要求,这是最让人难受的地方。
java、C#的这种接口机制,实在与现实对不上号,真是找不到任何原型,任何类型的物品,就算是新造的东西,我们都不可能一开始就穷尽它的所有性质所有功能。就算是药物,都有可能是歪打正着的功能,比如伟哥的功能,是其研发阶段中意想不到的。java、c#的这种接口,会很干扰类的完整最小化的设计原则,进而加大类的设计难度。当然,它也非一无是处,起码,类支持多少接口,一眼就看出来了,毫无疑义。问题是,接口这种东西,本质上就应该是不确定的横向视角来考察类的关系。java、C#下的接口问题,大大限制了接口的使用场合。
其次,继承时,子类就继承了基类的所有东西,包括其实现的接口。但是,有些时候,子类并不想拥有父类的某些接口。比如,鸭子应该算是鸟类的一个子类,而鸟类支持“会飞”这个接口,但是鸭子显然不会飞,也就是说,虽然鸭子包含了鸟类的所有数据,但是它不拥有会飞这个功能。对此,我们希望在编译期间,就能在要求会飞的场合下,传鸭子对象进去时,编译器报错。但是,对此,只能在运行中报错,而且,还是在调用会飞的成员函数里面才报错。原则上,编译器是可以知道鸭子不会飞这个概念的,但是,由于java、C#的接口控制粒度单一,满足不了这种要求。
再次,接口不能组合,比如说,函数fn的参数,假设名字为pp,pp要求同时实现接口IA,IB。对此,java、C#中是没有语法满足这种多个接口的要求。遇到这种需求时,只能用强制类型转换,先随便让参数类型为IA或者IB,然后在必要时,强制转换为另外的类型,只能在运行时报错。又或者是,新造一个接口IAB从IA,IB上继承,然后函数fn的参数pp的类型为IAB,但是这样,依然存在不足,假如某个类实现IA和IB,但是没有表明它实现IAB,那么还是不能满足参数的要求。接口组合的问题,不管是go、rust,都没有很好的支持,只能到运行时类型转换才能发生。
最重要的是,这种接口机制违反了零惩罚的机制。就以c++为例来说明,就只论接口好了,也即是只有虚函数但是没有成员字段的基类。为了方便描述,还是举例子。
struct IA {virtual void fa() = 0;};
struct IB {virtual void fb() = 0;};
struct Base{…};
struct Derived : public Base, public IA, public IB{…};
接口IA有虚函数,里面就要有一个指针指向其虚函数表,所以其内存占用就是一个指针的大小;同理,IB也如此。表面的意思是Derived实现了接口IA,IB,实际上,在C++中,接口实现就是继承,也就是说每个Derived的实例都要包含IA,IB里面的数据,指向对应虚函数表的指针字段,也即是有两个指针。这里做不到零惩罚的意思,是说, Derived为了表明自己有IA、IB的能力,每个对象付出了两个多余的内存指针空间的代价,即便是对象不需要在IA、IB的环境下使用,这个代价都避免不了。零惩罚抽象,就是要用到的时候才付出代价,哪怕这个代价可以大一点。用不到时,则不必消耗哪怕一点点空间时间上的浪费。空间上浪费的问题不在于节省内存,而在于丧失了精致的内存布局,进而影响到二进制的复用。这一点,非侵入式接口就不用也没办法在对象身上包含其所支持的所有接口的虚函数表指针,因为类型定义完毕,后面还可能在其上添加新的接口实现。
而由这几点问题引申出来的其他缺陷就不必提了。反正,C++,包括java,C#的这种接口机制最不讨人喜欢了。
至于狗语言的鸭子接口,有时会出现函数名字冲突的小问题,稍微改一下名字就好了。主要是这种接口机制只要一个类包含了某个接口的所有成员函数,就隐式认为它实现了这个接口。这里会有暗示(误导,诱惑),就是定义类的成员函数时,会有意或者无意地迁就现有接口的成员函数,同样,声明接口成员函数时,也会有意无意地往现有类的成员函数上靠。从而导致真正函数的语义上把控不够精准。并且,这种机制太过粗暴,万一这个类虽然支持某个接口的所有函数,但是并不一定就意味着它就要实现这个接口了。狗语言最令人反感之处就是各种自作聪明自以为是的规定。当然,由于狗语言的成员函数可以非侵入式,这个问题造成的不便一定程度上有所减轻,但是,说实在,就连非侵入式的成员函数,本座也不太喜欢了。另外,仅仅从语言层面上,不借助文档,很难知道一个类到底实现那些接口,某个接口被那些类实现,java、C#的接口在这一点的表现上就很卓越。其实,本座反感狗语言的最大原因还是因为狗粉,相比之下,java粉、php粉等粉,就可爱多了。
rust以trait形式实提供的接口机制就不多说了,语法形式上简洁漂亮,基本上梦寐以求的接口样子就是这样子的了。
以上语言的接口,全部属于静态接口,也即是类型所实现的接口在编译期间就全部定下来了,运行时就不再有任何变化。但是,如果对象一直在变化,好比生物,就说人类好了,有婴儿少年青年中年老年死亡这些变化阶段,显然每一阶段的行为能力都大不一样,也拥有不同头衔,不同身份。也就是说,现实中,活生生对象的接口集合并非一成不变,它完全可以现在就不支持某个接口,高兴时候又可以支持了,不高兴时就又不支持了,聋了就听不到声音,盲了就看不见,好似消息发送那样子,显然以上语言是不支持这种动态需求的接口的。
另外,com的接口查询虽然发生在运行时,但是,com的规范,比如对称性、传递性、时间无关性等规则,硬是把com从动态接口降维到静态接口,这也可以理解,因为动态接口的应用场景真的并不多。这些都没什么,com最根本的问题,还是在于接口要承载类的功能,当然,这样也有好处,比如语言的无关性。IUnknown的三大成员函数分明就是类的本职工作,AddRef,Release管理对象的生命周期,Query查询所要的接口。生命周期由对象粒度细化为接口粒度,就显得太琐碎,要谨记好几条规则,要小心翼翼地应付AddRef,Release的函数调用,智能指针也只能减轻部分工作量,这就是粒度过小带来的痛苦。而Query的本质就是对象所实现接口集合,这是对象的本分工作,现在搞成接口与接口之间的关系。由于接口越俎代庖,承接了类的职责,就要求每个接口都要继承IUnknown,本来接口之间就应该没什么关联性的才对,还导致com的实现以及使用,在c++下,非常繁复麻烦,令人头皮发麻。所以说,类与接口,一体两面,谁也不能代替谁。
---------------------------------------------------------------------------------------------------------------------------------
备注:现实世界中,一种或几种功能就能推导出来其他性质,对应到接口中,就是如果对象实现某些接口,就表示它能实现另外其他接口。目前的语言,也就是接口继承,子接口继承父接口,那么,如果一个类实现了子接口,就表示它也实现了父接口,语言明面上只支持这种接口的蕴含关系。对于其他的蕴含情况,只能用适配器来凑数,而在非侵入式接口中,其语言形式就显得更加的累赘,这一点,在java上尤为突出。其实,说到底,适配器模式只是弥补语言不支持接口蕴含机制的产物。
类的设计在于用恰到好处的信息来完整表达一个职责清晰的概念,恰到好处的意思是不多也不少,少了,就概念就不完整;多了,就显得冗余,累赘,当然特例下,允许少许的重复,但是,这里必须要有很好的理由。冗余往往就意味着包含了过多的信息,概念的表达不够精准,好比goto,指针,多继承这些货色,就是因为其过多的内涵,才要严格限制其使用。好像,more effective c++上说的,class的成员函数,应该是在完整的情况下保持最小化。但是,这里我们的出发点,是成员数据的完整最小化。
最小化的好处是可以保持概念最大的独立性,也意味着,可以用最小的代价来实现这个概念,也意味着对应用层的代码要求越少,非侵入式?好比c++11 noexcept取代throw(),好比从多继承中分化出来接口的概念,好比不考虑多继承虚继承的普通成员函数指针。又比如,如果不要求只读字符串以0结束,那么就可以把只读字符串的任何一部分都当成是只读字符串。类的对外功能固然重要,但是,类不能做的事情,也很重要。
首先是要有清晰的概念以及这个概念要支持的最基本运算,然后在此基础上组织数据,务求成员数据的最小化。当然,概念的产生,并非拍着脑袋想出来的,是因为代码里面出现太多那种相关数据的次数,所以就有必要把这些数据打包起来,抽象成一个概念。好比说,看到stl算法函数参数到处开始结束的迭代器,就有必要把开始结束放在一起。比如说,string_view的出现,这里假设其字符存储类型为char,string_view就是连续char内存块的意思,可以这样表示
struct string_view
{
const char* textBegin;
size_t length; //或者 const char* textEnd
};
这里的重点是,string_view里面的两个成员字段缺一不可,但是也不必再添加别的什么其他东西。然后,在这两个数据上展开实现一系列的成员函数,这里,成员函数和成员字段这两者,有一点点鸡生蛋生鸡的纠结,因为必要成员函数的集合(原始概念的细化),成员函数决定了成员字段的表示,而成员字段定下来之后,这反过来又能够验证成员函数的必要性。不管怎么说都好,成员函数的设计,也必须遵从最小完整化的原则。再具体一点,就是说但凡一个成员函数可以通过其他成员函数来实现,就意味着这个函数应该赶出类外,作为全局函数存在。当然,这也不是死板的教条,有些很特殊的函数,也可以是成员函数,因为成员函数的使用,确实很方便。
可能会有疑惑,感觉所有的成员函数其实都可以是全局函数。或者说,我们可以对每一个成员字段都搞一对set、get的函数,那么所有的其他成员函数就可以是全局函数的形式,很容易就可以遵守最小完整化的原则。当然,这是明显偷懒,拒绝思考的恶劣行为。与其这样,还不如就开放所有的成员字段,那样子就落入c语言的套路了。所以的法论是,一个函数,这里假设是全局函数,如果它的实现必须要访问到成员字段,不能通过调用该类的成员函数(一般不是get,set)来达到目的,或者,也可以强行用其他函数来完成任务,但是很麻烦,或者要付出时间空间上的代价,那么就意味着这个函数应该是该类的成员函数。
类的设计,就是必不可少的成员字段和必不可少的成员函数,它们一起,实现了对类的原始概念的完整表达,其他什么的,都不必理会。一个类如果不好写,往往意味着这个类的功能不专一,或者其概念不完整,这时,可以不要急着抽象,如果一个类有必要诞生,那么在代码的编写中,该类的抽象概念将一再重复出现,猿猴对它的理解也越来越清晰,从而,水到渠成地把它造出来。所有非需求推动,非代码推动的,拍着脑袋,想当然的造类行为,都是在臆造抽象,脱离实际生活的艺术,最终将被淘汰。
类的设计,其着眼点在于用必要的数据来完整表达一个清晰的概念。而继承,则是对类的概念进行细化,也就是分类,好比说生物下面开出来动物、植物这两个子类,就是把生物分成动物、植物这两类,继承与日常生活的分类不太一样,继承的分类方式是开放式,根据需要,随时可以添加新的子类别。整个类的体系,是一颗严格的单根树,任何类只能有一个根类。从任何类开始,只能有一条路径回溯到最开始的根类,java、C#中就是Object,所有的类都派生自Object,这是一棵大树。单根系下,万物皆是对象,这自然很方便,起码,这就从语言层面上直接支持c++ std的垃圾any了。而由于java、C#完善的反射信息,抛弃静态类型信息,也可以做动态语言层面上的事情,而c,c++的void*,所有的动态类型信息全部都在猿猴的大脑中。java平台上生存着大把的动态语言,而且,性能都还很不错。
相对很多语言来说,c++就是怪胎就是异数,自有其自身的设计哲学,它是多根系的,它不可能也没必要搞成单根系,当然,我们可以假设一个空类,然后所有的类都默认继承自这个空类。c++的所有类组成一个森林,森林里的树都长自大地。但是不管怎么说都好,只能允许单继承,千万不要有多继承,这是底线,千万千万不能违背(当然,奇技淫巧的场合,就不必遵守这个戒条,多继承千般不是,但是不可或缺,因为它可以玩出来很多花样,并且都很实用很必要)。最起码,单根系出来的内存布局直观可预测,一定程度上跨编译器,只有良好的内存布局,才有望良好的二进制复用。另外,父类对子类一无所知,不要引用到子类一丁点的信息,要保持这种信息的单向流动性。
在这种单根系的等级分明的阶级体系下,一切死气沉沉,没有一点点的社会活力。显然,只有同属于同一父类的类别之间,才能共享那么一丁点可怜的共性。如果没有接口捣乱,将是怎样的悲剧,最好的例子,mfc,真是厉害,没有用到接口,居然可以做出来严谨满足大多数需要的gui框架,没有接口,并不表示它不需要,因为mfc开了后门,用上了更厉害的玩意----消息发送,即便如此,mfc有些地方的基类还有依赖到子类,这就很让人无语了。
c++下,类的设计绝对不对儿戏,一定要清楚自己想要的是什么,抽象出来的概念才不会变成垃圾。大而全的类,远远不如几个小而专的细类。java,C#下的类开发很方便,但是粒度过大,把一揽子的东西都丢给你,强卖强买,反正只要类一定义,必然相应的就会出现一大坨完善的反射信息,而对象里面也包含了一些无关紧要的成员字段,而对象的访问,也全部都是间接引用的访问,虽然,现在计算机性能过剩,这些都无伤大雅。c++给了开发者最大的选择,而搞c++的猿猴,基本上都智力过剩,对于每种选择,都清楚其背后的代价以及所要到达的目的,所以虽然开发时候,存在心智包袱影响开发效率,但是,但内心就不会存在什么性能包袱的负罪感。就个人而言,还是喜欢c++这种最高自由度的语言,有时候,对于内存最细致的控制,可以得到更精简的设计,这里无关运行性能,好比说,在c++中,只要内存布局一致,即便是不同类型的对象,通过强制类型转换来统一对待,进而做匪夷所思之事,好比COM里面,为了聚合复用,一个类,竟然可以针对同一个接口提供两套实现方式。这种方便,在其他强类型语言中是不支持的。
某种意义上讲,c++在面向对象上提供的语言机制,就是为了方便地生成各种内存布局,以及此内存布局上所能支持的操作,虚函数用以生成一堆成员函数指针,继承则用以方便地生成一坨成员字段,……。所以,c++的面向对象就是面向内存布局地设计,而多继承、虚继承、模板这些鬼东西很容易就导致内存布局的失控,不过,如果使用得当,却又有鬼斧神工之奇效,创造出来其他语言所没有的奇迹。真的,论动态行为艺术,任何语言在c++这个大人面前都是幼儿园里的小学生。
为了引出接口,本座花大力气做科普。这也没办法,因为类虽然是基础,但是静态面向对象的精华,全部都在接口上。只有清晰明确类的功能职责,才能理解接口的必要性以及其多样性。那么,可不可以只有接口,没有类的。可以,就好像com那样子,而代价是,使用起来,各种不方便。这个世界,从来就不存在包治百病之万能药。什么事情都能做的意思就是什么都做不好。
此文只是杂乱的记录一点点对于面向对象的个人看法,有些观点也并非原创。没什么系统性可言,虽然笔者稍作整理,但始终还是显得很散乱,只是一些片段的堆积。
由于涉及的题目过于庞大,反而不知道如何下笔。先罗列一下问题,之间没有严格的先后之分,纯粹就是笔者想到哪里,就写到哪里。也不一定就会解答。继承的本质是什么?为什么一定要有接口?c++多继承为何饱受非议,真的就一无是处?为何笔者就反感go接口,反正go独有的一切,笔者都是下意识的排斥?功能繁杂的Com,结合C++的自身特点,能否改头换面? ……
在原教旨眼里,面向对象的教义就是“对象+消息发送”,整个程序由对象组成,而对象之间的就仅仅只通过发送消息响应消息来交互,程序的功能都是在对象与对象的来回消息发送中完成,用现实事情类比,人类就是一个个活生生的对象,人类通过消息的往来,比如语音、文字、广播等,有人制造新闻,有人接受到这些消息后,各自反应,最后完成一切社会活动。好像说得有点抽象,展开来说,其实就是,消息的发送者,原则上不需要事先了解目标对象的任何背景资料,甚至他明知道对方不鸟消息,比如说,明明对方就是一个乞丐,但是并不妨碍你向他借500万人民币,反正,消息就是这样发送出去的。然后,对象接受到消息之后,就各自反应,比如说有人真的借钱给你;有人哭穷;有人嘀咕你到处借钱,无耻;……,各式各样,不一而足。
听起来好像人类社会活动就是消息的往来下推动,艰难的前进,但是,这能拿来搬砖吗?可以的,真的可以!即便是C语言,都可以来搞消息发送这种高大上的事情,就好像win32那样子,通过SendMessage函数给窗口发送消息,其签名如下:
LRESULT SendMessage(HWND hWnd, UINT Msg, WPARAM wParam, LPARAM lParam);
好像参数有点多。说白了,消息发送就相当于成员函数函数调用的一个新马甲,换了一种说法而已。成员函数调用,形式是这样子,obj.fn(param1, param2, …),涉及到对象,函数名字,还有参数,可能参数数量不止一个,参数类型也各不一样,这些都没关系。hWnd为窗口,也即是对象;Msg为函数名称,现在用正整型编号来代表,有些消息发送系统用原子,qt好像是用字符串(性能堪忧啊);wParam,lParam可以看成void*类型,也即是函数的参数,用这两个值封装所有的参数。天真,天下函数参数类型成千上万,参数数目或0个、或1个、或三五个、或七八个,就wParam,lParam这两个弱鸡,就能封装得过来?可以的,通过强制类型转换,就可以让void*的值保存char、int、float等值,又或者是将参数打包为结构体,这样子,就可以应付千千万万的函数参数要求,这样子,不要说,有两个wParam,lParam来传递参数,就算是只有一个,也都可以应付千千万万的函数要求。
那么,如何响应消息?可以参考win32的原生api开发,这里就不展开了。原理就是,每个对象都有一个函数指针,那个函数把全部的成员函数都压缩在一个庞大的switch语句里面,每个消息编号case分支,就代表一个成员函数,显然,这个分支,要先将wParam,lParam里面在还原成对应参数的实际情况,然后再执行相应操作。
SendMessage显然抹去了所有窗口的具体类型信息,甭管你是按钮、漂亮按钮、菜单、编辑框、……,全部一律都退化成窗口对象。要往编辑框里面添加文字,就给它发送添加文字的消息,wParam,lParam就带着要添加的文本和长度。而不是调用编辑框的添加文字的成员函数来做这个事情,最明显的事情,就是也可以给按钮窗口也发送添加文本的消息,虽然按钮窗口对此消息的反应是啥也不做。令人惊讶的是,你可以子类化一个按钮窗口,让它对添加文本的消息做出反应,这完全是可以的。
显然,原教旨的面向对象教义,的而且确,灵活,解耦彻底,不同类型对象之间的耦合关系一律不复存在,之间只有消息的往来。随心所欲的发送消息(胡乱调用成员函数),自由自在的反应消息(一切全无契约可言),不理睬,或者这一刻不理睬下一刻又动了,或者这一刻动了下一刻又拒绝反应。甚至,消息还可以保存,排队,求反,叠加什么的,也即是消息已经是一种抽象数据类型了,支持多种运算。相比于不知所谓的基于类的静态面向对象(继承封装多态),简直不可同日而语,太多的约束,呆板的语法,深入的哲学思考,架床叠屋的类型关系,也好意思学人家叫面向对象。
当然,对象+消息发送这种机制,付出的代价也是很巨大的,基本上,函数调用的静态类型检查不服存在,所有问题都要到运行时才能发现。并且,消息发送的语法也很不直观,必须各种类型转换,而响应消息时又必须转换回去。此外,为函数定义消息编号,也很恶心。不过,这些在动态语言里面都不是问题,反正,动态语言里面没有静态类型约束。另外,笔者用template、全局变量、宏等奇技淫巧,在c++里面,已经实现了类型安全的消息发送框架,比如,Send(obj, kAppendText, U8String(“hello”)),而对象实现对消息的响应,直接也是成员函数的形式,并且还是非侵入式的,也即是说,在main函数之前,可以随时在任意地方给对象添加新的消息反射,所有参数上类型转换以及返回值上的类型转换,全部都不需要了。 但即便是这样,也不赞成原教旨的面向对象到处泛滥。原因是,用它写出来的程序,类型层次很不清晰,相比于架构良好的类形式的面向对象程序,可读性远远不如,也不好维护。更深刻的原因是,对象+消息发送的威力太惊人,用途太广,任何多态上的行为,都可以用它来做。什么都可以做,就意味着什么都尽量不要让他来做。
其实,即便java、C#这种继承封装多态的面向对象千般弱鸡各种繁文缛节,也不妨碍人家称霸天下,到处流行。你对象+消息发送再美妙,流行度都不及人家java一个零头,obj c还不是靠着ios的流行才有所起色,挤入排行榜十名内。虽然说市场不能说明什么,但是对比如此悬殊,自有其道理。
再说,静态类型的成员函数调用模式,广泛存在于人类社会活动中。人与人之间的很多事情,其实只要满足一定的条件,必然就会发生,其后果也可以预料。很多消息的发送,其实是有考虑到对方的身份问题,才会发起,好比小孩子跟爸妈要零用钱的消息,小孩子再发送要零用钱的消息,一定是针对亲人才发起的。真相是,往往要满足一些必要条件,消息才得以发起,当然,只要你高兴,随时都可以发起任何消息,问题是,这种人多半不正常。量体裁衣,针对什么样的问题,就应该采用相应的手段,一招鲜吃遍全天下,行不通的。具体问题,必须具体分析。每种问题,都有自己最独特有效的解法。笔者在原教旨的面向对象上重复太多内容,连自己都恶心,以后应该很少再提及。
所以说,面向对象的设计,首先应该采用的必然还是继承封装多态的思路。在此基础上,根据不同的动态要求,采用不同策略来应对。企图用万能的消息发送来代替静态类型面向对象的荒谬就如同用僵化的面向对象来模拟一切动态行为,两者都是犯了同样的毛病。可是,静态面向对象做设计,又确实困难重重,而最终的开发成果,总是让人难以满意。那是因为,广大劳动群众对静态面向对象一些基本概念的理解,存在这样那样的误区,而由于面向对象语言(java,C#)还缺乏一些必要机制,导致设计上出现妥协,原则性的错误越积越深,以至于最后崩盘。其实,不要说一般人,就连大人物,在面向对象上,也都只是探索,好比c++之父BS,搞出来多继承,虚继承,iostream体系,在错误的道路上,越走越远,越走越远。
好吧,其实,多继承,还是很有作用的,在很多奇技淫巧上很有用武之地,很方便。但是,用多继承做架构的危险,就在于其功能太过强大。这就意味着它要沦落成为goto啊、指针啊那样的角色,先甭管它钻石尴尬。多继承的最重要角色,概念实现,也即是接口,也即是定义一批虚函数,里面没有任何数据,这个抽象就必须鲜明,这一点,java和C#就做得很到位。就应该从多继承上提炼出来这么一个好东西,咦,对了,为何要有接口?没有接口,就真的不行吗?是的,静态面向对象里面,接口确实必不可少。
继承,本质上就是分类学。而分类,最重要一点,就是任何一件元素,必须也只能只属于其中一个类,不得含糊。可以存在多种分类方式,但是,一旦确定某种分类方式,那么集合里面的一个东西,就必须只能属于其中一大类。继承,就是分类的一再细化,也是概念的继续丰富。比如说,从生物到动物到哺乳动物,概念包含的数据越来越多。所以说,继承体现的是数据上的丰富关系,它强调的是数据的积累,从远古基类开始,一路积累下来的数据,全部必不可少,也不得重复,一旦违反这条底线,就意味着继承体系上的错乱。继承,相当于类型的硬件,缺乏硬件元器件时,就无法完整表达该类型的概念。比如说,人类可分为男人、女人,自然,男人有男人的阳刚,女人有女人的阴柔,那么阴阳同体怎么办,集两性之所长,难道就要阴阳人多继承与男人女人吗?那么,这样继承下来,阴阳人岂不是就是有两个头,四只手,四条腿了,啊,这不是阴阳人,这是超人,抑或是怪物。所以,阴阳人应该是人里面的一个分支,也即是,人的分类,就要有男人、女人、阴阳人这三大基类。再次强调,继承是为了继承数据,而不是为了功能,功能只不过是数据的附带品。那么,怎么描述男人的阳刚、女人的阴柔,怎么避免阴阳人引入后,分别从男人阳刚,女人阴柔上复制代码呢?此外,再次考虑平行四边形,下面好像又有菱形,有矩形两大类,然后身集菱形矩形的正方形,这里的分类该如何处理,难道忍不住要让正方形多继承菱形矩形吗?从这个意义上讲,在同一体系下,多继承的出现,理所当然,大错特错,由此可知,iostream就是败类。iostream通过虚继承避免绝世钻石的出现,但是这个虚继承啊,真是要让人呵呵。C++中引入虚继承真是,怎么说呢,好吧,也算脑洞大开的优良物品,也不是完全一无是处,起码,在iostream上就大派用场了。你就说说,虚继承那点不好了?就一点,为了子子类的千秋基业,子类必须虚继承基类,子类受子子类影响,就这一点,你能忍。
突然发现,文章已经很长了,不管了,这就打住。至于非侵入式接口,以后再说吧!
C++的面向对象设计能力,与java,C#这两个杂碎相比,一直都是一个大笑话,现在谁敢正儿八经地用c++搞面向对象的框架系统,业界都用java、C#搞设计模式,那关C++什么事情了。而C++也很有自知之明,很知趣,98年之后,就不怎么对外宣称自己是面向对象的语言,就不怎么搞面向对象研究了(难道是c++下的面向对象已经被研究透彻?),一直在吃template的老本,一直到现在,template这笔丰厚的遗产,貌似还够c++吃上几十年。今时今日,virtual早就沦落为template的附庸,除了帮助template搞点类型擦除的行为艺术之外,就很难再见到其身影了。有那么几年,业界反思c++的面向对象范式,批斗virtual,特别是function出现之后,要搞动态行为,就更加不关virtual的什么事情了。而那几年,本座也学着大神忌讳virtual关键字。现在大家似乎已经达成共识,c++里头的面向对象能力很不完善,要玩面向对象就应该找其他语言,比如java、C#杂碎;或者更动态类型的语言,好比python,Ruby;或者干脆就是原教旨的面向对象(消息发送),object C,smalltalk。
是啊,1、没有垃圾回收;2、没有原生支持的完善反射能力;3、多继承、虚继承导致的复杂内存布局。这三座大山面前,c++的码猿哪敢染指什么面向对象,只在迫不得已的情况下,小心翼翼地使用virtual。但是,事实上,要玩面向对象,c++原来也可以玩得很炫,甚至,可以说,关于面向对象的能力,c++是最强的(没有之一)。这怎么可能?
所谓的面向对象,说白了,就是对动态行为的信息支持,能在面向对象设计上独领风骚的语言,都是有着完善的运行时类型信息,就连lisp,其运行时元数据也都很完备。静态强类型语言(java、C#)与动态语言比,显然有着强大的静态类型能力(这不是废话吗),能在编译期就提前发现类型上的诸多错误,但是也因此带上静态约束,导致呆板、繁琐的代码,java的繁文缛节,就是最好证明;而动态语言恰好相反,代码简洁,废话少,但是丧失静态信息,所谓重构火葬场,那都是血和泪的教训。静态语言与动态语言真是一对冤家,如同光的波粒性,己之所长恰是彼之所短,己之所短又是彼之所长,鱼与熊掌不可兼得。而C++竟然能集两家之所长,在静态语言的领域中玩各种动态行为艺术,比如动态修改类型的反射信息,千奇百怪的花样作死(丧心病狂的类型转换);在动态范畴里面,又可以在编译期榨取出来静态类型信息,比如,消息发送的参数信息,想想win32的无类型的wparam和lparam,每次都要猿猴对照手册解码,从而最大限度地挖掘编译器的最大潜力。所以说,c++是最强大的面向对象语言,没有之一。而把静态和动态融为一体之后,c++的抽象能力也到达一个全新的高度,自动代码生成,以后再发挥,这是一个庞大的课题。C++令人发指的强大,绝对远远超乎等闲猿猴的想象,特别是那批c with class的草覆虫原始生物。C++只在部分函数领域的概念上表现令人不满,比如lambda表达式的参数类型自动推导,monad表达式,缺乏原生的延迟求值等。当然,c++整个的设计理念非常繁杂随心所欲,但是,却可以在这一块混沌里面整理出来一些举世无双的思想体系,就是说,c++是一大堆原材料,还有很多厨房用具,包括柴火,让猿猴自行下厨,做出来的菜肴可以很难吃,也可以是满汉全席,全看猿猴的手艺。
当然,要在c++里头搞面向对象,多继承,虚继承的那一套,必须彻底抛弃。最大的问题是,多继承会导致混乱未知的二进制内存布局,虚函数表也一塌糊涂,十几年前,c++设计新思维的基于policy的范式,虽然令人耳目一新,也因为这种范式下对象的内存布局千奇百怪,所以,即便是最轻微的流行也没有出现过。当然,也不可能大规模搞消息发送这种很geek的套路,功能太泛化了,其实,消息发送就是动态的给对象添加成员函数,并且可以在运行时知道对象有多少成员函数,那个成员函数可以对该消息做出反应,消息可以是字符串,整型ID(原子), MFC的消息映射表(BEGIN_MESSAGE_MAP,…)就是一个功能严重缩水版的好例子,c++下支持消息映射的库,绝对可以比破mfc的那一套要好上千百倍,不管是性能、类型安全、使用方便上。目前除了在gui这种变态的场合下才需要大搞消息发送,其他场景,完全可以说用不上,虽然说消息发送很强大很灵活,但也因为其杀伤力太厉害,反而要更加慎重。这好比goto,好比指针,好比stl的迭代器,什么都能做的意思,就是什么都尽量不让它做。
那么,c++下搞面向对象,还有什么法宝可用呢?当然,在此之前,我们先要直面内存分配。内存既是c++的安身立命之本,又是c++沦落为落水狗丧家犬之幕后大黑手。假如不能为所欲为的操作内存,那么c++的折腾法子,奇技淫巧,起码要死掉一大半以上。而由于要支持各种花样作死的内存操作,c++的垃圾回收迟迟未曾出现,就连以巨硬之大能整出来的.net那么好的gc,霸王硬上弓,在给原生c++强硬加上托管功能(垃圾回收),都出力不讨好。可见未来垃圾回收,对c++来说,嗯,想想就好了。内存是资源,没错,用raii来管理,也无可厚非。但是,内存却是一种很特殊的资源,1、内存时对象的安身立命之所;2、不同于普通资源,内存很多,不需要马上用完就急急忙忙启动清理工作,只要系统还有大把空余的内存,就算还有很多被浪费了的内存,都不要紧,gc也是因为这个原因才得以存在。相比内存,普通资源给人的感觉就是数量及其有限,然后要提交工作结果,否则之前所做努力就废了。所以,对于内存,应该也要特别对待。就算raii,也要采用专门的raii 。
假设我们的程序里面使用多种内存分配器,比如说,每个线程都有自己专有的内存allocator对象,然后,线程之间的共享数据由全局的内存分配器分配,线程的内部对象都用线程的专属allocator来分配,那么,内存分配器就是一个线程局部变量(tls,thread local storage)。于是,可以规定,所有的内存分配都通过GetTlsAllocator()来new对象,当然,确定是全局共享变量的话,没办法,就只能用GetGlobalAllocator()来new对象。那么,有理由相信,启动一个任务时,我们先定义一个arena allocator变量,并令其成为当前线程的专属内存分配器,那么这个任务后面的所有new 出来的对象,包括循环引用,都不必关心。只要任务一结束,这个arena allocator变量一释放,所有寄生在它身上的对象,全部也都消失得干干净净,没有任何一点点的内存泄露。就算任务内部有大量的内存泄露,那又如何,任务一结束,所有跟此任务有关的一切内存,全部成块清空。总之,不要以常规raii来解决内存困境,解放思想,在内存释放上,我们可以有九种办法让它死,而不是仅仅靠shared_ptr,unique_ptr,weak_ptr这些狭隘的思维。
其次,完善的面向对象设计,避免不了完备的反射,用以在运行时提供动态类型信息,无参模板函数可以把静态类型映射成全局唯一变量,好比,TypeOf<vector<int>>,返回vector<int>的全局唯一的const TypeInfo*对象,这个对象包含了vector<int>的所有静态类型信息,可以这么说,在静态类型层面上vector<int>所能做的任何事情,比如定义一个vector<int>的变量,也即是创建对象;遍历、添加元素、析构、复制赋值、元素数量等等一切操作,与vector<int>对应的TypeInfo对象,统统都可以做到。所不同的是,vector<int>的静态类型代码,只能用于vector<int>自身的情况(这样子可放在源文件中),又或者是通过template,表现行为类似于vector<int>的数据类型(代码必须在头文件上)。而用TypeInfo*做的事情,全部都在运行时发生,所有的静态类型信息,全部被带到运行时来做,所以这些代码全部都可以处在源文件里面,甚至动态库里头,只不过是TypeInfo*操作的对象是一个二进制内存布局和vector<int>一模一样的内存块,可以通过强制类型转换,把运行时的内存块转换成静态编译时的vector<int>。其实这里的思想,就是想方设法将丰富多彩的静态类型信息无损的保存到运行时中,让编译时能做的事情,运行时也可以做。差别在于,一个是用静态类型信息来做事情,这里,任何一点点类型上的错误,都会让编译器很不高兴;一个则是用动态类型信息来做事情,这里,显然只能让猿猴人肉编译器。这里,可见动态类型信息和静态类型信息的表达能力是等价的,也即是同等重要性的意义,而静态类型信息的意义有多大,相信大家都知道。
那么,如何建立完备的反射信息,这个必须只能用宏来配合完成,外部工具生成的反射信息代码,功能很不完备,另外,c#、java等的反射信息全部都是编译器生成的,可定制性很差。我们需要的是一点都不逊色于静态行为的动态行为。所以,只有由自己自行管理反射,才能做到真正意义上的完备反射。必要时,我们还可以在运行时修改反射信息,从而动态地增删对象的行为方式,改变对象的面貌。看到这里,是否觉得很多的设计模式,在这里会有更清晰更简洁的表达方式呢,甚至,轻而易举就可以出现新的设计模式。比如,以下定义对象反射信息的代码。
在c++下,由于全局变量生命周期的随意性(构造函数调用顺序不确定,析构顺序也不确定),大家都很忌讳其使用,虽然全局变量功能很强大,很多时候都避免不了。但是,标准上还是规定了全局变量的顺序,所有的全局变量必须在main函数之前构造完成,其析构函数也只能在main函数结束后才调用。另外,函数的静态变量必须在其第一次访问之前构造完整。基于这两点,我们就可以在main函数之前构建全部的反射信息,流程是这样子,所有的类型的反射对象都是以函数内部的静态指针变量存在,他们都通过调用GetStaticAllocator()的内存分配器来创建,这样子,提供反射信息的函数,就避免了其内部TypeInfo对象的析构发生。最后,main结束后,由GetStaticAllocator()函数内的内存分配器的析构函数统一释放所有反射信息占用的内存。最后,附上一个例子
struct Student
{
//ClassCat表示为Student的基类,为空类,所以Student可以继承它,但是代码上又不需要明确继承它,非侵入式的基类。
//ClassCat提供二进制序列化操作,xml序列化,json序列化,数据库序列化等操作
PPInlineClassTI(ClassCat, Student, ti)
{
PPReflAField(ti, name);
PPReflAField(ti, age);
PPReflAField(ti, sex, { kAttrXmlIgnore }); //表示不参与xml的序列化操作
}
AString name;
int age;
bool sex;
};
struct Config : Student
{
PPInlineClassTI(Student, Config, ti)
{
PPReflAField(ti, map);
}
HashMap<U8String, int> map;
};
下期的主角是非侵入式接口,彻底替换c++上的多继承,功能远远好过C#、java杂碎的弱鸡接口,更超越狗语言的不知所谓的非侵入式接口。如果仅仅是完备的反射信息,而缺乏非侵入式接口,在c++下搞面向对象,其实还是很痛苦的。但是,有了非侵入式接口之后,一切豁然开朗。甚至可以说,感觉c++里面搞那么多玩意,都不过是为了给非侵入式接口造势。然而非侵入式接口一直未曾正式诞生过。
古龙说过,一个人的最大优点往往将是其致命的弱点。这句话用在stl的迭代器上,最是合适不过。stl通过迭代器来解耦容器与算法,可谓击节赞叹;但是,让迭代器满世界的到处乱跑,未免就大煞风景。此话怎讲?
其实,有些语言就没有迭代器的概念,并且还活得很优雅,好比haskell的list啊、tree啊,压根就不需要什么迭代器,只需要模式匹配,体现其数据结构的递归特点,就可以很优雅地表达算法。就是java、c#、C++这几个破面向对象语言,才需要大用特用迭代器,没有迭代器就活不下去了。迭代器的出现就是为了弥补其语言丧失清晰表达递归数据结构的能力。看到haskell的list到c++的stl下的对应样子,很多人都表示很难过,因为stl里面,list根本就没有tail函数,更逞论支持list的tail还是一个list这样绝妙的idea。一切必须通过迭代器这个万金油来糊弄其尴尬的困境。
随便来看看几行stl算法函数的代码
Vector<int> nums = {..};
find(nums.begin(), nums.end(), 2);
remove_if(nums.begin(), nums.end(), _1 >= 0); //为了省事,用了bll的风格,在c++11中,要从零开始造一个bll风格的轮子,不能更方便,大概也就两三百行的代码
看到没有,你信不信,随便统计一下,一打的algorithm函数,起码就有12个函数的调用之道,必须传递container.begin(),container.end()。begin和end这对兄弟,总是成双成对的出现,说明了一件事情,就是从一开始,它们必须被打包在一起,而不应该硬生生地将它们拆开。知道这一拆开,带来多少问题吗?代码上的累赘还算是小事,比如,简洁清晰流畅的find(nums, 2),却要生硬的写成find(nums.begin(), nums.end(), 2)。当然,这种api设计,也并非一无是处,起码,在表达容器里面的部分区间时,很方便,好比下面的代码
int nums[10] = {…};
find(nums+1, end(nums)-1, 2);
看起来,好像的确挺方便的,将begin、end放在一起,要表达这样的概念,似乎就有些麻烦,但其实,这是假象,当角度变换时,我们可以会有更方便的方式来表达这样的需求。最起码,容器的部分区间也应该是由容器本身来表达,而不应转嫁给迭代器来应付,数组的部分也是数组,树的分支也是树,这样的概念,就应该由容器本身来定义。像是哈希表就不支持部分区间的概念。
为何algorithm的算法,全部(不是基本)都要求一对迭代器。那是因为这些算法的输入对象,本来就是一个数据集合。而一个迭代器无法完整地表达一个容器,起码必须一对迭代器才能完整地表达一个数据集。但是,用一对迭代器来作为入参,和用一个区间作为入参,它所体现抽象的侧重点完全不同,而由于此种不同,最后的演变结果,也是天渊之别,即是一对迭代器设计思路是渊,自然,而区间的设计方案,显然是天。
再次回顾上文的结尾,find,find_if,remove, remove_copy, remove_copy_if, remove_if,……,有没有感受,一股浓浓的过程式风格,十分的笨重,明显的非正交,浓烈的c语言风格。对于这样的api,让本座对委员会的那帮老不死,彻底的绝望了。他们(它们)的审美观,停留在很低很低的层次上。
将begin,end拆分开来的最大问题,其实也就只是,前一个函数的处理结果,不能平滑的传递到下一个函数里面去。比如说,现在函数make_nums返回vector<int>,试比较一下,高下立判。
auto nums = make_nums();
find(nums.begin(), nums.end(), 2); //一对迭代器作为入参
find(make_nums(), 2);//直接数据区间作为入参
说了这么多,我们强烈要求的仅仅是函数风格的api,正交式的函数设计,前一个函数的处理结果可以平滑地传递给下一个函数。总结algorithm的一坨函数,本质上只需filter,fold,map,insert(copy)这屈指可数的几个函数就可以自由地组合出来,并且还能组合出来algorithm上没有的效果。首先,这几个函数的返回结果都是数据区的数据对象(里面有begin和end的成员函数,用以返回迭代器)。其次,就是在迭代器上面做文章,以支持filter、map等操作,也就是在*、++、!=这几个运算符上做花样,要达到filter、map的效果,很容易的。至于像是要求随机访问迭代器概念的函数,太常用的就做到array_view里面好了,或者就明确规定入参就是array_view。
然后stl里面还臆造了一种好像叫做insert_iterator迭代器类型的适配器,用以通过迭代器的语法往容器里头插入数据,好像很玄妙,实则就是强行拔高迭代器的用途,完全就违背了迭代器出现的初衷。这种扭曲的想法,完全就是上面那一坨病态api的产物。所以,原本的api设计,算法函数必须以容器(数据区间)为入参,内部调用其begin和end成员函数获得迭代器来遍历容器的函数,何其清晰的设计思路。但是,stl的设计思路,导致迭代器泛滥,甚至连客户层面的代码也大把大把的迭代器,于是迭代器的问题就接二连三的产生,什么失效啊,什么first和last匹对错误。还有,导致容器里面的关于迭代器的成员函数多了一倍,哈希表里面也没有类似于C#里Dictionary的Keys和Values属性函数,这些用起来很方便的,不是吗?
stl的这种api设计思路完全不是以方便使用为主,而是以满足自己的独特口味为目的。看看find函数,它返回一个迭代器,所以,我们使用时,必须通过用end来判断要找的东西是否在区间里面,
auto found = find(nums.begin(), nums.end(), 2);
if (found != nums.end()){…}
依本座看,直接就返回指针好了,指针为nullptr,就表示元素找不到,代码变成这样
if (auto found = find(nums, 2)){…}
代码合并成一行,不用再和end比较了。更重要的是,返回结果就是指针,类型非常明确,可以平滑的传递到别的函数里;而不是迭代器类型,谁知道迭代器类型是什么类型。template这种东西的类型,能明确下来时,就尽快明确下来。至于说,有些区间的元素不支持返回地址,好比,vector<bool>,很简单,那就不支持好了。本座编写c++代码的原则之一,不求大而全,需求专一,绝不会因为个别同学,就牺牲大多数情况下清晰方便高效的api风格。对于这些异数,必要时,用奇技淫巧解决。你知道,因为多继承,虚继承,把成员函数指针这个简洁的概念搞得非常复杂,不能按正常人方式来使用了,严重影响成员函数的用范围,一直让本座耿耿于怀。其实,95%以上的情况下,我们就仅仅需要普通成员函数指针而已,另外的5%,也都可以用普通成员函数来封装。所以,为了弥补这个遗憾,本座做了一个精简版的delegate,只接受全局函数和普通成员函数,当字段object为空,就表示字段函数指针是全局函数,不为空,就表示函数指针是成员函数。至于其他一切奇奇怪怪的函数,本座的这个delegate就say no,明确拒绝。
stl的这种独特到处都是,boost更是将其发扬光大,反正设计出来的api,就是不考虑让你用的舒爽,二进制的布局,更加一塌糊涂。比如,any的使用,是这样子用的,cout << any_cast<int>(anyValue),这里还好,假如要分别针对any的实际类型来写代码,必须这样子:
if(anyValue.type() == typeid(int))
cout << any_cast<int>(anyValue);
else if (anyValue.type() == typeid(double))
cout << any_cast< double >(anyValue);
…
这种对类型安全无理取闹的强调,让人火冒三丈。要本座说,直接在any里面添加Cast模板成员函数,结果就返回指针好了,指针为空,就表示类型不匹配,代码就变成这样
if(auto value = anyValue.Cast<int>())
cout << *value;
else if(auto value = anyValue.Cast< double >())
cout << *value;
…
是否就没那么心烦呢。另外,鉴于stl对于反射的拒绝,采用virtual+template的类型拭擦大法来弥补,其实并不怎么完美。本座用反射重新实现的any,比stl的any好多了,不管是性能、编译速度、使用方便上,都是要好太多。还有,stl的any,要为每个用到的类型都要生成一个实实在在的多态具体类,每个类都要有一个专门的虚函数表对应,这些可都要写到二进制文件里面,代码就是这样膨胀起来的。总之,stl回避反射后,反射就以另一种形式回归,好比virtual+template,好比%d、%s,好比locale的那些facet实现, 这些动态机制各自为政,各种混乱。还不如干脆就从源头上系统化公理化地给予终极解决。
所以,总体上感受stl设计思路上存在的路线,就是太在意于c++语言本身上的特点,受语言自身的缺陷复杂影响太多,忽略了真正的需求,太多的臆造需求,强行让需求来迁就语言,而不是让语言来配合基础库的实际普遍需求,需求才是根本,为了可以最方便,最清晰,最性能的基础库,完全可以大规模地使用宏、挖掘语言里面最黑暗的边角料,甚至为了库的清晰性,可以拒绝那些用了复杂特性的数据结构,比如多继承,虚继承等无聊玩意。
概括起来,路线问题导致最终的正果,也即是stl的具体弱鸡表现就是,最根本是二进制接口使用上的重重阻碍,谁敢在动态库api使用stl的数据类型。其次是以下5小点:
1、内存分配器不应该是容器的模板参数,对allocator的处理太过草率,当初这里必须做深入的挖掘,c++完全可以实现一定程度上的垃圾回收功能,比如arean allocator,不必一一回收在arena allocator上分配的对象,只需一次性释放arena allocator的内存,达到多次分配,一次释放的高性能效果,还避免内存泄露,也不用直接面对循环引用的怪胎设计问题。现有的内存管理策略,把压力都放在智能指针上;
2、提供的通用容器不够完备;原本stl的数据结构就大可满足所有正常和非正常的使用场合,比如满足侵入式的链表需求,比如不管理元素生命周期的容器等;
3、过多的暴露迭代器,迭代器的应用范围过广,stl的算法函数用起来很不方便;
4、回避动态类型反射信息,对数据的输入输出支持非常单薄,包括字符串处理、文件读写、网络数据收发等,标准库上的现有那点小功能,仅仅是聊胜于无而已,难堪大任;
5、非容器系的实用类太少;
一句话,目前stl的使用,还是远远不够爽。原本用上stl的代码,应该可以更短、更快、更小。只可惜,stl在通过迭代器实现算法与容器的分离之后,就停步不前,其设计体系在别的地方,鲜有建树创新。战略高度过于局促,很多复杂难搞的问题,其实都蕴含着绝大的机遇,而stl都一一回避,真是回避得好!