原创 张铁蕾 张铁蕾 2016-08-22
歌者没有太多的抱怨,生存需要投入更多的思想和精力。宇宙的熵在升高,有序度在降低,像平衡鹏那无边无际的黑翅膀,向存在的一切压下来,压下来。可是低熵体不一样,低熵体的熵还在降低,有序度还在上升,像漆黑海面上升起的磷火,这就是意义,最高层的意义,比乐趣的意义层次要高。要维持这种意义,低熵体就必须存在和延续。
对科幻有一点了解的朋友也许已经猜到,这段描写出自《三体》。这想必是整部《三体》中最烧脑的一段文字了。
歌者反复提到的“低熵体”,到底是一个怎样的存在呢?要理解它,我们首先要来讲讲“熵”这个概念。
据说,在很多物理学家的眼中,科学史上出现的最重要的物理规律,既不是牛顿三大定律,也不是相对论或者宇宙大爆炸理论,而是热力学第二定律。它在物理规律中具有至高无上的地位,因为它从根本上支配了我们这个宇宙演化的方向。这个定律指出:任何孤立系统,只能沿着熵增加的方向演化。
什么是熵?通俗来讲,可以理解为物体或系统的无序状态,或者混乱程度(乱度)。在没有外力干涉的情况下,随着时间的推移,一个系统的乱度将会越来越大。将冰块投入温水中,它终将融化,并与水交融为一体,因为温水的无序程度要高于冰块;一副扑克牌,即使按照花色和大小排列整齐,但经过多次随机的洗牌之后,它终将变得混乱无序;一间干净整洁的房间,如果长期没有人收拾的话,它将会变得脏乱不堪。
而生命体,尤其是智慧生命体(比如人类),却是典型的“低熵体”,能够维持自身和周围环境长期处于低熵的状态。可以想象,如果一所房子能够长期保持干净整洁,多半是因为它有一位热爱整洁且勤于家务的女主人。
纵观整个人类的发展史,人们将荒野开垦成农田,将河流疏导成生命的水源,结束散居生活从而聚集成村落。同时,人类又花费了数千年的时间,建立起辉煌的城市文明。城市道路和建筑楼群排列有致,轨道交通也井然有序;城市的地下管线错综复杂,为每家每户输送水电能源;清洁工人每天清扫垃圾,并将它们分门别类,运往恰当的处理地点……
所有的这一切,得以让我们这个世界远离无序状态,将熵值维持在一个很低的水平。
但是,一旦离开人类这个“低熵体”的延续和运转,这一切整齐有序都将不复存在。甚至是当人类的单个个体死亡之后,它的有机体也再不能维持自身。它终将随着时间腐烂,最终化为泥土。
记得在开发微爱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);
}
/**
* 表示一个聊天消息的类定义。
*/
public class Message { //该消息的ID public long messageId; //该消息的类型 public int type; //其它描述字段 ......
}
如前所述,当网络环境不好而造成请求失败时,发送队列会尝试重试请求,但如果连续失败很多次,最终发送队列也只能宣告发送失败。这时候,在用户聊天界面上通常会标记该消息(比如在消息旁边标记一个红色的叹号)。用户可以等待网络好转之后,再次点击该消息来重新发送它。
这里的重新发送,可以仍然调用前面的send接口。但是,由于这个时候消息已经在持久化存储中存在了,所以不应该再调用appendToMessageLocalStore了。当然,保持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);
}
当然,有的同学可能会反驳说,这样新增一个接口的方式,看起来对接口的统一性有破坏。不管是首次发送,还是重发,都是发送,如果调用同一个接口,会更简洁。
选择任何事情都是有代价的。如何选择,取决于你对于逻辑清晰和接口统一,哪一个更看重。
在熵增原理的统治之下,系统的演变体现出了明确的方向性,它总是向着代表混乱无序的多数状态的方向发展。
我们的编程,以及一切有条理的生命活动,都是在同这一终极原理对抗。
更进一步理解,熵增原理所体现的系统演变的方向性,其实正是时间箭头的方向性。
它表明时间不可逆转,一切物品,都会随着时间的推移而逐渐损坏、腐化、衰老,甚至逐渐丧失与周围环境的界限。
唯一的解决方式,就是耗费我们的智能,不停地维持下去。有如文明的延续。