概要
Solidity(或者更概略的说,EVM),在软件工程师的生产效率和语言表达能力都还有好长一段路需要走。如果你曾经在以太坊上开发智能合约,到现在你应该已经察觉,Solidity真是个绑手绑脚的语言。
特别是在你是从Swift或Javascript转行过来开发Solidity。用Solidity开发程序,这个语言允许软件工程师能做的事情,还有整个语言的表达能力,都让人有种倒退走的感觉。
这种感觉有时候真的会让人发飙。
但为什么它是受限的语言
Solidity和其他能够被编译成能够在EVM(以太坊虚拟机)上运作的bytecode(位组码),都会受到某些限制,因为:
· 当你要执行你的程序的时候,你的程序会在整个系统上的每一台节点上运作。当一个节点收到新的区块的时候,这个节点就会验证这个区块的完整性。在以太坊上这些验证包含了,这个区块所包含的所有计算是正确的,而且合约的每个状态都是计算正确的。
· 这造成了,即便EVM是图灵完备,大量的计算会十分昂贵(甚至会因为超过燃料(gas)上限而直接不被允许),因为每个节点都需要把这些计算计算一遍。也就因此把整个以太网络拖慢。
· 标准函式库(standard library)还没被开发完成。尤其是数组和字串都很难用。我本人都还曾经自己实作过操作字串的函式库,就为了一些很基本,我们之前都习以为常的功能。
· 你所写的合约没办法从外界(EVM之外)获得任何数据,除非透过交易(Oracle)。而且当一笔交易被布署到网络上,他就没把办法再升级改版(除非是透过migration或是纯储存合约(pure storage contract))。
这些限制有些是以太坊必须一定存在的限制(就像是你永远不可能会有办法储存你Google Photos上照片的备份在链上,或者单纯透过链上的计算资源去做图像辨识,但这也没什么关系啦)。但其他限制会存在,只是因为这是一个十分新的科技(虽然真的进步的超级快),但这个技术也将会一直改善。
好的所以我到底要怎么解决这个问题?
当我们在开发项目的时候,我们可能在未来会对合约做更改。我们可以透过横跨不同合约间信息传递的方式去间接解决这个问题。在进入可升级智能合约(Upgradable Smart Contracts)的实作之前,让我们先来了解它的限制。
什么是函式库,为什么我们需要函式库?
在Solidity,函式库(library)是一种特殊的合约,这种合约不会储存任何数据(storage),并且也不能持有任何以太币。有时候我们可以试着把函式库当成一种以太坊虚拟机(EVM)的单例(Singleton)就好。这个单例是一段可以被其他合约呼叫的代码,而且这段代码在被呼叫的时候不需要重新布署。这个特性解决了一些我们所面对的大问题,比如说:
· 布署所需的燃料(gas)花费:因为同样的代码不需要一而再再而三地被布署,所以一个很明显的优势就是能够节省大量的燃料。并且不同的合约可以都倚赖同一个已经被布署出去的函式库。
· 繁冗的代码在区块链重复出现:这明显的是上面那点所伴随来的好处,布署的次数比较少,区块链上的纪录也就比较少。
· 代码升级:在之前的状况是,如果需要修改程序错误或者帮合约改版,就需要重新布署一个新的,因而与之前合约独立的合约(甚至更惨的状况是像之前一样要对以太方进行硬分岔)。这个问题因此被解决了
有没有开始觉得函式库是一个很厉害的东西了阿?不幸的是,函式库也有一些限制,下面是几点是关于函式库我们必须知道的重要信息:
· 没有储存(storage)的能力
· 函式库能够操纵其他合约的储存(storage)
· 函式库不能有任何payable的函式(function)。
· 函式库不能有任何fallback的函式(function)。
· 函式库不能有事件日志(event log)。
· Libraries can be used to fire event logs for the contract which uses it.
· 函式库是不能被继承的
· 虽然函式库不能直接被继承,但是函式库可以跟其他函式库接在一起,就能像一个一般合约一样的使用被接上的函式库,单这样使用,函式库本身的限制依然还会在。
这几点可能在一开始会让人听起来很混乱,但是别灰心,这边有一个非常棒的资源可以协助你了解函式库。
但至少接下来,我们只会用上一些,因为了解、实作可升级智能合约所必须了解的部分就好了。
函式库是如何运作的?
函式库是一种特殊的合约,这种合约不能有任何payable函式,而且也不能有任何fallback函式(这些限制在编译期间就被强行限制了,因此可以让函式库这种合约绝无可能持有任何资金)。函式库是透过函式库关键字(library L{})去定义的,就像一个合约会透过(contract C{})去定义一样。
library L{
function a() returns (address) {
return address(this);
}
}
contract C{
function a() constant returns (address) {
//This will behave as if the library code was written within this contract
return L.a();
}
}
要呼叫一个函式库里面的函式必须用特别的指令(DELEGATECALL),这个指令会传递呼叫函式的信息(calling context)到函式库,因此就几乎像是一个都在同个合约里面,合约自己处理自己的指令。我真的蛮喜欢在Solidity文件里面阐述这件事情的角度。
函式库可以被视为给其他智能合约使用的固有基础合约
从上面的的代码可以知道,如果合约C的函式a()被呼叫,a()会回传合约C的地址而非函式库L的地址。同样这点也适用所有msg的性质:msg.sender,msg.value,msg.sig,msg.data以及msg.gas(Solidity文件里面所写的与这相反,但是在做了一些实验之后,似乎我这样解释才是正确的)。
一件我们在这边会注意到的事情是,我们还是不太清楚类型C(class C)和函式库L(library L)是怎么被连在一起的,因此接下来我们就会试着了解这件事情。
函式库们是如何被连接起来的?
与显式继承不同的是,一个倚赖函式库合约(contract C is B {}),他与函式库之间的连接(link)方式是没有那么清楚的。在上述例子,合约C在他自己的函式a()中用到函式库L。但是在上面这短短的代码中,我们都没有提到函式库要使用什么地址,而且函式库L不会出现在合约C编译完的位组码(byecode)中。
函式库的连接是发生在位组码等级。当合约C被编译完成后,合约C会替函式库的地址给一个像是下面这种形式的预留位置0073__L_____________________________________630dbe671f(0dbe671f是a()的function signature),如果我们完全不更动合约C就布署合约C,这样就会因为位组码是不合法的导致布署失败。
简单解释就是,函式库连接就是简单的把所有在合约位组码里面预留给函式库的位置,全部都用函式库的地址填上就完成了。这样填完的位组码就可以顺利的布署到区块链上。
好的我们现在介绍了函式库的基本概念后,我们就可以了解怎么用函式库去开发可升级智能合约了。
函式库本身是没办法升级的
对的函式库没办法升级,而且合约也没办法升级。就像我们本篇文章前面所提到的,对函式库的参考(reference)是位组码(bytecode)等级的事情,而不是储存(storage)等级的事情。在合约一布署后,就无法修改合约的位组码。因此对于函式库的参考就会被随着合约永存。
所以我们必须问这个问题「为什么还是有人提出可升级这个特点呢?」
最后,他到底怎么运作的?
这边我们会用到一些小技巧,让我们一起仔细了解。
我们没有直接将给使用者使用的主合约C和布署所需的函式库连接,而是将给使用者使用的主合约C跟一个调度员(Dispatcher)合约连接起来。在编译时期和布署时期,因为调度员合约没有实作函式库内任何函数,所以一切都不会出现问题。这也就意味着,调度员合约并没有用到任何函式库内的代码,调度员合约仅仅是位组码(就像是我们在上面看到合约C的位组码一样),在调度员的位组码中,并不需要引用(include)函式库的地址。我们就没有将任何地址写死(hardcode)在位元组码等级,也因此我们就可以随时把函式库替换成任何我们所需的函式库。
然而,如果我们没有在调度员合约中用任何函式库的代码,这样我们要如何执行函式库中的函式?
当一笔交易进来的时候,主合约(Token contract,代币合约)发现这笔交易需要从主合约所连接的函式库(TokenLib1)呼叫delegatecall,然而呼叫delegatecall这件事情不会直接请函式库直接回传,而是会先透过调度员合约呼叫delegatecall。
接下来的事情就会变得很有趣了。一当调度员合约在他自己的fallback function中接收到delegatecall,调度员合约会在fallback function中判断所需的正确版本函式库是哪个,然后将呼叫delegatecall的要求导向正确的函式库。当函式库回传数据后,数据就会一路回到主合约。
虽然这个解法运作起来还不错,但是他还是有些限制。
限制
调度员合约必须知道呼叫函式库,函式库回传值的內存大小。现在这个问题可以透过mapping解决,会以function signature去取得回传值大小。为了简化说明,所以我们故意避免对这方面多做解释。
上述方法在以太坊虚拟机中可以成功运行,但是我们只能在不同合约间储存足迹(storage footprint)都相同的状况下才能使用。因为函式库没有任何储存,所以我们必须让调度员合约里面也没有任何储存。这也就是为什么需要有另外一个分离的Dispatcher Storage(见上图下面方块)去储存调度员合约所需的数据。还有,Dispatcher Storage的地址必须写死在调度员合约的位组码里面。
可以发现,使用者所会面使用到的合约(Token contract)其实没有使用到任何特别的技术,唯一不一样的地方就是,不能像之前一样连接到某特定版本的函式库,而是连接到调度员合约。