在以太坊智能合约开发中,mapping 是一种极其核心且常用的数据结构,它类似于其他编程语言中的哈希表或字典,提供了一种键(key)到值(value)的高效映射关系,与数组(Array)或动态数组(Dynamic Array)不同,mapping 在 Solidity 中有一个非常显著的特点:它本身是不可直接遍历的,本文将深入探讨这一特性,分析其原因,并介绍在实际开发中如何巧妙地实现类似遍历的效果,以及相关的注意事项。
理解 Mapping 的本质
让我们回顾一下 mapping 的基本定义和特性:
pragma solidity ^0.8.0;
contract MyContract {
// 定义一个从地址到uint256的mapping
mapping(address => uint256) public balances;
function setBalance(address _addr, uint256 _amount) public {
balances[_addr] = _amount;
}
function getBalance(address _addr) public view returns (uint256) {
return balances[_addr];
}
}
mapping 的关键特性包括:
- 键(Key)类型:可以是任何基础类型,如
uint,int,address,bool,bytes32,甚至是另一个
mapping或数组(但数组元素必须是固定大小或 bytes32)。 - 值(Value)类型:可以是任何类型,包括自定义结构体。
- 默认值:当通过
key访问mapping时,如果该key从未被设置过,Solidity 会返回一个默认值(uint256默认为 0,address默认为address(0),bool默认为false)。 - 存储位置:
mapping类型的变量存储在存储(storage)中,并且它们的数据是分散存储的,没有连续的索引。 - 不可直接迭代:这是本文的重点。
mapping没有长度(length)属性,也没有内置的迭代方法(如for循环遍历)。
为何 Mapping 不可直接遍历
Solidity 中 mapping 不可直接遍历主要是由其底层存储机制和设计哲学决定的:
- 存储效率与 Gas 成本:以太坊的存储是按 slot(槽位)计费的,每个 slot 32 字节。
mapping的设计允许高效地插入和查找特定键对应的值,而不需要预先分配大量连续的存储空间。mapping可遍历,Solidity 虚拟机(EVM)将需要一种方法来“知道”哪些 slot 被实际使用了,这在设计上会变得非常复杂且低效,可能导致不必要的 Gas 消耗。 - 动态性与不确定性:
mapping的大小是动态的,理论上可以无限增长(受限于区块 Gas 限制),EVM 无法预先知道一个mapping中有多少个有效的键值对,因此无法提供一个简单的迭代器。 - 键的不可预测性:
mapping的键可以是任意值(如任意地址),没有自然的顺序或范围,不像数组有从 0 到 length-1 的索引,mapping的键是离散且无序的。
mapping 被设计为一种“按需访问”的结构,而不是一种“批量处理”的结构。
实现“遍历” Mapping 的实用技巧
尽管 mapping 本身不可遍历,但在实际应用中,我们经常需要知道一个 mapping 中所有已设置的键或所有值,实现一个代币合约,需要记录所有持有者及其余额,这时,我们可以借助以下几种方法:
维护一个键的数组(Keys Array)
这是最常用且相对高效的方法,我们额外维护一个数组,专门存储 mapping 中所有有效的键。
pragma solidity ^0.8.0;
contract EnumerableMapping {
mapping(address => uint256) private _balances;
address[] private _allKeys; // 维护所有键的数组
function setBalance(address _addr, uint256 _amount) public {
// 如果之前没有这个键,则添加到数组中
if (_balances[_addr] == 0 && _amount != 0) { // 注意处理_amount为0的情况,避免重复添加address(0)
_allKeys.push(_addr);
}
_balances[_addr] = _amount;
}
function getBalance(address _addr) public view returns (uint256) {
return _balances[_addr];
}
// 获取所有键的数量
function allKeysLength() public view returns (uint256) {
return _allKeys.length;
}
// 按索引获取键
function keyAt(uint256 _index) public view returns (address) {
require(_index < _allKeys.length, "Index out of bounds");
return _allKeys[_index];
}
// 遍历示例:获取所有键及其对应的值
function getAllBalances() public view returns (address[] memory, uint256[] memory) {
uint256 length = _allKeys.length;
address[] memory keys = new address[](length);
uint256[] memory values = new uint256[](length);
for (uint256 i = 0; i < length; i++) {
keys[i] = _allKeys[i];
values[i] = _balances[_allKeys[i]];
}
return (keys, values);
}
}
优点:
- 实现相对直观。
- 遍历效率较高(O(n))。
缺点:
- 需要额外的存储空间来存储数组,会增加 Gas 成本。
- 需要仔细处理键的添加和删除逻辑,避免重复或遗漏,如果删除某个键,需要从数组中移除,这可能导致数组需要重新整理或使用标记删除,增加复杂性。
使用 Struct 和数组组合(更结构化)
mapping 的值比较复杂,可以将 key 和 value 封装到一个结构体中,然后用一个数组来存储这些结构体。
pragma solidity ^0.8.0;
contract StructMapping {
struct KeyValue {
address key;
uint256 value;
}
mapping(address => KeyValue) private _keyValuePairs;
address[] private _allKeys; // 仍然需要维护键的数组以便索引,或者直接存储结构体数组
// 或者直接使用结构体数组
KeyValue[] private _allPairs;
function set(address _addr, uint256 _amount) public {
if (_keyValuePairs[_addr].key == address(0) && _amount != 0) {
_allPairs.push(KeyValue(_addr, _amount));
} else if (_amount == 0) {
// 删除逻辑较复杂,需要遍历数组找到并移除,或使用标记删除
// 这里简化处理,实际中需更严谨
for (uint256 i = 0; i < _allPairs.length; i++) {
if (_allPairs[i].key == _addr) {
// 交换到末尾并删除(非严格删除,只是减少长度)
if (i < _allPairs.length - 1) {
_allPairs[i] = _allPairs[_allPairs.length - 1];
}
_allPairs.pop();
break;
}
}
} else {
_keyValuePairs[_addr].value = _amount;
// 如果已存在且更新值,数组中的值也需要更新(如果存储的是结构体数组)
for (uint256 i = 0; i < _allPairs.length; i++) {
if (_allPairs[i].key == _addr) {
_allPairs[i].value = _amount;
break;
}
}
}
}
function getAll() public view returns (KeyValue[] memory) {
return _allPairs;
}
}
优点:
- 数据结构更清晰,便于管理复杂值。
- 可以一次性获取键值对。
缺点:
- 与方法一类似,需要额外存储,删除操作可能较复杂且 Gas 消耗较高。
使用 OpenZeppelin 的 EnumerableSet 库
OpenZeppelin 提供了经过审计且优化的 EnumerableSet 库,它实现了可枚举的集合(包括 AddressSet 和 UintSet),内部使用了类似方法一的技巧,但封装得更好,处理了边界情况。
pragma solidity ^0.8.0;
import "@openzeppelin/contracts/utils/structs/EnumerableSet.sol";
contract UsingEnumerableSet {
using EnumerableSet for EnumerableSet.AddressSet;
// 使用AddressSet来存储唯一的地址键
EnumerableSet.AddressSet private _holders;
mapping(address => uint256) public balances;
function setBalance(address _addr, uint256