總的來說,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++下的格式化函數,可以是類型安全,緩沖不會溢出,支持容器,支持成員變量名字。這樣子,通過格式化,可以吸收大部分的字符串操作??上В?/span>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++標準庫算法的正兒八經的函數,標準會要有多扭曲的審美觀,才會這樣設計如此丑陋的接口,難道就沒有一點點的羞恥心理!這種接口怎么可以拿出來見人見證,丟人現眼。