比特币脚本如何借鉴Chialisp解决循环与结构化数据难题?

我准备深入探讨如何为比特币脚本编程设计 Lisp 语言的一种变体,但深入细节之前,我发现,总结一下Chialisp的设计理念可能会有帮助,因为这给人们反馈的许多问题提供了很好的答案。如果你觉得文章的这个主题有趣,就读下去;如果你觉得无聊,那么也可以记得这篇帖子,这样你可以跳转回来,在日后读到我将本文内容作为前置知识、你又认为是有趣文章的时候!

本文假设你已经熟悉了比特币和区块链编程的基础知识,但还不熟悉ChialispCLVM。本文会采用逐步推导的方法,不会深入到关于实现方式的任何细节。

对于已经熟悉 Chialisp 的读者来说,请注意,我主要谈论的是CLVM的动作(即,在 Chia 区块链的共识中实现、在链上可见的部分),而不是 Chialisp 本身(你用来编写复杂程序的语法;其代码会被编译到 CLVM 中运行)。

为什么?

为什么这个问题值得思考?不论如何,Bitcoin Script 是一个非常小的语言,本质上是一个小巧的、基于堆栈的逆波兰表达法(PRN)计算器;它足够结实,已准备好取代中央银行的清算所,而且有足够多的可编程性,可以做出许多小玩意儿。但从一名程序员的角度看,作为一种编程语言,有两件事它做得不好:循环和结构化数据。

原生不能制作循环,意味着重复做同一件事的脚本在几轮之后就会变得烦人,比如说“默克尔树验证”,有 20 个步骤,每一步都涉及一个 8 层的默克尔树,每一次都要单独说明默克尔树,而不是用两个循环解决问题——如果有循环,那么 160 个步骤应该只构成 1 个循环。以往也一直有关于向 Bitcoin Script 添加可以启用循环的构造的思考,比如Bitcoin-NGDrivechain,两者都显式限制为禁用循环(或者说限制迭代次数)。

不能原生处理结构化的数据不算一个大问题:你可以通过使用多种堆栈轮换操作码(将数据在栈层中上下移动)来绕过这个问题;然而,如果真的要在实践中这么做,就会很烦人。举个例子,部分提出的脚本升级提议都建议了能够应用多一些结构的更改,例如:
- 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)

标签:

上一篇:Polkadex:波卡生态DEX的创新实践
下一篇:LNURL-PAY如何实现静态二维码支付?