文章来源《DAPP应用开发指南》
通过前面几章,我们对以太坊上智能合约开发有了一些宏观的了解,本章我们将开始探索智能合约Solidity开发语言基础特性,在本章我们将介绍Solidity的数据类型(包含常用整型、地址类型、数组、映射和结构体等),合约及错误处理。
6.1 Solidity数据类型
Solidity是一种静态类型语言,常见的静态类型语言有C、C++、Java等,静态类型意味着在编译时需要为每个变量(本地或状态变量)都指定类型。
Solidity数据类型看起来很简单,但却是最容易出现漏洞(如发生“溢出”等问题)。
还有一点需要关注,Solidity的数据类型非常在意所占空间的大小。
另外,Solidity的一些基本数据类型可以组合成复杂数据类型。
Solidity数据类型分为两类:
·值类型(Value Type)
·引用类型(Reference Type)
6.1.1 值类型
值类型变量用表示可以用32个字节来存储的数据,它们在赋值或传参时,总是进行值拷贝。
值类型包含:
·布尔类型(Booleans)
·整型(Integers)
·定长浮点型(Fixed Point Numbers)
·定长字节数组(Fixed-size byte arrays)
·有理数和整型常量(Rational and Integer Literals)
·字符串常量(String literals)
·十六进制常量(Hexadecimal literals)
·枚举(Enums)
·函数类型(Function Types)
·地址类型(Address)
·地址常量(Address Literals)
本章不打算讲解所有的类型,只重点介绍下常用的整型、地址类型和函数类型,至于其他的类型,可以参考笔者参与翻译的Solidity中文文档,英文好的人可以查看官方文档。
6.1.2 整型
整数类型用int/uint表示有符号和无符号的整数。
关键字的末尾接上一个数字表示数据类型所占用空间的大小,这个数字是8的倍数,最高为256。
因此,表示不同空间大小的整型有:uint8、uint16、uint32……uint256,int同理,无数字时uint和int对应uint256和int256。
因此整数的取值范围跟不同的空间大小有关,比如uint32类型的取值范围是0到232-1。
如果整数的某些操作,其结果不在取值范围内,则会被溢出截断。
这些截断可能会让开发者承担严重后果,稍后举例。
整型支持的运算符包括以下几种:
·比较运算符:<=(小于等于)、<(小于)、==(等于)、!=(不等于)、>=(大于等于)、>(大于)
·位操作符:&(和)、|(或)、^(异或)、~(位取反)
·算术操作符:+(加号)、-(减)、-(负号)、*(乘号)、/(除号)、%(取余数)、**(幂)
·移位:<<(左移位)、>>(右移位)这里略作说明:
(1)整数除法总是截断的,但如果运算符是字面量(字面量稍后讲),则不会截断。
(2)整数除0会抛出异常。
(3)移位运算结果的正负取决于操作符左边的数。
x << y和x *(2**y)是相等的,x >> y和x /(2*y)是相等的。
(4)不能进行负移位,即操作符右边的数不可以为负数,否则会在运行时抛出异常。
可以使用代码操练一下不同操作符的使用,运行之前,先自己预测一下结果,看是否和运行结果不一样。

整型溢出问题
在使用整型时,要特别注意整型的大小及所能容纳的最大值和最小值,如uint8的最大值为0xff(255),最小值是0,从solidity 0.6.0版本开始可以通过Type(T)
.min和Type(T)
.max获得整型的最小值与最大值。
下面这段合约代码用来演示整型溢出的情况,大家可以预测3个函数分别的结果是什么?
然后运行看看。

结果分析:
add1()的结果是0,而不是256,add2()的结果同样是0,sub1是255,而不是-1。
溢出就像我们的时钟一样,当秒针走到59之后,下一秒又从0开始。
业界名气颇大的BEC(Beauty Chain的代币符号),就曾经因发生溢出问题被交易所暂停交易,损失惨重。
防止整型溢出问题,一个方法是对加法运算的结果进行判断,防止出现异常值,例如:

以上函数使用require进行条件检查,当条件为false的时候,就是抛出异常,并还原交易的状态,关于require的使用方法在本书6.3节会作进一步介绍。
幸运的是,从Solidity 0.8开始,编译器集成了SafeMath功能,将自动对整型运算做溢出判断。
6.1.3 地址类型
Solidity中,使用地址类型来表示一个账号,地址类型有两种形式。
·address:保存一个20字节的值(以太坊地址的大小)。
·address payable:表示可支付地址,与address相同也是20字节,不过它有成员函数transfer(和send)。
这种区别背后的思想是address payable可以接受以太币的地址,而普通的address则不能,不过其实在使用的时候,大部分时间我们不需要关注address和address payable,一般使用address就好,如果遇到编译问题,需要address payable,可以使用以下方式进行转换:

提示: 上面的转换方法是在Solidity 0.6版本加入的,如果是Solidity 0.5版本,则使用address payable ap = address(uint160(addr)); 可以看出,address可以显式地和整型进行转换,除此之外,address还可以显式地跟bytes20(20个字节长度的数组)和合约类型进行相互转换。 |
当被转换的地址是一个合约地址时,需要合约实现接收(receive)函数或具有payable修饰的回退(fallback)函数(这是两个特殊定义的函数,在6.2节会详细介绍),才能显式地实现和“address payable”类型相互转换(转换仍然使用address(addr)执行),如果合约没有实现接收或回退函数,则需要进行两次转换,将payable(address(addr))转换为address payable类型。
地址类型支持的比较运算包括:<=、<、==、!=、>=以及>。
常用的还是判断两个地址是相等(==)还是不相等(!=)。
地址类型成员
地址类型和整型等基本类型不同,地址类型还有自己的成员属性及函数。
·<address>.balance(uint256)balance成员属性:
返回地址类型address的余额,余额以wei为单位。
·<address payable>.transfer(uint256 amount)transfer成员函数:
用来向地址发送amount数量wei的以太币,失败时抛出异常,消耗固定的2300 gas。
·<address payable>.send(uint256 amount)returns(bool)send成员函数:
向地址发送特定数量(以wei为单位,用参数amount指定)的以太币,失败时返回false,消耗固定的2 300 gas。
实际上addr.transfer(y)与require(addr.send(y))是等价的。
注意:
send(是transfer)的低级版本。
如果执行失败,当前的合约不会因为异常而终止,在使用send(的时候,如果不检查返回值,会有风险。大部分情况下应该用transfer)。
地址类型使用示例:

本书在4.2节中,介绍过外部账号和合约本质是一样的,每一个合约也是它自己的类型,如上代码中的testAddr就是一个合约类型,它也可以转化为地址类型,上面代码的address myAddress = address(this);
就是把合约转换为地址类型,然后用.balance获取余额。
这里有一个很多开发者忽略的知识点:
如果给一个合约地址转账,即上面代码x是合约地址时,合约的receive函数或fallback函数会随着transfer调用一起执行(这个是EVM特性),而send()和transfer()的执行只会使用2 300 gas,因此在接收者是一个合约地址的情况下,很容易出现receive函数或fallback函数把gas耗光而出现转账失败的情况。
| 为了避免gas不足导致转账失败的情况,可以使用下面介绍的底层函数call(),使用addr.call{value:1 ether}("")来进行转账,这句代码在功能上等价于addr.transfer(y),但call调用方式会用上当前交易所有可用的gas。 |
地址类型还有3个更底层的成员函数,通常用于与合约交互。
·<address>
.call(bytes memory)returns(bool, bytes memory)
·<address>.delegatecall(bytes memory)returns(bool, bytes memory)
·<address>.staticcall(bytes memory)returns(bool, bytes memory)这3个函数用直接控制的编码[给定有效载荷(payload)作为参数]与合约交互,返回成功状态及数据,默认发送所有可用gas。
它是向另一个合约发送原始数据,支持任何类型、任意数量的参数。
每个参数会按规则(接口定义ABI协议)打包成32字节并拼接到一起。
Solidity提供了全局的函数abi.encode、abi.encodePacked、abi.encodeWithSelector和abi.encodeWithSignature用于编码结构化数据。
例如,下面的代码是用底层方法call调用合约register方法。

注意:所有这些函数都是低级函数,应谨慎使用。
因为我们在调用一个合约的同时就将控制权交给了被调合约,当我们对一个未知的合约进行这样的调用时,这个合约可能是恶意的,并且被调合约又可以回调我们的合约,这可能发生重入攻击。
与其他合约交互的常规方法是在合约对象上调用函数(即x.f())。
底层函数还可以通过value选项附加发送ether(delegatecall不支持.value()),如上面用来避免转账失败的方法:
addr.call{value:1 ether}("")。
下面则表示调用函数register()时,同时存入1eth。

底层函数还可以通过gas选项控制的调用函数使用gas的数量。

它们还可以联合使用,出现的顺序不重要。

使用函数delegatecall()也是类似的方式,delegatecall()被称为“委托调用”,顾名思义,是把一个功能委托到另一个合约,它使用当前合约(发起调用的合约)的上下文环境(如存储状态,余额等),同时使用另一个合约的函数。
delegatecall()多用于调用库代码以及合约升级。
6.1.4 合约类型
合约类型用contract关键字定义,每一个contract定义都有它自己的类型,如下代码定义了一个Hello合约类型(类似其他语言的类)。

Hello类型有一个成员函数sayHi及接收函数,如果声明一个合约类型的变量(如Hello c),则可以用c.sayHi()调用该合约的函数。
合约可以显式转换为address类型,从而可以使用地址类型的成员函数。
在合约内部,可以使用this关键字表示当前的合约,可以通过address(this)转换为一个地址类型。
在合约内部,还可以通过成员函数selfdestruct()来销毁当前的合约,selfdestruct()函数说明为:

在合约销毁时,如果合约保存有以太币,所有的以太币会发送到参数recipient地址(这个操作不会调用本书后面6.2.9小节介绍的receive()函数),合约销毁后,合约的任何函数将不可调用。
合约类型信息
Solidity从0.6版本开始,对于合约C,可以通过type(C)来获得合约的类型信息,这些信息包含以下内容。
(1)type(C).name:获得合约的名字。
(2)type(C).creationCode:获得创建合约的字节码。
(3)type(C).runtimeCode:获得合约运行时的字节码。
如何区分合约地址及外部账号地址
我们经常需要区分一个地址是合约地址还是外部账号地址,区分的关键是看这个地址有没有与之相关联的代码。
EVM提供了一个操作码EXTCODESIZE,用来获取地址相关联的代码大小(长度),如果是外部账号地址,则没有代码返回。
因此我们可以使用以下方法判断合约地址及外部账号地址。

如果我们要限定一个方法只能由外部账号调用,则需要使用require(msg.sender== tx.origin, “Must EOA”);
来进行检查。
因为当合约创建时,还没有储存其代码,此时用isContract检查将失效。
如果是在合约外部判断,则可以使用web3.eth.getCode()(一个Web3.0的API),或者是对应的JSON-RPC方法——eth_getcode。
getCode()用来获取参数地址所对应合约的代码,如果参数是一个外部账号地址,则返回“0x”;
如果参数是合约,则返回对应的字节码,下面两行代码分别对应无代码和有代码的输出。

这时候,通过对比getCode()的输出内容,就可以很容易判断出是哪一种地址。
6.1.5 函数类型
Solidity中的函数也可以是一种类型,并且它属于值类型,可以将一个函数赋值给一个函数类型的变量,也可以将一个函数作为参数进行传递,还可以在函数调用中返回一个函数。

select()第一个参数就是函数类型,getfun()函数的返回值是函数类型,callTest()函数声明了一个函数类型的变量。
函数类型有两类:
内部(internal)函数和外部(external)函数。
本书将在6.2节作进一步介绍。函数类型的表示形式如下:

函数类型的成员
公有或外部(public /external)函数类型有以下成员属性和方法。
·.address:返回函数所在的合约地址。
·.selector:返回ABI函数选择器,函数选择器在本书7.4节作进一步介绍。
下面的例子展示的是如何使用成员。

6.1.6 引用类型
值类型的变量,赋值时总是进行完整独立的拷贝。
而一些复杂类型如数组和结构体,占用的空间通常超过256位(32个字节),拷贝时开销很大,这时就可以使用引用的方式,即通过多个不同名称的变量指向一个值。
目前,引用类型包括结构、数组和映射。
数据位置
引用类型都有一个额外属性来标识数据的存储位置,因此在使用引用类型时,必须明确指明数据存储于哪种类型的位置(空间)里,EVM中有3种位置。
·memory(内存):其生命周期只存在于函数调用期间,局部变量默认存储在内存,不能用于外部调用。
·storage(存储):状态变量保存的位置,只要合约存在就一直保存在区块链中。
·calldata(调用数据):用来存储函数参数的特殊数据位置,它是一个不可修改的、非持久的函数参数存储区域。
如果可以的话,应尽量使用calldata作为数据位置,因为它可以避免数据的复制(减小开销),并确保不能修改数据。
引用类型在进行赋值的时候,只有在更改数据位置或进行类型转换时会创建一份拷贝,而在同一数据位置内通常是增加一个引用,接下来我们对其具体分析。
(1)在存储和内存之间两两赋值(或者从调用数据赋值),都会创建一份独立的拷贝。
(2)从内存到内存的赋值只创建引用,这意味着更改内存变量时,其他引用相同数据的所有其他内存变量的值也会跟着改变。
(3)从存储到本地存储变量的赋值也只分配一个引用。
(4)其他的位置向存储赋值,总是进行拷贝。下面一段代码可以帮助理解数据位置。

不同的数据位置的gas消耗
·存储会永久保存合约状态变量,开销最大。
·内存仅保存临时变量,函数调用之后释放,开销很小。
·调用数据(calldata)保存很小的局部变量,几乎免费使用,但有数量限制。
6.1.7 数组
和大多数语言一样,在一个类型后面加上一个[],就构成一个数组类型,表示可以存储该类型的多个变量。
数组类型有两种:固定长度的数组和动态长度的数组。
一个元素类型为T,固定长度为k的数组,可以声明为T[k],一个动态长度的数组,可以声明为T[]。
例如:

数组声明可以进行初始化:

数组还可以用new关键字进行声明,创建基于运行时长度的内存数组,形式如下:

数组通过下标进行访问,序号是从0开始的。
例如,访问第1个元素时使用tens[0],对元素赋值,即tens[0] = 1。
Solidity也支持多维数组。
例如,声明一个类型为uint、长度为5的变长数组(5个元素都是变长数组),则可以声明为uint[][5]。
要访问第3个动态数组的第2个元素,使用x[2][1]即可。
访问第三个动态数组使用x[2],数组的序号是从0开始的,序号顺序与定义相反。
| 注意,定义多维组和很多语言里顺序不一样,如在Java中,声明一个包含5个元素、每个元素都是数组的方式为int[5][]。 |
bytes和string
还有两个特殊的数组类型:bytes和string。
它们的声明几乎是一样的,形式如下:

bytes是动态分配大小字节的数组,类似于byte[],但是bytes的gas费用更低,一般来讲,bytes和string都可以用来表达字符串,对任意长度的原始字节数据使用bytes,对任意长度字符串(UTF-8)数据使用string。
可以将字符串s通过bytes(s)转为一个bytes,通过bytes(s).length获取长度,bytes(s)[n]获取对应的UTF-8编码。
通过下标访问获取到的不是对应字符,而是UTF-8编码,比如中文的编码是变长的多字节,因此通过下标访问中文字符串得到的只是其中的一个编码。
注意:bytes和string不支持用下标索引进行访问。
如果使用一个长度有限制的字节数组,应该使用一个bytes1到bytes32的具体类型,因为它们占用空间更少,消耗的gas更少。
string扩展
Solidity语言本身提供的string功能比较弱,因此有人实现了string的实用工具库,这个库中提供了一些实用函数,如获取字符串长度、获得子字符串、大小写转换、字符串拼接等函数。
数组成员
数组类型可以通过成员属性获取数组状态以及可以通过成员函数来修改数组的状态,这些成员包括以下几种。
·length属性:表示当前数组的长度,这是一个只读属性,不能通过修改length属性来更改数组的大小。
·push():用来添加新的零初始化元素到数组末尾,并返回元素的引用,以便修改元素的内容,如:x.push().t = 2或x.push()= b,push方法只对存储(storage)中的数组及bytes类型有效(string类型不可用)。
·push(x):用来添加给定元素到数组末尾。push(x)没有返回值,方法只对存储(storage)中的数组及bytes类型有效(string类型不可用)。
·pop():用来从数组末尾删除元素,数组的长度减1,会在移除的元素上隐含调用delete,释放存储空间(及时释放不再使用的空间,可以节约gas)。
pop()没有返回值,pop()方法只对存储(storage)中的数组及bytes类型有效(string不可用)。
下面是一段使用数组的示例。



数组切片
数组切片是数组的一段连续的部分,用法是:x[start:end]。
start和end是uint256类型(或结果为uint256的表达式),x[start:end]的第一个元素是x[start],最后一个元素是x[end - 1]。
start和end都可以是可选的:start默认是0,而end默认是数组长度。
如果start比end大或者end比数组长度还大,将会抛出异常。数组切片在ABI解码数据的时候非常有用,示例代码如下。

6.1.8 映射
映射类型和Java的Map、Python的Dict在功能上差不多,它是一种键值对的映射关系存储结构,定义方式为mapping(KT => KV)。
如:
mapping(uint => string)idName;映射是一种使用广泛的类型,经常在合约中充当一个类似数据库的角色,比如在代币合约中用映射来存储账户的余额,在游戏合约里可以用映射来存储每个账号的级别,
如:

映射的访问和数组类似,可以用balances[userAddr]访问。
键类型有一些限制:
不可以是映射、变长数组、合约、枚举、结构体。值的类型没有任何限制,可以为任何类型,包括映射类型。下面是一段示例代码。

6.1.9 结构体
Solidity可以使用struct关键字来定义一个自定义类型,例如:

除可以使用基本类型作为成员以外,还可以使用数组、结构体、映射作为成员,如:

不能在声明结构体的同时将自身结构体作为成员,但是可以将它作为结构体中映射的值类型,如:

结构体声明与初始化
使用结构体声明变量及初始化有以下几个方式。
(1)仅声明变量而不初始化,此时会使用默认值创建结构体变量,例如:

(2)按成员顺序(结构体声明时的顺序)初始化,例如:

这种方式需要特别注意参数的类型及数量的匹配。
另外,如果结构体中有mapping,则需要跳过对mapping的初始化。
例如对6.1.9中的CustomType3的初始化方法为:

(3)具名方式初始化。
使用具名方式可以不按定义的顺序初始化,初始化方法如下:

参数的个数需要保持和定义时一致,如果有mapping类型,也同样需要忽略。
6.2 合约
Solidity中的合约和类非常类似,使用contract关键字来声明一个合约,一个合约通常由状态变量、函数、函数修改器以及事件组成。
我们前面的实例里已经使用过合约,这一节我们将更详细地介绍它。
6.2.1 可见性
跟其他很多语言一样,Solidity使用public private关键字来控制变量和函数是否可以被外部使用。
Solidity提供了4种可见性来修饰函数及状态变量,分别是:external(不修饰状态变量)、public、internal、private。
不同的可见性还会对函数调用方式产生影响,Solidity有两种函数调用:
·内部调用
·外部调用
外部调用是指在合约之外(通过其他的合约或者web3 api)调用合约函数,也称为消息调用或EVM调用,调用形式为:
c.f(),而内部调用可以理解为仅仅是一个代码调转,直接使用函数名调用,如f()。
我们来分析一下4种可见性。
·external——我们把external修饰的函数称为外部函数,外部函数是合约接口的一部分,所以我们可以从其他合约或通过交易来发起调用。
一个外部函数f()不能通过内部的方式来发起调用,即不可以使用f()发起调用,只能使用this.f()发起调用。
·public——我们把public修饰的函数称为公开函数,公开函数也是合约接口的一部分,它可以同时支持内部调用以及消息调用。
对于public类型的状态变量,Solidity还会自动创建一个访问器函数,这是一个与状态变量名字相同的函数,用来获取状态变量的值。
·internal——internal声明的函数和状态变量只能在当前合约中调用或者在继承的合约里访问,也就是说只能通过内部调用的方式访问。
·private——private函数和状态变量仅在当前定义它们的合约中使用,并且不能被派生合约使用。
注意:所有在合约内的内容,在链层面都是可见的,将某些函数或变量标记为private仅仅阻止了其他合约来进行访问和修改,但并不能阻止其他人看到相关的信息。
可见性标识符的定义位置,对于状态变量来说是在类型后面,对于函数是在参数列表和返回关键字中间,如:

6.2.2 构造函数
构造函数是使用constructor关键字声明的一个函数,它在创建合约时执行,用来运行合约初始化代码,如果没有初始化代码也可以省略(此时,编译器会添加一个默认的构造函数constructor()public {})。
对于状态变量的初始化,也可以在声明时进行指定,未指定时,默认为0。
构造函数可以是公有函数public,也可以是内部函数internal,当构造函数为internal时,表示此合约不可以部署,仅仅作为一个抽象合约,在本书第7章,我们会进一步介绍合约继承与抽象合约。
下面是一个构造函数的示例代码。

6.2.3 使用new创建合约
创建合约常见的方式是通过IDE(如Remix)及钱包向零地址发起一个创建合约交易,本书在第5章介绍过,如果我们需要用编程的方式创建合约,可以使用web3接口来创建(其实这也是IDE背后使用的方式),另外还可以在合约内通过new关键字来创建一个新合约,示例代码如下。

6.2.4 constant状态常量
状态变量可以被声明为constant。
编译器并不会为常量在storage上预留空间,而是在编译时使用对应的表达式值替换变量。

如果在编译期不能确定表达式的值,则无法给constant修饰的变量赋值,例如一些获取链上状态的表达式:
now、address(this).balance、block.number、msg.value、gasleft()等是不可以的。
不过对于内建函数,如keccak256、sha256、ripemd160、ecrecover、addmod和mulmod,是允许的,因为这些函数运算的结构在编译时就可以确定。
(这些函数会在本书7.5节进一步介绍)下面这句代码就是合法的:

constant目前仅支持修饰字符串及值类型。
6.2.5 immutable不可变量
immutable修饰的变量是在部署的时候确定变量的值,它在构造函数中赋值一次之后,就不再改变,这是一个运行时赋值,就可以解除之前constant不支持使用运行时状态赋值的限制。
immutable不可变量同样不会占用状态变量存储空间,在部署时,变量的值会被追加到运行时的字节码中,因此它比使用状态变量便宜得多,同样带来了更多的安全性(确保了这个值无法再修改)。
这个特性在很多时候非常有用,最常见的如ERC20代币(本书第8章会介绍ERC20代币的实现)用来指示小数位置的decimals变量,它应该是一个不能修改的变量,很多时候我们需要在创建合约的时候指定它的值,这时immutable就大有用武之地,类似的还有保存创建者地址、关联合约地址等。以下是immutable的声明举例。

6.2.6 视图函数
可以将函数声明为view,表示这个函数不会修改状态,这个函数在通过DApp外部调用时可以获得函数的返回值(对于会修改状态的函数,我们仅仅可以获得交易的哈希值)。
以下代码定义了一个名为f()的视图函数:

以下操作被认为是修改状态,在声明为view的函数中进行以下操作时,编译器会报错。
(1)修改状态变量。
(2)触发一个事件。
(3)创建其他合约。
(4)使用selfdestruct。
(5)通过调用发送以太币。
(6)调用任何没有标记为view或者pure的函数。
(7)使用低级调用。
(8)使用包含特定操作码的内联汇编。
6.2.7 纯函数
函数可以声明为pure,表示函数不读取也不修改状态。
除了上一节列举的状态修改语句之外,以下操作被认为是读取状态。
(1)读取状态变量。
(2)访问address(this).balance或者.balance。
(3)访问block、tx、msg中任意成员(除msg.sig和msg.data之外)。
(4)调用任何未标记为pure的函数。
(5)使用包含某些操作码的内联汇编。
示例代码:

6.2.8 访问器函数(getter)
对于public类型的状态变量,Solidity编译器会自动为合约创建一个访问器函数,这是一个与状态变量名字相同的函数,用来获取状态变量的值(不用再额外写函数获取变量的值)。
值类型
如果状态变量的类型是基本(值)类型,会生成一个同名的无参数的external的视图函数,如这个状态变量:

会生成函数:

数组
对于状态变量标记public的数组,会生成带参数的访问器函数,参数会访问数组的下标索引,即只能通过生成的访问器函数访问数组的单个元素。
如果是多维数组,会有多个参数。如:

会生成函数:

如果我们要返回整个数据,需要额外添加函数,如:

映射
对于状态变量标记为public的映射类型,其处理方式和数组一致,参数是键类型,返回值类型。

会生成函数:

来看一个稍微复杂一些的例子:

data变量会生成以下函数:

6.2.9 receive函数(接收函数)
合约的receive(接收)函数是一种特殊的函数,表示合约可以用来接收以太币的转账,一个合约最多有一个接收函数,接收函数的声明为:

函数名只有一个receive关键字,而不需要function关键字,也没有参数和返回值,并且必须具备外部可见性(external)和可支付(payable)。
在对合约没有任何附加数据调用(通常是对合约转账)时就会执行receive函数,例如通过addr.send()或者addr.transfer()调用时(addr为合约地址),就会执行合约的receive函数。
如果合约中没有定义receive函数,但是定义了payable修饰的fallback函数(见本书6.2.10小节),那么在进行以太转账时,fallback函数会被调用。
如果receive函数和fallback函数都没有,这个合约就没法通过转账交易接收以太币(转账交易会抛出异常)。
一个例外:
没有定义receive函数的合约,可以作为coinbase交易(矿工区块回报交易)的接收者或者作为selfdestruct(销毁合约)的目标来接收以太币。
下面是使用receive函数的例子。

6.2.10 fallback函数(回退函数)
和接收函数类似,fallback函数也是特殊的函数,中文一般称为“回退函数”,一个合约最多有一个fallback函数。
fallback函数的声明如下:

注意,在solidity 0.6里,回退函数是一个无名函数(没有函数名的函数),如果你看到一些老合约代码出现没有名字的函数,不用感到奇怪,它就是回退函数。
这个函数无参数,也无返回值,也没有function关键字,必须满足external可见性。
如果对合约函数进行调用,而合约并没有实现对应的函数,那么fallback函数会被调用。
或者是对合约转账,而合约又没有实现receive函数,那么此时标记为payable的fallback函数会被调用。
下面的这段代码可以帮助我们进一步理解receive函数与fallback函数。

需要注意的是,当在合约中使用send(和transfer)向合约转账时,仅仅会提供2300 gas来执行,如果receive或fallback函数的实现需要较多的运算量,会导致转账失败。
特别要说明的是,以下操作的消耗会大于2 300 gas。
(1)写存储变量;
(2)创建合约;
(3)执行外部函数调用,会花费比较多的gas;
(4)发送以太币。
6.2.11 函数修改器
函数修改器可以用来改变函数的行为,比如用于在函数执行前检查某种前置条件。
熟悉Python的读者会发现,函数修改器的作用和Python的装饰器很相似。
函数修改器使用关键字modifier,以下代码定义了onlyOwner函数修改器。
onlyOwner函数修改器定义了一个验证:
要求函数的调用者必须是合约的创建者,onlyOwner的实现中使用了require,可以参见本书6.3节。

上面使用函数修改器onlyOwner修饰了transferOwner(),这样的话,只有在满足创建者的情况下才能成功调用transferOwner()。
函数修改器一般带有特殊符号“_;”,修改器所修饰的函数体会被插入到“_;”的位置。
因此transferOwner扩展开就是:

修改器可继承
修改器也是一种可被合约继承的属性,同时还可被继承合约重写(Override)。
例如:

mortal合约从上面的owned继承了onlyOwner修饰符,并将其应用于close函数。
noReentrancy
onlyOwner是一个常用的修改器,以下代码用noReentrancy来防止重复调用,这也同样十分常见。

f()函数中,使用底层的call调用,而call调用的目标函数也可能反过来调用f()函数(可能发生不可知问题),通过给f()函数加入互斥量locked保护,可以阻止call调用再次调用f()。
注意:在f()函数中return 7语句返回之后,修改器中的语句locked = false仍会执行。
修改器带参数
修改器可以接收参数,例如:

以上marry()函数只有满足age >= 22才可以成功调用。
多个函数修改器
一个函数也可以被多个函数修改器修饰,这时我们就需要理解多个函数修改器的执行次序,另外,修改器或函数体中显式的return语句仅仅跳出当前的修改器和函数体,整个执行逻辑会在前一个修改器中定义的“_;”之后继续执行。
来看看下面的例子:

上面的智能合约在运行test1()之后,状态变量a的值是多少?
是1、11、12还是13呢?
答案是11,大家可以运行get_a获取a的值。
我们来分析一下test1,它扩展之后是这样的:

这个时候通过展开之后的代码看a的值就一目了然了,最后a为11。
6.2.12 函数重载(Function Overloading)
合约可以具有多个包含不同参数的同名函数,称为“重载”(overloading)。
以下示例展示了合约A中的重载函数f()。

需要注意的是,重载外部函数需要保证参数在ABI接口(见7.4节)层面是不同的,例如下面是一个错误示例:

以上两个f()函数重载时,一个使用合约类型,一个是地址类型,但是在对外的ABI表示时,都会被认为是地址类型,因此无法实现重载。
6.2.13 函数返回多个值
Solidity内置支持元组(tuple),它是一个由数量固定、类型可以不同的元素组成的一个列表。
使用元组可以用来返回多个值,也可以用于同时赋值给多个变量,示例如下。

6.2.14 事件
事件(Event)是合约与外部一个很重要的接口,当我们向合约发起一个交易时,这个交易是在链上异步执行的,无法立即知道执行的结果,通过在执行过程中触发某个事件,可以把执行的状态变化通知到外部(需要外部监听事件变化)。
事件是通过关键字event来声明的,event不需要实现,我们可以认为事件是一个用来被监听的接口。

如果使用Web3.js,则监听Deposit事件的方法如下:

我们会在本书第9章进一步介绍如何监听事件。
如果在事件中使用indexed修饰,表示对这个字段建立索引,这样就可以进行额外的过滤。
示例代码:

要想过滤出所有26岁的人,方法如下:

6.3 错误处理及异常
错误处理是指在程序发生错误时的处理方式。
Solidity处理错误和我们常见的语言(如Java、JavaScript等)有些不一样,Solidity是通过回退状态的方式来处理错误的,即如果合约在运行时发生异常,则会撤销当前交易所有调用(包含子调用)所改变的状态,同时给调用者返回一个错误标识。
为什么Solidity要这样处理错误呢?
我们可以把区块链理解为分布式事务性数据库。
如果想修改这个数据库中的内容,就必须创建一个事务。
事务意味着要做的修改(假如我们想同时修改两个值)只能被全部应用,只修改部分是不行的。
Solidity错误处理就是要保证每次调用都是事务性的。
6.3.1 错误处理函数
Solidity提供了两个函数assert()和require()来进行条件检查,并在条件不满足时抛出异常。
assert函数通常用来检查(测试)内部错误(发生了这样的错误,说明程序出现了一个bug),而require函数用来检查输入变量或合约状态变量是否满足条件,以及验证调用外部合约的返回值。
另外,如果我们正确使用assert函数,那么有一些Solidity分析工具可以帮我们分析出智能合约中的错误。
还有另外一个触发异常的方法:
使用revert函数,它可以用来标记错误并恢复当前的调用。
详细说明以下几个函数。
·assert(bool condition):如果不满足条件,会导致无效的操作码,撤销状态更改,主要用于检查内部错误。
·require(bool condition):如果条件不满足,则撤销状态更改,主要用于检查由输入或者外部组件引起的错误。
·require(bool condition, string memory message):如果条件不满足,则撤销状态更改,主要用于检查由输入或者外部组件引起的错误,可以同时提供一个错误消息。
·revert():终止运行并撤销状态更改。
·revert(string memory reason):终止运行并撤销状态更改,可以同时提供一个解释性的字符串。
其实我们在前面介绍函数修改器的时候已经使用过require,再通过一个示例代码来加深印象:

在EVM里,处理assert和require两种异常的方式是不一样的,虽然它们都会回退状态,不同点表现在:
(1)gas消耗不同。
assert类型的异常会消耗掉所有剩余的gas,而require不会消耗掉剩余的gas(剩余gas会返还给调用者)。
(2)操作符不同。
当发生assert类型的异常时,Solidity会执行一个无效操作(无效指令0xfe)。
当发生require类型的异常时,Solidity会执行一个回退操作(REVERT指令0xfd)。
由此,我们可以知道,下面这两行代码是等价的:if(msg.sender != owner){ revert(); }require(msg.sender == owner);
下列情况将会产生一个assert式异常。
·访问数组的索引太大或为负数(例如x[i]其中的i >= x.length或i < 0)。
·访问固定长度bytesN的索引太大或为负数。
·用零当除数做除法或模运算(例如5 / 0或23 % 0)。
·移位负数位。
·将一个太大或负数值转换为一个枚举类型。
·调用内部函数类型的零初始化变量。
·调用assert的参数(表达式)最终结算为false。
·下列情况将会产生一个require式异常。
·调用require的参数(表达式)最终结算为false。
·通过消息调用调用某个函数,但该函数没有正确结束(它耗尽了gas,没有匹配函数,或者本身抛出一个异常),上述函数不包括低级别的操作call、send、delegatecall、staticcall。
低级操作不会抛出异常,而通过返回false来指示失败。
·使用new关键字创建合约,但合约创建没有正确结束(请参阅上条有关”未正确结束“的解释)。
·执行外部函数调用的函数不包含任何代码。·合约通过一个没有payable修饰符的公有函数(包括构造函数和fallback函数)接收Ether。
·合约通过公有getter函数接收Ether。·.transfer()失败。
6.3.2 require还是assert?
以下是一些关于使用require还是assert的经验总结。
这些情况优先使用require():
(1)用于检查用户输入。
(2)用于检查合约调用返回值,如require(external.send(amount))。
(3)用于检查状态,如msg.send == owner。
(4)通常用于函数的开头。
(5)不知道使用哪一个的时候,就使用require。
这些情况优先使用assert():
(1)用于检查溢出错误,如z = x + y ; assert(z >= x);。
(2)用于检查不应该发生的异常情况。
(3)用于在状态改变之后,检查合约状态。
(4)尽量少使用assert。
(5)通常用于函数中间或结尾。
6.3.3 try/catch
Solidity 0.6版本之后,加入try/catch来捕获外部调用的异常,让我们在编写智能合约时,有更多的灵活性,例如try/catch结构在以下场景很有用。
·如果一个调用回滚(revert)了,我们不想终止交易的执行。
·我们想在同一个交易中重试调用、存储错误状态、对失败的调用做出处理等。
在Solidity 0.6之前,模拟try/catch仅有的方式是使用低级的调用,如call、delegatecall和staticcall,这是一个简单的示例,在Solidity 0.6之前实现某种try/catch。

当调用execute(uint256 amount),输入的参数amount会通过低级的call调用传给onlyEven(uint256)函数,call调用会返回布尔值作为第一个参数来指示调用的成功与否,而不会让整个交易失败。
不过低级的call调用会绕过一些安全检查,需要谨慎使用。
在最新的编译器中,可以这样写:

当调用execute(uint256 amount),输入的参数amount会通过低级的call调用传给onlyEven(uint256)函数,call调用会返回布尔值作为第一个参数来指示调用的成功与否,而不会让整个交易失败。
不过低级的call调用会绕过一些安全检查,需要谨慎使用。
在最新的编译器中,可以这样写:

注意,try/catch仅适用于外部调用,因此上面调用this.onlyEven(),另外try大括号内的代码块是不能被catch本身捕获的。

对外部调用进行try/catch时,允许获得外部调用的返回值,示例代码:

注意本地变量newValue和返回值只在try代码块内有效。
类似地,也可以在catch块内声明变量。
在catch语句中也可以使用返回值,外部调用失败时返回的数据将转换为bytes,catch中考虑了各种可能的revert原因,不过如果由于某种原因转码bytes失败,则try/catch也会失败,会回退整个交易。
catch语句中使用以下语法:

指定catch条件子句
Solidity的try/catch也可以包括特定的catch条件子句。
例如:

如果错误是由require(condition,“reason string”)或revert(“reason string”)引起的,则错误与catch Error(string memory revertReason)子句匹配,然后与之匹配的代码块被执行。在任何其他情况下(例如assert失败),都会执行更通用的catch(bytes memory returnData)子句。
注意:catch Error(string memory revertReason)不能捕获除上述两种情况以外的任何错误。
如果我们仅使用它(不使用其他子句),最终将丢失一些错误。通常需要将catch或catch(bytes memory returnData)与catch Error(string memory revertReason)一起使用,以确保我们涵盖了所有可能的revert原因。
在一些特定的情况下,如果catch Error(string memory revertReason)解码返回的字符串失败,catch(bytes memory returnData)(如果存在)将能够捕获它。
处理out-of-gas失败
首先要明确,如果交易没有足够的gas执行,则out of gas错误是不能捕获到的。
在某些情况下,我们可能需要为外部调用指定gas,因此即使交易中有足够的gas,如果外部调用的执行需要的gas比我们设置的多,内部out of gas错误可能会被低级的catch子句捕获。

当gas设置为20时,try调用的执行将用掉所有的gas,最后一个catch语句将捕获异常:
catch(bytes memory returnData)。
如果将gas设置为更大的量(例如2000),执行try块将会成功。

