乍听之下,不无道理;仔细揣摩,胡说八道

0%

序言

我在上大学的时候并没有接触过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——这样会被误认为是一棵空树的。

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