0%

“实战Elisp”系列旨在讲述我使用Elisp定制Emacs的经验,抛砖引玉,还请广大Emacs同好不吝赐教——如果真的有广大Emacs用户的话,哈哈哈。

序言

上一篇文章的末尾,我说有一个更优雅的办法实现快速设置任务的开始时间,本文便来揭秘这个办法。

阅读全文 »

本文是“实战Elisp”系列的第一篇。本系列旨在讲述我使用Elisp定制Emacs的经验,抛砖引玉,还请广大Emacs同好不吝赐教——如果真的有广大Emacs用户的话,哈哈哈。

序言

用Emacs的org-mode安排业余时间颇有些时日,渐渐地开始编写一些Elisp函数来改善自己的使用体验。

日程管理中一个常见的需求,便是给任务设定一个开始时间。在org-mode中,这可以调用函数org-schedule实现:将光标移动到一个任务上,再按下C-c C-s,会出现一个日历界面,帮助选择日期和时间来作为日程的开始时间。具体效果如下图所示

阅读全文 »

序言

9102年都已经过去好几天了,现在才来产出年终总结。

个人项目

cuckoo——定时提醒工具

18年的总结中,我提到自己开发了一个名为cuckoo的工具,用来代替macOS的提醒事项、日历,以及由crontab调用的shell脚本。这个目标在19年得以实现,cuckoo已经完全取代了它们。

cuckoo实现了以下功能:

  1. 创建一次性和周期性的提醒。cuckoo甚至可以在正确的二月最后一天弹出提醒——不管是平年还是闰年;
  2. 利用alertercuckoo可以在提醒弹出后推迟它(5分钟、10分钟),或推迟到指定的时刻;
  3. 利用Server酱cuckoo可以把提醒以微信消息推给手机;
  4. 利用ControlPlane,实现按场景提醒——比如10点钟若在公司就提醒自己开晨会,若在家则绝不弹出。

我还提供了给Emacs用的minor mode和Alfred Workflow,以提高易用性:

  1. 在Emacs的org-mode中启用这个minor mode后,只需要按下C-c r便可为光标所在的条目创建提醒;
  2. 一个条目切换至DONECANCELLED状态时,也会自动更改cuckoo中任务的状态(感兴趣的读者可以移步之前的文章);
  3. Alfred Workflow便于创建一次性提醒——比如提醒自己在25分钟后打开支付宝的蚂蚁庄园看看有没有鸡贼。

wa——Alfred Workflow脚本

在18年入手MBP后不久,我便入手了Alfred,并购买了Powerpack。平均每天使用Alfred 110次,大多是Snippets(auto expansion真香)、Clipboard(临时存储文字和图片的绝佳位置)、Workflow。常用的Workflow都是我自己开发的:

  1. unit用于快速输入不同时间长度的秒数的,例如输入6天的秒数6 * 24 * 60 * 60 * 1000
  2. upload用于上传图片到GitHub,把GitHub当图床用(感兴趣的读者可以移步这篇文章);
  3. gt用于获取指定日期的UNIX时间戳、int用于获取一些预设的时间戳(例如“昨天0点”);
  4. yl用于精确设定macOS的音量;
  5. bqb用于斗图(感兴趣的读者可以移步这里)。

这些脚本都收集在名为wa的私有仓库中。由于需求比较稳定,这个仓库的迭代不多。

jjcc——将LISP语言编译为x64汇编

不害臊地说,jjcc是一个用Common Lisp写就的编译器,运行在SLIME中,如果投喂它某种LISP方言代码,就可以编译出跑在macOS上的x64汇编代码。这是我的第一款生成汇编指令的编译器,为此还恶补了不少汇编语言知识,尤其是x64的calling convention。这款编译器的开发过程写成了文章发表在博客和知乎上,感兴趣的读者可以移步这个专栏

在完成了蹩脚的自定义函数特性后,我读了《An Incremental Approach to Compiler Construction》这篇论文,它循序渐进地开发一个Scheme语言到x86汇编的编译器,并且阶段划分得更好,后来我也按照论文的思路重新实现了一遍。

savemoney——未完待续的RescueTime代替品

19年10月份时RescueTime Premium到期,由于太鸡肋了便不再续费。我仍然有time-tracking的需求,于是打算自己动手写一个代替品。皇天不负有心人,我找到了active-win这个库,它可以获取当前有焦点的窗口的元信息。基于这个库我写了两个脚本:

  1. savemoney.js,每隔一秒调用active-win获取当前激活窗口的元信息,然后写入到Redis中;
  2. accounting.js,不停地从Redis中取出数据,运算后写入到MySQL中。

目前仅仅是将数据记录在了MySQL中,没有做报表和统计。这两个脚本通过Launchd在每次登录后自动运行。

写作

19年在GitHub博客一共发表了28篇博文,数量差强人意,质量亟待提高。在读了利用金字塔原理写出好文章后,我逐渐改进自己的写作方法,以期写得更有条理。

除了GitHub博客和SegmentFault,我还在知乎上发表了一些文章,大多是关于jjcc编译器的。刚才我说文章的质量亟待提高,如果你看过jjcc编译器系列的文章的话,一定会很同意我的观点。

博客和SegmentFault的受众很小,于是我又开通了微信订阅号,希望可以在技术圈子里更多的展示自己——有没有值得展示的内容另说。在订阅号上发表文章多了一种无形的压力,毕竟这些内容更容易被同学、同事、同行看到(前提是人家乐意看)。万一写的很糟糕,可就糗大了。目前订阅号关注者寥寥,有兴趣的可以微信搜索“小打小闹写点bug”关注。

学习、进修

19年读完的书不多:

  1. 《MongoDB in Action》,没什么太大的收获;
  2. 《High Performance MySQL》,看过后确实有些帮助,切实根据书中的指导调优过生产环境的MySQL;
  3. 《Algorithms》,光看没练手;
  4. 《Linux Shell Scripting Cookbook》,看着看着幡然醒悟这东西只要用到的时候查阅就足够了,遂弃之。

还有许多在读经典书籍,如《Clean Architecture》、《重构》。刚开始我会在上下班搭地铁时读这些书,后来将看书时间固定在了每天下午一点至一点半。这些在读的书像一条队列,我每天会读队头的书,然后放到队尾。这样一来,每天都在涉猎不同领域的内容。

在18年9月,我闭门造车地整理了一份Web后端软件工程师的技能树,再据此来寻找要读的书。例如,上述的《MongoDB in Action》是依据“后端知识/文档数据库/MongoDB”找的,《Linux Shell Scripting Cookbook》是依据“后端知识/命令行操作”找的。技能树整理得好不好暂且按下不表,但渐渐地我忘记了看书的初衷,成了“为看而看”。看似每天中午都在学习,实际上由于目的性不强,收获不大。由于每天读不同类型的书,同一个主题的学习过程也变得支离破碎。这个学习方法已经到了迫切需要优化的地步。

macOS更新换代——AppleScript来袭、Chrome上位

19年10月升级到macOS Catalina,然后问题便接踵而至。首当其冲的是alerterterminal-notifier没法用了,无法在右上角弹出提醒。一番折腾后不见起色,只好先用AppleScript代替,让cuckoo可以弹出提醒。AppleScript的display notification功能远不及alerter那么丰富,聊胜于无吧。

Firefox也开始闹别扭。只要打开Firefox稍微用两下,就会有一个名为FirefoxWebCP Extension的进程疯狂地使用CPU,Firefox内的各标签页也纷纷失灵转圈,几天下来都是如此。没办法,只好起用Chrome。稍微磨合后发现Chrome其实挺不错,各方面都今非昔比。以前之所以一直坚守在Firefox的阵营,主要是因为:

  1. Firefox的Pocket插件更好用——早年间不需要打开Pocket的网站即可查看自己的列表,不过自从Firefox集成Pocket后,这个优势已经荡然无存;
  2. Firefox的Vimperator更好用——这也一样,Vimperator逝者已逝,继承者Vim vixen和Chrome的Vimium大同小异;
  3. Firefox的地址栏搜索浏览历史更好用,这一点迄今未被Chrome超越——Chrome的地址栏要么搜不到,要么必须输入更多关键词,然后还是搜不到。

希望Mozilla在2020年可以修复这个问题,让我重回Firefox的怀抱。

CL虐我千万遍,我待CL如初见

2019年的Common Lisp依然让人哀其不幸怒其不争,我也依然痴迷于这门古怪的语言。但痴迷不能当饭吃,要将CL投入到实际应用实在太难。且不说Quicklisp上库的数量远不及PyPI和NPM,质量也令人抓狂。这不,都9102年了,访问一个返回JSON数据的HTTP接口,还得先用drakma发出请求(也许不支持HTTP/2),再用flexi-streams将字节数组转换为UTF-8编码的字符串,再用cl-json解析一番。拿到一个列表对象后,再用carcdrassoc一顿操作猛如虎,才能拿到需要的数据。

2020年,MAKE CL GREAT AGAIN!

记账

在年中的时候,萌生了换记账软件的想法,因为挖财用起来越来越不爽了,而且整天记流水账也没什么收获。一番摸索后,我决定尝试一下复式记账法,并选择了GNU Cash——早年间用过一次,但没看入门手册就开始用,根本玩不转。这一次倒是读了手册,但GNU Cash的UI和操作方式还是无法让我心动;之后知道了beancount,却无法在我的系统中顺利运行;最终我选择了ledger,它是一个命令行程序,不负责记录,只负责读取手打显诚意的交易明细,然后产出报表。Emacs有一个ledger-mode插件,两者配合用来记账超痛快。

结尾

在2020年我希望至少能完成:

  1. 发布cuckoo
  2. 发布wa
  3. 开发一个alerter的代替品;
  4. 写更多的博文,让微信订阅号的粉丝涨到130;
  5. 给CL写一些库解决一些常见的需求

最后

序言

我在上大学的时候并没有接触过VCS(版本控制系统)。虽然曾经在Google Code发布过去项目,但是以压缩包的形式发布的;与室友合作开发计算机网络这门课的课程设计时,也没有用上。直到入职第一家公司后才真正开始使用,当时用的是Git,此后也始终没用过其它的VCS——SVN仅仅耳闻未曾使用——转眼间已经用了六年多的Git了。

尽管日常使用问题不大,但对于Git的内部运行原理我仍然是一知半解——也不是我谦虚,基本就是不懂吧。例如,使用git addgit commitgit branch等命令的时候,Git在背后究竟做了什么,我是答不上来的。好在互联网上有许多这方面的资料可供学习,我硬着头皮看了不少文档和博客后,总算是习得了一些皮毛。

现在,我试着循序渐进地讲解一遍吧。

git add的时候发生了什么?

首先创建出一个仓库并向其中添加一个文件

1
2
3
4
5
mkdir git-test
cd git-test
git init
echo 'hello' > a
git add .

到此为止,暂时不要提交改动。现在,我来看看Git到底在背后做了些什么。Git的秘密都藏在叫做.git的目录中,尤其是其中的objects目录。用tree命令查看这个目录的结果如下

1
2
3
4
5
.git/objects
├── ce
│   └── 013625030ba8dba906f756967f9e9ca394464a
├── info
└── pack

与运行git add前相比,多出了一个叫ce的目录,以及位于其中的叫013625030ba8dba906f756967f9e9ca394464a的文件。这个文件其实就是a的一个“副本”,其中存储着文件a的内容。但是不能用cat直接查看,因为Git对这个文件做了压缩。可以用pigz来得到压缩前的原文,示例代码如下

1
pigz -d < .git/objects/ce/013625030ba8dba906f756967f9e9ca394464a

结果为

1
blob 6hello

Git生成这个文件的规则其实不复杂。首先Git会计算原文件的长度,即6(之所以是6,是因为用echo和重定向写入文件a时,添加了一个换行符)。然后,Git将一个固定的前缀blob(此处有一个空格)、文件长度、一个空字符(ASCII码为0的字符),以及文件内容这四者连接成一个字符串,并计算这个字符串的SHA1摘要。具体到文件a,可以用下面的命令试着计算

1
printf "blob 6\0hello\n" | shasum

或者用Git内置的hash-object子命令会更简单

1
git hash-object a

不管是哪一个命令,算出来的摘要都是ce013625030ba8dba906f756967f9e9ca394464a。然后Git会取前两个字符(ce)作为目录名,在.git/objects下创建新的目录。以从第三个字符开始的剩余内容(013625030ba8dba906f756967f9e9ca394464a)为文件名,将方才拼接好的内容压缩后写如文件。这种文件用Git的术语来讲叫做blob对象,稍后还会遇到tree类型和commit类型的对象。

git commit的时候发生了什么?

接下来提交改动

1
2
3
git config user.email 'foobar'
git config user.name 'foobar'
git commit -m 'test'

此时会发现.git/objects下新增了两个文件

1
2
3
4
5
6
7
8
9
.git/objects
├── 09
│   └── 76950c1fdbcb52435a433913017bf044b3a58f # 新的
├── 14
│   └── c77e71bd06df41e1509280cfba045e1db2aa5f # 新的
├── ce
│   └── 013625030ba8dba906f756967f9e9ca394464a
├── info
└── pack

git cat-file -t可以查看这两个新文件的类型

1
2
git cat-file -t 14c77e71bd06df41e1509280cfba045e1db2aa5f # 输出commit
git cat-file -t 0976950c1fdbcb52435a433913017bf044b3a58f # 输出tree

也可以用git cat-file -p以可读的方式输出新文件的内容。例如用git cat-file -p 0976950c1fdbcb52435a433913017bf044b3a58f输出tree类型的对象的内容,结果为

1
100644 blob ce013625030ba8dba906f756967f9e9ca394464a	a

tree类型的对象中记录着Git所追踪的文件的元信息,包括文件的权限、在Git中的对象类型、对象摘要,以及文件名。另一个commit类型的对象中存储着本次提交的信息,用git cat-file -p查看的结果如下

1
2
3
4
5
tree 0976950c1fdbcb52435a433913017bf044b3a58f
author foobar <foobar> 1576676836 +0800
committer foobar <foobar> 1576676836 +0800

test

第一行表示这个commit对象指向的是哪一个tree对象,从这个tree对象出发,可以遍历仓库中直到本次提交为止、所有被Git追踪的文件。commit指向treetree可以指向blob也可以指向其它的treeblob就像是树中的叶子节点,不再指向其它的对象,它们之间的关系如下图所示

git branch的时候发生了什么?

Git的branch子命令用于创建新分支——虽然我平时更多地使用git checkout -b。既然addcommit的时候,Git会创建出blobtree,以及commit类型的对象,那么创建新分支的时候,Git是不是也会创建名为branch的对象呢?答案是否定的。

Git的分支非常简单——它仅仅是指向某个commit对象的引用,就像是*nix系统中的符号链接一样。所有分支都存储在.git/refs/heads之下。例如文件.git/refs/heads/master中便存储着master分支上的最新提交的摘要

1
cat .git/refs/heads/master # 输出14c77e71bd06df41e1509280cfba045e1db2aa5f

这就是在Git中创建新分支的成本很低的原因——不过是复制一下当前分支在.git/refs/heads下的同名文件而已。我创建一个新分支develop并提交一个新文件b.git/objects下会多出三个文件

1
2
3
4
git checkout -b develop
echo 'good' > b
git add b
git commit -m 'new branch'

三个新文件分别存储着文件b的内容(一个blob对象)、文件b的元信息(一个tree对象),以及本次提交(一个commit对象)。这些文件中没有任何关于develop分支的信息,develop分支仅仅是一个存在于.git/refs/heads/目录下的同名文件。

git merge一个子代时发生了什么?

develop分支是从master分叉出来,将develop合并回master时,Git会进行一次fast-forward的合并。虽然名字很唬人但其实Git做的事情非常简单,只需要将.git/refs/heads/master文件的内容修改为与develop相同的摘要即可。

也可以要求Git不使用fast-forward。先用git reset --hard HEAD^1master分支回退到第一次提交的状态,然后使用下列的命令再次将develop合并进来

1
git merge --no-ff develop

这一次,Git不再简单地修改.git/refs/heads/master文件了事,而是会创建一个新的commit对象。在我的电脑上,这个新的commit对象的摘要为d1403bb629c7a636c724069b22875ed882b54bcc,使用git cat-file -p看看它的内容

1
2
3
4
5
6
7
tree e960ed43b8e6b5fe9b4e57b806f70796da820056
parent 14c77e71bd06df41e1509280cfba045e1db2aa5f
parent db891542d3e44448433ba86c7cd636d8aec3da54
author foobar <foobar> 1576679608 +0800
committer foobar <foobar> 1576679608 +0800

Merge branch 'develop'

有趣的是,这个commit对象有两个“父级”的commit,而不像平常所认识的树形数据结构那般只有一个“父节点”。显然,这两个父节点分别是合并前的master分支的最新一次提交,以及develop的最新提交。

虽然创建了一个新的commit对象,但其实develop分支的最新提交持有的便是整个仓库的最新版本,所以不需要创建新的tree,合并所产生的commit直接与develop分支的最新提交共用同一个tree对象便足够了——在上面输出内容的第一行的摘要,就是develop分支的最新commit所指向的tree对象的摘要。

至此,终于解决了我一直以来的一个困惑。我曾天真地以为,Git在合并两个分支的时候,会将待合进来的分支中的所有多出来的改动,复制到要合进去的分支中去。这都是因为我没有理解分支的本质,Git的分支并不是一根水管,没有哪一个提交是只能装在一个特定的分支中的。Git合并的时候,就像是在一个immutable的树上做修改,只需要创建不多的新committree对象,再引用已经存在的旧committree对象即可。否则,哪能快速地完成两个分支的合并呢。

后记

没想到还写了蛮多内容的,经过这么几次试验,我对Git的核心原理也算略知一二了,暂时不打算继续深入。各位读者如果有兴趣,可以试着制造一次有冲突的合并,然后看看冲突解决的前后,.git/objects目录下会有什么变化。

最后,在摸索Git原理的过程中,我找到了不少优质的参考资料,这里一并奉上:

  1. https://nfarina.com/post/9868516270/git-is-simpler
  2. https://maryrosecook.com/blog/post/git-from-the-inside-out
  3. http://www-cs-students.stanford.edu/~blynn/gitmagic/ch08.html
  4. https://git-scm.com/book/en/v2/Git-Internals-Git-Objects

序言

以前用Windows的时候,我在QQ中添加了很多自定义表情,其中有很多我还为它们设置了短语,以便可以便捷地发出去。后来微信用得多了,也在微信中收集了很多的表情。很多时候,表情包真的是一图胜千言,而且比起直白地说出同样的话,发图片显得更有意思。比如说,某一天你的同事在谈论他朋友的一些事情时,你可以发一张

当然了,很多时候可能手头没有这张图片,或者一时找不到,也可以来一句“你说的这个朋友到底是不是你自己.jpg”,异曲同工。每每遇到这些有趣的图片的时候,我便会将它们保存下来以备不时之需。不过收集得多了之后,在想要用的时候便发现找表情也不是一件特别容易的事情。比如说,我的表情包目录中已经有92张图片了,即便我明确地知道我要找的就是上面这张图,文件名也没记错,但要在九十多张图片中一眼看到它,还是颇具难度的。

好在,咱是程序员,在很多事情上可以动动手指写写代码来予以辅助——找表情图片这件事情,恰好是其中之一。

准备素材

首先,我要祭出神器Alfred。我会编写一个Alfred的Workflow,它起码要能够方便我按名字找到图片,并且能够复制到粘贴板中。这么一来,我就可以直接在IM软件的聊天窗口中发出选好的表情图。

其次,正所谓巧妇难为无米之炊,如果没有表情图片的储备,那制作这么个工具也就毫无意义了。因此,我还得准备一个目录,用来存放所有将来可能会用上的图片文件,这个目录便是~/OneDrive/图片/表情包。OneDrive上1TB的存储空间,放点图片也是绰绰有余了——当然了,也不是非要把图片目录放到一个同步网盘里。

最后,这些搜集回来的图片还不能就这么晾着,必须给它们取一些容易记忆的名字,毕竟之后就靠名字来找它们了。给表情图片文件命名很简单,因为许多图片中含有一两句关键的话。比如说,下面这张图

直接用图中的文字来命名即可。

编写Workflow

现在可以开始编写Workflow了。它的最终形态是下图这样的

作为Workflow入口的是一个keyword为bqbFile FilterFile Filter是一个挺强大的工具,它本身就可以完成我所需要的功能,见如下演示

可见,File Filter本身便足矣完成搜索图片并复制到粘贴板的需求,本篇文章也就到此为止了——才怪,光有File Filter还不够,因为收集回来的表情图片的尺寸并不统一,直接在IM工具中发送的效果并不好,可能有霸屏之嫌。比如文章开头的图片,用来自ImageMagick套件的identify程序可以看到这张图片足足有527像素宽405像素高,一下子占据了半个聊天窗口——群聊的时候,还是应当照顾一下群里其他人的感受的。

因此,Workflow中的第二个对象,便负责将图片缩放为合适的尺寸。第二个对象是一个Run Script Action,Alfred在运行后第一个File Filter后便会接着调用这个对象中所指定的外部脚本。这个对象的配置如下图所示

其中的脚本内容如下

1
2
3
4
5
6
7
8
9
10
11
12
#!/bin/bash
# 将图片等比缩放为300像素的宽度

filename=$(basename "${1}")
suffix="${filename##*.}"
if [ "${suffix}" = 'gif' ]
then
echo -n "${1}"
else
sips --resampleWidth 300 "${1}" --out /tmp > /dev/null 2>&1
echo -n "/tmp/${filename}"
fi

运行这个脚本后,便可以在/tmp目录下得到与原始图片同名的、宽度缩放为300像素的新图片。刚开始我也打算处理.gif文件,但试验后发现sips把GIF缩放成了一张静态图,于是便不处理GIF文件了。

第三个对象很简单,是一个Argument and Variables Utility,配置很简单,直接上图比较直观

第四个对象又是一个Run Script Action,用于将缩放后的图片复制到粘贴板中——没错,这本来是File Filter完成的工作。这个对象同样会调用一个External Script,内容如下

1
2
3
#!/bin/bash
cd /Users/liutos/SourceCode/applescript/
osascript copy_file_to_clipboard.scpt ${1}

咦,复制文件到粘贴板的逻辑呢?别着急,在/Users/liutos/SourceCode/applescript/copy_file_to_clipboard.scpt这个文件中,内容如下

1
2
3
4
5
#!/usr/bin/osascript
# 方法来自这里:https://superuser.com/questions/1132777/copy-an-image-to-clipboard-from-the-mac-terminal
on run args
set the clipboard to POSIX file (first item of args)
end run

它借助AppleScrippt来实现复制文件的功能。

最后一个对象是Alfred内置的Post Notification,用于在一切就绪后在右上角弹出提醒,反馈给Workflow的使用者,它的配置如下图所示

至此,这个集查找、缩放,以及复制图片于一身的Workflow,便大功告成了。怎样?是不是已经跃跃欲试了?

后记

等跃跃欲试的感觉褪去后便会发现,这个Workflow还相当地不成熟:

  1. 使用AppleScript复制文件后,只要在IM软件中一粘贴便会立即发送出去,让人有点猝不及防。我希望的效果,是类似于在浏览器中右键复制一张图片那般的;
  2. 它基于Alfred的File Filter来查找目录下的文件,但File Filter的搜索能力并非很强。虽然从上面的动图看来,它支持以拼音来搜索,但很多时候稍微多打几个字母,便什么结果也没有了。如果可以支持模糊查找,甚至全文搜索乃是极好的;
  3. 表情图片的文件名需要自己维护,每次收集到新的表情时都需要自己手打显诚意。若是有一个配套的工具可以从图片中提取出文字来自动命名便更好了——OCR了解一下?;
  4. 最后,当收集的表情图片多起来后,许多图片便不好找了,毕竟谁也无法很容易地记忆九十多张图片的名字。渐渐地,很多图片的使用率也会下降,变成了鸡肋。而当它们真的派上用场的时候,早已忘记了它们正静静地躺在目录下等待召唤。或许,我需要一个可以自动阅读我的聊天内容并向我推荐表情图的AI助理?

等哪天有空了,说不定我会按上面的思路稍微改进一下吧,哈哈。

序言

There are only two hard things in Computer Science: cache invalidation and naming things.

– Phil Karlton

乔鲁诺·乔巴拿有一个梦想,便是要成为程序员巨星。但如果你看过我写的代码,便知道我还远远够不上“巨星”二字。我的代码中有许多不一致的命名:

  1. 常量的命名时而是全大写的(如WAIT_CONFIRM),时而是全小写的;
  2. 某个项目大部分命名用的是camel case(如TaskController),但由于数据库中的列名用了snake case(如context_id),导致项目中与数据库列有关的代码混用了camel case和snake case(如restricted_hours[new Date(timestamp * 1000).getHours()] = 1;);
  3. 同样是构造复杂对象的函数,它们的前缀可能会是build、create、make,甚至compute中的任何一个;
  4. 明明是一个数组,却用了单数的order作为变量名。

之所以如此混乱,正是因为我没有遵循一套一致的命名规则。每当我在一个项目中蹦出一些新想法时,便会跃跃欲试——不,我真的就用上了。我不曾整理过自己的命名规则(天哪我已经写了三年的JavaScript了),以至于无从判断“新想法”是否真的新——也许它是一个已经被我抛弃的规则。

为了不再深陷不一致命名的泥潭,我定下了本篇的命名规则,期望它们为以后的我指点迷津。

变量名

通用规则

  • 变量名使用camel case的命名风格。例如,使用namingConvention,而不是naming_convention
  • 在尽量遵循规则的基础上随机应变。

变量名的单复数规则

  • 如果一个变量存储的值的类型为数组(即该变量作为Array.isArray方法的参数时结果为真),那么变量的名称就使用复数形式。例如,使用fruits = []而不是fruit = []
  • 如果一个变量存储的值的类型为集合(即Set这个类型),那么变量的名称应当使用单词unique为前缀。例如,使用uniqueUserIds而不是userIdSet

布尔变量的命名规则

如果一个变量的值的类型为boolean,那么变量的名称应当以下列单词为前缀:

  • is。当变量表达一个二元状态的时候,例如isFullisEmpty。在is后面的应当是一个形容词;
  • has。当变量表达历史上是否发生过某个事件的时候,例如hasPaidhasArrived。在has后面的应当是一个动词,并且采用过去分词;
  • can。当变量表达某种权限的时候,例如canWritecanExecute。在can后面的应当是一个动词,并且采用现在分词。

数值变量的命名规则

  • 如果变量中存储的是一系列数字中的最大值,那么变量的名称应当以max为前缀,例如maxScore。如果是最小值,则是以min为前缀,例如minScore
  • 如果变量中存储的是一系列数字的和,那么变量的名称应当以total为前缀,例如totalIncome
  • 如果变量中存储的是数组的长度,那么变量的名称可以用numberOf为前缀,例如numberOfUsers

字符串变量的命名规则

  • 如果表达的是人名、品牌名、公司名、数据库中的表名,那么变量的名称可以用单词name结尾,例如customerNamebrandNamecompanyName
  • 如果表达的是按键或按钮上刻着的文字、纸质表格或电子表单上输入框左侧的简短说明,那么变量的名称可以用单词label结尾,例如buttonLabel
  • 不允许使用单词content

函数名

  • 函数的名称应当由动词和名词组成,例如readFilewriteFile
  • 如果函数的功能是将参数转换为另一种形式的输出(比如进制转换、币种转换),那么函数的名称应当以单词to为前缀,例如toDollartoHexadecimal
  • 如果函数的功能是检验参数并返回一个布尔值,那么函数的名称应当以单词check为前缀,例如checkIsDirectorycheckIsExecutable
  • 如果函数的功能是“计数”,那么函数的名称应当以单词count为前缀,并且其中被计数的对象应当为复数形式,例如countPaidOrders

构造型函数的命名规则

  • 如果表达的是从无到有地创造一个对象,那么函数名可以用create作为前缀,例如createObject
  • 如果表达的是将一些输入原封不动地放在一起(可能输入之间添加了其它东西)创造出一个对象,那么函数名可以用make作为前缀,例如makeFloor。进一步地,如果函数不改变输入的相对顺序,那么函数名可以用concat,例如concatString
  • 如果函数会将根据输入创造出具有不止一个层级的对象,那么函数名可以用build作为前缀,例如buildBinarySearchTree

修改型函数的命名规则

  • 如果函数负责更新数据库中的记录,那么函数名应当以单词update为前缀;
  • 如果函数修改的是一些可枚举的状态,那么函数名应当以单词change为前缀。

类名

  • 类的名称应当采用capital case的命名风格,例如DatabaseConnection
  • 类的名称应当以一个名词结尾;
  • 如果类的存在是为了使用某种设计模式,那么类名应当可以反映在设计模式中所处的位置,例如在State模式中,代表具体状态的类的名称可以是InitialStateUnpaidState

参考资料

本文通过与VSCode作对比,来简单地介绍Emacs的基本功能、特点,以及一些插件。本文所说的Emacs指的是GNU Emacs,下文简称Emacs。

基本概念

  • Emacs是一个文本编辑器,就像VSCode那样。它可以用来写代码、Markdown,以及其它任何纯文本;
  • 在Emacs中打开的每个文件都有一个“主模式”(major mode),就像在VSCode中每个文件都可以设置一种语言模式;
  • 除了主模式,在Emacs中还可以同时启用多个“次模式”(minor mode)。每一个次模式都可以提供自己的个性化功能;
  • 可以用一门叫ELisp的编程语言为Emacs开发插件,扩展新功能。有许多现成的插件可以安装使用。

与VSCode对比

接下来通过与VSCode作对比,来直观地感受一下Emacs的基本功能。

外观

Emacs的官网上有一张应用截图

Emacs的官网截图

第一眼看起来和VSCode还是非常不一样的

VSCode的官网截图

默认的Emacs界面上也会有菜单栏、工具栏,以及底下的状态栏(在Emacs中其实这一行叫做mode line),这些元素在VSCode上也可以找到。VSCode一般给人的印象还有侧边栏、资源管理器视图,以及minimap视图。Emacs的默认底色是白色,而VSCode则是黑色。

键盘操作

Emacs有着丰富的快捷键(在Emacs中称之为key binding),但一些常见的功能的快捷键与VSCode等其它软件并不相同,如下表所示

Emacs VSCode
新建文件 C-x C-f ⌘n
打开文件或目录 C-x C-f ⌘o
保存文件 C-x C-s ⌘s
另存为 C-x C-w ⇧⌘s
撤销 C-x u ⌘z
剪切 C-w ⌘x
复制 M-w ⌘c
粘贴 C-y ⌘v
查找 C-s ⌘f

在上表中,C-x表示先按住control键再按x键,M-w表示先按住alt键再按w键;表示Mac上的command键、表示shift键。此外,在Emacs中还可以使用C-pC-nnC-b,以及C-f键来往上、下、左、右四个方向移动光标,不需要移动手臂便可以在编辑的文件中到处移动,提高效率。VSCode也可以使用Emacs风格的快捷键,如下图所示

文件管理

VSCode自带美观大方的标签页功能,此外还可以将编辑器横向及纵向拆分,如下图所示

Emacs默认是没有标签页的功能的,但也支持切割编辑器,比如在下图中,就将编辑器分为左右两部分,并且左侧还被分为了上下两部分,这三个区域可以展示相同或不同的三个文件。

默认的编程语言支持

VSCode支持非常多的编程语言,点击窗口右下角的语言模式便可以看到这份清单

Emacs虽然没有这么一份清单,但支持的语言的数量也是不遑多让的。不过仅仅是默认的Emacs的话,对编程语言的支持没有VSCode那么开箱即用。例如,比起Emacs,VSCode默认对JavaScript的支持就很好,不仅仅有语法高亮、自动补全,并且还有基于变量类型、函数定义,以及导入的模块等信息实现的智能补全,而Emacs只有平凡的基于文本的语法高亮和自动补全罢了。

搜索功能

除了按下⌘f在文件内搜索之外,VSCode左侧工具栏中有一个名为搜索的入口,可以实现在打开的项目的所有文件中搜索特定内容的功能,并且还能用于替换。Emacs尽管没有这么一个GUI入口,但提供了grep命令来做到同样的事情。

在Emacs中按下M-x后输入grep并按回车,便会在minibuffer中等待使用者的进一步输入

显然,这是直接调用了同名的命令行程序grep来实现搜索的,控制选项比VSCode的搜索功能要丰富许多。

集成git

git可以说已经成为了日常开发中不可或缺的一个工具,如果能够在编辑器内方便地调用git的话会大大提高效率。VSCode通过左侧工具栏的源代码管理入口提供了这个功能,在Emacs中则是通过一个叫做VC dir的主模式提供这个功能。

在Emacs中按下快捷键C-x v d,然后输入使用git管理的项目的目录地址,便会打开一个新的编辑区域

当处于这个模式下时,会有一些新的快捷键可以用,比如将光标移动到显示edited的行上按下等号键,可以打开另一个编辑区域查看该文件的修改内容;按下m键可以选中光标所在行的文件,然后按v键打开一个新的编辑区域来填写commit message,写完之后按下C-c C-c提交(即调用git commit命令);最后按下q键可以退出VC dir模式的编辑区域。

集成终端

在VSCode中通过选中顶部菜单的“查看”,再点击“集成终端”,便可以打开命令行,然后像平时在其它的终端模拟器中那样使用命令。Emacs中也可以打开终端,方法是按下快捷键M-x然后输入eshell并回车,然后Emacs便会在当前窗口中打开一个名为*eshell*的编辑区域,显示命令行提示符和闪烁的光标,并等待使用者的进一步交互。

有意思的是,这不是一个真正的命令行程序。比如在*eshell*中输入which pwd,输出结果是eshell/pwd is a compiled Lisp function in ‘em-dirs.el’.。也就是说,eshell中的某一些命令是Emacs重新实现的;此外,在*eshell*中可以运行Emacs的扩展语言——ELisp。比如输入(+ 1 1)并按下回车,会输出2。

扩展能力

VSCode有一个插件市场

Emacs也有一个,不过得承认这个网页确实没有VSCode阵营的吸引人

在Emacs中按下M-x后输入package-install回车,Emacs会等待用户输入要安装的插件的名字——可以按下键(Mac上的tab键)让Emacs尝试自动补全。确认名字无误后再按回车便可以安装使用了——但一般还需要做一些微调。

炫酷的特性和插件

除了上面与VSCode对比的一些基本操作以外,Emacs还有自身的一些特色功能,更有世上的众多优秀程序员为Emacs贡献了不胜枚举的优秀插件,它们极大地扩展了Emacs的能力,提升了文字编辑这项活动的效率,甚至超越了文字编辑。

内置的功能

颜色主题

Emacs也可以安装颜色主题来改变外观。我比较喜欢的一款主题是gruvbox-light-soft,只需要在Emacs的配置文件中写上(load-theme 'gruvbox-light-soft t)即可启用。在这个主题的仓库主页可以看到一些效果图。

将当前行居中或置顶

Emacs可以用纯键盘的操作,将光标当前所在的行移到窗口的中间来显示。我特别喜欢这个功能,之前用VSCode时候一直想找这个功能的等价物,可惜没找着。在Emacs中,按下一次control+l,光标所在的行就会移动到窗口的中间;按下第二次,则移动到窗口的顶部展示;再按一次,会去到窗口的底部。如果再按一次,那么就跟第一次一样回到窗口的中间。每当Emacs正在编辑的内容已经去到屏幕上较低的位置时,我便会用这个功能校正一下。

比起用鼠标拖动滚动条或者用滚轮来滚动,我更喜欢这种表意更清晰的方式。

纯键盘选中一片区域

在Emacs中可以用单击鼠标右键的方式来选中一片区域。当按下鼠标右键的时候,从光标所在的位置开始,到鼠标点击的位置结束的内容便会被选中。但我个人更喜欢纯键盘的操作,首先是在要选中的内容的起点按下快捷键C-@,这时候Emacs会在minibuffer中打印一条Mark set的消息。然后移动光标——用方向键还是用一系列的快捷键,甚至直接跳转到某一行也可以——到待选中的内容的终点。这时候加在起点和终点间的内容便会被选中,它们会有特殊的背景色,如下动图所示

因为按C-@实在是太别扭了,所以我将这个快捷键修改为了M-SPC(先按住alt键再按下空格键)。如果更喜欢鼠标操作的话,也可以像在VSCode中那样,在终点按住shift键再单击鼠标左键。

矩形编辑

在VSCode中按住shiftoption键,再单击鼠标右键并拖动就可以选中一片矩形的区域,在Emacs中也支持这样的矩形编辑的功能。像下图这样,先定位到要选中为矩形区域的左上角按下C-@,再移动光标到目标矩形区域的右下角,最后按下快捷键C-x r k即可。

惊艳的插件

快速跳转

Emacs本身支持跳转到指定的某一行,只需要按下快捷键M-g M-g然后输入行号并回车即可。但我一般是不显示行号的,所以这个功能其实比较少用。我使用一个名为avy的插件来增强跳转功能,主要用的是它提供的avy-goto-line函数。依照这个插件的文档,我为这个函数配置了快捷键M-g f,因此当我想要快速地跳转到屏幕上可见的区域中、离光标所在位置稍微有点远的行时,我便按下这个快捷键,然后按照提示按下相应的英文字母键既可,具体效果参见下面的演示

发出HTTP请求

VSCode中有一个叫做REST Client的插件,Emacs中则是有一个叫做restclient.el的主模式。借助于restclient.el便可以直接在一个文本文件中写好自己要发出的HTTP请求的内容,然后一键触发。之前我是用Insomnia这个工具的,尽管它很强大,但很多时候我不需要那么强大的功能,而且Insomnia消耗内存比较多,于是我便回到restclient.el上了。restclient.el的效果大致如下图所示

增强的git集成功能

尽管Emacs自带了VC dir这个主模式,但我更喜欢用magit这个插件,尤其是它的magit-discard功能,可以在查看代码的差异的过程中方便地舍弃一些不必要的修改(例如添加一行console.log的调用)。例如下图,通过按下n将光标移动到某一片修改上再按下k键,Emacs便会询问使用者是否要“丢弃”这一块改动。如果按下y,那么这一块被选中的区域的内容便会恢复到git当中未修改的状态。

增强的文件内搜索功能

Emacs默认的搜索使用的是search-forward函数,插件swiper提供的功能更强大——不仅可以是字符串的完全匹配,也可以基于正则表达式来搜索,并且展示效果更直观,如下图所示

代码片段工具

VSCode自带了一个“用户代码片段”的功能(通过顶部菜单“Code”,再选中“首选项”可以看到),可以用来定义一些短语,这些短语会在被选中的时候展开为完整的内容。Emacs有一个名为yasnippet的插件也提供了类似的功能,但定义代码片段的语法不同。并且,yasnippet支持在短语的定义中嵌入ELisp代码,扩展性远远高于只能使用字符串及占位符的VSCode的等价功能。

其它

Emacs还有许多有意思的插件,比如dimmer.el,可以让当前没有获得焦点的窗口显示得黯淡一点;ledger-mode,可以用Emacs来记账。这里就不一一列举了,各位有兴趣的话可以自己摸索Emacs,相信会遇到自己喜欢的插件的。

总结

比起家大业大的VSCode,Emacs算不上是开箱即用。它没有VSCode那么友好的界面和平易近人的操作方式,人气也没有VSCode那么旺,当遇到问题的时候可能没那么好求助到人,并且学习曲线(小众的预设快捷键、冷门的扩展语言)也比较高。但Emacs的扩展能力很强,现有的插件已经很丰富了,可以满足大部分的需求,遇到问题也可以到有模有样的论坛求助。如果喜欢折腾的话,Emacs会是一个不错的选择,至少我自己用得很开心。

背景

有一阵子很好奇一个问题:MySQL到底是如何将内存中的B+树写入到磁盘文件中的。明明是一棵树,要怎样才能存储成线性的字节流呢?干脆自己动手,试着实现一个简单的版本,来帮助自己摸点门道。虽然想法很不错,不过一上来就面对噩梦级别的B+树也太为难人了,因此就先从简单的二叉树入手吧。

出来吧,二叉搜索树

本文使用Common Lisp进行开发。

首先定义这棵二叉搜索树的节点的类型

1
2
3
4
5
6
7
8
9
10
11
12
13
14
(defclass <node> ()
((data
:accessor node-data
:initarg :data
:documentation "节点中的数据")
(left
:accessor node-left
:initarg :left
:documentation "左子树")
(right
:accessor node-right
:initarg :right
:documentation "右子树"))
(:documentation "二叉搜索树的节点"))

基于节点进一步定义二叉树的类型

1
(deftype <bst> () '(or <node> null))

如此一来,要创建节点和空树都是浑然天成的事情了

1
2
3
4
5
6
7
8
9
10
11
12
13
(defun make-node (data left right)
"创建一个二叉搜索树的节点"
(check-type data integer)
(check-type left <bst>)
(check-type right <bst>)
(make-instance '<node>
:data data
:left left
:right right))

(defun make-empty-bst ()
"创建一颗空树"
nil)

要判断一颗二叉树是否为空树只需要简单包装一下cl:null函数即可

1
2
3
(defun empty-bst-p (bst)
"检查BST是否为一个空的二叉搜索树"
(null bst))

为了生成必要的测试数据,需要提供一个往二叉树中添加数据的功能

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
(defun insert-node (bst data)
"往一颗现有的二叉搜索树BST中加入一个数据,并返回这颗新的二叉搜索树"
(check-type bst <bst>)
(check-type data integer)
(when (empty-bst-p bst)
(return-from insert-node
(make-node data
(make-empty-bst)
(make-empty-bst))))

(cond ((< data (node-data bst))
(setf (node-left bst)
(insert-node (node-left bst) data))
bst)
(t
(setf (node-right bst)
(insert-node (node-right bst) data))
bst)))

有了insert-node便可以从空树开始构筑起一棵二叉搜索树

1
2
3
4
5
6
7
(defun create-bst (numbers)
"根据NUMBERS中的数值构造一棵二叉搜索树。相当于NUMBERS中的数字从左往右地插入到一棵空的二叉搜索树中"
(check-type numbers list)
(reduce #'(lambda (bst data)
(insert-node bst data))
numbers
:initial-value (make-empty-bst)))

现在来生成稍后测试用的二叉树

1
(defvar *bst* (create-bst '(2 1 3)))

模仿命令行工具tree的格式,提供一个打印二叉树的功能

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
(defun print-spaces (n)
"打印N个空格"
(dotimes (i n)
(declare (ignorable i))
(format t " ")))

(defun print-bst (bst)
"打印二叉树BST到标准输出"
(check-type bst <bst>)
(labels ((aux (bst depth)
(cond ((empty-bst-p bst)
(format t "^~%"))
(t
(format t "~D~%" (node-data bst))
(print-spaces (* 2 depth))
(format t "|-")
(aux (node-left bst) (1+ depth))
(print-spaces (* 2 depth))
(format t "`-")
(aux (node-right bst) (1+ depth))))))
(aux bst 0)))

二叉树*bst*的打印结果如下

1
2
3
4
5
6
7
2
|-1
|-^
`-^
`-3
|-^
`-^

接下来终于要写盘了

总算要开始实现将二叉树写入磁盘文件的功能了。将内存中的二叉树写入到文件中,相当于将树形的数据结构转换为线性的存储结构——毕竟磁盘上的文件可以认为就是线性的字节流。在这块字节流中,除了要保存每一个节点的数据之外,同样重要的还有节点间的父子关系。

有很多种写盘的方法。比如说,可以模仿树的顺序存储结构将二叉树序列化到磁盘上。以上面的二叉树*bst*为例,它是一棵满二叉树,如果采用顺序存储,那么首先分配一个长度为3的数组,在下标为0的位置存储根节点的数字2,在下标为1的位置存储左孩子的数字1,在下标为2的位置存储右孩子的数字3,如下图所示

推广到高度为h的二叉树,则需要长度为$2^h-1$的数组来存储所有节点的数据。假设每一个节点的数据都是32位整数类型,那么一棵高度为h的二叉树在磁盘上便需要占据$4·(2^h-1)$个字节。这个做法虽然可行,但比较浪费存储空间。它将节点间的父子关系用隐式的下标关系来代替,节省了存储左右子树的“指针”所需的空间,比较适合存储满二叉树或接近满的二叉树。

对于稀疏的二叉树,如果在序列化后的字节流中显式地记录节点间的父子关系,便可以节省很多不存在的节点所占据的存储空间。比如说,对于每一个节点,都序列化为磁盘上的12个字节:

  1. 下标为0到3的4个字节,存储的是节点中的数据;
  2. 下标为4到7的4个字节,存储的是节点的左子树在文件中的偏移;
  3. 下标为8到11的4个字节,存储的是节点的右子树在文件中的偏移

如下图所示

上面的数组表示磁盘上的一个文件,每一个方格为一个字节,每个方格在文件内的偏移从左往右依次增大。由于采用后序遍历的方式依次序列化二叉树中的节点数据和指针,因此左孩子首先被写入文件,然后是右孩子,最后才是根节点。推广到所有的二叉树,便是先将左右子树追加写入磁盘文件,再将根节点的数据、左子树根节点在文件内的偏移,以及右子树根节点在文件内的偏移追加到文件末尾;如果左右子树是空的,那么以偏移0表示。

这是一个递归的过程,而每一次递归调用应当返回两个值:

  1. 写入的总字节数bytes
  2. 根节点所占据的字节数root-bytes

bytes便是右子树开始写入时的文件偏移,必须依靠这个信息确定右子树的每一个节点在文件内的偏移;使用bytes减去root-bytes,再加上左子树开始写入时的偏移量,便可以得知左子树的根节点在文件内的位置。最终实现写盘功能的代码如下

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
;;; 定义序列化二叉树的函数
(defun write-fixnum/32 (n stream)
"将定长数字N输出为32位的比特流"
(check-type n fixnum)
(check-type stream stream)
(let ((octets (bit-smasher:octets<- n)))
(setf octets (coerce octets 'list))
(dotimes (i (- 4 (length octets)))
(declare (ignorable i))
(push 0 octets))
(dolist (n octets)
(write-byte n stream))))

;;; 这是一个递归的函数,写入一棵二叉树的逻辑,就是先写入左子树,再写入右子树,最后写入根节点,也就是后序遍历
;;; 由于要序列化为字节流,因此需要用字节流中的偏移的形式代替内存中的指针,实现从根节点指向左右子树
;;; offset是开始序列化bst的时候,在字节流中所处的偏移,同时也是这颗树第一个被写入的节点在字节流中的偏移
;;; 每次调用write-bst-bytes后的返回值有两个,分别为二叉树一共写入的字节数,以及根节点所占的字节数
(defun write-bst-bytes (bst stream offset)
"将二叉树BST序列化为字节写入到流STREAM中。OFFSET表示BST的第一个字节距离文件头的偏移"
(check-type bst <bst>)
(check-type stream stream)
(check-type offset integer)
(when (empty-bst-p bst)
(return-from write-bst-bytes
(values 0 0)))

;; 以后序遍历的方式处理整棵二叉树
(multiple-value-bind (left-bytes left-root-bytes)
(write-bst-bytes (node-left bst) stream offset)

(multiple-value-bind (right-bytes right-root-bytes)
(write-bst-bytes (node-right bst) stream (+ offset left-bytes))

(write-fixnum/32 (node-data bst) stream)
(if (zerop left-bytes)
(write-fixnum/32 0 stream)
(write-fixnum/32 (- (+ offset left-bytes) left-root-bytes) stream))
(if (zerop right-bytes)
(write-fixnum/32 0 stream)
(write-fixnum/32 (- (+ offset left-bytes right-bytes) right-root-bytes) stream))
;; 之所以要加上12个字节,是因为在写完了左右子树之后,就紧邻着写根节点了。因此,根节点就是在从right-node-offset的位置,接着写完右子树的根节点后的位置,而右子树的根节点占12个字节
(let ((root-bytes (* 3 4)))
(values (+ left-bytes right-bytes root-bytes)
root-bytes)))))

(defun write-bst-to-file (bst filespec)
"将二叉树BST序列化为字节流并写入到文件中"
(check-type bst <bst>)
(with-open-file (stream filespec
:direction :output
:element-type '(unsigned-byte 8)
:if-exists :supersede)
(write-fixnum/32 (char-code #\m) stream)
(write-fixnum/32 (char-code #\y) stream)
(write-fixnum/32 (char-code #\b) stream)
(write-fixnum/32 (char-code #\s) stream)
(write-fixnum/32 (char-code #\t) stream)
(write-bst-bytes bst stream (* 5 4))))

现在可以将*bst*写入文件了

1
(write-bst-to-file *bst* "/tmp/bst.dat")

使用hexdump验证写入的效果

文件最开始的五个字节依次存储着字符串"mybst"的ASCII码,为的就是让最早被写入文件中的根节点——也就是二叉树最左下角的节点——的偏移不为0,以免在后续反序列化的时候,从该节点的父节点中读到左子树的偏移为0——这样会被误认为是一棵空树的。

有哪里写得不好的还请各位读者不吝赐教。

给Emacs写插件有种痛并快乐着的感觉。虽然这个发挥创意的过程很有趣,但是Elisp写起来总有种别扭的感觉。一方面,我把它当成是Common Lisp,写的时候没有觉得“这个用法可能会有问题”;另一方面,它又不是普通的写lisp代码,还要一边写一边摸索Emacs中的一些概念。不过总体而言,还是挺好玩的,除了没有一个像模像样的REPL之外。

来龙去脉

我用Emacs记录了不少的“笔记”。虽说我自己将其称为笔记,但是它们更像是我把遇到的一些问题和解决方法给记录下来,而没有太多自己的感悟。它们的外观倒是高度的一致,见下图

(第一次尝试给自己的图片打水印,有点好玩)每一个一级条目都是一个问题,并且这个文件中只有一级条目。而条目下的内容则是对标题的问题的回答。其中还有代码块——也就是写着BEGIN_SRC和END_SRC的那部分。用org-mode来记录笔记有几个好处,其中一个便是可以在笔记中插入任何Emacs支持的编程语言代码片段并具备语法高亮。当然了,还有一个巨大的优势,便是org-mode尽管看似花里胡哨,骨子里却是正统的纯文本文件,它可以很方便地在其它工具中处理。

而我用来处理的其中一个工具便是ElasticSearch。比如说,上图的第一条笔记,在ElasticSearch中存成了下面这样的结构

本来我是写了一个Alfred的Workflow来查询ElasticSearch的,但是奈何Workflow那种一行行的方式展示org-mode格式的笔记不太友好,因此便打算直接在Emacs中查询并查看笔记内容。

牛刀小试

为了可以在Emacs中查看笔记内容,我打算借助于Helm的力量。Helm是Emacs的一个补全的框架,可以用来呈现一系列的候选项,然后选中后触发一些什么动作。我期望的形式,是在Emacs中按下某种快捷键或者输入某个命令行,可以在minibuffer中输入自己要查询的内容,然后Emacs查询ElasticSearch并最终通过Helm来呈现这些查询内容匹配的笔记条目。目前的成果是下面这样子的

具体的做法其实也很简单。首先,要知道Helm是如何被使用的。通过这篇文档,初步了解到只需要定一个变量,并通过:sources关键字参数传递给helm这个函数即可。我所定义的传递给helm函数的“source”如下

1
2
3
4
5
6
(setq faq-helm-sources
`((name . "FAQ at Emacs")
(candidates . faq-candidates)
(action . (lambda (candidate)
(let ((url (format "http://localhost:9200/faq/_doc/%s" candidate)))
(browse-url url))))))

其中faq-candidates的作用便是根据minibuffer中的关键字查询ElasticSearch并组织好一个结构返回给helm。需要注意的是,faq-candidates必须是一个无参的函数才行,但输入的数据又偏偏需要从minibuffer中获取。因此,我的做法是约定一个变量faq-query,在调用helm之前首先调用read-from-minibuffer函数读取输入,然后将输入的字符串赋值给faq-query,之后当helm开始使用这个source的时候,faq-candidates函数便不需要参数,而可以直接从faq-query中拿到自己需要的搜索内容向ElasticSearch请求了。当然了,如果有像Common Lisp动态作用域的话,也就不需要定义这么一个全局变量了,对Emacs全局的侵入会更少一点。

目前能够做到的也仅仅是查询ElasticSearch,并在选中某个条目并按下回车的时候打开浏览器来查看而已,之后应该会继续完善。目前的完整代码如下

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
;;; 调用ElasticSearch查询笔记
(require 'request)

(defun faq (query)
"向ElasticSearch查询QUERY匹配的笔记"
(let ((response))
(request
"http://localhost:9200/faq/_search"
:data (encode-coding-string
(json-encode
(list
(cons "query" (list
(cons "multi_match" (list
(cons "fields" (list "answer" "question"))
(cons "query" query)))))))
'utf-8)
:headers '(("Content-Type" . "application/json"))
:parser 'buffer-string
:success (cl-function
(lambda (&key data &allow-other-keys)
(setq data (decode-coding-string data 'utf-8))
(setq response (json-read-from-string data))))
:sync t)
response))

(defun make-faq-candidates (response)
"将查询ElasticSearch的结果构造为helm可以识别的candidates格式"
(let ((hits (cdr (assoc 'hits (cdr (assoc 'hits response))))))
(mapcar (lambda (doc)
(let ((_source (cdr (assoc '_source doc))))
(cons (cdr (assoc 'question _source))
(cdr (assoc '_id doc)))))
hits)))

(defvar faq-query nil)

(defun faq-candidates ()
(make-faq-candidates (faq faq-query)))

(setq faq-helm-sources
`((name . "FAQ at Emacs")
(candidates . faq-candidates)
(action . (lambda (candidate)
(let ((url (format "http://localhost:9200/faq/_doc/%s" candidate)))
(browse-url url))))))

(defun lt-ask ()
"交互式地从minibuffer中读取笔记的关键词并展示选项"
(interactive)
(let ((content (read-from-minibuffer "笔记关键词:")))
(setq faq-query content)
(helm :sources '(faq-helm-sources))))

有不少值得吐槽的地方,不过都先按下不表吧,各位读者有兴趣的话可以留言交流一下XD

本文讲解如何编译defun。在Common Lisp中,defun用于定义函数。例如,下列的代码定义了函数foo

1
2
3
4
(defun foo (a)
"一个名为FOO的函数"
(declare (ignorable a))
(1+ 1))

defun语法中,第一行的字符串是这个函数的文档,可以用documentation函数获取;第二行是declaration。(不管是documentation还是declaration,也许要等到自举的那一天才能够支持了)目前只打算支持如下这般朴素的defun用法:

阅读全文 »