合约
Solidity 合约类似于面向对象语言中的类。它包含状态变量中的持久数据,以及可以修改这些变量的函数。 在不同合约(实例)上调用函数将执行 EVM 函数调用,从而切换上下文,使得调用合约中的状态变量不可访问。 必须调用合约及其函数才能触发变化。以太坊中没有“cron”概念来自动在特定事件下调用函数。
创建合约
合约可以通过以太坊交易“从外部”创建,也可以通过 Solidity 合约内部创建。
IDE,如 Remix,通过 UI 元素使创建过程更加顺畅。
在以太坊上以编程方式创建合约的一种方法是通过 JavaScript API web3.js。 它有一个名为 web3.eth.Contract 的函数来促进合约的创建。
当合约被创建时,它的 constructor (用 constructor
关键字声明的函数)会被执行一次。
构造函数是可选的。只允许存在一个构造函数,这意味着不支持重载。
构造函数执行后,合约的最终代码会存储在区块链上。此代码包括所有公共和外部函数以及所有可以通过函数调用从那里访问的函数。部署的代码不包括构造函数代码或仅从构造函数调用的内部函数。
在内部,构造函数参数在合约代码本身之后以 ABI 编码 传递,但如果你使用 web3.js
,则不必关心这一点。
如果一个合约想要创建另一个合约,则必须知道被创建合约的源代码(和二进制)。这意味着循环创建依赖是不可能的。
// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.4.22 <0.9.0;
contract OwnedToken {
// `TokenCreator` 是下面定义的合约类型。
// 只要不用于创建新合约,引用它是可以的。
TokenCreator creator;
address owner;
bytes32 name;
// 这是构造函数,它注册了
// 创建者和传入的名称。
constructor(bytes32 name_) {
// 状态变量通过其名称访问
// 而不是通过例如 `this.owner`。
// 函数可以直接访问或通过 `this.f` 访问,
// 但后者提供了对函数的外部视图。特别是在构造函数中,
// 你不应该从外部访问函数,
// 因为该函数尚不存在。
// 有关详细信息,请参见下一节。
owner = msg.sender;
// `address` 显式转换为 `TokenCreator`
// 并假设调用合约的类型是 `TokenCreator`,
// 但没有真正的方法来验证这一点。
// 这不会创建新合约。
creator = TokenCreator(msg.sender);
name = name_;
}
function changeName(bytes32 newName) public {
// 只有创建者可以更改名称。
// 我们根据其地址比较合约,
// 合约可以通过显式转换为地址来检索。
if (msg.sender == address(creator))
name = newName;
}
function transfer(address newOwner) public {
// 只有当前所有者可以转让代币。
if (msg.sender != owner) return;
// 我们询问创建者合约是否应该继续转让
// 通过使用下面定义的 `TokenCreator` 合约的一个函数。如果
// 调用失败(例如由于耗尽 gas),
// 此处的执行也会失败。
if (creator.isTokenTransferOK(owner, newOwner))
owner = newOwner;
}
}
contract TokenCreator {
function createToken(bytes32 name)
public
returns (OwnedToken tokenAddress)
{
// 创建一个新的 `Token` 合约并返回其地址。
// 从 JavaScript 端,此函数的返回类型
// 是 `address`,因为这是
// ABI 中可用的最接近的类型。
return new OwnedToken(name);
}
function changeName(OwnedToken tokenAddress, bytes32 name) public {
// 同样,`tokenAddress` 的外部类型
// 也是 `address`。
tokenAddress.changeName(name);
}
// 执行检查以确定是否应该继续将代币转让给
// `OwnedToken` 合约
function isTokenTransferOK(address currentOwner, address newOwner)
public
pure
returns (bool ok)
{
// 检查任意条件以查看转让是否应该继续
return keccak256(abi.encodePacked(currentOwner, newOwner))[0] == 0x7f;
}
}
可见性和 getter 函数
状态变量可见性
public
公共状态变量与内部状态变量的不同之处在于,编译器会自动为它们生成 getter 函数,这允许其他合约读取它们的值。 在同一合约内使用时,外部访问(例如
this.x
)会调用 getter,而内部访问(例如x
)则直接从存储中获取变量值。 不会生成 Setter函数,因此其他合约无法直接修改它们的值。internal
内部状态变量只能在定义它们的合约及其派生合约中访问。 它们无法被外部访问。 这是状态变量的默认可见性级别。
private
私有状态变量类似于内部变量,但在派生合约中不可见。
警告
将某些内容设置为 private
或 internal
仅仅是防止其他合约读取或修改这些信息,但它仍然对区块链外的整个世界可见。
函数可见性
Solidity 有两种类型的函数调用:外部调用会创建实际的 EVM 消息调用,而内部调用则不会。 此外,内部函数可以对派生合约不可访问。 这产生了四种函数的可见性类型。
external
外部函数是合约接口的一部分, 这意味着它们可以从其他合约和通过交易调用。 外部函数
f
不能被内部调用(即f()
不起作用,但this.f()
有效)。public
公共函数是合约接口的一部分可以通过内部调用或消息调用。
internal
内部函数只能在当前合约内 或从其派生的合约中访问。 它们无法被外部访问。 由于它们没有通过合约的 ABI 暴露给外部,因此可以接受内部类型的参数,如映射或存储引用。
private
私有函数类似于内部函数,但在派生合约中不可见。
警告
将某些内容设置为 private
或 internal
仅仅是防止其他合约读取或修改这些信息,但它仍然对区块链外的整个世界可见。
可见性修改器在状态变量的类型后给出,在函数的参数列表和返回参数列表之间。
// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.4.16 <0.9.0;
contract C {
function f(uint a) private pure returns (uint b) { return a + 1; }
function setData(uint a) internal { data = a; }
uint public data;
}
在以下示例中,D
可以调用 c.getData()
来检索在状态存储中的 data
的值,但无法调用 f
。
合约 E
从 C
派生,因此可以调用 compute
。
// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.4.16 <0.9.0;
contract C {
uint private data;
function f(uint a) private pure returns(uint b) { return a + 1; }
function setData(uint a) public { data = a; }
function getData() public view returns(uint) { return data; }
function compute(uint a, uint b) internal pure returns (uint) { return a + b; }
}
// 这将无法编译
contract D {
function readData() public {
C c = new C();
uint local = c.f(7); // 错误:成员 `f` 不可见
c.setData(3);
local = c.getData();
local = c.compute(3, 5); // 错误:成员 `compute` 不可见
}
}
contract E is C {
function g() public {
C c = new C();
uint val = compute(3, 5); // 访问内部成员(从派生到父合约)
}
}
Getter 函数
编译器会自动为所有 public 状态变量创建 getter 函数。
对于下面给出的合约,编译器将生成一个名为 data
的函数,该函数不接受任何参数并返回一个 uint
,即状态变量 data
的值。
状态变量可以在声明时初始化。
// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.4.16 <0.9.0;
contract C {
uint public data = 42;
}
contract Caller {
C c = new C();
function f() public view returns (uint) {
return c.data();
}
}
getter 函数具有外部可见性。
如果变量在内部访问(即不带 this.
),它会被评估为状态变量。
如果被外部访问(即带有 this.
),它会被评估为一个函数。
// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.4.0 <0.9.0;
contract C {
uint public data;
function x() public returns (uint) {
data = 3; // 内部访问
return this.data(); // 外部访问
}
}
如果你有一个数组类型的 public
状态变量,那么你只能通过生成的 getter 函数检索数组的单个元素。
这个机制的存在是为了避免在返回整个数组时产生高昂的 gas 成本。
可以使用参数来指定要返回的单个元素,例如 myArray(0)
。如果想在一次调用中返回整个数组,那么需要编写一个函数,例如:
// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.4.16 <0.9.0;
contract arrayExample {
// 公共状态变量
uint[] public myArray;
// 编译器生成的 getter 函数
/*
function myArray(uint i) public view returns (uint) {
return myArray[i];
}
*/
// 返回整个数组的函数
function getArray() public view returns (uint[] memory) {
return myArray;
}
}
现在可以使用 getArray()
来检索整个数组,而不是 myArray(i)
, 这会每次调用返回一个单独的元素。
下一个示例更复杂:
// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.4.0 <0.9.0;
contract Complex {
struct Data {
uint a;
bytes3 b;
mapping(uint => uint) map;
uint[3] c;
uint[] d;
bytes e;
}
mapping(uint => mapping(bool => Data[])) public data;
}
它生成一个如下形式的函数。 结构中的映射和数组(字节数组除外)被省略,因为没有好的方法选择单个结构成员或为映射提供键:
function data(uint arg1, bool arg2, uint arg3)
public
returns (uint a, bytes3 b, bytes memory e)
{
a = data[arg1][arg2][arg3].a;
b = data[arg1][arg2][arg3].b;
e = data[arg1][arg2][arg3].e;
}
函数修改器
修改器 可以以声明的方式改变函数的行为。例如,你可以使用 修改器 在执行函数之前自动检查条件。
修改器 是合约的可继承属性,可以被派生合约重写 ,但只有在标记为 virtual
的情况下。有关详细信息,请参见 修改器重写。
// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.7.1 <0.9.0;
contract owned {
constructor() { owner = payable(msg.sender); }
address payable owner;
// 该合约仅定义了一个修改器,但未使用它:它将在派生合约中使用。
// 函数体插入在修改器定义中的特殊符号 `_;` 出现的位置。
// 这意味着如果所有者调用此函数,则函数将被执行,否则将抛出异常。
modifier onlyOwner {
require(
msg.sender == owner,
"Only owner can call this function."
);
_;
}
}
contract priced {
// 修改器可以接收参数:
modifier costs(uint price) {
if (msg.value >= price) {
_;
}
}
}
contract Register is priced, owned {
mapping(address => bool) registeredAddresses;
uint price;
constructor(uint initialPrice) { price = initialPrice; }
// 这里也必须提供 `payable` 关键字,否则该函数将自动拒绝所有发送给它的以太币。
function register() public payable costs(price) {
registeredAddresses[msg.sender] = true;
}
// 该合约从 `owned` 合约继承了 `onlyOwner` 修改器。
// 因此,调用 `changePrice` 仅在存储的所有者进行调用时才会生效。
function changePrice(uint price_) public onlyOwner {
price = price_;
}
}
contract Mutex {
bool locked;
modifier noReentrancy() {
require(
!locked,
"Reentrant call."
);
locked = true;
_;
locked = false;
}
/// 此函数受互斥锁保护,这意味着来自 `msg.sender.call` 的重入调用不能再次调用 `f`。
/// `return 7` 语句将 7 赋值给返回值,执行修改器中的语句 `locked = false`。仍会执行。
function f() public noReentrancy returns (uint) {
(bool success,) = msg.sender.call("");
require(success);
return 7;
}
}
如果你想访问合约 C
中定义的 修改器 m
,可以使用 C.m
来引用它,而无需虚拟查找。
只能使用当前合约或其基合约中定义的 修改器 。 修改器 也可以在库中定义,但是他们被限定在库函数使用。
通过在空格分隔的列表中指定多个修改器,可以将它们应用于一个函数,并按呈现的顺序进行评估。
修改器不能隐式访问或更改它们所修饰的函数的参数和返回值。 这些值只能在调用时显式传递给它们。
在函数修改器中,必须指定希望应用修饰符的函数何时运行。占位符语句(由单个下划线字符 _
表示)用于表示应插入被修饰函数的主体的位置。
请注意,占位符运算符与在变量名称中使用下划线作为前导或尾随字符不同,这是一种风格选择。
修改器 或函数主体的显式返回仅离开当前|modifier| 或函数主体。
返回变量被赋值,控制流在前面的 修改器 中的 _
之后继续。
警告
在早期版本的 Solidity 中,具有 修改器 的函数中的 return
语句的行为不同。
从 修改器 显式返回 return;
不会影响函数返回值。
但是,修改器 可以选择完全不执行函数主体,在这种情况下,返回变量被设置为 默认值,就像函数有一个空主体一样。
_
符号可以在 修改器 中多次出现。每次出现都被函数主体替换,函数返回最后一次出现的返回值。
修改器 参数允许任意表达式,在这种情况下,函数中可见的所有符号在 修改器 中都是可见的。 在 修改器 中引入的符号在函数中不可见(因为它们可能通过重载而改变)。
瞬态存储
瞬态存储是除了内存、存储、调用数据(以及返回数据和代码)之外的另一种数据位置,它与其各自的操作码 TSTORE
和 TLOAD
一起引入,参见 EIP-1153。
这种新的数据位置表现得像一个键值存储,类似于存储,主要区别在于瞬态存储中的数据不是永久的,而是仅限于当前交易的作用域,之后将重置为零。
由于瞬态存储的内容具有非常有限的生命周期和大小,因此不需要作为状态的一部分永久存储,并且相关的 gas 费用远低于存储的情况。
需要 EVM 版本 cancun
或更新版本才能使用瞬态存储。
瞬态存储变量不能在声明时初始化,即不能在声明时赋值,因为该值将在创建交易结束时被清除,从而使初始化无效。
瞬态变量将根据其底层类型进行 默认值 初始化。
constant
和 immutable
变量与瞬态存储冲突,因为它们的值要么是内联的,要么直接存储在代码中。
瞬态存储变量与存储具有完全独立的地址空间,因此瞬态状态变量的顺序不会影响存储状态变量的布局,反之亦然。 不过,它们需要不同的名称,因为所有状态变量共享相同的命名空间。 还需要注意的是,瞬态存储中的值以与持久存储中相同的方式打包。 有关更多信息,请参见 存储布局。
此外,瞬态变量也可以具有可见性,public
变量将像往常一样自动生成一个 getter 函数。
请注意,目前,作为数据位置的 transient
仅允许用于 值类型 状态变量声明。
引用类型,如数组、映射和结构体,以及局部或参数变量尚不支持。
瞬态存储的一个预期典型用例是更便宜的重入锁,这可以通过操作码轻松实现,如下所示。
// SPDX-License-Identifier: GPL-3.0
pragma solidity ^0.8.28;
contract Generosity {
mapping(address => bool) sentGifts;
bool transient locked;
modifier nonReentrant {
require(!locked, "Reentrancy attempt");
locked = true;
_;
// 解锁保护,使模式可组合。
// 函数退出后,可以再次调用,即使在同一交易中。
locked = false;
}
function claimGift() nonReentrant public {
require(address(this).balance >= 1 ether);
require(!sentGifts[msg.sender]);
(bool success, ) = msg.sender.call{value: 1 ether}("");
require(success);
// 在重入函数中,最后这样做会打开漏洞
sentGifts[msg.sender] = true;
}
}
瞬态存储对拥有它的合约是私有的,方式与持久存储相同。 只有拥有合约的帧可以访问其瞬态存储,当它们访问时,所有帧访问相同的瞬态存储。
瞬态存储是 EVM 状态的一部分,并受到与持久存储相同的可变性强制执行的约束。
因此,任何对它的读取访问都不是 pure
,写入访问也不是 view
。
如果在 STATICCALL
的上下文中调用 TSTORE
操作码,将导致异常,而不是执行修改。
在 STATICCALL
的上下文中允许使用 TLOAD
。
当在 DELEGATECALL
或 CALLCODE
的上下文中使用瞬态存储时,瞬态存储的拥有合约是发出 DELEGATECALL
或 CALLCODE
指令的合约(调用者),与持久存储相同。
当在 CALL
或 STATICCALL
的上下文中使用瞬态存储时,瞬态存储的拥有合约是 CALL
或 STATICCALL
指令的目标合约(被调用者)。
备注
在 DELEGATECALL
的情况下,由于当前不支持对瞬态存储变量的引用,因此无法将其传递给库调用。
在库中,访问瞬态存储只能通过内联汇编实现。
如果一个帧回滚,则在进入帧和返回之间对瞬态存储的所有写入都将被回滚,包括在内部调用中进行的写入。
外部调用的调用者可以使用 try ... catch
块来防止回滚从内部调用中冒泡。
智能合约的可组合性与瞬态存储的注意事项
鉴于 EIP-1153 规范中提到的注意事项,为了保持智能合约的可组合性,建议在更高级的瞬态存储用例中格外小心。
对于智能合约,可组合性是实现自包含行为的一个非常重要的设计原则,使得对单个智能合约的多次调用可以组合成更复杂的应用程序。 到目前为止,EVM 在很大程度上保证了可组合行为,因为在复杂交易中对智能合约的多次调用与在多个交易中对合约的多次调用在本质上是不可区分的。 然而,瞬态存储允许违反这一原则,不正确的使用可能导致复杂的错误,这些错误仅在跨多个调用时显现。
让我们用一个简单的例子来说明这个问题:
// SPDX-License-Identifier: GPL-3.0
pragma solidity ^0.8.28;
contract MulService {
uint transient multiplier;
function setMultiplier(uint mul) external {
multiplier = mul;
}
function multiply(uint value) external view returns (uint) {
return value * multiplier;
}
}
以及一系列外部调用:
setMultiplier(42);
multiply(1);
multiply(2);
如果示例使用内存或存储来存储乘数,它将是完全可组合的。
无论是将序列拆分为单独的交易还是以某种方式将它们组合在一起,都没有关系。
总是会得到相同的结果:在 multiplier
设置为 42
后,后续调用将分别返回 42
和 84
。
这使得可以将来自多个交易的调用批量处理在一起以减少 gas 费用。
瞬态存储可能会破坏这样的用例,因为可组合性不再是理所当然的。
在这个例子中,如果调用不是在同一交易中执行的,则 multiplier
将被重置,后续对函数 multiply
的调用将始终返回 0
。
作为另一个例子,由于瞬态存储被构造为相对便宜的键值存储,智能合约作者可能会被诱使将瞬态存储用作内存映射的替代品,而不跟踪映射中修改的键,从而在调用结束时不清除映射。 然而,这可能会在复杂交易中导致意想不到的行为,其中在同一交易中对合约的先前调用设置的值仍然存在。 使用瞬态存储来处理在调用框架结束时清除的重入锁是安全的。 然而,请务必抵制节省重入锁重置所需的 100 gas 的诱惑,因为不这样做将限制你的合约在一个交易中只能进行一次调用,从而阻止其在复杂组合交易中的使用,而复杂组合交易一直是链上复杂应用的基石。
建议在调用智能合约结束时,通常始终完全清除瞬态存储,以避免此类问题,并简化对合约在复杂交易中行为的分析。 有关更多详细信息,请查看 EIP-1153 的 安全考虑 部分 <https://eips.ethereum.org/EIPS/eip-1153#security-considerations>`_。
常量和不可变状态变量
状态变量可以声明为 constant
(常量)或 immutable
(不可变量)。
在这两种情况下,变量在合约构造后不能被修改。
对于 constant
变量,值必须在编译时固定,而对于 immutable
,它仍然可以在构造时赋值。
也可以在文件级别定义 constant
变量。
源代码中每次出现这样的变量都会被其基础值替换,编译器不会为其保留存储槽。
它也不能使用 transient
关键字在临时存储中分配槽。
与常规状态变量相比,常量和不可变变量的 gas 成本要低得多。对于常量,赋值给它的表达式会被复制到所有访问它的地方,并且每次都会重新评估。这允许进行局部优化。不可变变量在构造时评估一次,其值会被复制到代码中所有访问它的地方。对于这些值,保留 32 字节,即使它们可以适应更少的字节。因此,常量值有时可能比不可变值更便宜。
目前并不是所有类型的常量和不可变变量都已实现。唯一支持的类型是 strings (仅适用于常量)和 value types。
// SPDX-License-Identifier: GPL-3.0
pragma solidity ^0.8.21;
uint constant X = 32**22 + 8;
contract C {
string constant TEXT = "abc";
bytes32 constant MY_HASH = keccak256("abc");
uint immutable decimals = 18;
uint immutable maxBalance;
address immutable owner = msg.sender;
constructor(uint decimals_, address ref) {
if (decimals_ != 0)
// 仅在部署时不可变。
// 在构造时可以被赋值多次。
decimals = decimals_;
// 对不可变变量的赋值甚至可以访问(上下文)环境。
maxBalance = ref.balance;
}
function isBalanceTooHigh(address other) public view returns (bool) {
return other.balance > maxBalance;
}
}
常量
对于 constant
变量,值必须在编译时是常量,并且必须在变量声明时赋值。任何访问存储、区块链数据(例如 block.timestamp
、address(this).balance
或
block.number
)或执行数据(msg.value
或 gasleft()
)或调用外部合约的表达式都是不允许的。可能对内存分配产生副作用的表达式是允许的,但可能对其他内存对象产生副作用的表达式则不允许。内置函数 keccak256
、sha256
、ripemd160
、ecrecover
、addmod
和 mulmod
是允许的(尽管除了 keccak256
之外,它们确实调用外部合约)。
允许对内存分配器的副作用的原因是,它应该能够构造复杂对象,例如查找表。此功能尚未完全可用。
不可变量
声明为 immutable
的变量比声明为 constant
的变量限制稍少:不可变变量可以在构造时赋值。
在部署之前,值可以随时更改,然后它变得永久。
另一个额外的限制是,不可变变量的赋值只能在创建后不会被执行的表达式中。 这排除了所有修改器定义和构造函数以外的函数。
读取不可变变量没有限制。 读取甚至可以在变量第一次写入之前发生,因为 Solidity 中的变量始终具有明确定义的初始值。 因此,也允许不显式地给不可变变量赋值。
警告
在构造时访问不可变变量时,请记住 初始化顺序。 即使你提供了显式初始化器,一些表达式可能会在该初始化器之前被评估,特别是当它们位于继承层次结构的不同级别时。
备注
在 Solidity 0.8.21 之前,不可变变量的初始化限制更严格。 此类变量必须在构造时初始化一次,并且在那之前不能读取。
编译器生成的合约创建代码将在返回之前修改合约的运行时代码,通过用赋值替换所有对不可变变量的引用。这在你比较编译器生成的运行时代码与实际存储在区块链中的代码时非常重要。编译器在 compiler JSON standard output 的 immutableReferences
字段中输出这些不可变变量在部署字节码中的位置。
函数
函数可以在合约内外定义。
合约外的函数,也称为“自由函数”,始终具有隐式的 internal
可见性。
它们的代码包含在所有调用它们的合约中,类似于内部库函数。
// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.7.1 <0.9.0;
function sum(uint[] memory arr) pure returns (uint s) {
for (uint i = 0; i < arr.length; i++)
s += arr[i];
}
contract ArrayExample {
bool found;
function f(uint[] memory arr) public {
// 这在内部调用自由函数。
// 编译器会将其代码添加到合约中。
uint s = sum(arr);
require(s >= 10);
found = true;
}
}
备注
在合约外定义的函数仍然在合约的上下文中执行。
它们仍然可以调用其他合约,向它们发送以太,并销毁调用它们的合约,以及其他事情。
与在合约内定义的函数的主要区别是自由函数无法直接访问变量 this
、存储变量和不在其作用域范围内的函数。
函数参数和返回变量
函数接受类型化参数作为输入,并且与许多其他语言不同,它们也可以返回任意数量的值作为输出。
函数参数
函数参数的声明方式与变量相同,未使用参数的名称可以省略。
例如,如果你希望合约接受一种带有两个整数的外部调用,你可以使用如下内容:
// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.4.16 <0.9.0;
contract Simple {
uint sum;
function taker(uint a, uint b) public {
sum = a + b;
}
}
函数参数可以像其它局部变量一样使用,并且它们也可以被赋值。
返回变量
函数返回变量语法声明与 returns
关键字后的语法声明相同。
例如,假设你想返回两个结果:两个作为函数参数传递的整数的和与积,那么你可以使用如下内容:
// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.4.16 <0.9.0;
contract Simple {
function arithmetic(uint a, uint b)
public
pure
returns (uint sum, uint product)
{
sum = a + b;
product = a * b;
}
}
返回变量的名称可以省略。 返回变量可以像其他局部变量一样使用,并且它们以其 默认值 初始化,并在被(重新)赋值之前保持该值。
可以显式地赋值给返回变量,然后像上面那样离开函数,或者可以通过 return
语句直接提供返回值(可以是单个或 多个值):
// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.4.16 <0.9.0;
contract Simple {
function arithmetic(uint a, uint b)
public
pure
returns (uint sum, uint product)
{
return (a + b, a * b);
}
}
如果使用早期的 return
离开一个具有返回变量的函数,必须与返回语句一起提供返回值。
备注
不能从非内部函数返回某些类型。 这包括以下列出的类型以及任何递归包含它们的复合类型:
mappings (映射),
内部函数类型,
位置设置为
storage
的引用类型,多维数组(仅适用于 ABI 编码器 v1),
结构体(仅适用于 ABI 编码器 v1)。
由于库函数的不同 内部 ABI,此限制不适用于库函数。
返回多个值
当一个函数有多个返回类型时,可以使用语句 return (v0, v1, ..., vn)
返回多个值。
组件的数量必须与返回变量的数量相同,并且它们的类型必须匹配,可能在 隐式转换 之后。
状态可变性
视图函数
函数可以声明为 view
,在这种情况下它们承诺不修改状态。
备注
如果编译器的 EVM 目标是 Byzantium 或更新版本(默认),则在调用 view
函数时使用操作码 STATICCALL
,这强制状态在 EVM 执行过程中保持不变。
对于库 view
函数使用 DELEGATECALL
,因为没有组合的 DELEGATECALL
和 STATICCALL
。
这意味着库 view
函数没有运行时检查来防止状态修改。这不应对安全性产生负面影响,因为库代码通常在编译时已知,静态检查器执行编译时检查。
以下语句被视为修改状态:
写入状态变量(存储和临时存储)。
发出事件。
使用
selfdestruct
。通过调用发送以太。
调用任何未标记为
view
或pure
的函数。使用低级调用。
使用包含某些操作码的内联汇编。
// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.5.0 <0.9.0;
contract C {
function f(uint a, uint b) public view returns (uint) {
return a * (b + 42) + block.timestamp;
}
}
备注
constant
在函数上曾经是 view
的别名,但在 0.5.0 版本中被删除。
备注
Getter 方法自动标记为 view
。
备注
在 0.5.0 版本之前,编译器未对 view
函数使用 STATICCALL
操作码。
这使得通过使用无效的显式类型转换在 view
函数中进行状态修改成为可能。
通过对 view
函数使用 STATICCALL
,在 EVM 层面上防止了对状态的修改。
纯函数
函数可以声明为 pure
,在这种情况下,函数承诺不读取或修改状态。
特别是,给定仅其输入和 msg.data
,但不需要了解当前区块链状态,应该能够在编译时评估 pure
函数。
这意味着读取 immutable
变量可能是非纯操作。
备注
如果编译器的 EVM 编译目标设置为 Byzantium 或更新版本(默认),则使用操作码 STATICCALL
,这并不保证状态不被读取,但至少保证状态不被修改。
除了上述解释的状态修改语句列表,以下被视为读取状态:
从状态变量(存储和临时存储)读取。
访问
address(this).balance
或<address>.balance
。访问
block
、tx
、msg
的任何成员(msg.sig
和msg.data
除外)。调用任何未标记为
pure
的函数。使用包含某些操作码的内联汇编。
// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.5.0 <0.9.0;
contract C {
function f(uint a, uint b) public pure returns (uint) {
return a * (b + 42);
}
}
纯函数能够使用 revert()
和 require()
函数在发生 错误 时恢复潜在的状态更改。
恢复状态变化不被视为“状态修改”,因为只有在代码中先前进行的、未带有 view
或 pure
限制的状态更改被恢复,而且该代码可以选择捕捉 revert
并不传递它。
这种行为也符合 STATICCALL
操作码。
警告
在 EVM 层面上,无法阻止函数读取状态,只能阻止它们写入状态(即只能在 EVM 层面上强制执行 view
,而无法强制执行 pure
)。
备注
在 0.5.0 版本之前,编译器未对 pure
函数使用 STATICCALL
操作码。
这使得通过使用无效的显式类型转换在 pure
函数中启用了状态修改。
通过对 pure
函数使用 STATICCALL
,在 EVM 层面上防止了对状态的修改。
备注
在 0.4.17 版本之前,编译器未强制 pure
不读取状态。
这是一种编译时类型检查,可以通过进行无效的显式转换来规避合约类型之间的转换,因为编译器可以验证合约的类型不执行状态更改操作,但无法检查在运行时将被调用的合约实际上是否属于该类型。
特殊函数
接收以太币函数
一个合约最多可以有一个 receive
函数,声明为 receive() external payable { ... }
(不带 function
关键字)。
该函数不能有参数,不能返回任何内容,必须具有 external
可见性和 payable
状态可变性。
它可以是虚拟的,可以重写,并且可以有 修改器。
接收函数在调用合约时执行,且没有提供任何 calldata。这是执行普通以太转账时调用的函数(例如通过 .send()
或 .transfer()
)。
如果不存在这样的函数,但存在可支付的 回退函数,则在普通以太转账时将调用回退函数。
如果既没有接收以太函数也没有可支付的回退函数,合约将无法通过调用不可支付函数来接收以太,将抛出异常。
在最坏的情况下,receive
函数只有 2300 gas 可用(例如当使用 send
或 transfer
时),几乎没有空间执行其他操作,除了基本的日志记录。
以下操作将消耗超过 2300 gas 补贴:
写入存储
创建合约
调用消耗大量 gas 的外部函数
发送以太币
警告
当以太币直接发送到合约(没有函数调用,即发送者使用 send
或 transfer
),但接收合约未定义接收以太币函数或可支付回退函数时,将抛出异常,退回以太币(在 Solidity v0.4.0 之前是不同的)。
如果你希望你的合约接收以太币,你必须实现接收以太币函数(使用可支付回退函数接收以太并不推荐,因为回退会被调用,并且不会因发送者的接口混淆而失败)。
警告
没有接收以太币函数的合约可以作为 coinbase 交易 (即 矿工区块奖励)的接收者或作为 selfdestruct
的目标接收以太币。
合约无法对这种以太转账做出反应,因此也无法拒绝它们。这是 EVM 的设计选择,Solidity 无法规避。
这也意味着 address(this).balance
可能高于合约中实现的一些手动会计的总和(即在接收以太币函数中更新计数器)。
下面是一个使用 receive
函数的 Sink 合约示例。
// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.6.0 <0.9.0;
// 该合约保留所有发送给它的以太币,无法取回。
contract Sink {
event Received(address, uint);
receive() external payable {
emit Received(msg.sender, msg.value);
}
}
回退函数
一个合约最多可以有一个 fallback
函数,声明为 fallback () external [payable]
或 fallback (bytes calldata input) external [payable] returns (bytes memory output)
(两者均不带 function
关键字)。
该函数必须具有 external
可见性。回退函数可以是虚拟的,可以重写,并且可以有修改器。
如果没有其他函数与给定的函数签名匹配,或者根本没有提供数据且没有 接收以太函数,、则在调用合约时执行回退函数。
回退函数始终接收数据,但为了接收以太币,它必须标记为 payable
。
如果使用带参数的版本,input
将包含发送到合约的完整数据(等于 msg.data
),并可以在 output
中返回数据。
返回的数据将不会被 ABI 编码。相反,它将未经修改(甚至不填充)返回。
在最坏的情况下,如果可支付的回退函数也用作接收函数,则它只能依赖于 2300 gas 可用(请参阅 接收以太函数 以简要描述其影响)。
像任何函数一样,只要传递给它的 gas 足够,回退函数可以执行复杂的操作。
警告
如果没有 接收以太币函数,则对于普通以太币转账,也会执行 payable
回退函数。
建议如果你定义可支付回退函数,也始终定义接收以太币函数,以区分以太币转账和接口混淆。
备注
如果想解码输入数据,可以检查前四个字节以获取函数选择器,然后可以使用 abi.decode
结合数组切片语法来解码 ABI 编码的数据:
(c, d) = abi.decode(input[4:], (uint256, uint256));
请注意,这应仅作为最后的手段使用,应该使用适当的函数。
// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.6.2 <0.9.0;
contract Test {
uint x;
// 此函数会被调用以处理发送到此合约的所有消息(没有其他函数)。
// 向此合约发送以太币将导致异常,因为回退函数没有 `payable`修改器。
fallback() external { x = 1; }
}
contract TestPayable {
uint x;
uint y;
// 此函数会被调用以处理发送到此合约的所有消息,除了普通的以太币转账(除了接收函数外没有其他函数)。
// 任何带有非空 calldata 的调用都会执行回退函数(即使在调用时发送了以太币)。
fallback() external payable { x = 1; y = msg.value; }
// 此函数会被调用以处理普通的以太币转账,即
// 对于每个带有空 calldata 的调用。
receive() external payable { x = 2; y = msg.value; }
}
contract Caller {
function callTest(Test test) public returns (bool) {
(bool success,) = address(test).call(abi.encodeWithSignature("nonExistingFunction()"));
require(success);
// 结果是 test.x 变成 == 1。
// address(test) 不允许直接调用 ``send``,因为 ``test`` 没有 payable 回退函数。
// 必须将其转换为 ``address payable`` 类型才能允许调用 ``send``。
address payable testPayable = payable(address(test));
// 如果有人向该合约发送以太币,转账将失败,即这里返回 false。
return testPayable.send(2 ether);
}
function callTestPayable(TestPayable test) public returns (bool) {
(bool success,) = address(test).call(abi.encodeWithSignature("nonExistingFunction()"));
require(success);
// 结果是 test.x 变为 == 1,test.y 变为 0。
(success,) = address(test).call{value: 1}(abi.encodeWithSignature("nonExistingFunction()"));
require(success);
// 结果是 test.x 变为 == 1,test.y 变为 1。
// 如果有人向该合约发送以太币,TestPayable 中的接收函数将被调用。
// 由于该函数写入存储,它消耗的 gas 比简单的 ``send`` 或 ``transfer`` 更多。
// 因此,我们必须使用低级调用。
(success,) = address(test).call{value: 2 ether}("");
require(success);
// 结果是 test.x 变为 == 2,test.y 变为 2 ether。
return true;
}
}
函数重载
一个合约可以有多个同名但参数类型不同的函数。
这个过程称为“重载”,也适用于继承的函数。
以下示例展示了合约 A
范围内函数 f
的重载。
// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.4.16 <0.9.0;
contract A {
function f(uint value) public pure returns (uint out) {
out = value;
}
function f(uint value, bool really) public pure returns (uint out) {
if (really)
out = value;
}
}
重载函数也存在于外部接口中。如果两个 外部可见的函数在 Solidity 类型上不同但在外部类型上相同,则会出错。
// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.4.16 <0.9.0;
// 这将无法编译
contract A {
function f(B value) public pure returns (B out) {
out = value;
}
function f(address value) public pure returns (address out) {
out = value;
}
}
contract B {
}
上述两个 f
函数重载最终都接受地址类型用于 ABI,尽管
它们在 Solidity 内部被视为不同。
重载解析和参数匹配
通过将当前范围内的函数声明与函数调用中提供的参数进行匹配来选择重载函数。 如果所有参数都可以隐式转换为预期类型,则函数被选为重载候选。 如果没有恰好一个候选,解析将失败。
备注
返回参数不计入重载解析。
// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.4.16 <0.9.0;
contract A {
function f(uint8 val) public pure returns (uint8 out) {
out = val;
}
function f(uint256 val) public pure returns (uint256 out) {
out = val;
}
}
调用 f(50)
将产生类型错误,因为 50
可以隐式转换为 uint8
和 uint256
类型。另一方面,f(256)
将解析为 f(uint256)
重载,因为 256
不能隐式
转换为 uint8
。
事件
Solidity 事件 在 EVM 的日志功能之上提供了一个抽象层。 应用程序可以通过以太坊客户端的 RPC 接口订阅和监听这些事件。
事件可以在文件级别定义或作为合约(包括接口和库)的可继承成员定义。 当你调用它们时,它们会导致参数被存储在交易的日志中——区块链中的一种特殊数据结构。这些日志与发出它们的合约地址相关联,包含在区块链中,并在一个区块可访问的时间内保留(目前是永久的,但未来可能会改变)。日志及其事件数据无法从合约内部访问(甚至无法从创建它们的合约访问)。
可以请求日志的 Merkle 证明,因此如果外部实体向合约提供这样的证明,它可以检查日志是否确实存在于区块链中。你必须提供区块头,因为合约只能看到最后 256 个区块哈希。
你可以将属性 indexed
添加到最多三个参数,这会将它们添加到一个称为 “topics” 的特殊数据结构中,而不是日志的数据部分。
一个 主题 只能容纳一个单词(32 字节),因此如果你对一个索引参数使用 引用类型,则该值的 Keccak-256 哈希将作为主题存储。
所有没有 indexed
属性的参数都被 ABI 编码 到日志的数据部分。
主题允许你搜索事件,例如在过滤一系列区块以查找特定事件时。你还可以通过发出事件的合约地址过滤事件。
例如,下面的代码使用 web3.js subscribe("logs")
方法 来过滤与某个地址值匹配的主题的日志:
var options = {
fromBlock: 0,
address: web3.eth.defaultAccount,
topics: ["0x0000000000000000000000000000000000000000000000000000000000000000", null, null]
};
web3.eth.subscribe('logs', options, function (error, result) {
if (!error)
console.log(result);
})
.on("data", function (log) {
console.log(log);
})
.on("changed", function (log) {
});
事件的签名哈希是主题之一,除非你使用 anonymous
修改器声明事件。这意味着无法按名称过滤特定的匿名事件,只能按合约地址过滤。匿名事件的优点是它们的部署和调用成本更低。它还允许你声明四个索引参数而不是三个。
备注
由于交易日志只存储事件数据而不存储类型,你必须知道事件的类型,包括哪个参数是索引的,以及事件是否是匿名的,以便正确解释数据。 特别是,可以使用匿名事件“伪造”另一个事件的签名。
Events 成员
event.selector
: 对于非匿名事件,这是一个bytes32
值 包含事件签名的keccak256
哈希,作为默认主题使用。
示例
// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.4.21 <0.9.0;
contract ClientReceipt {
event Deposit(
address indexed from,
bytes32 indexed id,
uint value
);
function deposit(bytes32 id) public payable {
// 事件通过 `emit` 发出,后跟事件的名称和(如果有的话)括号中的参数
// 任何这样的调用(即使是深度嵌套)都可以通过
// JavaScript API 通过过滤 `Deposit` 来检测。
emit Deposit(msg.sender, id, msg.value);
}
}
在 JavaScript API 中的用法如下:
var abi = /* abi 由编译器产生 */;
var ClientReceipt = web3.eth.contract(abi);
var clientReceipt = ClientReceipt.at("0x1234...ab67" /* 地址 */);
var depositEvent = clientReceipt.Deposit();
// 监听变化
depositEvent.watch(function(error, result){
// result 包含非索引参数和
// 传递给 `Deposit` 调用的主题。
if (!error)
console.log(result);
});
// 或者传递一个回调以立即开始监视
var depositEvent = clientReceipt.Deposit(function(error, result) {
if (!error)
console.log(result);
});
上述输出如下(已修剪):
{
"returnValues": {
"from": "0x1111…FFFFCCCC",
"id": "0x50…sd5adb20",
"value": "0x420042"
},
"raw": {
"data": "0x7f…91385",
"topics": ["0xfd4…b4ead7", "0x7f…1a91385"]
}
}
理解事件的其他资源
自定义错误
Solidity 中的错误提供了一种方便且节省 gas 的方式来向用户解释操作失败的原因。它们可以在合约内部和外部(包括接口和库)定义。
它们必须与 revert 语句 或 require 函数 一起使用。
在 revert
语句或 require
调用中,如果条件被评估为 false,则当前调用中的所有更改都会被回滚,错误数据会传回调用者。
下面的示例展示了在函数 transferWithRevertError
中使用 revert
语句的自定义错误,以及在函数 transferWithRequireError
中使用 require
的新方法。
// SPDX-License-Identifier: GPL-3.0
pragma solidity ^0.8.27;
/// 转账余额不足。需要 `required` 但只有
/// `available` 可用。
/// @param available 可用余额。
/// @param required 请求转账的金额。
error InsufficientBalance(uint256 available, uint256 required);
contract TestToken {
mapping(address => uint) balance;
function transferWithRevertError(address to, uint256 amount) public {
if (amount > balance[msg.sender])
revert InsufficientBalance({
available: balance[msg.sender],
required: amount
});
balance[msg.sender] -= amount;
balance[to] += amount;
}
function transferWithRequireError(address to, uint256 amount) public {
require(amount <= balance[msg.sender], InsufficientBalance(balance[msg.sender], amount));
balance[msg.sender] -= amount;
balance[to] += amount;
}
// ...
}
另一个重要的细节是,当使用 require
和自定义错误时,错误基础的回滚原因的内存分配仅在回滚的情况下发生,这与常量和字符串字面量的优化一起,使其在 gas 效率上与 if (!condition) revert CustomError(args)
模式相当。
错误不能被重载或重写,但可以被继承。
同一个错误可以在多个地方定义,只要作用域不同。
错误的实例只能通过 revert
语句创建,或作为 require
函数的第二个参数。
错误创建的数据会在回滚操作中传递给调用者,以便返回给链外组件或在 try/catch 语句 中捕获。 请注意,只有来自外部调用的错误才能被捕获,内部调用或同一函数内发生的回滚无法被捕获。
如果你不提供任何参数,错误只需要四个字节的数据,你可以使用 NatSpec 来进一步解释错误背后的原因,这些原因不会存储在链上。 这使得这是一个非常便宜且方便的错误报告功能。
更具体地说,一个错误实例的ABI编码方式与调用相同名称和类型的函数的方式相同,然后作为 revert
操作码中的返回数据使用。
这意味着数据由一个 4 字节的选择器和 ABI 编码 数据组成。
选择器由错误类型签名的 keccak256 哈希的前四个字节组成。
备注
合约可以不同的错误名称或甚至不同位置定义的错误进行回滚,这些错误对调用者来说是不可区分的。对于外部,即 ABI,只有错误的名称是相关的,而不是定义它的合约或文件。
如果你可以定义 error Error(string)
,语句 require(condition, "description");
等价于 if (!condition) revert Error("description")
。
但是,请注意,Error
是内置类型,不能在用户提供的代码中定义。
同样,失败的 assert
或类似条件将以内置类型 Panic(uint256)
的错误进行回滚。
备注
错误数据应仅用于指示失败,而不是作为控制流的手段。原因是内部调用的回滚数据默认通过外部调用链传播。这意味着内部调用可以“伪造”看似来自调用它的合约的回滚数据。
Errors 成员
error.selector
: 一个bytes4
值,包含错误选择器。
继承
Solidity 支持多重继承,包括多态。
多态意味着函数调用(内部和外部)始终在继承层次结构中最派生的合约中执行同名(和参数类型相同)的函数。这必须在层次结构中的每个函数上显式启用,使用 virtual
和 override
关键字。有关更多详细信息,请参见 函数重写。
可以通过显式指定合约来在继承层次结构中更高层次上内部调用函数,使用 ContractName.functionName()
或使用 super.functionName()
如果你想调用在扁平化继承层次结构中高一层的函数(见下文)。
当一个合约从其他合约继承时,区块链上只创建一个合约,所有基类合约的代码都被编译到创建的合约中。这意味着对基类合约函数的所有内部调用也仅使用内部函数调用(super.f(..)
将使用 JUMP 而不是消息调用)。
状态变量遮蔽被视为错误。派生合约只能声明状态变量 x
,如果在其任何基类中没有可见的同名状态变量。
一般的继承系统与 Python’s 非常相似,特别是在多重继承方面,但也有一些 差异。
以下示例提供了详细信息。
// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.7.0 <0.9.0;
contract Owned {
address payable owner;
constructor() { owner = payable(msg.sender); }
}
// 使用 `is` 从另一个合约派生。派生合约可以访问所有非私有成员,包括内部函数和状态变量。然而,这些不能通过 `this` 从外部访问。
contract Emittable is Owned {
event Emitted();
// 关键字 `virtual` 意味着该函数可以在派生类中改变其行为(“重写”)。
function emitEvent() virtual public {
if (msg.sender == owner)
emit Emitted();
}
}
// 这些抽象合约仅用于使接口为编译器所知。注意没有主体的函数。如果合约没有实现所有函数,它只能用作接口。
abstract contract Config {
function lookup(uint id) public virtual returns (address adr);
}
abstract contract NameReg {
function register(bytes32 name) public virtual;
function unregister() public virtual;
}
// 多重继承是可能的。注意 `Owned` 也是 `Emittable` 的基类,但只有一个 `Owned` 的实例(就像 C++ 中的虚拟继承一样)。
contract Named is Owned, Emittable {
constructor(bytes32 name) {
Config config = Config(0xD5f9D8D94886E70b06E474c3fB14Fd43E2f23970);
NameReg(config.lookup(1)).register(name);
}
// 函数可以被另一个具有相同名称和相同数量/类型输入的函数重写。如果重写函数具有不同类型的输出参数,则会导致错误。
// 本地和基于消息的函数调用都会考虑这些重写。
// 如果你希望函数重写,你需要使用 `override` 关键字。如果你希望这个函数再次被重写,你需要再次指定 `virtual` 关键字。
function emitEvent() public virtual override {
if (msg.sender == owner) {
Config config = Config(0xD5f9D8D94886E70b06E474c3fB14Fd43E2f23970);
NameReg(config.lookup(1)).unregister();
// 仍然可以调用特定的重写函数。
Emittable.emitEvent();
}
}
}
// 如果构造函数需要一个参数,则需要在派生合约的构造函数的头部或修改器调用样式中提供。
contract PriceFeed is Owned, Emittable, Named("GoldFeed") {
uint info;
function updateInfo(uint newInfo) public {
if (msg.sender == owner) info = newInfo;
}
// 在这里,我们只指定 `override` 而不指定 `virtual`。
// 这意味着从 `PriceFeed` 派生的合约不能再改变 `emitEvent` 的行为。
function emitEvent() public override(Emittable, Named) { Named.emitEvent(); }
function get() public view returns(uint r) { return info; }
}
注意上面,我们调用 Emittable.emitEvent()
来“转发”发出事件请求。这样做是有问题的,如下例所示:
// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.7.0 <0.9.0;
contract Owned {
address payable owner;
constructor() { owner = payable(msg.sender); }
}
contract Emittable is Owned {
event Emitted();
function emitEvent() virtual public {
if (msg.sender == owner) {
emit Emitted();
}
}
}
contract Base1 is Emittable {
event Base1Emitted();
function emitEvent() public virtual override {
/* 在这里,我们发出一个事件以模拟一些 Base1 逻辑 */
emit Base1Emitted();
Emittable.emitEvent();
}
}
contract Base2 is Emittable {
event Base2Emitted();
function emitEvent() public virtual override {
/* 在这里,我们发出一个事件以模拟一些 Base2 逻辑 */
emit Base2Emitted();
Emittable.emitEvent();
}
}
contract Final is Base1, Base2 {
event FinalEmitted();
function emitEvent() public override(Base1, Base2) {
/* 在这里,我们发出一个事件以模拟一些 Final 逻辑 */
emit FinalEmitted();
Base2.emitEvent();
}
}
对 Final.emitEvent()
的调用将调用 Base2.emitEvent
,因为我们在最终重写中显式指定了它,但这个函数将绕过 Base1.emitEvent
,导致以下事件序列:
FinalEmitted -> Base2Emitted -> Emitted
,而不是预期的序列:
FinalEmitted -> Base2Emitted -> Base1Emitted -> Emitted
。
解决此问题的方法是使用 super
:
// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.7.0 <0.9.0;
contract Owned {
address payable owner;
constructor() { owner = payable(msg.sender); }
}
contract Emittable is Owned {
event Emitted();
function emitEvent() virtual public {
if (msg.sender == owner) {
emit Emitted();
}
}
}
contract Base1 is Emittable {
event Base1Emitted();
function emitEvent() public virtual override {
/* 在这里,我们发出一个事件以模拟一些 Base1 逻辑 */
emit Base1Emitted();
super.emitEvent();
}
}
contract Base2 is Emittable {
event Base2Emitted();
function emitEvent() public virtual override {
/* 在这里,我们发出一个事件以模拟一些 Base2 逻辑 */
emit Base2Emitted();
super.emitEvent();
}
}
contract Final is Base1, Base2 {
event FinalEmitted();
function emitEvent() public override(Base1, Base2) {
/* 在这里,我们发出一个事件以模拟一些 Final 逻辑 */
emit FinalEmitted();
super.emitEvent();
}
}
如果 Final
调用 super
的一个函数,它并不只是简单地在其基合约之一上调用此函数。
相反,它在最终继承图中的下一个基合约上调用此函数,因此它将调用 ``Base1.emitEvent()``(请注意最终继承顺序是 – 从最派生的合约开始:Final, Base2, Base1, Emittable, Owned)。
在使用 super 时调用的实际函数在使用它的类的上下文中并不为人所知,尽管其类型是已知的。
这与普通的虚拟方法查找类似。
函数重写
基函数可以通过继承合约进行重写,以改变其行为,如果它们被标记为 virtual
。
重写的函数必须在函数头中使用 override
关键字。
重写的函数只能将被重写函数的可见性从 external
更改为 public
。
可变性可以按照以下顺序更改为更严格的:
nonpayable
可以被 view
和 pure
重写。 view
可以被 pure
重写。
payable
是一个例外,不能更改为任何其他可变性。
以下示例演示了可变性和可见性的更改:
// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.7.0 <0.9.0;
contract Base
{
function foo() virtual external view {}
}
contract Middle is Base {}
contract Inherited is Middle
{
function foo() override public pure {}
}
对于多重继承,必须在 override
关键字后显式指定定义相同函数的最派生基合约。
换句话说,你必须指定所有定义相同函数的基合约并且尚未被另一个基合约重写(在继承图的某些路径上)。
此外,如果一个合约从多个(无关的)基合约继承相同的函数,则必须显式重写它:
// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.6.0 <0.9.0;
contract Base1
{
function foo() virtual public {}
}
contract Base2
{
function foo() virtual public {}
}
contract Inherited is Base1, Base2
{
// 从多个定义 foo() 的基合约派生,因此我们必须显式重写它
function foo() public override(Base1, Base2) {}
}
如果函数在一个公共基合约中定义,或者在一个公共基合约中有一个唯一的函数已经重写了所有其他函数,则不需要显式重写说明符。
// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.6.0 <0.9.0;
contract A { function f() public pure{} }
contract B is A {}
contract C is A {}
// 不需要显式重写
contract D is B, C {}
更正式地说,如果存在一个基合约是所有签名的重写路径的一部分,并且 (1)该基合约实现了该函数,并且从当前合约到基合约的路径中没有提到具有该签名的函数,或者( 2)该基合约没有实现该函数,并且在从当前合约到该基合约的所有路径中最多只有一个提到该函数,则不需要重写从多个基合约继承的函数。
在这个意义上,签名的重写路径是一个路径,通过继承图,从考虑的合约开始并结束于提到具有该签名的函数的合约而不重写。
如果你没有将重写的函数标记为 virtual
,则派生合约将不再能够改变该函数的行为。
备注
具有 private
可见性的函数不能是 virtual
。
备注
没有实现的函数必须在接口外标记为 virtual
。在接口中,所有函数都被自动视为 virtual
。
备注
从 Solidity 0.8.8 开始,重写接口函数时不需要 override
关键字,除非函数在多个基合约中定义。
公共状态变量可以重写外部函数,如果函数的参数和返回类型与变量的 getter 函数匹配:
// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.6.0 <0.9.0;
contract A
{
function f() external view virtual returns(uint) { return 5; }
}
contract B is A
{
uint public override f;
}
备注
虽然公共状态变量可以重写外部函数,但它们本身不能被重写。
修改器重写
函数修改器可以相互重写。这与 函数重写 的工作方式相同(除了修改器没有重载)。
重写的修改器必须在重写的修改器中使用 virtual
关键字,并且在重写的修改器中必须使用 override
关键字:
// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.6.0 <0.9.0;
contract Base
{
modifier foo() virtual {_;}
}
contract Inherited is Base
{
modifier foo() override {_;}
}
在多重继承的情况下,所有直接基合约必须显式指定:
// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.6.0 <0.9.0;
contract Base1
{
modifier foo() virtual {_;}
}
contract Base2
{
modifier foo() virtual {_;}
}
contract Inherited is Base1, Base2
{
modifier foo() override(Base1, Base2) {_;}
}
构造函数
构造函数是一个可选的函数,用 constructor
关键字声明在合约创建时执行,可以在其中运行合约初始化代码。
在执行构造函数代码之前,如果在行内初始化状态变量,则它们会将被初始化为指定的值,或者不初始化,则为 默认值。
在构造函数运行后,合约的最终代码被部署到区块链。代码的部署会产生额外的 gas 费用,费用与代码的长度成线性关系。 此代码包括所有属于公共接口的函数以及所有可以通过函数调用从那里到达的函数。 它不包括构造函数代码或仅从构造函数调用的内部函数。
如果没有构造函数,合约将假定默认构造函数,这等同于 constructor() {}
。例如:
// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.7.0 <0.9.0;
abstract contract A {
uint public a;
constructor(uint a_) {
a = a_;
}
}
contract B is A(1) {
constructor() {}
}
你可以在构造函数中使用内部参数(例如存储指针)。在这种情况下,合约必须标记为 abstract,因为这些参数不能从外部分配有效值,而只能通过派生合约的构造函数进行分配。
警告
在版本 0.4.22 之前,构造函数被定义为与合约同名的函数。 这种语法已被弃用,并且在版本 0.5.0 中不再允许。
警告
在版本 0.7.0 之前,你必须将构造函数的可见性指定为
internal
或 public
。
基类构造函数的参数
所有基类的构造函数将按照下面解释的线性化规则被调用。如果基类构造函数有参数,派生合约需要指定所有参数。这可以通过两种方式完成:
// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.7.0 <0.9.0;
contract Base {
uint x;
constructor(uint x_) { x = x_; }
}
// 要么直接在继承列表中指定...
contract Derived1 is Base(7) {
constructor() {}
}
// 或通过派生构造函数的“修改器”...
contract Derived2 is Base {
constructor(uint y) Base(y * y) {}
}
// 或声明为抽象...
abstract contract Derived3 is Base {
}
// 然后让下一个具体的派生合约进行初始化。
contract DerivedFromDerived is Derived3 {
constructor() Base(10 + 10) {}
}
一种方式是在继承列表中直接指定(is Base(7)
)。
另一种方式是在派生构造函数中以修改器的方式调用(Base(y * y)
)。
如果构造函数参数是常量并定义了合约的行为或描述它,第一种方式更方便。
如果基类的构造函数参数依赖于派生合约的参数,则必须使用第二种方式。
参数必须在继承列表中给出,或者在派生构造函数中以修改器样式给出。
在两个地方指定参数是错误的。
如果派生合约没有为其所有基类构造函数指定参数,则必须声明为抽象。
在这种情况下,当另一个合约从它派生时,那个合约的继承列表或构造函数必须为所有未指定参数的基类提供必要的参数(否则,那个合约也必须声明为抽象)。
例如,在上面的代码片段中,参见 Derived3
和 DerivedFromDerived
。
多重继承和线性化
允许多重继承的语言必须处理几个问题。其中一个是 钻石问题。
Solidity 类似于 Python,因为它使用 “C3 线性化” 来强制基类的有向无环图(DAG)中的特定顺序。
这导致了单调性的理想属性,但不允许某些继承图。特别是,基类在 is
指令中给出的顺序是重要的:必须按“最基础”到“最派生”的顺序列出直接基合约。
请注意,这个顺序与 Python 中使用的顺序相反。
另一种简化的解释是,当调用在不同合约中多次定义的函数时,给定的基类是从右到左(在 Python 中是从左到右)以深度优先的方式进行搜索,直到找到第一个匹配。如果一个基合约已经被搜索,则会被跳过。
在以下代码中,Solidity 将给出错误“无法线性化继承图”。
// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.4.0 <0.9.0;
contract X {}
contract A is X {}
// 这将无法编译
contract C is A, X {}
原因是 C
请求 X
来覆盖 A``(通过以这种顺序指定 ``A, X
),但 A
自身请求覆盖 X
,这是一种无法解决的矛盾。
由于必须显式覆盖从多个基类继承的没有唯一覆盖的函数,因此 C3 线性化在实践中并不是太重要。
继承层次结构中多个构造函数的一个领域,继承线性化尤其重要,可能不太清楚。构造函数将始终按照线性化顺序执行,而不管它们的参数在继承合约的构造函数中提供的顺序。例如:
// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.7.0 <0.9.0;
contract Base1 {
constructor() {}
}
contract Base2 {
constructor() {}
}
// 构造函数按以下顺序执行:
// 1 - Base1
// 2 - Base2
// 3 - Derived1
contract Derived1 is Base1, Base2 {
constructor() Base1() Base2() {}
}
// 构造函数按以下顺序执行:
// 1 - Base2
// 2 - Base1
// 3 - Derived2
contract Derived2 is Base2, Base1 {
constructor() Base2() Base1() {}
}
// 构造函数仍然按以下顺序执行:
// 1 - Base2
// 2 - Base1
// 3 - Derived3
contract Derived3 is Base2, Base1 {
constructor() Base1() Base2() {}
}
继承同名的不同类型成员
由于继承,合约可能包含多个共享相同名称的定义的唯一情况是:
函数的重载。
虚函数的重写。
通过状态变量获取器重写外部虚函数。
虚修改器的重写。
事件的重载。
抽象合约
当合约的至少一个函数未实现或未为其所有基合约构造函数提供参数时,合约必须标记为抽象。即使不是这种情况,合约仍然可以标记为抽象,例如当你不打算直接创建该合约时。抽象合约类似于 接口,但接口在声明内容上更为有限。
抽象合约使用 abstract
关键字声明,如下例所示。请注意,该合约需要定义为抽象,因为函数 utterance()
已声明,但未提供实现(未给出实现体 { }
)。
// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.6.0 <0.9.0;
abstract contract Feline {
function utterance() public virtual returns (bytes32);
}
这样的抽象合约不能直接实例化。如果一个抽象合约本身实现了所有定义的函数,这一点也是如此。抽象合约作为基类的用法在以下示例中展示:
// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.6.0 <0.9.0;
abstract contract Feline {
function utterance() public pure virtual returns (bytes32);
}
contract Cat is Feline {
function utterance() public pure override returns (bytes32) { return "miaow"; }
}
如果一个合约继承自抽象合约并且未通过重写实现所有未实现的函数,则该合约也需要标记为抽象合约。
请注意,未实现的函数与 Function Type 是不同的,尽管它们的语法看起来非常相似。
未实现函数的示例(函数声明):
function foo(address) external returns (address);
函数类型的变量声明示例:
function(address) external returns (address) foo;
抽象合约将合约的定义与其实现解耦,提供更好的可扩展性和自我文档化,并促进像 Template method 这样的模式,消除代码重复。抽象合约的用途与在接口中定义方法的用途相同。这是抽象合约设计者表示“我的任何子类必须实现此方法”的一种方式。
备注
抽象合约不能用未实现的函数覆盖已实现的虚函数。
接口
接口类似于抽象合约,但它们不能实现任何函数。 还有进一步的限制:
它们不能从其他合约继承,但可以从其他接口继承。
所有声明的函数在接口中必须是外部的,即使它们在合约中是公共的。
它们不能声明构造函数。
它们不能声明状态变量。
它们不能声明修改器。
这些限制中的一些可能在未来会被解除。
接口基本上仅限于合约 ABI 可以表示的内容,ABI 与接口之间的转换应该可以在没有任何信息丢失的情况下进行。
接口由它们自己的关键字表示:
// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.6.2 <0.9.0;
interface Token {
enum TokenType { Fungible, NonFungible }
struct Coin { string obverse; string reverse; }
function transfer(address recipient, uint amount) external;
}
合约可以像继承其他合约一样继承接口。
在接口中声明的所有函数隐式为 virtual
,任何重写它们的函数不需要 override
关键字。
这并不自动意味着重写的函数可以再次被重写 —— 只有当重写的函数被标记为 virtual
时才可以再次重写。
接口可以从其他接口继承。这与正常继承的规则相同。
// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.6.2 <0.9.0;
interface ParentA {
function test() external returns (uint256);
}
interface ParentB {
function test() external returns (uint256);
}
interface SubInterface is ParentA, ParentB {
// 必须重新定义 test 以确保父级含义是兼容的。
function test() external override(ParentA, ParentB) returns (uint256);
}
在接口和其他类似合约的结构中定义的类型可以从其他合约访问: Token.TokenType
或 Token.Coin
。
警告
接口自 Solidity 版本 0.5.0 起支持 enum
类型,确保 pragma 版本指定此版本作为最低版本。
库
库类似于合约,但它们的目的是仅在特定地址上部署一次,并通过 EVM 的 DELEGATECALL
(在 Homestead 之前为 CALLCODE
)功能重用其代码。
这意味着如果调用库函数,其代码将在调用合约的上下文中执行,即 this
指向调用合约,特别是可以访问调用合约的存储。
由于库是一个独立的源代码片段,它只能访问调用合约的状态变量,前提是这些变量被显式提供(否则它将无法命名它们)。
库函数只能直接调用(即不使用 DELEGATECALL
),如果它们不修改状态(即如果它们是 view
或 pure
函数),因为库被假定为无状态。特别一点是,库库不能被销毁。
备注
在 0.4.20 版本之前,可以通过规避 Solidity 的类型系统来销毁库。从该版本开始,库包含一个 机制,禁止直接调用状态修改函数(即不使用 DELEGATECALL
)。
库可以被视为使用它们的合约的隐式基合约。它们不会在继承层次结构中显式可见,但对库函数的调用看起来就像对显式基合约的函数的调用(使用合格访问,如 L.f()
)。
当然,对内部函数的调用使用内部调用约定,这意味着所有内部类型可以传递,类型 存储在内存中 将按引用传递而不是复制。
为了在 EVM 中实现这一点,从合约调用的内部库函数的代码以及从其中调用的所有函数将在编译时包含在调用合约中,并将使用常规的 JUMP
调用而不是 DELEGATECALL
。
备注
当涉及到公共函数时,继承类比会失效。
使用 L.f()
调用公共库函数会导致外部调用(准确地说是 DELEGATECALL
)。
相反,当 A
是当前合约的基合约时,A.f()
是内部调用。
以下示例说明了如何使用库(但使用手动方法,确保查看 using for 以获取更高级的实现集合的示例)。
// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.6.0 <0.9.0;
// 我们定义一个新的结构数据类型,将用于在调用合约中保存其数据。
struct Data {
mapping(uint => bool) flags;
}
library Set {
// 注意,第一个参数是“存储引用”类型,因此仅其存储地址而不是其内容作为调用的一部分传递。
// 这是库函数的一个特殊特性。如果函数可以被视为该对象的方法,通常将第一个参数称为 `self`。
function insert(Data storage self, uint value)
public
returns (bool)
{
if (self.flags[value])
return false; // 已经存在
self.flags[value] = true;
return true;
}
function remove(Data storage self, uint value)
public
returns (bool)
{
if (!self.flags[value])
return false; // 不存在
self.flags[value] = false;
return true;
}
function contains(Data storage self, uint value)
public
view
returns (bool)
{
return self.flags[value];
}
}
contract C {
Data knownValues;
function register(uint value) public {
// 可以在没有特定库实例的情况下调用库函数,因为“实例”将是当前合约。
require(Set.insert(knownValues, value));
}
// 在这个合约中,如果需要,我们也可以直接访问 knownValues.flags。
}
当然,不必遵循这种方式来使用库:它们也可以在不定义结构数据类型的情况下使用。 函数也可以在没有任何存储引用参数的情况下工作,并且可以有多个存储引用参数,并且可以在任何位置。
对 Set.contains
、Set.insert
和 Set.remove
的调用都被编译为对外部合约/库的调用(DELEGATECALL
)。
如果使用库,请注意会执行实际的外部函数调用。
尽管如此,msg.sender
、msg.value
和 this
在此调用中将保留其值(在 Homestead 之前,由于使用 CALLCODE
,msg.sender
和 msg.value
会发生变化)。
以下示例展示了如何使用 存储在内存中的类型 和库中的内部函数,以实现自定义类型而不增加外部函数调用的开销:
// SPDX-License-Identifier: GPL-3.0
pragma solidity ^0.8.0;
struct bigint {
uint[] limbs;
}
library BigInt {
function fromUint(uint x) internal pure returns (bigint memory r) {
r.limbs = new uint[](1);
r.limbs[0] = x;
}
function add(bigint memory a, bigint memory b) internal pure returns (bigint memory r) {
r.limbs = new uint[](max(a.limbs.length, b.limbs.length));
uint carry = 0;
for (uint i = 0; i < r.limbs.length; ++i) {
uint limbA = limb(a, i);
uint limbB = limb(b, i);
unchecked {
r.limbs[i] = limbA + limbB + carry;
if (limbA + limbB < limbA || (limbA + limbB == type(uint).max && carry > 0))
carry = 1;
else
carry = 0;
}
}
if (carry > 0) {
// 太糟糕了,我们必须添加一个 limb
uint[] memory newLimbs = new uint[](r.limbs.length + 1);
uint i;
for (i = 0; i < r.limbs.length; ++i)
newLimbs[i] = r.limbs[i];
newLimbs[i] = carry;
r.limbs = newLimbs;
}
}
function limb(bigint memory a, uint index) internal pure returns (uint) {
return index < a.limbs.length ? a.limbs[index] : 0;
}
function max(uint a, uint b) private pure returns (uint) {
return a > b ? a : b;
}
}
contract C {
using BigInt for bigint;
function f() public pure {
bigint memory x = BigInt.fromUint(7);
bigint memory y = BigInt.fromUint(type(uint).max);
bigint memory z = x.add(y);
assert(z.limb(1) > 0);
}
}
可以通过将库类型转换为 address
类型来获取库的地址,即使用 address(LibraryName)
。
由于编译器不知道库将被部署到的地址,编译后的十六进制代码将包含形式为 __$30bbc0abd4d6364515865950d3e0d10953$__
的占位符 (格式在 <v0.5.0)。
占位符是完全限定库名称的 keccak256 哈希的十六进制编码的 34 个字符前缀,例如,如果库存储在名为 bigint.sol
的文件中的 libraries/
目录下,则为 libraries/bigint.sol:BigInt
。
这样的字节码是不完整的,不应被部署。占位符需要被实际地址替换。
可以通过在编译库时将它们传递给编译器,或者使用链接器更新已编译的二进制文件来做到这一点。
有关如何使用命令行编译器进行链接的信息,请参见 库链接。
与合约相比,库在以下方面受到限制:
它们不能有状态变量
它们不能继承,也不能被继承
它们不能接收以太币
它们不能被销毁
(这些限制可能在以后被解除。)
库的函数签名和选择器
虽然可以对公共或外部库函数进行外部调用,但此类调用的调用约定被认为是 Solidity 内部的,与常规的 contract ABI 中规定的不同。 外部库函数支持比外部合约函数更多的参数类型,例如递归结构和存储指针。 因此,用于计算 4 字节选择器的函数签名是根据内部命名方案计算的,而在合约 ABI 中不支持的参数类型使用内部编码。
以下标识符用于签名中的类型:
值类型、非存储的
string
和非存储的bytes
使用与合约 ABI 中相同的标识符。非存储数组类型遵循与合约 ABI 中相同的约定,即动态数组为
<type>[]
,固定大小数组为<type>[M]
,其中M
为元素个数。非存储结构通过其完全限定名引用,即
C.S
表示contract C { struct S { ... } }
。存储指针映射使用
mapping(<keyType> => <valueType>) storage
,其中<keyType>
和<valueType>
分别是映射的键和值类型的标识符。其他存储指针类型使用其对应非存储类型的类型标识符,但在其后附加一个空格和
storage
。
参数编码与常规合约 ABI 相同,存储指针的编码为一个 uint256
值,指向它们所指向的存储槽。
与合约 ABI 类似,选择器由签名的 Keccak256 哈希的前四个字节组成。
其值可以通过 Solidity 使用 .selector
成员获得,如下所示:
// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.5.14 <0.9.0;
library L {
function f(uint256) external {}
}
contract C {
function g() public pure returns (bytes4) {
return L.f.selector;
}
}
库的调用保护
如前言所述,如果库的代码使用 CALL
而不是 DELEGATECALL
或 CALLCODE
执行,它将会回退,除非调用的是 view
或 pure
函数。
EVM 并没有提供合约直接检测是否使用 CALL
调用的方式,但合约可以使用 ADDRESS
操作码来找出“它”当前运行的位置。
生成的代码将此地址与构造时使用的地址进行比较,以确定调用模式。
更具体地说,库的运行时代码总是以一个推送指令开始,该指令在编译时是一个 20 字节的零。 当部署代码运行时,这个常量在内存中被当前地址替换,并且这个修改后的代码被存储在合约中。 在运行时,这导致部署时的地址成为第一个被推送到栈上的常量,调度代码将当前地址与此常量进行比较,以检查任何非视图和非纯函数。
这意味着存储在链上的库的实际代码与编译器输出的 deployedBytecode
不同。
Using For
指令 using A for B
可用于将函数(A
)作为运算符附加到用户定义的值类型或作为任何类型(B
)的成员函数。
成员函数接收调用它们的对象作为第一个参数(类似于 Python 中的 self
变量)。运算符函数接收操作数作为参数。
它在文件级别或合约内部、合约级别都是有效的。
第一部分 A
可以是以下之一:
函数列表,选项上可以指定运算符名称(例如
using {f, g as +, h, L.t} for uint
)。如果未指定运算符,则该函数可以是库函数或自由函数,并作为成员函数附加到类型上。否则,它必须是自由函数,并成为该类型上运算符的定义。库的名称(例如
using L for uint
) —— 库的所有非私有函数作为成员函数附加到该类型上。
在文件级别,第二部分 B
必须是显式类型(没有数据位置说明符)。
在合约内部,你也可以使用 *
代替类型(例如 using L for *;
),这将使库 L
的所有函数附加到 所有 类型上。
如果指定一个库,所有 非私有函数都会被附加,即使第一个参数的类型与对象的类型不匹配。类型在调用函数时进行检查,并执行函数重载解析。
如果使用函数列表(例如 using {f, g, h, L.t} for uint
),则类型(uint
)必须可以隐式转换为这些函数的第一个参数。
即使这些函数没有被调用,也会执行此检查。请注意,私有库函数只能在 using for
在库内部时指定。
如果定义一个运算符(例如 using {f as +} for T
),则类型(T
)必须是 用户定义值类型,并且定义必须是 pure
函数。
运算符定义必须是全局的。
可以通过以下方式定义以下运算符:
Category |
Operator |
Possible signatures |
---|---|---|
Bitwise |
|
|
|
|
|
|
|
|
|
|
|
Arithmetic |
|
|
|
|
|
|
||
|
|
|
|
|
|
|
|
|
Comparison |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
请注意,单目和双目 -
需要单独的定义。编译器将根据运算符的调用方式选择正确的定义。
using A for B;
指令仅在当前作用域内有效(无论是合约还是当前模块/源单元),包括其所有函数内,并且在使用它的合约或模块外没有效果。
当指令在文件级别使用并应用于在同一文件中以文件级别定义的用户定义类型时,可以在末尾添加单词 global
。
这将使得函数和运算符在类型可用的所有地方(包括其他文件)附加到该类型,而不仅仅是在使用语句的作用域内。
让我们以这种方式重写 库 部分中的集合示例,使用文件级函数而不是库函数。
// SPDX-License-Identifier: GPL-3.0
pragma solidity ^0.8.13;
struct Data { mapping(uint => bool) flags; }
// 现在我们将函数附加到类型上。
// 附加的函数可以在模块的其余部分使用。
// 如果你导入模块,你必须在那里重复使用指令,例如
// import "flags.sol" as Flags;
// using {Flags.insert, Flags.remove, Flags.contains}
// for Flags.Data;
using {insert, remove, contains} for Data;
function insert(Data storage self, uint value)
returns (bool)
{
if (self.flags[value])
return false; // 已经存在
self.flags[value] = true;
return true;
}
function remove(Data storage self, uint value)
returns (bool)
{
if (!self.flags[value])
return false; // 不存在
self.flags[value] = false;
return true;
}
function contains(Data storage self, uint value)
view
returns (bool)
{
return self.flags[value];
}
contract C {
Data knownValues;
function register(uint value) public {
// 在这里,所有类型为 Data 的变量都有相应的成员函数。
// 以下函数调用与 `Set.insert(knownValues, value)` 相同
require(knownValues.insert(value));
}
}
以这种方式扩展内置类型也是可能的。在这个例子中,我们将使用一个库。
// SPDX-License-Identifier: GPL-3.0
pragma solidity ^0.8.13;
library Search {
function indexOf(uint[] storage self, uint value)
public
view
returns (uint)
{
for (uint i = 0; i < self.length; i++)
if (self[i] == value) return i;
return type(uint).max;
}
}
using Search for uint[];
contract C {
uint[] data;
function append(uint value) public {
data.push(value);
}
function replace(uint from, uint to) public {
// 执行库函数调用
uint index = data.indexOf(from);
if (index == type(uint).max)
data.push(to);
else
data[index] = to;
}
}
注意所有外部库调用都是实际的 EVM 函数调用。
这意味着如果你传递内存或值类型,将会执行复制,即使在 self
变量的情况下。
唯一不执行复制的情况是使用存储引用变量或调用内部库函数时。
另一个示例展示了如何为用户定义类型定义自定义运算符:
// SPDX-License-Identifier: GPL-3.0
pragma solidity ^0.8.19;
type UFixed16x2 is uint16;
using {
add as +,
div as /
} for UFixed16x2 global;
uint32 constant SCALE = 100;
function add(UFixed16x2 a, UFixed16x2 b) pure returns (UFixed16x2) {
return UFixed16x2.wrap(UFixed16x2.unwrap(a) + UFixed16x2.unwrap(b));
}
function div(UFixed16x2 a, UFixed16x2 b) pure returns (UFixed16x2) {
uint32 a32 = UFixed16x2.unwrap(a);
uint32 b32 = UFixed16x2.unwrap(b);
uint32 result32 = a32 * SCALE / b32;
require(result32 <= type(uint16).max, "Divide overflow");
return UFixed16x2.wrap(uint16(a32 * SCALE / b32));
}
contract Math {
function avg(UFixed16x2 a, UFixed16x2 b) public pure returns (UFixed16x2) {
return (a + b) / UFixed16x2.wrap(200);
}
}