崔鹏飞的Octopress Blog

当锤子遇到钉子

| Comments

此文标题党。更切题的标题应该叫做《如何用统计科学来黑星座》或者是《积极注册Facehub,促进统计科学蓬勃发展》

锤子 JS Promise

最近对JS社区里的Promises/A+规范产生了很浓的兴趣,感觉Promise这套东西确实蛮不错的,给异步算法的编写者和异步算法的消费者之间提供了一套统一的沟通手段,也为异步算法的消费者提供了更悦目易读的代码组织方式。

自己把它实现了一遍:https://www.npmjs.com/package/RWPromise

然后在武汉办公室run了3次workshop:http://cuipengfei.me/blog/2016/05/15/promise/

手里有了这么一把锤子就总想找个钉子敲一敲。总在想能去哪里找N多异步操作来让我来组织一下呢?

钉子 《异类》

无巧不成书,刚好最近在豆瓣上买了一本叫做《异类》的书在看: https://read.douban.com/ebook/10580943/

书中一开篇就提到了加拿大冰球运动员选拔机制中很有趣的一件轶事:

职业青年队绝大多数球员的生日集中在1月、2月和3月,这实在有点不可思议

加拿大冰球队按年龄分组所依据的分界线是1月1日,即从1月1日到当年12月31日之间出生的球员将会被分在同一组。也就是说,一个1月1日出生的选手,是在跟许多年纪比他小的队友争夺晋级权——在青春期到来之前,由于有将近12个月的年龄差距,球员之间在生理成熟度上将会表现出巨大的差异。

正如冰球队员的成长经历一样,因年龄大几个月而显现的微弱优势会在孩子的成长过程中不断积累,最终引导孩子走向成功或不成功,自信或不自信的轨道中,其影响会延伸许多年。

能否进入加拿大职业青年冰球队竟然和出生月份有关系,实在是太有趣了。

假想如果你运动天赋非常好,但是不幸出生在12月,于是不得不从小和大你将近一岁的少年运动员一起训练。 表现难免显得不那么出色,不受教练待见,得不到正面反馈,出场坐冷板凳……逐渐逐渐,伤仲永。

由此就联想到了,到TW来工作的人其出生月份是否也存在什么神奇的规律呢?

Facehub

生日信息哪家强? Facehub帮你忙,它可以查到每个注册用户的生日(只有月日,没有年)。

(注:Facehub是ThoughtWorks公司内部的一个社交网站,只对员工开放注册。用户可以在该网站了解其他同事的信息。)

作为一个内部推广的网站,FaceHub在公司内邮件组里总是铺天盖地、见缝插针地作广告宣传,每次看到觉得审美都疲劳了,不过到了需要的时候第一个就想到了它。 广告的作用,诚不我欺。

我先目测,Facehub用户不会超过500人。 并且用户的id是连续的自增数字,这就很好办了,我只要构造N多获取用户数据的GET请求就好了。 这N多的GET请求,肯定不能一个一个慢慢发送,那就需要异步请求了,这就是钉子啊! 这就是大量的需要被组织的异步操作啊! 钉锤终于有机会合体了。

于是我就写了这么个脚本: https://github.com/cuipengfei/Spikes/blob/master/js/birth/birth.js

其中使用Promise把大量的异步Http请求组织起来,然后统计其结果。

(注:如果需要自己跑这个脚本,需要把第七行的token替换成你自己的合法值,如果您没有账号的话就无需尝试了哦)

以下是统计结果,获取到用户数量348:

由此可以看到,来TW工作的人,11月,10月,8月出生的最多,5月出生的最少。

由于样本量不够大(只有348人),所以统计数据的含义还不太好说。

下面是我胡乱猜测的、不科学的、不严谨的理论

我们小学入学时卡生日印象中是用8月卡的,那么这就和加拿大的冰球挑队员卡1月类似。

那这样,小学入学后,优势的积累就应该会倾向于8,9,10这几月的学生(类比冰球青年职业队队员集中分布在1,2,3这几个月)。

这几个月生日的小学生从小积累优势,并在成年后把优势携带到了求职之中去。

那为什么我们统计出来是8,10,11月份的人份额多呢?怎么不是8,9,10这几个月呢?统计出来的数据为什么把9月给跳过去了呢?可怜的9月得罪谁了呢?

我猜是由于9月这群人会成为某种星座的几率实在是太高了,被命运之神无情的给镇压掉了。:(抱歉

既然已经有了这份代码,可以统计出生月份,索性再统计些其他侧面的数据:

可见天秤和狮子座的最多,金牛座的最少。和上面的月份排名差异不大。

仔细看下的话,发现我只得罪了7.4%的人,好欣慰:)

看入职日期,大学毕业和跳槽的高峰期很凸显。9,10月最少,这倒不算什么惊人的发现。

最后

由于只统计了348人的信息,所以以上猜测仅供娱乐,请勿当真。

请大家积极注册Facehub,并填写真实的生日信息,以促进统计科学的繁荣发展。 (如果您无法注册,可以考虑投一份简历,然后再来促进统计科学的繁荣发展哦)

自己动手实现Promises/A+规范

| Comments

Promise并不是一个新的概念,它已经有将近30年的历史.

其早期的雏形还有里氏替换原则的提出者Barbara Liskov的贡献在其中.

https://en.wikipedia.org/wiki/Futures_and_promises#History

而Promises/A+这个规范的出现,则为JavaScript世界中众多Promise实现库提供了一套统一的API和交互机制.

Promises/A+提供了配套的测试集:https://github.com/promises-aplus/promises-tests.

其中共有872个测试,如果你的实现能够让全部测试绿起来,则可以认为该实现符合了标准.

我的Promise实现:https://github.com/cuipengfei/Spikes/tree/master/js/promise

在npm上的发布:https://www.npmjs.com/package/RWPromise

要实现Promises/A+的规范其实并不需要很多代码,我的实现只有88行.当然,仅仅是符合规范和一个可用,易用的Promise库之间还有很大的差距.

如果作为教学或者演示的目的,我认为我的这份实现是已有实现中最简洁的一版.

自己实现Promise规范时需要注意的几点:

1. promise的状态一旦确定,不可更改

一个符合规范的promise有三种可能的状态:pending,resolved,rejected。

这三者是互斥的。

一个pending的promise可以变成resolved,或者rejected。

但是一旦进入resolved或者rejected状态,就再也不能变了。

用形象的语言来描述的话:一个promise就是一个关于未来的承诺,诺言一旦履行,不能反悔。

假设有如下代码:

1
2
var p = ???();//首先以某种方式拿到一个promise,假设这个promise现在是pending的
p.then(x,y);//然后把你希望在成功和失败时执行的x,y通过then方法挂进去

时间流逝,假设???()方法内部在未来某个不确定的时间执行了:

1
p.resolve();

然后,你的x函数应该会被调用。

再然后,无论p的resolve方法或者reject方法再怎么被调用,p的状态都不会再变更,x和y也再不会被执行了

2. 树状结构

对then方法的多次调用会形成一个树状的数据结构。

假设有如下代码:

1
2
3
4
var p = ???();//首先以某种方式拿到一个promise
p
    .then(a,b) //假设这次then的调用返回的是一个新的promise实例,称之为p1
    .then(c,d);//假设这次then的调用返回的是一个新的promise实例,称之为p2

上述代码等价于:

1
2
3
var p = ???();//首先以某种方式拿到一个promise
var p1 = p.then(a,b);
var p2 = p1.then(c,d);

当然,这个代码形成的会是类似于一个链表的结构,可以把它看作是树状结构的一个特例,也就是树中每个节点都最多只有一个子节点。

而如下的代码则会形成我们惯常看到的树:

1
2
3
4
5
6
7
var p = ???();
var p1 = p.then(a,b);
var p2 = p.then(c,d);
var p3 = p.then(e,f);

var p4 = p1.then(g,h);
var p5 = p3.then(i,j);

这时,树中每一个节点可以有任意多的子节点(取决于它的then被调用了多少次)。

了解promise的树状结构,将有助于实现promise时在自己脑子里构造递归模型。

3. 回调的执行时机

这是实现promise的时候,最容易把人搞晕的一点。

1
2
3
var p = ???();//首先以某种方式拿到一个promise,假设这时p是pending的状态
var p1 = p.then(a,b);
var p2 = p1.then(c,d);

以上代码执行完之后,我们手里有3个promise:p,p1,p2.

这时,a,b,c,d都还没有执行。

在未来某个不确定的时间,如果p的resolve方法被调用了,接下来会发生的事情是:

  • p会把传给resolve方法的参数value记住,并把自己的状态标记为resolved (以后就再也不能变了)
  • a会被调用到,其参数为value
    • 如果a执行过程中不出错
      • p1的状态被变成resolved,p1会把a的返回值记住
      • c会被调用到,其参数为a的返回值
        • 如果c执行过程中不出错
          • p2的状态被变成resolved,p2会把c的返回值记住
        • 如果c执行过程中出错
          • p2的状态被变成rejected,p2会把c抛出的异常记住
    • 如果a执行过程中出错
      • p1的状态被变成rejected,p1会把a抛出的异常记住
      • d会被调用到,参数为a抛出的异常
        • 如果d执行过程中不出错
          • p2的状态被变成resolved,p2会把d的返回值记住
        • 如果d执行过程中出错
          • p2的状态被变成rejected,p2会把d抛出的异常记住

这样,就看出递归的意思来了。不过b并没有在上面出现,这是因为p本身是被resolve的,b只有在p被reject的时候才会执行。

在未来某个不确定的时间,如果p的reject方法被调用了,接下来会发生的事情是:

。。。 。。。

就不用写了,把上面的a替换为b就好了。

以上的例子中,我们拿到p的时候它的状态是pending的,我们会先调用p的then,然后p才会被resolve(或者reject掉)。 也就是说当我们通过调用then传递给promise两个回调的时候,promise还没有能力确定应该执行哪个回调,只有当未来promise自己被resolve或者reject了的时候,它自己的状态确定了,它才知道该挑哪一个回调来执行。

还有另一种可能性,那就是当你拿到p的时候p就已经被resolve(或者reject掉了),这时如果你再调用then方法的话,所传入的两个回调,到底哪个应该被调用,马上就可以决定了。

也就是说回调被调用的触发点一共有三个,then,resolve,reject这三个方法。

利用CouchBase为弱网环境构建云同步Android应用

| Comments

背景

Wifi,4G,3G,这些我们习以为常的东西,未必对所有人来说都是随时可用的。

以我当前所在项目为例,应用场景是某欠发达地区医疗服务机构的药品库存管理。

所谓欠发达,具体怎样呢?

  • 没有台式电脑
  • 没有笔记本
  • 只能使用低端的安卓平板
  • 4G,3G信号不要想
  • 我们去过现场的一位同事甚至要爬到树上去,才能勉强收到2G信号 tree
  • 即便是2G信号,也是时断时续,非常不稳定

因此,需要随时保持连通的BS结构基本不可行,我们选择了重度依赖移动端设备本地存储的CS结构(胖客户端)。

网络不可用时,库存变动存储在安卓本地,何时网络可用,再将数据与服务器同步。

问题

以上描述的解决方案似乎合情合理,但是真实实施中还是遇到不少问题:

  • 本地schema与服务器schema不一致,中间涉及数据转换与回转
  • 本地到服务器的同步数据流动链条过长(本地orm->本地Json serialization->服务器Json deserialization->服务器orm),链条中任何一环都有出差池的可能性。 换句话说,导致数据健全性受损的可能性分散在了太多的点上,一旦出错,难以定位
  • 服务器到本地的数据同步,上一条中所描述的链条的逆向,同样是链条太长,潜在的出现错误的点太多
  • 服务器端所掌握的数据只是客户端真实数据的一个变体,并且还未必是最新的,这样就导致当移动端应用因其本地数据而出错的时候,我们只能对着服务器干着急

以上描述的问题并不是偶发性的,它不像这里有个bug今天修了,明天那里有个bug再修一次就好。

只要我们仍然要在弱网环境中运行应用,我们就需要重度依赖本地存储,就需要持续的在移动端和服务端进行双向数据同步,以上的问题就将会一直存在。

这是自然环境限制与技术选择所带来的固有的内在的问题。

解决方案

上面提到:

这是自然环境限制与技术选择所带来的固有的内在的问题。

这句话再解释明白一些,自然环境限制指的是很差的网络可用性,技术选择指的是服务器端提供REST API,移动端利用该API进行通信。

以上这二者相结合导致了上述情况成为了固有的内在的问题。

自然环境的限制我们无法突破,我们不能把基站部署过去,让大家打电话之前不用再爬到树上去。

但是技术选择是完全受我们控制的,是有做文章的空间的。

这就引出了文章标题提到的CouchBase。

CouchBase

关于CouchBase是一个怎样的DB,请大家自行搜索。

我们主要关注它推出的CouchBase-Lite(android和iOS均有对应版本)。

replicate

左边的绿色方框是移动端应用,它通过蓝色标示的Sync Gateway与CouchBase Server通信。

请注意图中的箭头都是双向的,任何一方对本地数据库的写操作,都会导致对方的更新。任何一方的网络暂时中断也没有关系,在网络恢复的时候将会自动重试。

这样一来,数据同步的思路就变了,不再是在服务器端定义上传下载的API,移动端进行调用。而是利用DB自有的replication机制进行数据同步。

这就意味着我们在移动端只需要关注建立领域特定的模型,并将其存储入移动端本地的CouchBase即可,至于后面的序列化、网络通信等等问题就不需要我们去担心了。

关注点中很大一部分就这样被分离了出去,交由infrastructure去完成。

至于DB自有的replication机制的可靠性,应该可以比较安全的做出假设,认为一个有商用场景的DB厂商的通用数据备份机制不会比我们自己拼凑出来的更差。

一个原型

https://github.com/cuipengfei/Spikes/tree/master/android/sync-prototype

上面的链接是一个基本可用的购物清单应用。全部代码都在,供参考。

下面谈如何把玩它。

第一步

下载CouchBase Server: http://www.couchbase.com/nosql-databases/downloads#,安装,配置管理员账户,不赘述。

在CouchBase Server的Admin console(默认地址: http://127.0.0.1:8091/index.html)中创建一个bucket,命名为demodb。

第二步

安装sync_gateway,Mac用户可以:

brew install sync_gateway

以上github代码克隆下来后,sync-gateway路径下有个名为start_sync_gateway_server.sh的脚本,运行它来启动sync gateway。

第三步

运行同一个路径下的create_user.sh,来创建一个名为user1的用户,然后运行create_session.sh,为该用户创建一个session。

create_session.sh脚本有类似如下的输出:

{"session_id":"a469f18027647e4957ffd1743e2ea33ce0386dbc","expires":"2016-02-21T17:51:43.071175586+08:00","cookie_name":"SyncGatewaySession"}

把其中的session id记下备用。

(注:这里的用户和session都是sync gateway需要的,与CouchBase Server无直接关系)

第四步

找到代码中的MainActivity类,在startSync方法中加入session id:

1
2
3
4
5
6
7
8
9
10
//......
Replication pullReplication = database.createPullReplication(syncUrl);
pullReplication.setCookie("SyncGatewaySession", "a469f18027647e4957ffd1743e2ea33ce0386dbc", null, 86400000000000L, false, false);
pullReplication.setChannels(asList("user1"));
pullReplication.setContinuous(true);

Replication pushReplication = database.createPushReplication(syncUrl);
pushReplication.setCookie("SyncGatewaySession", "a469f18027647e4957ffd1743e2ea33ce0386dbc", null, 86400000000000L, false, false);
pushReplication.setChannels(asList("user1"));
pushReplication.setContinuous(true);

这段代码负责启动replication,双向同步从此而起。

找到createGroceryItem,为其中创建document的代码指定其所归属的用户:

1
2
3
4
5
//......
Document document = database.createDocument();

Map<String, Object> properties = new HashMap<String, Object>();
properties.put("channels", asList("user1"));

这几行代码可以保证各个移动端用户之间的数据不会混杂在一起。

第五步

在genymotion中启动android虚拟机(如果使用其他虚拟设备或者真机,请注意修改代码中的服务器ip地址)。

在购物清单中创建几条记录,然后清空移动端本机数据,重启应用,可以看到刚刚被清空的购物清单会从服务器上同步回来。

也可以尝试把虚拟机的网络连接断掉,创建或者修改几条记录,稍后重新连通网络,可以发现数据仍然可以上传到服务器。

还可以尝试用第三步中提到的脚本多创建几个用户,在不同的android虚拟机中使用不同用户,可以发现它们对彼此的数据是没有访问权的。

总结

以上第五步提到的双向同步,离线操作,不同用户之间的数据隔离,都不需要我们写任何特殊的代码来实现。

我们移动端的代码与CouchBase的集成基本就只涉及到第四步中提到的启动replication和创建document,那这样移动端剩下的工作就只有构建业务逻辑了。

如果你的移动端应用也需要在弱网环境下进行离线操作,在网络恢复时与服务器同步数据的话,不妨尝试一下CouchBase。

2015

| Comments

2015年结束了,一如已经结束了的每一年,非常迅速。

按惯例,从博客说起。

博客

15年写了16篇博客,其中9篇是与Scala,Reactive,OODP相关的。这个数字倒还不算太坏。

14年总结的时候说:

明年可以改进的是不要嫌话题小,不要嫌话题不够深。 有了有价值的想法就记下来,形成惯性。

15年第一季度没有执行这一条原则。其原因在于懒惰。

15年第二季度超额执行。其原因在于找到了主线任务,足够的探索便促成了足够的产出。

第三季度和第四季度未执行。其原因在于到客户现场去工作彻底打乱了所有的日常习惯与日程计划。

这么看来,博客这一块有得有失,得者为要找到一个足够有趣的主线任务,用来催生产出。失者为习惯的打破与难以重补。

由此得出16年需要执行的事项:

主线任务要明确,暂定为Reactive以及与之相关的一切。Review不能每个季度做一次,要每个月做一次,每次可以短,不能没有,这样强迫自己去关注进度,不可斜视。

读书

这个数字可以,分布情况不太漂亮。5月和9月是两个空档。

5月是因为刚去TWU,说得过去。9月是因为去了客户现场,其实也说得过去。

但是内容控制的不好。

14年总结说的是:

另外一个改进点是领域,我明年需要看一些轻量级的经济、哲学和社会心理学的书。口说无凭,于此立字为据。

这方面第一季度执行的可以,第二季度开始用trello track,也不错。 但是下半年就废了,还是前文提过的同样的原因。

16年的trello重新建了一个board,遵循上段同样的方式,每个月review。

MOOC

极其差。

一年只上完了一门课。没有勇气提16年的目标了。看际遇吧。

健身

这个坚持执行的很好。还探索出了好的玩法。

数据统计不完整,但是可以凑合看,增重0.4,减脂0.1,算是净增1斤肌肉。曲线不难看,但是这个数字不算漂亮。

从8月4号开始用bodyspace来track运动量,至此共91次,重量累计935吨。

这个数字累积到1000吨(也就是100万千克)时就换方法,不着重统计重量了,而是统计围度。

下面统计下出勤率:

从14年6月8日到现在,共574天。出勤362次,出勤率63%。

时间管理与统计

这个统计是从15年4月1日开始的,9,10月份由于去客户现场的原因,中断掉了。数据就放在这儿,不分析了。

其他

这一年脾气见好,不错。

2015第三季度

| Comments

惨。

八月份上了一个大客户的项目,到了晚上没力气也没心情做任何事情。

博客

没写。上个季度六月的系列告一段落后7月没写。到了8月就不用说了。

读书

七月八月读了7本书,9月一本未读。

在trello中建的计划,其中7月执行的不错,8月一般,九月,当让,啥都没做。

MOOC

无。

翻译

上次提到的书已经交稿了,坐等出版。

健身

这项不错,我从一开始就想好,无论什么事没时间没心情做,这件事不能停。

从八月份开始,有了系统的计划

Alt text

三个大肌群,所有小肌群,各自一个计划。

从八月初执行至今,共记录41次workout,举铁345吨。

Alt text

平均每次去健身房8吨,日最高记录15吨。

这段时间出勤率67%。

从去年6月8日到现在,共485天,出勤314次,出勤率64%。

其中武汉201次,印度72次,成都41次。

总之

Alt text

2015第二季度

| Comments

2015竟然这么快就过去一半了。逝者如斯夫。

博客

第一季度总结的时候说:

今年的博客主线任务定为OO与FP的比较和结合应用吧。

这个任务完成的不错,这个主题写了7篇博客。

迭代器已经irrelevant了,中介者和备忘录太简单就没写,状态模式没找到好的FP实现方式。 这样11种行为模式除去上述4个,算是基本覆盖完了。

Principles of Reactive Programming作业导学写了两篇,后面的有点难,写不出来了。。。 这一点后面MOOC再说。

还有一篇gender pay gap的博客,算是今年到目前为止阅读量最多的得意之作。

在武汉做了一次学英语的workshop,在TWU做了pecha kucha,总结成了两篇。

这样,不算季度总结的博客,写了12篇,数量和内容自己都满意。和技术相关的还可以充当复习和刷新记忆的资料。

不错,博客这方面达标了。

读书

这个季度比较惨。这一季度只读了五本书。

一部分原因在于TWU的日程太紧,一部分原因在于博客,MOOC和翻译占用的时间很多,不可兼得。

还好第一季度看书比较多,所以到目前为止这半年一共读过16本书,总体数量还不算太惨。

这十六本书里有7本有笔记,可以充当复习和刷新记忆的资料,而且效果很好。自己写的笔记,瞄一眼,整本书的内容和重点就全部复活。

不过看书的领域方面有点杂,2014总结的时候说:

明年需要看一些轻量级的经济、哲学和社会心理学的书。

这方面执行的不好。

http://i1.tietuku.com/26b6dd870a313983.png

于是建了一个trello,把2015下半年要读的书预先plan出来。每个月plan两三本,一部分符合上述他山之石的领域,一部分符合OOP和FP的技术主线。

http://i1.tietuku.com/09607b3bd8c47d1d.png

这样plan的数量并不大,如果突然出现兴趣很高,或者优先级很高的书的话,可以随时插入计划中。

另外,pipeline定义清晰,每本书要读完,有笔记,有复习,有某种形式的产出(笔记也算)。

MOOC

把Principles of Reactive Programming跟完了,证书拿到了。

http://i1.tietuku.com/cff384ac4788e10b.png

但是照实说,这门课没学懂,只是应付过了。后四周的作业导学没写出来。

主要原因在于事先对课程难度估计过低,投入时间不够。下次开课,需要再跟一遍。

除此之外,下半年对于MOOC这方面不做过多预期,有特别好的特别感兴趣的就跟,没有就算了。

算是对年初说过的话彻底食言了。。。

体重

在印度这段时间健身房出勤率74%左右,还挺好。

现在,60.5公斤,第一季度结束时是61。基本算是没变。充分说明了没有改进目标就不会有成绩。

不过鉴于我现在已经不算胖子了,这方面仍然不做过多奢求,维持就好。

另外,腹肌的轮廓开始出现了,我很开心:)

翻译

接了出版社一个翻译的活儿,《Seven more languages in seven weeks》,是本蛮不错的书。这个额外的任务投入时间较多也是读书方面有欠缺的原因之一。

对出版充满期待。

时间管理与统计

从四月一号开始,开始使用pomotodo这款超赞的番茄钟软件。从开始的第一天开始,到现在为止,凡是需要坐下来专心执行的事情都有记录。

http://i1.tietuku.com/50d083b12ab39342.png

这款软件的统计分析功能很好用,每个番茄钟还可以加tag。可以看到,这三个月做多的时间放在了TWU的备课上(26%),其次是翻译书(16%),mooc和blog紧跟其后(13%和10%),然后还有读书和写TWU需要的总结反馈(都是7%)。

这样,有数据,为什么读书少就一目了然了。

另外,可以看到这三个月平均日完成7.55个番茄钟。这个数量很说明问题,工作的效率靠感觉是感觉不出来的,要靠统计数据和分析。 7.55个番茄钟,相当于四个小时左右。每天平均专心工作,执行任务的时间只有四个小时啊!

这里面有一部分原因是TWU过程中需要听别的讲师的很多sessions,如果没有这个因素,日均数量应该是9个左右。

下个季度这方面的数据会是什么样的很难说,如果做的工作中有很大部分涉及沟通和协调的话,这个数字或许会走低。

模板方法模式:子类型多态和高阶函数

| Comments

模板方法模式定义了一个算法的步骤,并允许次类别为一个或多个步骤提供其实践方式。让次类别在不改变算法架构的情况下,重新定义算法中的某些步骤。

以上是wiki对模板方法的定义。

比较容易理解,我们有一个算法,其中某些步骤是确定的不太会变的代码。而另外一些步骤则需要变化并且自由组合。

《Head First Design Patterns》里有一个🌰:

假设我们需要制作咖啡因饮品,其实就是咖啡和茶。制作步骤有些类似,分为四步:1烧水,2泡,3装杯,4加调料。

其中第一步和第三步是稳定的代码,变化可能性不大,而第二步和第四步则每种饮品有自己的风味。

这样就有了下面的代码:

Java

首先有一个咖啡因饮品的抽象类,定义一个算法骨架:1烧水,2泡,3装杯,4加调料。 其中的第二步和第四步是有待实现的抽象方法,留给子类决定怎么搞。第一步和第三步是写死的。

接下来是咖啡,它实现了过滤咖啡和添加牛奶、糖的步骤。这样当它的实例的prepareRecipe方法被调用时就会执行父类的烧水、装杯,以及自己的泡和加调料。

还有,就是茶了。它和咖啡不一样,是用浸泡而不是过滤,加的是柠檬而不是牛奶和糖。

最后用一个main函数来执行制作咖啡和茶的代码。

很好,如果再有其他的咖啡因饮品,只需要增加一个子类,并且实现两个方法就好了。只要我们对于四个步骤的定义在该领域中足够稳定,这份代码就是很好很强大,易于扩展的。

有代码如此,夫复何求呢?

不过再想一下

这个模式想要达到的,不过是将一个算法的某些部分做的灵活一些,可以自由替换和组合。

那这个,不就是函数组合吗?如果我们使用的是允许高阶函数的语言的话,那还有什么必要把这些函数包装在类里呢?

functions

接下来是用Scala实现的版本:

首先,定义三个type,分别是泡和加调料这两个步骤,还有饮品本身(这三个type其实是一样的,看起来有点傻)。

然后有一个算法骨架,把第一和第三步锁死,把第二和第四步空出来,分别用一个参数来实现注入不同的实现。

接下来有泡和加调料的四种不同实现,分别是一个函数,符合各自的函数签名。

最后,用一个main函数来执行。可以看到,泡和加调料的函数是作为参数传入的。如果我们需要加牛奶和糖的茶,或者是柠檬味的咖啡的话,也会变得非常容易。

就这样,51行代码变成了28行。四个类变成了一个object。

而如果是要用子类型多态(subtype polymorphism)来做到这样的自由组合,那么我们需要的或许就是策略模式,把泡和加调料分别写成接口并提供不同的实现类来组合。可以想象,这会导致很多的boilerplate。

结语

Java代码中实现多态的方式是通过子类继承父类并且实现抽象方法来实现的。而Scala代码中则是通过把不同的函数传入骨架组合出一个新的函数来实现的。

子类型多态(subtype polymorphism)是个好东西,但是在某些场景下显得有点重。能用高阶函数这种轻量级的方式来实现的时候,就没有必要选择子类型多态这种过重的方式。

访问者模式 in FP:Pattern Matching

| Comments

访问者模式是一种将算法与对象结构分离的软件设计模式。

这个模式的基本想法如下:首先我们拥有一个由许多对象构成的对象结构,这些对象的类都拥有一个accept方法用来接受访问者对象;访问者是一个接口,它拥有一个visit方法,这个方法对访问到的对象结构中不同类型的元素作出不同的反应;在对象结构的一次访问过程中,我们遍历整个对象结构,对每一个元素都实施accept方法,在每一个元素的accept方法中回调访问者的visit方法,从而使访问者得以处理对象结构的每一个元素。我们可以针对对象结构设计不同的实在的访问者类来完成不同的操作。

以上是wiki对访问者模式的定义。

这个定义着实难读。我们来看wiki给出的例子:

假设我们要为汽车建模,汽车有不同的组成部件,轮胎,车身,和引擎。

在开车之前需要先检查车辆每个部件的状况,然后依次启动所有部件以启动汽车。

在这里我们很容易识别出车的组件各自应该是一个实体。而对车辆组件进行检查和启动的代码应该分别处于不同的实体中。

这样就有了访问者的代码(来自wiki):

Java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
interface ICarElement {
    void accept(ICarElementVisitor visitor);
}

class Wheel implements ICarElement {
    private String name;

    public Wheel(String name) {
        this.name = name;
    }

    public String getName() {
        return this.name;
    }

    public void accept(ICarElementVisitor visitor) {
        visitor.visit(this);
    }
}

class Engine implements ICarElement {
    public void accept(ICarElementVisitor visitor) {
        visitor.visit(this);
    }
}

class Body implements ICarElement {
    public void accept(ICarElementVisitor visitor) {
        visitor.visit(this);
    }
}

class Car implements ICarElement {
    ICarElement[] elements;

    public Car() {
        this.elements = new ICarElement[]{new Wheel("front left"),
                new Wheel("front right"), new Wheel("back left"),
                new Wheel("back right"), new Body(), new Engine()};
    }

    public void accept(ICarElementVisitor visitor) {
        for (ICarElement elem : elements) {
            elem.accept(visitor);
        }
        visitor.visit(this);
    }
}

首先是汽车部件的实体。它们都实现同一个ICarElement的接口。 该接口定义一个accept方法,用来接受访问者然后用访问者来访问所有汽车部件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
interface ICarElementVisitor {
    void visit(Wheel wheel);

    void visit(Engine engine);

    void visit(Body body);

    void visit(Car car);
}

class CarElementPrintVisitor implements ICarElementVisitor {
    public void visit(Wheel wheel) {
        System.out.println("Visiting " + wheel.getName() + " wheel");
    }

    public void visit(Engine engine) {
        System.out.println("Visiting engine");
    }

    public void visit(Body body) {
        System.out.println("Visiting body");
    }

    public void visit(Car car) {
        System.out.println("Visiting car");
    }
}

class CarElementDoVisitor implements ICarElementVisitor {
    public void visit(Wheel wheel) {
        System.out.println("Kicking my " + wheel.getName() + " wheel");
    }

    public void visit(Engine engine) {
        System.out.println("Starting my engine");
    }

    public void visit(Body body) {
        System.out.println("Moving my body");
    }

    public void visit(Car car) {
        System.out.println("Starting my car");
    }
}

然后就是访问者的实体。它们都实现ICarElementVisitor接口。 这个接口里定义的方法有点多,分别对应每个汽车部件定义了一个visit方法的重载。

在实现的时候自然是做检查的实体实现每个部件的检查,启动的实体实现每个部件的启动。

这里就有一个陷阱,如果代码发展的趋势是汽车部件的种类会增加的话,那这个接口就很不稳定。每增加一种汽车部件就要修改接口并且修改每个实现类。

而如果代码发展的趋势是在自检和启动之外加一些保养啊,洗车啊之类的话就没问题,不需要对已有代码进行修改。

所以使用访问者模式的时候要注意识别被访问者是否是相对稳定而访问者是有扩展趋势的,这样用这个模式才合适。

接下来的代码把以上所有代码串起来执行:

1
2
3
4
5
6
7
public class VisitorDemo {
    public static void main(String[] args) {
        ICarElement car = new Car();
        car.accept(new CarElementPrintVisitor());
        car.accept(new CarElementDoVisitor());
    }
}

从最后的main函数来看,只要能确保汽车部件的数量不会增加,而只有访问者增加,那么客户代码只需要增加一行就能够增加对整车进行清洗或者保养的行为。

车的部件和对部件的操作相互分离,独立发展。很灵活,很巧妙,对吧?

不过再想一下

其实也不需要使劲想了,如果你看过这一系列博文前面的几篇的话,想必已经能够猜到我的用意了。

这些访问者存在的意义就在于承载对汽车部件的某些具体操作,操作是个好听的词儿,说白了就是函数啊。

那既然这些类只是承载函数而已,何不直接就用函数而不费劲去用类包裹一层呢?

functions

那接下来就是用Scala的实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
trait CarElement {
  def accept(visitor: Visitor) = visitor(this)
}

case class Body() extends CarElement

case class Engine() extends CarElement

case class Wheel(name: String) extends CarElement

case class Car() extends CarElement {
  val elements: Seq[CarElement] = Seq(
    Wheel("front left"), Wheel("front right"),
    Wheel("back left"), Wheel("back right"),
    Body(), Engine())

  override def accept(visitor: Visitor) = {
    elements.foreach(_.accept(visitor))
    visitor(this)
  }
}

以上是汽车各种部件的定义,和Java代码没有太大区别。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
object Visitors {
  type Visitor = CarElement => Unit

  val printVisitor: Visitor = {
    case Wheel(name) => println(s"Visiting $name wheel")
    case Body() => println("Visiting Body")
    case Engine() => println("Visiting Engine")
    case Car() => println("Visiting Car")
  }

  val doVisitor: Visitor = {
    case Wheel(name) => println(s"Kicking my $name wheel")
    case Body() => println("Moving my body")
    case Engine() => println("Starting my engine")
    case Car() => println("Starting my car")
  }
}

上面这一段定义了一个叫做Visitor的type,它只是一个函数签名。任何接受一个汽车部件作为参数并且没有返回值的函数都符合它的签名,也就可以被视作Visitor。

接下来是两个符合Visitor签名的函数,都是用pattern match实现的。

pattern match这种神奇的语言特性是如何实现的呢?背后的原因并不神奇,更多详情请参考我之前的另一篇博客:http://cuipengfei.me/blog/2013/12/29/desugar-scala-8/

1
2
3
4
5
6
7
object VisitorDemo {
  def main(args: Array[String]) {
    val car = Car()
    car.accept(printVisitor)
    car.accept(doVisitor)
  }
}

最后定义一个main函数,与Java的main函数做的事情是等价的。

这样,100行变成了45行。Visitor不再作为臃肿的实体存在,而只是函数。

而且如果遵照同样的假设,认为车的部件是稳定的,而访问者是会增多的,那这段Scala代码的增长趋势是每加一个访问者就加一个函数。与Java代码的增长趋势相同。

结语

这次分析的访问者模式和之前的一些模式很类似,当我们需要的实体仅仅是作为承载某种行为的一个载具,那就可以考虑将实体消去,而换用函数这种更简单,更轻量级的抽象方式来实现我们想要的东西。

当年OO模式出现的时候,FP并不盛行,原作者提出的方案无可厚非。不过我们今天有了FP这种更趁手的工具,就可以考虑在合适的时候将其与OO结合使用来达到更好的设计的目的。

观察者模式 in FP:Mutation vs Transformation

| Comments

观察者模式

观察者模式(有时又被称为发布/订阅模式)是软件设计模式的一种。在此种模式中,一个目标对象管理所有相依于它的观察者对象,并且在它本身的状态改变时主动发出通知。这通常透过呼叫各观察者所提供的方法来实现。此种模式通常被用来实作事件处理系统。

以上是wiki对观察者模式的解释。

举一个《Head first design pattern》中的例子:

比如说有一个气象站,每当气象有变化的时候就需要显示当前天气。 需要显示历史平均气温,最高气温和最低气温。 还需要根据气压预测晴雨。

这种情况就很适合使用观察者模式,每种需要显示气象的装置作为观察者,气象数据本身作为可以被观察的对象。 每当气象变化的时候,被观察的对象就会通知观察者来根据新的数据作出新的显示。

以下是书中给出的代码:

Java

1
2
3
4
5
6
7
8
9
public interface Observer {
    void update(float temp, float humidity, float pressure);
}

public interface Subject {
    void registerObserver(Observer o);

    void notifyObservers();
}

首先定义两个接口,一个是观察者,接收新的气象数据。一个是被观察者,可以注册观察者以及通知观察者。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public class WeatherData implements Subject {
    private ArrayList<Observer> observers = new ArrayList<>();
    private float temperature;
    private float humidity;
    private float pressure;

    public void registerObserver(Observer o) {
        observers.add(o);
    }

    public void notifyObservers() {
        for (Observer observer : observers) {
            observer.update(temperature, humidity, pressure);
        }
    }

    public void setMeasurements(float temperature, float humidity, float pressure) {
        this.temperature = temperature;
        this.humidity = humidity;
        this.pressure = pressure;
        notifyObservers();
    }
}

接下来定义气象数据本身。代码很容易理解,把观察者保存在一个list里,每当气象数据变化的时候就通知这些观察者去做出新的处理。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
public class CurrentConditionsDisplay implements Observer {

    public CurrentConditionsDisplay(Subject weatherData) {
        weatherData.registerObserver(this);
    }

    public void update(float temperature, float humidity, float pressure) {
        System.out.println("Current conditions: " + temperature
                + "F degrees and " + humidity + "% humidity");
    }
}

public class StatisticsDisplay implements Observer {
    private float maxTemp = 0.0f;
    private float minTemp = 200;
    private float tempSum = 0.0f;
    private int numReadings;

    public StatisticsDisplay(WeatherData weatherData) {
        weatherData.registerObserver(this);
    }

    public void update(float temp, float humidity, float pressure) {
        tempSum += temp;
        numReadings++;

        if (temp > maxTemp) {
            maxTemp = temp;
        }

        if (temp < minTemp) {
            minTemp = temp;
        }

        System.out.println("Avg/Max/Min temperature = " + (tempSum / numReadings)
                + "/" + maxTemp + "/" + minTemp);
    }
}

public class ForecastDisplay implements Observer {
    private float currentPressure = 29.92f;
    private float lastPressure;

    public ForecastDisplay(WeatherData weatherData) {
        weatherData.registerObserver(this);
    }

    public void update(float temp, float humidity, float pressure) {
        lastPressure = currentPressure;
        currentPressure = pressure;

        System.out.print("Forecast: ");
        if (currentPressure > lastPressure) {
            System.out.println("Improving weather on the way!");
        } else if (currentPressure == lastPressure) {
            System.out.println("More of the same");
        } else if (currentPressure < lastPressure) {
            System.out.println("Watch out for cooler, rainy weather");
        }
    }
}

然后有三个观察者,分别负责显示当前气象,气象历史分析和晴雨预测。

CurrentConditionsDisplay是最简单的,没有任何状态,它只是负责在每次气象有变化的时候把最新的气象显示出来。

StatisticsDisplay复杂一点点,它需要记录历史气温,以便于计算平均温度,最高和最低气温。这是一个会有状态变化的对象。

ForecastDisplay也有状态变化,它需要记录上次的气压,以便于根据气压变化来预测晴雨。

1
2
3
4
5
6
7
8
9
10
11
12
13
public class WeatherStation {

    public static void main(String[] args) {
        WeatherData weatherData = new WeatherData();
        CurrentConditionsDisplay currentDisplay = new CurrentConditionsDisplay(weatherData);
        StatisticsDisplay statisticsDisplay = new StatisticsDisplay(weatherData);
        ForecastDisplay forecastDisplay = new ForecastDisplay(weatherData);

        weatherData.setMeasurements(80, 65, 30.4f);
        weatherData.setMeasurements(82, 70, 29.2f);
        weatherData.setMeasurements(78, 90, 29.2f);
    }
}

最后,有一个main函数,把以上所有代码串起来。

三个观察者都在观察同一个气象数据,每当气象有变化的时候,三个观察者都会被通知,并作出相应处理。

如果我们需要其他的更复杂的气象显示装置,只需要实现Observer接口,注册到气象数据上去,那么在每次气象有变化的时候就可以收到通知并作出处理。不需要对已有代码做出任何改变。

很灵活,很强大,对吧?

不过再想一下

观察者模式有没有更好地实现方式呢?

答案肯定是有的。

C#的delegate和Event就是一种用来实现观察者模式的很好的语言特性。它在语言级别为添加事件订阅和取消订阅提供了支持。

不过这一篇博客主要是想要讲一个immutable的观察者模式实现,C#就不多讲了。

可以想一下,上面的Java代码里的三个观察者,CurrentConditionsDisplay是没有任何状态变化的,它存在的意义仅在于其update方法。 而这个方法每次都是接受最新的气象数据,并作出输出。

StatisticsDisplay和ForecastDisplay则是截取气象历史数据不同的片段,将其作为可变状态封装在内部,并据其状态的改变决定update方法的行为。

这样看来,如果我们有一种方式,可以提供完整的气象数据历史,那么这三个观察者就都可以各取所需,而不需要拥有自己的可变状态了。

具体该怎么做呢?

functions

以下是Scala的实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
case class WeatherData(temperature: Float = 0,
                       humidity: Float = 0,
                       pressure: Float = 0,
                       observers: Seq[Observer] = Nil,
                       history: Seq[WeatherData] = Seq(WeatherData(history = Nil))) {

  def register(observer: Observer) =
    this.copy(temperature, humidity, pressure, observers :+ observer, history)

  def weatherChanged(weatherData: WeatherData) = {
    val newHistory = history :+ weatherData
    observers.foreach(observer => observer(newHistory))
    this.copy(temperature, humidity, pressure, observers, newHistory)
  }
}

object Observers {
  type Observer = Seq[WeatherData] => Unit

  val currentConditionsDisplay: Observer = history =>
    println(s"Current conditions: " +
      s"${history.last.temperature} F degrees and " +
      s"${history.last.humidity} % humidity")

  val statisticsDisplay: Observer = history =>
    println(s"Avg/Max/Min temperature = " +
      s"${history.map(_.temperature).sum / history.size}" +
      s"/${history.map(_.temperature).max}" +
      s"/${history.map(_.temperature).max}")

  val forecastDisplay: Observer = history => {
    val currentPressure = history.last.pressure
    val lastPressure = history.dropRight(1).last.pressure

    print("Forecast: ")
    if (currentPressure > lastPressure) println("Improving weather on the way!")
    else if (currentPressure == lastPressure) println("More of the same")
    else if (currentPressure < lastPressure) println("Watch out for cooler, rainy weather")
  }
}

object WeatherStation {
  def main(args: Array[String]) {
    val weatherData = WeatherData()
      .register(currentConditionsDisplay)
      .register(statisticsDisplay)
      .register(forecastDisplay)

    weatherData
      .weatherChanged(WeatherData(80, 65, 30.4f))
      .weatherChanged(WeatherData(82, 70, 29.2f))
      .weatherChanged(WeatherData(78, 90, 29.2f))
  }
}

以上就是Scala实现的全部代码了。

开始分析之前,先做一个极其复杂的数学运算: 106行的Java代码,等价于54行Scala代码。 7个类,变成了3个。

下面开始正经的分析。

首先有一个叫做WeatherData的case class,它是完全不可变的。

其register方法,接受一个新的Observer作为参数,并产生一个新的包含比原来多一个Observer的WeatherData实例。

其weatherChanged方法接受一个新的气象数据,生成一个新的历史数据Seq,并把目前为止包含所有历史气象数据的Seq传递给每一个Observer去做处理。最后返回一个包含最新历史数据的新的WeatherData实例。

那么这些Observer具体是怎么定义的呢?

首先Observer只是一个type,不是一个class,它是没有状态的,用来定义函数签名。

三个具体的display仅仅是三个符合Observer签名的函数,它们都接受气象历史数据作为参数,在历史数据中各取所需,作出处理。都是没有任何副作用的。 这很合理,毕竟只是display,仅需要对数据进行分析和显示,只读不写,没有什么要改变已有数据的必要性。

最后一个main函数把所有代码串起来,就得到了一份没有任何可变性的代码。

Mutation vs Transformation

在Java版的代码中,不同的显示设备不断地根据最新的气象数据改变自己的状态,并根据改变之后的状态来决定其update的行为。

而在Scala代码中,不同的显示设备没有状态,它们都仅仅是函数而已。它们在每次气象变化时根据全部气象历史数据决定自己的行为。

全部代码中没有重新赋值语句,所有的赋值操作都是对局部变量的赋值,程序员可以感知到的变化就只在于observers列表和history列表的增长。而即便是这两个数据结构的增长都是通过不断生成新的不可变的Seq来实现的。

总结来说,Java版代码通过改变已有数据来达成行为的改变。而Scala代码则通过利用不可变的函数和不断生成不可变的数据来实现行为的改变。

这种不可变的代码于什么优势呢?

其好处在于需要程序员操心的事情更少。变化的点越少,麻烦事越少。

如果以上的Java代码有问题,程序员除了需要检查计算平均气温,最高最低气温,气压变化的算法之外,还需要检查重新赋值语句所造成的效果。气温的sum是否算对了?测温次数是否算错了?气压变化是否记录对了?这些都是变化的点,这些都是导致错误的可能性之所在。

而在Scala代码中,如果代码有问题,同样需要检查算法的正确性,也就是检查不可变的函数的正确性。除此之外,只需要检查history列表的增长就可以了。而一个列表的增长是很难出错的。

Java中所有对象状态的改变分散在代码中不同的地方,到了Scala代码中它们都集中到了一个列表的增长上,仅仅通过对这个列表的transformation就驱动了其余全部代码的行为改变。减少了变化的点,就减少了出错的可能情况的数量,减少了程序员的负担。

解释器模式:OOP Versus Functional Decomposition

| Comments

解释器模式

In computer programming, the interpreter pattern is a design pattern that specifies how to evaluate sentences in a language. The basic idea is to have a class for each symbol (terminal or nonterminal) in a specialized computer language. The syntax tree of a sentence in the language is an instance of the composite pattern and is used to evaluate (interpret) the sentence for a client.

以上是wiki对解释器模式的描述。

这是一个学术性稍强的模式,不太好找到生活化的比喻。

就直接上代码吧。

Java

1
2
3
interface Expression {
    int interpret(Map<String, Expression> variables);
}

首先有一个表达式的接口,定义一个求值的方法,该方法接收一个String -> Expression的map。

可以猜到,这个map就是该表达式求值的时候需要用到的context。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
class Plus implements Expression {
    Expression leftOperand;
    Expression rightOperand;

    public Plus(Expression left, Expression right) {
        leftOperand = left;
        rightOperand = right;
    }

    public int interpret(Map<String, Expression> variables) {
        return leftOperand.interpret(variables) + rightOperand.interpret(variables);
    }
}

class Minus implements Expression {
    Expression leftOperand;
    Expression rightOperand;

    public Minus(Expression left, Expression right) {
        leftOperand = left;
        rightOperand = right;
    }

    public int interpret(Map<String, Expression> variables) {
        return leftOperand.interpret(variables) - rightOperand.interpret(variables);
    }
}

class Number implements Expression {
    private int number;

    public Number(int number) {
        this.number = number;
    }

    public int interpret(Map<String, Expression> variables) {
        return number;
    }
}

class Variable implements Expression {
    private String name;

    public Variable(String name) {
        this.name = name;
    }

    public int interpret(Map<String, Expression> variables) {
        if (null == variables.get(name)) return 0;
        return variables.get(name).interpret(variables);
    }
}

然后有表达式的四个实现类:加法表达式,减法表达式,数字表达式,还有变量。

数字表达式在求值的时候就直接返回它被创建时拿到的数字就好了,这是最简单的。

加法和减法的interpret方法在求值的时候仅仅是把行为委托给了两个子表达式,再对子表达式的求值结果做加减法。

变量在求值的时候则是去context里面寻找其name对应的表达式(也就是它所指向的表达式),然后对其求值。

下面是对它们的结合使用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class InterpreterExample {
    public static void main(String[] args) {
        Map<String, Expression> context = new HashMap<>();
        context.put("w", new Number(5));
        context.put("x", new Number(10));
        context.put("z", new Number(42));

        Plus expr = new Plus(new Variable("w"),
                new Minus(new Variable("x"),
                        new Variable("z")));

        System.out.println(expr.interpret(context));
    }
}

首先构造一个context,里面有w,x,z三个数字。然后计算w+(x-z)的值(看着像不像Lisp?)。

不过再想一下

这些代码实际做的是什么事呢?

实际就是把一个以遇到Number为退出条件的递归算法拆碎了。

如果我们不把它拆碎,就写成递归函数会如何呢?

functions

用Scala试着实现一下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
trait Expr

case class Plus(left: Expr, right: Expr) extends Expr

case class Minus(left: Expr, right: Expr) extends Expr

case class Number(n: Int) extends Expr

case class Var(name: String) extends Expr

object ExprEval {
  def eval(expr: Expr, context: Map[String, Expr]): Int = {
    expr match {
      case Plus(l, r) => eval(l, context) + eval(r, context)
      case Minus(l, r) => eval(l, context) - eval(r, context)
      case Var(name) => eval(context(name), context)
      case Number(n) => n
    }
  }

  def main(args: Array[String]) {
    val context = Map("w" -> Number(5), "x" -> Number(10), "z" -> Number(42))
    val expr = Plus(Var("w"), Minus(Var("x"), Var("z")))
    println(eval(expr, context))
  }
}

以上就是全部代码,与Java版等价。 递归函数很容易看懂,其退出条件也很容易看出来。

69行代码变成了26行。

四个case class代表四种表达式,其中并没有什么方法,只是用来作为数据的承载者。

一个eval函数,用pattern match来对四种表达式进行不同的处理。

不过这次我倒不是要宣扬说解释器模式属于是用不合适的工具解决问题。

而是要介绍两种组织代码的方式:按行组织还是按列组织。

按行组织代码与按列组织代码

昨天我在看解释器模式,准备写一个Java实现,再写一个Scala实现,并以此来达到我黑Java的阴暗目的。

但是看了wiki上的示例代码后,马上就想起了去年上过的一门MOOC:《Programming languages》。 (这门课是由华盛顿大学的Dan Grossman教授讲授的,内容极好,强烈推荐。)

这门课里有一节就提到了上面说的两种组织代码的方式:按行组织还是按列组织。 这节课的视频在这里:https://www.youtube.com/watch?v=uEHnI3pq_FM)

比如我们上面的两版代码,Java代码把对表达式的求值分散在每个不同的表达式类里。

而Scala代码把求值代码集中写在一个函数里,pattern match每种表达式类型并求值。

如果要做成一个表格的话,就是这样的:

table

其中的问号代表具体的求值实现。

Java代码横向组织,有一个Plus类,里面有interpret方法,有一个Minus类,里面有interpret方法,等等。这是按照行组织。

而Scala代码则纵向组织,有一个eval函数,纵向把四种表达式的求值都包揽了。这是按列组织。

上面的表格太小,看着不明显,现在假设我们需要打印表达式的功能。那么表格就会变成这样:

table2

可以想象,Java代码里会在每个表达式类里加一个toString函数的实现。横向扩展,一个类把数据和算法组织在一起。

而在Scala代码里则会写一个toString的递归函数,包揽所有字符串打印的工作。纵向扩展,一个函数去分辨数据类型,并据此选择计算策略。

OOP versus Functional Decomposition

那到底哪种组织方式更好呢?

并没有确定的答案,Dan Grossman教授在课程中给出的解释是这样的:

FP and OOP often doing the same thing in exact opposite way: organize the program “by rows” or “by columns”. Which is “most natural” may depend on what you are doing (e.g., an interpreter vs. a GUI) or personal taste.

到底如何组织取决于你想要解决什么样的问题,比如你要做一个GUI库,那么数据与算法放在一起,互相接近是最自然的组织方式。这时选择OOP是最好的设计决策。

而如果你要实现的东西类似于本文中的解释器,那么一个递归的算法来统一处理所有表达式类型则是最自然的。这时选择Functional Decomposition是最好的设计决策。

结语

OOP与Functional Decomposition,这二者并不是完全对立的。

熟练掌握多种抽象与代码组织方式,正确识别应用场景,据此选择合适的范式,或者是选择多种范式结合使用,才是这一系列博文的真实用意。

只不过由于传统的OO设计模式过于盛行,FP范式接受度不够,才会有这一系列博文黑Java,捧Scala的表象。