程式設計世界的熵增原理

張鐵蕾發表於2019-02-25

歌者沒有太多的抱怨,生存需要投入更多的思想和精力。
宇宙的熵在升高,有序度在降低,像平衡鵬那無邊無際的黑翅膀,向存在的一切壓下來,壓下來。可是低熵體不一樣,低熵體的熵還在降低,有序度還在上升,像漆黑海面上升起的磷火,這就是意義,最高層的意義,比樂趣的意義層次要高。要維持這種意義,低熵體就必須存在和延續。

對科幻有一點了解的朋友也許已經猜到,這段描寫出自《三體》。這想必是整部《三體》中最燒腦的一段文字了。

歌者反覆提到的“低熵體”,到底是一個怎樣的存在呢?要理解它,我們首先要來講講“熵”這個概念。

據說,在很多物理學家的眼中,科學史上出現的最重要的物理規律,既不是牛頓三大定律,也不是相對論或者宇宙大爆炸理論,而是熱力學第二定律。它在物理規律中具有至高無上的地位,因為它從根本上支配了我們這個宇宙演化的方向。這個定律指出:任何孤立系統,只能沿著熵增加的方向演化。

什麼是熵?通俗來講,可以理解為物體或系統的無序狀態,或者混亂程度(亂度)。在沒有外力干涉的情況下,隨著時間的推移,一個系統的亂度將會越來越大。將冰塊投入溫水中,它終將融化,並與水交融為一體,因為溫水的無序程度要高於冰塊;一副撲克牌,即使按照花色和大小排列整齊,但經過多次隨機的洗牌之後,它終將變得混亂無序;一間乾淨整潔的房間,如果長期沒有人收拾的話,它將會變得髒亂不堪。

而生命體,尤其是智慧生命體(比如人類),卻是典型的“低熵體”,能夠維持自身和周圍環境長期處於低熵的狀態。可以想象,如果一所房子能夠長期保持乾淨整潔,多半是因為它有一位熱愛整潔且勤於家務的女主人。

縱觀整個人類的發展史,人們將荒野開墾成農田,將河流疏導成生命的水源,結束散居生活從而聚整合村落。同時,人類又花費了數千年的時間,建立起輝煌的城市文明。城市道路和建築樓群排列有致,軌道交通也井然有序;城市的地下管線錯綜複雜,為每家每戶輸送水電能源;清潔工人每天清掃垃圾,並將它們分門別類,運往恰當的處理地點……

所有的這一切,得以讓我們這個世界遠離無序狀態,將熵值維持在一個很低的水平。

但是,一旦離開人類這個“低熵體”的延續和運轉,這一切整齊有序都將不復存在。甚至是當人類的單個個體死亡之後,它的有機體也再不能維持自身。它終將隨著時間腐爛,最終化為泥土。


記得在開發微愛App的過程中,我們曾經實現過這樣一個主題皮膚的功能:

按照上面的截圖所示,使用者可以將軟體的顯示風格設定成多種主題皮膚中的一個(上面截圖中顯示了8個可選的主題)。當然,使用者同一時刻只能選中一個主題。

我們的一位工程師按照這樣的思路對儲存結構進行了設計:每個主題用一個物件來表示,這個物件裡儲存了該主題的相關描述,以及該主題是否被使用者選中(作為當前主題)。這些物件的資料最初都是從伺服器獲得的,都需要在本地進行持久化儲存。物件的資料結構定義如下(偽碼):

/**
 * 表示主題皮膚的類定義。
 */
public class Theme {
    //該主題的ID
    public int themeId;
    //該主題的名稱
    public String name;
    //該主題的圖片地址
    public String picture;

    //其它描述欄位
    ......

    //該主題是否被選中
    public boolean selected;
}

/**
 * 全域性配置:儲存的各個主題配置資料。
 * 從持久化儲存中獲得。
 */
Theme[] themes = getFromLocalStore();複製程式碼

上面截圖介面中的主題選中狀態的顯示邏輯如下(偽碼):

//輸入引數:
//介面中顯示各個主題的View層控制元件
View[] themeViews;

......

for (int i = 0; i < themeViews.length; i++) {
    if (themes[i].selected) {
        //將第i個主題顯示為選中狀態
        displaySelected(themeViews[i]);
    }
    else {
        //將第i個主題顯示為未選中狀態
        displayNotSelected(themeViews[i]);
    }
}複製程式碼

而使用者重新設定主題的時候,選中邏輯如下(偽碼):

//輸入引數:
//介面中顯示各個主題的View層控制元件
View[] themeViews;
//當前使用者要選擇的新主題的下標
int toSelect;

......

//找到舊的選中主題
int oldSelected = -1;
for (int i = 0; i < themes.length; i++) {
    if (themes[i].selected) {
        oldSelected = i; //找到了
        break;
    }
}

if (toSelect != oldSelected) {
    //修改當前選中的主題資料
    themes[toSelect].selected = true;
    //將當前選中的主題顯示為選中狀態
    displaySelected(themeViews[toSelect]);

    if (oldSelected != -1) {
        //修改舊的選中主題的資料
        themes[oldSelected].selected = false;
        //將舊的選中主題顯示為非選中狀態
        displayNotSelected(themeViews[oldSelected]);
    }

    //最後,將修改後的主題資料持久化下來
    saveToLocalStore(themes);
}複製程式碼

這幾段程式碼看起來是沒有什麼邏輯問題的。但是,在使用者使用了一段時間之後,有使用者給我們發來了類似如下的截圖:

竟然同時選中了兩個主題!而我們自己不管怎樣測試都重現不了這樣的問題,檢查程式碼也沒發現哪裡有問題。

這到底是怎麼回事呢?

經過仔細思考,我們終於發現,按照上面這個實現,系統具有的“熵”比它的理論值要稍微高了一點。因此,它才有機會出現這種亂度較高的狀態(兩個同時選中)。

什麼?一個軟體系統也有熵嗎?各位莫急,且聽我慢慢道來。

熱力學第二定律,我們通俗地稱它為熵增原理,乃是宇宙中至高無上的普遍規律,在程式設計世界當然也不例外。

為了從程式設計師的角度來解釋熵增原理的本質,我們仔細分析一下前面提到過的撲克牌洗牌的例子。我第一次看到這個例子,是在一本叫做《悖論:破解科學史上最複雜的9大謎團》的書上看到的。再也沒有例子能夠如此通俗地表現熵增原理了。

從花色和大小整齊排列的一個初始狀態開始隨機洗牌,撲克牌將會變得混亂無序;而反過來則不太可能。想象一下,如果我們拿著一副徹底洗過的牌,繼續洗牌,然後突然出現了花色和大小按有序排列的情況。我們一定會認為,這是在變魔術!

系統的演變為什麼會體現出這種明確的方向性呢?本質上是系統狀態數的區別

花色和大小有序排列,只有一種情況,所以狀態數為1;而混亂無序的排列方式的數量,是一個非常非常大的值。稍微應用一點組合數學的知識,我們就能算出來,所有混亂無序的排列方式,總共有(54!-1)種,其中(54!)表示54的階乘。混亂的狀態數多到數不勝數,因此隨機洗牌過程總是使牌序壓倒性地往混亂無序的方向發展。

而混亂無序的反面——整齊有序,則本質上意味著對於系統可取狀態數的限制。對於所有54張牌,我們限制只能取一種特定的排列,就意味著整齊。同樣,在整潔的房間裡,一隻襪子不會出現在鍋裡,或者其它任意地方,也是一種對於可取狀態的限制。

我們程式設計的過程,就是根據每一個條件分支,逐漸細化和限制系統的混亂狀態,從而最終達到有序的一個過程。我們構建出來的系統,對於可取狀態數的限制越強,系統的熵就越低,它可能達到的狀態數就越少,就越不可能進入混亂的狀態(也是我們不需要的狀態)。

回到剛才主題皮膚的那個例子,假設總共有8個主題,按前面的實現,每個主題都有“選中”和“未選中”兩個狀態。那麼,系統總的可取狀態數一共有“2的8次方”個,其中有8個狀態是我們所希望的(也就是有序的狀態,分別對應8個主題分別被選中的情況),剩餘的(2的8次方-8)個狀態,都屬於混亂狀態(錯誤狀態)。前面出現的兩個主題被同時選中的情況,就屬於這其中的一種混亂狀態。

在前面的具體實現中,程式邏輯已經在盡力將系統狀態限制在8個有序狀態上,但實際執行的時候還是進入了某個混亂狀態,這是為什麼呢?

因為一個具體的工程實現,是要面對非常複雜的工程細節的,幾乎沒有一個邏輯是能夠被完美實現的。也許在某個微小的實現細節上出現了意想不到的情況,也許是持久化的時候沒有正確地運用事務處理,也可能有來自系統外的干擾。

但是,對於這個例子來說,我們其實可以在限制系統狀態方面做得更好。有些同學可能已經看出來了,表示主題“選中”和“未選中”的狀態,其實不應該儲存在每個主題物件中(Theme類),而應該全域性儲存一個當前選中的主題ID,這樣,所有可能的選中狀態就只有8個了。

修改之後的資料結構如下(偽碼):

/**
 * 表示主題皮膚的類定義。
 */
public class Theme {
    //該主題的ID
    public int themeId;
    //該主題的名稱
    public String name;
    //該主題的圖片地址
    public String picture;

    //其它描述欄位
    ......
}

/**
 * 各個主題資料。
 */
Theme[] themes = ...;

/**
 * 全域性配置:當前選中的主題的ID。
 * 初始值是預設主題的ID。
 */
 int currentThemeId = getFromLocalStore(DEFAULT_CLASSIC_THEME_ID);複製程式碼

顯示邏輯修改後如下(偽碼):

//輸入引數:
//介面中顯示各個主題的View層控制元件
View[] themeViews;

......

for (int i = 0; i < themeViews.length; i++) {
    if (themes[i].themeId == currentThemeId) {
        //將第i個主題顯示為選中狀態
        displaySelected(themeViews[i]);
    }
    else {
        //將第i個主題顯示為未選中狀態
        displayNotSelected(themeViews[i]);
    }
}複製程式碼

使用者重新設定主題的時候,修改後的選中邏輯如下(偽碼):

//輸入引數:
//介面中顯示各個主題的View層控制元件
View[] themeViews;
//當前使用者要選擇的新主題的下標
int toSelect;

......

//找到舊的選中主題
int oldSelected = -1;
for (int i = 0; i < themes.length; i++) {
    if (themes[i].themeId == currentThemeId) {
        oldSelected = i; //找到了
        break;
    }
}

if (toSelect != oldSelected) {
    //修改當前選中主題的全域性配置
    currentThemeId = themes[toSelect].themeId;
    //將當前選中的主題顯示為選中狀態
    displaySelected(themeViews[toSelect]);

    if (oldSelected != -1) {
        //將舊的選中主題顯示為非選中狀態
        displayNotSelected(themeViews[oldSelected]);
    }

    //最後,將修改後的主題資料持久化下來
    saveToLocalStore(currentThemeId);    
}複製程式碼

這個例子雖然簡單,但卻很好地體現出了軟體系統的熵值的概念。

我們程式設計的過程,實際上就是不斷地向系統輸入規則的過程。通過這些規則,我們將系統的執行狀態限制在那些我們認為正確的狀態上(即有序狀態)。因此,避免系統出現那些不合法的、額外的狀態(即混亂狀態),是我們應該竭力去做的,哪怕那些狀態初看起來是“無害”的。


第二個例子

若干年前,當我們在某開放平臺上開發Web應用的時候,發生過這樣一件事。

我們當時的某位後端工程師,打算在新使用者第一次訪問我們的應用的時候,為使用者建立一份初始資料(UserData結構)。同時,在當前訪問請求中還要向使用者展示這份使用者資料。這樣的話,如果是老使用者來訪問,那麼展示的就是該使用者最新積累的資料;相反,如果來訪的是新使用者的話,那麼展示的就是該使用者剛剛初始化的這份資料。

因此,這位工程師設計並實現瞭如下介面:

UserData createOrGet(long userId);複製程式碼

在這個介面的實現中,程式先去資料庫查詢UserData,如果能查到,說明是老使用者了,直接返回該UserData;否則,說明是新使用者,則為其初始化一份UserData,並存入資料庫中,然後返回新建立的這份UserData。

如果這裡的UserData確實是一份很基本的使用者資料,且上述介面的實現編碼得當的話,這裡的做法是沒有什麼大問題的。對於一般的應用來說,使用者基本資料通常在註冊時建立,在登入時查詢。而對於開放平臺的內嵌Web應用來說,第一個訪問請求往往同時帶有註冊和登入的性質,因此將建立和查詢合併在一起是合理的。

但是不久,應用內就出現了另外一些查詢UserData的需求。既然原來已經有一個現成的createOrGet介面了,而且它確實能返回一個UserData物件,所以這位工程師出於“程式碼複用”的考慮,在這些需要查詢UserData的地方呼叫了createOrGet介面。

經過本文前面的討論,我們不難看出這樣做的問題:這種做法無意間讓系統的熵增加了。在本該是查詢的邏輯分支上,程式不得不處理跟建立有關的額外邏輯和狀態,而這些多餘的狀態增加了系統進入混亂的概率。

第三個例子

在這一部分,我們討論一個稍微複雜一點的例子,它跟訊息傳送佇列有關。

假設我們要開發一個IM軟體,就跟微信類似。那麼,它傳送訊息(Message)的時候,不應該只是提交一次網路請求這麼簡單。

  • 首先,前後多次傳送訊息的各個請求需要排隊;
  • 其次,由於網路環境不好而造成請求失敗時,應該在一定程度上能夠重試請求。
  • 第三,請求佇列本身需要持久化,這樣即使軟體重啟,未傳送完的訊息也能夠繼續傳送。

因此,我們需要為傳送訊息建立一個有排隊、重試和本地持久化功能的傳送佇列。

關於持久化,其實除了傳送佇列本身需要本地持久化,使用者輸入和接收到的聊天訊息,也需要本地持久化。當訊息傳送成功後,或者當訊息嘗試多次最終還是失敗之後,該訊息在傳送佇列的持久化儲存裡刪除,但是仍然儲存在聊天訊息的持久化儲存裡。

經過以上分析,我們的傳送訊息的介面(send),實現如下(偽碼):

public void send(Message message) {
    //插入到聊天訊息的持久化儲存裡
    appendToMessageLocalStore(message);
    //插入到傳送佇列的持久化儲存裡
    //注:和前一步的持久化操作應該放到同一個DB事務中操作,
    //這裡為了演示方便,省去事務程式碼
    appendToMessageSendQueueStore(message);

    //在記憶體中排隊或者立即傳送請求(帶重試)
    queueingOrRequesting(message);
}複製程式碼

其中,表示訊息的類Message,如下定義(偽碼):

/**
 * 表示一個聊天訊息的類定義。
 */
public class Message {
    //該訊息的ID
    public long messageId;
    //該訊息的型別
    public int type;

    //其它描述欄位
    ......
}複製程式碼

如前所述,當網路環境不好而造成請求失敗時,傳送佇列會嘗試重試請求,但如果連續失敗很多次,最終傳送佇列也只能宣告傳送失敗。這時候,在使用者聊天介面上通常會標記該訊息(比如在訊息旁邊標記一個紅色的歎號)。使用者可以等待網路好轉之後,再次點選該訊息來重新傳送它。

這裡的重新傳送,可以仍然呼叫前面的send介面。但是,由於這個時候訊息已經在持久化儲存中存在了,所以不應該再呼叫appendToMessageLocalStore了。當然,保持send介面不變,我們可以通過一個查詢操作來區分是第一次傳送還是重發。

修改後的send介面的實現如下(偽碼):

public void send(Message message) {
    Message oldMessage = queryFromMessageLocalStore(message.messageId);
    if (oldMessage == null) {
        //沒有查到有這個訊息,說明是首次傳送
        //插入到聊天訊息的持久化儲存裡
        appendToMessageLocalStore(message);
    }
    else {
        //查到有這個訊息,說明是重發
        //只是修改一下聊天訊息的狀態就可以了
        //從失敗狀態修改成正在傳送狀態
        modifyMessageStatusInLocalStore(message.messageId, STATUS_SENDING);
    }
    //插入到傳送佇列的持久化儲存裡
    //注:和前面兩步的查詢操作以及插入和修改操作
    //應該放到同一個DB事務中操作,
    //這裡為了演示方便,省去事務程式碼
    appendToMessageSendQueueStore(message);

    //在記憶體中排隊或者立即傳送請求(帶重試)
    queueingOrRequesting(message);
}複製程式碼

但是,如果按照本文前面分析的程式設計的熵增原理來看待的話,這裡對於send的修改使得系統的熵增加了。本來首次傳送和重發這兩種不同的情況,在呼叫send之前是很清楚的,但進入send之後我們卻丟失了這個資訊。因此,我們需要在send的實現裡面再依賴一次查詢的結果來判斷這兩種情況(狀態)。

一個程式執行的過程,本質上是根據每一個條件分支,從邏輯樹的頂端,一層一層地向下,選擇出一條執行路徑,最終到達某個終端葉子節點的過程。程式每進入新的下一層,它對於當前系統狀態的理解就更清晰了一點,也就是它需要處理的狀態數就少了一點。最終到達葉子節點的時候,就意味著對於系統某個具體狀態的確定,從而可以執行對應的操作,把問題解決掉。

而上面對於send的修改,卻造成了程式執行過程中需要處理的狀態數反而增加的情況,也就是熵增加了。

如果想要避免這種熵增現象的出現,我們可以考慮新增一個重發介面(resend),程式碼如下(偽碼):

public void resend(long messageId) {
    Message message = queryFromMessageLocalStore(messageId);
    if (message == null) {
        //不可能情況,錯誤處理
        return;
    }

    //只是修改一下聊天訊息的狀態就可以了
    //從失敗狀態修改成正在傳送狀態
    modifyMessageStatusInLocalStore(message.messageId, STATUS_SENDING);
    //插入到傳送佇列的持久化儲存裡
    //注:和前一步的持久化操作應該放到同一個DB事務中操作,
    //這裡為了演示方便,省去事務程式碼
    appendToMessageSendQueueStore(message);

    //在記憶體中排隊或者立即傳送請求(帶重試)
    queueingOrRequesting(message);
}複製程式碼

當然,有的同學可能會反駁說,這樣新增一個介面的方式,看起來對介面的統一性有破壞。不管是首次傳送,還是重發,都是傳送,如果呼叫同一個介面,會更簡潔。

沒錯,這裡存在一個取捨的問題。

選擇任何事情都是有代價的。如何選擇,取決於你對於邏輯清晰和介面統一,哪一個更看重。

當然,我個人更喜歡邏輯清晰的方式。


在熵增原理的統治之下,系統的演變體現出了明確的方向性,它總是向著代表混亂無序的多數狀態的方向發展。

我們的程式設計,以及一切有條理的生命活動,都是在同這一終極原理對抗。

更進一步理解,熵增原理所體現的系統演變的方向性,其實正是時間箭頭的方向性。

它表明時間不可逆轉,一切物品,都會隨著時間的推移而逐漸損壞、腐化、衰老,甚至逐漸喪失與周圍環境的界限。

它是時間之神手裡的鐵律。

程式碼也和其它物品一樣,不可避免地隨著時間腐化。

唯一的解決方式,就是耗費我們的智慧,不停地維持下去。有如文明的延續。

除非——

有朝一日,

AI出現。

也許,到那時,我們的世界才能維持低熵永遠運轉下去。

那時的低熵體,也許會像歌者一樣,輕聲吟唱起那首古老的歌謠:

我看到了我的愛戀
我飛到她的身邊
我捧出給她的禮物
那是一小塊凝固的時間
時間上有美麗的條紋
摸起來像淺海的泥一樣柔軟
……

(完)


後記

本文總共列舉了三個程式設計的實際例子。我之所以選擇它們作為例子,並不是因為它們是最好的例子,而是因為它們相對獨立,也相對容易描述清楚。實際上,在日常的程式設計工作中,那些跟本文主旨有關的、涉及系統狀態表達和維護的取捨、折中和決策,幾乎隨時都在進行,特別是在進行介面設計的時候。只是這其中產生的思考也許大多都是靈光一閃,轉瞬即逝。本文嘗試把這些看似微小的思想聚整合篇,希望能對看到本文的讀者們產生一絲幫助。

其它精選文章

相關文章