总的来说,stl整个的设计还是很有水准的,抽象度非常高,采用泛型template手法,回避面向对象里面的虚函数,回避了继承,做到零惩罚,达到了非侵入式的要求(非侵入式远比侵入式要好,当然设计难度也更高出许多)。高性能、可扩展,容器提供迭代器,而算法则作用在迭代器上,容器与算法之间通过迭代器完全解耦,同一种算法可用于多种容器,只要该容器的迭代器满足其算法的要求;而同一个容器,又可被多种算法操作。更重要的是,容器与算法之间完全是开放的,或者说整个stl的体系是开放式,任何新的算法可用于已有的容器,任何新的容器又可被已有的算法所运算。然后,考虑到某些场合下容器的内存分配的高性能要求,分配器allocator也是可以替换,虽然逼格不高。此外,容器算法体系外的边角料,比如智能指针、any、iostream、复数、functio等,也都高性能、类型安全、可扩展,基本上都是c++风格量身定制,这些都无可厚非。真的,stl作为c++的基础库,已经很不错了。只是,依个人观点,当然,以下纯属一家之言,某些情况下,stl可以做得更好,或者说api的使用上,可以更加清爽。以下对stl的非议,似有鸡蛋里挑骨头之嫌,吹毛求疵,强挑毛病。
软件框架或者说库的设计,是由需求决定的。脱离需求的设计,不管多精致,代码多漂亮,一切都是废物,都是空谈。程序库所能提供的功能,当然是越强大越好,可扩展,高性能,零惩罚,这些明面上的概念自然重要。殊不知,程序库不能做的事情,或者说,其限制条件,或者说程序库对外界的依赖条件,也很重要。一个基础库,如果它敢什么都做,那就意味着它什么都做不好。事情是这样子的,如果只专注于某件目的,某些条件下的运用,往往可以获得更大的灵活性或者独立性。首先,代码必须很好地完成基本需求,在此基础上,才有资格谈论点什么别的更高层次的概念。
上文提到stl由于对动态类型的排斥,所导致的功能残缺以及二进制复用上的尴尬,如果stl愿意正面在动态类型做完善的考虑,相信stl的格局将会大很多,动态类型的话题,本文就不再过多重复了。当然,动态类型的引入必须满足零惩罚的必要条件,c++的程序库,所有不符合零惩罚的概念,最后都将被抛弃。所谓的零惩罚,就是不为用不到的任何特性付出一点点代价。请注意,这里的零惩罚必须是一步都不能妥协。
比如说,字符串实现的引用计数、短字符串优化这些奇技淫巧,就不是零惩罚,短字符串优化的惩罚度虽然很轻微,每次访问字符串内容时,都要通过字符串长度来确定其数据的地址,长度小,字符串就放在对象本身上,从而避免动态内存分配。长度大,字符串就放在对象外的堆内存上。短字符串优化空间上的惩罚,字符串对象的占用内存变大了,到了长字符串的时候,字符串对象里因为短字符串的内存空间就没有任何价值。在32位机上,字符串对象可以只包含内存分配器地址,字符缓冲起始地址,字符长度,缓冲的大小,满打满算,也就16个字节。而短字符串优化,就可能要用到32个字节。其实,如果有一个高性能的内存分配器,短字符串优化完全就可以没有任何必要。算了,扯远了,我们还是回到stl的设计思路上吧。
大家都知道,stl的字符串其实顶多就是一个字符缓冲管理对象。都98年的标准库了,完全就没有考虑字符编码的需求,真是奇怪之极,令人发指的完全偷工减料。是啊,字符编码不好搞,但是既然有这个需求,就必须支持啊,鸵鸟政策是行不通的。虽然说框架上设计可以既然做不好,那就完全都不做。但是,作为字符串组件,不鸟编码,你真的好意思以字符串自居。撇开编码,string居然有一百多个函数,更让人惊喜的是,这一百多个函数用于日常的开发,还远远不能满足需求。仔细看,这一坨函数大多数仅仅是为了性能需要上的重载,为了避开临时string对象搞出来的累赘。所以,stl里面必须要有一个只读的字符串,不涉及任何动态内存分配,也即是c++17的string_view,string_view里面有一个很良好的性质,string_view的任何一部分还是string_view(不同于c语言字符串的以零结束,必须带零的右部分才是字符串),然后string_view就可以做只读字符串上的运算,比如比较,查找,替换,截取等,分摊string里面大部分的函数。很奇怪的是,string_view这么有用的概念,居然要到c++17里面才有,都不知道stl委员会的人才在想什么。由此可见,如果class里面的成员函数如果过多,好比一百多个,那么其设计思路就一定大有问题,甭管它的出处来自何方。
同理可得,只读数组array_view也是很有用的概念,它是内存块的抽象。array_view的任何一部分都是array_view,不同于string_view,array_view仅仅是长度不能变,但是其元素可修改,可以把array_view看成其他语言的数组,但是array_view不能创建,只能从vector或者c++数组获得,或者又可以看成是切片,array_view本身可以有排序和二分查找的成员函数。Array_view可以取代大多数vector下的使用场合。很奇怪的是,这么强有力地概念,c++17上居然就可以没有。差评!另外,我想说的是,对于排序二分查找,就仅仅用于连续内存块上就好了,其他地方则可免就免,搞那么多飞机干什么,stl在排序二分查找的处理上显然就是过度抽象。
或者有人反对,array_view和string_view不就是两个新的容器,把它们加进stl里,不就行了,stl体系设计完备,绝对对外开放。不是这样的,array_view和string_view的出现,严重影响现有的string和vector的设计,这两者必须基于array_view和string_view的存在整个重新设计。
Stl就是对良好性质的基础数据结构缺乏抽象,容器的设计只到迭代器为止,提供迭代器之后,就高高兴兴对外宣称完成任务,不再深入地挖掘,可谓固步自封,浅尝辄止。在stl的世界观里面,就只有迭代器,什么都搞成迭代器,什么都只做到迭代器就止步不前,可悲可恨可叹!在其他基础容器的设计上,缺乏深入的考虑,罔顾需求,罔顾用户体验。对于链表的定位,就足以体现其眼光的狭隘。
众所周知,单向链表的尾部也是单向链表,可类比haskell或者lisp的列表,这么强有力的好概念,stl里居然完全没有单向链表,更别说凸显此概念。当然,单向链表里面只有一个节点指针,不能包含内存分配器的,也不能有元素计数器,而且生命周期也要由用户控制,但是,用户控制就用户控制,这一点都不要紧,特别是存在arena allocator的情况下,拼命的new单向链表,最后由arena allocator来统一释放内存好了。总之,stl太中规中矩,对于离经叛道的idea,完全就是逃避就是无视,对于动态类型的处理,也是这种态度。Stl对allocator的处置,太过简单粗暴,一步错,步步错。
而双向链表,在付出O(n)的访问代价后,在为每个元素都要付出前后节点的内存占用后,应该得到咋样的回报呢?显然,stl的list,O(1)插入,O(n)通过迭代器删除元素,无论如何,完全不能接受,回报太少。首先,O(1)删除元素,不能妥协。为达此目的,我们先开发一个隐式侵入式要求的循环链表,它不关心元素的生命周期,任何插入此链表的元素,其首地址之前必须存在前后节点的指针。然后,链表本身的首两个字段是头节点和尾节点,内存布局上看,循环链表自身就是一个链表节点,一开始,链表为空,其首尾字段都指向自身。这种内存布局下的循环链表和其节点的关系非常松散,节点的插入和删除,只需要修改其前后节点的前后指针的值,完全不需要经过链表本身来完成操作。要删除元素时,只要往前爬两个指针的位置,就得到包含此元素的节点,进而时间O(1)上删除节点,何其方便。显然,循环链表不能包含节点数量,否则每次删除插入节点,都要加1减1链表的计数器,节点和链表就不能彻底的解耦。这种内存布局上的循环链表,就可支持多态了,比如,比如,xlist<Base> objects,可把Derived1类型的对象d1,Derived2类型的对象d2,插入到循环链表xlist里,只要d1和d2的前面保留前后节点指针的内存空间。
然后,封装这个裸循环链表,用allocator管理其节点元素的生命周期,好像stl的list那样,创建删除节点元素。封装过得链表,节点和链表的关系就不再松散,因为节点必须通过链表的allocator来分配内存回收内存。但是,O(1)时间的删除节点,完全是没有任何问题。并且也能支持多态,可插入子类对象。相比之下,可见stl的list有多弱鸡,简直不知所谓。
不管怎么说都好,stl里面对字符串的支持很薄弱,要有多不方便就有多不方便,虽然比C要好多了,这个必须的。当然,有人会辩解,很多操作很容易就可以添加进去的,但是,如果标准库支持的话,毕竟情况会好很多,不一定要做成成员函数,之所以迟迟未添加这些常用的字符串函数,怀疑是因为找不到合适的方式添加这些操作。字符串的操作可分为两大类,1、生成字符串;2、解析字符串。这两大类,都是格式化大显身手的地方,也即是sprintf和scanf,c++下的格式化函数,可以是类型安全,缓冲不会溢出,支持容器,支持成员变量名字。这样子,通过格式化,可以吸收大部分的字符串操作。可惜,stl对于反射的排斥,功能强大的格式化处理是不会出现的,而字符串操作,也将永远是stl的永远之痛。堂堂c++唯一的官方标准库,居然对最常用(可以说没有之一)的编程任务字符串操作支持如此灰头土脸,真是要笑死人了。为什么这么说,因为本座的库早就实现了这些想法(包括string_view,array_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的第一个参数表示格式化结果的目标输出对象,可以是文件,标准输出stdout,gui的文本框等。同时,Scanf的第一个参数表示格式化的输入源,可以是stdin,文件等。总之,Fmt,Scanf这两个函数就可以概括所有的格式化操作,这两个函数,基本上可以解决满足日常上大多数关于字符串的操作。其实格式化的实现,离不开运行时类型信息的支持,本座要有多大的怨念,才会一而再地抱怨stl在反射上的无所作为。
至于iostream和locale本座就不想批评太多,免得伤心难过,因为iostream竟然出自c++老父bs之手,必须是精品,某种意义上,确实是!
啰里啰嗦一大堆不满,还没有写完。后一篇的文章,主角是迭代器,整个stl的大亮点,同时也是大败笔。既造就了stl框架的灵活性,同时也导致stl函数使用上的不方便,特别是stl算法函数的冗余,非正交,不可组合。你看看,find,find_if,remove, remove_copy, remove_copy_if, remove_if,……,难道就不觉得面目可憎,低逼格,心里难受,堂堂大c++标准库算法的正儿八经的函数,标准会要有多扭曲的审美观,才会这样设计如此丑陋的接口,难道就没有一点点的羞耻心理!这种接口怎么可以拿出来见人见证,丢人现眼。