我准备深入探讨如何为比特币脚本编程设计 Lisp 语言的一种变体,但深入细节之前,我发现,总结一下Chialisp的设计理念可能会有帮助,因为这给人们反馈的许多问题提供了很好的答案。如果你觉得文章的这个主题有趣,就读下去;如果你觉得无聊,那么也可以记得这篇帖子,这样你可以跳转回来,在日后读到我将本文内容作为前置知识、你又认为是有趣文章的时候!
本文假设你已经熟悉了比特币和区块链编程的基础知识,但还不熟悉Chialisp和CLVM。本文会采用逐步推导的方法,不会深入到关于实现方式的任何细节。
对于已经熟悉 Chialisp 的读者来说,请注意,我主要谈论的是CLVM的动作(即,在 Chia 区块链的共识中实现、在链上可见的部分),而不是 Chialisp 本身(你用来编写复杂程序的语法;其代码会被编译到 CLVM 中运行)。
为什么?
为什么这个问题值得思考?不论如何,Bitcoin Script 是一个非常小的语言,本质上是一个小巧的、基于堆栈的逆波兰表达法(PRN)计算器;它足够结实,已准备好取代中央银行的清算所,而且有足够多的可编程性,可以做出许多小玩意儿。但从一名程序员的角度看,作为一种编程语言,有两件事它做得不好:循环和结构化数据。
原生不能制作循环,意味着重复做同一件事的脚本在几轮之后就会变得烦人,比如说“默克尔树验证”,有 20 个步骤,每一步都涉及一个 8 层的默克尔树,每一次都要单独说明默克尔树,而不是用两个循环解决问题——如果有循环,那么 160 个步骤应该只构成 1 个循环。以往也一直有关于向 Bitcoin Script 添加可以启用循环的构造的思考,比如Bitcoin-NG和Drivechain,两者都显式限制为禁用循环(或者说限制迭代次数)。
不能原生处理结构化的数据不算一个大问题:你可以通过使用多种堆栈轮换操作码(将数据在栈层中上下移动)来绕过这个问题;然而,如果真的要在实践中这么做,就会很烦人。举个例子,部分提出的脚本升级提议都建议了能够应用多一些结构的更改,例如:
- BIP-116 让你可以指定少量数据,将它们编码为最小化的数据推入,作为给定的leaf-update-script-body
的前缀。
- BIP-117 提议引入一种类似于代码分隔符的操作码,自身并不做任何事,但可以用来隔离OP_SUCCESSx
动作的影响,从而可以在脚本内部操作脚本的片段。
- BIP-340,也允许指定额外最多 16 个比特的消息数据,该消息会被哈希并决定实际发生的签名验证操作
Chia Lisp 所采取的解决这些限制的方法,可能是最简单的:
- 它添加了一种 “应用” 操作码(a
),该操作码允许递归,因此可以用来制作循环;
- 它将堆栈的概念替换成了二叉树(binary tree)的概念——任何元素,要么是一个叶子节点(叫做 “atom”),即只是一个字节串(跟现在的堆栈元素一样);要么是一个分支节点,包含了一对元素(叫做 “cons”),这些元素自身又可以是分支或者叶子。
在 Lisp 中,常见的做法是,程序会用相同的数据结构编码,从而,程序可以用操作任何其它数据结构的相同操作来内省自身。
制作列表
不过,Lisp 以其列表(而非二叉树)而知名,所以上述段落可能会让你稍微有点意外。不过,这很容易解释:Lisp 的列表就是递归地创建出来的,其中列表的第一个元素就放在一个 cons 的左边,而列表剩余的元素就放在右边,而空的列表被定义成空的字节向量,也就是众所周知的 “nil”。我们有一套特殊的记号:
- ()
是一个空的列表,也是空的字节向量
- (a . b)
表示一个 cons,或者说一个分支元素,其中 a
在左边、b
在右边
- (a b c d)
表示一个包含四个元素的列表,等价于 (a . (b . (c . (d . ()))))
- (a b c d . e)
表示一个 “有断点” 的列表,以 e
(而不是隐式的 nil
)结尾,也即等价于 (a . (b . (c . (d . e))))
这意味着,(a b . (c d))
只是编写 (a b c d)
的另一种方法。
关于一个列表,如果你只看每一个 cons 的右边项,直到遇到一个 atom(即一个字节向量,也是一个叶子节点),那么要么这个字节向量是空的,说明这是一个正常的列表,也叫 “规整的列表”;要么,它不是一个空向量,从而这是一个 “不规整” 的列表。
如果你也像我一样,听过 “car” 和 “cdr”,但不明其意,那么,car
意味着取一对元素的左边项,cdr
以为这取右边项,它们来自于最初的 Lisp 的实现细节。我个人认为,最好管它们叫 “头” 和 “尾”(就像其它的常见叫法一样);或者,像 Common Lisp 和 Chia Lisp 那样,叫 “第一项” 和 “其余项”。
针对列表的操作码
然后,拓展出对一列参数的操作码就非常直接的,例如,我们不需要再使用OP_ADD
从栈顶中取出两个元素并加总,我可以定义出+
操作码,预期传入一列数值,然后输出这一列数值的和。然后,我们只需将操作码和它的参数组合成一对,即(+ . (1 2 3))
,就可以了;当然,如前所述,这跟(+ 1 2 3)
的写法的意思是一样的,并且后者也成了我们程序中的表达式的通用记号。
运行环境
我们希望能够编写能够应用到一些 “用户输入” 上的程序,为此,我们定义一个叫做 “环境” 的概念,就是程序可以访问的东西。环境只是另一个列表(或者说二叉树),但我们有一种更聪明的办法来访问列表的任一部分,就通过整数。具体来说,1
会让我们得到整个式子;2
则会让我们得到左分支;3
会得到右分支;4
会得到左分支的左分支;6
会得到左分支的右分支,等等。
通用的程序如下:python def get_env(n, e): if n == 0: return nil while n > 1: if n % 2 == 0: e = e.left else: e = e.right n //= 2 return e
所以,如果环境是一个列表(1 2 3)
,那么get_env(2, env)
会得出 1,而get_env(5, env)
会得到 2,get_env(11, env)
会得到 3,而get_env(15, env)
会得到整个列表的最后一个元素,nil
。
因此,如果你想要程序 “ (a*b)+c ”,你可以写成(+ (* 2 5) 11)
,这些数字会被视为环境查找,如果环境是(1 2 3)
,那么结果就是(1 * 2) +3 = 5
。
引用
要是你想让一个数字变成 3 倍大,而不是乘以某个由用户指定的输入,那该怎么办呢?这时候你可以 “引用” 数值,以避免将它解析成环境查找或者作为一个表达式来求值。这涉及到一个特殊的操作码q
,它的求值规则跟其它每一种操作码都不同。它的动作非常简单:它的结果就是我们传入的参数,没有任何求值过程。它的特殊性还在于,它的参数并不非得是一个规整的列表(以 nil 结尾的列表)。
所以,如果你想计算的是(a*b)+(3*c)
(而非(a*b)+c
),你可以写成(+ (* 2 5) (* (q . 3) 11)
。