高级 Solidity 主题

原文地址: Advanced Solidity Topics

翻译: JulySong

在新生课程中,我们查看了一些基本的 Solidity 语法。我们涵盖了变量、数据类型、函数、循环、条件流和数组。

然而,Solidity 还有一些东西,这些东西对于大二及以后的编码任务很重要。在本教程中,我们将介绍一些更重要的 Solidity 主题。

喜欢视频?

如果您想从视频中学习,我们的 YouTube 上有本教程的录音。它分为两部分,并且有时间戳。单击下面的屏幕截图观看视频,或继续阅读教程!

第 1 部分

视频

第 2 部分

视频

索引

映射(Mappings)

Solidity 中的映射就像其他编程语言中的哈希图或字典一样。它们用于将数据存储在键值对中。

映射是使用语法创建的 mapping (keyType => valueType)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.10;

contract Mapping {
// 从地址映射到 uint
mapping(address => uint) public myMap;

function get(address _addr) public view returns (uint) {
// 映射总是返回一个值。
// 如果该值从未设置,它将返回默认值。
// uint 的默认值为 0
return myMap[_addr];
}

function set(address _addr, uint _i) public {
// 更新这个地址的值
myMap[_addr] = _i;
}

function remove(address _addr) public {
// 将值重置为默认值。
delete myMap[_addr];
}
}

我们还可以创建嵌套映射,其中key指向第二个嵌套映射。为此,我们将 设置valueType为映射本身。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
contract NestedMappings {
// 从地址映射 => (从 uint 映射到 bool)
mapping(address => mapping(uint => bool)) public nestedMap;

function get(address _addr1, uint _i) public view returns (bool) {
// 你可以从嵌套映射中获取值
// 即使它没有初始化
// bool 类型的默认值为 false
return nestedMap[_addr1][_i];
}

function set(
address _addr1,
uint _i,
bool _boo
) public {
nestedMap[_addr1][_i] = _boo;
}

function remove(address _addr1, uint _i) public {
delete nestedMap[_addr1][_i];
}
}

枚举(Enums)

这个词Enum代表Enumerable。它们是用户定义的类型,其中包含一组常量(称为成员)的人类可读名称。它们通常用于将变量限制为仅具有几个预定义值之一。由于它们只是人类可读常量的抽象,实际上,它们在内部表示为uints。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.10;

contract Enum {
// 代表不同可能运输状态的
enum Status {
Pending,
Shipped,
Accepted,
Rejected,
Canceled
}

// 声明一个 Status 类型的变量
// 这只能包含一个预定义的值
Status public status;

// 因为枚举在内部由uint 表示
// 此函数将始终返回一个 uint
// Pending = 0
// Shipped = 1
// Accepted = 2
// Rejected = 3
// Canceled = 4
// 不能大于 4被返回
function get() public view returns (Status) {
return status;
}

// 为输入传递一个 uint 以更新值
function set(Status _status) public {
status = _status;
}

// 更新特定枚举成员的值
function cancel() public {
status = Status.Canceled; // 将设置状态 = 4
}
}

结构(Structs)

结构的概念存在于许多高级编程语言中。它们用于定义您自己的数据类型,这些数据类型将相关数据组合在一起。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.10;

contract TodoList {
// 声明一个将两种数据类型组合在一起的
struct TodoItem {
string text;
bool completed;
}

// 创建一个 TodoItem 结构数组
TodoItem[] public todos;

function createTodo(string memory _text) public {
// 有多种初始化结构的方法

// 方法 1 - 像函数一样调用它
todos.push(TodoItem(_text, false));

// 方法 2 - 显式设置其键
todos.push(TodoItem({ text: _text, completed: false }));

// 方法 3 - 初始化一个空结构体,然后设置单个属性
TodoItem memory todo;
todo.text = _text;
todo.completed = false;
todos.push(todo);
}

// 更新一个结构体值
function update(uint _index, string memory _text) public {
todos[_index].text = _text;
}

// 更新完成
function toggleCompleted(uint _index) public {
todos[_index].completed = !todos[_index].completed;
}
}

视图和纯函数(View and Pure Functions)

您可能已经注意到,我们一直在编写的一些函数在函数头中指定了viewpure关键字之一。这些是特殊关键字,表示函数的特定行为。

Getter 函数(返回值的函数)可以声明为viewpure

  • View: 不改变任何状态值的函数
  • Pure: 不改变任何状态值,但也不读取任何状态值的函数
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.10;

contract ViewAndPure {
// 声明一个状态变量
uint public x = 1;

// 承诺不修改状态(但可以读取状态)
function addToX(uint y) public view returns (uint) {
return x + y;
}

// 承诺不修改或读取状态
function add(uint i, uint j) public pure returns (uint) {
return i + j;
}
}

函数修饰符(Function Modifiers)

修饰符是可以在函数调用之前和/或之后运行的代码。它们通常用于限制对某些功能的访问、验证输入参数、防止某些类型的攻击等。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.10;

contract Modifiers {
address public owner;

constructor() {
// 设置合约部署者为合约
owner = msg.sender;
}

// 创建一个只允许所有者调用函数的
modifier onlyOwner() {
require(msg.sender == owner, "You are not the owner");

// 下划线是修饰符内部使用的特殊字符
// 它告诉 Solidity 此时执行该修饰符所用的函数
// 因此,这个修饰符将首先执行上述检查
// 然后运行其余代码
_;
}

// 创建一个函数并对其应用 onlyOwner 修饰符
function changeOwner(address _newOwner) public onlyOwner {
// 只有当修饰符通过检查成功时,我们才会到达这一点
// 所以这个事务的调用者必须是当前的所有者
owner = _newOwner;
}
}

活动(Events)

事件允许合约在以太坊区块链上执行日志记录。例如,可以稍后解析给定合约的日志以在前端界面上执行更新。它们通常用于允许前端界面侦听特定事件并更新用户界面,或用作廉价的存储形式。

1
2
3
4
5
6
7
8
9
10
11
12
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.10;

contract Events {
// 声明一个记录地址和字符串事件的
event TestCalled(address sender, string message);

function test() public {
// 记录一个事件
emit TestCalled(msg.sender, "Someone called test()!");
}
}

构造函数(Constructors)

constructor 是一个可选函数,在首次部署合约时执行。您还可以将参数传递给构造函数。

P.S. - 如果你还记得,我们实际上在新生跟踪加密货币和 NFT 教程中使用了构造函数!

1
2
3
4
5
6
7
8
9
10
11
12
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.10;

contract X {
string public name;

// 部署合约时需要提供一个字符串参数
constructor(string memory _name) {
// 部署合约时会立即设置
name = _name;
}
}

遗产(Inheritance)

继承是一个合同可以继承另一个合同的属性和方法的过程。Solidity 支持多重继承。合约可以使用is关键字继承其他合约。

注意:我们实际上还在 Freshman Track Cryptocurrency 和 NFT 教程中进行了继承——我们分别从ERC20ERC721合约继承。

具有可以被子合约覆盖的函数的父合约必须声明为virtual函数。

将要覆盖父函数的子合约必须使用override关键字。

如果父合约以相同的名称共享方法或属性,则继承顺序很重要。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.10;

/* 继承图
A
/ \
B C
/ /
D E

*/

contract A {
// 声明一个可以被子函数覆盖的虚函数 foo()
function foo() public pure virtual returns (string memory) {
return "A";
}
}

contract B is A {
// 覆盖 A.foo();
// 但也允许这个函数被更多的子函数覆盖
// 所以我们指定了两个关键字 - virtual 和 override
function foo() public pure virtual override returns (string memory) {
return "B";
}
}

contract C is A {
// 类似于上面的合约 B
function foo() public pure virtual override returns (string memory) {
return "C";
}
}

// 从多个合约继承时,如果一个函数被多次定义,则使用最右边的父合约的函数。
contract D is B, C {
// D.foo() 返回 "C"
// 因为 C 是函数 foo() 的最右边父级;
// override (B,C) 表示我们要覆盖两个父级中存在的方法
function foo() public pure override (B, C) returns (string memory) {
// super 是一个特殊的关键字,用于调用functions
// 在父合约中
return super.foo();
}
}

contract E is C, B {
// E.foo() 返回 "B"
// 因为 B 是函数 foo() 的最右边父级;
function foo() public pure override (C, B) returns (string memory) {
return super.foo();
}
}

转移 ETH(Transferring ETH)

有三种方法可以将 ETH 从合约转移到其他地址。但是,其中两种方法在最新版本中不再是 Solidity 推荐的方法,因此我们将跳过它们。

目前,从合约中转移 ETH 的推荐方式是使用该call功能。该call函数返回一个bool指示传输成功或失败的信息。

如何在普通的以太坊账户地址接收以太币

如果将 ETH 转移到普通账户(如 Metamask 地址),您无需做任何特殊的事情,因为所有此类账户都可以自动接受 ETH 转移。

如何在合约中接收 ETH

但是,如果您正在编写一份合约,希望能够直接接收 ETH 转账,您必须至少具有以下功能之一

  • receive() external payable
  • fallback() external payable

如果msg.data是空值则调用receive(),否则使用fallback()

msg.data是一种指定任意数据和事务的方法。您通常不会手动使用它。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.10;

contract ReceiveEther {
/*
调用哪个函数,fallback() 还是 receive()?

发送以太币
|
msg.data 为空?
/ \
yes no
/ \
receive() 存在吗? fallback()
/ \
是 否
/ \
receive() fallback()
*/

// 接收以太币的函数。msg.data 必须为空
receive() external payable {}

// 当 msg.data 不为空时调用 fallback 函数
fallback() external payable {}

function getBalance() public view returns (uint) {
return address(this).balance;
}
}

contract SendEther {
function sendEth(address payable _to) public payable {
// 只需将在此应付函数中收到的 ETH 转发到给定地址
uint amountToSend = msg.value;
// call 返回一个 bool 值,指定成功或失败
(bool success, bytes memory data) = _to.call{value: msg.value}("");
require(success == true, "Failed to send ETH");
}
}

调用外部合约(Calling External Contracts)

合约可以通过调用其他合约实例上的函数来调用其他合约,例如A.foo(x, y, z). 为此,您必须有一个接口A来告诉您的合约存在哪些功能。Solidity 中的接口行为类似于头文件,并且与我们在从前端调用合约时使用的 ABI 具有相似的用途。这允许合约知道如何编码和解码函数参数并返回值以调用外部合约。

注意:您使用的接口不需要很广泛。即它们不一定需要包含外部合同中存在的所有功能 - 只需要您可能在某个时候调用的那些功能。

假设有一个外部ERC20合约,我们有兴趣调用该balanceOf函数来检查我们合约中给定地址的余额。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.10;

interface MinimalERC20 {
// 只需在接口包含我们感兴趣的函数
function balanceOf(address account) external view returns (uint256);
}

contract MyContract {
MinimalERC20 externalContract;

constructor(address _externalContract) {
// 初始化一个 MinimalERC20 合约实例
externalContract = MinimalERC20(_externalContract);
}

function mustHaveSomeBalance() public {
// 要求该交易的调用者在外部 ERC20 合约中的代币余额为非零
uint balance = externalContract.balanceOf(msg.sender);
require(balance > 0, "You don't own any tokens of external contract");
}
}

Import 声明(Import Statements)

为了保持代码的可读性,您可以将 Solidity 代码拆分为多个文件。Solidity 允许导入本地和外部文件。

本地 Import

假设我们有一个这样的文件夹结构:

1
2
├── Import.sol
└── Foo.sol

Foo.sol 在哪里

1
2
3
4
5
6
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.10;

contract Foo {
string public name = "Foo";
}

我们可以像这样导入Foo和使用它Import.sol

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.10;

// 从当前目录导入 Foo.sol
import "./Foo.sol";

contract Import {
// 初始化 Foo.sol
Foo public foo = new Foo();

// 通过获取 Foo.sol 的名称来测试它。
function getFooName() public view returns (string memory) {
return foo.name();
}
}

注意:当我们使用 Hardhat 时,我们也可以通过 将合约安装为节点模块npm,然后从node_modules文件夹中导入合约。这些也算作本地导入,因为从技术上讲,当您安装软件包时,您正在将合同下载到本地计算机。

外部 Import

您也可以通过简单地复制 URL 从 Github 导入。我们在新生课程的加密货币和 NFT 教程中做到了这一点。

1
2
3
4
5
6
// https://github.com/owner/repo/blob/branch/path/to/Contract.sol
import "https://github.com/owner/repo/blob/branch/path/to/Contract.sol";

// 示例从 openzeppelin-contract repo 导入 ERC20.sol
// https://github.com/OpenZeppelin/openzeppelin-contracts/blob/master/contracts/token/ERC20/ERC20.sol
import "https://github.com/OpenZeppelin/openzeppelin-contracts/blob/master/contracts/token/ERC20/ERC20.sol";

Solidity 库

库类似于 Solidity 中的合约,但有一些限制。库不能包含任何状态变量,也不能转移 ETH。

通常,库用于向您的合约添加辅助函数。Solidity 世界中一个非常常用的库是SafeMath- 它确保数学运算不会导致整数下溢或溢出。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.10;

library SafeMath {
function add(uint x, uint y) internal pure returns (uint) {
uint z = x + y;
// 如果 z 溢出,抛出错误
require(z >= x, "uint overflow");
return z;
}
}

contract TestSafeMath {
function testAdd(uint x, uint y) public pure returns (uint) {
return SafeMath.add(x, y);
}
}

练习题

  1. 🤔 映射(Mappings)的行为类似于哪种数据结构?

    A: Arrays

    B: Trees

    C: Graphs

    D: Hashmaps

  2. 🤔 枚举(enums)有什么用?

    A: 将变量限制为仅具有几个预定义值之一。

    B: 将函数限制为仅具有几个预定义值之一

    C: 将接口限制为仅具有几个预定义值之一

  3. 🤔 结构(structs)是用来做什么的?

    A: 创建自定义数据类型

    B: 限制变量只有几个预定义的值

    C: 存储键值对

    D: 登录到区块链

  4. 🤔 纯函数(Pure functions)用于将数据写入区块链

    A: 是的

    B: 错误

  5. 🤔 视图函数(View functions)不会改变任何状态值

    A: 是的

    B: 错误

  6. 🤔 修饰符(modifiers)不用于什么?

    A: 限制对某些功能的访问

    B: 验证输入参数

    C: 功能替换

    D: 防止某些类型的攻击

  7. 🤔 事件(events)有什么用?

    A: 登录到区块链

    B: 创建自定义数据类型

    C: 创建在特定时间间隔后运行的函数

  8. 🤔 构造函数(constructor)是可选的吗?

    A: 是的

    B: 错误

  9. 🤔 contract B is A { }

    A: A 继承了 B

    B: B 继承 A

    C: A 和 B 是同一份合约

  10. 🤔 你必须有一个智能合约才能将 ETH 发送到另一个 ETH 地址

    A: 是的

    B: 错误

  11. 🤔 如果你想让你的合约直接接收 ETH 转账,你必须使用“receive() external pay”或“fallback() external pay”

    A: 是的

    B: 错误

  12. 🤔 库可以包含状态变量

    A: 是的

    B: 错误

参考答案:

  1. D
  2. A
  3. A
  4. B
  5. A
  6. C
  7. A
  8. A
  9. B
  10. B
  11. A
  12. B