huaxiazhihuo

 

2017年7月15日

回顾C++

本人对于c++的认识,多年下来,经历了以下几个阶段,

1、 c++很好很强大,盲目追求运行性能,简直巴普洛夫条件反射,贡献了一大坨垃圾代码;

2、 c++的面向对象对持很垃圾,什么鬼,代码很容易就耦合,于是迷上对象+消息发送的面向对象;

3、 c++太复杂了,template太抽象,天外飞仙,搞不懂,二进制复用又差。整个c++就是垃圾,简直程序设计语言里面的败类,C语言多好啊,小巧精致简单清晰;

4、 使用其他语言做开发,javaC#F#elispschemepythonhaskelljavascriptphp等等一大坨语言,感概每一种语言都比垃圾C++不要好太多,发誓不再用c++写哪怕一行的代码;

5、 某一天,突然有点理解了这种语言,一切变得清晰了,原来c++也相当不错,也可以做一些事情,看开之后,感觉开发效率也跟上来了,做同样的事情,用c++实现不会比C#python等慢。

相比于其他语言,c++的独特优势在于

预编译期的伪图灵完备,这一点,好多语言还是有的,并且更超级好,比如rustscheme

编译期间的C++是功能完备的解释器,其输出结果是正常运行的c++代码,结合宏,可以制造很多其他语言必须在语法层面上支持的语法糖。这个解释器的奇妙之处在于它运行于编译期,一旦错误的模板代码要进入运行期,就会出现编译错误,而不需要进入运行时的代码,即便天大错误,也都不要紧,而一旦这段代码要进入运行时,那么模板错误就逃不过编译期解释器的法眼。

生成各种内存布局的便利语法糖和自由的内存操控;不同类型的对象,只要其内存布局一致,通过强制转换,就可按同一类型来处理,这一点作死能力,绝不被有gc的语言支持。内存的无节操玩弄,结合template,分分钟就能仿真出来其他必须语言层面上提供的数据结构,类型安全、运行性能、易用性,一点都不逊色,好比string,委托,元组,列表,可空类型;

C++的专有特性,raii、多继承和全局变量。特别是全局变量,结合它的构造函数特点和类型推导,所能玩出来的丰富新花样,其他语言很难做到。全局变量是连接运行期和编译期的桥梁。如果没有全局变量,本座应该不会再次对c++产生热情。奇怪的是,至今为止,c++的基础库都不怎么挖掘全局变量的潜能。当然,对全局变量的使用,肯定是把它当做常量来用,全局变量有唯一的内存地址,就起到原子的作用,但它又可打包了丰富的静态类型信息。

以上的独特,造就了c++层出不穷的新意,而卓越的运行性能,只是其微不足道的优点。虽然说,语言不重要,思想才重要,软件架构才重要,但是c++所能承载的思想,以及其到达的抽象高度,的确就真的大大降低框架的复杂性,诚然,c++的基础库开发要面临无穷无尽的细节纠结,其实,这也反映了c++编译器掌控细节的能力,因此,我们又可以让编译器自动完成很多很多细节重复,从而大幅度地减轻代码数量,还无损其运行性能。又由于c++完备强大的静态类型特性,在用动态语言风格的简洁来编写代码的同时,又无损其快速方便地代码重构。笔者的基础库项目,几十次大规模的重构,借助单元测试,保证了重构顺利快速的完成,深感c++在重构上的便利,这些代码,包括不到1千行却功能完整的xml库(还支持对象与xml数据的直接互相转换);不到1千行却一点都不逊色于boostspirit组合子解释器(编译速度却快了很多,语法上简洁很多,更能方便地解释各种语法);才1千多行的异步io框架;输入输出,文件操作,数据库,协程等代码都简洁异常,所有这些代码都支持动态库上的二进制复用,让人很惊诧于c++的光怪陆离的强大。

当然,c++的缺陷也震撼人心,

1、 语言特性太过繁杂抽象微妙,比如template、多继承、运算符重载、类型转换、兼容性考虑的很多糟糕语言特性,所以对使用者的节制力要求很高,要求他们时刻清楚自己在干什么,琐碎上的思考太多;

2、 缺乏统一的二进制标准,基础库都用源代码的形式共享,这让原本就龟速的编译速度更加地令人大大感动;

3、 缺乏高标准的基础库,stlboost更在某些技术运用的展示上更起到很坏的影响;

4、 缺乏某些延迟求值的机制,缺乏必要的函数式语言机制,所以c++始终就无法成为堂堂正正的现代化高级语言!

就这样吧。

posted @ 2017-07-15 20:07 华夏之火 阅读(1179) | 评论 (2)编辑 收藏

C++的非侵入式接口

终于写到c++的非侵入式接口了,兴奋,开心,失望,解脱,…… 。在搞了这么多的面向对象科普之后,本人也已经开始不耐烦,至此,不想做太多阐述。

虽然,很早就清楚怎么在c++下搞非侵入式接口,但是,整个框架代码,重构了十几次之后,才终于满意。支持给基本类型添加接口,好比intcharconst char*double;支持泛型,好比vectorlist;支持继承,基类实现的接口,表示子类也继承了对该接口的实现,而且子类也可以拒绝基类的接口,好比鸭子拒绝基类鸟类“会飞”,编译时报错;支持接口组合;……,但是,这里仅仅简单介绍其原理,并不涉及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、将实现类注册到类型信息的接口实现列表中,这一步可以省略,只是为了运行时的接口查询,相当于IUnknownQuery。这一行代码是在全局对象的构造函数中执行的,放在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对象的虚函数表(也就是其起始地址的指针数据)指针赋值给InterfaceLayoutmVtbl,进而依次把实际对象的指针放在mThis上,获取到类型信息对象放在mTypeInfo中,如果有必要搭理mParam,也相应地赋值。

然后,就是template<typename Interface, typename Object>struct InterfaceOf各种特化的运用而已,就不值一提了。

由于c++abi没有统一标准,并且,c++标准也没有规定编译器必须用虚函数表来实现多态,所以,这里的奇技淫巧并不能保证在所有平台上都能够成立,但是,非侵入式接口真是方便,已经是本座写c++代码的核心工具,一切都围绕着非侵入式接口来展开。

原本打算长篇大论,也只有草草收场。之后,本座就解放了,会暂时离开cppblog很久,计划中的内容,消息发送,虚模板函数,字符串,输入输出,格式化,序列化, locale,全局变量,模板表达式,组合子解析器,allocator,智能指针,程序运行时,抽象工厂访问者等模式的另类实现,以求从全新的角度上来表现C++的强大,也只能中断了。








posted @ 2017-07-15 17:01 华夏之火 阅读(818) | 评论 (2)编辑 收藏

再论接口

如果说,类的设计思路,是以数据为基础的纵向组织结构,只有唯一的分类方式,有相同基类的,就意味着其相似性,共同点都体现在基类上;那么,接口就是以功能以性质从横向上,来看待类的相似性,并且存在无数的横向视角(否则就失去意义)。

静态面向对象语言,这里不考虑templatec++template是鸭子类型,本质上,c++编译期就是一个功能完备的动态语言。代码上的复用就只能以基类为粒度来进行,比如,函数int fn(Base* bb),只有Base的子类,才有资格成为函数fn的会员。函数fn之所以声明其变量bb的类型为Base,就是为了使用类型Base里面的一些东西,一般就是成员函数(对于清教徒来说,不是一般,而是必然)。假如,函数fn的实现中,就用到Base的几个成员函数,比如说f1f2fn。换句话说,虽然fn(Base* bb)表面上要求一定要Base的子孙后代才能担当重任,但实际上,只要别的class,不必跟Base有半毛钱关系,只要这个class里面支持f1f2fn这些操作,那么原则上他就有资格到fn里面一游。天下唯有德者居之,不必讲究什么贵族。但是,在没有接口的等级森严的封建社会里面,就算你有惊天之地之能,就因为你没有某种高贵的血统,所以你就不行。

在单根类的王国中,所有对象都源于Object,也可以通过反射,通过函数名字运行时获取串f1f2fn等成员函数,然后再人肉编译器关于参数信息和返回值类型,以摆脱Base的类型桎梏,但是,估计也只有在最特殊的时候,才会这样玩。这样玩,简直置编译器的类型检查于不顾,静态语言就是要尽可能的挖掘编译器类型检查的最后一丝潜力。

接口的出现,就在纵向的类型关系上撕开一道道口子,从而尽最大限度释放对象的能力。时代不同了,现在接口IBase里面声明f1f2fn等函数,然后函数fn的入参为IBase,也即是 int  fn(IBase* bb),以明确表示fn里面只用到IBase的函数,语义的要求上更加精准。然后,任何class,只要其实现了接口IBase,就有资格被fn接纳,不必再是Base之后了。所以说,要面向接口编程,就是要面向功能来搬砖,选择的样本空间就广阔了很多。接口是比具体类型要灵活,但不意味着所有的地方就必须只出现接口,class类型就没用了,当然不是,有些地方就很有必要用具体类型,比如说string类型,比如说复数这些,就必须明确规定具体类型,无须用到接口的灵活性。总之,还是那句话,没有银弹,具体问题具体分析。

使用对象,其实就是在使用对象的成员函数,那么,接口也可以看成是成员函数的粒度管理工具。所以,接口就表示了一批成员函数,需要用一批成员函数的时候,用接口最为方便。坊间有一些犯virtual恐惧症的c++猿猴,高高兴兴地用一批function代替接口,罔顾其性能(时间空间)的损失、使用上的不便,哎!面向对象是强有力的抽象工具,比之于面向过程,函数式,有着独特的优点,反正代码构架上,优先使用面向对象,绝不会错。而面向对象,就必然回避不了接口。

坊间支持面向对象语言中对接口的支持,当以rustscalatrait机制最为令人喜欢,非侵入式啊,自然狗语言的也还好,但是,本人最反感,反正,狗语言上一切独有特性,本人都本能地毫无理由排斥。自然,javaC#或者c++的多继承,最为笨拙,呆板。

javaC#里面,类能够实现的接口,在类的定义中,就已经定下来了。类一旦定义完毕,与该类相关的接口就定下来,铁板一块,密不透风,不能增不能减也不能改。你明明看到一个类就已经实现了某个接口的所有方法(函数名字和签名一模一样),但就是因为该类没有在定义中明确说明实现该接口,所以编译器就死活不承认该类实现这个接口。只能用适配器模式,也即是新造一个class,实现该接口,包含旧类的对象,将接口的所有方法都委托给对象的相应函数来做。java的繁文缛节就是这样来的,规规矩矩,毕恭毕敬,一步一个脚印。更麻烦的是,每次传递参数都要new一个适配器对象来满足参数的要求,这是最让人难受的地方。

javaC#的这种接口机制,实在与现实对不上号,真是找不到任何原型,任何类型的物品,就算是新造的东西,我们都不可能一开始就穷尽它的所有性质所有功能。就算是药物,都有可能是歪打正着的功能,比如伟哥的功能,是其研发阶段中意想不到的。javac#的这种接口,会很干扰类的完整最小化的设计原则,进而加大类的设计难度。当然,它也非一无是处,起码,类支持多少接口,一眼就看出来了,毫无疑义。问题是,接口这种东西,本质上就应该是不确定的横向视角来考察类的关系。javaC#下的接口问题,大大限制了接口的使用场合。

其次,继承时,子类就继承了基类的所有东西,包括其实现的接口。但是,有些时候,子类并不想拥有父类的某些接口。比如,鸭子应该算是鸟类的一个子类,而鸟类支持“会飞”这个接口,但是鸭子显然不会飞,也就是说,虽然鸭子包含了鸟类的所有数据,但是它不拥有会飞这个功能。对此,我们希望在编译期间,就能在要求会飞的场合下,传鸭子对象进去时,编译器报错。但是,对此,只能在运行中报错,而且,还是在调用会飞的成员函数里面才报错。原则上,编译器是可以知道鸭子不会飞这个概念的,但是,由于javaC#的接口控制粒度单一,满足不了这种要求。

再次,接口不能组合,比如说,函数fn的参数,假设名字为pppp要求同时实现接口IAIB。对此,javaC#中是没有语法满足这种多个接口的要求。遇到这种需求时,只能用强制类型转换,先随便让参数类型为IA或者IB,然后在必要时,强制转换为另外的类型,只能在运行时报错。又或者是,新造一个接口IABIAIB上继承,然后函数fn的参数pp的类型为IAB,但是这样,依然存在不足,假如某个类实现IAIB,但是没有表明它实现IAB,那么还是不能满足参数的要求。接口组合的问题,不管是gorust,都没有很好的支持,只能到运行时类型转换才能发生。

最重要的是,这种接口机制违反了零惩罚的机制。就以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实现了接口IAIB,实际上,在C++中,接口实现就是继承,也就是说每个Derived的实例都要包含IAIB里面的数据,指向对应虚函数表的指针字段,也即是有两个指针。这里做不到零惩罚的意思,是说, Derived为了表明自己有IAIB的能力,每个对象付出了两个多余的内存指针空间的代价,即便是对象不需要在IAIB的环境下使用,这个代价都避免不了。零惩罚抽象,就是要用到的时候才付出代价,哪怕这个代价可以大一点。用不到时,则不必消耗哪怕一点点空间时间上的浪费。空间上浪费的问题不在于节省内存,而在于丧失了精致的内存布局,进而影响到二进制的复用。这一点,非侵入式接口就不用也没办法在对象身上包含其所支持的所有接口的虚函数表指针,因为类型定义完毕,后面还可能在其上添加新的接口实现。

而由这几点问题引申出来的其他缺陷就不必提了。反正,C++,包括javaC#的这种接口机制最不讨人喜欢了。

至于狗语言的鸭子接口,有时会出现函数名字冲突的小问题,稍微改一下名字就好了。主要是这种接口机制只要一个类包含了某个接口的所有成员函数,就隐式认为它实现了这个接口。这里会有暗示(误导,诱惑),就是定义类的成员函数时,会有意或者无意地迁就现有接口的成员函数,同样,声明接口成员函数时,也会有意无意地往现有类的成员函数上靠。从而导致真正函数的语义上把控不够精准。并且,这种机制太过粗暴,万一这个类虽然支持某个接口的所有函数,但是并不一定就意味着它就要实现这个接口了。狗语言最令人反感之处就是各种自作聪明自以为是的规定。当然,由于狗语言的成员函数可以非侵入式,这个问题造成的不便一定程度上有所减轻,但是,说实在,就连非侵入式的成员函数,本座也不太喜欢了。另外,仅仅从语言层面上,不借助文档,很难知道一个类到底实现那些接口,某个接口被那些类实现,javaC#的接口在这一点的表现上就很卓越。其实,本座反感狗语言的最大原因还是因为狗粉,相比之下,java粉、php粉等粉,就可爱多了。

rusttrait形式实提供的接口机制就不多说了,语法形式上简洁漂亮,基本上梦寐以求的接口样子就是这样子的了。

以上语言的接口,全部属于静态接口,也即是类型所实现的接口在编译期间就全部定下来了,运行时就不再有任何变化。但是,如果对象一直在变化,好比生物,就说人类好了,有婴儿少年青年中年老年死亡这些变化阶段,显然每一阶段的行为能力都大不一样,也拥有不同头衔,不同身份。也就是说,现实中,活生生对象的接口集合并非一成不变,它完全可以现在就不支持某个接口,高兴时候又可以支持了,不高兴时就又不支持了,聋了就听不到声音,盲了就看不见,好似消息发送那样子,显然以上语言是不支持这种动态需求的接口的。

另外,com的接口查询虽然发生在运行时,但是,com的规范,比如对称性、传递性、时间无关性等规则,硬是把com从动态接口降维到静态接口,这也可以理解,因为动态接口的应用场景真的并不多。这些都没什么,com最根本的问题,还是在于接口要承载类的功能,当然,这样也有好处,比如语言的无关性。IUnknown的三大成员函数分明就是类的本职工作,AddRefRelease管理对象的生命周期,Query查询所要的接口。生命周期由对象粒度细化为接口粒度,就显得太琐碎,要谨记好几条规则,要小心翼翼地应付AddRefRelease的函数调用,智能指针也只能减轻部分工作量,这就是粒度过小带来的痛苦。而Query的本质就是对象所实现接口集合,这是对象的本分工作,现在搞成接口与接口之间的关系。由于接口越俎代庖,承接了类的职责,就要求每个接口都要继承IUnknown,本来接口之间就应该没什么关联性的才对,还导致com的实现以及使用,在c++下,非常繁复麻烦,令人头皮发麻。所以说,类与接口,一体两面,谁也不能代替谁。

---------------------------------------------------------------------------------------------------------------------------------

备注:现实世界中,一种或几种功能就能推导出来其他性质,对应到接口中,就是如果对象实现某些接口,就表示它能实现另外其他接口。目前的语言,也就是接口继承,子接口继承父接口,那么,如果一个类实现了子接口,就表示它也实现了父接口,语言明面上只支持这种接口的蕴含关系。对于其他的蕴含情况,只能用适配器来凑数,而在非侵入式接口中,其语言形式就显得更加的累赘,这一点,在java上尤为突出。其实,说到底,适配器模式只是弥补语言不支持接口蕴含机制的产物。


posted @ 2017-07-15 11:42 华夏之火 阅读(404) | 评论 (0)编辑 收藏

2017年7月14日

c++面向对象的类设计

类的设计在于用恰到好处的信息来完整表达一个职责清晰的概念,恰到好处的意思是不多也不少,少了,就概念就不完整;多了,就显得冗余,累赘,当然特例下,允许少许的重复,但是,这里必须要有很好的理由。冗余往往就意味着包含了过多的信息,概念的表达不够精准,好比goto,指针,多继承这些货色,就是因为其过多的内涵,才要严格限制其使用。好像,more effective c++上说的,class的成员函数,应该是在完整的情况下保持最小化。但是,这里我们的出发点,是成员数据的完整最小化。

最小化的好处是可以保持概念最大的独立性,也意味着,可以用最小的代价来实现这个概念,也意味着对应用层的代码要求越少,非侵入式?好比c++11 noexcept取代throw(),好比从多继承中分化出来接口的概念,好比不考虑多继承虚继承的普通成员函数指针。又比如,如果不要求只读字符串以0结束,那么就可以把只读字符串的任何一部分都当成是只读字符串。类的对外功能固然重要,但是,类不能做的事情,也很重要。

首先是要有清晰的概念以及这个概念要支持的最基本运算,然后在此基础上组织数据,务求成员数据的最小化。当然,概念的产生,并非拍着脑袋想出来的,是因为代码里面出现太多那种相关数据的次数,所以就有必要把这些数据打包起来,抽象成一个概念。好比说,看到stl算法函数参数到处开始结束的迭代器,就有必要把开始结束放在一起。比如说,string_view的出现,这里假设其字符存储类型为charstring_view就是连续char内存块的意思,可以这样表示

struct string_view

{

         const char* textBegin;

         size_t length; //或者 const char* textEnd

};

这里的重点是,string_view里面的两个成员字段缺一不可,但是也不必再添加别的什么其他东西。然后,在这两个数据上展开实现一系列的成员函数,这里,成员函数和成员字段这两者,有一点点鸡生蛋生鸡的纠结,因为必要成员函数的集合(原始概念的细化),成员函数决定了成员字段的表示,而成员字段定下来之后,这反过来又能够验证成员函数的必要性。不管怎么说都好,成员函数的设计,也必须遵从最小完整化的原则。再具体一点,就是说但凡一个成员函数可以通过其他成员函数来实现,就意味着这个函数应该赶出类外,作为全局函数存在。当然,这也不是死板的教条,有些很特殊的函数,也可以是成员函数,因为成员函数的使用,确实很方便。

可能会有疑惑,感觉所有的成员函数其实都可以是全局函数。或者说,我们可以对每一个成员字段都搞一对setget的函数,那么所有的其他成员函数就可以是全局函数的形式,很容易就可以遵守最小完整化的原则。当然,这是明显偷懒,拒绝思考的恶劣行为。与其这样,还不如就开放所有的成员字段,那样子就落入c语言的套路了。所以的法论是,一个函数,这里假设是全局函数,如果它的实现必须要访问到成员字段,不能通过调用该类的成员函数(一般不是getset)来达到目的,或者,也可以强行用其他函数来完成任务,但是很麻烦,或者要付出时间空间上的代价,那么就意味着这个函数应该是该类的成员函数。

类的设计,就是必不可少的成员字段和必不可少的成员函数,它们一起,实现了对类的原始概念的完整表达,其他什么的,都不必理会。一个类如果不好写,往往意味着这个类的功能不专一,或者其概念不完整,这时,可以不要急着抽象,如果一个类有必要诞生,那么在代码的编写中,该类的抽象概念将一再重复出现,猿猴对它的理解也越来越清晰,从而,水到渠成地把它造出来。所有非需求推动,非代码推动的,拍着脑袋,想当然的造类行为,都是在臆造抽象,脱离实际生活的艺术,最终将被淘汰。

类的设计,其着眼点在于用必要的数据来完整表达一个清晰的概念。而继承,则是对类的概念进行细化,也就是分类,好比说生物下面开出来动物、植物这两个子类,就是把生物分成动物、植物这两类,继承与日常生活的分类不太一样,继承的分类方式是开放式,根据需要,随时可以添加新的子类别。整个类的体系,是一颗严格的单根树,任何类只能有一个根类。从任何类开始,只能有一条路径回溯到最开始的根类,javaC#中就是Object,所有的类都派生自Object,这是一棵大树。单根系下,万物皆是对象,这自然很方便,起码,这就从语言层面上直接支持c++ std的垃圾any了。而由于javaC#完善的反射信息,抛弃静态类型信息,也可以做动态语言层面上的事情,而cc++void*,所有的动态类型信息全部都在猿猴的大脑中。java平台上生存着大把的动态语言,而且,性能都还很不错。

相对很多语言来说,c++就是怪胎就是异数,自有其自身的设计哲学,它是多根系的,它不可能也没必要搞成单根系,当然,我们可以假设一个空类,然后所有的类都默认继承自这个空类。c++的所有类组成一个森林,森林里的树都长自大地。但是不管怎么说都好,只能允许单继承,千万不要有多继承,这是底线,千万千万不能违背(当然,奇技淫巧的场合,就不必遵守这个戒条,多继承千般不是,但是不可或缺,因为它可以玩出来很多花样,并且都很实用很必要)。最起码,单根系出来的内存布局直观可预测,一定程度上跨编译器,只有良好的内存布局,才有望良好的二进制复用。另外,父类对子类一无所知,不要引用到子类一丁点的信息,要保持这种信息的单向流动性。

在这种单根系的等级分明的阶级体系下,一切死气沉沉,没有一点点的社会活力。显然,只有同属于同一父类的类别之间,才能共享那么一丁点可怜的共性。如果没有接口捣乱,将是怎样的悲剧,最好的例子,mfc,真是厉害,没有用到接口,居然可以做出来严谨满足大多数需要的gui框架,没有接口,并不表示它不需要,因为mfc开了后门,用上了更厉害的玩意----消息发送,即便如此,mfc有些地方的基类还有依赖到子类,这就很让人无语了。

c++下,类的设计绝对不对儿戏,一定要清楚自己想要的是什么,抽象出来的概念才不会变成垃圾。大而全的类,远远不如几个小而专的细类。javaC#下的类开发很方便,但是粒度过大,把一揽子的东西都丢给你,强卖强买,反正只要类一定义,必然相应的就会出现一大坨完善的反射信息,而对象里面也包含了一些无关紧要的成员字段,而对象的访问,也全部都是间接引用的访问,虽然,现在计算机性能过剩,这些都无伤大雅。c++给了开发者最大的选择,而搞c++的猿猴,基本上都智力过剩,对于每种选择,都清楚其背后的代价以及所要到达的目的,所以虽然开发时候,存在心智包袱影响开发效率,但是,但内心就不会存在什么性能包袱的负罪感。就个人而言,还是喜欢c++这种最高自由度的语言,有时候,对于内存最细致的控制,可以得到更精简的设计,这里无关运行性能,好比说,在c++中,只要内存布局一致,即便是不同类型的对象,通过强制类型转换来统一对待,进而做匪夷所思之事,好比COM里面,为了聚合复用,一个类,竟然可以针对同一个接口提供两套实现方式。这种方便,在其他强类型语言中是不支持的。

某种意义上讲,c++在面向对象上提供的语言机制,就是为了方便地生成各种内存布局,以及此内存布局上所能支持的操作,虚函数用以生成一堆成员函数指针,继承则用以方便地生成一坨成员字段,……。所以,c++的面向对象就是面向内存布局地设计,而多继承、虚继承、模板这些鬼东西很容易就导致内存布局的失控,不过,如果使用得当,却又有鬼斧神工之奇效,创造出来其他语言所没有的奇迹。真的,论动态行为艺术,任何语言在c++这个大人面前都是幼儿园里的小学生。

为了引出接口,本座花大力气做科普。这也没办法,因为类虽然是基础,但是静态面向对象的精华,全部都在接口上。只有清晰明确类的功能职责,才能理解接口的必要性以及其多样性。那么,可不可以只有接口,没有类的。可以,就好像com那样子,而代价是,使用起来,各种不方便。这个世界,从来就不存在包治百病之万能药。什么事情都能做的意思就是什么都做不好。

posted @ 2017-07-14 11:48 华夏之火 阅读(510) | 评论 (0)编辑 收藏

2017年7月12日

c++的面向对象之前传

此文只是杂乱的记录一点点对于面向对象的个人看法,有些观点也并非原创。没什么系统性可言,虽然笔者稍作整理,但始终还是显得很散乱,只是一些片段的堆积。

由于涉及的题目过于庞大,反而不知道如何下笔。先罗列一下问题,之间没有严格的先后之分,纯粹就是笔者想到哪里,就写到哪里。也不一定就会解答。继承的本质是什么?为什么一定要有接口?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好像是用字符串(性能堪忧啊);wParamlParam可以看成void*类型,也即是函数的参数,用这两个值封装所有的参数。天真,天下函数参数类型成千上万,参数数目或0个、或1个、或三五个、或七八个,就wParamlParam这两个弱鸡,就能封装得过来?可以的,通过强制类型转换,就可以让void*的值保存charintfloat等值,又或者是将参数打包为结构体,这样子,就可以应付千千万万的函数参数要求,这样子,不要说,有两个wParamlParam来传递参数,就算是只有一个,也都可以应付千千万万的函数要求。

那么,如何响应消息?可以参考win32的原生api开发,这里就不展开了。原理就是,每个对象都有一个函数指针,那个函数把全部的成员函数都压缩在一个庞大的switch语句里面,每个消息编号case分支,就代表一个成员函数,显然,这个分支,要先将wParamlParam里面在还原成对应参数的实际情况,然后再执行相应操作。

SendMessage显然抹去了所有窗口的具体类型信息,甭管你是按钮、漂亮按钮、菜单、编辑框、……,全部一律都退化成窗口对象。要往编辑框里面添加文字,就给它发送添加文字的消息,wParamlParam就带着要添加的文本和长度。而不是调用编辑框的添加文字的成员函数来做这个事情,最明显的事情,就是也可以给按钮窗口也发送添加文本的消息,虽然按钮窗口对此消息的反应是啥也不做。令人惊讶的是,你可以子类化一个按钮窗口,让它对添加文本的消息做出反应,这完全是可以的。

显然,原教旨的面向对象教义,的而且确,灵活,解耦彻底,不同类型对象之间的耦合关系一律不复存在,之间只有消息的往来。随心所欲的发送消息(胡乱调用成员函数),自由自在的反应消息(一切全无契约可言),不理睬,或者这一刻不理睬下一刻又动了,或者这一刻动了下一刻又拒绝反应。甚至,消息还可以保存,排队,求反,叠加什么的,也即是消息已经是一种抽象数据类型了,支持多种运算。相比于不知所谓的基于类的静态面向对象(继承封装多态),简直不可同日而语,太多的约束,呆板的语法,深入的哲学思考,架床叠屋的类型关系,也好意思学人家叫面向对象。

当然,对象+消息发送这种机制,付出的代价也是很巨大的,基本上,函数调用的静态类型检查不服存在,所有问题都要到运行时才能发现。并且,消息发送的语法也很不直观,必须各种类型转换,而响应消息时又必须转换回去。此外,为函数定义消息编号,也很恶心。不过,这些在动态语言里面都不是问题,反正,动态语言里面没有静态类型约束。另外,笔者用template、全局变量、宏等奇技淫巧,在c++里面,已经实现了类型安全的消息发送框架,比如,Send(obj, kAppendText, U8String(“hello”)),而对象实现对消息的响应,直接也是成员函数的形式,并且还是非侵入式的,也即是说,在main函数之前,可以随时在任意地方给对象添加新的消息反射,所有参数上类型转换以及返回值上的类型转换,全部都不需要了。 但即便是这样,也不赞成原教旨的面向对象到处泛滥。原因是,用它写出来的程序,类型层次很不清晰,相比于架构良好的类形式的面向对象程序,可读性远远不如,也不好维护。更深刻的原因是,对象+消息发送的威力太惊人,用途太广,任何多态上的行为,都可以用它来做。什么都可以做,就意味着什么都尽量不要让他来做。

其实,即便javaC#这种继承封装多态的面向对象千般弱鸡各种繁文缛节,也不妨碍人家称霸天下,到处流行。你对象+消息发送再美妙,流行度都不及人家java一个零头,obj c还不是靠着ios的流行才有所起色,挤入排行榜十名内。虽然说市场不能说明什么,但是对比如此悬殊,自有其道理。

再说,静态类型的成员函数调用模式,广泛存在于人类社会活动中。人与人之间的很多事情,其实只要满足一定的条件,必然就会发生,其后果也可以预料。很多消息的发送,其实是有考虑到对方的身份问题,才会发起,好比小孩子跟爸妈要零用钱的消息,小孩子再发送要零用钱的消息,一定是针对亲人才发起的。真相是,往往要满足一些必要条件,消息才得以发起,当然,只要你高兴,随时都可以发起任何消息,问题是,这种人多半不正常。量体裁衣,针对什么样的问题,就应该采用相应的手段,一招鲜吃遍全天下,行不通的。具体问题,必须具体分析。每种问题,都有自己最独特有效的解法。笔者在原教旨的面向对象上重复太多内容,连自己都恶心,以后应该很少再提及。

所以说,面向对象的设计,首先应该采用的必然还是继承封装多态的思路。在此基础上,根据不同的动态要求,采用不同策略来应对。企图用万能的消息发送来代替静态类型面向对象的荒谬就如同用僵化的面向对象来模拟一切动态行为,两者都是犯了同样的毛病。可是,静态面向对象做设计,又确实困难重重,而最终的开发成果,总是让人难以满意。那是因为,广大劳动群众对静态面向对象一些基本概念的理解,存在这样那样的误区,而由于面向对象语言(javaC#)还缺乏一些必要机制,导致设计上出现妥协,原则性的错误越积越深,以至于最后崩盘。其实,不要说一般人,就连大人物,在面向对象上,也都只是探索,好比c++之父BS,搞出来多继承,虚继承,iostream体系,在错误的道路上,越走越远,越走越远。

好吧,其实,多继承,还是很有作用的,在很多奇技淫巧上很有用武之地,很方便。但是,用多继承做架构的危险,就在于其功能太过强大。这就意味着它要沦落成为goto啊、指针啊那样的角色,先甭管它钻石尴尬。多继承的最重要角色,概念实现,也即是接口,也即是定义一批虚函数,里面没有任何数据,这个抽象就必须鲜明,这一点,javaC#就做得很到位。就应该从多继承上提炼出来这么一个好东西,咦,对了,为何要有接口?没有接口,就真的不行吗?是的,静态面向对象里面,接口确实必不可少。

继承,本质上就是分类学。而分类,最重要一点,就是任何一件元素,必须也只能只属于其中一个类,不得含糊。可以存在多种分类方式,但是,一旦确定某种分类方式,那么集合里面的一个东西,就必须只能属于其中一大类。继承,就是分类的一再细化,也是概念的继续丰富。比如说,从生物到动物到哺乳动物,概念包含的数据越来越多。所以说,继承体现的是数据上的丰富关系,它强调的是数据的积累,从远古基类开始,一路积累下来的数据,全部必不可少,也不得重复,一旦违反这条底线,就意味着继承体系上的错乱。继承,相当于类型的硬件,缺乏硬件元器件时,就无法完整表达该类型的概念。比如说,人类可分为男人、女人,自然,男人有男人的阳刚,女人有女人的阴柔,那么阴阳同体怎么办,集两性之所长,难道就要阴阳人多继承与男人女人吗?那么,这样继承下来,阴阳人岂不是就是有两个头,四只手,四条腿了,啊,这不是阴阳人,这是超人,抑或是怪物。所以,阴阳人应该是人里面的一个分支,也即是,人的分类,就要有男人、女人、阴阳人这三大基类。再次强调,继承是为了继承数据,而不是为了功能,功能只不过是数据的附带品。那么,怎么描述男人的阳刚、女人的阴柔,怎么避免阴阳人引入后,分别从男人阳刚,女人阴柔上复制代码呢?此外,再次考虑平行四边形,下面好像又有菱形,有矩形两大类,然后身集菱形矩形的正方形,这里的分类该如何处理,难道忍不住要让正方形多继承菱形矩形吗?从这个意义上讲,在同一体系下,多继承的出现,理所当然,大错特错,由此可知,iostream就是败类。iostream通过虚继承避免绝世钻石的出现,但是这个虚继承啊,真是要让人呵呵。C++中引入虚继承真是,怎么说呢,好吧,也算脑洞大开的优良物品,也不是完全一无是处,起码,在iostream上就大派用场了。你就说说,虚继承那点不好了?就一点,为了子子类的千秋基业,子类必须虚继承基类,子类受子子类影响,就这一点,你能忍。

 

突然发现,文章已经很长了,不管了,这就打住。至于非侵入式接口,以后再说吧!

posted @ 2017-07-12 18:17 华夏之火 阅读(413) | 评论 (1)编辑 收藏

2017年7月11日

再议c++的面向对象能力之上

C++的面向对象设计能力,与javaC#这两个杂碎相比,一直都是一个大笑话,现在谁敢正儿八经地用c++搞面向对象的框架系统,业界都用javaC#搞设计模式,那关C++什么事情了。而C++也很有自知之明,很知趣,98年之后,就不怎么对外宣称自己是面向对象的语言,就不怎么搞面向对象研究了(难道是c++下的面向对象已经被研究透彻?),一直在吃template的老本,一直到现在,template这笔丰厚的遗产,貌似还够c++吃上几十年。今时今日,virtual早就沦落为template的附庸,除了帮助template搞点类型擦除的行为艺术之外,就很难再见到其身影了。有那么几年,业界反思c++的面向对象范式,批斗virtual,特别是function出现之后,要搞动态行为,就更加不关virtual的什么事情了。而那几年,本座也学着大神忌讳virtual关键字。现在大家似乎已经达成共识,c++里头的面向对象能力很不完善,要玩面向对象就应该找其他语言,比如javaC#杂碎;或者更动态类型的语言,好比pythonRuby;或者干脆就是原教旨的面向对象(消息发送),object Csmalltalk

是啊,1、没有垃圾回收;2、没有原生支持的完善反射能力;3、多继承、虚继承导致的复杂内存布局。这三座大山面前,c++的码猿哪敢染指什么面向对象,只在迫不得已的情况下,小心翼翼地使用virtual。但是,事实上,要玩面向对象,c++原来也可以玩得很炫,甚至,可以说,关于面向对象的能力,c++是最强的(没有之一)。这怎么可能?

所谓的面向对象,说白了,就是对动态行为的信息支持,能在面向对象设计上独领风骚的语言,都是有着完善的运行时类型信息,就连lisp,其运行时元数据也都很完备。静态强类型语言(javaC#)与动态语言比,显然有着强大的静态类型能力(这不是废话吗),能在编译期就提前发现类型上的诸多错误,但是也因此带上静态约束,导致呆板、繁琐的代码,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来分配,那么,内存分配器就是一个线程局部变量(tlsthread local storage)。于是,可以规定,所有的内存分配都通过GetTlsAllocator()new对象,当然,确定是全局共享变量的话,没办法,就只能用GetGlobalAllocator()new对象。那么,有理由相信,启动一个任务时,我们先定义一个arena allocator变量,并令其成为当前线程的专属内存分配器,那么这个任务后面的所有new 出来的对象,包括循环引用,都不必关心。只要任务一结束,这个arena allocator变量一释放,所有寄生在它身上的对象,全部也都消失得干干净净,没有任何一点点的内存泄露。就算任务内部有大量的内存泄露,那又如何,任务一结束,所有跟此任务有关的一切内存,全部成块清空。总之,不要以常规raii来解决内存困境,解放思想,在内存释放上,我们可以有九种办法让它死,而不是仅仅靠shared_ptrunique_ptrweak_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++里面搞那么多玩意,都不过是为了给非侵入式接口造势。然而非侵入式接口一直未曾正式诞生过。

posted @ 2017-07-11 11:56 华夏之火 阅读(513) | 评论 (3)编辑 收藏

2017年7月10日

stl的抽象缺陷终结

古龙说过,一个人的最大优点往往将是其致命的弱点。这句话用在stl的迭代器上,最是合适不过。stl通过迭代器来解耦容器与算法,可谓击节赞叹;但是,让迭代器满世界的到处乱跑,未免就大煞风景。此话怎讲?

其实,有些语言就没有迭代器的概念,并且还活得很优雅,好比haskelllist啊、tree啊,压根就不需要什么迭代器,只需要模式匹配,体现其数据结构的递归特点,就可以很优雅地表达算法。就是javac#C++这几个破面向对象语言,才需要大用特用迭代器,没有迭代器就活不下去了。迭代器的出现就是为了弥补其语言丧失清晰表达递归数据结构的能力。看到haskelllistc++stl下的对应样子,很多人都表示很难过,因为stl里面,list根本就没有tail函数,更逞论支持listtail还是一个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()beginend这对兄弟,总是成双成对的出现,说明了一件事情,就是从一开始,它们必须被打包在一起,而不应该硬生生地将它们拆开。知道这一拆开,带来多少问题吗?代码上的累赘还算是小事,比如,简洁清晰流畅的find(nums, 2),却要生硬的写成find(nums.begin(), nums.end(), 2)。当然,这种api设计,也并非一无是处,起码,在表达容器里面的部分区间时,很方便,好比下面的代码

int nums[10] = {…};

find(nums+1, end(nums)-1, 2);

看起来,好像的确挺方便的,将beginend放在一起,要表达这样的概念,似乎就有些麻烦,但其实,这是假象,当角度变换时,我们可以会有更方便的方式来表达这样的需求。最起码,容器的部分区间也应该是由容器本身来表达,而不应转嫁给迭代器来应付,数组的部分也是数组,树的分支也是树,这样的概念,就应该由容器本身来定义。像是哈希表就不支持部分区间的概念。

为何algorithm的算法,全部(不是基本)都要求一对迭代器。那是因为这些算法的输入对象,本来就是一个数据集合。而一个迭代器无法完整地表达一个容器,起码必须一对迭代器才能完整地表达一个数据集。但是,用一对迭代器来作为入参,和用一个区间作为入参,它所体现抽象的侧重点完全不同,而由于此种不同,最后的演变结果,也是天渊之别,即是一对迭代器设计思路是渊,自然,而区间的设计方案,显然是天。

再次回顾上文的结尾,findfind_ifremove, remove_copy, remove_copy_if, remove_if,……有没有感受,一股浓浓的过程式风格,十分的笨重,明显的非正交,浓烈的c语言风格。对于这样的api,让本座对委员会的那帮老不死,彻底的绝望了。他们(它们)的审美观,停留在很低很低的层次上。

beginend拆分开来的最大问题,其实也就只是,前一个函数的处理结果,不能平滑的传递到下一个函数里面去。比如说,现在函数make_nums返回vector<int>,试比较一下,高下立判。

auto nums = make_nums();
find(nums.begin(), nums.end(), 
2); //一对迭代器作为入参
find(make_nums(), 2);//直接数据区间作为入参

说了这么多,我们强烈要求的仅仅是函数风格的api,正交式的函数设计,前一个函数的处理结果可以平滑地传递给下一个函数。总结algorithm的一坨函数,本质上只需filterfoldmapinsert(copy)这屈指可数的几个函数就可以自由地组合出来,并且还能组合出来algorithm上没有的效果。首先,这几个函数的返回结果都是数据区的数据对象(里面有beginend的成员函数,用以返回迭代器)。其次,就是在迭代器上面做文章,以支持filtermap等操作,也就是在*++!=这几个运算符上做花样,要达到filtermap的效果,很容易的。至于像是要求随机访问迭代器概念的函数,太常用的就做到array_view里面好了,或者就明确规定入参就是array_view

然后stl里面还臆造了一种好像叫做insert_iterator迭代器类型的适配器,用以通过迭代器的语法往容器里头插入数据,好像很玄妙,实则就是强行拔高迭代器的用途,完全就违背了迭代器出现的初衷。这种扭曲的想法,完全就是上面那一坨病态api的产物。所以,原本的api设计,算法函数必须以容器(数据区间)为入参,内部调用其beginend成员函数获得迭代器来遍历容器的函数,何其清晰的设计思路。但是,stl的设计思路,导致迭代器泛滥,甚至连客户层面的代码也大把大把的迭代器,于是迭代器的问题就接二连三的产生,什么失效啊,什么firstlast匹对错误。还有,导致容器里面的关于迭代器的成员函数多了一倍,哈希表里面也没有类似于C#DictionaryKeysValues属性函数,这些用起来很方便的,不是吗?

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为空,就表示字段函数指针是全局函数,不为空,就表示函数指针是成员函数。至于其他一切奇奇怪怪的函数,本座的这个delegatesay 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,比stlany好多了,不管是性能、编译速度、使用方便上,都是要好太多。还有,stlany,要为每个用到的类型都要生成一个实实在在的多态具体类,每个类都要有一个专门的虚函数表对应,这些可都要写到二进制文件里面,代码就是这样膨胀起来的。总之,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都一一回避,真是回避得好!


posted @ 2017-07-10 18:30 华夏之火 阅读(435) | 评论 (0)编辑 收藏

2017年7月9日

stl的缺陷抽象不足

总的来说,stl整个的设计还是很有水准的,抽象度非常高,采用泛型template手法,回避面向对象里面的虚函数,回避了继承,做到零惩罚,达到了非侵入式的要求(非侵入式远比侵入式要好,当然设计难度也更高出许多)。高性能、可扩展,容器提供迭代器,而算法则作用在迭代器上,容器与算法之间通过迭代器完全解耦,同一种算法可用于多种容器,只要该容器的迭代器满足其算法的要求;而同一个容器,又可被多种算法操作。更重要的是,容器与算法之间完全是开放的,或者说整个stl的体系是开放式,任何新的算法可用于已有的容器,任何新的容器又可被已有的算法所运算。然后,考虑到某些场合下容器的内存分配的高性能要求,分配器allocator也是可以替换,虽然逼格不高。此外,容器算法体系外的边角料,比如智能指针、anyiostream、复数、functio等,也都高性能、类型安全、可扩展,基本上都是c++风格量身定制,这些都无可厚非。真的,stl作为c++的基础库,已经很不错了。只是,依个人观点,当然,以下纯属一家之言,某些情况下,stl可以做得更好,或者说api的使用上,可以更加清爽。以下对stl的非议,似有鸡蛋里挑骨头之嫌,吹毛求疵,强挑毛病。

 

软件框架或者说库的设计,是由需求决定的。脱离需求的设计,不管多精致,代码多漂亮,一切都是废物,都是空谈。程序库所能提供的功能,当然是越强大越好,可扩展,高性能,零惩罚,这些明面上的概念自然重要。殊不知,程序库不能做的事情,或者说,其限制条件,或者说程序库对外界的依赖条件,也很重要。一个基础库,如果它敢什么都做,那就意味着它什么都做不好。事情是这样子的,如果只专注于某件目的,某些条件下的运用,往往可以获得更大的灵活性或者独立性。首先,代码必须很好地完成基本需求,在此基础上,才有资格谈论点什么别的更高层次的概念。

 

上文提到stl由于对动态类型的排斥,所导致的功能残缺以及二进制复用上的尴尬,如果stl愿意正面在动态类型做完善的考虑,相信stl的格局将会大很多,动态类型的话题,本文就不再过多重复了。当然,动态类型的引入必须满足零惩罚的必要条件,c++的程序库,所有不符合零惩罚的概念,最后都将被抛弃。所谓的零惩罚,就是不为用不到的任何特性付出一点点代价。请注意,这里的零惩罚必须是一步都不能妥协。

 

比如说,字符串实现的引用计数、短字符串优化这些奇技淫巧,就不是零惩罚,短字符串优化的惩罚度虽然很轻微,每次访问字符串内容时,都要通过字符串长度来确定其数据的地址,长度小,字符串就放在对象本身上,从而避免动态内存分配。长度大,字符串就放在对象外的堆内存上。短字符串优化空间上的惩罚,字符串对象的占用内存变大了,到了长字符串的时候,字符串对象里因为短字符串的内存空间就没有任何价值。在32位机上,字符串对象可以只包含内存分配器地址,字符缓冲起始地址,字符长度,缓冲的大小,满打满算,也就16个字节。而短字符串优化,就可能要用到32个字节。其实,如果有一个高性能的内存分配器,短字符串优化完全就可以没有任何必要。算了,扯远了,我们还是回到stl的设计思路上吧。

 

大家都知道,stl的字符串其实顶多就是一个字符缓冲管理对象。都98年的标准库了,完全就没有考虑字符编码的需求,真是奇怪之极,令人发指的完全偷工减料。是啊,字符编码不好搞,但是既然有这个需求,就必须支持啊,鸵鸟政策是行不通的。虽然说框架上设计可以既然做不好,那就完全都不做。但是,作为字符串组件,不鸟编码,你真的好意思以字符串自居。撇开编码,string居然有一百多个函数,更让人惊喜的是,这一百多个函数用于日常的开发,还远远不能满足需求。仔细看,这一坨函数大多数仅仅是为了性能需要上的重载,为了避开临时string对象搞出来的累赘。所以,stl里面必须要有一个只读的字符串,不涉及任何动态内存分配,也即是c++17string_viewstring_view里面有一个很良好的性质,string_view的任何一部分还是string_view(不同于c语言字符串的以零结束,必须带零的右部分才是字符串),然后string_view就可以做只读字符串上的运算,比如比较,查找,替换,截取等,分摊string里面大部分的函数。很奇怪的是,string_view这么有用的概念,居然要到c++17里面才有,都不知道stl委员会的人才在想什么。由此可见,如果class里面的成员函数如果过多,好比一百多个,那么其设计思路就一定大有问题,甭管它的出处来自何方。

 

同理可得,只读数组array_view也是很有用的概念,它是内存块的抽象。array_view的任何一部分都是array_view,不同于string_viewarray_view仅仅是长度不能变,但是其元素可修改,可以把array_view看成其他语言的数组,但是array_view不能创建,只能从vector或者c++数组获得,或者又可以看成是切片,array_view本身可以有排序和二分查找的成员函数。Array_view可以取代大多数vector下的使用场合。很奇怪的是,这么强有力地概念,c++17上居然就可以没有。差评!另外,我想说的是,对于排序二分查找,就仅仅用于连续内存块上就好了,其他地方则可免就免,搞那么多飞机干什么,stl在排序二分查找的处理上显然就是过度抽象。

 

或者有人反对,array_viewstring_view不就是两个新的容器,把它们加进stl里,不就行了,stl体系设计完备,绝对对外开放。不是这样的,array_viewstring_view的出现,严重影响现有的stringvector的设计,这两者必须基于array_viewstring_view的存在整个重新设计。

 

Stl就是对良好性质的基础数据结构缺乏抽象,容器的设计只到迭代器为止,提供迭代器之后,就高高兴兴对外宣称完成任务,不再深入地挖掘,可谓固步自封,浅尝辄止。在stl的世界观里面,就只有迭代器,什么都搞成迭代器,什么都只做到迭代器就止步不前,可悲可恨可叹!在其他基础容器的设计上,缺乏深入的考虑,罔顾需求,罔顾用户体验。对于链表的定位,就足以体现其眼光的狭隘。

 

众所周知,单向链表的尾部也是单向链表,可类比haskell或者lisp的列表,这么强有力的好概念,stl里居然完全没有单向链表,更别说凸显此概念。当然,单向链表里面只有一个节点指针,不能包含内存分配器的,也不能有元素计数器,而且生命周期也要由用户控制,但是,用户控制就用户控制,这一点都不要紧,特别是存在arena allocator的情况下,拼命的new单向链表,最后由arena allocator来统一释放内存好了。总之,stl太中规中矩,对于离经叛道的idea,完全就是逃避就是无视,对于动态类型的处理,也是这种态度。Stlallocator的处置,太过简单粗暴,一步错,步步错。

 

而双向链表,在付出O(n)的访问代价后,在为每个元素都要付出前后节点的内存占用后,应该得到咋样的回报呢?显然,stllistO(1)插入,O(n)通过迭代器删除元素,无论如何,完全不能接受,回报太少。首先,O(1)删除元素,不能妥协。为达此目的,我们先开发一个隐式侵入式要求的循环链表,它不关心元素的生命周期,任何插入此链表的元素,其首地址之前必须存在前后节点的指针。然后,链表本身的首两个字段是头节点和尾节点,内存布局上看,循环链表自身就是一个链表节点,一开始,链表为空,其首尾字段都指向自身。这种内存布局下的循环链表和其节点的关系非常松散,节点的插入和删除,只需要修改其前后节点的前后指针的值,完全不需要经过链表本身来完成操作。要删除元素时,只要往前爬两个指针的位置,就得到包含此元素的节点,进而时间O(1)上删除节点,何其方便。显然,循环链表不能包含节点数量,否则每次删除插入节点,都要加11链表的计数器,节点和链表就不能彻底的解耦。这种内存布局上的循环链表,就可支持多态了,比如,比如,xlist<Base> objects,可把Derived1类型的对象d1Derived2类型的对象d2,插入到循环链表xlist里,只要d1d2的前面保留前后节点指针的内存空间。

 

然后,封装这个裸循环链表,用allocator管理其节点元素的生命周期,好像stllist那样,创建删除节点元素。封装过得链表,节点和链表的关系就不再松散,因为节点必须通过链表的allocator来分配内存回收内存。但是,O(1)时间的删除节点,完全是没有任何问题。并且也能支持多态,可插入子类对象。相比之下,可见stllist有多弱鸡,简直不知所谓。

 

不管怎么说都好,stl里面对字符串的支持很薄弱,要有多不方便就有多不方便,虽然比C要好多了,这个必须的。当然,有人会辩解,很多操作很容易就可以添加进去的,但是,如果标准库支持的话,毕竟情况会好很多,不一定要做成成员函数,之所以迟迟未添加这些常用的字符串函数,怀疑是因为找不到合适的方式添加这些操作。字符串的操作可分为两大类,1、生成字符串;2、解析字符串。这两大类,都是格式化大显身手的地方,也即是sprintfscanfc++下的格式化函数,可以是类型安全,缓冲不会溢出,支持容器,支持成员变量名字。这样子,通过格式化,可以吸收大部分的字符串操作。可惜,stl对于反射的排斥,功能强大的格式化处理是不会出现的,而字符串操作,也将永远是stl的永远之痛。堂堂c++唯一的官方标准库,居然对最常用(可以说没有之一)的编程任务字符串操作支持如此灰头土脸,真是要笑死人了。为什么这么说,因为本座的库早就实现了这些想法(包括string_viewarray_view,不带allocator类型参数的各种容器),字符串的处理,简直不要太方便,比之stl的破烂,不可同日而语。比如,在c++中,完全就可以做如下的字符串操作。
vector<byte> buf = {…};
u8string codes;
Fmt(codes, “{ 
~%.2x}”, buf);//codes就是buf的16进制显示,小写,即是”xx xx … xx”。符号~表示前面的部分(这里是一个空格)作为元素之间的分隔符。
vector<byte> copied;
Scanf(codes, “{ 
~%.2x}”, &copied);//这样就把文本codes解析到copied里面去
assert(Equals(buf, copied));

不用格式化,在stl下,用iostream要实现这样的效果,不知道要写多少恶心的代码,反正很多人都已经想吐了。有了格式化之后,日子美好了好多。对了,上面的vector<byte>可换成list<byte>,代码完全都可以成立。Fmt的第一个参数表示格式化结果的目标输出对象,可以是文件,标准输出stdoutgui的文本框等。同时,Scanf的第一个参数表示格式化的输入源,可以是stdin,文件等。总之,FmtScanf这两个函数就可以概括所有的格式化操作,这两个函数,基本上可以解决满足日常上大多数关于字符串的操作。其实格式化的实现,离不开运行时类型信息的支持,本座要有多大的怨念,才会一而再地抱怨stl在反射上的无所作为。

至于iostreamlocale本座就不想批评太多,免得伤心难过,因为iostream竟然出自c++老父bs之手,必须是精品,某种意义上,确实是!


啰里啰嗦一大堆不满,还没有写完。后一篇的文章,主角是迭代器,整个stl的大亮点,同时也是大败笔。既造就了stl框架的灵活性,同时也导致stl函数使用上的不方便,特别是stl算法函数的冗余,非正交,不可组合。你看看,findfind_ifremove, remove_copy, remove_copy_if, remove_if,……,难道就不觉得面目可憎,低逼格,心里难受,堂堂大c++标准库算法的正儿八经的函数,标准会要有多扭曲的审美观,才会这样设计如此丑陋的接口,难道就没有一点点的羞耻心理!这种接口怎么可以拿出来见人见证,丢人现眼。

posted @ 2017-07-09 11:35 华夏之火 阅读(447) | 评论 (5)编辑 收藏

2017年7月7日

非完美的stl

       C++类库开发之难,举世公认,最好的证据就是,1983年到现在,面世几十年,就没有一个正儿八经的基础类库。是啊,零惩罚,要高性能,要跨平台,要可扩展,要人性化,又没有垃圾回收的支持,又没有运行时类型信息可用,……,这些方方面面的因素纠结在一起,就好像一个巨大的意大利面线团,真的是众口难调至极。相比C#,java,php等,python等杂碎,它们面世不多久,马上就有官方的标准库,你要说这些杂碎的标准库有多好,那也未必,问题是就有大量人马心悦诚服高高兴兴地用之于开发,没有什么所谓的破心智包袱影响开发效率,甚至有人坚持认为直接用c开发,开发速度都可以快过c++。哪像c++的破事一大坨,总之就是没有一个好的基础库,能够让所有的c++开发者大爷满意。你要说这些c++大爷难侍候,也未必,因为的确就是,不管怎么呕心沥血捣鼓出来的库,确实就是是存在这样那样的问题,以至于后面的大量使用中,缺陷扩大越来越明显,难以忍受。

c++之父一直在重复强调,c++本身美过西施,美得像杨玉环,c++本身没有问题,只是欠缺好用的基础库。问题是好用的基础库千喊万喊,迟迟就是不肯露面。这种情况下,就很让人怀疑c++的存在意义了。因为很明显的事实,其他的后生语言早就有庞大严谨的标准库,就你c++诸多借口,搞不出来合格的基础库,难道不是c++语言本身就存在重大缺陷,所以才有此困境。很多c++的老残党(包括本座),都很赞同c++之父的观点,c++本身很好,就是欠缺好用的基础库。因此大力出奇迹,集整个c++界的精英,花多年的研发,终于奋斗出来stl这个“精品”,另外,还准备了一个候补的boost,以满足非正常性的需求。

平心而论,stl还是相当不错的,高性能,可扩展,零惩罚,跨平台等,基本上都满足要求了。除了二进制不能共用,除了编译速度慢,除了代码膨胀,除了出错的时候,可能铺天盖地的错误,这也是没有办法的事情,世上哪有十全十美之事。总之,在基础设施严重施缺乏的c++上面,能够做出来这么一个玩意,已经很不容易了。最显然的事实,面对着stl,除了一小撮乱党,广大劳动群众普遍都认可stl。只是,既然stl是c++里面如此官方的基础库,就有必要接受更高标准的考验。而最终,stl整个的设计,也不可避免地,也绝非完美。这由此可见,c++基础库开发的难度。

stl里面的字符串,编码,iostream,locale,allocator,algorithm里面算法函数的重复(非正交)等的问题,都只是表象。根子上考察,stl的设计思路上犯了左倾和右倾的问题。具体表现如下:
1、对动态类型的畏惧,对静态类型的过度拥抱。这个问题在c++11之后,有一定程度的改善(出现了shared_ptr, any, variant,内里用到动态类型,起码有virtual的关键字使用)。最明显的表现就是,把内存分配器allocator做成静态类型信息,由此造成的麻烦,真是罄竹难书。同一个整型的vector,因为使用不同类型的allocator,也即是,vector<int, xalloc>和vector<int, yalloc>居然分属不同的类型,然后有一个函数要处理整型数组,要么只能做成模板函数,放在头文件上,c++原本就编译速度龟慢,再这样玩,简直雪上加霜;如果函数坚持放在cpp文件里面,就只能处理专门的allocator的整型vector。基本上,用stl打造的公共代码,都要做成头文件的共享方式,然后每次小小修改,都要引起连锁的编译雪崩,大型的c++项目,对于头文件的修改,考虑到感人的编译速度,从来都是非到不得已的时候,能不动就不动。岂有此理,天理何在。c++17,标准库终于接受多态的allocator,这算是对过去左倾激进的纠正。某种程度可以上改善这个问题,因为到时候就可以只专门接受多态的allocator,只可惜,还不完备。

考虑批量分配arena类型的allocator,理想情况下,对于在此arena allocator上分配的对象,假如仅仅涉及到内存问题,其实大多数情况下,析构函数做的就只是释放内存。那么完全就可以不必苦逼的一个一个调用对象的析构函数,仅仅把arena allocator的内存归还给系统就好了,这对于运行性能的改善,意义重大,本座测过,真是快了很多。问题是,现有stl的体系下,不能保证容器的元素也使用和容器一样的allocator,或者说,容器的allocator对象无法传递给它的元素,让容器元素也使用同一个allocator对象来分配内存。比如说,vector<string>,vector和string的allocator都用polymorphic_allocator,但是,vector的allocator对象和string的allocator可能不是同一个。这样子,我们就不能仅仅简单的归还allocator对象内存,而必须像过去那样子,对vector<string>里面的每一个string都调用析构函数来归还内存了。差评!所以,一开始,allocator就不应该成为模板参数。另外,stl对allocator的粒度也考虑不周。allocator的迥异应用场合起码有几种:1、静态allocator,专门在main函数运行前的使用,用于生成元数据,这些元数据不必一一析构,主函数结束后,统一一次性释放;2、全局的allocator,考虑多线程考虑并发;3、scope,可以在一个任务下使用,任务完毕,统一释放,这里包括函数或者协程;4、gui下的allocator等;只可惜,stl的allocator就只关注全局的allocator。

既然stl对allocator都可以搞成静态类型的鬼样子,那么整个stl对运行时类型信息的忽视,逃避,就可想而知了。typeid得到的type_info,除了起到类型的唯一标识符的作用(动态库下,同一种类型的type_info可能还不一样),并得到类型的名字之外,就不知道这个type_info还有什么鬼用。即便是这么一点小功能,还是能用于很多地方的,比如,any,variant,双分派(double dispatch),由此可见运行时类型信息的重要性。

动态类型信息,也即是反射的重要性,一点都不亚于静态类型信息。意义重大,有了反射,我们就可以将类型当成变量,到处传来传去,也可以保存起来,供后面使用,这里可做的文章,可挖掘的潜力太多了。假如c++的反射信息完善的话,很多头文件上的模板代码实现就可以放到源文件里面,模板函数仅仅是提取一下静态类型的运行时对象,类型擦除,具体实现代码就可以放到cpp代码里面去。然后,虚模板函数也可以成为可能了。可以用来创建对象,析构对象,消息发送,非侵入式的接口,序列化……,甚至,连多继承也都是多余(当然,多继承还是很有用,只是这货不应该出现在正式的场合下)。最典型的例子,格式化printf,通过c++11的variadic template,提取类型的运行时类型对象再连同入参的地址,就可以实现现在c库里面的那个弱鸡sprintf,类型安全,缓冲安全,高性能的效果,不但类型可扩展,连同格式化的控制字符都可扩展,甚至还能支持变量名字。stl里面的iostream、locale的设计成这个鬼样子,也是因为运行时的缺失导致。c++里面要妥当地处理好字符编码、字符串、文件流、locale这几者的关系,绝对不是一件容易的事情,所以也难怪stl在这里的一塌糊涂。看过iostream,locale的实现源码,大家都说不好,大家都很难受,简直可以和mfc媲美,这是真的。

c++的反射可以做到零抽象,也即是,只对必要的类型必要的信息做反射,不像java或者C#,不管是什么类型,不管是信息,一些很明显就是无关紧要的临时的东西,,不管三七二十一,全部一股脑儿都反射起来。甚至,c++的反射,还能添加用户自定义的反射信息,甚至,还能运行时修改反射数据。这里,C#、java等,除了attribute或者注解,就别无他法了。反射的意义就在于,它提供了统一的接口,将类型信息全部集中存放在同一个地方,任何关于类型的运行时信息,全部被标准化公理化。有了完善的反射信息,c++里面做一个eval都手到擒来。说白了,反射就是静态类型语言里把“代码做成数据”的最重要机制(没有之一),虽然比之于lisp的“代码即数据”弱一些,但是已经可以应付99%以上的需求了。甚至可以说,c++的基础库迟迟未出现的原因就是因为反射的缺席而导致的(当然,没有合适的内存管理机制也是重要原因)。而可惜,stl对运行时这一块的关注,不到%1,这真是令人扼腕叹息至极。

2,stl的抽象缺陷:臆造抽象,过度抽象,抽象不足,想当然的抽象,大部分的精力都花在刀背上,或者说是很形式化的学术研究。
突然发现文章已经很长了,就先打住,以后有空再好好发挥。对了,cppblog人气太冷清,门可罗雀。再这样下去,本座只好转战知乎了。

posted @ 2017-07-07 16:52 华夏之火 阅读(536) | 评论 (6)编辑 收藏

2017年7月5日

完备的运行时类型信息

众所周知,码猿写代码,自然要求严谨周密,殊不知想象力也很重要。本座阅码几十年,很是感概很多码猿的脑洞被大大禁锢,鲜有人能越雷池一步,特别是c++的同学,连同委员会的那一坨老头子,都很让人无语至极,出自这些人的作品,都是一个死鱼眼睛样子,千人一面,毫无灵动之生趣可言。stl,boost这些库都是这样子(虽然它们确实可以完成大多数日常任务),更别说其他的库,没有什么让人耳目一新之处。

就说说动态类型信息这块,又或者说是反射。自然,语言本身提供的废物type_info就懒得说了,除了证明c++也东施效颦,也能支持动态信息之外,就别无用处了,有谁会正儿八经的用type_info做点正儿八经的事情呢。因此,各路人马纷纷上阵,都要弥补c++在运行时类型信息上的缺失。因为类型的反射信息实在太重要,或者说,反射的用武之地太多太多,表面上很多事情不需要反射,或者字面代码上就看不到反射的痕迹,但是内里的实现,大把大把的反射在发光发热。c++坚持不在动态信息上给予一点点多余的支持,并不表示c++就不需要反射了,看看标准库这个极力回避动多态的典范,是一个怎样的失败作品,嗯,这个以后再谈吧。假如stl一开始就没有如此大力排斥动多态,你看看就连内存分配的allocator都可以做到静态类型信息里面(最新版的c++终于也要接受多态的allocator,c++界居然一片欢呼鼓舞,真是悲哀),今时今日的c++就不会在很多领域上到处割地求和。

总的来说,现在市面上的c++反射库,都是侵入式,都学着mfc那一套,都是要求继承自一个基类Object,然后才能对外提供反射信息的功能,先不说它们提供的类型信息是否完备,这样子就把用途广泛限制死在一个很窄很窄的小圈子里面了。这些反射库,1、不能反射基本类型,int、char、double、const char*、……等;2、不能反射非继承自Object的class或者struct,3、也不能反射模板类,比如vector<int>、list<vector<vector<int>>>。虽然typeid千般弱鸡,但也非一无是处,起码非侵入、平等、多态。所以,理想的反射,应该像c++原生的typeid那样无色无味:1、非侵入式的;2、可以对所有的类型都提供反射,基本类型、非Object系的struct或者class、template类型的;3、多态的,只要改类型需要运行时的类型识别,那么就返回其本身的类型(子类),而非字面上的声明类型;4、支持类型参数,也即是说,以类型传递给该函数时,就返回相应的类型信息对象。

说得具体一点,我们要求的反射库是这样子的。当然,首先要有一个类型信息对象TypeInfo,里面装满了关于对于类型的所有详细信息。如下所示:可以猜到这种反射下框架,只支持单继承,这是故意的。
    struct TypeInfo
    {
    
public:
        template
<typename Args>
        
void ConstructObject(void* obj, MemoryAllocator* alloc, Args&& args)const
        
bool IsDerviedOf(const TypeInfo* base)const;

    
public:
        
virtual TIType GetTIType()const = 0;
        
virtual const InterfaceMap* GetInterfaces()const
        
virtual jushort GetMemorySize()const
        
virtual ConstText GetName() const
        
virtual AString GetFullName()const
        
virtual jushort GetAlignSize() const
        
virtual ConstText GetSpaceName()const;
        
virtual const TypeInfo* GetBaseTypeTI()const;
        
virtual const TypeInfo* GetPointeedTI()const
        
virtual size_t GetHashCode(const void* obj)const;
        
virtual bool IsValueType()const { return true; }
        
virtual bool IsClass()const { return true; }

        
virtual bool DoInitAllocator(void* obj, MemoryAllocator* memAlloc)const;
        
virtual bool NeedDestruct()const { return false; }
        
virtual void DoDefaultConstruct(void* obj)const;
        
virtual bool CanDefaultConstruct()const { return true; }
        
virtual void DoAssign(void* dest, const void* src)const;
        
virtual bool Equals(const void* objA, const void* objB)const;
        
virtual void DoDestruct(void* obj)const;
        
    };
然后,就要有一个函数TypeOf,应该是两个,一个是无参数的类型模板函数,可以这样调用,TypeOf<type>();一个是有一个参数的类型模板函数,可以这样调用,TypeOf(obj)。不管是那一个,其返回结果都是const TypeInfo*。TypeOf的要做到的事情是,对于每一种类型,有且只有一个唯一的TypeInfo对象与之对应,不管是template的还是非template的;比如,以下的几个判断必须成立。
TypeOf<int>() == TypeOf<int>();
TypeOf<int>() == TypeOf(n);    //n为整型
TypeOf<vector<int>>() == TypeOf(nums);//nums的类型为vector<int>
Object* a = new ObjectA; TypeOf(a) == TypeOf<ObjectA>();
其实这里面的原理也没什么神奇,无非就是trait配合sfine,接下来就全部都是苦力活,就是为每一种类型都专门特化一个详细描述的类型对象,用宏可以节省大量的代码。但是整个反射库,本座前前后后重构了十几次,现在也还在重构之中,终究还是解决了开发上所遇到的各种事情。比如,序列化(支持指针、支持多态)、对象与xml的互换、对象与json的互换、数据库表读写对象、格式化、Any类型、非侵入式接口、消息发送、字符串生成对象等等。
其实现方式,概括起来,就是引入间接层元函数TypeInfoImp专门用于返回一个类型type,type里面有一个GetTypeInfo()的函数。然后TypeOf调用TypeInfoImp里的type的GetTypeInfo()最终得到TypeInfo对象。代码如下所示。
    template<typename Ty> struct TypeInfoImp
    {
        typedef Ty type;
        
static const bool value = THasGetTypeInfoMethod<Ty>::value;
    };

    template
<typename Ty>
    
struct TypeInfoImp<const Ty> : public TypeInfoImp<Ty>
    {
        typedef typename TypeInfoImp
<Ty>::type type;
        
static const bool value = TypeInfoImp<Ty>::value;
    };
    
    template
<typename Ty>
    
const TypeInfo* TypeOf()
    {
        typedef typename TypeInfoImp
<Ty>::type TypeInfoProvider;
        
return TypeInfoProvider::GetTypeInfo();
    }
    
    template
<typename Ty>
    
const TypeInfo* TypeOf(const Ty& obj)
    {
        typedef typename IsRttiType
<Ty>::type is_rtti;    //又是间接层,对动态类型和非动态类型分别处理
        return ImpTypeOf(obj, is_rtti());
    }
    
    template
<>
    
struct TypeInfoImp < bool >
    {
        
static const bool value = true;
        typedef TypeInfoImp
<bool> type;
        
static TypeInfo* GetTypeInfo();
    };
        
    TypeInfo
* TypeInfoImp<bool>::GetTypeInfo()
    {
        
static TypeInfo* ti = CreateNativeTypeInfo<bool>("bool");
        
return ti;
    }
可能可以有简洁的方式,比如不需要引入TypeInfoImp,但是实际最终证明TypeInfoImp的方式最具灵活性也最能节省代码。最起码,它在自定义的struct或者class就很方便,只要改struct内部包含一个GetTypeInfo()的函数,它就可以被纳入TypeOf体系中,非常方便。对于模板类型的TypeInfoImp,就要用到哈希表了。比如,对于std::paira的类型信息,如下实现,
    template<typename FstTy, typename SndTy>
    struct TypeInfoImp < std::pair<FstTy, SndTy> >
    {
        static const bool value = true;
        typedef TypeInfoImp < std::pair<FstTy, SndTy> > type;
        static TypeInfo* GetTypeInfo()
        {
            ParamsTypeInfo<FstTy, SndTy> args;
            return PodPair::LookupTemplateTypeInfo(args);
        }
    };
提取其类型参数的const TypeInfo*,生成数组。用此数组到PodPair的哈希表里面查找,如果哈希表中以有此类型数组参数的对象就返回,否则见创建一个添加一条哈希条目,然后返回。每一个泛型类型,比如vector,list,pair都有一个属于自己的哈希表。
打完收工。原理很简单,但是对于工业级的反射库,要考虑很多细节,比如,TypeInfo对象的内存管理;怎么为enum类型生成一堆字符串,以支持字符串和enume值的互相转换;生成并保存class的构造函数和析构函数指针;命名空间的支持;仿真C#里面的attribute;如何以最方便的方式生成成员字段或者成员函数信息等等,一句话,就是他妈的体力活。但是,回报是很丰盛的,这里的苦力活做完之后,程序的其他地方上,基本上,就没有什么重复相似的代码,一切的体力工作全部就可以压在类型信息这里了。

posted @ 2017-07-05 11:45 华夏之火 阅读(423) | 评论 (1)编辑 收藏

仅列出标题  下一页

导航

统计

常用链接

留言簿(3)

随笔分类

随笔档案

搜索

积分与排名

最新评论

阅读排行榜

评论排行榜