一.区块链的回顾
1.区块链
区块链实质上是一个去中心化、分布式的可进行交易的数据库或账本
特征:
- 去中心化:简单来说,在网络上一个或多个服务器瘫痪的情况下,应用或服务仍然能够持续地运行,这就是去中心化。服务和应用部署在网络上后,尽管每个服务器都有一份数据和执行程序的副本,但是没有任何一个服务器能够绝对控制数据和程序的执行过程。
- 分布式:网络上的每个服务器或节点都互相连接在一起,服务器之间是多对多连接,而不是一对一或一对多连接。
- 数据库:指的是存储持久化数据、用户能够及时从任何地点进行访问的地方。数据库的基本功能是数据存储和检索,同时也提供了一些管理功能,以方便高效地管理数据,如:数据导入和导出,数据备份和恢复。
- 账本:这是一个会计专业术语。你也可以认为它是一个专门存储和检索数据的地方。账本对银行业而言很有用处。例如,Tom在他的银行账户上存入了100美元,对银行而言,需要在账本上计入一笔贷方金额。未来的某一天,Tom取回了25美元,银行不会直接把100美元修改成75美元,而是在同一个账本上,新增一笔借方金额25美元。从这个例子中可以看出,账本是一种特殊的数据存储方式,它不允许修改历史数据,要改变账户的余额只能通过新增和追加记录来实现。区块链是与账本存在共同特征的数据库,新的数据只能通过追加的方式进行存储,没有任何修改历史数据的可能。
- 因为不能修改历史记录,所以区块链具有较高的可信任性、透明性和公正性。
- 区块链是由区块组成的一个链条。这意味着它是由多个区块前后连接在一起的,而交易记录则是保存在每个区块的内部,采用这种方式后,这些交易记录就不可能再被更改。由于去中心化和分布式特性,区块链具有稳定性、健壮性、持久性和高可用性的特点,不存在单点故障的问题。没有单个节点或服务器能控制整个链上的数据,因此人人都能够参与其中,成为区块链社区的参与者。
1.1区块链的用途
- 信任 :区块链可以用于创建去中 心化应用,实现数据由 人集体控制, 其中的任何一个人都没有权力去更改或删除以前的记录 即使有人确实 做到了,他产生的数据也不会被其 参与者接受
- 自治性: 对于区块链上的应用来说,没有唯一的所有者 由于没有唯一 的所有者,也就没有人能够单独控制它 ,但是每个人却都可以通过它的 行为来参与治理过程,这就有利于建立 个不能被操控或不易诱发腐败 的解决方
- 去中介化 :基于 块链的应用能够消除现有流程的中间环节 例如在车 辆登记 、驾照发放等场景 中, 会存 个中间角色,它承担着车辆登记和驾照发放的职 如果 于区块链来设计流程,那么这个中间角 色就没有存在的必要了,因为区块链上的数据在被确认后,驾照就会自 动签发,车辆就会被自动登记 区块链将开启一个新的时代,很多业务 不再需要中间的权威机构进行背书了
2.加密技术
2.1 散列
散列是将输入的数据转换成一个固定长度的随机字符串(散列值)的过程,但是不能从结果反向生成或识别出原始数据,因此,散列也被称为数据指纹。几乎不可能基于其散列值导出输入数据哪怕原始数据发生了一点点的变化,也将产生完全不同的散列值,这样就确保了没有人敢在原始数据上做手脚。散列还有另外一个特征:虽然输入的字符串数据可能长短不同,但产生的散列值长度是固定的。例如,使用SHA256散列算法,不论输入数据的长度大小如何,总会产生一个256个字节的散列值。当数据量很大时,这一点就非常有用了,它总能产生一个256个字节的散列值,这样可以保存下来作为证据。以太坊在很多地方使用了散列技术,它会对每一笔交易进行散列,会对两个交易的散列值进行再次散列,最终为同一区块内的每个交易产生一个根散列值。
散列还有一个重要特征,就是从数学上来看,两个不同的输入数据不会产 生同一个散列值。
在线哈希计算器:在线哈希值计算
2.2数字签名
前面我们介绍了非对称加密,它的一个重要应用就是在数字签名创建和验证时使用非对称密钥。数字签名类似于一个人在纸上手写的签名。与手写签名的作用一样,数字签名有助于识别一个人,还有助于确保信息在传递过程中不被篡改。让我们举个例子来理解数字签名。 Alice准备给Tom发送一条信息。那么问题来了,Tom如何确保收到的信息是由Alice发出来的,如何确保信息在传递过程中没有被篡改过?解决方案就是不能发送原始的信息/交易,Alice首先需要取得发送的信息的散列值,然后用她的私钥对散列值进行加密,最后,她把这个刚产生的数字签名附加在散列值后发送给Tom。Tom收到信息后,他使用Alice的公钥提取出数字签名并解密,找到原始散列值。同时,他从实际接收到的信息中提取散列值,并对两个散列值进行比较,如果两个散列值一致,那么说明信息在传递过程中没有被篡改过。 数字签名通常用于资产或加密数字货币(例如以太币)的所有者对交易进行签名确认。
身份的辨别(公钥和私钥)
确保数字不被篡改(哈希)
3.区块链和以太坊架构
区块链与智能合约之间的桥梁:以太坊 具体详情请点击旁边以太坊的链接
以太坊是区块链,但不仅仅是区块链,它在区块链的基础上架构了一个虚拟机,可以在这个虚拟机上用以太坊指定的语言运行程序,这个指定的语言是solidty,程序即智能合约。
区块链是一种包含多个组件的体系结构,区块链独特的地方在于这些组件 的功能和相互作用 重要的组件包括 EVM ( Ethereum Virtual Machine 以太坊 虚拟机)、矿工、区块、交易、共识算法、账户、智能合约、挖矿、以太币和 gas 一个区块链网络是由大量的节点构成的,其中 部分是属于矿工的挖矿 节点,另一部分节点不挖矿但会帮助执行智能合约和交易 这些节点统称为 EVM 网络上的各个节点之间互相连接,节点之间通过 P2P 协议进行通信,默 认情况下使用 30303 端口 每个节点都维护着 个账本的实例(副本),包含链上的全部区块 由于网 络上存在大量矿工节点,为了避免节点之间的区块数据存在差异,这些节点会 持续同步区块,确保账本数据一致
以太坊虚拟机EVM是智能合约的运行环境。
以太坊相当于分散在世界各地的节点共同组成的公共电脑
3.1以太币
在以太坊这个公共电脑上运行程序就像是在网吧上网,必须要付费,这个地方不是付人名币而是以太币。以太币采用十进制的计量体系,其最小的单位是 wei 下面列出了一些计 量单位,可 以在网站 https: //g ithub.com/e thereum/we b3.js blob/ 0.15 .O/lib/utils/ utils. js#L40 上查到更多信息。
3.2gas
也可以把以太坊理解成联通全球的道路网,智能合约在上面运行就像是在这个道路上面开车,需要耗费汽油。
3.3以太坊节点
以太坊客户端是一个软件应用程序,它实现了以太坊规范,并通过点对点网络与其他以太坊客户端进行通信。不同的以太坊客户端如果符合参考规范和标准化的通信协议,就可以实现互操作。虽然这些不同的客户端是由不同的团队用不同的编程语言实现的,但它们都 "说 "着相同的协议,遵循相同的规则。因此,它们都可以用来操作和与同一个以太坊网络进行交互一个节点需要运行两种客户端软件:共识客户端和执行客户端。
- 执行客户端(也称为执行引擎、EL 客户端或旧称“以太坊 1”客户端)侦听网络中广播的新交易,并在以太坊虚拟机中执行它们,并保存所有当前以太坊数据的最新状态和数据库。
- 共识客户端(也称为信标节点、CL 客户端或旧称“以太坊 2”客户端)实现权益证明共识算法,使网络能够根据来自执行客户端的经验证数据达成一致。 此外还有名为“验证者”的第三种软件,它们可被添加到共识客户端中,使节点能参与保护网络安全。
作用:连接以太坊网络
在区块链和以太坊中,每个区块都连接着另外一个区块 两个区块之间是 对父子的关系,并且是 的关系,这样首尾相接就组成了 个链条 章后面会讲到区块,在接下来这张图中,我 3个区块( 区块1 区块2,区块3 )来示意 区块1 是区块1 的父区块,区块2 是区块3 的父区块 在每个 区块的头部都存储了父区块的散列值,这样就建立了父子关系
区块2 在头部存储了区块 1的散列值,区块 3在头部存储了区块2 的散列 值,以太坊有个创世区块的概念, 它就是第一个区块 这个区块是在链初次发起时·自动创建的 你也可以这样认 为,整个链条是由创世区块(通过 genesis jso 文件来生成)作为第一个区 块而开始启动的,如下图所示
3.4以太坊账户
具体详情请点击上面以太坊账户的链接
帐户是存储以太币之处。 用户可以初始化帐户,将以太币存入帐户,并将自己帐户中的以太币转账给其他用户。 帐户和帐户余额存储在以太坊虚拟机中的一个大表格中,是以太坊虚拟机总体状态的一部分。
以太坊有两类账户(它们共用同一个地址空间):
- 外部账户 :由公钥-私钥对(也就是人)控制
- 合约账户 :由和账户一起存储的代码控制
外部账户的地址是由公钥决定的,而合约账户的地址是在创建合约时确定的
相同点:
每个账户都有一个键值对形式持久化存储,其中key和value的长度都是256位,我们称之为存储
3.5交易
具体详情请点击上面交易的链接
交易是由帐户发出,带密码学签名的指令。 帐户将发起交易以更新以太坊网络的状态。 最简单的交易是将 ETH 从一个帐户转到另一个帐户。
以太坊交易是指由外部持有帐户发起的行动,换句话说,是指由人管理而不是智能合约管理的帐户。 例如,如果 Bob 发送 Alice 1 ETH,则 Bob 的帐户必须减少 1 ETH,而 Alice 的帐户必须增加 1 ETH。 交易会造成状态的改变。
端到端的交易
前面介绍了区块链和以太坊的一些基本知识,接下来我们介绍一个完整的端到端的交易流程,看看交易如何贯穿多个组件并保存到账本中 本例中,Sam打算发送一个数字资产(如:美元)给Mark。首先,Sam新建了一个交易,里面包括了from、to、value等字段数据,然后发送到以太网络上,该交易并没有立即写入到账本中,而是暂存到交易池中。 挖矿节点新建了一个区块,然后按照gas上限标准,从交易池中提取交易(Sam的交易也将被提取),并添加到区块中,网络上的全体矿工都在执行相同的任务。 接下来,矿工们开始争先恐后地去计算难题,在一段时间(或几秒)后,获胜者(第一个解决难题的人)会发出通知,声称他找到了答案,赢得了比赛,需要向区块链写入区块,与此同时,获胜者将答案添加到区块上并发送给其他矿工。其他矿工收到通知后,首先验证这个答案,一旦认定该答案确实有效,就立即停止自己的计算,接收这个包含了Sam的交易的区块,然后添加到他们的本地账本中。这样下来,就在链上产生了一个新的区块,它将一直跨越时间和空间而永久的存在下去。在这期间,双方的账户余额都会得到更新,最后,区块被分发复制到网络上的全部节点。这个过程如下图所示:
以太坊上支持的3 种交易类型:
1. 从一个账户向另外一个账户发送以太币 :这个账户可能是外部账户或者 合约账户
2. 智能合 外部账户在 EVM 上部署合约是通过交易的方式实现的。
使用或借助合约内的函数 如果需要执行合约内的函数去改变一个状态, 就需要一个交易,如果执行函数没有改变任何一个状态,就不需要交易
下面介绍与交易有关的一些重要属性:
- From 账户属性说明了这个账户是交易的发起方,发送 gas 或以太币 前面 章节我们介绍过以太币和 gas 的概念 From 账户可以是外部账户或合约账户
- To 账户属性指的是接收以太币或其 收益的账户,它可以是外部账户或合 约账户 如果是部署合约的交易,则To 字段为空
- Value 账户属性指的是账户之间转移的以太币数量。
- Input 账户属性指的是合约编译后被部署在 EVM 上的字节码 input 用于保存有关智能合约函数带参调用的信息 下图展示了在典型的以太坊交易 中使用智能合约函数的地方,从这个截图上看,请注意 Input 字段中包含了带 有参数的函数调用
- BlockHash 账户属性指的是该交易所属的区块的散列值
- BlockNurnber 账户属性指的是交易所属的区块序号
- Gas 账户属性指的是交易的发送方支付的 gas
- GasPrice 账户属性指的是发送方支付的 gas 价格,以 wei 为单位(在本 章前面介绍以太币的 方,提到过 we 的概念) 总的 gas 消耗=gas数量* gas 价格
- Hash 账户属性指的是交易的散列值
- Nonce 账户属性指的是交易的编号,它由发送方在当前交易之前产生
- Transaction nde 账户属性指的是区块中当前交易的流水号
- Value 账户属性指的用 wei 计算的传递的以太的数量
- v,r,s属性指的是数字签名和交易的签名
3.6区块
区块是指一批交易的组合,并且包含链中上一个区块的哈希。 这将区块连接在一起(成为一个链),因为哈希是从区块数据中加密得出的。 这可以防止欺诈,因为以前的任何区块中的任何改变都会使后续所有区块无效,而且所有哈希都会改变,所有运行区块链的人都会注意到。
区块有很多属性,为了便于掌握关键内容,下面只介绍一些重要的部分
- Difficulty 属性指的是矿工为了挖到这个区块而需要面对的计算难度
- GasLimit 属性指的是区块允许的 gas 总量上限 它决定了区块中能包含 多少个交易
- Gas Used 属性指的是区块中的交易实际消耗的 gas 数
- Hash 属性指的是这个区块的散列值
- Nonce 属性指的是一个数字,它是解决难题的答案
- Miner 属性指的是矿工的账户,可以用 co in base etherbase 的地址
- Number 属性指的是该区块在区块链上的序号
- ParentHash 属性指的是父区块的散列值
- ReceiptsRoot stateRoot TransactionsRoot 属性指的是在前 面的挖矿流程中提到的 merkle树
- Transactions 属性指的是区块中的交易组成的 个数组
- TotalDifficulty 属性指的是区块链的整体难度
3.7存储,内存和栈
以太坊虚拟机有三个区域来存储数据:存储(storage),内存(memory)和栈(stack)
- 存储:每个账户有一块持久化内存区称为存储。存储是将256位字映射到256位字的键值存储区。在合约中枚举存储是不可能的,且读存储的是相对开销很高,修改存储的开销甚至更高。合约智能读写存储区内属于自己的部分。
- 内存:合约会试图为每一次信息调用获取一块被重新擦拭干净的内存实例。内存是线性的,可按字节级寻址,但读的长度被限制为256位,而写的长度可以是8位或者256位。当访问(无论是读还是写)之前访问过的内存字(word)时(无论是便宜到该字内的任何位置),内存将按字进行扩展(每个字是256位)。扩容也将消耗一定的gas。随着内存使用量的增长,其费用也会增高(以平方级别)。
- 栈:所有计算都在一个被称为栈(stack)的区域执行。栈最大1024个元素,每个元素长度是一个字(256位)。对栈的访问只限于其顶端
二.智能合约的介绍
1.合约
合约是经过双方或多方同意,约定立即执行或在将来执行一项交易的法律文件。因为合约是法律文件,所以它具有强制性和可执行性。合约应用的场景很多,例如:一个人和保险公司签订合同购买健康险,一个人从另外一个人手里购买一块土地,个公司出售股权给另外一家公司
2.智能合约
具体详情请点击上面智能合约的链接
智能合约只是一个运行在以太坊链上的一个程序。 它是位于以太坊区块链上一个特定地址的一系列代码(函数)和数据(状态)。
智能合约也是一个以太坊帐户,我们称之为合约帐户。 这意味着它们有余额,可以成为交易的对象。 但是,他们无法被人操控,他们是被部署在网络上作为程序运行着。 个人用户可以通过提交交易执行智能合约的某一个函数来与智能合约进行交互。 智能合约能像常规合约一样定义规则,并通过代码自动强制执行。 默认情况下,您无法删除智能合约,与它们的交互是不可逆的。
3.编写智能合约
编写智能合约的工具:Visual Studio。
Remix 打开 http:// remix. ethereum. org 网页就可以 直接使用。可以在浏览 器上进行智能合约的创建、开发、部署和调试 合约维护有关的操作(如 :创 建、发布、调试)都可以在同一个环境下完成,而不需要切换到其他的窗口或 页面。
4.Remix的具体使用
- 打开 remix.ethereum.org 网址,在浏览器中默认打开一个智能合约
2.新建一个合约,选择左边菜单栏中的+.对这个 Sol iy 文件进行命名,以 sol 作为后缀 输入合约名字 Hello orld ,点击“ OK ”,就创建了 白合约,
3.在制作 内的空白处,输入下面这段代码,就能创建你的第一个合约.
你可以使用关键词contract 创建合约,声明全局状态变量和函数,保存合约为后缀名.是 sol的文件。在下面的源代码片段中,当 Get elloWorld 数调 HelloWorld合约时,将返回 Hello World 字符。其中确保版本号与开头pragma solidity ^0.8.24版本号相同。
// SPDX-License-Identifier: MIT pragma solidity ^0.8.24; contract HelloWeb3{ string public _string = "Hello Web3!"; }
我们拆解程序,学习 Solidity 代码源文件的结构:
第 1 行是注释,说明代码所使用的软件许可(license),这里使用的是 MIT 许可。如果不写许可,编译时会出现警告(warning),但程序仍可运行。Solidity 注释以“//”开头,后面跟注释内容,注释不会被程序执行。
// SPDX-License-Identifier: MIT
第 2 行声明源文件所使用的 Solidity 版本,因为不同版本的语法有差异。这行代码表示源文件将不允许小于 0.8.4 版本或大于等于 0.9.0 的编译器编译(第二个条件由
^
提供)。Solidity 语句以分号(;)结尾。pragma solidity ^0.8.4;
第 3-4 行是合约部分。第 3 行创建合约(contract),并声明合约名为
HelloWeb3
。第 4 行是合约内容,声明了一个 string(字符串)变量_string
,并赋值为 "Hello Web3!"。
contract HelloWeb3 { string public _string = "Hello Web3!"; }
5.编译与部署智能合约
在 Remix 编辑代码的页面,按 Ctrl + S 即可编译代码,非常方便。
编译完成后,点击左侧菜单的“部署”按钮,进入部署页面。
默认情况下,Remix
会使用 Remix
虚拟机(以前称为 JavaScript 虚拟机)来模拟以太坊链,运行智能合约,类似在浏览器里运行一条测试链。Remix
还会为你分配一些测试账户,每个账户里有 100 ETH(测试代币),随意使用。点击 Deploy
(黄色按钮),即可部署我们编写的合约。
部署成功后,在下方会看到名为 HelloWeb3
的合约。点击 _string
,即可看到 "Hello Web3!"。
三.Solidity中的变量类型
值类型(Value Type):包括布尔型,整数型等等,这类变量赋值时候直接传递数值。
引用类型(Reference Type):包括数组和结构体,这类变量占空间大,赋值时候直接传递地址(类似指针)。
映射类型(Mapping Type): Solidity中存储键值对的数据结构,可以理解为哈希表
1.值类型
1.1布尔型
布尔型是二值变量,取值为 true
或 false
。
// 布尔值 bool public _bool = true;
布尔值的运算符包括:
!
(逻辑非)&&
(逻辑与,"and")||
(逻辑或,"or")==
(等于)!=
(不等于)-
// SPDX-License-Identifier: MIT pragma solidity ^0.8.24; contract HelloWeb3{ string public _string = "Hello Web3!"; bool public _bool = true; bool public _bool1 = !_bool; // 取非 bool public _bool2 = _bool && _bool1; // 与 bool public _bool3 = _bool || _bool1; // 或 bool public _bool4 = _bool == _bool1; // 相等 bool public _bool5 = _bool != _bool1; // 不相等 }
在上述代码中:变量
_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)
的结果是相反的。假如存在f(x) && g(y)
的表达式,如果f(x)
是false
,g(y)
不会被计算。所谓“短路规则”,一般出现在逻辑与(&&)和逻辑或(||)中。 当逻辑或(&&)的第一个条件为false时,就不会再去判断第二个条件; 当逻辑与(||)的第一个条件为true时,就不会再去判断第二个条件,这就是短路规则。
1.2整型
整型是 Solidity 中的整数,最常用的包括:
// 整型 int public _int = -1; // 整数,包括负数 uint public _uint = 1; // 正整数 uint256 public _number = 20220330; // 256位正整数
常用的整型运算符包括:
比较运算符(返回布尔值):
<=
,<
,==
,!=
,>=
,>
算数运算符:
+
,-
,*
,/
,%
(取余),**
(幂)
// 整数运算 uint256 public _number1 = _number + 1; // +,-,*,/ uint256 public _number2 = 2**2; // 指数 uint256 public _number3 = 7 % 2; // 取余数 bool public _numberbool = _number2 > _number3; // 比大小
1.3地址类型
地址类型(address)有两类:
普通地址(address): 存储一个 20 字节的值(以太坊地址的大小)。
payable address: 比普通地址多了
transfer
和send
两个成员方法,用于接收转账。
// 地址 address public _address = 0x7A58c0Be72BE218B41C608b7Fe7C5bB630736C71; address payable public _address1 = payable(_address); // payable address,可以转账、查余额 // 地址类型的成员 uint256 public balance = _address1.balance; // balance of address
1.4定长字节数组
字节数组分为定长和不定长两种:
定长字节数组: 属于值类型,数组长度在声明之后不能改变。根据字节数组的长度分为
bytes1
,bytes8
,bytes32
等类型。定长字节数组最多存储 32 bytes 数据,即bytes32
。不定长字节数组: 属于引用类型(之后的章节介绍),数组长度在声明之后可以改变,包括
bytes
等。
// 固定长度的字节数组 bytes32 public _byte32 = "MiniSolidity"; bytes1 public _byte = _byte32[0];
在上述代码中,MiniSolidity
变量以字节的方式存储进变量 _byte32
。如果把它转换成 16 进制
,就是:0x4d696e69536f6c69646974790000000000000000000000000000000000000000
_byte
变量的值为 _byte32
的第一个字节,即 0x4d
。
1.5枚举enum
枚举(enum
)是 Solidity 中用户定义的数据类型。它主要用于为 uint
分配名称,使程序易于阅读和维护。它与 C 语言
中的 enum
类似,使用名称来代替从 0
开始的 uint
:
// 用enum将uint 0, 1, 2表示为Buy, Hold, Sell enum ActionSet { Buy, Hold, Sell } // 创建enum变量 action ActionSet action = ActionSet.Buy;
枚举可以显式地和 uint
相互转换,并会检查转换的正整数是否在枚举的长度内,否则会报错:
// enum可以和uint显式的转换 function enumToUint() external view returns(uint){ return uint(action); }
enum
是一个比较冷门的变量,几乎没什么人用。
2.函数
Solidity语言的函数非常灵活,可以进行各种复杂操作。在本教程中,我们将会概述函数的基础概念,并通过一些示例演示如何使用函数。
我们先看一下 Solidity 中函数的形式:
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。pure
和view
的介绍见下一节。[returns ()]
:函数返回的变量类型和名称。
2.1 Pure
和View讲解
刚开始学习 solidity
时,pure
和 view
关键字可能令人费解,因为其他编程语言中没有类似的关键字。solidity
引入这两个关键字主要是因为 以太坊交易需要支付气费(gas fee)。合约的状态变量存储在链上,gas fee 很贵,如果计算不改变链上状态,就可以不用付 gas
。包含 pure
和 view
关键字的函数是不改写链上状态的,因此用户直接调用它们是不需要付 gas 的(注意,合约中非 pure
/view
函数调用 pure
/view
函数时需要付gas)。
在以太坊中,以下语句被视为修改链上状态:
写入状态变量。
释放事件。
创建其他合约。
使用
selfdestruct
.通过调用发送以太币。
调用任何未标记
view
或pure
的函数。使用低级调用(low-level calls)。
使用包含某些操作码的内联汇编。
为了帮助大家理解,我画了一个马里奥插图。在这幅插图中,我将合约中的状态变量(存储在链上)比作碧琪公主,三种不同的角色代表不同的关键字。
pure
,中文意思是“纯”,这里可以理解为”纯打酱油的”。pure
函数既不能读取也不能写入链上的状态变量。就像小怪一样,看不到也摸不到碧琪公主。view
,“看”,这里可以理解为“看客”。view
函数能读取但也不能写入状态变量。类似马里奥,能看到碧琪公主,但终究是看客,不能入洞房。非
pure
或view
的函数既可以读取也可以写入状态变量。类似马里奥里的boss
,可以对碧琪公主为所欲为🐶。
2.2代码解析
1. pure 和 view
我们在合约里定义一个状态变量 number
,初始化为 5。
// SPDX-License-Identifier: MIT pragma solidity ^0.8.4; contract FunctionTypes{ uint256 public number = 5; }
定义一个 add()
函数,每次调用会让 number
增加 1。
// 默认function function add() external{ number = number + 1; }
如果 add()
函数被标记为 pure
,比如 function add() external pure
,就会报错。因为 pure
是不配读取合约里的状态变量的,更不配改写。那 pure
函数能做些什么?举个例子,你可以给函数传递一个参数 _number
,然后让他返回 _number + 1
,这个操作不会读取或写入状态变量。
// pure: 纯纯牛马 function addPure(uint256 _number) external pure returns(uint256 new_number){ new_number = _number + 1; }
如果 add()
函数被标记为 view
,比如 function add() external view
,也会报错。因为 view
能读取,但不能够改写状态变量。我们可以稍微改写下函数,读取但是不改写 number
,返回一个新的变量。
// view: 看客 function addView() external view returns(uint256 new_number) { new_number = number + 1; }
2. internal v.s. external
// internal: 内部函数 function minus() internal { number = number - 1; } // 合约内的函数可以调用内部函数 function minusCall() external { minus(); }
我们定义一个 internal
的 minus()
函数,每次调用使得 number
变量减少 1。由于 internal
函数只能由合约内部调用,我们必须再定义一个 external
的 minusCall()
函数,通过它间接调用内部的 minus()
函数。
3. payable
// payable: 递钱,能给合约支付eth的函数 function minusPayable() external payable returns(uint256 balance) { minus(); balance = address(this).balance; }
我们定义一个 external payable
的 minusPayable()
函数,间接的调用 minus()
,并且返回合约里的 ETH 余额(this
关键字可以让我们引用合约地址)。我们可以在调用 minusPayable()
时往合约里转入1个 ETH。
我们可以在返回的信息中看到,合约的余额变为 1 ETH。
我们介绍了 Solidity
中的函数。pure
和 view
关键字比较难理解,在其他语言中没出现过:view
函数可以读取状态变量,但不能改写;pure
函数既不能读取也不能改写状态变量。
3.函数输出
这一讲,我们将介绍 Solidity 函数输出,包括:返回多种变量,命名式返回,以及利用解构式赋值读取全部或部分返回值。
3.1返回值:return 和 returns
Solidity 中与函数输出相关的有两个关键字:return
和returns
。它们的区别在于:
returns
:跟在函数名后面,用于声明返回的变量类型及变量名。return
:用于函数主体中,返回指定的变量。
// 返回多个变量 function returnMultiple() public pure returns(uint256, bool, uint256[3] memory){ return(1, true, [uint256(1),2,5]); }
在上述代码中,我们利用 returns
关键字声明了有多个返回值的 returnMultiple()
函数,然后我们在函数主体中使用 return(1, true, [uint256(1),2,5])
确定了返回值。
这里uint256[3]
声明了一个长度为3
且类型为uint256
的数组作为返回值。因为[1,2,3]
会默认为uint8(3)
,因此[uint256(1),2,5]
中首个元素必须强转uint256
来声明该数组内的元素皆为此类型。数组类型返回值默认必须用memory修饰,在下一个章节会细说变量的存储和作用域。
3.2命名式返回
我们可以在 returns
中标明返回变量的名称。Solidity 会初始化这些变量,并且自动返回这些函数的值,无需使用 return
。
// 命名式返回 function returnNamed() public pure returns(uint256 _number, bool _bool, uint256[3] memory _array){ _number = 2; _bool = false; _array = [uint256(3),2,1]; }
在上述代码中,我们用 returns(uint256 _number, bool _bool, uint256[3] memory _array)
声明了返回变量类型以及变量名。这样,在主体中只需为变量 _number
、_bool
和_array
赋值,即可自动返回。
当然,你也可以在命名式返回中用 return
来返回变量:
// 命名式返回,依然支持return function returnNamed2() public pure returns(uint256 _number, bool _bool, uint256[3] memory _array){ return(1, true, [uint256(1),2,5]); }
3.3解构式赋值
Solidity 支持使用解构式赋值规则来读取函数的全部或部分返回值。
读取所有返回值:声明变量,然后将要赋值的变量用
,
隔开,按顺序排列。uint256 _number; bool _bool; uint256[3] memory _array; (_number, _bool, _array) = returnNamed();
读取部分返回值:声明要读取的返回值对应的变量,不读取的留空。在下面的代码中,我们只读取
_bool
,而不读取返回的_number
和_array
:(, _bool2, ) = returnNamed();
3.4在 Remix 上运行
部署合约后查看三种返回方式的结果
3.5总结
这一讲,我们介绍 Solidity 函数返回值,包括:返回多种变量,命名式返回,以及利用解构式赋值读取全部或部分返回值。这些知识点有助于我们在编写智能合约时,更灵活地处理函数返回值。
4.变量数据存储和作用域
4.1Solidity中的引用类型
引用类型(Reference Type):包括数组(array
)和结构体(struct
),由于这类变量比较复杂,占用存储空间大,我们在使用时必须要声明数据存储的位置。
uint256 256uint256[10] 256*10 = 2560
4.2数据位置
Solidity数据存储位置有三类:storage
,memory
和calldata
。不同存储位置的gas
成本不同。storage
类型的数据存在链上,类似计算机的硬盘,消耗gas
多;memory
和calldata
类型的临时存在内存里,消耗gas
少。大致用法:
storage
:合约里的状态变量默认都是storage
,存储在链上。memory
:函数里的参数和临时变量一般用memory
,存储在内存中,不上链。尤其是如果返回数据类型是变长的情况下,必须加memory修饰,例如:string, bytes, array和自定义结构。calldata
:和memory
类似,存储在内存中,不上链。与memory
的不同点在于calldata
变量不能修改(immutable
),一般用于函数的参数。例子:
function fCalldata(uint[] calldata _x) public pure returns(uint[] calldata){ //参数为calldata数组,不能被修改 // _x[0] = 0 //这样修改会报错 return(_x); }
Example:
4.3数据位置和赋值规则
在不同存储类型相互赋值时候,有时会产生独立的副本(修改新变量不会影响原变量),有时会产生引用(修改新变量会影响原变量)。规则如下:
赋值本质上是创建引用指向本体,因此修改本体或者是引用,变化可以被同步:
storage
(合约的状态变量)赋值给本地storage
(函数里的)时候,会创建引用,改变新变量会影响原变量。例子:uint[] x = [1,2,3]; // 状态变量:数组 x function fStorage() public{ //声明一个storage的变量 xStorage,指向x。修改xStorage也会影响x uint[] storage xStorage = x; xStorage[0] = 100; }
Example:
memory
赋值给memory
,会创建引用,改变新变量会影响原变量。
其他情况下,赋值创建的是本体的副本,即对二者之一的修改,并不会同步到另一方
4.4变量的作用域
Solidity
中变量按作用域划分有三种,分别是状态变量(state variable),局部变量(local variable)和全局变量(global variable)
1. 状态变量
状态变量是数据存储在链上的变量,所有合约内函数都可以访问,gas
消耗高。状态变量在合约内、函数外声明:
contract Variables { uint public x = 1; uint public y; string public z; }
我们可以在函数里更改状态变量的值:
function foo() external{ // 可以在函数里更改状态变量的值 x = 5; y = 2; z = "0xAA"; }
2. 局部变量
局部变量是仅在函数执行过程中有效的变量,函数退出后,变量无效。局部变量的数据存储在内存里,不上链,gas
低。局部变量在函数内声明:
function bar() external pure returns(uint){ uint xx = 1; uint yy = 3; uint zz = xx + yy; return(zz); }
3. 全局变量
全局变量是全局范围工作的变量,都是solidity
预留关键字。他们可以在函数内不声明直接使用:
function global() external view returns(address, uint, bytes memory){ address sender = msg.sender; uint blockNum = block.number; bytes memory data = msg.data; return(sender, blockNum, data); }
在上面例子里,我们使用了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
值
Example:
4. 全局变量-以太单位与时间单位
以太单位
Solidity
中不存在小数点,以0
代替为小数点,来确保交易的精确度,并且防止精度的损失,利用以太单位可以避免误算的问题,方便程序员在合约中处理货币交易。
wei
: 1gwei
: 1e9 = 1000000000ether
: 1e18 = 1000000000000000000
function weiUnit() external pure returns(uint) { assert(1 wei == 1e0); assert(1 wei == 1); return 1 wei; } function gweiUnit() external pure returns(uint) { assert(1 gwei == 1e9); assert(1 gwei == 1000000000); return 1 gwei; } function etherUnit() external pure returns(uint) { assert(1 ether == 1e18); assert(1 ether == 1000000000000000000); return 1 ether; }
Example:
时间单位
可以在合约中规定一个操作必须在一周内完成,或者某个事件在一个月后发生。这样就能让合约的执行可以更加精确,不会因为技术上的误差而影响合约的结果。因此,时间单位在Solidity
中是一个重要的概念,有助于提高合约的可读性和可维护性。
seconds
: 1minutes
: 60 seconds = 60hours
: 60 minutes = 3600days
: 24 hours = 86400weeks
: 7 days = 604800
function secondsUnit() external pure returns(uint) { assert(1 seconds == 1); return 1 seconds; } function minutesUnit() external pure returns(uint) { assert(1 minutes == 60); assert(1 minutes == 60 seconds); return 1 minutes; } function hoursUnit() external pure returns(uint) { assert(1 hours == 3600); assert(1 hours == 60 minutes); return 1 hours; } function daysUnit() external pure returns(uint) { assert(1 days == 86400); assert(1 days == 24 hours); return 1 days; } function weeksUnit() external pure returns(uint) { assert(1 weeks == 604800); assert(1 weeks == 7 days); return 1 weeks; }
Example:
4.5总结
在这一讲,我们介绍了Solidity
中的引用类型,数据位置和变量的作用域。重点是storage
, memory
和calldata
三个关键字的用法。他们出现的原因是为了节省链上有限的存储空间和降低gas
。下一讲我们会介绍引用类型中的数组。
5.引用类型, array, struct
这一讲,我们将介绍Solidity
中的两个重要变量类型:数组(array
)和结构体(struct
)。
5.1数组 array
数组(Array
)是Solidity
常用的一种变量类型,用来存储一组数据(整数,字节,地址等等)。数组分为固定长度数组和可变长度数组两种:
固定长度数组:在声明时指定数组的长度。用
T[k]
的格式声明,其中T
是元素的类型,k
是长度,例如:// 固定长度 Array uint[8] array1; bytes1[5] array2; address[100] array3;
可变长度数组(动态数组):在声明时不指定数组的长度。用
T[]
的格式声明,其中T
是元素的类型,例如:// 可变长度 Array uint[] array4; bytes1[] array5; address[] array6; bytes array7;
注意:
bytes
比较特殊,是数组,但是不用加[]
。另外,不能用byte[]
声明单字节数组,可以使用bytes
或bytes1[]
。bytes
比bytes1[]
省gas。
5.2创建数组的规则
在Solidity里,创建数组有一些规则:
对于
memory
修饰的动态数组
,可以用new
操作符来创建,但是必须声明长度,并且声明后长度不能改变。例子:-
// memory动态数组 uint[] memory array8 = new uint[](5); bytes memory array9 = new bytes(9);
数组字面常数(Array Literals)是写作表达式形式的数组,用方括号包着来初始化array的一种方式,并且里面每一个元素的type是以第一个元素为准的,例如
[1,2,3]
里面所有的元素都是uint8
类型,因为在Solidity中,如果一个值没有指定type的话,默认就是最小单位的该type,这里uint
的默认最小单位类型就是uint8
。而[uint(1),2,3]
里面的元素都是uint
类型,因为第一个元素指定了是uint
类型了,我们都以第一个元素为准。下面的例子中,如果没有对传入
g()
函数的数组进行uint
转换,是会报错的。// SPDX-License-Identifier: GPL-3.0 pragma solidity >=0.4.16 <0.9.0; contract C { function f() public pure { g([uint(1), 2, 3]); } function g(uint[3] memory _data) public pure { // ... } }
如果创建的是动态数组,你需要一个一个元素的赋值。
uint[] memory x = new uint[](3); x[0] = 1; x[1] = 3; x[2] = 4;
5.3数组成员
length
: 数组有一个包含元素数量的length
成员,memory
数组的长度在创建后是固定的。push()
:动态数组
拥有push()
成员,可以在数组最后添加一个0
元素,并返回该元素的引用。push(x)
:动态数组
拥有push(x)
成员,可以在数组最后添加一个x
元素。pop()
:动态数组
拥有pop()
成员,可以移除数组最后一个元素。
Example:
5.4结构体 struct
Solidity
支持通过构造结构体的形式定义新的类型。结构体中的元素可以是原始类型,也可以是引用类型;结构体可以作为数组或映射的元素。创建结构体的方法:
// 结构体 struct Student{ uint256 id; uint256 score; string str; address adr; } Student student; // 初始一个student结构体
给结构体赋值的四种方法:
// 给结构体赋值 // 方法1:在函数中创建一个storage的struct引用 function initStudent1() external{ Student storage _student = student; // assign a copy of student _student.id = 11; _student.score = 100; }
Example:
// 方法2:直接引用状态变量的struct function initStudent2() external{ student.id = 1; student.score = 80; }
Example:
// 方法3:构造函数式 function initStudent3() external { student = Student(3, 90, "hello world", 0xfg16); }
Example:
// 方法4:key value function initStudent4() external { student = Student({id: 4, score: 60, str: "Hello World", adr: 0xc3a8}); }
Example:
5.5总结
这一讲,我们介绍了Solidity中数组(array
)和结构体(struct
)的基本用法。下一讲我们将介绍Solidity中的哈希表——映射(mapping
)。
6.映射类型 mapping
这一讲,我们将介绍映射(Mapping
)类型,Solidity中存储键值对的数据结构,可以理解为哈希表。
6.1映射Mapping
在映射中,人们可以通过键(Key
)来查询对应的值(Value
),比如:通过一个人的id
来查询他的钱包地址。
声明映射的格式为mapping(_KeyType => _ValueType)
,其中_KeyType
和_ValueType
分别是Key
和Value
的变量类型。例子:
mapping(uint => address) public idToAddress; // id映射到地址 mapping(address => address) public swapPair; // 币对的映射,地址到地址
6.2映射的规则
规则1:映射的
_KeyType
只能选择Solidity内置的值类型,比如uint
,address
等,不能用自定义的结构体。而_ValueType
可以使用自定义的类型。下面这个例子会报错,因为_KeyType
使用了我们自定义的结构体:// 我们定义一个结构体 Struct struct Student{ uint256 id; uint256 score; } mapping(Student => uint) public testVar;
规则2:映射的存储位置必须是
storage
,因此可以用于合约的状态变量,函数中的storage
变量和library函数的参数(见例子)。不能用于public
函数的参数或返回结果中,因为mapping
记录的是一种关系 (key - value pair)。规则3:如果映射声明为
public
,那么Solidity会自动给你创建一个getter
函数,可以通过Key
来查询对应的Value
。规则4:给映射新增的键值对的语法为
_Var[_Key] = _Value
,其中_Var
是映射变量名,_Key
和_Value
对应新增的键值对。例子:function writeMap (uint _Key, address _Value) public{ idToAddress[_Key] = _Value; }
6.3映射的原理
原理1: 映射不储存任何键(
Key
)的资讯,也没有length的资讯。原理2: 映射使用
keccak256(abi.encodePacked(key, slot))
当成offset存取value,其中slot
是映射变量定义所在的插槽位置。原理3: 因为Ethereum会定义所有未使用的空间为0,所以未赋值(
Value
)的键(Key
)初始值都是各个type的默认值,如uint的默认值是0。
6.4在Remix上验证 (以 Mapping.sol
为例)
映射示例 1 部署
映射示例 2 初始值
映射示例 3 key-value pair
6.5总结
这一讲,我们介绍了Solidity中哈希表——映射(Mapping
)的用法。至此,我们已经学习了所有常用变量种类,之后我们会学习控制流if-else
,while
等。
7.变量初始值
在Solidity
中,声明但没赋值的变量都有它的初始值或默认值。这一讲,我们将介绍常用变量的初始值。
7.1值类型初始值
boolean
:false
string
:""
int
:0
uint
:0
enum
: 枚举中的第一个元素address
:0x0000000000000000000000000000000000000000
(或address(0)
)function
internal
: 空白函数external
: 空白函数
可以用public
变量的getter
函数验证上面写的初始值是否正确:
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; // 第1个内容Buy的索引0 function fi() internal{} // internal空白函数 function fe() external{} // external空白函数
7.2引用类型初始值
映射
mapping
: 所有元素都为其默认值的mapping
结构体
struct
: 所有成员设为其默认值的结构体数组
array
动态数组:
[]
静态数组(定长): 所有成员设为其默认值的静态数组
可以用public
变量的getter
函数验证上面写的初始值是否正确:
// Reference Types uint[8] public _staticArray; // 所有成员设为其默认值的静态数组[0,0,0,0,0,0,0,0] uint[] public _dynamicArray; // `[]` mapping(uint => address) public _mapping; // 所有元素都为其默认值的mapping // 所有成员设为其默认值的结构体 0, 0 struct Student{ uint256 id; uint256 score; } Student public student;
7.3delete
操作符
delete a
会让变量a
的值变为初始值。
// delete操作符 bool public _bool2 = true; function d() external { delete _bool2; // delete 会让_bool2变为默认值,false }
7.4在remix上验证
部署合约查看值类型、引用类型的初始值
值类型、引用类型
delete
操作后的默认值
7.5总结
这一讲,我们介绍了Solidity
中变量的初始值。变量被声明但没有赋值的时候,它的值默认为初始值。不同类型的变量初始值不同,delete
操作符可以删除一个变量的值并代替为初始值。
8.常数 constant和immutable
这一讲,我们介绍Solidity中和常量相关的两个关键字,constant
(常量)和immutable
(不变量)。状态变量声明这两个关键字之后,不能在初始化后更改数值。这样做的好处是提升合约的安全性并节省gas
。
另外,只有数值变量可以声明constant
和immutable
;string
和bytes
可以声明为constant
,但不能为immutable
。
8.1constant和immutable
constant
constant
变量必须在声明的时候初始化,之后再也不能改变。尝试改变的话,编译不通过。
// constant变量必须在声明的时候初始化,之后不能改变 uint256 constant CONSTANT_NUM = 10; string constant CONSTANT_STRING = "0xAA"; bytes constant CONSTANT_BYTES = "WTF"; address constant CONSTANT_ADDRESS = 0x0000000000000000000000000000000000000000;
immutable
immutable
变量可以在声明时或构造函数中初始化,因此更加灵活。
// immutable变量可以在constructor里初始化,之后不能改变 uint256 public immutable IMMUTABLE_NUM = 9999999999; address public immutable IMMUTABLE_ADDRESS; uint256 public immutable IMMUTABLE_BLOCK; uint256 public immutable IMMUTABLE_TEST;
你可以使用全局变量例如address(this)
,block.number
或者自定义的函数给immutable
变量初始化。在下面这个例子,我们利用了test()
函数给IMMUTABLE_TEST
初始化为9
:
// 利用constructor初始化immutable变量,因此可以利用 constructor(){ IMMUTABLE_ADDRESS = address(this); IMMUTABLE_BLOCK = block.number; IMMUTABLE_TEST = test(); } function test() public pure returns(uint256){ uint256 what = 9; return(what); }
8.2在remix上验证
部署好合约之后,通过remix上的
getter
函数,能获取到constant
和immutable
变量初始化好的值。constant
变量初始化之后,尝试改变它的值,会编译不通过并抛出TypeError: Cannot assign to a constant variable.
的错误。immutable
变量初始化之后,尝试改变它的值,会编译不通过并抛出TypeError: Immutable state variable already initialized.
的错误。
8.3总结
这一讲,我们介绍了Solidity中两个关键字,constant
(常量)和immutable
(不变量),让不应该变的变量保持不变。这样的做法能在节省gas
的同时提升合约的安全性。
9.控制流,用Solidity实现插入排序
这一讲,我们将介绍Solidity
中的控制流,然后讲如何用Solidity
实现插入排序(InsertionSort
),一个看起来简单,但实际上很容易写出bug
的程序。
9.1控制流
Solidity
的控制流与其他语言类似,主要包含以下几种:
if-else
function ifElseTest(uint256 _number) public pure returns(bool){ if(_number == 0){ return(true); }else{ return(false); } }
for循环
function forLoopTest() public pure returns(uint256){ uint sum = 0; for(uint i = 0; i < 10; i++){ sum += i; } return(sum); }
while循环
function whileTest() public pure returns(uint256){ uint sum = 0; uint i = 0; while(i < 10){ sum += i; i++; } return(sum); }
do-while循环
function doWhileTest() public pure returns(uint256){ uint sum = 0; uint i = 0; do{ sum += i; i++; }while(i < 10); return(sum); }
三元运算符
三元运算符是
Solidity
中唯一一个接受三个操作数的运算符,规则条件? 条件为真的表达式:条件为假的表达式
。此运算符经常用作if
语句的快捷方式。// 三元运算符 ternary/conditional operator function ternaryTest(uint256 x, uint256 y) public pure returns(uint256){ // return the max of x and y return x >= y ? x: y; }
另外还有continue
(立即进入下一个循环)和break
(跳出当前循环)关键字可以使用。
9.2用Solidity
实现插入排序
写在前面:90%以上的人用Solidity
写插入算法都会出错。
9.3插入排序
排序算法解决的问题是将无序的一组数字,例如[2, 5, 3, 1]
,从小到大依次排列好。插入排序(InsertionSort
)是最简单的一种排序算法,也是很多人学习的第一个算法。它的思路很简单,从前往后,依次将每一个数和排在他前面的数字比大小,如果比前面的数字小,就互换位置。
9.4python
代码
我们可以先看一下插入排序的python代码:
# Python program for implementation of Insertion Sort def insertionSort(arr): for i in range(1, len(arr)): key = arr[i] j = i-1 while j >=0 and key < arr[j] : arr[j+1] = arr[j] j -= 1 arr[j+1] = key return arr
9.5改写成Solidity
后有BUG
一共8行python
代码就可以完成插入排序,非常简单。那么我们将它改写成Solidity
代码,将函数,变量,循环等等都做了相应的转换,只需要9行代码:
// 插入排序 错误版 function insertionSortWrong(uint[] memory a) public pure returns(uint[] memory) { for (uint i = 1;i < a.length;i++){ uint temp = a[i]; uint j=i-1; while( (j >= 0) && (temp < a[j])){ a[j+1] = a[j]; j--; } a[j+1] = temp; } return(a); }
那我们把改好的放到remix
上去跑,输入[2, 5, 3, 1]
。BOOM!有bug
!改了半天,没找到bug
在哪。我又去google
搜”solidity insertion sort”,然后发现网上用solidity
写的插入算法教程都是错的,比如:Sorting in Solidity without Comparison
Remix decoded output 出现错误内容
9.6正确的Solidity插入排序
花了几个小时,在Dapp-Learning
社群一个朋友的帮助下,终于找到了bug
所在。Solidity
中最常用的变量类型是uint
,也就是正整数,取到负值的话,会报underflow
错误。而在插入算法中,变量j
有可能会取到-1
,引起报错。
这里,我们需要把j
加1,让它无法取到负值。正确代码:
// 插入排序 正确版 function insertionSort(uint[] memory a) public pure returns(uint[] memory) { // note that uint can not take negative value for (uint i = 1;i < a.length;i++){ uint temp = a[i]; uint j=i; while( (j >= 1) && (temp < a[j-1])){ a[j] = a[j-1]; j--; } a[j] = temp; } return(a); }
运行后的结果:
!["输入[2,5,3,1] 输出[1,2,3,5]"](https://images.mirror-media.xyz/publication-images/S-i6rwCMeXoi8eNJ0fRdB.png?height=300&width=554)
9.7总结
这一讲,我们介绍了Solidity
中控制流,并且用Solidity
写了插入排序。看起来很简单,但实际很难。这就是Solidity
,坑很多,每个月都有项目因为这些小bug
损失几千万甚至上亿美元。掌握好基础,不断练习,才能写出更好的Solidity
代码。
10.构造函数和修饰器
这一讲,我们将用合约权限控制(Ownable
)的例子介绍Solidity
语言中构造函数(constructor
)和独有的修饰器(modifier
)。
10.1构造函数
构造函数(constructor
)是一种特殊的函数,每个合约可以定义一个,并在部署合约的时候自动运行一次。它可以用来初始化合约的一些参数,例如初始化合约的owner
地址:
address owner; // 定义owner变量 // 构造函数 constructor() { owner = msg.sender; // 在部署合约的时候,将owner设置为部署者的地址 }
注意⚠️:构造函数在不同的Solidity版本中的语法并不一致,在Solidity 0.4.22之前,构造函数不使用 constructor
而是使用与合约名同名的函数作为构造函数而使用,由于这种旧写法容易使开发者在书写时发生疏漏(例如合约名叫 Parents
,构造函数名写成 parents
),使得构造函数变成普通函数,引发漏洞,所以0.4.22版本及之后,采用了全新的 constructor
写法。
构造函数的旧写法代码示例: pragma solidity =0.4.21; contract Parents { // 与合约名Parents同名的函数就是构造函数 function Parents () public { } }
10.2修饰器
修饰器(modifier
)是Solidity
特有的语法,类似于面向对象编程中的装饰器(decorator
),声明函数拥有的特性,并减少代码冗余。它就像钢铁侠的智能盔甲,穿上它的函数会带有某些特定的行为。modifier
的主要使用场景是运行函数前的检查,例如地址,变量,余额等。
我们来定义一个叫做onlyOwner的modifier:
// 定义modifier modifier onlyOwner { require(msg.sender == owner); // 检查调用者是否为owner地址 _; // 如果是的话,继续运行函数主体;否则报错并revert交易 }
带有onlyOwner
修饰符的函数只能被owner
地址调用,比如下面这个例子:
function changeOwner(address _newOwner) external onlyOwner{ owner = _newOwner; // 只有owner地址运行这个函数,并改变owner }
我们定义了一个changeOwner
函数,运行它可以改变合约的owner
,但是由于onlyOwner
修饰符的存在,只有原先的owner
可以调用,别人调用就会报错。这也是最常用的控制智能合约权限的方法。
10.3OpenZeppelin的Ownable标准实现
OpenZeppelin
是一个维护Solidity
标准化代码库的组织,他的Ownable
标准实现如下:
http://E:\区块链智能合约开发\课件\0312\11_Modifier\Owner.sol
10.4Remix 演示示例
以 Owner.sol
为例。
在 Remix 上编译部署代码。
点击
owner
按钮查看当前 owner 变量。以 owner 地址的用户身份,调用
changeOwner
函数,交易成功。以非 owner 地址的用户身份,调用
changeOwner
函数,交易失败,因为modifier onlyOwner 的检查语句不满足。
10.5总结
这一讲,我们介绍了Solidity
中的构造函数和修饰符,并举了一个控制合约权限的Ownable
合约。
11.事件
这一讲,我们用转账ERC20代币为例来介绍Solidity
中的事件(event
)。
11.1事件
Solidity
中的事件(event
)是EVM
上日志的抽象,它具有两个特点:
响应:应用程序(ethers.js)可以通过
RPC
接口订阅和监听这些事件,并在前端做响应。经济:事件是
EVM
上比较经济的存储数据的方式,每个大概消耗2,000gas
;相比之下,链上存储一个新变量至少需要20,000gas
。
1).声明事件
事件的声明由event
关键字开头,接着是事件名称,括号里面写好事件需要记录的变量类型和变量名。以ERC20
代币合约的Transfer
事件为例:
event Transfer(address indexed from, address indexed to, uint256 value);
我们可以看到,Transfer
事件共记录了3个变量from
,to
和value
,分别对应代币的转账地址,接收地址和转账数量,其中from
和to
前面带有indexed
关键字,他们会保存在以太坊虚拟机日志的topics
中,方便之后检索。
2).释放事件
我们可以在函数里释放事件。在下面的例子中,每次用_transfer()
函数进行转账操作的时候,都会释放Transfer
事件,并记录相应的变量。
// 定义_transfer函数,执行转账逻辑 function _transfer( address from, address to, uint256 amount ) external { _balances[from] = 10000000; // 给转账地址一些初始代币 _balances[from] -= amount; // from地址减去转账数量 _balances[to] += amount; // to地址加上转账数量 // 释放事件 emit Transfer(from, to, amount); }
11.2EVM日志 Log
以太坊虚拟机(EVM)用日志Log
来存储Solidity
事件,每条日志记录都包含主题topics
和数据data
两部分。
1).主题 topics
日志的第一部分是主题数组,用于描述事件,长度不能超过4
。它的第一个元素是事件的签名(哈希)。对于上面的Transfer
事件,它的事件哈希就是:
keccak256("Transfer(address,address,uint256)") //0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef
除了事件哈希,主题还可以包含至多3
个indexed
参数,也就是Transfer
事件中的from
和to
。
indexed
标记的参数可以理解为检索事件的索引“键”,方便之后搜索。每个 indexed
参数的大小为固定的256比特,如果参数太大了(比如字符串),就会自动计算哈希存储在主题中。
2).数据 data
事件中不带 indexed
的参数会被存储在 data
部分中,可以理解为事件的“值”。data
部分的变量不能被直接检索,但可以存储任意大小的数据。因此一般 data
部分可以用来存储复杂的数据结构,例如数组和字符串等等,因为这些数据超过了256比特,即使存储在事件的 topics
部分中,也是以哈希的方式存储。另外,data
部分的变量在存储上消耗的gas相比于 topics
更少。
11.3Remix
演示
以 Event.sol
合约为例,编译部署。
然后调用 _transfer
函数。
点击右侧的交易查看详情,可以看到日志的具体内容。
在Etherscan上查询事件
我们尝试用_transfer()
函数在Rinkeby
测试网络上转账100代币,可以在Etherscan
上查询到相应的tx
:网址。
点击Logs
按钮,就能看到事件明细:
Topics
里面有三个元素,[0]
是这个事件的哈希,[1]
和[2]
是我们定义的两个indexed
变量的信息,即转账的转出地址和接收地址。Data
里面是剩下的不带indexed
的变量,也就是转账数量。
11.4总结
这一讲,我们介绍了如何使用和查询Solidity
中的事件。很多链上分析工具包括Nansen
和Dune Analysis
都是基于事件工作的。
12.继承
这一讲,我们介绍Solidity
中的继承(inheritance
),包括简单继承,多重继承,以及修饰器(Modifier
)和构造函数(Constructor
)的继承。
12.1继承
继承是面向对象编程很重要的组成部分,可以显著减少重复代码。如果把合约看作是对象的话,Solidity
也是面向对象的编程,也支持继承。
1).规则
virtual
: 父合约中的函数,如果希望子合约重写,需要加上virtual
关键字。override
:子合约重写了父合约中的函数,需要加上override
关键字。
注意:用override
修饰public
变量,会重写与变量同名的getter
函数,例如:
mapping(address => uint256) public override balanceOf;
2).简单继承
我们先写一个简单的爷爷合约Yeye
,里面包含1个Log
事件和3个function
: hip()
, pop()
, yeye()
,输出都是”Yeye”。
contract Yeye { event Log(string msg); // 定义3个function: hip(), pop(), man(),Log值为Yeye。 function hip() public virtual{ emit Log("Yeye"); } function pop() public virtual{ emit Log("Yeye"); } function yeye() public virtual { emit Log("Yeye"); } }
我们再定义一个爸爸合约Baba
,让他继承Yeye
合约,语法就是contract Baba is Yeye
,非常直观。在Baba
合约里,我们重写一下hip()
和pop()
这两个函数,加上override
关键字,并将他们的输出改为”Baba”
;并且加一个新的函数baba
,输出也是”Baba”
。
contract Baba is Yeye{ // 继承两个function: hip()和pop(),输出改为Baba。 function hip() public virtual override{ emit Log("Baba"); } function pop() public virtual override{ emit Log("Baba"); } function baba() public virtual{ emit Log("Baba"); } }
我们部署合约,可以看到Baba
合约里有4个函数,其中hip()
和pop()
的输出被成功改写成”Baba”
,而继承来的yeye()
的输出仍然是”Yeye”
。
3).多重继承
Solidity
的合约可以继承多个合约。规则:
继承时要按辈分最高到最低的顺序排。比如我们写一个
Erzi
合约,继承Yeye
合约和Baba
合约,那么就要写成contract Erzi is Yeye, Baba
,而不能写成contract Erzi is Baba, Yeye
,不然就会报错。如果某一个函数在多个继承的合约里都存在,比如例子中的
hip()
和pop()
,在子合约里必须重写,不然会报错。重写在多个父合约中都重名的函数时,
override
关键字后面要加上所有父合约名字,例如override(Yeye, Baba)
。
例子:
contract Erzi is Yeye, Baba{ // 继承两个function: hip()和pop(),输出值为Erzi。 function hip() public virtual override(Yeye, Baba){ emit Log("Erzi"); } function pop() public virtual override(Yeye, Baba) { emit Log("Erzi"); } }
我们可以看到,Erzi
合约里面重写了hip()
和pop()
两个函数,将输出改为”Erzi”
,并且还分别从Yeye
和Baba
合约继承了yeye()
和baba()
两个函数。
4).修饰器的继承
Solidity
中的修饰器(Modifier
)同样可以继承,用法与函数继承类似,在相应的地方加virtual
和override
关键字即可。
contract Base1 { modifier exactDividedBy2And3(uint _a) virtual { require(_a % 2 == 0 && _a % 3 == 0); _; } } contract Identifier is Base1 { //计算一个数分别被2除和被3除的值,但是传入的参数必须是2和3的倍数 function getExactDividedBy2And3(uint _dividend) public exactDividedBy2And3(_dividend) pure returns(uint, uint) { return getExactDividedBy2And3WithoutModifier(_dividend); } //计算一个数分别被2除和被3除的值 function getExactDividedBy2And3WithoutModifier(uint _dividend) public pure returns(uint, uint){ uint div2 = _dividend / 2; uint div3 = _dividend / 3; return (div2, div3); } }
Identifier
合约可以直接在代码中使用父合约中的exactDividedBy2And3
修饰器,也可以利用override
关键字重写修饰器:
modifier exactDividedBy2And3(uint _a) override { _; require(_a % 2 == 0 && _a % 3 == 0); }
5).构造函数的继承
子合约有两种方法继承父合约的构造函数。举个简单的例子,父合约A
里面有一个状态变量a
,并由构造函数的参数来确定:
// 构造函数的继承 abstract contract A { uint public a; constructor(uint _a) { a = _a; } }
在继承时声明父构造函数的参数,例如:
contract B is A(1)
在子合约的构造函数中声明构造函数的参数,例如:
contract C is A { constructor(uint _c) A(_c * _c) {} }
6).调用父合约的函数
子合约有两种方式调用父合约的函数,直接调用和利用super
关键字。
直接调用:子合约可以直接用
父合约名.函数名()
的方式来调用父合约函数,例如Yeye.pop()
function callParent() public{ Yeye.pop(); }
super
关键字:子合约可以利用super.函数名()
来调用最近的父合约函数。Solidity
继承关系按声明时从右到左的顺序是:contract Erzi is Yeye, Baba
,那么Baba
是最近的父合约,super.pop()
将调用Baba.pop()
而不是Yeye.pop()
:function callParentSuper() public{ // 将调用最近的父合约函数,Baba.pop() super.pop(); }
7).钻石继承
在面向对象编程中,钻石继承(菱形继承)指一个派生类同时有两个或两个以上的基类。
在多重+菱形继承链条上使用super
关键字时,需要注意的是使用super
会调用继承链条上的每一个合约的相关函数,而不是只调用最近的父合约。
我们先写一个合约God
,再写Adam
和Eve
两个合约继承God
合约,最后让创建合约people
继承自Adam
和Eve
,每个合约都有foo
和bar
两个函数。
// SPDX-License-Identifier: MIT pragma solidity ^0.8.13; /* 继承树: God / \ Adam Eve \ / people */ contract God { event Log(string message); function foo() public virtual { emit Log("God.foo called"); } function bar() public virtual { emit Log("God.bar called"); } } contract Adam is God { function foo() public virtual override { emit Log("Adam.foo called"); super.foo(); } function bar() public virtual override { emit Log("Adam.bar called"); super.bar(); } } contract Eve is God { function foo() public virtual override { emit Log("Eve.foo called"); super.foo(); } function bar() public virtual override { emit Log("Eve.bar called"); super.bar(); } } contract people is Adam, Eve { function foo() public override(Adam, Eve) { super.foo(); } function bar() public override(Adam, Eve) { super.bar(); } }
在这个例子中,调用合约people
中的super.bar()
会依次调用Eve
、Adam
,最后是God
合约。
虽然Eve
、Adam
都是God
的子合约,但整个过程中God
合约只会被调用一次。原因是Solidity
借鉴了Python的方式,强制一个由基类构成的DAG(有向无环图)使其保证一个特定的顺序。更多细节你可以查阅Solidity的官方文档。
12.2在Remix上验证
合约简单继承示例, 可以观察到Baba合约多了Yeye的函数
合约多重继承可以参考简单继承的操作步骤来增加部署Erzi合约,然后观察暴露的函数以及尝试调用来查看日志
修饰器继承示例
构造函数继承示例
调用父合约示例
菱形继承示例
12.3总结
这一讲,我们介绍了Solidity
继承的基本用法,包括简单继承,多重继承,修饰器和构造函数的继承、调用父合约中的函数,以及多重继承中的菱形继承问题。
13.抽象
这一讲,我们用ERC721
的接口合约为例介绍Solidity
中的抽象合约(abstract
)和接口(interface
),帮助大家更好的理解ERC721
标准。
13.1抽象合约
如果一个智能合约里至少有一个未实现的函数,即某个函数缺少主体{}
中的内容,则必须将该合约标为abstract
,不然编译会报错;另外,未实现的函数需要加virtual
,以便子合约重写。拿我们之前的插入排序合约为例,如果我们还没想好具体怎么实现插入排序函数,那么可以把合约标为abstract
,之后让别人补写上。
abstract contract InsertionSort{ function insertionSort(uint[] memory a) public pure virtual returns(uint[] memory); }
13.2接口
接口类似于抽象合约,但它不实现任何功能。接口的规则:
不能包含状态变量
不能包含构造函数
不能继承除接口外的其他合约
所有函数都必须是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都实现了这些函数。我们可以看到,接口和常规合约的区别在于每个函数都以;
代替函数体{ }
结尾。
interface IERC721 is IERC165 { event Transfer(address indexed from, address indexed to, uint256 indexed tokenId); event Approval(address indexed owner, address indexed approved, uint256 indexed tokenId); event ApprovalForAll(address indexed owner, address indexed operator, bool approved); function balanceOf(address owner) external view returns (uint256 balance); function ownerOf(uint256 tokenId) external view returns (address owner); function safeTransferFrom(address from, address to, uint256 tokenId) external; function transferFrom(address from, address to, uint256 tokenId) external; function approve(address to, uint256 tokenId) external; function getApproved(uint256 tokenId) external view returns (address operator); function setApprovalForAll(address operator, bool _approved) external; function isApprovedForAll(address owner, address operator) external view returns (bool); function safeTransferFrom( address from, address to, uint256 tokenId, bytes calldata data) external; }
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
。
contract interactBAYC { // 利用BAYC地址创建接口合约变量(ETH主网) IERC721 BAYC = IERC721(0xBC4CA0EdA7647A8aB7C2061c2E118A18a936f13D); // 通过接口调用BAYC的balanceOf()查询持仓量 function balanceOfBAYC(address owner) external view returns (uint256 balance){ return BAYC.balanceOf(owner); } // 通过接口调用BAYC的safeTransferFrom()安全转账 function safeTransferFromBAYC(address from, address to, uint256 tokenId) external{ BAYC.safeTransferFrom(from, to, tokenId); } }
13.3在Remix上验证
抽象合约示例(简单的演示代码如图所示)
接口示例(简单的演示代码如图所示)
13.4总结
这一讲,我介绍了Solidity
中的抽象合约(abstract
)和接口(interface
),他们都可以写模版并且减少代码冗余。我们还讲了ERC721
接口合约IERC721
,以及如何利用它与无聊猿BAYC
合约进行交互。
14.异常
这一讲,我们介绍Solidity
三种抛出异常的方法:error
,require
和assert
,并比较三种方法的gas
消耗。
14.1异常
写智能合约经常会出bug
,Solidity
中的异常命令帮助我们debug
。
1).Error
error
是solidity 0.8.4版本
新加的内容,方便且高效(省gas
)地向用户解释操作失败的原因,同时还可以在抛出异常的同时携带参数,帮助开发者更好地调试。人们可以在contract
之外定义异常。下面,我们定义一个TransferNotOwner
异常,当用户不是代币owner
的时候尝试转账,会抛出错误:
error TransferNotOwner(); // 自定义error
我们也可以定义一个携带参数的异常,来提示尝试转账的账户地址
error TransferNotOwner(address sender); // 自定义的带参数的error
在执行当中,error
必须搭配revert
(回退)命令使用。
function transferOwner1(uint256 tokenId, address newOwner) public { if(_owners[tokenId] != msg.sender){ revert TransferNotOwner(); // revert TransferNotOwner(msg.sender); } _owners[tokenId] = newOwner; }
我们定义了一个transferOwner1()
函数,它会检查代币的owner
是不是发起人,如果不是,就会抛出TransferNotOwner
异常;如果是的话,就会转账。
2).Require
require
命令是solidity 0.8版本
之前抛出异常的常用方法,目前很多主流合约仍然还在使用它。它很好用,唯一的缺点就是gas
随着描述异常的字符串长度增加,比error
命令要高。使用方法:require(检查条件,"异常的描述")
,当检查条件不成立的时候,就会抛出异常。
我们用require
命令重写一下上面的transferOwner1
函数:
function transferOwner2(uint256 tokenId, address newOwner) public { require(_owners[tokenId] == msg.sender, "Transfer Not Owner"); _owners[tokenId] = newOwner; }
4).Assert
assert
命令一般用于程序员写程序debug
,因为它不能解释抛出异常的原因(比require
少个字符串)。它的用法很简单,assert(检查条件)
,当检查条件不成立的时候,就会抛出异常。
我们用assert
命令重写一下上面的transferOwner1
函数:
function transferOwner3(uint256 tokenId, address newOwner) public { assert(_owners[tokenId] == msg.sender); _owners[tokenId] = newOwner; }
14.2在remix上验证
输入任意
uint256
数字和非0地址,调用transferOwner1
,也就是error
方法,控制台抛出了异常并显示我们自定义的TransferNotOwner
。输入任意
uint256
数字和非0地址,调用transferOwner2
,也就是require
方法,控制台抛出了异常并打印出require
中的字符串。输入任意
uint256
数字和非0地址,调用transferOwner3
,也就是assert
方法,控制台只抛出了异常。
14.3三种方法的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
全部消耗,不会返还。更多细节见官方文档。
14.4总结
这一讲,我们介绍Solidity
三种抛出异常的方法:error
,require
和assert
,并比较了三种方法的gas
消耗。结论:error
既可以告知用户抛出异常的原因,又能省gas
。