区块链学习笔记Day7:Solidity语法
学区块链做的笔记Day7,大部分内容(99%)来自《精通以太坊》和WTF-Solidity。
接Day6的内容,主要学习Solidity的语法。
控制流
Solidity
的控制流与其他语言类似,主要包含以下几种:
if-else
1 | function ifElseTest(uint256 _number) public pure returns(bool){ |
for循环
1 | function forLoopTest() public pure returns(uint256){ |
while循环
1 | function whileTest() public pure returns(uint256){ |
do-while循环
1 | function doWhileTest() public pure returns(uint256){ |
三元运算符
元运算符是solidity
中唯一一个接受三个操作数的运算符,规则条件? 条件为真的表达式:条件为假的表达式
。 此运算符经常用作 if 语句的快捷方式。
1 | // 三元运算符 ternary/conditional operator |
另外还有continue
(立即进入下一个循环)和break
(跳出当前循环)关键字可以使用。
这里WTF-solidity的作者举了个排序的例子,想写好solidity还是要多练。
构造函数和修饰器
这一讲,我们将用合约权限控制(Ownable
)的例子介绍solidity
语言中构造函数(constructor
)和独有的修饰器(modifier
)。
构造函数
构造函数(constructor
)是一种特殊的函数,每个合约可以定义一个,并在部署合约的时候自动运行一次。它可以用来初始化合约的一些参数,例如初始化合约的owner
地址:
1 | address owner; // 定义owner变量 |
注意⚠️:构造函数在不同的solidity版本中的语法并不一致,在Solidity 0.4.22之前,构造函数不使用
constructor
而是使用与合约名同名的函数作为构造函数而使用,由于这种旧写法容易使开发者在书写时发生疏漏(例如合约名叫Parents
,构造函数名写成parents
),使得构造函数变成普通函数,引发漏洞,所以0.4.22版本及之后,采用了全新的constructor
写法。(精通以太坊中写的就是旧版本的)
修饰器
修饰器(modifier
)是solidity
特有的语法,类似于面向对象编程中的decorator
,声明函数拥有的特性,并减少代码冗余。modifier
的主要使用场景是运行函数前的检查,例如地址,变量,余额等。
我们来定义一个叫做onlyOwner
的modifier
:
1 | // 定义modifier |
带有onlyOwner
修饰符的函数只能被owner
地址调用,比如下面这个例子:
1 | function changeOwner(address _newOwner) external onlyOwner{ |
我们定义了一个changeOwner
函数,运行他可以改变合约的owner
,但是由于onlyOwner
修饰符的存在,只有原先的owner
可以调用,别人调用就会报错。这也是最常用的控制智能合约权限的方法。
OppenZepplin的Ownable标准实现:
OppenZepplin
是一个维护solidity
标准化代码库的组织,他的Ownable
标准实现如下: https://github.com/OpenZeppelin/openzeppelin-contracts/blob/master/contracts/access/Ownable.sol
事件
Solidity
中的事件(event
)是EVM
上日志的抽象,它具有两个特点:
- 响应:应用程序(
ethers.js
)可以通过RPC
接口订阅和监听这些事件,并在前端做响应。 - 经济:事件是
EVM
上比较经济的存储数据的方式,每个大概消耗2,000gas
;相比之下,链上存储一个新变量至少需要20,000gas
。
声明事件
事件的声明由event
关键字开头,接着是事件名称,括号里面写好事件需要记录的变量类型和变量名。以ERC20
代币合约的Transfer
事件为例:
1 | event Transfer(address indexed from, address indexed to, uint256 value); |
我们可以看到,Transfer
事件共记录了3个变量from
,to
和value
,分别对应代币的转账地址,接收地址和转账数量,其中from
和to
前面带有indexed
关键字,他们会保存在以太坊虚拟机日志的topics
中,方便之后检索。
释放事件
我们可以在函数里释放事件。在下面的例子中,每次用_transfer()
函数进行转账操作的时候,都会释放Transfer
事件,并记录相应的变量。
1 | // 定义_transfer函数,执行转账逻辑 |
EVM日志
以太坊虚拟机(EVM)用日志Log
来存储Solidity
事件,每条日志记录都包含主题topics
和数据data
两部分。
主题Topics
日志的第一部分是主题数组,用于描述事件,长度不能超过4
。它的第一个元素是事件的签名(哈希)。对于上面的Transfer
事件,它的签名就是:
1 | keccak256("Transfer(addrses,address,uint256)") |
除了事件签名,主题还可以包含至多3
个indexed
参数,也就是Transfer
事件中的from
和to
。
indexed
标记的参数可以理解为检索事件的索引“键”,方便之后搜索。每个 indexed
参数的大小为固定的256比特,如果参数太大了(比如字符串),就会自动计算哈希存储在主题中。
数据 Data
事件中不带 indexed
的参数会被存储在 data
部分中,可以理解为事件的“值”。data
部分的变量不能被直接检索,但可以存储任意大小的数据。因此一般 data
部分可以用来存储复杂的数据结构,例如数组和字符串等等,因为这些数据超过了256比特,即使存储在事件的 topic
部分中,也是以哈希的方式存储。另外,data
部分的变量在存储上消耗的gas相比于 topic
更少。
继承
solidity
中的继承(inheritance
),包括:
- 简单继承
- 多重继承
- 修饰器(
modifier
)和构造函数(constructor
)的继承
继承是面向对象编程很重要的组成部分,可以显著减少重复代码。如果把合约看作是对象的话,solidity
也是面向对象的编程,也支持继承。
继承的规则
virtual
: 父合约中的函数,如果希望子合约重写,需要加上virtual
关键字。override
:子合约重写了父合约中的函数,需要加上override
关键字。
注意:用override
修饰public
变量,会重写与变量同名的getter
函数(没看懂喵),例如:
1 | mapping(address => uint256) public override balanceOf; |
简单继承
我们先写一个简单的爷爷合约Yeye
,里面包含1个Log
事件和3个function
: hip()
, pop()
, yeye()
,输出都是“Yeye”。
1 | contract Yeye { |
我们再定义一个爸爸合约Baba
,让他继承Yeye
合约,语法就是contract Baba is Yeye
,非常直观。在Baba
合约里,我们重写一下hip()
和pop()
这两个函数,加上override
关键字,并将他们的输出改为“Baba”
;并且加一个新的函数baba
,输出也是“Baba”
。
1 | contract Baba is Yeye{ |
我们部署合约,可以看到Baba
合约里有4个函数,其中hip()
和pop()
的输出被成功改写成”Baba”
,而继承来的yeye()
的输出仍然是”Yeye”
。
多重继承
solidity
的合约可以继承多个合约。规则:
- 继承时要按辈分最高到最低的顺序排。比如我们写一个
Erzi
合约,继承Yeye
合约和Baba
合约,那么就要写成contract Erzi is Yeye, Baba
,而不能写成contract Erzi is Baba, Yeye
,不然就会报错。 - 如果某一个函数在多个继承的合约里都存在,比如例子中的
hip()
和pop()
,在子合约里必须重写,不然会报错。 - 重写在多个父合约中都重名的函数时,
override
关键字后面要加上所有父合约名字,例如override(Yeye, Baba)
。
例子:
1 | contract Erzi is Yeye, Baba{ |
我们可以看到,Erzi
合约里面重写了hip()
和pop()
两个函数,将输出改为”Erzi”
,并且还分别从Yeye
和Baba
合约继承了yeye()
和baba()
两个函数。
修饰器的继承
Solidity
中的修饰器(Modifier
)同样可以继承,用法与函数继承类似,在相应的地方加virtual
和override
关键字即可。
1 | contract Base1 { |
Identifier
合约可以直接在代码中使用父合约中的exactDividedBy2And3
修饰器,也可以利用override
关键字重写修饰器:
1 | modifier exactDividedBy2And3(uint _a) override { |
构造函数的继承
子合约有两种方法继承父合约的构造函数。举个简单的例子,父合约A
里面有一个状态变量a
,并由构造函数的参数来确定:
1 | // 构造函数的继承 |
- 在继承时声明父构造函数的参数,例如:
contract B is A(1)
- 在子合约的构造函数中声明构造函数的参数,例如:
1 | contract C is A { |
调用父合约的函数
子合约有两种方式调用父合约的函数,直接调用和利用super
关键字。
- 直接调用:子合约可以直接用
父合约名.函数名()
的方式来调用父合约函数,例如Yeye.pop()
。
1 | function callParent() public{ |
super
关键字:子合约可以利用super.函数名()
来调用最近的父合约函数。solidity
继承关系按声明时从右到左的顺序是:contract Erzi is Yeye, Baba
,那么Baba
是最近的父合约,super.pop()
将调用Baba.pop()
而不是Yeye.pop()
:
1 | function callParentSuper() public{ |
钻石继承
在面向对象编程中,钻石继承(菱形继承)指一个派生类同时有两个或两个以上的基类。
在多重+菱形继承链条上使用super
关键字时,需要注意的是使用super
会调用继承链条上的每一个合约的相关函数,而不是只调用最近的父合约。
我们先写一个合约God
,再写Adam
和Eve
两个合约继承God
合约,最后让创建合约people
继承自Adam
和Eve
,每个合约都有foo
和bar
两个函数。
1 | // SPDX-License-Identifier: MIT |
在这个例子中,调用合约people
中的super.bar()
会依次调用Eve
、Adam
,最后是God
合约。
虽然Eve
、Adam
都是God
的子合约,但整个过程中God
合约只会被调用一次。原因是Solidity借鉴了Python的方式,强制一个由基类构成的DAG(有向无环图)使其保证一个特定的顺序。更多细节你可以查阅Solidity的官方文档。
这里的意思大概是Adam的foo里没有super再往上调用,只有Eve一人调了,但是bar里两个人都调了God的,所以bar里就会有God的输出,但是foo里没有(因为要两个人一起调才行)(根据结果瞎猜的)。
还有这里好像不能用ganache来部署合约(?好像会报错,也有可能是我的ganache的设置的问题。
抽象合约和接口
这一讲,我们用ERC721
的接口合约为例介绍solidity
中的抽象合约(abstract
)和接口(interface
),帮助大家更好的理解ERC721
标准。
抽象合约
如果一个智能合约里至少有一个未实现的函数,即某个函数缺少主体{}
中的内容,则必须将该合约标为abstract
,不然编译会报错;另外,未实现的函数需要加virtual
,以便子合约重写。拿我们之前的插入排序合约为例,如果我们还没想好具体怎么实现插入排序函数,那么可以把合约标为abstract
,之后让别人补写上。
1 | abstract contract InsertionSort{ |
大概类似python中函数没写完,我先写个pass放这里,免得程序出错了,也好调试。
接口
接口类似于抽象合约,但它不实现任何功能。接口的规则:
- 不能包含状态变量
- 不能包含构造函数
- 不能继承除接口外的其他合约
- 所有函数都必须是external且不能有函数体
- 继承接口的合约必须实现接口定义的所有功能
虽然接口不实现任何功能,但它非常重要。接口是智能合约的骨架,定义了合约的功能以及如何触发它们:如果智能合约实现了某种接口(比如ERC20
或ERC721
),其他Dapps和智能合约就知道如何与它交互。因为接口提供了两个重要的信息:
- 合约里每个函数的
bytes4
选择器,以及函数签名函数名(每个参数类型)
。 - 接口id(更多信息见EIP165)
另外,接口与合约ABI
(Application Binary Interface)等价,可以相互转换:编译接口可以得到合约的ABI
,利用abi-to-sol工具也可以将ABI json
文件转换为接口sol
文件。
这么说来接口大概也能看做对合约的描述(?
我们以ERC721
接口合约IERC721
为例,它定义了3个event
和9个function
,所有ERC721
标准的NFT都实现了这些函数。我们可以看到,接口和常规合约的区别在于每个函数都以;
代替函数体{ }
结尾。
1 | interface IERC721 is IERC165 { |
IERC721事件
IERC721
包含3个事件,其中Transfer
和Approval
事件在ERC20
中也有。
Transfer
事件:在转账时被释放,记录代币的发出地址from
,接收地址to
和tokenid
。Approval
事件:在授权时释放,记录授权地址owner
,被授权地址approved
和tokenid
。ApprovalForAll
事件:在批量授权时释放,记录批量授权的发出地址owner
,被授权地址operator
和授权与否的approved
。
IERC721函数
balanceOf
:返回某地址的NFT持有量balance
。ownerOf
:返回某tokenId
的主人owner
。transferFrom
:普通转账,参数为转出地址from
,接收地址to
和tokenId
。safeTransferFrom
:安全转账(如果接收方是合约地址,会要求实现ERC721Receiver
接口)。参数为转出地址from
,接收地址to
和tokenId
。approve
:授权另一个地址使用你的NFT。参数为被授权地址approve
和tokenId
。getApproved
:查询tokenId
被批准给了哪个地址。setApprovalForAll
:将自己持有的该系列NFT批量授权给某个地址operator
。isApprovedForAll
:查询某地址的NFT是否批量授权给了另一个operator
地址。safeTransferFrom
:安全转账的重载函数,参数里面包含了data
。
什么时候使用接口?
如果我们知道一个合约实现了IERC721
接口,我们不需要知道它具体代码实现,就可以与它交互。
无聊猿BAYC
属于ERC721
代币,实现了IERC721
接口的功能。我们不需要知道它的源代码,只需知道它的合约地址,用IERC721
接口就可以与它交互,比如用balanceOf()
来查询某个地址的BAYC
余额,用safeTransferFrom()
来转账BAYC
。
1 | contract interactBAYC { |
异常
写智能合约经常会出bug
,solidity
中的异常命令帮助我们debug
。
solidity
有三种抛出异常的方法:
error
require
assert
Error
error
是solidity 0.8.4版本
新加的内容,方便且高效(省gas
)地向用户解释操作失败的原因,同时还可以在抛出异常的同时携带参数,帮助开发者更好地调试。人们可以在contract
之外定义异常。下面,我们定义一个TransferNotOwner
异常,当用户不是代币owner
的时候尝试转账,会抛出错误:
1 | error TransferNotOwner(); // 自定义error |
我们也可以定义一个携带参数的异常,来提示尝试转账的账户地址
1 | error TransferNotOwner(address sender); // 自定义的带参数的error |
在执行当中,error
必须搭配revert
(回退)命令使用。
1 | function transferOwner1(uint256 tokenId, address newOwner) public { |
我们定义了一个transferOwner1()
函数,它会检查代币的owner
是不是发起人,如果不是,就会抛出TransferNotOwner
异常;如果是的话,就会转账。
Require
require
命令是solidity 0.8版本
之前抛出异常的常用方法,目前很多主流合约仍然还在使用它。它很好用,唯一的缺点就是gas
随着描述异常的字符串长度增加,比error
命令要高。使用方法:require(检查条件,"异常的描述")
,当检查条件不成立的时候,就会抛出异常。
我们用require
命令重写一下上面的transferOwner
函数:
1 | function transferOwner2(uint256 tokenId, address newOwner) public { |
Assert
assert
命令一般用于程序员写程序debug
,因为它不能解释抛出异常的原因(比require
少个字符串)。它的用法很简单,assert(检查条件)
,当检查条件不成立的时候,就会抛出异常。
我们用assert
命令重写一下上面的transferOwner
函数:
1 | function transferOwner3(uint256 tokenId, address newOwner) public { |
三种方法的gas比较
我们比较一下三种抛出异常的gas
消耗,通过remix控制台的Debug按钮,能查到每次函数调用的gas
消耗分别如下: (使用0.8.17版本编译)
error
方法gas
消耗:24457 (加入参数后gas
消耗:24660)require
方法gas
消耗:24755assert
方法gas
消耗:24473
我们可以看到,error
方法gas
最少,其次是assert
,require
方法消耗gas
最多!因此,error
既可以告知用户抛出异常的原因,又能省gas
,大家要多用!(注意,由于部署测试时间的不同,每个函数的gas
消耗会有所不同,但是比较结果会是一致的。)
备注: Solidity 0.8.0之前的版本,assert
抛出的是一个 panic exception
,会把剩余的 gas
全部消耗,不会返还。更多细节见官方文档。