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人气太冷清,门可罗雀。再这样下去,本座只好转战知乎了。