区块链学习笔记Day6:智能合约与Solidity(变量)
学区块链做的笔记Day6,大部分内容来自《精通以太坊》和WTF-Solidity。
之前的笔记感觉抄书的东西还是太多了,以后尽量自己总结了。
什么是智能合约
- 计算机程序
智能合约就是计算机程序。 - 不可改变
一旦部署之后,智能合约的代码就不能被改变。更改智能合约的唯一办法就是部署一个新的实例。 - 确定性的
对于触发智能合约执行的交易上下文,或执行时的以太坊区块链状态,智能合约执行结果的输出对于每一个运行或调用它的人来说都是一样的。 - 以太坊虚拟机上下文
智能合约运行在一个非常有限的执行环境中。它们可以访问自己的状态,调用合约交易的上下文信息,以及有关最近区块的信息。 - 去中心化的世界计算机
EVM作为每一个以太坊节点的本地实例运行,但是因为所有EVM都是运行在相同的初始状态,并且会输出完全相同的最终状态,所以整个系统就像是一台世界计算机。
合约的生命周期
每一个合约实例都通过以太坊地址来表示,这个地址由合约的创建交易在创建账户和随机数时生成。合约没有私钥,作为合约的创建者们不会在协议层获得相对其他用户而言的任何特权(但是可以把这种特权写进合约内部)。
合约只有在被交易调用时才会被执行。一个合约可以调用另一个合约。在没有交易触发执行的情况下,合约永远处在等待调用的状态。任何情况下智能合约的“并发执行”都是没有意义的:以太坊世界计算机可以被认为是一台单线程的计算机。
交易是原子化的,交易的执行是一个整体,对于全局状态的修改仅会在所有执行都确定成功之后才会进行。如果程序执行因为错误而终止,它之前进行的所有操作都会被回滚,就像这个执行从来没有发生过一样。失败的交易仍旧会被作为一次失败的尝试而记录在案,执行所花费的gas将从发起账户中被扣除,除此之外,它不会对合约或者账户状态产生任何影响。
合约实例可以被删除,从它的地址把代码和合约实例的内部状态(存储)清空,让这个地址变成一个空账户。要删除合约实例,需要执行名为SELFDESTRUCT
(之前称为SUICIDE
)的EVM字节码。这个操作会产生负的gas
消耗,也就是系统会提供gas
退款,这也会激励人们通过删除存储状态的方式释放资源。删除合约并不会清除这个合约之前交易的历史记录,因为区块链本身是不可变的。需要注意,只有合约开发者在代码中编写了对应的删除功能,SELFDESTRUCT
字节码才会起作用。如果合约的代码不包含SELFDESTRUCT
字节码,或者合约实例是不可执行的,那么这个合约实例就无法删除。
以太坊高级编程语言
智能合约运行在一个高度隔离并且极其简单的执行环境(EVM)中。EVM是一个运行字节码这种特殊形式机器码的虚拟机,类似运行x86_64
指令集的计算机CPU。大多数常见的用户界面,操作系统接口和硬件接口在EVM环境中都不复存在。
编程语言可分为两大类:声明式的和指令式的,也对应称为函数式的和过程式的。指令式程序代码更容易编写和阅读,但是却很难用于编写那些严格按部就班执行的代码,并为非预期的副作用和错误引入了许多机会。
智能合约为程序员设定了一个很高的门栏:如果有bug,可能会损失大量的金钱。因此,编写智能合约就需要极力避免任何可能的副作用。可见,声明式编程语言在智能合约中的作用要大于在通用软件中的作用。然而,智能合约(Solidity)最为广泛使用的语言却是指令式的。
目前用于智能合约编写的高级语言如下:
LLL
:
一种函数式(声明式)编程语言,语法类似Lisp
,这是首款可以在以太坊上编写智能合约的编程语言。Serpent
:
一种过程式(指令式)编程语言,语法类似Python
。也可以用来编写函数式(声明式)代码。Solidity
:
一种过程式编程语言,语法类似JavaScript
、C++或者Java。这是最流行也是最常用的以太坊智能合约编程语言。Vyper
:
语法类似Serpent
和Python
。目标在于达到比Serpent
更加纯函数式的类Python
语言,但不是为了替代Serpent
。Bamboo
:
受Erlang
启发,引入了显式状态转换,并去掉了递归式的循环。旨在减少副作用并提升可审计性。
使用Solidity
编写智能合约
ethereum/solidity: Solidity, the Smart Contract Programming Language (github.com)
Solidity项目的产出主要是Solidity编译器(solc),它用于把Solidity语言编写的代码编译为字节码。这个项目也管理以太坊是智能合约的应用程序二进制接口(ABI)标准。
Solidity的版本模型采用语义化版本,也就是说,通过MAJOR.MINOR.PATCH
三组数字的方式来制定版本。
MAJOR
主版本号:当你做了不兼容的 API 修改(大版本更新)MINOR
次版本号:当你做了向下兼容的功能性新增(小版本更新)PATCH
修订号:当你做了向下兼容的问题修正(修bug)
以太坊合约的应用程序二进制接口(ABI)
在计算机软件中,应用程序二进制接口(ABI)
是指两个程序模块之间的接口,通常,一个在操作系统层面,另外一个在用户程序层面。ABI定义了数据结构和函数如何在机器指令中被访问。
需要注意,这并不是我们常说的API,API定义了高级的,供程序员阅读和使用的源代码接口。
ABI是向机器指令层面编码和解码并传送数据的主要方式。
在以太坊中,ABI用来编码合约中对EVM的调用和从交易中获取数据的调用。ABI的目的是定义合约中哪一个函数可以被调用,并且描述这个函数接收的参数和返回的数据。
合约ABI使用JSON格式表示,其中包含描述合约中函数和事件的数组。
在JSON中,有关函数的属性描述字段包括type
,name
,inputs
,outputs
,constant
和payable
。有关事件的属性描述字段包括type
,name
,inputs
和anonymous
。
应用与合约的交互完全依赖于ABI和应用实例部署的以太坊地址。
(ABI大概可以看作一个程序的描述,比如我要调用什么,输出什么,知道这些后我们才能对合约进行操作,不然我们就不知道该发啥)
使用Solidity进行编程
Solidity文档Solidity — Solidity 0.8.20 文档 (soliditylang.org)
推荐:
Solidity by Example | 0.8.20 (solidity-by-example.org)
以下内容主要参考WTF-Solidity
软件许可声明
一般在代码的第一行注释中写代码所用的软件许可(license),这里用的是MIT license。
1 | // SPDX-License-Identifier: MIT |
如果不写许可,编译时会警告(warning),但程序可以运行。solidity的注释由“//”开头,后面跟注释的内容。
Solidity中的变量
- 数值类型(Value Type):包括布尔型,整数型等等,这类变量赋值时候直接传递数值。
- 引用类型(Reference Type):包括数组和结构体,这类变量占空间大,赋值时候直接传递地址(类似指针)。
- 映射类型(Mapping Type):
Solidity
里的哈希表。 - 函数类型(Function Type):
Solidity
文档里把函数归到数值类型,但我觉得他跟其他类型差别很大,所以单独分一类。
变量数据存储和作用域
数据位置
solidity数据存储位置有三类:storage
,memory
和calldata
。不同存储位置的gas
成本不同。storage
类型的数据存在链上,类似计算机的硬盘,消耗gas
多;memory
和calldata
类型的临时存在内存里,消耗gas
少。大致用法:
storage
:合约里的状态变量默认都是storage
,存储在链上。memory
:函数里的参数和临时变量一般用memory
,存储在内存中,不上链。calldata
:和memory
类似,存储在内存中,不上链。与memory
的不同点在于calldata
变量不能修改(immutable
)(类似const
?),一般用于函数的参数。例子:
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 | contract Variables { |
我们可以在函数里更改状态变量的值:
1 | function foo() external{ |
局部变量
局部变量是仅在函数执行过程中有效的变量,函数退出后,变量无效。局部变量的数据存储在内存里,不上链,gas
低。局部变量在函数内声明:
1 | function bar() external pure returns(uint){ |
全局变量
全局变量是全局范围工作的变量,都是solidity
预留关键字。他们可以在函数内不声明直接使用:
1 | function global() external view returns(address, uint, bytes memory){ |
在上面例子里,我们使用了3个常用的全局变量:msg.sender
, block.number
和msg.data
,他们分别代表请求发起地址,当前区块高度,和请求数据。下面是一些常用的全局变量,更完整的列表请看这个链接:
交易/消息的调用上下文
msg
对象是指触发合约执行的交易,这个对象包含了一些属性:
msg.sender
发起合约调用的以太坊地址。如果这个合约是由来自外部账户的交易触发的,那么这个地址就是对交易进行签名的地址,否则就是另外一个合约的地址(合约之间互相调用)。msg.value
调用中发送的以太币数量(以wei
为单位)。msg.gas
执行环境中当前可用的gas
数量,这个属性已经被废弃,将会被gasleft
函数取代。msg.data
调用合约时传入的数据。msg.sig
传输数据的前四个字节,这是一个函数选择器。
交易的上下文
tx
对象提供了访问交易相关信息的办法。
tx.gasprice
调用交易的gas
价格。tx.origin
发起这个交易的外部账户的地址。警告:这是个不安全的做法
区块的上下文
block
对象包含了当前区块的信息:
block.blockhash(blockNumber)
指定区块的哈希值,仅限于当前区块之前不超过256个区块。目前已经弃用,被blockhash
方法取代。block.coinbase
当前区块的矿工地址,用于接收这个区块的出块奖励和所有交易费。block.difficulty
当前区块工作量证明的难度。block.gaslimit
可以在当前区块中包含的所有事物中花费的最大gas
数量。block.number
当前的区块编号(区块在链中的高度)。block.timestamp
由狂高写入的当前区块的时间戳。
地址对象
不论是来自于外部输入,还是从合约对象中获取,地址类对象都包含如下属性和方法:
address.balance
当前地址的余额,以wei
为单位。address.transfer(amount)
转账一定数量(以wei
为单位)的以太币到指定的地址,遇到任何错误都将抛出异常。address.send(amount)
与上面的address.transfer(amount)
类似,但是不抛出异常。address.call(payload)
一种底层CALL
函数,可以构建一个包含自定义数据的调用,出错时会返回false
。address.callcode(payload)
一种底层CALLCODE
函数。address.delegatecall()
一种底层DELEGATECALL
函数。
内建函数
其他值得注意的函数是:
addmod
、mulmod
模数的加法和乘法,列如:addmod(x,y,k)
计算keccak256
、sha256
、sha3
、ripemd160
多种算法的哈希计算函数ecrecover
从数字签名的数据中计算,获取签名地址。selfdestrunct(recipient_address)
删除当前的合约,把合约中剩余的比特币转账到recipient_address
这个地址。this
当前执行合约账户的以太坊地址。
数值类型
布尔型(bool)
布尔型是二值变量,取值为true
或false
。
1 | // 布尔值 |
布尔值的运算符,包括:
!
(逻辑非)&&
(逻辑与, “and” )||
(逻辑或, “or” )==
(等于)!=
(不等于)
整型(int,unit)
整型是solidity
中的整数,最常用的包括
1 | // 整型 |
常用的整型运算符包括:
- 比较运算符(返回布尔值):
<=
,<
,==
,!=
,>=
,>
- 算数运算符:
+
,-
, 一元运算-
,+
,*
,/
,%
(取余),**
(幂)
固定浮点数(fixed、ufixed)
固定小数点的浮点数,使用(u)fixedMxN定义,其中M是比特的位数(从8到256),N用来表示小数点之后有多少位(最多18),列如ufixed32x2
。
地址类型
地址类型(address)存储一个 20 字节的值(以太坊地址的大小)。地址类型也有成员变量,并作为所有合约的基础。有普通的地址和可以转账ETH
的地址(payable
)。其中,payable
修饰的地址相对普通地址多了transfer
和send
两个成员。在payable
修饰的地址中,send
执行失败不会影响当前合约的执行(但是返回false值,需要开发人员检查send
返回值)。balance
和transfer()
,可以用来查询ETH
余额以及安全转账(内置执行失败的处理)。
1 | // 地址 |
定长字节数组
字节数组bytes
分两种,一种定长(byte
, bytes8
, bytes32
),另一种不定长。定长的属于数值类型,不定长的是引用类型(之后讲)。 定长bytes
可以存一些数据,消耗gas
比较少。
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
的一个比较冷门的变量,几乎没什么人用。
函数类型
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种。没标明函数类型的,默认public
。合约之外的函数,即”自由函数”,始终具有隐含internal
可见性。
public
: 内部外部均可见。private
: 只能从本合约内部访问,继承的合约也不能用。external
: 只能从合约外部访问(但是可以用this.f()
来调用,f
是函数名)。internal
: 只能从合约内部访问,继承的合约可以用。
Note 1
: 没有标明可见性类型的函数,默认为public
。
Note 2
:public|private|internal
也可用于修饰状态变量。public
变量会自动生成同名的getter
函数,用于查询数值。
Note 3
: 没有标明可见性类型的状态变量,默认为internal
。
[pure|view|payable]
决定函数权限/功能的关键字。
view
:当函数被标注为view
时,它将承诺不对任何状态进行修改。包含view
关键字的函数,能读取但也不能写入状态变量。pure
:pure
函数表示这个函数不会在区块链存储中读取或者写入任何数据。这样的函数只能处理参数,然后返回值给调用方,无法在区块链上读取或者存储任何数据。payable
:payable
函数用于接收外部的支付。未声明为payable
的函数不能接收任何以太币支付。
在以太坊中,以下语句被视为修改链上状态:
- 写入状态变量。
- 释放事件。
- 创建其他合约。
- 使用
selfdestruct
。 - 通过调用发送以太币。
- 调用任何未标记
view
或pure
的函数。 - 使用低级调用(low-level calls)。
- 使用包含某些操作码的内联汇编。
[returns ()]
函数返回的变量类型和名称。
返回值 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(); |
引用类型
数组array
数组(Array
)是solidity
常用的一种变量类型,用来存储一组数据(整数,字节,地址等等)。数组分为固定长度数组和可变长度数组两种:
- 固定长度数组:在声明时指定数组的长度。用
T[k]
的格式声明,其中T
是元素的类型,k
是长度,例如:
1 | // 固定长度 Array |
- 可变长度数组(动态数组):在声明时不指定数组的长度。用
T[]
的格式声明,其中T
是元素的类型,例如:
1 | // 可变长度 Array |
注意:bytes
比较特殊,是数组,但是不用加[]
。另外,不能用byte[]
声明单字节数组,可以使用bytes
或bytes1[]
。在gas上,bytes
比bytes1[]
便宜。因为bytes1[]
在memory
中要增加31个字节进行填充,会产生额外的gas。但是在storage
中,由于内存紧密打包,不存在字节填充。
创建数组的规则
在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
Solidity
支持通过构造结构体的形式定义新的类型。创建结构体的方法:
1 | // 结构体 |
1 | Student student; // 初始一个student结构体 |
给结构体赋值的两种方法:
1 | // 给结构体赋值 |
1 | // 方法2:直接引用状态变量的struct |
映射类型
映射mapping
在映射中,人们可以通过键(Key
)来查询对应的值(Value
),比如:通过一个人的id
来查询他的钱包地址。
声明映射的格式为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
)初始值都是各个type的默认值,如uint的默认值是0。
变量初始值
在solidity
中,声明但没赋值的变量都有它的初始值或默认值。
值类型初始值
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操作符 |
常数
solidity
中两个关键字,constant
(常量)和immutable
(不变量)。状态变量声明这个两个关键字之后,不能在合约后更改数值;并且还可以节省gas
。另外,只有数值变量可以声明constant
和immutable
;string
和bytes
可以声明为constant
,但不能为immutable
。
constant
constant
变量必须在声明的时候初始化,之后再也不能改变。尝试改变的话,编译不通过。
1 | // constant变量必须在声明的时候初始化,之后不能改变 |
immutable
immutable
变量可以在声明时或构造函数中初始化,因此更加灵活。
1 | // immutable变量可以在constructor里初始化,之后不能改变 |
你可以使用全局变量例如address(this)
,block.number
,或者自定义的函数给immutable
变量初始化。在下面这个例子,我们利用了test()
函数给IMMUTABLE_TEST
初始化为9
:
1 | // 利用constructor初始化immutable变量,因此可以利用 |