Hello Web3
Solidity简介
Solidity
是一种用于编写以太坊虚拟机(EVM
)智能合约的编程语言。
Hello Web3
简单的程序HelloWeb3.sol
如下:
1 | // SPDX-License-Identifier: MIT |
使用到https://remix.ethereum.org/
进行编译和部署,点击_string
输出。
代码中的
1 | pragma solidity ^0.8.21; |
表示源文件将不允许小于 0.8.21 版本或大于等于 0.9.0 的编译器编译(第二个条件由 ^
提供)。
其中
1 | string public _string = "Hello Web3!"; |
public
修饰符表示 _string
变量是公开的,任何人都可以读取该变量的值。
Solidity 会自动为 public
修饰的状态变量生成一个 getter 函数,允许外部代码(如其他合约或外部用户)通过合约的 ABI(应用二进制接口)访问这个变量的值。
值类型
Solidity中的变量类型
- 值类型(Value Type):包括布尔型、整数型等等,这类变量赋值时候直接传递数值。
- 引用类型(Reference Type):包括数组和结构体,这类变量占空间大,赋值时候直接传递地址(类似指针)。
- 映射类型(Mapping Type):Solidity中存储键值对的数据结构,可以理解为哈希表。
编写一个简单的程序ValueTypes.sol
,介绍一下各种变量类型,程序如下:
1 | // SPDX-License-Identifier: MIT |
地址类型
地址类型(address)有两类:
- 普通地址(address): 存储一个 20 字节的值(以太坊地址的大小)。
- payable address: 比普通地址多了
transfer
和send
两个成员方法,用于接收转账。
定长字节数组
字节数组分为定长和不定长两种:
- 定长字节数组: 属于值类型,数组长度在声明之后不能改变。根据字节数组的长度分为
bytes1
,bytes8
,bytes32
等类型。定长字节数组最多存储 32 bytes 数据,即bytes32
。 - 不定长字节数组: 属于引用类型,数组长度在声明之后可以改变,包括
bytes
等。
1 | // 固定长度的字节数组 |
在上述代码中,MiniSolidity
变量以字节的方式存储进变量 _byte32
。如果把它转换成 16 进制
,就是:0x4d696e69536f6c69646974790000000000000000000000000000000000000000
_byte
变量的值为 _byte32
的第一个字节,即 0x4d
。
枚举enum
枚举(enum
)是 Solidity 中用户定义的数据类型。它主要用于为 uint
分配名称,使程序易于阅读和维护。它与 C 语言
中的 enum
类似,使用名称来代替从 0
开始的 uint
:
1 | // 用enum将uint 0, 1, 2表示为Buy, Hold, Sell |
枚举可以显式地和 uint
相互转换,并会检查转换的正整数是否在枚举的长度内,否则会报错:
1 | // enum可以和uint显式的转换 |
enum
是一个比较冷门的变量,几乎没什么人用。
enum
和uint
相互转换,如下:
函数和函数输出
函数
函数的代码形式
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种。-
public
:内部和外部均可见。 -
private
:只能从本合约内部访问,继承的合约也不能使用。 -
external
:只能从合约外部访问(但内部可以通过this.f()
来调用,f
是函数名)。 -
internal
: 只能从合约内部访问,继承的合约可以用。
注意1⚠️:合约中定义的函数需要明确指定可见性,它们没有默认值。
注意2⚠️:
public|private|internal
也可用于修饰状态变量。public
变量会自动生成同名的getter
函数,用于查询数值。未标明可见性类型的状态变量,默认为internal
。 -
-
[pure|view|payable]
:决定函数权限/功能的关键字。payable
(可支付的)很好理解,带着它的函数,运行的时候可以给合约转入 ETH。 -
[returns (<return types>)]
:函数返回的变量类型和名称。
到底什么是Pure
和View
?
solidity
引入这两个关键字主要是因为 以太坊交易需要支付gas fee。合约的状态变量存储在链上,gas fee 很贵,如果计算不改变链上状态,就可以不用付 gas
。包含 pure
和 view
关键字的函数是不改写链上状态的,因此用户直接调用它们是不需要付 gas 的(注意⚠️,合约中非 pure
/view
函数调用 pure
/view
函数时需要付gas)。
在以太坊中,以下语句被视为修改链上状态:
- 写入状态变量。
- 释放事件。
- 创建其他合约。
- 使用
selfdestruct
. - 通过调用发送以太币。
- 调用任何未标记
view
或pure
的函数。 - 使用低级调用(low-level calls)。
- 使用包含某些操作码的内联汇编。
pure
函数既不能读取也不能写入链上的状态变量。view
函数能读取但不能写入状态变量。- 非
pure
或view
的函数既可以读取也可以写入状态变量。
代码测试
测试代码FunctionTypes.sol
如下:
1 | // SPDX-License-Identifier: MIT |
pure和view
在代码中定义了一个add()
函数,每次调用会使得number
加1。
1 | // 默认function |
如果 add()
函数被标记为 pure
,比如 function add() external pure
,就会报错。因为 pure
是不能读取合约里的状态变量的,更不能改写。
使用到了pure
出现的报错,如下:
如果使用view
,则代码会报错,证实了view
是只能读取而不能写入状态变量。
那 pure
函数能做些什么?举个例子,你可以给函数传递一个参数 _number
,然后让他返回 _number + 1
,这个操作不会读取或写入状态变量。
如代码中的addPure
:
1 | // pure |
view
读取状态变量,然后对读取的结果进行加1。
1 | // view |
internal和external的对比
代码如下:
1 | // internal: 内部函数 |
定义了一个 internal
的 minus()
函数,每次调用使得 number
变量减少 1。由于 internal
函数只能由合约内部调用,我们必须再定义一个 external
的 minusCall()
函数,外部通过它间接调用内部的 minus()
函数。
payable的使用
1 | // payable: 递钱,能给合约支付eth的函数 |
定义一个 external payable
的 minusPayable()
函数,间接的调用 minus()
,并且返回合约里的 ETH 余额(this
关键字可以让我们引用合约地址)。我们可以在调用 minusPayable()
时往合约里转入1个 ETH。
函数输出
测试代码Return.sol
如下:
1 | // SPDX-License-Identifier: MIT |
返回值:return和returns
returns
: 跟在函数名后面,用于声明返回的变量类型及变量名。return
: 用于函数主体中,返回指定的变量。
1 | // 返回多个变量 |
在上述代码中,我们利用 returns
关键字声明了有多个返回值的 returnMultiple()
函数,然后我们在函数主体中使用 return(1, true, [uint256(1),2,5])
确定了返回值。
这里uint256[3]
声明了一个长度为3
且类型为uint256
的数组作为返回值。因为[1,2,3]
会默认为uint8(3)
,因此[uint256(1),2,5]
中首个元素必须强转uint256
来声明该数组内的元素皆为此类型。
命名式返回
可以在 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(); |
变量数据存储和作用域
测试代码DataStorage.sol
,如下所示:
1 | // SPDX-License-Identifier: MIT |
测试代码Variables.sol
,如下图所示:
1 | // SPDX-License-Identifier: MIT |
Solidity中的引用类型
引用类型(Reference Type):包括数组(array
)和结构体(struct
),由于这类变量比较复杂,占用存储空间大,我们在使用时必须要声明数据存储的位置。
数据位置
Solidity数据存储位置有三类:storage
,memory
和calldata
。不同存储位置的gas
成本不同。
-
storage
类型的数据存在链上,类似计算机的硬盘,消耗gas
多; -
memory
和calldata
类型的临时存在内存里,消耗gas
少。
大致用法:
storage
:合约里的状态变量默认都是storage
,存储在链上。memory
:函数里的参数和临时变量一般用memory
,存储在内存中,不上链。尤其是如果返回数据类型是变长的情况下,必须加memory修饰,例如:string, bytes, array和自定义结构。calldata
:和memory
类似,存储在内存中,不上链。与memory
的不同点在于calldata
变量不能修改(immutable
),一般用于函数的参数。
calldata
修饰的变量不能被修改,例子如下:
1 | function fCalldata(uint[] calldata _x) public pure returns(uint[] calldata){ |
数据位置和赋值规则
在不同存储类型相互赋值时候,有时会产生独立的副本(修改新变量不会影响原变量),有时会产生引用(修改新变量会影响原变量)。规则如下:
- 赋值本质上是创建引用指向本体,因此修改本体或者是引用,变化可以被同步。
storage
(合约的状态变量)赋值给本地storage
(函数里的)时候,会创建引用,改变新变量会影响原变量。样例如下:
1 | uint[] x = [1,2,3]; // 状态变量:数组 x |
部署调用一下fStorage
函数,然后利用交易的hash
对代码进行调试,发现数值被更改。
memory
赋值给memory
,会创建引用,改变新变量会影响原变量。
- 其他情况下,赋值创建的是本体的副本,即对二者之一的修改,并不会同步到另一方。
变量的作用域
Solidity
中变量按作用域划分有三种,分别是状态变量(state variable),局部变量(local variable)和全局变量(global variable)
状态变量(state variable)
状态变量是数据存储在链上的变量,所有合约内函数都可以访问,gas
消耗高。状态变量在合约内、函数外声明:
1 | contract Variables { |
可以在函数里更改状态变量的值:
1 | function foo() external{ |
局部变量(local variable)
局部变量是仅在函数执行过程中有效的变量,函数退出后,变量无效。局部变量的数据存储在内存里,不上链,gas
低。
局部变量在函数内声明:
1 | function bar() external pure returns(uint){ |
全局变量(global variable)
全局变量是全局范围工作的变量,都是solidity
预留关键字。他们可以在函数内不声明直接使用:
1 | function global() external view returns(address, uint, bytes memory){ |
在上面例子里,我们使用了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
值block.blobbasefee
: (uint
) 当前区块的blob基础费用。这是Cancun升级新增的全局变量。blobhash(uint index)
: (bytes32
) 返回跟当前交易关联的第index
个blob的版本化哈希(第一个字节为版本号,当前为0x01
,后面接KZG承诺的SHA256哈希的最后31个字节)。若当前交易不包含blob,则返回空字节。这是Cancun升级新增的全局变量。
全局变量-以太单位与时间单位
以太单位
Solidity
中不存在小数点,以0
代替为小数点,来确保交易的精确度,并且防止精度的损失,利用以太单位可以避免误算的问题,方便程序员在合约中处理货币交易。
wei
: 1gwei
: 1e9 = 1000000000ether
: 1e18 = 1000000000000000000
时间单位
可以在合约中规定一个操作必须在一周内完成,或者某个事件在一个月后发生。这样就能让合约的执行可以更加精确,不会因为技术上的误差而影响合约的结果。因此,时间单位在Solidity
中是一个重要的概念,有助于提高合约的可读性和可维护性。
seconds
: 1minutes
: 60 seconds = 60hours
: 60 minutes = 3600days
: 24 hours = 86400weeks
: 7 days = 604800
引用类型和映射类型
引用类型
这一讲,我们将介绍Solidity
中的两个重要变量类型:数组(array
)和结构体(struct
)。
数组array
数组(Array
)是Solidity
常用的一种变量类型,用来存储一组数据(整数,字节,地址等等)。数组分为固定长度数组和可变长度数组两种:
- 固定长度数组:在声明时指定数组的长度。用
T[k]
的格式声明,其中T
是元素的类型,k
是长度,例如:
1 | // 固定长度 Array |
- 可变长度数组(动态数组):在声明时不指定数组的长度。用
T[]
的格式声明,其中T
是元素的类型,例如:
1 | // 可变长度 Array |
注意⚠️:bytes
比较特殊,是数组,但是不用加[]
。另外,不能用byte[]
声明单字节数组,可以使用bytes
或bytes1[]
。bytes
比 bytes1[]
省gas。
创建数组的规则
在Solidity里,创建数组有一些规则:
- 对于
memory
修饰的动态数组
,可以用new
操作符来创建,但是必须声明长度,并且声明后长度不能改变。例子:
1 | // memory动态数组 |
-
数组字面常数(Array Literals)是写作表达式形式的数组,用方括号包着来初始化array的一种方式,并且里面每一个元素的type是以第一个元素为准的,例如
[1,2,3]
里面所有的元素都是uint8
类型,因为在Solidity中,如果一个值没有指定type的话,会根据上下文推断出元素的类型,默认就是最小单位的type,这里默认最小单位类型是uint8
。而[uint(1),2,3]
里面的元素都是uint
类型,因为第一个元素指定了是uint
类型了,里面每一个元素的type都以第一个元素为准。下面的例子中,如果没有对传入
g()
函数的数组进行uint
转换,是会报错的。
1 | // SPDX-License-Identifier: GPL-3.0 |
- 如果创建的是动态数组,你需要一个一个元素的赋值。
1 | uint[] memory x = new uint[](3); |
数组成员
length
: 数组有一个包含元素数量的length
成员,memory
数组的长度在创建后是固定的。push()
:动态数组
拥有push()
成员,可以在数组最后添加一个0
元素,并返回该元素的引用。push(x)
:动态数组
拥有push(x)
成员,可以在数组最后添加一个x
元素。pop()
:动态数组
拥有pop()
成员,可以移除数组最后一个元素。
结构体struct
此时代码StructTypes.sol
,代码如下:
1 | // SPDX-License-Identifier: MIT |
Solidity
支持通过构造结构体的形式定义新的类型。结构体中的元素可以是原始类型,也可以是引用类型;结构体可以作为数组或映射的元素。创建结构体的方法:
1 | // 结构体 |
给结构体赋值的四种方法:
1 | // 给结构体赋值 |
1 | // 方法2:直接引用状态变量的struct |
1 | // 方法3:构造函数式 |
1 | // 方法4:key value |
映射类型
这一讲,我们将介绍映射(Mapping
)类型,Solidity中存储键值对的数据结构,可以理解为哈希表。
测试代码Mapping.sol
,代码如下所示:
1 | // SPDX-License-Identifier: MIT |
映射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{ |
写入mapping
,并读取。
映射的原理
- 原理1: 映射不储存任何键(
Key
)的资讯,也没有length的资讯。 - 原理2: 映射使用
keccak256(abi.encodePacked(key, slot))
当成offset存取value,其中slot
是映射变量定义所在的插槽位置。 - 原理3: 因为Ethereum会定义所有未使用的空间为0,所以未赋值(
Value
)的键(Key
)初始值都是各个type的默认值,如uint的默认值是0。
变量初始化
在Solidity
中,声明但没赋值的变量都有它的初始值或默认值。这一讲,我们将介绍常用变量的初始值。
1 | // SPDX-License-Identifier: MIT |
值类型初始值
-
boolean
:false
-
string
:""
-
int
:0
-
uint
:0
-
enum
: 枚举中的第一个元素 -
address
:0x0000000000000000000000000000000000000000
(或address(0)
) -
function
-
internal
: 空白函数 -
external
: 空白函数
-
可以用public
变量的getter
函数验证上面写的初始值是否正确:
1 | bool public _bool; // false |
引用类型初始值
- 映射
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
。
测试代码Constant.sol
,代码如下:
1 | // SPDX-License-Identifier: MIT |
constant和immutable
constant
constant
变量必须在声明的时候初始化,之后再也不能改变。尝试改变的话,编译不通过。
1 | // constant变量必须在声明的时候初始化,之后不能改变 |
immutable
immutable
变量可以在声明时或构造函数中初始化,因此更加灵活。在Solidity v8.0.21
以后,immutable
变量不需要显式初始化。反之,则需要显式初始化。 若immutable
变量既在声明时初始化,又在constructor中初始化,会使用constructor初始化的值。
1 | // immutable变量可以在constructor里初始化,之后不能改变 |
你可以使用全局变量例如address(this)
,block.number
或者自定义的函数给immutable
变量初始化。在下面这个例子,我们利用了test()
函数给IMMUTABLE_TEST
初始化为9
:
1 | // 利用constructor初始化immutable变量,因此可以利用 |