控制流
这一讲,我们将介绍Solidity中的控制流。
测试代码Control.sol,代码如下:
1 | // SPDX-License-Identifier: MIT |
注意⚠️:Solidity中最常用的变量类型是uint,也就是正整数,取到负值的话,会报underflow错误。
控制流
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(跳出当前循环)关键字可以使用。
构造函数和修饰器
这一讲,我们将用合约权限控制(Ownable)的例子介绍Solidity语言中构造函数(constructor)和独有的修饰器(modifier)。测试代码Owner.sol,如下所示:
1 | // SPDX-License-Identifier: MIT |
构造函数
构造函数(constructor)是一种特殊的函数,每个合约可以定义一个,并在部署合约的时候自动运行一次。它可以用来初始化合约的一些参数,例如初始化合约的owner地址。
1 | address owner; // 定义owner变量 |
注意:构造函数在不同的Solidity版本中的语法并不一致,在Solidity 0.4.22之前,构造函数不使用 constructor 而是使用与合约名同名的函数作为构造函数而使用,由于这种旧写法容易使开发者在书写时发生疏漏(例如合约名叫 Parents,构造函数名写成 parents),使得构造函数变成普通函数,引发漏洞,所以0.4.22版本及之后,采用了全新的 constructor 写法。
构造函数的旧写法代码示例:
1 |
|
修饰器
修饰器(modifier)是Solidity特有的语法,类似于面向对象编程中的装饰器(decorator),声明函数拥有的特性,并减少代码冗余。它就像钢铁侠的智能盔甲,穿上它的函数会带有某些特定的行为。modifier的主要使用场景是运行函数前的检查,例如地址,变量,余额等。
我们来定义一个叫做onlyOwner的modifier:
1 | modifier onlyOwner{ |
带有onlyOwner修饰符的函数只能被owner地址调用,比如下面这个例子:
1 | //定义一个带有onlyOwner修饰符的函数 |
我们定义了一个changeOwner函数,运行它可以改变合约的owner,但是由于onlyOwner修饰符的存在,只有原先的owner可以调用,别人调用就会报错。这也是最常用的控制智能合约权限的方法。
运行测试
首先是部署代码,传递一个需要初始化的owner,初始化owner为0x5B38Da6a701c568545dCfcB03FcB875f56beddC4

使用另一个用户0xAb8483F64d9C6d1EcF9b849Ae677dD3315835cb2,去调用合约,并尝试修改owner。owner修改失败!


尝试使用部署时输入的账户``0x5B38Da6a701c568545dCfcB03FcB875f56beddC4`。
去改变owner为0xAb8483F64d9C6d1EcF9b849Ae677dD3315835cb2,owner修改成功!


事件
这一讲,我们用转账ERC20代币为例来介绍Solidity中的事件(event)。
测试代码Events.sol,代码如下:
1 | // SPDX-License-Identifier: MIT |
事件
Solidity中的事件(event)是EVM上日志的抽象,它具有两个特点:
- 响应:应用程序(
ethers.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关键字,他们会保存在以太坊虚拟机日志的topics中,方便之后检索。
释放事件
我们可以在函数里释放事件。在下面的例子中,每次用_transfer()函数进行转账操作的时候,都会释放Transfer事件,并记录相应的变量。
1 | // 定义_transfer函数,执行转账逻辑 |
EVM日志(Log)
以太坊虚拟机(EVM)用日志Log来存储Solidity事件,每条日志记录都包含主题topics和数据data两部分。

主题(Topics)
日志的第一部分是主题数组,用于描述事件,长度不能超过4。它的第一个元素是事件的签名(哈希)。
对于上面的Transfer事件,它的事件哈希就是:
1 | keccak256("Transfer(address,address,uint256)") |
除了事件哈希,主题还可以包含至多3个indexed参数,也就是Transfer事件中的from和to。
indexed标记的参数可以理解为检索事件的索引“键”,方便之后搜索。每个 indexed 参数的大小为固定的256比特,如果参数太大了(比如字符串),就会自动计算哈希存储在主题中。
这里其实会引入一个新的问题,根据Solidity的官方文档, 对于非值类型的参数(如arrays, bytes, strings), Solidity不会直接存储,而是会将Keccak-256哈希存储在主题中,从而导致数据信息的丢失。这对于某些依赖于链上事件的DAPP(跨链,用户注册等等)来说,可能会导致事件检索困难,需要解析哈希值。
数据(Data)
事件中不带 indexed的参数会被存储在 data 部分中,可以理解为事件的"值"。data 部分的变量不能被直接检索,但可以存储任意大小的数据。因此一般 data 部分可以用来存储复杂的数据结构,例如数组和字符串等等,因为这些数据超过了256比特,即使存储在事件的 topics 部分中,也是以哈希的方式存储。另外,data 部分的变量在存储上消耗的gas相比于 topics 更少。

Topics里面有三个元素,第一个是这个事件的哈希,第二和三是我们定义的两个indexed变量的信息,即转账的转出地址和接收地址。Data里面是剩下的不带indexed的变量,也就是转账数量。可以在上图看到Data部分为0x32,50的16进制。
继承
这一讲,我们介绍Solidity中的继承(inheritance),包括简单继承,多重继承,以及修饰器(Modifier)和构造函数(Constructor)的继承。
继承
继承是面向对象编程很重要的组成部分,可以显著减少重复代码。如果把合约看作是对象的话,Solidity也是面向对象的编程,也支持继承。
规则
virtual: 父合约中的函数,如果希望子合约重写,需要加上virtual关键字。override:子合约重写了父合约中的函数,需要加上override关键字。
注意:用override修饰public变量,会重写与变量同名的getter函数,例如:
1 | mapping(address => uint256) public override balanceOf; |
简单继承
我们先写一个简单的爷爷合约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{ |
完整的测试代码Yeye.sol,如下所示:
1 | // SPDX-License-Identifier: MIT |

多重继承
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()两个函数。
完整的测试代码Yeye.sol,完整的代码如下:
1 | // SPDX-License-Identifier: MIT |

修饰器的继承
Solidity中的修饰器(Modifier)同样可以继承,用法与函数继承类似,在相应的地方加virtual和override关键字即可。
1 | contract Base1 { |
Identifier合约可以直接在代码中使用父合约中的exactDividedBy2And3修饰器,也可以利用override关键字重写修饰器:
1 | modifier exactDividedBy2And3(uint _a) override { |

完整的测试代码Base1.sol,代码如下:
1 | // SPDX-License-Identifier: MIT |
构造函数的继承
子合约有两种方法继承父合约的构造函数。举个简单的例子,父合约A里面有一个状态变量a,并由构造函数的参数来确定:
1 | // 构造函数的继承 |
- 在继承时声明父构造函数的参数,例如:
contract B is A(6) - 在子合约的构造函数中声明构造函数的参数,例如:
1 | contract C is A { |
完整的测试代码A.sol如下:
1 | // SPDX-License-Identifier: MIT |

调用父合约的函数
子合约有两种方式调用父合约的函数,直接调用和利用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{ |
钻石继承
在面向对象编程中,钻石继承(菱形继承)指一个派生类同时有两个或两个以上的基类。
在多重+菱形继承链条上使用super关键字时,需要注意的是使用super会调用继承链条上的每一个合约的相关函数,而不是只调用最近的父合约。
我们先写一个合约God,再写Adam和Eve两个合约继承God合约,最后让创建合约people继承自Adam和Eve,每个合约都有foo和bar两个函数。
1 | // SPDX-License-Identifier: MIT |
在这个例子中,调用合约people中的super.bar()会依次调用Eve、Adam,最后是God合约。
虽然Eve、Adam都是God的子合约,但整个过程中God合约只会被调用一次。原因是Solidity借鉴了Python的方式,强制一个由基类构成的DAG(有向无环图)使其保证一个特定的顺序。更多细节你可以查阅Solidity的官方文档。

抽象合约和接口
这一讲,我们用ERC721的接口合约为例介绍Solidity中的抽象合约(abstract)和接口(interface),帮助大家更好的理解ERC721标准。
ERC-721是以太坊网络上用于实现非同质化代币(NFT)的标准。它定义了一组规则,允许创建具有唯一性的数字资产或代币。与ERC-20标准的同质化代币不同,ERC-721代币每一个都是独一无二的,可以用来表示艺术作品、收藏品、游戏道具、房地产等独特的资产。
抽象合约
如果一个智能合约里至少有一个未实现的函数,即某个函数缺少主体{}中的内容,则必须将该合约标为abstract,不然编译会报错;另外,未实现的函数需要加virtual,以便子合约重写。如果我们还没想好具体怎么实现某个函数,那么可以把合约标为abstract,之后让别人补写上。
1 | abstract contract InsertionSort{ |
测试抽象合约的代码Abstract.sol,如下:
1 | // SPDX-License-Identifier: MIT |
测试结果如下:

接口
接口类似于抽象合约,但它不实现任何功能。接口的规则:
- 不能包含状态变量
- 不能包含构造函数
- 不能继承除接口外的其他合约
- 所有函数都必须是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 { |
测试接口的代码Interface.sol,如下:
1 | // SPDX-License-Identifier: MIT |

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.sol,如下:
1 | // SPDX-License-Identifier: MIT |
Error
error是solidity 0.8.4版本新加的内容,方便且高效(省gas)地向用户解释操作失败的原因,同时还可以在抛出异常的同时携带参数,帮助开发者更好地调试。人们可以在contract之外定义异常。下面,我们定义一个TransferNotOwner异常,当用户不是代币owner的时候尝试转账,会抛出错误:
1 | error TransferNotOwner(); // 自定义error |
我们也可以定义一个携带参数的异常,来提示尝试转账的账户地址
1 | error TransferNotOwner(address sender); // 自定义的带参数的error |
在执行当中,error必须搭配revert(回退)命令使用。
1 | function transferOwner1(uint256 tokenId, address newOwner) public { |
我们定义了一个transferOwner1()函数,它会检查代币的owner是不是发起人,如果不是,就会抛出TransferNotOwner异常;如果是的话,就会转账。
不带参数的error,消耗gas量为24095

带参数的error,消耗gas量为24113

Require
require命令是solidity 0.8版本之前抛出异常的常用方法,目前很多主流合约仍然还在使用它。它很好用,唯一的缺点就是gas随着描述异常的字符串长度增加,比error命令要高。使用方法:require(检查条件,"异常的描述"),当检查条件不成立的时候,就会抛出异常。
我们用require命令重写一下上面的transferOwner1函数:
1 | function transferOwner2(uint256 tokenId, address newOwner) public { |
require消耗gas的量为24211

Assert
assert命令一般用于程序员写程序debug,因为它不能解释抛出异常的原因(比require少个字符串)。它的用法很简单,assert(检查条件),当检查条件不成立的时候,就会抛出异常。
我们用assert命令重写一下上面的transferOwner1函数:
1 | function transferOwner3(uint256 tokenId, address newOwner) public { |
assert消耗gas的量为24109

三种方法的gas比较
我们比较一下三种抛出异常的gas消耗,查询gas的消耗 (使用0.8.26版本编译)
error方法gas消耗:24095 (加入参数后gas消耗:24113)require方法gas消耗:24211assert方法gas消耗:24109
我们可以看到,error方法gas最少,其次是assert,require方法消耗gas最多!因此,error既可以告知用户抛出异常的原因,又能省gas,大家要多用!(注意,由于部署测试时间的不同,每个函数的gas消耗会有所不同,但是比较结果会是一致的。)
备注: Solidity 0.8.0之前的版本,assert抛出的是一个 panic exception,会把剩余的 gas 全部消耗,不会返还。更多细节见官方文档。
参考
https://www.wtf.academy/docs/solidity-101/HelloWeb3