Blockchain纪要
固定格式
solidity的注释由“//”开头,后面跟注释的内容(不会被程序运行)。
Solidity 语句以分号(;)结尾。
1 | // SPDX-License-Identifier: MIT //软件许可 |
第一行是表示声明软件许可(license),不写会报错waring。
第二行是表示solidity版本,意思是源文件将不允许小于 0.8.4 版本或大于等于 0.9.0 版本的编译器编译(第二个条件由^提供)。
第三行是表示创建合约(contract),并且声明合约的名字为:HelloWeb3。
第四行是声明字符串变量共有属性变量名为_string,赋值为”Hello Web3!”。
solidity纪要
创建合约
1 | // SPDX-License-Identifier: MIT //软件许可 |
变量
数值
| 类型 | 关键字 | 运算符 |
|---|---|---|
| 布尔类型 | bool | !、&&、||、==、!= |
| 整型 | int、uint、uint256 | <=,<,==,!=,>=,>,+,-,-(负),*,/,%(余),**(幂) |
| 地址类型 | address | 成员balance、transfer() |
| 字节数组 | byte、bytes8、bytes32 | |
| 枚举 | enum |
hints:在布尔值中&& 和 ||运算符遵循短路规则。
1 | // 布尔值 |
全局变量
blockhash(uint blockNumber): (bytes32)给定区块的哈希值 – 只适用于256最近区块, 不包含当前区块。block.coinbase: (address payable) 当前区块矿工的地址block.gaslimit: (uint) 当前区块的gaslimitblock.number: (uint) 当前区块的numberblock.timestamp: (uint) 当前区块的时间戳,为unix纪元以来的秒gasleft(): (uint256) 剩余 gasmsg.data: (bytes calldata) 完整call datamsg.sender: (address payable) 消息发送者 (当前 caller),部署者的地址msg.sig: (bytes4) calldata的前四个字节 (function identifier)msg.value: (uint) 当前交易发送的wei值
变量初始值
在solidity中,声明但没赋值的变量都有它的初始值或默认值。
数值类
boolean:falsestring:""int:0uint:0enum: 枚举中的第一个元素address:0x0000000000000000000000000000000000000000(或address(0))functioninternal: 空白方程external: 空白方程
引用类
- 映射
mapping: 所有元素都为其默认值的mapping - 结构体
struct: 所有成员设为其默认值的结构体 - 数组
array- 动态数组:
[] - 静态数组(定长): 所有成员设为其默认值的静态数组
- 动态数组:
delete操作
delete a会让变量a的值变为初始值。
1 | // delete操作符 |
常量constant
constant变量必须在声明的时候初始化,之后再也不能改变。尝试改变的话,编译不通过。
1 | // constant变量必须在声明的时候初始化,之后不能改变 |
不变量immutable
immutable变量可以在声明时或构造函数中初始化,因此更加灵活。
1 | // immutable变量可以在constructor里初始化,之后不能改变 |
使用全局变量例如address(this),block.number ,或者自定义的函数给immutable变量初始化。
1 | // 利用constructor初始化immutable变量,因此可以利用 |
数据位置
solidity数据存储位置有三类:storage,memory和calldata。不同存储位置的gas成本不同。storage类型的数据存在链上,类似计算机的硬盘,消耗gas多;memory和calldata类型的临时存在内存里,消耗gas少
storage:合约里的状态变量默认都是storage,存储在链上。memory:函数里的参数和临时变量一般用memory,存储在内存中,不上链。calldata:和memory类似,存储在内存中,不上链。与memory的不同点在于calldata变量不能修改(immutable),一般用于函数的参数。
赋值规则
storage(合约的状态变量)赋值给本地storage(函数里的)时候,会创建引用,改变新变量会影响原变量。storage赋值给memory,会创建独立的复本,修改其中一个不会影响另一个;反之亦然。memory赋值给memory,会创建引用,改变新变量会影响原变量。其他情况,变量赋值给
storage,会创建独立的复本,修改其中一个不会影响另一个。
数组array
- 固定长度数组:在声明时指定数组的长度。
1 | // 固定长度 Array |
- 可变长度数组(动态数组):在声明时不指定数组的长度。
1 | // 可变长度 Array |
- 对于
memory修饰的动态数组,可以用new操作符来创建,但是必须声明长度,并且声明后长度不能改变。
1 | // memory动态数组 |
- 如果创建的是动态数组,你需要一个一个元素的赋值。
数组操作
length: 数组有一个包含元素数量的length成员,memory数组的长度在创建后是固定的。push():动态数组和bytes拥有push()成员,可以在数组最后添加一个0元素。push(x):动态数组和bytes拥有push(x)成员,可以在数组最后添加一个x元素。pop():动态数组和bytes拥有pop()成员,可以移除数组最后一个元素。
结构体struct
Solidity支持通过构造结构体的形式定义新的类型。创建结构体的方法:
1 | // 结构体 |
结构体赋值
- 在函数中创建一个storage的struct引用
1 | // 给结构体赋值 |
- 直接引用状态变量的struct
1 | // 方法2:直接引用状态变量的struct |
映射Mapping
在映射中,人们可以通过键(Key)来查询对应的值(Value)
声明映射的格式为mapping(_KeyType => _ValueType),其中_KeyType和_ValueType分别是Key和Value的变量类型。
1 | mapping(uint => address) public idToAddress; // id映射到地址 |
映射规则
映射的
_KeyType只能选择solidity默认的类型,比如uint,address等,不能用自定义的结构体。而_ValueType可以使用自定义的类型。映射的存储位置必须是
storage,因此可以用于合约的状态变量,函数中的storage变量,和library函数的参数。不能用于public函数的参数或返回结果中,因为mapping记录的是一种关系 (key - value pair)。如果映射声明为
public,那么solidity会自动给你创建一个getter函数,可以通过Key来查询对应的Value。给映射新增的键值对的语法为
_Var[_Key] = _Value,其中_Var是映射变量名,_Key和_Value对应新增的键值对。
映射原理
- 映射不储存任何键(
Key)的资讯,也没有length的资讯。 - 映射使用
keccak256(key)当成offset存取value。 - 因为Ethereum会定义所有未使用的空间为0,所以未赋值(
Value)的键(Key)初始值都是0。
控制流
Solidity的控制流与其他语言类似,主要包含以下几种:
if-else
1 | function ifElseTest(uint256 _number) public pure returns(bool){ |
for循环
1 | function whileTest() public pure returns(uint256){ |
do-while循环
1 | function doWhileTest() public pure returns(uint256){ |
三元运算符
三元运算符是solidity中唯一一个接受三个操作数的运算符,规则条件? 条件为真的表达式:条件为假的表达式。 此运算符经常用作 if 语句的快捷方式。
1 | //条件? 条件为真的表达式:条件为假的表达式 |
还有continue(立即进入下一个循环)和break(跳出当前循环)关键字可以使用。
函数function
1 | //function 函数名称 (函数参数) 函数可见性 函数权限 返回变量类型和名称 |
函数返回值
return和returns,区别
returns加在函数名后面,用于声明返回的变量类型及变量名;return用于函数主体中,返回指定的变量。
1 | // 返回多个变量 |
构造函数constructor
构造函数(constructor)是一种特殊的函数,每个合约可以定义一个,并在部署合约的时候自动运行一次。
1 | address owner; // 定义owner变量 |
修饰器modifier
修饰器(modifier)是solidity特有的语法,类似于面向对象编程中的decorator,声明函数拥有的特性,并减少代码冗余。它就像钢铁侠的智能盔甲,穿上它的函数会带有某些特定的行为。modifier的主要使用场景是运行函数前的检查,例如地址,变量,余额等。
1 | // 定义modifier |
代有onlyOwner修饰符的函数只能被owner地址调用,比如下面这个例子:
1 | function changeOwner(address _newOwner) external onlyOwner{ |
我们定义了一个changeOwner函数,运行他可以改变合约的owner,但是由于onlyOwner修饰符的存在,只有原先的owner可以调用,别人调用就会报错。这也是最常用的控制智能合约权限的方法。
事件event
Solidity中的事件(event)是EVM上日志的抽象,它具有两个特点:
- 响应:应用程序(
ether.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关键字,每个indexed标记的变量可以理解为检索事件的索引“键”,在以太坊上单独作为一个topic进行存储和索引,程序可以轻松的筛选出特定转账地址和接收地址的转账事件。每个事件最多有3个带indexed的变量。每个 indexed 变量的大小为固定的256比特。事件的哈希以及这三个带indexed的变量在EVM日志中通常被存储为topic。其中topic[0]是此事件的keccak256哈希,topic[1]到topic[3]存储了带indexed变量的keccak256哈希。
value 不带 indexed 关键字,会存储在事件的 data 部分中,可以理解为事件的“值”。data 部分的变量不能被直接检索,但可以存储任意大小的数据。因此一般 data 部分可以用来存储复杂的数据结构,例如数组和字符串等等,因为这些数据超过了256比特,即使存储在事件的 topic 部分中,也是以哈希的方式存储。另外,data 部分的变量在存储上消耗的gas相比于 topic 更少。
我们可以在函数里释放事件。在下面的例子中,每次用_transfer()函数进行转账操作的时候,都会释放Transfer事件,并记录相应的变量。
1 | // 定义_transfer函数,执行转账逻辑 |
事件查询
在https://rinkeby.etherscan.io/中可以查看到交易记录和点击`Logs`按钮,就能看到事件明细:
Topics里面有三个元素,[0]是这个事件的哈希,[1]和[2]是我们定义的两个indexed变量的信息,即转账的转出地址和接收地址。Data里面是剩下的不带indexed的变量,也就是转账数量。
继承inheritance
承是面向对象编程很重要的组成部分,可以显著减少重复代码。如果把合约看作是对象的话,solidity也是面向对象的编程,也支持继承。
继承规则
virtual: 父合约中的函数,如果希望子合约重写,需要加上virtual关键字。override:子合约重写了父合约中的函数,需要加上override关键字。
简单继承
先写一个简单的爷爷合约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个函数:yeye()、baba()、hip()和pop(),其中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{ |
抽象abstract
如果一个智能合约里至少有一个未实现的函数,即某个函数缺少主体{}中的内容,则必须将该合约标为abstract,不然编译会报错;另外,未实现的函数需要加virtual,以便子合约重写。如果我们还没想好具体怎么实现函数,那么可以把合约标为abstract,之后让别人补写上。
1 | abstract contract InsertionSort{ |
接口interface
接口类似于抽象合约,但它不实现任何功能。
接口的规则
- 不能包含状态变量
- 不能包含构造函数
- 不能继承除接口外的其他合约
- 所有函数都必须是external且不能有函数体
- 继承接口的合约必须实现接口定义的所有功能
虽然接口不实现任何功能,但它非常重要。接口是智能合约的骨架,定义了合约的功能以及如何触发它们:如果智能合约实现了某种接口(比如ERC20或ERC721),其他Dapps和智能合约就知道如何与它交互。因为接口提供了两个重要的信息:
- 合约里每个函数的
bytes4选择器,以及基于它们的函数签名函数名(每个参数类型)。 - 接口id(更多信息见EIP165)
另外,接口与合约ABI(Application Binary Interface)等价,可以相互转换:编译接口可以得到合约的ABI,利用abi-to-sol工具也可以将ABI json文件转换为接口sol文件。
ERC721接口
我们以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 { |
异常
solidity三种抛出异常的方法:error,require和assert,并比较三种方法的gas消耗。
Error
error是solidity 0.8版本新加的内容,方便且高效(省gas)地向用户解释操作失败的原因。人们可以在contract之外定义异常。下面,我们定义一个TransferNotOwner异常,当用户不是代币owner的时候尝试转账,会抛出错误:
1 | error TransferNotOwner(); // 自定义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 { |

