solidity 101
约 9449 字大约 32 分钟
2022-04-21
合约
pragma
concept
pragma是一个用于指定编译器版本的关键字。它的作用是确保代码能够在特定版本的编译器下正确编译和执行,以避免潜在的兼容性问题。
比喻
当我们和外国人交流时,我们可以借助自助翻译软件,编译器的作用类似于自助翻译软件。
随着语言的发展,可能会有一些的词语出现这时就需要翻译软件(编译器)的更新迭代,我们需要使用对应版本的翻译软件才可以识别出我们所使用的语句(语法)
真实用例
pragma solidity ^0.8.20;
abstract contract ERC20 is Context, IERC20, IERC20Metadata, IERC20Errors { }
只有版本大于等于0.8.20的编译器可以编译此ERC20合约。这样就避免了代码和编译器版本可能存在的兼容性问题。
documentation
//指定合约的编译器版本仅可为0.8.4
pragma solidity 0.8.4;
//指定合约的编译器版本仅可为≥0.8.4 且 <0.9.0()
//限制条件“<0.9.0”通过^ 提供,^0.a.b 表示它需要编译器版本 ≥0.a.b 且 <0.(a+1).0。
pragma solidity ^0.8.4;
FAQ
编译器为什么会有不同版本?
Solidity 在不断发展,不同版本的 Solidity 有不同的功能和兼容性问题。
例如旧版本中的某些关键字在新版本中不再使用。
当你编写 Solidity 智能合约时,需要选择合适的版本。否则,程序可能无法编译。
contract
concept
使用关键字 contract 定义合约,一个 Solidity 的 .sol 文件可以包含一个或多个 contract。
比喻
智能合约就像是一份自动执行的合同,但不是由法律机构执行,而是由区块链网络上的计算机节点执行。
真实用例
contract UniswapV2Pair{ }
documentation
要定义一个 合约,我们使用关键字 contract,后面跟上合约的名称。
在同一个.sol文件下可以定义多个合约,且他们都使用同一个编译器版本。
contract Name { }
对于合约的命名,我们建议遵循“大驼峰”的命名规范,“大驼峰”是指每个单词的首字母都大写,例如:MyContract
FAQ
从代码的角度怎么理解 contract ?
它类似于面向对象编程中的类。
智能合约的代码逻辑包含了一系列函数和事件,用于定义合约的行为和功能。和其他编程语言不同的是,
每一个 contract 对应着一个地址,且不可更改。
变量
int
concept
一个最基础的变量类型 - int。
整数类型,具体细分有很多,比如uint、uint8、uint32、uint256等,带个u就是无符号,只能存正整数。
比喻
变量 就像程序(在这里指智能合约)内部存储信息的容器。
想象一下一个可以放东西的容器,比如一个盒子。在 Solidity 中,这个容器就是一个变量,它可以存储不同类型的数据,例如数字、文本或地址。
真实用例
在OpenZepplin
的SignedMath
合约中的average
函数使用int256
来表示参与运算的参数,也就意味着该函数可以进行正/负数的计算。
function average(int256 a, int256 b) internal pure returns (int256) {
// Formula from the book "Hacker's Delight"
int256 x = (a & b) + ((a ^ b) >> 1);
return x + (int256(uint256(x) >> 255) & (a ^ b));
}
documentation
可以通过以下的方式为变量赋值
//定义一个整形变量a
int a;
a = 10;//a为10
a = a + 10; // a现在为20
FAQ
变量由什么构成?
一个变量由三部分组成:
●变量名称:这是我们给容器取的名字
●变量类型:每个变量都有一个类型,例如整数
●变量值:实际放入容器中的信息
例如在下面例子中,变量名称为 a
,变量类型为 int
,变量值为100
。
int a = 100;
bool
concept
布尔变量(也称为 bool)只有两个值:true 或 false,通常用于判断。
比喻
你可以把布尔变量当作开关,它可以是开启(对应 true )或关闭(对应 false )的状态。
真实用例
在 ERC20
的 approve
授权函数中,需要一个 bool
类型的变量 emitEvent
来表示此次授权是否需要提交事件( emit event)
function average(int256 a, int256 b) internal pure returns (int256) {
// Formula from the book "Hacker's Delight"
int256 x = (a & b) + ((a ^ b) >> 1);
return x + (int256(uint256(x) >> 255) & (a ^ b));
}
documentation
使用 bool 关键字定义布尔变量
pragma solidity ^0.8.7;
contract Book {
bool a = true;
bool b = false;
//逻辑非
bool c = !a; // 此处c为false,我们对a的值进行了逻辑非操作,并将其赋值给 c
bool d = !c; // 同理,d此处为true
//与运算
bool e = d && a; // e 为 true,因为 d 和 a 都是 true
bool f = e && b; // f 不会是 true,因为 b 不是 true
bool g = e && true; // g 为 true
//或运算
bool h = true || false; // h 为 true
bool i = false || true; // i 为 true
bool j = false || false; // j 为 false
//另一个常见的操作是 ==,它将在两个值相等时返回 true ,否则返回 false 。
bool k = a == true; // k = true,其实就相当于:bool k = (a == true)
//最后,我们还需要不等操作 !=,它将在两个值不同时返回 true ,否则返回 false 。
bool l = a != true; // l为false,因为 a 是 true
}
FAQ
什么时候使用布尔变量?
在编程中,布尔变量通常用于控制程序的流程。
例如,如果你需要让某个代码段在特定条件才能运行,可以使用一个布尔变量来表示该条件是否已经满足。
函数
function
concept
函数可以在不同的代码中多次使用,并避免编写重复的代码。
比喻
可以将函数视为一个黑匣子。给定输入,它将执行一组预定义的计算,并输出某些内容或对你的变量进行一些更改。
真实用例
在ERC20
合约中,我们定义了一个名为transfer
的函数,每当用户想要发送代币时,他们都会调用这个函数,但他们不知道它到底做了什么,但他们知道他们的余额会减少,而接收者的余额会增加。
function transfer(address to, uint256 value) public virtual returns (bool) {
...
}
documentation
作为一个黑匣子,我们需要定义它的使用方式,即它的名称、输入和输出。这是一行代码完成的,我们称之为函数头。
//一个名为sum的函数
function sum() {
//函数体
}
FAQ
什么是函数,为什么需要函数?
函数就是把实现某功能的所有的代码打包,每次需要这个功能的时候不用重复去写实现这个功能的代码,而是直接调用函数。
可见性
concept
在了解如何定义函数后,还需要定义一个可见性,也称为作用域。它指定了何时可以访问此 变量 或 函数。
比喻
就像你的信息一样,私密的信息你不会公开,所以定义为 private 。而可以公开的需要被大众知晓的信息定义为 public 。
真实用例
在ERC20
中有像transfer
这样的public
函数,需要提供给所有的人调用,以触发转账操作。
function transfer(address to, uint256 value) public virtual returns (bool) {
address owner = _msgSender();
_transfer(owner, to, value);
return true;
}
但也有像_balances
一样的private
变量, 它不需要提供给外人,即使我们可以使用权限控制来保护余额信息不被泄露。
mapping(address account => uint256) private _balances;
documentation
要定义一个 公共变量 或 公共函数,我们使用关键字 public,并将其放在 变量 名称之前或 函数 参数之后。
contract A {
2 //aa 和 bb 函数,以及 a 变量可以从任何地方访问,因为它们是 public 。
3 //b 和 bbb只能从合约内部访问,因为它们是 private 。
4 uint public a;
5 uint private b;
6 function aa() public {
7 //这与a = a + 1 等同;
8 a++;
9 }
10 function bb() public {
11 b++;
12 }
13 function bbb() private {
14 b++;
15 }
16}
FAQ
为什么需要区分 public 和 private ?
1.安全性:通过将某些函数标记为 private ,可以确保只有合约内部的其他函数可以调用它们。这可以防止外部恶意合约或攻击者调用可能对合约安全性构成威胁的内部函数。
2.隐私性:有时,合约可能包含处理敏感信息的函数。通过将这些函数标记为 private ,可以防止外部查看或访问这些敏感数据。这有助于保护用户的隐私。
3.优化和成本:public 函数通常需要更多的燃气( gas )来执行,因为它们需要处理许多安全性和访问控制检查。通过将某些非必要公开函数标记为 private ,可以减少合约执行时的燃气成本,从而提高效率。
internal
concept
在Solidity中,指定函数或变量的可见性或作用域 关键字还有 internal
,有时我们将限定某些变量或函数仅在内部合约使用。
比喻
internal
好比你的卧室,只有你和家人可以进入,外面的人不能进来。你的卧室是私人的,其他人无法访问。
注意:继承的合约也可以调用被internal
标记的函数。
真实用例
在ERC20中有transfer
这样的internal
函数,仅在合约内部使用,以保障代币转移的安全性。
function _transfer(address from, address to, uint256 value) internal {
if (from == address(0)) {
revert ERC20InvalidSender(address(0));
}
if (to == address(0)) {
revert ERC20InvalidReceiver(address(0));
}
_update(from, to, value);
}
documentation
要定义一个仅在合约内部,以及继承它的合约中才能使用的函数,我们使用关键字 internal
,并将其放在函数 参数之后。
使用时可以直接使用函数名funcName()调用函数。(区别与external
)
contract A {
uint public result;
function aa(uint a) internal { // aa仅在合约内部以及继承它的合约中才能使用
result = a + 1;
}
function b(uint b) public {
aa(b);
}
}
FAQ
为什么需要区分 external 和 internal ?
为了控制访问权限和提高安全性。有些功能需要被其他合约使用,而有些功能只应该在当前合约内部使用。通过定义不同的可见性,我们可以确保合约的数据和功能仅在适当的上下文中被访问,从而增强了智能合约的安全性和可维护性。
external
concept
某些情况下合约中的特定功能需要与其他合约共享,此时我们可以使用external
关键词。
比喻
external
好比一个公共图书馆,所有人都可以进去,但只有在开放时间才能进入。
真实用例
在OpenZepplin
的GovernorTimelockControl
合约中,定义了一个updateTimelock
的函数供外部用户或合约使用。
function updateTimelock(TimelockController newTimelock) external virtual onlyGovernance {
_updateTimelock(newTimelock);
}
documentation
要定义一个外部用户或其他合约能使用的函数,使用关键字 external
,并将其放在函数 参数之后。
在本合约中使用时必须加上this关键词。(区别与internal)
contract A {
uint public result;
function aa(uint a) external {
result = a + 1;
}
function b(uint b) public {
this.aa(b);
}
}
FAQ
状态变量可以用 external 定义吗?
external
不能用于定义变量。
接口合约的函数可见性有什么特殊要求吗?
接口合约中的函数都必须是 external
的。
input
concept
函数就相当于黑匣子,有时候需要给这个黑匣子一些信息,让它来处理这些信息,因此我们引入“输入参数”这个概念来表示这些信息。
比喻
就像是黑匣子的入口,我们可以通过入口将一些信息传递给黑匣子来处理。
真实用例
在ERC20
合约中,我们可以通过balanceOf
函数查询某个地址的代币余额,而要查询的地址就是通过参数传递给函数的。
function balanceOf(address account) public view virtual returns (uint256) {
return _balances[account];
}
documentation
要定义一个函数的输入参数,我们在函数名后的括号中放置它们。
如果我们想要多个参数,则使用,进行分隔。
pragma solidity 0.8.4;
contract Function {
//定义一个名为 add 的公共函数
//它接受两个int类型的参数a和b,一个bool类型c,一个address类型d
//并且不返回任何值。
function add(int a, int b, bool c, address d) public { }
}
FAQ
什么是输入参数?
使用不同的输入参数,函数会返回不同的结果。
output
concept
在函数处理完后,可能还需要输出一个信息,这就是函数的输出 output
。
比喻
还是用我们黑匣子的例子,当黑匣子处理完信息后,他可能需要把处理完的结果返回给调用者。
真实用例
就像ERC20
的balanceOf
函数一样,输入一个地址来查询该地址的余额。而余额需要通过输出来返回给调用者。这样才完成了查询的整个步骤。
function balanceOf(address account) public view virtual returns (uint256) {
return _balances[account];
}
documentation
要定义函数的输出,我们在函数花括号前加上 returns
关键字定义返回类型,并且在函数体中使用 return
关键词返回函数输出。
pragma solidity 0.8.4;
contract Function {
//在这里定义名为add的公共函数,它接受两个int类型的参数a和b,并返回一个int类型的结果。
function add(int a, int b) public returns(int) {
return a + b;
}
}
FAQ
什么是输出(返回值)?
函数的输出是在函数执行完毕后返回给调用者的结果。
Alternative Output
concept
进一步了解函数返回值的另一种形式——通过已命名的变量返回。
在这种模式下,我们无需使用 return 语句,只需直接为预先命名的返回值变量赋值即可。
比喻
预先确定了要返回的信息。无论黑匣子内部的处理如何,最终它都会将我们之前确定的那个信息作为返回值呈现给我们。不再需要使用return
语句来显式地返回值。
真实用例
在 Uniswap
v2
中的 mint
函数,会将铸造所得的流动性 liquidity
作为返回值返回,这时这个返回值已经命名为 liquidity
,这样一来,函数里只需要对 liquidity
赋值,最后不需要 return
语句也会将该值返回给调用者。
function mint(address to) external lock returns (uint liquidity) {
...
if (_totalSupply == 0) {
liquidity = Math.sqrt(amount0.mul(amount1)).sub(MINIMUM_LIQUIDITY);
_mint(address(0), MINIMUM_LIQUIDITY); // permanently lock the first MINIMUM_LIQUIDITY tokens
} else {
liquidity = Math.min(amount0.mul(_totalSupply) / _reserve0, amount1.mul(_totalSupply) / _reserve1);
}
require(liquidity > 0, 'UniswapV2: INSUFFICIENT_LIQUIDITY_MINTED');
_mint(to, liquidity);
...
}
documentation
通过已命名的变量返回。
pragma solidity ^0.8.7;
contract Math {
//我们定义了两个要返回的int值,k返回5,j将以默认值0返回,因为我们没有给它赋值
function sum() public returns(int k, int j) {
k = 5;
}
}
FAQ
什么时候需要使用已经命名的返回值?
当函数有具体的返回值目标时,使用已命名的返回值类型可以使代码更易懂。例如,如果一个函数的返回值声明为 uint256 count,读者一看就知道该函数将返回一个计数值。这种明确的返回值命名有助于更好地理解函数的预期结果。
call
concept
学习如何实际访问(调用)函数了。
比喻
调用函数就像按下黑盒子上的按钮,它将为您执行某些操作。在你调用该函数之前,什么都不会发生。
当程序执行到调用函数的语句时,会跳转到该函数所在的位置开始执行函数内部的代码,并且可以通过传递参数给函数来影响函数的行为。当函数执行完毕后,会返回到调用它的语句处继续执行后面的代码。
真实用例
在ERC20
合约的transferFrom
函数中先后调用了_spendAllowance
和_transfer
以完成授权的更新和转账的功能。
function transferFrom(address from, address to, uint256 value) public virtual returns (bool) {
address spender = _msgSender();
_spendAllowance(from, spender, value);
_transfer(from, to, value);
return true;
}
documentation
调用函数需要知道函数的名称以及可能需要传递给它的参数。
pragma solidity 0.8.4;
contract A {
// 定义一个名为 add 的公共函数,接收两个整型参数 a 和 b,并返回它们的和
function add(int a, int b) public returns(int) {
return a + b;
}
// 定义一个名为 addUp 的公共函数,接收三个整型参数 a、b 和 c,并返回它们的和
function addUp(int a, int b, int c) public returns(int) {
// 调用 add 函数将 a 和 b 相加,将结果保存在变量 d 中
int d = add(a, b);
// 调用 add 函数将 d 和 c 相加,将结果作为 addUp 函数的返回值返回
return add(d, c);
}
// 定义一个名为 addMul 的公共函数,接收两个整型参数 a 和 b,并返回它们的和与积
function addMul(int a, int b) public returns(int, int) {
return (a + b, a * b);
}
// 定义一个名为 addMulUp 的公共函数,接收三个整型参数 a、b 和 c,返回两个整数类型的值,分别为a+b+c, (a+b)*c
function addMulUp(int a, int b, int c) public returns(int, int) {
(int d,int e) = addMul(a, b);
return addMul(d, c);
}
}
FAQ
调用函数前后的代码是怎么执行的?
function b() public {
//代码片段3
}
function a() public {
//代码片段1
b();
//代码片段2
}
Simple modifiers
state variable
concept
接下来我们将会学习变量的两种定义方式:状态变量和局部变量。
比喻
合约和状态变量的关系就像学校和学校的信息,该信息为学校内所有人员共享,维护的一个变量。并且其他人也可以随意的查询学校的信息。
真实用例
在ERC20
中,_totalSupply
就是一个状态变量,它存储了该代币的总发行量。
uint256 private _totalSupply;
documentation
要定义一个状态变量,需要将其放在函数之外。
contract ContractName {
//这是一个状态变量
int a;
function add(int b) returns(int) {
//b被定义为函数的输入参数,所以它不是状态变量
//c是在函数中定义的,所以它也不是状态变量
int c = a + b;
return c;
}
}
FAQ
什么是状态变量?
状态变量是一种永久存在于区块链上的变量。
例如,假设你有一个智能合约,用于跟踪网站上的按钮被点击的次数。你可以创建一个名为 clickCount 的状态变量,它从零开始,每次有人点击该按钮时,其值增加1。
任何与合约交互的人都可以通过该状态变量来查询该按钮被点击了多少次。
何时使用状态变量?
如果这个信息应该被记录在区块链上,则将其设置为状态变量。
状态变量的通常需要更多的 gas 来读写(gas用于衡量执行智能合约操作所需的计算资源。),所以应当仅在必要时使用。
local variable
concept
继续学习局部变量。
比喻
上一节中我们提到如果合约代表一个学校,那么状态变量就相当于学校的信息,该信息将公开在区块链上,并供学校内所有人维护。
那么局部变量就相当于班级信息,每个班级只能够知道自己班级的信息以及学校的信息。而不能获取其他班级(函数)的班级信息(局部变量)
真实用例
在ERC20
的approve
函数中就使用了局部变量owner
来记录调用者地址,这就是只属于这个函数的变量(信息)。
function approve(address spender, uint256 value) public virtual returns (bool) {
address owner = _msgSender();
_approve(owner, spender, value);
return true;
}
documentation
要定义一个局部变量,需要将其定义在函数内部。
pragma solidity ^0.8.0;
contract Example {
uint c; // 状态变量
function getResult() public returns(uint){
uint a = 1; // 局部变量
uint b = 2; // 局部变量
uint result = a + b;
c = result;
return result; //返回局部变量
}
}
FAQ
什么是局部变量?
局部变量是在函数内部声明的变量,其作用域仅限于该函数内部。
pure
concept
变量可以分为 状态变量 和 局部变量, 函数也可以分为3种 —— pure 函数、view 函数和其他函数。
首先学习纯函数— pure
,所谓纯函数就是该函数不会访问以及修改任何状态变量。 一般来讲 pure
函数用于返回一个固定的值或完成计算。
比喻
pure 函数就像是计算器,你只是输入数字和运算符,并等待计算器返回结果。
并且该计算器能获取的信息只有你的输入,它并不能从其他地方获取信息,也不会对任何事物造成影响。
真实用例
在 Openzepplin
的 governance
合约有一个 votingDelay
的函数,用于返回当前投票的延迟。
function votingDelay() public pure override returns (uint256) {
return 4;
}
documentation
要将一个函数定义为 pure
函数,我们需要在函数头中使用关键字 pure
。
pragma solidity ^0.8.0;
contract Example {
mapping(int => int) aa;
//这是一个pure函数 不读也不写状态变量
function add(int a, int b) public pure returns(int) {
return a + b;
}
//这不是一个pure函数 修改状态变量
function addNotPure(int a, int b) public returns(int) {
aa[0] = a + b;
return aa[0];
}
}
FAQ
为什么要使用 pure 定义函数?
使用 pure
定义的函数被调用时不用花费 gas
,并且可以保证该函数不会改变状态变量,有益于开发时的模块化管理。
view
concept
变量可以分为 状态变量 和 局部变量, 函数也可以分为3种 —— pure 函数、view 函数和其他函数。
接下来介绍另一种函数:view(视图)函数。
比喻
之前提到 pure
函数就像计算器,那么view
函数就像只具有“读”权限的数据库,它既可以使用参数进行计算,也可以在数据库中查找数据进行运算。
而其他函数就像是同时具有“读写”权限的数据库,它可以将计算后的结果重新写入数据库当中。
真实用例
在 ERC20
合约中,totalSupply
函数用于返回该代币的发行总量。由于它访问了状态变量(读状态变量) _totalSupply
,因此不能用 pure
,而使用 view
。
function totalSupply() public view virtual returns (uint256) {
return _totalSupply;
}
documentation
要定义一个 view
函数,我们使用关键字 view
。
pragma solidity ^0.8.0;
contract Example {
int c = 10; // 这是状态变量c.
//这是一个pure 函数
function add(int a, int b) public pure returns(int) {
return a + b;
}
//这是一个view函数,但不是pure函数
function addView(int a) public view returns(int) {
//这里使用了状态变量c;
//所以不是pure函数,但我们没有修改信息,所以它是view函数
return a + c;
}
//这既不是pure函数也不是view函数
function addNotPure(int a, int b) public returns(int) {
//这里修改了状态变量c;
c = a + b;
return c;
}
}
FAQ
pure 函数 vs view 函数
view 函数不会修改状态变量,但可能使用(读取)状态变量,而 pure 函数甚至不会读取状态变量。
详细的讲一讲什么是 view 函数吗?
一个 view 函数可以读取状态变量,但不能修改它。
例如,假设你想知道你银行账户的余额,你会向银行发送一个查询请求,并等待收到响应。在这个过程中,你只是读取了你的账户余额信息,但没有修改它。
如果某个函数告诉你某些信息,但不对区块链进行任何更改,那么它就是一个 view
函数。
address
address
concept
(address)地址是以太坊区块链上账户或智能合约的唯一标识符。
比喻
地址类似于唯一的用户名或帐户号,有助于识别和与以太坊平台上的个人用户或智能合约并与之交互。
真实用例
在ERC20
的transfer
函数中,使用address
类型的to
来表示要转账的接收方地址。
function transfer(address to, uint256 value) public virtual returns (bool) {
address owner = _msgSender();
_transfer(owner, to, value);
return true;
}
documentation
要定义一个地址,只需要使用 address
关键字。
地址占20个字节,一个字节有8个 bit ,所以地址共有160个 bit,一个字节需要两个十六进制数表示,所以需要40个十六进制数表示一个地址。
//定义
address address1 = 0x35701d003AF0e05D539c6c71bF9421728426b8A0;
//在以太坊中,每个地址都有一个成员变量,即地址的余额 balance
//余额以 uint 形式存在,因为它永远不可能为负值
uint balance = address1.balance;
FAQ
不同的地址之间有区别吗?
地址分为两类:账户地址 或 合约地址。
账户地址
它是由用户创建的用于接收或发送资金的地址,由用户控制,也称为钱包。如果你不熟悉此概念,请参考 Metamask
。
合约地址
与“账户地址”相反,“合约地址”由合约(程序)控制。将合约放在以太坊上时,系统会为它生成一个独特的地址。其他人通过这个地址与合约进行交互。
address payable
concept
address
类型缺少一个重要的功能,即:转移资金(ETH)。
在 solidity 中,只能对申明为 payable 的地址进行转账。
比喻
就像只能对白名单中的地址转账一样,我们只能对有 payable 修饰的地址转账。
真实用例
在 OpenZepplin
的 ERC2771Forwarder
合约中,参数 refundReceiver
被定义为 payable
,这也就意味着该地址可以接收转账。
function executeBatch(
ForwardRequestData[] calldata requests,
address payable refundReceiver
) public payable virtual {
...
}
documentation
要定义一个 address payable
类型的变量,需要使用关键字 payable
。
address payable add;
//类型转换
address add1 = 0x5B38Da6a701c568545dCfcB03FcB875f56beddC4;
address payable add2 = payable(add1); //使用 payable() 显式转换
address add3 = add2; //隐式转换
//转账
//从当前合约向address1转移5 Wei
address1.transfer(5);
FAQ
何时应该将地址定义为 address payable ?
如果想要从自己的账户向该账户转移资金,那么我们应该将其标记为address payable
。
address payable receiver;
receiver.transfer(amount);
mapping
definition
concept
到目前为止,已经学习了许多变量类型,包括布尔值、整数和地址。在本节中,将学习另一种类型,称为 mapping
,它提供了一种以结构化方式存储和管理信息的方法。
比喻
在 Solidity 中,mapping 是一种用于存储键值对的数据结构,其中键是唯一的,并且可以将一个值与一个键相关联。这类似于现实生活中的电话簿,其中每个电话号码都对应一个姓名。
真实用例
在 ERC20
合约中,有一个_balances
映射用来存储地址和余额之间的对应关系。
如果我们想知道某个地址的余额,就可以通过该映射来查询。
mapping(address => uint256) private _balances;
documentation
要定义一个 mappin
g,使用关键字 mapping
,后跟希望建立单向关联的两种类型。最后是名称。
pragma solidity ^0.8.4;
contract book {
//声明一个mapping,名称为owned_book,将地址映射到 uint 类型的值;
mapping(address => uint) public owned_book;
}
FAQ
更详细的描述 mapping
吗?
想象你是一家银行。你需要跟踪所有账户(地址)的余额(整数)。
很多时候,你可以查找给定帐户的余额,但你不能查找给定余额的账户。这是一种单向关联。
mapping
提供了两个类型之间的单向关联 —— 在这里,一个是地址,一个是 uint。
mapping(address => uint) balance;
在这里,地址称为键( key ),uint 是值( value )。我们可以通过键查询值,不能通过值查询键。
add&update
concept
对于整数,可以执行加减乘除等操作;对于mapping
,我们可以添加、更新、删除和查询。
这里学习如何在 mapping
当中添加和修改“值”。
比喻
上一节中,我们提到 mapping
就像电话簿,一个人物对应着一个电话号码。
但如果我们有联系人更换了电话号码,我们就需要在电话簿上进行修改。
真实用例
在 ERC20
的 _update
函数中,对 _balances
映射做出了修改。修改了 from
对应的余额为 fromBalance - value
。
function _update(address from, address to, uint256 value) internal virtual {
_balances[from] = fromBalance - value;
}
documentation
和其他编程语言的映射一样,将键放置在 mapping 名称之后的[]
内。
使用与分配变量值相同的语法,使用=
向 mapping
中添加一个键值对
。
对于 mapping
键值的更新,也使用相同的语法。
balance[address(0x123)] = 10;///这将为地址 0x123 分配一个新值
balance[address(0x123)] = 20;//这将把值从 10 更新为 20
其实,mapping 的添加也相当于更新,只不过是将 mapping
的默认值
更新为要添加的值。
FAQ
添加和更新的区别?
其实没有本质的区别,都是修改 key
存储位置对应的 value
。
query
concept
mapping
的另一个操作:查找。
比喻
mapping
就像是电话簿,那么就需要在电话簿中通过姓名查找他的电话号码。
真实用例
还是以 ERC20
当中的 balanceOf
函数作为用例,_balances
是一个映射,可以通过映射的键account
来查找其对应的值。
function balanceOf(address account) public view virtual returns (uint256) {
return _balances[account];
}
documentation
要查询映射中某个键对应的值,只需在 mapping
名称后面加上[]
,[]
里面放入要查询的键
即可。
pragma solidity ^0.8.4;
contract book {
mapping(address => uint) private owned_book;
function add_book(address owner, uint bookID) public {
owned_book[owner] = bookID;
}
//获取书籍函数,根据地址获取对应的书籍 ID
function get_book(address owner) public view returns(uint){
return owned_book[owner];
}
}
如果要查询的键不存在,则会返回这个值类型的默认值
。 例如 uint
的默认值是0
,bool
的默认值是 false
。
FAQ
如何在映射当中进行查询?
为一个键分配了一个值之后,我们可以使用这个键来查找它对应的值。
valueType value = mappingName[key];
like this:
delete
concept
mapping
的最后一个操作:删除
。
删除其实只是把 key
对应的 value
重新置为默认值
。
比喻
如果 mapping
是电话簿,那么可能会有删除某个联系人的操作,这时就需要删除 mapping
中的某一个键值对
。
真实用例
在 TimelockController
合约的 cancel
函数中,使用 delete
来删除 _timestamps
这个 mapping
中 id
所对应的值。
function cancel(bytes32 id) public virtual onlyRole(CANCELLER_ROLE) {
...
delete _timestamps[id];
}
documentation
要删除键值对,使用关键字 delete
。
contract A {
// 定义映射,将地址映射到 uint 类型的余额
mapping(address => uint) public balance;
// 添加函数,将指定地址的余额设置为 10
function add() public {
balance[address(0x0000000000000000000000000000000000000123)] = 10;
}
// 删除函数,删除指定地址的余额记录
function deleteF() public {
delete balance[address(0x0000000000000000000000000000000000000123)];
}
// 更新函数,将指定地址的余额增加10
function update() public {
balance[address(0x123)] += 10;
}
}
FAQ
删除操作意味着什么?
删除实际上等同于将该值指定为默认值
。
例如,如果是 uint256
类型,则被删除的元素的值将变为 0
。
因此,在执行删除操作后,仍然可以通过访问相应键的方式来获取该元素的值,只不过该值现在是默认值而已。
返回默认值不是通用行为,在其他编程语言中,它可能会报错。
others
constructor
concept
一个特殊的函数:构造函数。
构造函数是在合约部署时自动调用
且只被调用一次
的函数。
比喻
如果合约是印章的话,那么构造函数就像是购买印章时要求在印章上刻字的流程。
通过构造函数,可以在印章成型之前,按照我们的要求先“初始化”它。
真实用例
在 ERC20
合约中,有一个构造函数如下,可以通过构造函数来指定该 ERC20 Token
的 name
和 symbol
。
constructor(string memory name_, string memory symbol_) {
_name = name_;
_symbol = symbol_;
}
documentation
构造函数 没有名称和返回值:
1.名称,不需要显式命名。由于每个类中只能有一个构造函数,它将在对象创建时被自动调用。
2.返回值,没有返回值,因为构造函数是用于初始设置的。
要定义构造函数,我们只需要使用关键字 constructor
,后跟参数
。
如果你没有定义构造函数,那么在部署合约时,Solidity 将创建一个不执行任何操作的空构造函数。
// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.7.0 <0.9.0;
contract A {
uint public a;
//构造函数,初始化变量a
constructor(uint a_) {
a = a_;
}
}
contract B {
//一个空的构造函数
constructor() {}
}
FAQ
为什么需要构造函数?
1.访问控制。例如,想要发行自己的代币,并且我想定义只有我才能铸造代币。可以通过构造函数在部署时设置——谁部署了合约,谁就是所有者。
2.确保合约正确的初始化。因为一旦合约部署上链,所有人即可和合约交互,因此需要通过构造函数来保证合约部署后所有需要初始化的变量都已经正确的初始化。
require
concept
require
顾名思义,是一种类似于断言的语法,如果 require
当中的条件没有满足,此次调用将会失败。
比喻
想象你要去电影院观看一部电影,但是你需要购买一张门票才能进入。电影院在入口处设有售票窗口,并且售票员会检查你是否持有有效的门票。在这个场景中,require
语法就像售票员的检查,用于确保你持有有效的门票才能进入电影院观看电影。
真实用例
在Openzepplin
的CompTimelock
合约中queueTransaction
函数使用了require
语句来断言调用者(msg.sender
)是admin
地址。
如果不是,此次调用将会被回滚。
function queueTransaction(
address target,
uint256 value,
string memory signature,
bytes memory data,
uint256 eta
) public returns (bytes32) {
require(msg.sender == admin, "Timelock::queueTransaction: Call must come from admin.");
...
}
documentation
为了检查条件是否成立,使用关键字 require
,然后跟上条件,如果不满足条件,则报告错误消息。
require(recipient != address(0), "Recipient address cannot be zero");
require
语句的第一个参数是一个布尔表达式,如果为 true
,则继续执行程序。如果为 false
,则会中止执行,并将第二个参数作为错误消息发送到调用者。第二个参数 是可选的
。
FAQ
为什么我们需要 require
?
“错误处理”是每段代码中的重要部分。我们都希望事情按照某种方式进行,但实际上往往不是这样。在许多情况下,事情并不会按照我们的期望进行。
例如,买火车票时售票员期望我们提供身份证明,如果无法提供有效的身份证明,售票员会拒绝卖票。
我们需要使用 require
来确保事情按照我们的期望进行,并在它们没有按照我们的期望运行时采取适当的行动。
special variable - msg
concept
一个特殊变量msg.sender
。msg.sender
可以获取本次调用的调用者地址。
比喻
就像我们在接收消息时,有需求知道发送消息的人是谁一样。
在函数的调用中,我们也需要知道此次调用的调用者是谁。
真实用例
还是以上一节中的CompTimelock
合约中queueTransaction
函数使用了msg.sender
来查询此次调用的调用者。
function queueTransaction(
address target,
uint256 value,
string memory signature,
bytes memory data,
uint256 eta
) public returns (bytes32) {
require(msg.sender == admin, "Timelock::queueTransaction: Call must come from admin.");
...
}
documentation
要使用msg.sender
,不需要定义它。它在函数中处处可用,代表函数的调用者。
function a() {
//这里 msg.sender 没有定义为状态变量
//也不作为参数传入,我们可以直接使用它
address a = msg.sender;
}
FAQ
msg.sender与tx.origin的区别?
可以想象一个场景,我这个用户调用了函数A,则A又调用了B,B又调用了C。A,B,C是三个不同合约中的函数。我的钱包地址为0x123。
对于函数C,msg.sender
是函数B的合约地址,而tx.origin
则是0x123,也就是我的钱包地址,因为tx.origin
表示最初初始化整个调用链的账户地址。