首页 > 杂记 > 正文

编程世界的熵增原理


歌者没有太多的抱怨,生存需要投入更多的思想和精力。
宇宙的熵在升高,有序度在降低,像平衡鹏那无边无际的黑翅膀,向存在的一切压下来,压下来。可是低熵体不一样,低熵体的熵还在降低,有序度还在上升,像漆黑海面上升起的磷火,这就是意义,最高层的意义,比乐趣的意义层次要高。要维持这种意义,低熵体就必须存在和延续。

对科幻有一点了解的朋友也许已经猜到,这段描写出自《三体》。这想必是整部《三体》中最烧脑的一段文字了。

歌者反复提到的“低熵体”,到底是一个怎样的存在呢?要理解它,我们首先要来讲讲“熵”这个概念。

据说,在很多物理学家的眼中,科学史上出现的最重要的物理规律,既不是牛顿三大定律,也不是相对论或者宇宙大爆炸理论,而是热力学第二定律。它在物理规律中具有至高无上的地位,因为它从根本上支配了我们这个宇宙演化的方向。这个定律指出:任何孤立系统,只能沿着熵增加的方向演化。

什么是熵?通俗来讲,可以理解为物体或系统的无序状态,或者混乱程度(乱度)。在没有外力干涉的情况下,随着时间的推移,一个系统的乱度将会越来越大。将冰块投入温水中,它终将融化,并与水交融为一体,因为温水的无序程度要高于冰块;一副扑克牌,即使按照花色和大小排列整齐,但经过多次随机的洗牌之后,它终将变得混乱无序;一间干净整洁的房间,如果长期没有人收拾的话,它将会变得脏乱不堪。

而生命体,尤其是智慧生命体(比如人类),却是典型的“低熵体”,能够维持自身和周围环境长期处于低熵的状态。可以想象,如果一所房子能够长期保持干净整洁,多半是因为它有一位热爱整洁且勤于家务的女主人。

纵观整个人类的发展史,人们将荒野开垦成农田,将河流疏导成生命的水源,结束散居生活从而聚集成村落。同时,人类又花费了数千年的时间,建立起辉煌的城市文明。城市道路和建筑楼群排列有致,轨道交通也井然有序;城市的地下管线错综复杂,为每家每户输送水电能源;清洁工人每天清扫垃圾,并将它们分门别类,运往恰当的处理地点……

所有的这一切,得以让我们这个世界远离无序状态,将熵值维持在一个很低的水平。

但是,一旦离开人类这个“低熵体”的延续和运转,这一切整齐有序都将不复存在。甚至是当人类的单个个体死亡之后,它的有机体也再不能维持自身。它终将随着时间腐烂,最终化为泥土。


记得在开发微爱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出现。

也许,到那时,我们的世界才能维持低熵永远运转下去。

那时的低熵体,也许会像歌者一样,轻声吟唱起那首古老的歌谣:

我看到了我的爱恋
我飞到她的身边
我捧出给她的礼物
那是一小块凝固的时间
时间上有美丽的条纹
摸起来像浅海的泥一样柔软
……

(完)


后记

本文总共列举了三个编程的实际例子。我之所以选择它们作为例子,并不是因为它们是最好的例子,而是因为它们相对独立,也相对容易描述清楚。实际上,在日常的编程工作中,那些跟本文主旨有关的、涉及系统状态表达和维护的取舍、折中和决策,几乎随时都在进行,特别是在进行接口设计的时候。只是这其中产生的思考也许大多都是灵光一闪,转瞬即逝。本文尝试把这些看似微小的思想聚集成篇,希望能对看到本文的读者们产生一丝帮助。

其它精选文章


原创文章,转载请注明出处,并包含下面的二维码!否则拒绝转载!
本文链接:http://zhangtielei.com/posts/blog-programming-entropy.html
我的微信公众号: tielei-blog (张铁蕾)
上篇: 技术的正宗与野路子
下篇: 程序员的那些反模式