最近一段時間,在公司的項目中,分別寫了一些Go與C++代碼,在體驗了Go所帶來的生產力提升后,對于C++越發嫌棄。
《Thinking in C++》作者Bruce Eckel曾經說過他選擇一門編程語言的標準:
I have always sought the most powerful languages. For me, the most important aspect of “power” is programmer productivity, and this is predominantly determined by simplicity and clarity. So I seek languages that emphasize simplicity and clarity above all. I am very aware of the cognitive overhead of language features, and the limitations of the human mind in managing complexity. The more of the mind that is used on arbitrary complexity, the less is available to solve the problems at hand. Thus, I seek languages that don’t force the programmer to jump through arbitrary complexity hoops in order to compensate for language decisions that were made for reasons other than “simplest for the programmer.” I’ve found that once I discover the compromises and what they cost – and especially when they cost more than the language benefits, in terms of programmer time and effort – I start losing interest in that language.
其主要觀點是:
- 生產力是衡量一門語言的核心標準。
- 生產力主要由語言的簡潔性、清晰性決定。
- 在管理復雜性方面,人腦存在局限。語言本身應盡可能少地增加認知負擔,開發人員在語言本身上消耗的認知能力越多,其專注于解決問題的認知能力越少。
那么,讓我們來看一看,C++都給開發人員增加了哪些認知負擔。
1. 隱形學習成本高
C++本身無官方文檔、無官方教程,同樣是C++程序員,寫出來的程序無論從代碼風格還是質量上都良莠不齊。除此之外,沒有任何一門語言像C++一樣,有這多么經典圖書,這也從側面證明了這門語言的艱深晦澀程度之深。類似《Effective C++》系列,內容竟全部是一些C++使用上的最佳實踐,都是前人經驗的血淚總結。
有經驗的C++程序員早已將這些最佳實踐內化吸收,融入到自己的日常開發之中,而不覺有任何負擔。這顯然是一種幸存者偏差,任何C++程序員要想習得這些最佳實踐/躲坑指南,要么從自己的經驗中學習,靠自己一步一個腳印地踩過,要么從他人的經驗里學習,閱讀正是第二種方式。
2. 聲明與實現分離:服務于編譯器,而非人
頭文件機制一直是C++被詬病最多的地方,讓我們看看.h與.cpp分離給開發人員帶來了哪些認知負擔:
- 頭文件 guard。ifndef/define/endif幾乎是每個頭文件的標配,然而這些工作只是為了能夠編譯通過,為編譯器掃清障礙。
- 代碼冗余。頭文件中的函數聲明,都要復制一份到cpp文件中。為了實現一個很簡單的功能,不得不寫一堆boiler plate代碼。
- 冗長的include列表。面向對象中,整個程序被分割成一個一個的類,稍微復雜的程序都會同時用到很多類,尤以基礎庫中的類使用最為頻繁,而它們幾乎被include進每一個cpp文件。
- 為了優化編譯速度,不include頭文件,而是使用前置聲明。
開發人員背負了這些認知負擔,又獲得了什么呢?沒有任何實質性的產出,不過是為編譯器打工罷了。
3. 內存管理
在沒有智能指針的時代,C++將內存管理的責任全部委托給了開發人員。有過幾年編程經驗的人,或多或少都被各種內存問題困擾過,memory leak、segment fault等。
在智能指針出現以后,只要想清楚對象的所有權(具有獨占性的對象使用unique_ptr,被多人共享的對象使用shared_ptr),現代C++程序就不再需要手動delete,基本杜絕了memroy leak類問題。
而導致segment fault比較常見的兩種bug是:
- 誤用野指針
- 底層直接操作內存的代碼(即unsafe代碼)存在bug
對于第1類bug,智能指針可以解決;而第2類問題,并非智能指針的適用場景,只能交給開發人員解決。
至此,智能指針的引入看起來完美的解決了兩類常見的內存錯誤,然而事實并非如此。在C++中,智能指針以庫的形式存在,與語言本身無關。也就是說,開發人員即使寫出了明顯存在bug的智能指針用法,編譯器也會綠燈放行,畢竟沒有發現語法錯誤。等到程序上線運行后,因內存問題導致程序崩潰,此時的調查成本已經成十倍百倍的放大。這主要是因為由內存問題導致的程序崩潰,其崩潰點(coredump)往往非第一現場,很多時候都是崩潰之前寫亂的內存導致后面的程序執行出錯,進而觸發程序崩潰,根據一個滯后的崩潰點反推出第一現場,談何容易?
如果不幸遇到這么棘手的內存問題,并沒有什么容易的解決辦法,我一般的解決思路如下:
- Code Review。最樸實的辦法,往往有奇效。
- 如果容易重現,用valgrind運行,valgrind的設計理念只會誤報,不會漏報。使用valgrind運行問題程序的缺點是嚴重降低程序運行性能,如果是由一些多線程競爭條件下導致的內存問題,很可能因為在valgrind框架下程序運行變慢而無法復現。
- 如果不易復現,還可以通過試錯法。學習進程的內存布局、熟悉業務邏輯,在懷疑的代碼路徑上加內存保護(mprotect(2)),以期反向追蹤到第一現場。
總結
最初,C++將內存管理的責任完全交給開發人員;隨后,以庫的形式引入智能指針,實現了半自動的內存管理。之所以稱為半自動主要是因為智能指針以庫的形式存在,并未與語言進行整合,導致本可以在編譯期發現的內存問題被隱藏,直到運行時才暴露出來。半自動的表現如:
- 雖然提供智能指針,依然可以使用原始的new/delete管理內存。
- 已經將所有權轉移出去的unique_ptr,依然可以被使用。
除了認知負擔以外,C++對于軟件工程的支持也并不友好
1. 標準庫匱乏,第三方庫良莠不齊,缺少集中式管理
大家是否觀察到一個現象,大公司的招聘啟事幾乎都有C++崗位,而小公司鮮有C++崗位。
我想,這主要是因為大公司發展久,內部開發&維護著眾多C++項目(畢竟C/C++是大學教學語言,容易招人),而這些C++項目中的基礎組件會被不斷的抽取出來,形成功能豐富的基礎組件庫,功能豐富的基礎庫又會吸引新項目使用,形成良性循環。
而小公司是沒有人力和時間來維護一份自己的基礎庫的。生存下來是小公司的頭等要事,快速搭建產品原型、快速驗證商業模式、快速迭代,可以說,小公司的發展始終圍繞一個快字。這就需要小公司選擇高生產力的語言,而高生產力的最直觀體現就是該語言的標準庫是否豐富,能用久經考驗地標準庫解決的問題,沒人愿意試用良莠不齊、無質量保證、有爛尾風險地開源第三方庫。
C++的標準庫實在太簡陋了,在我看來,標準委員會對標準庫的定位:一來是給語言打補丁,彌補先天設計不足;二來是嘗試開拓語言邊界,讓C++適合更多開發場景。標準委員會并沒有豐富標準庫的意思。而一個項目在做技術選型時,必然考慮所選擇的技術是否有所需的功能,如果標準庫沒有,就不得不在第三方庫中選擇,而選擇本身就是成本,更別說選擇優秀的第三方開源庫本身也是個技術活。我們之所以相信標準庫,本質上是選擇相信將第三方庫納入標準庫的人,相信他們的判斷,相信他們的準入標準足夠嚴格。
2. 缺少標準化的工程實踐
要想完成一個工程項目,光有編程語言是不夠的,還需要各種周邊工具及規范:
- 代碼結構規范:如何組織內部代碼,如何組織第三方代碼。
- 編碼風格檢查工具:避免無意義的風格之爭,易于他人閱讀和理解。
-
構建工具/依賴管理工具:持續集成,持續交付。
這里還要吐槽一下C++的庫依賴關系,對于動態庫來說,僅需指定依賴庫,無需關心依賴的依賴,這很符合直覺;而對于靜態庫來說,不僅要指定依賴庫,還需要指定依賴的依賴,甚至當庫拆分不當,出現循環依賴的時候,還需要通過多次指定依賴庫的方式繞過! - 測試框架/Mock框架:便于編寫、運行測試用例,統計測試結果,保證質量。
- 代碼覆蓋率統計工具:衡量質量的基礎指標。
- 文檔工具:便于他人二次開發。
很遺憾,以上任何內容都沒有納入標準,各個公司、組織、個人都有自己的一套規范與工具。這對開發人員來說并非好事,無論是規范也好、工具也罷,本身并不產生生產力,糟糕的規范和工具甚至會限制生產力,人們只是希望遵守凝聚前人經驗智慧的規范,使用標準易用的工具。
后記
上述5點,都是以Go作為對比對象,Go雖然優雅地解決了上述C++所面臨的問題,但也并非完美,見Why Go Is Not Good。