huaxiazhihuo

 

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 on 2017-07-10 18:30 华夏之火 阅读(931) 评论(0)  编辑 收藏 引用 所属分类: c++技术探讨


只有注册用户登录后才能发表评论。
网站导航: 博客园   IT新闻   BlogJava   知识库   博问   管理


导航

统计

常用链接

留言簿(6)

随笔分类

随笔档案

搜索

积分与排名

最新评论

阅读排行榜

评论排行榜