Solidity8.0-入门
Solidity8.0-入门
学习资料
1 | https://wtf.academy/solidity-start/HelloWeb3 |
Solidity简述
Solidity是以太坊虚拟机(EVM)智能合约的语言。同时solidity是玩链上项目必备的技能:区块链项目大部分是开源的,如果你能读懂代码,就可以规避很多亏钱项目。
Solidity具有两个特点:
- 基于对象:学会之后,能帮你挣钱找对象。
- 高级:不会solidity,在币圈显得很low。
进入remix,我们可以看到最左边的菜单有三个按钮,分别对应文件(写代码的地方),编译(跑代码),部署(部署到链上)。
我们点新建(Create New File)按钮,就可以创建一个空白的solidity合约。
hello world
基础语法
1 | //注释使用// |
编译和发布
使用在线网站对源代码编辑和修改之后可以编译和部署
1 | https://remix.ethereum.org/ |
在上一步操作过程中编译成功之后,下面开始部署,每一份智能合约都会经历编译部署环节。
当最后的按钮是蓝色的表示是一个只读的方法,点击按钮即可得到变量的值。
变量的作用域
数据位置
solidity数据存储位置有三类:storage
,memory
和calldata
。不同存储位置的gas
成本不同。storage
类型的数据存在链上,类似计算机的硬盘,消耗gas
多;memory
和calldata
类型的临时存在内存里,消耗gas
少。大致用法:
storage
:合约里的状态变量默认都是storage
,存储在链上。memory
:函数里的参数和临时变量一般用memory
,存储在内存中,不上链。calldata
:和memory
类似,存储在内存中,不上链。与memory
的不同点在于calldata
变量不能修改(immutable
),一般用于函数的参数。例子:
1 | function fCalldata(uint[] calldata _x) public pure returns(uint[] calldata){ |
数据位置和赋值规则
在不同存储类型相互赋值时候,有时会产生独立的副本(修改新变量不会影响原变量),有时会产生引用(修改新变量会影响原变量)。规则如下:
storage
(合约的状态变量)赋值给本地storage
(函数里的)时候,会创建引用,改变新变量会影响原变量。例子:
1 | uint[] x = [1,2,3]; // 状态变量:数组 x |
storage
赋值给memory
,会创建独立的复本,修改其中一个不会影响另一个;反之亦然。例子:
1 | uint[] x = [1,2,3]; // 状态变量:数组 x |
memory
赋值给memory
,会创建引用,改变新变量会影响原变量。其他情况,变量赋值给
storage
,会创建独立的复本,修改其中一个不会影响另一个。
变量
Solidity
中变量按作用域划分有三种,分别是状态变量(state variable),局部变量(local variable)和全局变量(global variable)
状态变量
状态变量是数据存储在链上的变量,所有合约内函数都可以访问 ,gas消耗高,状态变量在合约内、函数外声明:
1 | // SPDX-License-Identifier: GPL-3.0 |
只要不写修改的方法则会永远保存在链上。(类似C全局变量)
局部变量
局部变量是仅在函数执行过程中有效的变量,函数退出后,变量无效。局部变量的数据存储在内存里,不上链,gas低。局部变量在函数内声明:
1 | // SPDX-License-Identifier: GPL-3.0 |
上述代码部署之后可以看到一个函数和部分值的初始值
点击黄色的函数名称按钮代表写入方法,会改变链的状态
可以看到载入方法之后,状态变量的值发生变化,局部变量不会有变化而且不会产生数据
全局变量
全局变量是全局范围工作的变量,都是solidity预留关键字。不用定义就能显示内容的变量。他们可以在函数内不声明直接使用:
1 | // SPDX-License-Identifier: GPL-3.0 |
保存部署之后点击按钮即可查看全局变量的值
在上面例子里,我们使用了3个常用的全局变量:msg.sender
, block.number
和msg.data
,他们分别代表请求发起地址,当前区块高度,和请求数据。下面是一些常用的全局变量,更完整的列表请看这个链接:
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
:false
string
:""
int
:0
uint
:0
enum
: 枚举中的第一个元素address
:0x0000000000000000000000000000000000000000
(或address(0)
)```
function1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
- `internal`: 空白方程
- `external`: 空白方程
可以用`public`变量的`getter`函数验证上面写的初始值是否正确:
```solidity
bool public _bool; // false
string public _string; // ""
int public _int; // 0
uint public _uint; // 0
address public _address; // 0x0000000000000000000000000000000000000000
enum ActionSet { Buy, Hold, Sell}
ActionSet public _enum; // 第一个元素 0
function fi() internal{} // internal空白方程
function fe() external{} // external空白方程
引用类型初始值
- 映射
mapping
: 所有元素都为其默认值的mapping
- 结构体
struct
: 所有成员设为其默认值的结构体 - 数组
array
- 动态数组:
[]
- 静态数组(定长): 所有成员设为其默认值的静态数组
- 动态数组:
可以用public
变量的getter
函数验证上面写的初始值是否正确:
1 | // Reference Types |
delete
操作符
delete a
会让变量a
的值变为初始值。
1 | // delete操作符 |
变量被声明但没有赋值的时候,它的值默认为初始值。不同类型的变量初始值不同,delete
操作符可以删除一个变量的值并代替为初始值。
常数
solidity
中两个关键字,constant
(常量)和immutable
(不变量)。状态变量声明这个两个关键字之后,不能在合约后更改数值;并且还可以节省gas
。另外,只有数值变量可以声明constant
和immutable
;string
和bytes
可以声明为constant
,但不能为immutable
。让不应该变的变量保持不变。这样的做法能在节省gas
的同时提升合约的安全性。
常量(constant)
constant
变量必须在声明的时候初始化,之后再也不能改变。尝试改变的话,编译不通过。
1 | // constant变量必须在声明的时候初始化,之后不能改变 |
不变量(immutable)
immutable
变量可以在声明时或构造函数中初始化,因此更加灵活。
1 | // immutable变量可以在constructor里初始化,之后不能改变 |
你可以使用全局变量例如address(this)
,block.number
,或者自定义的函数给immutable
变量初始化。在下面这个例子,我们利用了test()
函数给IMMUTABLE_TEST
初始化为9
:
1 | // 利用constructor初始化immutable变量,因此可以利用 |
小结
要求实现计数器的智能合约,对状态变量加或者减操作
1 | // SPDX-License-Identifier: GPL-3.0 |
编译部署之后可以看到两个橙色按钮,点击即可实现对count数值变化
数据类型
Solidity中的变量类型
数值类型(Value Type):包括布尔型,整数型等等,这类变量赋值时候直接传递数值。
引用类型(Reference Type):包括数组和结构体,这类变量占空间大,赋值时候直接传递地址(类似指针)。
映射类型(Mapping Type): Solidity里的哈希表。
函数类型(Function Type):Solidity文档里把函数归到数值类型,但我觉得他跟其他类型差别很大,所以单独分一类。
数值类型
布尔型(bool)
布尔型是二值变量,取值为true或false。
1 | // 布尔值 |
布尔值的运算符,包括:
- ! (逻辑非)
- && (逻辑与, “and” )
- || (逻辑或, “or” )
- == (等于)
- != (不等于)
1 | // 布尔运算 |
上面的代码中:变量_bool
的取值是true
;_bool1
是_bool
的非,为false
;_bool && _bool1
为false
;_bool || _bool1
为true
;_bool == _bool1
为false
;_bool != _bool1
为true
。
值得注意的是:&&
和 ||
运算符遵循短路规则,这意味着,假如存在f(x) || g(y)
的表达式,如果f(x)
是true
,g(y)
不会被计算,即使它和f(x)
的结果是相反的
整型(int)
整型是solidity中的整数,最常用的包括
1 | // 整型 |
常用的整型运算符包括:
- 比较运算符(返回布尔值):
<=
,<
,==
,!=
,>=
,>
- 算数运算符:
+
,-
, 一元运算-
,+
,*
,/
,%
(取余),**
(幂)
1 | // 整数运算 |
地址类型(address)
地址类型(address)存储一个 20 字节的值(以太坊地址的大小)。地址类型也有成员变量,并作为所有合约的基础。有普通的地址和可以转账ETH的地址(payable)。payable的地址拥有balance和transfer()两个成员,方便查询ETH余额以及转账。
1 | // 地址 |
定长字节数组(bytes)
字节数组bytes分两种,一种定长(byte, bytes8, bytes32),另一种不定长。定长的属于数值类型,不定长的是引用类型(之后讲)。 定长bytes可以存一些数据,消耗gas比较少。
1 | // 固定长度的字节数组 |
MiniSolidity变量以字节的方式存储进变量_byte32,转换成16进制为:0x4d696e69536f6c69646974790000000000000000000000000000000000000000
枚举 (enum)
枚举(enum)是solidity中用户定义的数据类型。它主要用于为uint分配名称,使程序易于阅读和维护。它与C语言中的enum类似,使用名称来代替从0开始的uint:
1 | // 用enum将uint 0, 1, 2表示为Buy, Hold, Sell |
它可以显式的和uint
相互转换,并会检查转换的正整数是否在枚举的长度内,不然会报错:
1 | // enum可以和uint显式的转换 |
enum的一个比较冷门的变量,几乎没什么人用。
1 | // SPDX-License-Identifier: GPL-3.0 |
引用类型
**引用类型(Reference Type)**:包括数组(array
),结构体(struct
)和映射(mapping
),这类变量占空间大,赋值时候直接传递地址(类似指针)。由于这类变量比较复杂,占用存储空间大,我们在使用时必须要声明数据存储的位置。
数组 array
数组(Array
)是solidity
常用的一种变量类型,用来存储一组数据(整数,字节,地址等等)。数组分为固定长度数组和可变长度数组两种:
- 固定长度数组:在声明时指定数组的长度。用
T[k]
的格式声明,其中T
是元素的类型,k
是长度,例如:
1 | // 固定长度 Array |
- 可变长度数组(动态数组):在声明时不指定数组的长度。用
T[]
的格式声明,其中T
是元素的类型,例如(bytes
比较特殊,是数组,但是不用加[]
):
1 | // 可变长度 Array |
创建数组的规则
在solidity里,创建数组有一些规则:
- 对于
memory
修饰的动态数组
,可以用new
操作符来创建,但是必须声明长度,并且声明后长度不能改变。例子:
1 | // memory动态数组 |
- 数组字面常数(Array Literals)是写作表达式形式的数组,用方括号包着来初始化array的一种方式,并且里面每一个元素的type是以第一个元素为准的,例如
[1,2,3]
里面所有的元素都是uint8类型,因为在solidity中如果一个值没有指定type的话,默认就是最小单位的该type,这里int的默认最小单位类型就是uint8。而[uint(1),2,3]
里面的元素都是uint类型,因为第一个元素指定了是uint类型了,我们都以第一个元素为准。下面的合约中,对于f函数里面的调用,如果我们没有显式对第一个元素进行uint强转的话,是会报错的,因为如上所述我们其实是传入了uint8类型的array,可是g函数需要的却是uint类型的array,就会报错了。
1 | // SPDX-License-Identifier: GPL-3.0 |
- 如果创建的是动态数组,你需要一个一个元素的赋值。
1 | uint[] memory x = new uint[](3); |
数组成员
length
: 数组有一个包含元素数量的length
成员,memory
数组的长度在创建后是固定的。push()
:动态数组
和bytes
拥有push()
成员,可以在数组最后添加一个0
元素。push(x)
:动态数组
和bytes
拥有push(x)
成员,可以在数组最后添加一个x
元素。pop()
:动态数组
和bytes
拥有pop()
成员,可以移除数组最后一个元素。
结构体 struct
olidity
支持通过构造结构体的形式定义新的类型。创建结构体的方法:
1 | // 结构体 |
1 | Student student; // 初始一个student结构体 |
给结构体赋值的两种方法:
- 方法1:在函数中创建一个storage的struct引用
1 | // 给结构体赋值 |
- 方法2:直接引用状态变量的struct
1 | // 方法2:直接引用状态变量的struct |
映射类型
映射Mapping
在映射中,人们可以通过键(Key
)来查询对应的值(Value
),比如:通过一个人的id
来查询他的钱包地址。
声明映射的格式为mapping(_KeyType => _ValueType)
,其中_KeyType
和_ValueType
分别是Key
和Value
的变量类型。例子:
1 | mapping(uint => address) public idToAddress; // id映射到地址 |
映射的规则
- 规则1:映射的
_KeyType
只能选择solidity
默认的类型,比如uint
,address
等,不能用自定义的结构体。而_ValueType
可以使用自定义的类型。下面这个例子会报错,因为_KeyType
使用了我们自定义的结构体:
1 | // 我们定义一个结构体 Struct |
- 规则2:映射的存储位置必须是
storage
,因此可以用于合约的状态变量,函数中的storage
变量,和library函数的参数(见例子)。不能用于public
函数的参数或返回结果中,因为mapping
记录的是一种关系 (key - value pair)。 - 规则3:如果映射声明为
public
,那么solidity
会自动给你创建一个getter
函数,可以通过Key
来查询对应的Value
。 - 规则4:给映射新增的键值对的语法为
_Var[_Key] = _Value
,其中_Var
是映射变量名,_Key
和_Value
对应新增的键值对。例子:
1 | function writeMap (uint _Key, address _Value) public{ |
映射的原理
- 原理1: 映射不储存任何键(
Key
)的资讯,也没有length的资讯。 - 原理2: 映射使用
keccak256(key)
当成offset存取value。 - 原理3: 因为Ethereum会定义所有未使用的空间为0,所以未赋值(
Value
)的键(Key
)初始值都是0。
函数类型
solidity官方文档里把函数归到数值类型。我们先看一下solidity中函数的形式:
1 | function <function name> (<parameter types>) {internal|external|public|private} [pure|view|payable] [returns (<return types>)] |
从前往后一个一个看(方括号中的是可写可不写的关键字):
function
:声明函数时的固定用法,想写函数,就要以function关键字开头。<function name>
:函数名。(<parameter types>)
:圆括号里写函数的参数,也就是要输入到函数的变量类型和名字。{internal|external|public|private}
:函数可见性说明符,一共4种。没标明函数类型的,默认internal
。public
: 内部外部均可见。(也可用于修饰状态变量,public变量会自动生成getter
函数,用于查询数值).private
: 只能从本合约内部访问,继承的合约也不能用(也可用于修饰状态变量)。external
: 只能从合约外部访问(但是可以用this.f()
来调用,f
是函数名)internal
: 只能从合约内部访问,继承的合约可以用(也可用于修饰状态变量)。
[pure|view|payable]
:决定函数权限/功能的关键字。payable
(可支付的)很好理解,带着它的函数,运行的时候可以给合约转入ETH
。pure
和view
的介绍需要区分。[returns ()]
:函数返回的变量类型和名称。
Pure和View
合约的状态变量存储在链上,gas fee很贵,如果不改变链上状态,就不用付gas。包含pure跟view关键字的函数是不改写链上状态的,因此用户直接调用他们是不需要付gas的(合约中非pure/view函数调用它们则会改写链上状态,需要付gas)。
在以太坊中,以下语句被视为修改链上状态:
- 写入状态变量。
- 释放事件。
- 创建其他合同。
- 使用selfdestruct.
- 通过调用发送以太币。
- 调用任何未标记
view
或pure
的函数。 - 使用低级调用(low-level calls)。
- 使用包含某些操作码的内联汇编。
三种不同的关键字的区别。
pure
,中文意思是“纯”,在solidity
里理解为“纯纯牛马”。包含pure
关键字的函数,不能读取也不能写入存储在链上的状态变量。view
,“看”,在solidity
里理解为“看客”。包含view
关键字的函数,能读取但也不能写入状态变量。- 不写
pure
也不写view
,函数既可以读取也可以写入状态变量。
代码实现
- 智能合约跟大部分语言一样,有局部变量在合约中称为状态变量
- 存在返回值并需要指定类型
1 | // SPDX-License-Identifier: GPL-3.0 |
上述代码被部署之后会在部署结果的下方生成两个按钮,点击即可调用传递参数,并且根据函数中的分割符进行参数传递
view函数
只要读取了链上的东西就需要用到view
1 | // SPDX-License-Identifier: GPL-3.0 |
pure函数
只有局部变量或者什么都没有
1 | // SPDX-License-Identifier: GPL-3.0 |
函数输出
Solidity函数输出,包括:返回多种变量,命名式返回,以及利用解构式赋值读取全部和部分返回值。
返回值 return和returns
Solidity有两个关键字与函数输出相关:return和returns,他们的区别在于:
- returns加在函数名后面,用于声明返回的变量类型及变量名;
- return用于函数主体中,返回指定的变量。
1 | // 返回多个变量 |
上面这段代码中,我们声明了returnMultiple()
函数将有多个输出:returns(uint256, bool, uint256[3] memory)
,接着我们在函数主体中用return(1, true, [uint256(1),2,5])
确定了返回值。
命名式返回
可以在returns中标明返回变量的名称,这样solidity会自动给这些变量初始化,并且自动返回这些函数的值,不需要加return。
1 | // 命名式返回 |
在上面的代码中,我们用returns(uint256 _number, bool _bool, uint256[3] memory _array)
声明了返回变量类型以及变量名。这样,我们在主体中只需要给变量_number
,_bool
和_array
赋值就可以自动返回了。
也可以在命名式返回中用return
来返回变量:
1 | // 命名式返回,依然支持return |
解构式赋值
solidity使用解构式赋值的规则,支持读取函数的全部或部分返回值。
- 读取所有返回值:声明变量,并且将要赋值的变量用
,
隔开,按顺序排列。
1 | uint256 _number; |
- 读取部分返回值:声明要读取的返回值对应的变量,不读取的留空。下面这段代码中,我们只读取
_bool
,而不读取返回的_number
和_array
:
1 | (, _bool2, ) = returnNamed(); |
本节代码
1 | // SPDX-License-Identifier: GPL-3.0 |
运行截图
控制流
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
(跳出当前循环)关键字可以使用。
实现插入排序
排序算法解决的问题是将无序的一组数字,例如[2, 5, 3, 1]
,从小到大依次排列好。插入排序(InsertionSort
)是最简单的一种排序算法,也是很多人学习的第一个算法。它的思路很简答,从前往后,依次将每一个数和排在他前面的数字比大小,如果比前面的数字小,就互换位置。
python实现
可以先看一下插入排序的python代码:
1 | # Python program for implementation of Insertion Sort |
solidity 实现
如果直接修改上面的代码会报错:”solidity insertion sort”,然后发现:solidity
中最常用的变量类型是uint
,也就是正整数,取到负值的话,会报underflow
错误。而在插入算法中,变量j
有可能会取到-1
,引起报错。
这里,我们需要把j
加1,让它无法取到负值。正确代码:
1 | // 插入排序 正确版 |
构造函数和修饰器
构造函数
构造函数(constructor
)是一种特殊的函数,每个合约可以定义一个,并在部署合约的时候自动运行一次。它可以用来初始化合约的一些参数,例如初始化合约的owner
地址:
1 | address owner; // 定义owner变量 |
注意⚠️:构造函数在不同的solidity版本中的语法并不一致,在Solidity 0.4.22之前,构造函数不使用 constructor
而是使用与合约名同名的函数作为构造函数而使用,由于这种旧写法容易使开发者在书写时发生疏漏(例如合约名叫 Parents
,构造函数名写成 parents
),使得构造函数变成普通函数,引发漏洞,所以0.4.22版本及之后,采用了全新的 constructor
写法。
构造函数的旧写法代码示例:
1 | pragma solidity =0.4.21; |
修饰器
修饰器(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
控制合约权限代码
1 | // SPDX-License-Identifier: GPL-3.0 |
点击 owner
按钮查看当前 owner 变量。
以 owner 地址的用户身份,调用 changeOwner
函数,交易成功。
以非 owner 地址的用户身份,调用 changeOwner
函数,交易失败,因为modifier onlyOwner 的检查语句不满足。
事件
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函数,执行转账逻辑 |
事件的logs可以在下面的地址中查询:
1 | https://rinkeby.etherscan.io/tx/0x8cf87215b23055896d93004112bbd8ab754f081b4491cb48c37592ca8f8a36c7 |
点击Logs
按钮,就能看到事件明细:Topics
里面有三个元素,[0]
是这个事件的哈希,[1]
和[2]
是我们定义的两个indexed
变量的信息,即转账的转出地址和接收地址。Data
里面是剩下的不带indexed
的变量,也就是转账数量。
继承
继承是面向对象编程很重要的组成部分,可以显著减少重复代码。如果把合约看作是对象的话,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个函数,其中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
,不然编译会报错;另外,未实现的函数需要加virtual
,以便子合约重写。拿我们之前的插入排序合约为例,如果我们还没想好具体怎么实现插入排序函数,那么可以把合约标为abstract
,之后让别人补写上。
1 | abstract contract InsertionSort{ |
接口
接口类似于抽象合约,但它不实现任何功能。接口的规则:
- 不能包含状态变量
- 不能包含构造函数
- 不能继承除接口外的其他合约
- 所有函数都必须是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 { |
异常
介绍solidity
三种抛出异常的方法:error
,require
和assert
,并比较三种方法的gas
消耗。
写智能合约经常会出bug
,solidity
中的异常命令帮助我们debug
。
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 { |
在remix上验证
- 输入任意
uint256
数字和非0地址,调用transferOwner1
,也就是error
方法,控制台抛出了异常并显示我们自定义的TransferNotOwner
。 - 输入任意
uint256
数字和非0地址,调用transferOwner2
,也就是require
方法,控制台抛出了异常并打印出require
中的字符串。 - 输入任意
uint256
数字和非0地址,调用transferOwner3
,也就是assert
方法,控制台只抛出了异常。
三种方法的gas比较
比较一下三种抛出异常的gas
消耗,通过remix控制台的Debug按钮,能查到每次函数调用的gas
消耗分别如下:
error
方法gas
消耗:24445require
方法gas
消耗:24743assert
方法gas
消耗:24446
我们可以看到,error
方法gas
最少,其次是assert
,require
方法消耗gas
最多!因此,error
既可以告知用户抛出异常的原因,又能省gas
,大家要多用!(注意,由于部署测试时间的不同,每个函数的gas
消耗会有所不同,但是比较结果会是一致的。)