HOME REPUB

STL,恨铁不成钢

鉴于本座对 stl 吐槽实在太频繁,以至于不想再多说一句话,但总是感觉有些地方没有说清楚,所以……。平心而论,stl 还是设计得很精妙的,怎么说都好,其代码质量的水平也很高,api 的使用也挺人性化。如果 stl 不是作为大 c艹 的官方标准库,那么本座也不会如此吹毛求疵。正因其尊贵的殊荣,所以才要经得起最苛刻的考验。

相信大家多多少少都有这样的感受,经常会感觉到用 stl 写出来的代码,怎么搞都好,看着就是很别扭,不管怎么努力,都很难把代码做成简洁典雅的样子。我们会反省自己可能对 stl 掌握得不好,但这更多的其实是 stl 的内核架构就不干净。这怎么说呢? 感觉 stl 的设计思路就是在借鉴函数式语言的编程思想,这种借鉴(抄袭)行为年代越往后越明显,但是 stl 又保留了命令式的 api 调用风格,造成的水土不服,又企图通过面向对象的途径来弥补,但是对面向对象的威力又百般钳制。在此过程中,还夹杂着对类型安全近乎歇斯底里的追求,各种无理取闹地削弱语言的弱类型的灵活性。这种纠结复杂矛盾的心情贯穿了 stl 各个组件方方面面的设计,而更糟糕的是,标准会对于这些组件的功能需求,还缺乏清晰的认识。糊里糊涂就很勉强地就把组件的接口给定了出来,而且,这些 api 功能还追求什么功能完备通用,stl 中随处可见违反单一接口戒条的精妙代码 ,比如说 unique_ptr 除了能释放内存,还能释放非内存资源,这真是很妙的扩展。所以,最终展现在世人面前的 C++ 官方标准库,还能保持这样的绣花枕头模样,已属难能可贵。

在这尴尬的背后,隐隐约约潜藏着更让人不安的事实,那就是标准会在代码的审美感上没有太高的品味。透过 stl 的 api,可以感受这里面浓浓的恶性趣味。不禁让人怀疑,标准会千锤百炼苦心经营出来的作品,委员会的成员到底有没有在实际项目中大规模地运用,只需一次两次,就绝对会受不了 stl 的繁琐。如果没有这种体会,那就更让人放心不下,这一群人何德何能制定什么 C++ 官方标准模板库。不好意思,这话说得重了。

但是,造成现在这样的局面,又好像可以理解,那就是 stl 的设计原则,事实上是对 C++ 的语言能力做了一次很大的阉割又或者是降维打击。宏是绝对不能用的,弱类型禁用,面向对象慎用,强行照顾 const,一切尽量采用泛型的 api 调用方式,还臆想必须运行效率优先。原本的 C++ 虽然具备一流的抽象能力(template + 面向对象 + 底层管理),但是,受限于其语言表达形式的缺陷,C++ 库怎么设计,都没法做到像 C# 那般的行云流水,特别是在跟 Linq 看齐的时候。但是,这这并不是说 C++ 就没能力打造高质量的基础库,其自有其独到之处,另辟蹊径,完全可以做出来跟 .net 一较高下的 C++ 库,当然,指的只是基本功能。为此目的,只须动用 C++ 语法上所有可用的材料(宏、类型转换、虚函数、操作符重载、全局变量等),心理上不需要存在任何负罪感,不拘形式,怎么方便怎么来,就有望做出来的基础库可以支撑用户层代码写得非常爽,所谓的爽,无非是代码直白易懂,争取以尽可能少的代码完成业务代码,同时保证性能(不管是运行时间还是编译时间)过得去,运行性能不需要太高,只要保留可以日后优化的余地。

千万不要以为标准会就不会犯错了,在 stl 的某些组件上设计,出尔反尔的事情又不是一次两次,allocator、auto_ptr、string_view、vector<bool>、plus modulus 等仿函数,这些打脸的事情时有发生,完全足以证明标准会的库设计水平未必有多高明。当然,标准会也能知错能改亡羊补牢经常填坑,但是,标准库这个东西,一旦定下来,就意味着其基业长存,要一直维护。像是 auto_ptr 这些独立性很高的组件,还好办,逐渐 deprecate 掉就行了。但是对于 allocator,string_view 包括迭代器这些已经是框架的核心基础,错了也就只能一直错下去,因为它们已经是语言规范的一部分。

概括起来,stl 的不良表现有以下几点,仅仅简单描述。注:和知友交流后,感觉下面的文字太过分,很有点鸡蛋里挑骨头的勉强,其实 stl 也一直在进步,毕竟不比 mfc 的不堪一击,但是怎么感觉 stl 用起来就是不顺畅。

问题领域认识不清晰,强行糊代码:罪魁祸首,allocator 作为模板参数出现,以前讨论过; string 能横起心肠搞出来上百个没啥鬼用的成员函数,又不对 unicode 做任何抽象,而且 string 还是模板类型,而且还有好几个模板参数,真是让人不得不敬其为一条汉子,C++ 的沉沦,string 也算居功甚伟了;iostream 的实现代码之糟糕暗黑复杂,在整个 stl 中也算花魁了,深入研究的话,会让你恨屋及乌,连 stl 也都可以反感,甚至怀疑 C++ 的存在意义,但是 iostream 的使用不方便,功能也很弱,唯一小亮点就只是自我感觉良好的类型安全。

抽象泄露,这一块主要集中在迭代器上。不错,stl 通过迭代器来解耦容器与算法,容器与算法得以自行发展,迭代器在这其中扮演了很关键的角色,但是这并不表示迭代器就可以到处抛头露脸。实际上,迭代器只是幕后人物,是容器与算法的实现细节。也即是说,迭代器的相关代码不宜出现在应用层的代码上。api 的接口上,得尽量隐藏迭代器的出现,比如说各个算法函数,find, find_if, copy, accmulate 等函数的参数就应该直接是容器对象,而非一前一后的一对迭代器。以容器为粒度来设计 api 的好处是,函数的输出结果也是容器,然后这个容器又可以平滑地传递到下一个算法函数中。然后,容器的初始化赋值成员函数,也尽量都以容器参数为主。然后一大波查找算法的返回结果之前是迭代器,现在直接返回指针或者指针的指针好了,指针为 nullptr,就表示查找失败。总之,除了 begin 和 end 这两个成员函数没办法要返回迭代器,其他的任何函数,就都不要返回迭代器了。这又带来另外一个好处,你看看,stl 的算法函数里面,find, find_if, copy, copy_if, remove, remove_if, …,这是不是很不正交的 api 设计思路呢?很丑陋,很不简洁很不优雅啊,这一波 stl 的算法函数,很让人怀疑 stl 的代码品味。又或曰,前后一对迭代器能表达容器的部分元素的抽象,这也非很好的理由,首先,区间操作本身这样的需求就很少,其次,就算把迭代器隐藏起来,我们都有无数种方式实现部分区间的概念。可以想象,迭代器隐藏起来之后,所有与迭代器相关的麻烦就全部消失了。还有一大波迭代器之上的适配器组件,也都可以退休了,他们就应该有更正式 api 接口来做这些功能,stl 就是到处在卖弄一些小聪明。

抽象不足,墨守成规,缺乏想象力,比如,C++17 终于搞出来 string_view 的容器,但是,至今为止,另外一个也很有用的容器,array_view,表示连续内存块元素的集合,可以表示 vector 的一部分,静态数组的一部分,array_view 的一部分,这么重要的容器,要到 C++20 才面世,叫 span,感谢知友的提醒;又好比,list 完全就可以实现侵入式 list 的效果,以保证元素的删除操作在 O(1) 内完成,此外,按 list 的内存布局来看,可以存放多态子类型元素,也即是说,list<base> 里面可以存放 base 派生类的对象在里面,这一点,在面向对象实践中很有用;哈希表容器 unordered_map 里面也没有返回 Keys 和 Values 的成员函数,差评;……,总之欠缺很多便利的组件和函数就是了;

臆想需求,抽象过度,比如 unique_ptr,shared_ptr 管理对象的生命周期就挺好,还能管理资源的生命周期,这个就显得功能泛滥。因为对于资源,C++ 猿猴多半会用 struct 封装其使用,通过 RAII 来管理资源,就不捞智能指针了。如果一开始就明确限制智能指针的语义,那么就可以更高效更灵活地实现其功能,甚至用起来用更方便。又好比 vector,原本有更高效的实现方案,为了支持不可移动的对象,其内部代码又做多了多少事情;还有时间处理 Duration、Time point 又是模板又是 typedef,好像煞费苦心,一点都不好用,对于普通老百姓,我们只需要简简单单精确到毫秒的 DateTime 和 TimeSpan 就足够了;……。

剩下来的就是一些冗长代码上的调用不方便,比如 any 啊,any_cast(any 没有完备的反射支持,就是鸡肋);tuple 啊,什么 get<0>,get<1>;……,东西都是好东西,但是用起来,感觉代码看着就是不清爽。所有这些综合起来,就导致 stl 在做很多事情时,各种有劲使不上:面向对象搞不了;表格驱动也难搞(因为太多静态类型);序列化无能为力,不管是二进制序列化,还是文本序列化,还是数据库读写,stl 都没啥鬼用;字符串处理也帮不上忙,输入输出也很麻烦;单元测试也没分;……,就算是简单的算法实现,搞不好也要写很多代码,猿猴在用 stl 写代码很容易就气息不顺畅,可与写 C# 代码的畅快心情相比。

最后以一段简单的代码结束本文,和知友交流,更正相关描述。这也算是画蛇添足吧

template<typename ConTy>
void ForEach(ConTy& values, const ProcTy& proc)
{
        auto begin = values.begin();
        auto end = values.end();
        while (begin != end)
        {
                auto& obj = *begin;//这一步很关键
                ++begin;//进入到下一个迭代器
                proc(obj); //不管怎么操作元素,迭代器都不会失效
        }
}

ForEach 里面可以做容器元素的修改操作,相比之下,stl 的 for_each 就不能随意修改容器,C++11 之后,for_each 已经完全失去存在的意义了。