代碼的版本越高越難以被讀懂,是一種必然嗎?

大部分庫的0.x版本都比較容易閱讀和理解,但之後越寫越大,越polish越"臟",比如OGRE Nginx等。這是不可避免的嗎?

「臟」究竟是因為工程的複雜性本質 還是 人類思維或計算機體系結構的局限?


從什麼才算是真正的編程能力? - 劉賀的回答 引出來的問題。我來嘗試更系統的回答一下。

程序的複雜性有三種:

第一種是根本性的複雜。比如一個演算法,很抽象,很不直觀,模型脫離人的直覺認識,過程不容易可視化表達,因此不好理解。這種複雜存在的根本原因是數學模型本身的抽象。這種複雜是很難移除的,也不必移除。

第二種是過程性的複雜。比如在寫一個工程的時候,剛開始對問題沒有清楚的認識,但也必須摸著石頭過河先寫一下,然後再看結果如何,並不斷迭代,在迭代過程中產生了很多很難移除的迭代遺產。這種複雜存在的根本原因是人們從無到有認識事物需要一個過程。如果人們有精力有意願把這個工程從頭重寫一遍,那麼這種複雜是可以移除的。

第三種是失誤性的複雜。簡單地說,就是技術都是現成的,但是代碼寫屎了。這種複雜存在的根本原因是人無完人,是人都會犯錯誤。如果人們有精力有意願把這個工程從頭審視一遍,那麼這種複雜也是可以移除的。

第一種複雜體現出來是人類數字文明的進步。後兩種複雜體現出來則是臟代碼。

由於人們認識問題總要從無到有,並且每個人都會犯錯誤,所以臟代碼的產生是不可避免的。關鍵在於產生了之後是否能有效移除。

臟代碼移除是很困難的。一個根本問題在於缺乏反饋。人在寫代碼的時候心裡想的是如何解決一個問題,追求的往往是運行起來的高性能,和開發過程的低成本,而不會在乎代碼是否容易被拆分理解,對代碼的可讀性很少有客觀的考量。

那麼為什麼不考量呢?github上有很多開源代碼啊,為什麼大家不去讀呢?讀不懂為什麼不去要求作者提高可讀性呢?

因為大部分代碼都實在太難讀了,以至於一段代碼如果讀者讀不懂,讀者不會首先指責代碼作者的表達糟糕,而會先懷疑自己的閱讀理解能力低下。通常一個項目,如果超過5000行,基本就進入了不可讀的狀態。具體原因有三點:

一、程序語言本身過於複雜。每個人經常實際上都在使用語言的一個不同子集,一種自己的方言。於是一個人寫的C程序另一個也能寫C程序的程序員會完全讀不懂(不信去讀讀glibc?)。

二、項目模塊化缺乏管理,模塊之間大量使用循環依賴。循環依賴就像軟體工程中的goto一樣,開發的時候方便得很,但是卻是導致結構混亂的萬惡之源。假如一個項目的所有模塊都在一個大的循環依賴中,就導致如果了解這個項目不能從了解某個小的模塊開始,而必須要了解了所有模塊之後才能對這個項目的實現好壞有發言權。循環依賴使得一個讀者看不清它的具體結構,更不知道該從哪兒讀起。

三、工具鏈搭建複雜。一個小的項目,經常搭建一個編譯環境要一天。一個企業級的項目,則動輒要一周。和文字書寫的文檔不同,程序本是一個活物,要跑起來,給不同輸入,觀察其運作和輸出,讀起來才會事半功倍。然而如此繁瑣的工具鏈搭設,往往讓新來的人望而卻步。

然而代碼臟,API難用而不滿足使用者的需求,但又讀不懂,因此改不動,怎麼辦?於是人們便在原來的代碼介面之上再套一層,或者在旁邊多開幾個自己的,好封裝成滿足自己需要的結構和介面。怎麼快怎麼來。短期內,看起來問題似乎是有效解決了。可是實際上卻金玉其外敗絮其中,功能越封裝越複雜,引入更多的方言,結構越來越不可管理,工具鏈也越來越龐大。惡性循環。

於是,gcc沒人完全讀得懂,linux沒人完全讀得懂。人類自己從零搭建的純抽象的數字文明都建立在若干沒人完全能讀懂的神秘物體之上,有時想想實在很鬼魅。

怎麼辦?這事兒沒救了嗎?

我覺得還有救。但是人們寫程序的方法需要改進。

首先,設計並使用更簡單的程序語言。比如我最喜歡的語言是go語言,因為它簡單,可以100%了解其全部語言特性。go語言的標準庫是我唯一偶爾能讀懂的標準庫。

其次,要求把程序拆分成小模塊,並嚴格禁止循環依賴。一個沒有循環依賴的程序,才會總存在一個小的模塊不依賴任何其他模塊,而只要模塊足夠小,那麼讀不懂的人就可以直接說自己讀不懂,而不需要懷疑自己的閱讀能力。

最後,簡化工具鏈。編譯一個開源項目應該像打開一個網頁一樣簡單。編譯器應該直接在瀏覽器里運行併產生結果。在javascript都可以模擬x86並運行linux的今天,在有NaCl和asm.js的今天,配上go語言的鏈接結構所帶來的編譯速度,在瀏覽器里直接編譯運行程序其實是完全可行的。

做到了這些就不會有臟代碼了嗎?當然還會有。臟代碼的產生來自於人類認識的過程局限和人性弱點,所以是不可避免的。但是,我覺得,如果做到了這些,至少人們將可以開始讀代碼了,至少可以從不依賴任何模塊的最基礎的模塊讀起,並在讀不懂的時候可以大膽而及時地反饋給作者,而不需要懷疑自己的閱讀能力,甚至可以自己上手給出修改建議。假如這種反饋機制能形成一個大家一起互相讀代碼的社區,那麼可讀性好的代碼將得到彰顯並獲得更多的發展機會,從而形成良性循環而阻止惡性循環。

如果能讓代碼變得像小說一樣可讀,我覺得這是一項有可能會顛覆IT產業格局,甚至改變人類文明進程的技術。這是我做h8liu/e8vm · GitHub的目的,也是我希望我有生之年能做成的一件事情。

--

廣告時間,介紹鄙人的休閑娛樂項目e8vm。

e8vm是這樣一個項目:

  1. 它自定義了一套簡單的指令集(只有約50條指令),並為這個指令集寫了一個模擬器。

  2. 它擬為這個指令集設計一個簡單的語言,並為這個語言寫一個編譯器。語言類似於go語言,但是沒有垃圾回收,以適合寫操作系統。
  3. 它擬用這個設計出來的新語言寫一個簡單的操作系統。
  4. 項目目前用go語言編寫,在未來會翻譯到自己設計的新語言。
  5. 項目每個go語言文件不超過300行,每行不超過80個字元,文件之間沒有循環依賴。
  6. 除了go語言的標準庫,沒有時候其他第三方組件。
  7. 由於沒有循環依賴,所有模塊構成一個有向無環圖。整個項目可以自動可視化如:E8VM Source Map
  8. 做成之後,只需要把指令集的模擬器翻譯成javascript,整個系統就將可以在網頁中直接運行。
  9. 我希望項目的可讀性能夠因此很高。
  10. 性能一開始當然會很差,但我相信,如果項目能保持可讀性好,並且有人來讀,改起來容易,那麼性能優化只是時間的問題。
  11. 如果代碼可讀性好,並且在網頁中能簡單而直接地運行,將可以做一個網上的社區,大家從此用代碼說話交流,用代碼寫文章,用代碼問答技術問題,甚至用代碼直接做技術產品的交易。

目前只是一個人在做,還好自己喜歡。想參與一起玩的,咱們私信聯繫?


以前要寫處理PDF的東西,就找了Adobe的PDF文檔來看,結果1000多頁,結構極其複雜,看了一個禮拜都沒有頭緒。

後來我學乖了,找了PDF 1.0的文檔來看,才200多頁,結構清晰明了,很快就搞清楚了PDF的大致結構。看懂了1.0的文檔以後,發現後面的版本都是在以前的版本上做增量而已。

我想,代碼應該也是一個道理。學習新技術嘛,先花時間把原型搞明白,剩下的東西就好做多了。


你不能因為看不懂就說他臟,你之所以看不懂,是因為經過了那麼多個版本的變化,這個軟體要解決的問題已經跟你想像中的不一樣了。你連人家要幹什麼都不知道,當然看不懂。


後來的80%的你感覺髒的代碼是為了不重要的20%的功能。而你對行業不了解,沒用心思考過,不知道那20%的功能。


沒有做關注點分離的代碼就像圖像處理軟體裡面多個圖層最後「Merge Visible」的產物,一般來說除了原作者誰也還原不了最初的思路,除非遇到水平遠遠高出的高手。他能還原是因為他很早以前做過一樣的事,知道中手的思維方式


只會點 OOP 的程序員來試著回答一下。

先放結論:不是必然的。下面是我自己的一些思考,歡迎探討和指教。


軟體系統當中的複雜性分為兩部分:業務邏輯本身的複雜性,和代碼結構的複雜性。

對有點規模的系統程序來說,程序員本應該做的工作除了完成功能之外,就是消除代碼結構額外的複雜性,讓系統代碼整體的結構趨向於跟業務邏輯在各個抽象層面上一致——即人們常說的:你所寫的代碼就是你想要表達的業務。

那麼代碼結構為什麼總是變得越來越複雜呢?主要是跟程序員控制代碼結構複雜性的能力有關。能力不足的程序員很容易經常犯些小毛病——例如在一個方法內部出現了臨時變數、例如代碼中出現了一個帶了 2 個參數的方法 (注 [1]) ——但他本身並沒有意識到這是個問題。隨著業務的變化和增加,這種小毛病就會越積累越多。

到了某一時刻,程序員會發現以前某些代碼很難修改或者重用,多數情況下,能力不足的程序員這時候意識不到那些小毛病的存在,會很本能地選擇對以前的代碼做一種打補丁式的修改(比如多傳個參數、多加個條件分支來處理一下特殊情況),或者乾脆把代碼大片複製出來做些小修改,而不是重新組織它——也就是我們常說的「重構」。

程序員們經常把「重構」這個詞掛在嘴邊,但遇到問題他們的選擇要麼是打補丁,要麼乾脆全部重寫,真正做重構的真的很少,可能我見識少吧,工作這些年來好像還沒親眼見過。有點跑題了,收住。

打個比方。我們想像一下,上面我所提到的程序員在一開始經常犯的那些小錯誤,是在一根繩子上打了的一個小結。這種小結打多了之後,一整根繩子上就都是結。程序員對之前的問題代碼打補丁的動作可以看成在之前的小結上打了一個更大的結,結上再打結……如此發展下去,最終系統必然是一團錯綜複雜的結。這時候,問題本身終於顯而易見了——代碼無比的複雜,但產生這問題的根本原因還是像迷一樣難以被發現。

因為「解小結」的能力不足,所以才最終造成了這麼一團大結。同樣的因為「解小結」的能力一直不足,他們不知道應該從最外面的結開始解,解開一個結之後,才能再解這個結下面更多的小結(重構的過程真的是這樣,你會發現首先要把代碼用一種重構手法整理完之後,才能夠再用另一種重構手法對解開的代碼進行進一步的整理)。他們要麼直接把這一團東西扔了重寫一份——但是沒有訓練出「解小結」的能力的話,未來繼續重新開始打結是免不了的——要麼繼續痛苦地在這玩意上面打結。

代碼之所以不容易寫好,是因為許多錯誤的小決策往往要很久以後才能讓人看到顯而易見的問題。沒有即時的負面反饋,不夠細心的人們就發現不了問題,「問題」對他們來說不存在,「改進」自然就無從談起。複雜難用的系統都是由小毛小病慢慢積累而來,最終才表現成為顯而易見的大問題,但是每個小毛小病多數人看不見,臨時用打補丁的方式不講究地處理一下在他們看來似乎也沒什麼壞的影響。他們沒有意識到防微杜漸才是防止大病形成的唯一辦法。問題最終很明顯的表現出來之後他們也往往不知道根源到底在哪裡——其實根源都在之前做決定的每一步的細節里,沒有一個單獨存在的、明顯的大問題(如果有那早就被發現和解決了)——而正是因為這樣,最終的問題往往都很難解決,你總是,總是,會發現牽一髮要動全身。

所以代碼的版本越高越難以被讀懂,除了業務變得複雜了以外,我覺得主要的原因在於此。


提出問題,找到了原因,然後就得解決。

複雜是由簡單組合而成的,巨複雜的代碼結構問題本質上也都是些簡單的問題組合成的。最終的那個複雜無比的問題不可能只用一種簡單的手段一次性解決,只能針對組成整個大問題的那些小問題一個一個來解決。

那麼解決這些不起眼的小問題的手段是什麼?就是許多人不屑於練習的,被他們聲稱解決的問題過於簡單,被他們認為無法用於解決實際項目當中超級複雜的系統問題的,同樣不起眼的一個個重構手法。這些重構手法全在《重構》這本書後面的每一個示例里,我覺得這本書的正確讀法是把每個重構手法以及每一句示例代碼都認真的閱讀和理解一遍,搞清楚重構前的代碼問題在哪,為什麼那是個問題,應該如何解決,解決這種問題本質上所運用的該編程語言的特性和原理是什麼(比如像這樣)。然後也許就會發現,面向對象原來是這麼回事。

可能有人認為這書可以先放著,等遇到問題了再來讀。但矛盾的地方在於,這本書的其中一個作用就是訓練程序員對各種細微 bad smell 的嗅覺,在大腦里對各種 bad smell 設置 trigger。未經訓練的程序員在真遇到問題的時候,並不認為自己遇到了問題,因為那些問題就像這些重構手法一樣,是如此的不起眼,最後自然也就不會再次打開這本《重構》。

當程序員控制代碼結構複雜性的能力訓練得足夠好了,你會發現他寫的代碼里,大多數方法的代碼行數真的不超過 10 行(這個數字用不同的 OOP 語言會有所不同)。全是如此短小不互相糾纏的代碼,還會有人覺得難讀嗎?

我知道這可能可以引發出另一個問題的討論,到底什麼樣的代碼才叫「直觀」。似乎有不少程序員覺得層層調用的代碼很不「直觀」,把邏輯完全展開,平鋪直敘的代碼才叫「直觀」。這背後隱含的問題是「到底什麼是『複雜』」,這是另一個可以展開的話題了。我知道其實很多人不習慣跟類似「什麼是『複雜』」這樣看似簡單、每個人好像都知道,但認真解釋起來還是需要一些思考的概念死磕。

上面是我最近在思考的東西,我覺得這些可以算在一定程度上回答了題主的問題吧。


注 [1]:並不是所有的臨時變數都是不好的,也並不是所有參數多於 1 個的方法就是有問題的。這裡是故意把「大量臨時變數」中的「大量」給省去了,把「多個參數」寫成了「2 個參數」。因為不在具體的上下文當中,「大量」和「多個」根本沒個標準。

這裡想要表達的是,所有的 bad smell 都是一種反模式,所謂「模式」就是很容易被人發現的一種有規律的定式,一旦遇到就要動腦子分析,和具體的規模無關。但很多程序員傾向於總結出非此即彼的固定標準,因為具體問題具體分析太費腦子。所以:臨時變數既然不可能完全消除,就完全不需要消除;方法參數既然不可能永遠不超過 1 個那就可以是 5, 6, 7, 8 甚至 10 多個;方法行數既然不能全部減少到 5 行那就說明 100 來行也是沒問題的。最終失去了對 bad smell 的警覺。

而訓練有素的程序員會把這些模式當作「觸發器」,時時刻刻警覺著分析這些現象在當前的情況下到底算是個問題還是算正常情況。訓練有素的程序員知道很多東西不是非此即彼,不可以一概而論,編程本身是一項腦力勞動很大程度上也是因此吧。

但是多數情況下我們似乎都傾向於追求和諧的統一,只是因為那看起來更不需要思考。只是採用這種「不需要思考」的方式寫代碼造成結果卻往往是需要更多的思考,因為混亂的代碼結構維護起來絕對比條理清晰的代碼費腦得多。


1、成熟後的精鍊化導致;

2、附加項的增加;

3、全部走心的讀完。


」每次添加新功能都是對已有結構設計的破壞」

而在代碼迭代過程中很難有精力和全局觀去修正消除這種破壞。隨著代碼量的變大,這項工作的難度和成本會變得越來越大


個人覺得主要原因還是功能越來越多,大多數持續演化的系統都會經歷從小而精到大而全的轉變,這種轉變通常的副作用:

1.對代碼的重構,大部分是打補丁式的。這時候也未必會造成代碼可讀性的下降,但增加的代碼對需要解讀代碼又不需要這部分功能的人造成了干擾,成了所謂的「臟」的部分

2.設計層面的抽象和依賴關係越來越複雜;

3.依賴環境複雜,依賴庫持續膨脹;

出現這種情況,複雜性因素肯定是有的,大而全的系統通常突出適應性,追求在各種複雜場景下全面解決複雜問題的能力,解決複雜問題的系統則必然複雜,或在代碼實現上複雜或在設計層面複雜,但總之還沒什麼辦法讓這種複雜性憑空消失。

另外還要從成本的角度考慮,一個小而精的系統和一個大而全的系統在設計和實現層面肯定都是不一樣,而演化的過程必定成本可控(泛指開發、維護、使用、遷移等各種成本),這就導致了大多數的功能升遷採用打補丁的形式,鮮有推倒重來的,實在不行也是另起爐灶。


版本越高,功能越來越多,你要了解的概念也越來越多,變複雜是正常的。


在早期版本大多是想出一個能夠運行的版本,得到大眾認可之後就會想著要麼小而精、要麼大而全(居多),這樣一來就會想著把各種功能糅合進去,而且還要靈活糅合,這樣一來什麼抽象、模式就會上場了,代碼的複雜度就高了,對代碼閱讀者的編程能力要求就高了。


這要看代碼對需求的消化能力,我一般是通過代碼結構的複雜度來消化需求的複雜度,具體就是代碼如容器裝不下就換一個:函數-&>類-&>包-&>工程-&>領域。函數-&>組件-&>模塊-&>系統-&>平台-&>容器。保證代碼層次結構清晰,粒度均衡,讓代碼充滿美感。


推薦閱讀:

給一個巨大的繼承體系的基類增加新的屬性,如何降低重構強度?
這個例子中的if else也要重構掉嗎?
為什麼軟體開發需要重構?

TAG:重構 | 編程 | 代碼 |