大数跨境

第7章 Solidity进阶

第7章 Solidity进阶 数组智控产业发展科技院
2023-05-21
2
导读:文章来源《DAPP应用开发指南》前面第6章介绍了Solidity一些最常用的用法,本章将介绍一些进阶的用法,

文章来源《DAPP应用开发指南》

前面第6章介绍了Solidity一些最常用的用法,本章将介绍一些进阶的用法,如合约继承、接口、库的使用,另外还会介绍一些平时开发不怎么使用的ABI及Solidity内联汇编,了解这些知识可以更好地帮助我们理解合约的运行以及阅读他人的代码。

7.1 合约继承

继承是大多数高级语言都具有的特性,Solidity同样支持继承,Solidity继承使用的是关键字is(类似于Java等语言的extends或implements),如contract B is A表示合约B继承合约A,称A为父合约,B为子合约或派生合约。

当一个合约从多个合约继承时,在区块链上只有一个合约被创建,所有基类合约的代码被编译到创建的合约中,但是注意,这并不会连带部署基类合约。

因此当我们使用super.f()来调用基类的方法时,不是进行消息调用,而仅仅是代码跳转。

举个例子来说明继承的用法,示例代码如下。

我们在本书6.2.1小节也曾介绍过,派生合约可以访问基类合约内的所有非私有(private)成员,因此内部(internal)函数和状态变量在派生合约里是可以直接使用的,比如上面一段代码中的状态变量owner。

状态变量不能在派生的合约中覆盖。

例如,上面一段代码中的派生合约Mortal不可以再次声明基类合约中可见的状态变量owner。

7.1.1 多重继承

Solidity也支持多重继承,即可以从多个基类合约继承,直接在is后面接多个基类合约即可,例如:

注意:如果多个基类合约之间也有继承关系,那么is后面的合约的书写顺序就很重要,顺序应该是,基类合约在前,派生合约在后,否则,正如下面的代码,将无法编译。

7.1.2 基类构造函数

派生合约继承基类合约时,如果实现了构造函数,基类合约的代码会被编译器拷贝到派生合约的构造函数中,先看看最简单的情况,也就是构造函数没有参数的情况,用下面一段代码验证。

在部署B时候,可以查看到a为1,b为2。

基类合约构造函数如果有参数,会复杂一些,有两种方式对构造函数传参。

1直接在继承列表中指定参数

示例代码如下。

即通过contract B is A(1)的方式对构造函数传参进行初始化。

2.在派生合约的构造函数中使用修饰符方式调用基类合约

示例代码如下。

或者是:

不过这样就需要在部署B的时候,传入参数。

7.1.3 抽象合约

如果一个合约有构造函数,且是内部(internal)函数,或者合约包含没有实现的函数,这个合约将被标记为抽象合约,使用关键字abstract,抽象合约无法成功部署,它们通常是用作基类合约。

示例代码如下。

抽象合约可以声明一个纯虚函数,纯虚函数没有具体实现代码的函数,其函数声明用“;”

结尾,而不是用"{ }"结尾,例如:

如果合约继承自抽象合约,并且没有通过重写(overriding)来实现所有未实现的函数,那么它本身就是抽象的,隐含了一个抽象合约的设计思路,即要求任何继承都必须实施其方法。

7.1.4 函数重写(overriding)

父合约中的虚函数(函数使用了virtual修饰)可以在子合约重写该函数,以更改它们在父合约中的行为。

重写的函数需要使用关键字override修饰。示例代码如下。

对于多重继承,如果有多个父合约有相同定义的函数,override关键字后必须指定所有的父合约名。

示例代码如下。

如果函数没有标记为virtual(本书7.2节介绍的接口除外,因为接口里面所有的函数会自动标记为virtual),那么派生合约是不能重写来更改函数行为的。

另外private的函数是不可以标记为virtual的。

如果getter函数的参数和返回值都和外部函数一致,外部(external)函数是可以被public的状态变量重写的,示例代码如下。

但是public的状态变量不能被重写。

7.2 接口

接口和抽象合约类似,与之不同的是,接口不实现任何函数,同时还有以下限制:

(1)无法继承其他合约或接口。

(2)无法定义构造函数。

(3)无法定义变量。

(4)无法定义结构体。

(5)无法定义枚举。

接口由关键字interface来表示,示例代码如下。

就像继承其他合约一样,合约可以继承接口,接口中的函数都会隐式地标记为virtual,意味着它们会被重写。

合约间利用接口通信

除了接口的抽象功能外,接口广泛使用于合约之间的通信,即一个合约调用另一个合约的接口。

例如,有一个SimpleToken合约实现了上一节的IToken接口:

另外一个奖例合约(假设合约名为Award)则通过给SimpleToken合约给用户发送奖金,奖金就是SimpleToken合约表示的代币,这时Award就需要与SimpleToken通信(外部函数调用),代码可以这样写:

sendBonus函数用来发送奖金,通过接口函数调用SimpleToken实现转账。

7.3 库

在开发合约的时候,总是会有一些函数经常被多个合约调用,这个时候可以把这些函数封装为一个库,库使用关键字library来定义。

例如,下面的代码定义了一个SafeMath库。

SafeMath库里面实现了一个加法函数add(),它可以在多个合约中复用,例如下面的AddTest合约就是使用SafeMath的add()函数来实现加法。

当然我们可以在库里封账更多的函数,库是一个很好的代码复用手段。

同时要注意,库仅仅由函数构成,它没有自己的状态(后面会进一步解释)。

库在使用中,根据场景的不用,一种是嵌入引用的合约里部署(可以称为“内嵌库”),一种是单独部署(可以称为“链接库”)。

7.3.1 内嵌库

如果合约引用的库函数都是内部函数(见本书6.2.1小节的internal介绍),那么编译器在编译合约的时候,会把库函数的代码嵌入合约里,就像合约自己实现了这些函数,这时的库并不会单独部署,上面AddTest合约引用SafeMath库就属于这个情况。

7.3.2 链接库

如果库代码内有公共或外部函数(见本书6.2.1小节的public及external介绍),库就会被单独部署,在以太坊链上有自己的地址,此时合约引用库是通过地址这个“链接”进行(在部署合约的时候,需要进行链接),大家应该还有印象,在本书第6章介绍地址类型时,有一个低级函数委托调用delegatecall(),合约在调用库函数时,就是采用委托调用的方式(这是底层的处理方式,在编写代码时并不需要改动)。

前面提到,库没有自己的状态,因为在委托调用的方式下库合约函数是在发起合约(下文称“主调合约”,即发起调用的合约)的上下文中执行的,因此库合约函数中使用的变量(如果有的话)都来自主调合约的变量,库合约函数使用的this也是主调合约的地址。

我们也可以从另一个角度来理解,库是单独部署,而又会被多个合约引用(这也是库最主要的功能:

避免在多个合约里重复部署,以节约gas),如果库拥有自己的状态,那它一定会被多个调用合约修改状态,将无法保证调用库函数输出结果的确定性。

现在我们把前面的SafeMath库的add函数修改为外部函数,示例代码如下。

AddTest代码不用作任何的更改,因为SafeMath库合约是独立部署的,AddTest合约要调用SafeMath库就必须先知道后者的地址,这相当于AddTest合约会依赖于SafeMath库,因此部署AddTest合约会有一点点不同,多了一个AddTest合约与SafeMath库建立链接的步骤。

先来回顾一下合约的部署过程:

第一步是由编译器生成合约的字节码,第二步把字节码作为交易的附加数据提交交易。

编译器在编译引用了SafeMath库的AddTest时,编译出来的字节码会留一个空,部署AddTest时,需要用SafeMath库地址把这个空给填上(这就是链接过程)。

感兴趣的读者可以用命令行编译器solc操作一下,使用命令:


solc --optimize--bin AddTest.sol可以生成AddTest合约的字节码,其中有一段用双下划线留出的空,类似这样:


_ _SafeMath_ _,这个空就需要用SafeMath库地址替换。

上面介绍的库的部署、链接的过程,通常不需要手动编辑,开发者有更简单的选择,也就是用Truffle(在本书第9章会作进一步介绍)来进行部署,这时仅需要下面3行部署语句:

如果不理解,可以在阅读完第9章之后,再回头看这3行部署语句。

7.3.3 Using for

在上一节中,我们是通过SafeMath.add(x, y)这种方式来调用库函数,还有一个方式是使用using LibA for B,它表示把所有LibA的库函数关联到类型B。

这样就可以在B类型直接调用库的函数,描述有一点抽象,请看代码示例。

使用using SafeMath for uint;后,就可以直接在uint类型的x上调用x.add(y),代码明显更加简洁了。

using LibA for *则表示LibA中的函数可以关联到任意的类型上。

使用using...for...看上去就像扩展了类型的能力。

比如,我们可以给数组添加一个indexOf函数,查看一个元素在数组中的位置,示例代码如下。

这段代码中indexOf的第一个参数存储变量self,实际上对应着合约C的data变量。

7.4 应用程序二进制接口(ABI)

在以太坊(Ethereum)生态系统中,应用程序二进制接口(Application Binary Interface,ABI)是从区块链外部与合约进行交互,以及合约与合约之间进行交互的一种标准方式。

7.4.1 ABI编码

在本书第4章,我们介绍以太坊交易和比特币交易的不同时,以太坊交易多了一个DATA字段,DATA的内容会解析为对函数的消息调用,DATA的内容其实就是ABI编码。

以下面这个简单的合约为例来理解一下。

[插图]按照本书第5章的方法,把合约部署到以太坊测试网络Ropsten上,并调用count(),然后查看实际调用附带的输入数据,在区块链浏览器etherscan上交易的信息在该地址:

https://ropsten.etherscan.io/tx/0xafcf79373cb38081743fe5f0ba745c6846c6b08f375 fda028556b4e52330088b,如图7-1所示。

图7-1 调用信息截图

可以看到,交易通过携带附加数据0x06661abd来表示调用函数count(),0x06661abd被称为“函数选择器”(Function Selector)。

7.4.2 函数选择器

在调用函数时,用前面4字节的函数选择器指定要调用的函数,函数选择器是某个函数签名(下文介绍)的Keccak(SHA-3)哈希的前4字节,即:

count()的Keccak的哈希结果是:

06661abdecfcab6f8e8cf2e41182a05dfd130c76cb32b448d9306aa9791f3899,开发者可以用一个在线哈希的工具[插图]验证下,取出前面4个字节就是0x06661abd。

函数签名是包含函数名及参数类型的字符串,比如上文中的count()就是函数签名,当函数有参数时,使用参数的基本类型,并且不需要变量名,因此函数add(uint i)的签名是add(uint256),如果有多个参数,使用“,”隔开,并且要去掉表达式中的所有空格。

因此,foo(uint a, bool b)函数的签名是foo(uint256,bool),函数选择器计算则是:

公有或外部(public /external)函数都有成员属性.selector来获取函数的函数选择器。

7.4.3 参数编码

如果函数带有参数,编码的第5字节开始是函数的参数。在前面的Counter合约里添加一个带参数的方法:

重新部署之后,使用16作为参数调用add函数,调用方法如图7-2所示。

图7-2 Remix调用Add函数

在etherscan上参看交易附加的输入数据,查询地址为:

https://ropsten.etherscan.io/tx/0x5f2a2c6d94aff3461c1e8251ebc5204619acfef66e53955dd2cb81fcc57e12b6,该截图如图7-3所示。

图7-3 函数调用的ABI编码

输入数据为:

0x1003e2d20000000000000000000000000000000000000000000000000000000000000010。

其中,前4个字节0x1003e2d2为add函数的函数选择器,后面的32个字节是参数16的二进制表示,会补充到32字节长度

不同的类型,其参数编码方式会有所不同,详细的编码方式可以参考ABI编码规范:

https://learnblockchain.cn/docs/solidity/abi-spec.html。

通常,开发人员并不需要进行ABI编码调用函数,只需要提供ABI的接口描述JSON文件,编码由web3或ether.js库来完成。

7.4.4 ABI接口描述

ABI接口描述是由编译器编译代码之后,生成的一个对合约所有接口和事件描述的JSON文件。描述函数的JSON包含以下字段。

·type:可取值有function、constructor、fallback,默认为function。

·name:函数名称。

·inputs:一系列对象,每个对象包含以下属性。

〇name:参数名称。

〇type:参数的规范类型。

〇components:当type是元组(tuple)时,components列出元组中每个元素的名称(name)和类型(type)。

·outputs:一系列类似inputs的对象,无返回值时,可以省略。

·payable:true表示函数可以接收以太币,否则表示不能接收,默认值为false。

·stateMutability:函数的可变性状态,可取值有:pure、view、nonpayable、payable。

·constant:如果函数被指定为pure或view,则为true。

事件描述的JSON包含以下字段。

·type:总是“event”。

·name:事件名称。

·inputs:对象数组,每个数组对象会包含以下属性。

〇name:参数名称。

〇type:参数的权威类型。

〇components:供元组(tuple)类型使用。

·indexed:如果此字段是日志的一个主题,则为true,否则为false。

·anonymous:如果事件被声明为anonymous,则为true。

在Remix的编译器页面,编译输出的ABI接口描述文件,查看一下Counter合约的接口描述,只需要在如图7-4所示红框处单击“ABI”,ABI描述就会复制到剪切板上。

图7-4 获取ABI信息

下面是ABI描述代码示例。

JSON数组中包含了3个函数描述,描述合约所有接口方法,在合约外部(如DAPP)调用合约方法时,就需要利用这个描述来获得合约的方法,本书第9章会进一步介绍ABI JSON的应用。

7.5 Solidity全局API

其实我们在前面的章节里已经介绍过一些Solidity全局API的使用,比如获取一个地址的余额:

<addr>.balance,向一个地址转账:

<addr>.transfer()以及错误处理相关的require()、asset()、revert()等。

Solidity的全局API相当于很多语言的核心库或标准库,它们是语言层面的API,即语言自带实现的一些函数或者属性,在编写智能合约时可以直接调用它们。

除了在其他章节介绍的,Solidity全局API还有以下属性和方法,按照功能分成了3小节,大家可以把这3个小节当作API文档的索引目录。

7.5.1 区块和交易属性API

·blockhash(uint blockNumber)returns(bytes32):

获得指定区块的区块哈希,参数blockNumber仅支持传入最新的256个区块,且不包括当前区块(备注:returns后面表示的是函数返回的类型,下同)。

·block.coinbase(address):获得挖出当前区块的矿工地址(备注:()内表示获取属性的类型,下同)。

·block.difficulty(uint):获得当前区块难度。

·block.gaslimit(uint):获得当前区块最大gas限值。

·block.number(uint):获得当前区块号。

·block.timestamp(uint):获得当前区块以秒为单位的时间戳。

·gasleft()returns(uint256):获得当前执行还剩余多少gas。

·msg.data(bytes):获取当前调用完整的calldata参数数据。

·msg.sender(address):当前调用的消息发送者。

·msg.sig(bytes4):当前调用函数的标识符。

·msg.value(uint):当前调用发送的以太币数量(以wei为单位)。

·tx.gasprice(uint):获得当前交易的gas价格。

·tx.origin(address payable):获得交易的起始发起者,如果交易只有当前一个调用,那么tx.origin会和msg.sender相等,如果交易中触发了多个子调用,msg.sender会是每个发起子调用的合约地址,而tx.origin依旧是发起交易的签名者。

7.5.2 ABI编码及解码函数API

·abi.decode(bytes memory encodedData,(…))returns(…):对给定的数据进行ABI解码,而数据的类型在括号中第二个参数给出。

例如,(uint a,uint[2] memory b,bytes memory c)= abi.decode(data,(uint, uint[2],bytes))是从data数据中解码出3个变量a、b、c。

·abi.encode(…)returns(bytes):对给定参数进行ABI编码,即上一个方法的方向操作。

·abi.encodePacked(…)returns(bytes):对给定参数执行ABI编码,和上一个函数编码时会把参数填充到32个字节长度不同,encodePacked编码的参数数据会紧密地拼在一起。

·abi.encodeWithSelector(bytes4 selector, …)returns(bytes):从第二个参数开始进行ABI编码,并在前面加上给定的函数选择器(参数)一起返回。

·abi.encodeWithSignature(string signature, …)returns(bytes)等价于abi.encodeWith Selector(bytes4(keccak256(signature), …)。

ABI编码函数主要是用于构造函数调用数据(而不实际调用),另外有时我们需要一些数据进行密码学哈希计算(如接下来7.5.3小节中的哈希函数),这些哈希计算通常需要bytes类型的数据,这时我们就可以使用上面的ABI编码函数把需要哈希的数据类型转化为bytes类型。

7.5.3 数学和密码学函数API

·addmod(uint x, uint y, uint k)returns(uint):计算(x + y)% k,即先求和再求模。

求和可以在任意精度下执行,即求和的结果可以超过uint的最大值(2的256次方)。

求模运算会对k != 0作校验。

·mulmod(uint x, uint y, uint k)returns(uint):计算(x * y)% k,即先作乘法再求模,乘法可在任意精度下执行,即乘法的结果可以超过uint的最大值。

求模运算会对k != 0作校验。

·keccak256((bytes memory)returns(bytes32):用Keccak-256算法计算哈希。

·sha256(bytes memory)returns(bytes32):计算参数的SHA-256哈希。

·ripemd160(bytes memory)returns(bytes20):计算参数的RIPEMD-160哈希。

·ecrecover(bytes32 hash, uint8 v, bytes32 r, bytes32 s)returns(address):利用椭圆曲线签名恢复与公钥相关的地址(即通过签名数据获得地址),错误返回零值。

函数参数对应于ECDSA签名的值:

〇r =签名的前32字节

〇s =签名的第2个32字节

〇v =签名的最后一个字节

7.6 使用内联汇编

本节的内容在智能合约开发中使用较少,读者也可以选择跳过,本节亦是抛砖引玉,内联汇编语言Yul仍然在不断地进化,对这部分内容感兴趣的读者最好是阅读官方的第一手资料。

7.6.1 汇编基础概念

实际上很多高级语言(例如C、Go或Java)编写的程序,在执行之前都将先编译为“汇编语言”。

汇编语言与CPU或虚拟机绑定实现指令集,通过指令来告诉CPU或虚拟机执行一些基本任务。

Solidity语言可以理解为是以太坊虚拟机EVM指令集的抽象,让我们编写智能合约更容易。

而汇编语言则是Solidity语言和EVM指令集的一个中间形态,Solidity也支持直接使用内联汇编,下面是在Solidity代码中使用汇编代码的例子。

在Solidity中使用汇编代码有这样一些好处。
1进行细粒度控制       
可以在汇编代码中使用汇编操作码直接与EVM进行交互,从而对智能合约执行的操作实现更精细的控制。
汇编提供了更多的控制权来执行某些仅靠Solidity不可能实现的逻辑,例如控制指向特定的内存插槽。在编写库代码时,细粒度控制特别有用,例如这两个库的实现:
String Utils(链接:https://github.com/Arachnid/solidity-stringutils/blob/master/src/strings.sol)
和Bytes Utils(链接:https://github.com/GNSPS/solidity-bytes-utils/blob/master/contracts/BytesLib.sol)。
2.更少的Gas消耗
我们通过一个简单的加法运算对比两个版本的gas消耗,一个版本是仅使用Solidity代码,一个版本是仅使用内联Assembly。

gas的消耗如图7-5所示。

图7-5 gas消耗对比图

图7-5(续)
从图7-5可以看到,使用内联汇编可以节省86的gas。
对于这个简单的加法操作来说,减少的gas并不多,但可以帮助我们明白直接使用内联汇编将消耗更少的gas,更复杂的逻辑能更显著地节省gas。
7.6.2 Solidity中引入汇编
前面已经有个实例,可以在Solidity中使用assembly{}来嵌入汇编代码段,这被称为内联汇编。

在assembly块内的代码开发语言被称为Yul。
Solidity可以引入多个汇编代码块,不过汇编代码块之间不能通信,也就是说在一个汇编代码块里定义的变量,在另一个汇编代码块中不可以访问。
因此这段代码的b无法获取到a的值:

再来一个使用内联汇编代码完成加法的例子,我们重写addSolidity函数:

对上面这段代码作一个简单的说明:
①创建一个新的变量result,通过add操作码计算x+y,并将计算结果赋值给变量result;
②使用mstore操作码将result变量的值存入地址0x0的内存位置;
③表示从内存地址0x返回32字节。
7.6.3 汇编变量定义与赋值
在Yul语言中,使用let关键字定义变量。使用:=操作符给变量赋值。

Solidity只需要用=,因此不要忘了“:”。如果没有使用给变量赋值,那么变量会被初始化为0。

也可以用表达式给变量赋值,例如:

7.6.4 汇编中的块和作用域
在Yul汇编语言中,用一对大括号来表示一个代码块,变量的作用域是当前的代码块,即变量在当前的代码块中有效。

在上面的示例代码中,y和z都是仅在所在块内有效,因此z获取不到y的值。
不过在函数和循环中,作用域规则有些不一样,在接下来的循环及函数部分会介绍。
7.6.5 汇编中访问变量

7.6.6 for循环
Yul同样支持for循环,这段示例代码表示对value+2计算n次:

for循环的条件部分包含3个元素:
·初始化条件:let i := 0。
·判断条件:lt(i, n),这是函数式风格,表示i小于n。
·迭代后续步骤:add(i, 1)。
可以看出,for循环中变量的作用范围和前面介绍的作用域略有不同。
在初始化部分定义的变量在循环条件的其他部分都有效。
在for循环的其他部分声明的变量依旧遵守7.6.4节介绍的作用域规则。
此外,汇编语言中没有while循环。
7.6.7 if判断语句
汇编支持使用if语句来设置代码执行的条件,但是没有else分支,同时每个条件对应的执行代码都需要用大括号包起来。

7.6.8 汇编Switch语句
EVM汇编中也有switch语句,它将表达式的值与多个常量进行对比,并选择相应的代码分支来执行。
switch语句支持默认分支default,当表达式的值不匹配任何其他分支条件时,将执行默认分支的代码。

switch语句的分支条件类型相同但值不同,同时分支条件涵盖所有可能的值,那么不允许再出现default条件。
要注意的是,Solidity语言中是没有switch语句的。
7.6.9 汇编函数
可以在内联汇编中定义自定义底层函数,调用这些自定义的函数和使用内置的操作码一样。
下面的汇编函数用来分配指定长度(length)的内存,并返回内存指针pos。

上面的代码:
①定义了alloc函数,函数使用->指定返回值变量,不需要显式return返回语句;
②使用了定义的函数。
定义的函数不需要指定汇编函数的可见性,因为它们仅在定义所在的汇编代码块内有效。
7.6.10 元组
汇编函数可以返回多个值,它们被称为一个元组(tuple),可以通过元组一次给多个变量赋值,如:

7.6.11 汇编缺点
上面我们介绍了汇编语言的一些基本语法,可以帮助我们在智能合约中实现简单的内联汇编代码。
不过,一定要谨记,内联汇编是一种以较低级别访问以太坊虚拟机的方法。
它会绕过例如Solidity编译器的安全检查。只有在我们对自身能力非常有信心且必需时才使用它。

【声明】内容源于网络
0
0
数组智控产业发展科技院
以AI技术为底层能力,聚焦智慧园区、城市公共安全、数智警务、健康医疗、能源电力、科研实验及平安校园等领域,提供从感知到决策的全流程软硬件一体化的国产装备智能体产品解决方案。
内容 986
粉丝 0
数组智控产业发展科技院 以AI技术为底层能力,聚焦智慧园区、城市公共安全、数智警务、健康医疗、能源电力、科研实验及平安校园等领域,提供从感知到决策的全流程软硬件一体化的国产装备智能体产品解决方案。
总阅读2.3k
粉丝0
内容986