第5章Solidity高级编程 第4章介绍了Solidity最基础、最常用的一些语法特性,本章将介绍其更高级的用法。例如在编写一些复杂或大型的合约时,可能需要使用到合约继承、接口、库等特性,把一个合约拆分为多个合约,另外,还会介绍如何在合约中动态创建、如何使用通过ABI编码与合约交互、怎么节约合约调用的gas、如何使用Solidity内联汇编。 5.1合 约 继 承 继承是大多数高级语言都具有的特性,Solidity同样支持继承,Solidity继承使用关键字is(类似于Java等语言的extends或implements)。例如,contract B is A表示合约B继承合约A,称A为父合约,B为子合约或派生合约。 当一个合约从多个合约继承时,在区块链上只创建一个子合约,所有父合约的代码被编译到创建的合约中,并不会连带部署父合约。因此当使用super.f()调用父合约时,也不是进行消息调用,而仅仅是在本合约内进行代码跳转。 举个例子来说明继承的用法,示例代码如下: pragma solidity >=0.5.0; contract Owned { constructor() public { owner = msg.sender; } address payable owner; function setOwner(address _owner) public virtual { owner = payable(_owner); } } // 使用 is 表示继承 contract Mortal is Owned { event SetOwner(address indexed owner); function kill() public { if (msg.sender == owner) selfdestruct(owner); } function setOwner(address _owner) public override { super.setOwner(_owner); emit SetOwner(_owner); } } 在4.6.1节已介绍过,子合约可以访问父合约内的所有非私有成员,因此内部(internal)函数和状态变量在子合约里是可以直接使用的,比如上面示例代码中的父合约状态变量owner可以在子合约直接使用。 状态变量不能在子合约中覆盖。例如,上面示例代码中的子合约Mortal不可以再次声明状态变量owner,因为父合约中已经存在该状态变量。但是可以通过重写函数来更改父合约中函数的行为,例如上面示例代码中的子合约Mortal中的setOwner()函数。 5.1.1多重继承 Solidity支持多重继承,即可以从多个父合约继承,直接在is后面接多个父合约,例如: contract Named is Owned, Mortal { } 注意: 如果多个父合约之间也有继承关系,那么is后面的合约的书写顺序就很重要,顺序应该是,父合约在前,子合约在后。例如,下面的代码将无法编译: pragma solidity >=0.4.0; contract X {} contract A is X {} // 编译出错 contract C is A, X {} 5.1.2父合约构造函数 子合约继承父合约时,如果实现了构造函数,父合约的构造函数代码会被编译器复制到子合约的构造函数中,先看看最简单的情况,也就是构造函数没有参数的情况: contract A { uint public a; constructor() { a = 1; } } contract B is A { uint public b ; constructor() { b = 2; } } 在部署合约B时,可以看到a为1,b为2。 父合约构造函数如果有参数,会复杂一些,对构造函数传参有两种方式。 (1) 在继承列表中指定参数,即通过contract B is A(1)的方式对构造函数传参进行初始化,示例代码如下: abstract contract A { uint public a; constructor(uint _a) { a = _a; } } contract B is A(1) { uint public b ; constructor() { b = 2; } } (2) 在子合约构造函数中使用修饰符方式调用父合约,此时利用部署合约B的参数,传入到合约A中,示例代码如下: contract B is A { uint public b ; constructor() A(1) { b = 2; } } // 或者是 constructor(uint _b) A(_b / 2) { b = _b; } 5.1.3抽象合约 如果一个合约包含没有实现的函数,需要将合约标记为抽象合约,使用关键字abstract 定义抽象合约,不过即使实现了所有功能,合约也可能被标记为abstract。抽象合约是无法成功部署的,它们通常用作父合约。下面是抽象合约的示例代码: abstract contract A { uint public a; constructor(uint _a) { a = _a; } } 抽象合约可以声明一个纯虚函数,纯虚函数没有具体实现代码的函数,其函数声明用“; ”结尾,而不是"{ }",例如: pragma solidity >=0.5.0; abstract contract A { function get() virtual public ; } 纯虚函数和用关键字virtual修饰的虚函数略有区别。关键字virtual只表示该函数可以被重写,关键字virtual可以修饰除私有可见性(private)函数的任何函数上,无论函数是纯虚函数还是普通的函数,即便是重写的函数,也依然可以用关键字virtual修饰,表示该重写的函数可以被再次重写。 如果合约继承自抽象合约,并且没有通过重写实现所有未实现的函数,那么这个合约依旧是抽象的。 5.1.4函数重写 父合约中的虚函数(使用关键字virtual修饰的函数)可以在子合约被重写,以更改它们在父合约中的行为。重写的函数需要使用关键字override修饰,示例代码如下: pragma solidity >=0.6.0; contract Base { function foo() virtual public {} } contract Middle is Base {} contract Inherited is Middle { function foo() public override {} } 对于多重继承,如果有多个父合约有相同定义的函数,关键字override后必须指定所有的父合约名,示例代码如下: pragma solidity >=0.6.0; contract Base1 { function foo() virtual public {} } contract Base2 { function foo() virtual public {} } contract Inherited is Base1, Base2 { // 继承自隔两个父合约定义的foo(), 必须显式地指定override function foo() public override(Base1, Base2) {} } 如果函数没有标记为 virtual(5.2节介绍的接口除外,因为接口里面所有的函数会自动标记为virtual),那么就不能被子合约重写。另外私有函数不可以标记为virtual。如果getter()函数的参数和返回值都和外部函数一致,外部函数是可以被public的状态变量重写的,示例代码如下: pragma solidity >=0.6.0; contract A { function f() external pure virtual returns(uint) { return 5; } } contract B is A { uint public override f; } 但是公共的状态变量不能被重写。 如果函数在多个父合约都有实现,可以通过合约名指定调用哪一个父合约的实现,示例代码如下: pragma solidity >=0.5.0; contract X { uint public x; function setX() public virtual { x = 1; } } contract A is X { function setX() public virtual override { x = 2; } } contract C is X, A { function setX() public override(X, A) { X.setX(); // super.setX(); 将调用 A 的 setX。 } } 上述代码,合约C的setX()函数指定调用合约X的setX()函数。如何使用super来调用父合约函数,则会根据继承关系图谱,调用紧挨着的父合约,此例中继承关系图谱为(子合约到父合约序列)C、A、X,因此将调用A的setX()函数。 5.2接口 接口和抽象合约类似,不同的是接口不实现任何函数,同时还具有以下限制。 (1) 无法继承其他合约或接口。 (2) 无法定义构造函数。 (3) 无法定义变量。 (4) 无法定义结构体。 (5) 无法定义枚举。 接口由关键字interface表示,示例代码如下: interface IToken { function transfer(address recipient, uint amount) external; } 就像继承其他合约一样,合约可以继承接口,接口中的函数都会隐式地标记为virtual,意味着它们需要被重写。 除了接口的抽象功能外,接口广泛应用于合约之间的通信,即一个合约调用另一个合约的接口。例如,合约SimpleToken实现了上面的接口IToken: contract SimpleToken is IToken { function transfer(address recipient, uint256 amount) public override { ... } 另外一个合约(假设合约名为Award)则通过合约SimpleToken给用户发送奖金,奖金就是合约SimpleToken表示的代币,这时合约Award就需要与合约SimpleToken通信(外部函数调用),示例代码如下: contract Award { IToken immutable token; // 部署时传入合约SimpleToken的地址 constrcutor(IToken t) { token = t; } function sendBonus(address user) public { token.transfer(user, 100); } } 函数sendBonus()用于发送奖金,通过接口函数调用SimpleToken实现转账。 5.3库 在开发合约的时候,通常会有一些函数经常被多个合约调用,这个时候可以把这些函数封装为一个库,实现代码复用。库使用关键字library来定义,例如,定义库SafeMath的示例代码如下: pragma solidity >=0.5.0; library SafeMath { function add(uint a, uint b) internal pure returns (uint) { uint c = a + b; require(c >= a, "SafeMath: addition overflow"); return c; } } 库SafeMath实现了一个加法函数add(),它可以在多个合约中复用。例如,合约AddTest使用SafeMath的add()函数实现加法,代码如下: import "./SafeMath.sol"; contract AddTest { function add (uint x, uint y) public pure returns (uint) { return SafeMath.add(x, y); } } 库是一个很好的代码复用手段。不过要注意,库仅仅是由函数构成的,它不能有自己的状态变量。根据场景不同,库有两种使用方式: 一种是库代码嵌入引用的合约内部署(可以称为内嵌库); 另一种是作为库合约单独部署(可以称为链接库)。 5.3.1内嵌库 如果合约引用的库函数都是内部函数,那么在编译合约的时候,编译器会把库函数的代码嵌入合约里,就像合约自己实现了这些函数,这时的库并不会单独部署,前面的合约AddTest引用库SafeMath就属于这个情况。 5.3.2链接库 如果库代码内有公共或外部函数,库就可以被单独部署,它在以太坊链上有自己的地址,在部署合约的时候,需要通过库地址把库链接进合约里,合约通过委托调用的方式调用库函数。 前面提到,库没有自己的状态,在委托调用的方式下库合约函数是在发起调用的合约(下文称为主调合约)的上下文中执行的,因此库合约函数中使用的变量(如果有的话)都来自主调合约的变量,库合约函数使用的this也是主调合约的地址。 从另一个角度来理解为什么库不能有自己的状态。库是单独部署的,而它又会被多个合约引用(这也是库最主要的功能: 避免在多个合约里重复部署,可以节约gas),如果库拥有自己的状态,那它一定会被多个调用合约修改状态,这将无法保证调用库函数输出结果的确定性。 把前面的库SafeMath的函数add()修改为外部函数,就可以通过链接库的方式使用,示例代码如下: pragma solidity >=0.5.0; library SafeMath { function add(uint a, uint b) external pure returns (uint) { uint c = a + b; require(c >= a, "SafeMath: addition overflow"); return c; } } 合约AddTest的代码不用作任何更改,因为库SafeMath是独立部署的,合约AddTest要调用库SafeMath就必须先知道后者的地址,这相当于合约AddTest会依赖库SafeMath,因此部署合约AddTest会有一点不同,需要一个合约AddTest与库SafeMath建立连接的步骤。 先来回顾一下合约的部署过程: 第一步由编译器生成合约的字节码,第二步把字节码作为交易的附加数据提交交易。 编译器在编译引用了库SafeMath的合约AddTest时,编译出来的字节码会留一个空,部署合约AddTest时,需要用库SafeMath的地址填充此空,这就是链接过程。 感兴趣的读者可以用命令行编译器solc操作一下,使用命令solcoptimizebin AddTest.sol可以生成合约AddTest的字节码,其中有一段用双下画线留出的空,类似_$239d231e517799327d948ebf93f0befb5c98$_,这个空就需要用库SafeMath的地址替换,该占位符是完整的库名称的Keccak256哈希的十六进制编码的34个字符的前缀。 5.3.3using for 在5.3.2节中,案例通过SafeMath.add(x, y)调用库函数,还有一个方式是使用using LibA for B,它表示把所有LibA的库函数关联到类型B。这样就可以在类型B直接调用库函数,示例代码如下: contract testLib { using SafeMath for uint; function add (uint x, uint y) public pure returns (uint) { return x.add(y); } } 使用using SafeMath for uint后,就可以直接在uint类型的x上调用x.add(y),代码明显更加简洁了。 using LibA for *则表示LibA中的函数可以关联到任意的类型上。使用using…for…看上去就像扩展了类型的能力。例如,可以给数组添加一个函数indexOf(),查看一个元素在数组中的位置,示例代码如下: pragma solidity >=0.4.16; library Search { function indexOf(uint[] storage self, uint value) public view returns (uint) { for (uint i = 0; i < self.length; i++) if (self[i] == value) return i; return uint(-1); } } contract C { using Search for uint[]; uint[] data; function append(uint value) public { data.push(value); } function replace(uint _old, uint _new) public { // 执行库函数调用 uint index = data.indexOf(_old); if (index == uint(-1)) data.push(_new); else data[index] = _new; } } 这段代码中函数indexOf()的第一个参数存储变量self,实际上对应合约C的变量data。 5.4应用程序二进制接口 在以太坊(Ethereum)生态系统中,ABI是从区块链外部与合约进行交互,以及合约与合约之间进行交互的一种标准方式。 5.4.1ABI编码 前面在介绍以太坊交易和比特币交易的不同时提到,以太坊交易多了一个DATA字段,DATA的内容会解析为对函数的消息调用,DATA的内容其实就是ABI编码。以下面这个简单的合约为例进行理解。 pragma solidity ^0.5.0; contract Counter { uint counter; constructor() public { counter = 0; } function count() public { counter = counter + 1; } function get() public view returns (uint) { return counter; } } 按照第6章的方法,把合约部署到以太坊测试网络Ropsten上,并调用函数count(),然后查看实际调用附带的输入数据,在区块链浏览器etherscan上交易的信息地址为https://ropsten.etherscan.io/tx/0xafcf79373cb38081743fe5f0ba745c6846c6b08f375fda028556b4e52330088b,如图51所示。 图51调用信息截图 如图51所示,交易通过携带数据0x06661abd表示调用合约函数count(),0x06661abd被称为函数选择器(function selector)。 5.4.2函数选择器 在调用函数时,用前面4字节的函数选择器指定要调用的函数,函数选择器是某个函数签名的Keccak(SHA3)哈希的前4字节,即: bytes4(keccak256("count()")) count()的Keccak的哈希结果是06661abdecfcab6f8e8cf2e41182a05dfd130c76cb32b448d9306aa9791f3899,开发者可以用在线哈希工具(https://emn178.github.io/onlinetools/keccak_256.htm)验证,取出前面4个字节就是0x06661abd。 函数签名是函数名及参数类型的字符串(函数的返回类型并不是这个函数签名的一部分)。比如count()就是函数签名,当函数有参数时,使用参数的基本类型,并且不需要变量名,因此函数add(uinti)的签名是add(uint256)。如果有多个参数,使用“,”隔开,并且要去掉表达式中的所有空格。因此,foo(uint a,bool b)函数的签名是foo(uint256,bool),函数选择器计算则是: bytes4(keccak256("foo(uint256,bool)")) 公有或外部(public/external)函数都包含一个成员属性.selector的函数选择器。 5.4.3参数编码 如果一个函数带有参数,编码的第5字节开始是函数的参数。在前面的合约Counter中添加一个带参数的方法: function add(uint i) public { counter = counter + i; } 图52Remix调用add()函数 重新部署之后,使用16作为参数调用函数add(),调用方法如图52所示。 在etherscan上查看交易附加的输入数据,查询地址为https://ropsten.etherscan.io/tx/0x5f2a2c6d94aff3461c1e8251ebc5204619acfef66e53955dd2cb81fcc57e12b6,如图53所示。 图53函数调用的ABI编码 输入数据为0x1003e2d20000000000000000000000000000000000000000000000000000000000000010。其中,前4字节0x1003e2d2为函数add()的函数选择器,后面的32字节是参数16的二进制表示,会补充到32字节长度。不同的类型,参数编码方式会有所不同,详细的编码方式可以参考ABI编码规范(https://learnblockchain.cn/docs/solidity/abispec.html)。 5.4.4通过ABI编码调用函数 通常,在合约中调用合约Counter的函数count()的形式是Counter.count(),第4章介绍过底层调用函数call(),因此也可以直接通过函数call()和ABI编码来调用函数count(): (bool success, bytes memory returnData) = address(c).call("0x06661abd"); //c为Counter合约的地址,0x06661abd require(success); 其中,c为Counter合约的地址,0x06661abd是函数count()的编码,如果函数count()发生异常,调用call()会返回false,因此需要检查返回值。 使用底层调用可以非常灵活地调用不同合约的不同的函数,在编写合约时,并不需要提前知道目标函数的合约地址及函数。例如,定义一个合约Task,它可以调用任意合约,代码如下: contract Task { function execute(address target, uint value, bytes memory data) public payable returns (bytes memory) { (bool success, bytes memory returnData) = target.call{value: value}(data); require(success, "execute: Transaction execution reverted."); return returnData; } } 5.4.5ABI接口描述 ABI接口描述是由编译器编译代码之后,生成的一个对合约所有函数和事件描述的JSON文件。一个描述函数的JSON包含以下字段。 (1) type: 可取值有function、constructor、fallback,默认为function。 (2) name: 函数名称。 (3) inputs: 一系列对象,每个对象包含属性name(参数名称)和type(参数类型)。 (4) components: 给元组类型使用,当type是元组(tuple)时,components列出元组中每个元素的名称(name)和类型(type)。 (5) outputs: 一系列类似inputs的对象,无返回值时,可以省略。 (6) payable: true表示函数可以接收以太币,否则表示不能接收,默认值为false。 (7) stateMutability: 函数的可变性状态,可取值有pure、view、nonpayable、payable。 (8) constant: 如果函数被指定为pure或view,则为true。 一个描述事件的JSON包含以下字段。 (1) type: 总是event。 (2) name: 事件名称。 图54获取ABI信息 (3) inputs: 对象数组,每个数组对象会包含属性name(参数名称)和type(参数类型)。 (4) components: 供元组类型使用。 (5) indexed: 如果此字段是日志的一个主题,则为true,否则为false。 (6) anonymous: 如果事件被声明为anonymous,则为true。 在Remix的编译器页面,编译输出的ABI接口描述文件,可以用来查看合约Counter的接口描述,只需要在如图54所示方框处单击ABI按钮,ABI描述就会复制到剪切板上。 下面是ABI描述代码示例: { "constant": false, "inputs": [], "name": "count", "outputs": [], "payable": false, "stateMutability": "nonpayable", "type": "function" }, { "constant": true, "inputs": [], "name": "get", "outputs": [ { "internalType": "uint256", "name": "", "type": "uint256" } ], "payable": false, "stateMutability": "view", "type": "function" }, { "inputs": [], "payable": false, "stateMutability": "nonpayable", "type": "constructor" } JSON数组中包含了3个函数描述,描述合约所有接口方法,在合约外部(如DApp)调用合约方法时,就需要利用这个描述获得合约的方法,第7章会进一步介绍ABI JSON的应用。不过,DApp 开发人员并不需要使用ABI编码调用函数,只需要提供ABI的接口描述JSON文件,编码由Web3或ether.js库来完成。 5.5gas优化 gas优化是开发以太坊智能合约一项非常有挑战性的任务。以太坊上的计算资源是有限的,每个区块可用的gas是有上限的(2021年单个区块区块限制约为1250万)。随着链上去中心化金融应用的兴起,以太坊的利用率逐渐增长,由于矿工是以gas竞价排名的方式打包区块,当以太坊的利用率非常高时,只有gas价格高的交易才能得到打包的机会,从而导致交易手续费一直居高不下。在这样的背景下,优化合约交易的gas耗用量就显得更加重要。 5.5.1变量打包 合约内总是用连续32字节(256位)的插槽来存储状态变量。当操作者在一个插槽中放置多个变量时,被称为变量打包。 存储操作码指令(SSTORE)消耗的gas成本非常高,首次写时,每32字节的成本是20000gas,而后续每次修改则为5000gas,变量打包可以减少SSTORE指令的使用。 变量打包就像俄罗斯方块游戏。如果打包的变量超过当前槽的32字节限制,它将被存储在一个新的插槽中。操作者必须找出哪些变量最适合放在一起,以最小化浪费的空间。 因为使用每个插槽都需要消耗gas,变量打包通过减少合约要求的插槽数量,帮助优化gas 的使用,例如以下变量: uint128 a; uint256 b; uint128 c; 这些变量无法打包。如果b和a打包在一起,那么就会超过32字节的限制,所以会被放在新的一个储存插槽中。同样c和b打包也如此。使用下面的顺序定义变量,效果更好: uint128 a; uint128 c; uint256 b; 因为c和a打包之后不会超过 32 字节,所以可以被存放在一个插槽中。 在选择数据类型时,如果刚好可以与其他变量打包放入一个储存插槽中,那么使用一个小数据类型是不错的。 但是当变量无法和合并打包时,应该尽量使用256位的变量,例如,uint256和bytes32,因为EVM的运行时,总是一次处理32字节,如果只存储一个uint8,EVM其实会用零填充所有缺少的数字,这会耗费gas。同时,EVM执行计算时也执行类型转化,将变量转化为uint256,因此使用256位的变量会更有效率。 5.5.2选择适合的数据类型 1. 使用常量或不可变量 如果在合约运行中,一个数据的值一直是固定的,则应该使用常量(constant)或不可变量(immutable),这两种类型将数据包含在智能合约的字节码中,则用于加载和存储数据的gas消耗将大大减少。定义常量(constant)或不可变量(immutable)的代码如下: contract C { uint constant X = 32**22 + 8;// 定义常量 string constant TEXT = "abc"; uint immutable decimals; // 定义不可变量 constructor(uint _d) { decimals = _d; } } 常量与不可变量的区别是,常量在编译期确定值,不可变量在部署时确定值。 2. 固定长度比变长更好 如果能确定一个数组有多少元素,应该优先采用固定大小的方式,例如,下面是一个按月存储的数据,可以使用12个元素的数组: uint256[12] monthlys; 这同样也适用于字符型,一个string或者bytes变量是变长的。如果一个字符串很短,则应该使用固定长度的bytes1~bytes32类型。 3. 映射和数组 大多数的情况下,映射的gas消耗会优于数组,映射存储、读取、删除的gas消耗都是固定的,而数组的gas消耗会随数组长度增长而线性增长。不过,当元素类型是较小的数据类型时,数组是一个不错的选择时。数组元素会像其他存储变量一样被打包,这样可节省存储空间以弥补昂贵的数组操作。如果必须要设计一个动态数组,也需要尽量让数组保持末尾递增,避免数组的移位。 5.5.3内存和存储 在内存中操作数据,比在存储中操作状态变量数据方便得多。减少存储操作的一种常见方法是在分配给存储变量之前,使用内存变量进行操作。例如,下面的Solidity代码中,num变量是一个存储型状态变量,那么在每次循环中都操作num很浪费gas。 uint num = 0; function expensiveLoop(uint x) public { for(uint i = 0; i < x; i++) { num += 1; } } 可以创建一个临时变量,来代替上述全局变量参与循环,然后在循环结束后重新将临时变量的值赋给全局状态变量,代码如下: uint num = 0; function lessExpensiveLoop(uint x) public { uint temp = num; for(uint i = 0; i < x; i++) { temp += 1; } num = temp; } 5.5.4减少存储 1. 清理存储 根据EVM的规则,在删除状态变量时,EVM会返还一部分gas,返还的gas可以用来抵消交易消耗的gas。尤其在清理大数据变量时,返还的gas将相当可观,最高可达交易消耗gas的一半,代码如下: contract DelC { uint [] bigArr; function doSome() public { // do some delete bigArr; } } 同样的道理,当有一个合约不再使用时,可以把合约销毁返还gas。 2. 使用事件储存数据 那些不需要在链上被访问的数据可以存放在事件中达到节省gas的目的。 触发事件的LOG指令基础费用是375 gas,远小于SSTORE指令。 例如,要记录文档的注册记录,很多时候不假思索,会这样写: contract Registry { mapping (uint256 => address) public documents; function register(uint256 hash) public { documents[hash] = msg.sender; } } 其实下面的代码更高效: contract DocumentRegistry { event Registered(uint256 hash, address sender); function register(uint256 hash) public { emit Registered(hash, msg.sender); } } 这个合约没有任何变量存储,但是实现了同样的功能,事件记录同样会在区块链上永久保存,在需要查询数据时,可以通过订阅事件把数据缓存到数据库查询记录。 5.5.5其他建议 1. 初始化 在Solidity中,每个变量的赋值都要消耗gas。在初始化变量时,避免使用默认值初始化,例如“uint256 value”比“uint256 value=0”消耗的gas少。 2. Require字符串 如果操作者在require中增加语句,可以通过限制字符串长度为32字节降低gas消耗。 3. 精确的声明函数可见性 在Solidity合约开发中,显式声明函数的可见性不仅可以提高智能合约的安全性,同时也有利于优化合约执行的gas成本。例如,仅会通过外部执行的函数应该显式地标记函数为外部函数(external)而不是笼统地使用公共函数(public)。 4. 链下计算 例如在排序列表中,向列表中添加元素后,依旧要确保其仍是有序的。经验不足时需要在整个集合中进行迭代,以找到合适的位置进行插入。一种更有效的方法是在链下计算合适的位置,仅在链上进行相应的验证(例如,添加的值位于其相邻元素之间),这可以防止成本随数据结构的变化呈线性增长。 5. 警惕循环 当合约中存在依赖时间、依赖数据大小(如数组长度)的循环时,很可能导致潜在的漏洞。随着数据量的增多(或时间的增长),gas的消耗就可能对应地线性增长,很可能突破区块限制导致无法打包。 首先要尽可能避免使用这种循环,将依靠循环的线性增长的计算量尽可能转化为固定大小的计算量(或常量)。如果没法做到转换,那就要考虑限制循环的次数,即限制总数据的大小,把一个大数据分拆为多个分段的小数据(即想办法限制单次的计算量大小),比如依靠时间长度计算收益的质押合约,可以设置质押有效期,比如设置质押有效期最长为一年,一年到期之后,用户需提取再质押。 5.6使用内联汇编 本节的内容在智能合约开发中使用较少,读者也可以选择跳过,本节亦是抛砖引玉,内联汇编语言Yul仍然在不断地进化,对这部分内容感兴趣的读者可以阅读官方资料。 5.6.1汇编基础概念 实际上很多高级语言(例如C、Go或Java)编写的程序,在执行之前都将先编译为汇编语言。汇编语言与CPU或虚拟机绑定实现指令集,通过指令告诉CPU或虚拟机执行一些基本任务。 Solidity语言可以理解为是以太坊虚拟机EVM指令集的抽象,让操作者更容易编写智能合约更容易。而汇编语言则是Solidity语言和EVM指令集的一个中间形态,Solidity也支持直接使用内联汇编,下面是在Solidity代码中使用汇编代码的例子。 contract Assembler { function do_something_cpu() public { assembly { // 编写汇编代码 } } } 在Solidity中使用汇编代码有如下的好处。 (1) 进行细粒度控制。可以在汇编代码使用汇编操作码直接与EVM进行交互,从而对智能合约执行的操作进行更精细的控制。汇编提供了更多的控制权执行某些仅靠Solidity不可能实现的逻辑,例如控制指向特定的内存插槽。 (2) 更少的gas消耗。此处通过一个简单的加法运算对比两个版本的gas消耗,一个版本是仅使用Solidity代码,一个版本是仅使用内联汇编。 function addAssembly(uint x, uint y) public pure returns (uint) { assembly { let result := add(x, y)// x+y mstore(0x0, result) // 在内存中保存结果 return(0x0, 32) // 从内存中返回32字节 } } function addSolidity(uint x, uint y) public pure returns (uint) { return x + y; } gas的消耗如图55所示。从图55可以看到,使用内联汇编可以节省86gas。对于这个简单的加法操作来说,减少的gas并不多,但已经表明直接使用内联汇编将消耗更少的gas,更复杂的逻辑能更显著地节省gas。 图55gas消耗对比图 5.6.2Solidity中引入汇编 可以在Solidity中使用assembly{}嵌入汇编代码段,这被称为内联汇编,代码如下: assembly { // some assembly code here } 在assembly块内的代码开发语言被称为Yul。 Solidity可以引入多个汇编代码块,不过汇编代码块之间不能通信,也就是说在一个汇编代码块里定义的变量,在另一个汇编代码块中不可以访问。 因此以下这段代码的b无法获取到a的值: assembly { let a := 2 } assembly { let b := a// Error } 再看一个使用内联汇编代码完成加法的例子,重写函数addSolidity(),代码如下: function addSolidity(uint x, uint y) public pure returns (uint) { assembly { let result := add(x, y) // ① x + y mstore(0x0, result) // ② 将结果存入内存 return(0x0, 32)// ③ } } 对上面这段代码做一个简单的说明。 (1) 创建一个新的变量result,通过操作码add计算x+y,并将计算结果赋值给变量result。 (2) 使用操作码mstore将变量result的值存入地址0x0的内存位置。 (3) 表示从内存地址0x0返回32字节。 5.6.3汇编变量定义与赋值 在Yul语言中,使用关键字let定义变量。使用操作符“:=”给变量赋值。 assembly { let x := 2 } 由于Solidity只需要用“=”,因此在Yul不要忘了“:”。如果没有给变量赋值,那么变量会被初始化为0,代码如下: assembly { let x// 自动初始化为x = 0 x := 5// x 现在的值是5 } 也可以使用表达式给变量赋值,代码如下: assembly { let a := add(x, 3) } 5.6.4汇编中的块和作用域 在Yul汇编语言中,用{}表示一个代码块,变量的作用域是当前的代码块,即变量在当前的代码块中有效,代码如下: assembly { let x := 3// 变量x一直可见 { let y := x // 正确 }// 到此处会销毁y { let z := y// 错误 } } 在上面的示例代码中,y和z都是仅在所在块内有效,因此z获取不到y的值。不过在函数和循环中,作用域规则有一些不一样,将在5.6.6节及5.6.9节中介绍。 5.6.5汇编中访问变量 汇编中只需要使用变量名就可以访问局部变量(指在函数内部定义的变量),无论该变量是定义在汇编块中,还是在Solidity代码中,示例代码如下: function localvar() public pure { uint b = 5; assembly { let x := add(2, 3) let y := mul(x, b) // 使用了外面的b let z := add(x, y) // 访问了内部定义的x,y } } 5.6.6for循环 Yul汇编语言同样支持for循环,例如,value+2计算n次的示例代码如下: function forloop(uint n, uint value) public pure returns (uint) { assembly { for { let i := 0 } lt(i, n) { i := add(i, 1) } { value := add(2, value) } mstore(0x0, value) return(0x0, 32) } } for循环的条件部分包含3个元素。 (1) 初始化条件: let i:=0。 (2) 判断条件: lt(i,n) ,这是函数式风格,表示i小于n。 (3) 迭代后续步骤: add(i, 1)。 for循环中变量的作用范围和前面介绍的作用域略有不同。在初始化部分定义的变量在循环条件的其他部分都有效。在for循环的其他部分中声明的变量依旧遵守4.6.4节介绍的作用域规则。此外,Yul汇编语言中没有while循环。 5.6.7if判断语句 Yul汇编语言支持使用if语句设置代码执行的条件,但是没有else分支,同时每个条件对应的执行代码都需要用“{}”,示例代码如下: assembly { if slt(x, 0) { x := sub(0, x) } // 正确 if eq(value, 0) revert(0, 0) // 错误, 需要{} } 5.6.8汇编switch语句 汇编语言中也有switch语句,它将一个表达式的值与多个常量进行对比,并选择相应的代码分支来执行。switch语句支持一个默认分支default,当表达式的值不匹配任何其他分支条件时,将执行默认分支的代码,示例代码如下: assembly { let x := 0 switch calldataload(4) case 0 { x := calldataload(0x24) } default { x := calldataload(0x44) } sstore(0, div(x, 2)) } switch语句的分支条件需要具有相同的类型、不同的值。如果分支条件已经涵盖所有可能的值,那么不允许再出现default条件。要注意的是,Solidity语言中是没有switch语句的。 5.6.9汇编函数 可以在内联汇编中定义自定义底层函数,调用这些自定义的函数和使用内置的操作码一样。下面的汇编函数用来分配指定长度(length)的内存,并返回内存指针pos,代码如下: assembly { function alloc(length) → pos { // ① pos := mload(0x40) mstore(0x40, add(pos, length)) } let free_memory_pointer := alloc(64) // ② } 代码说明: ①定义了一个alloc()函数,函数使用“>”指定返回值变量,不需要显式return返回语句; ②使用了定义的函数。 定义函数不需要指定汇编函数的可见性,因为它们仅在定义的汇编代码块内有效。 5.6.10元组 汇编函数可以返回多个值,它们被称为元组,可以通过元组一次给多个变量赋值,示例代码如下: assembly { function f() -> a, b {} let c, d := f() } 5.6.11汇编缺点 上面的内容介绍了汇编语言的一些基本语法,可以帮助操作者在智能合约中实现简单的内联汇编代码。不过,一定要谨记,内联汇编是一种以较低级别访问以太坊虚拟机的方法。它会绕过Solidity编译器的安全检查。只有在操作者对自身能力非常有信心且必需时才使用它。