深入浅出,以太坊中 Mapping 的遍历挑战与实用技巧

在以太坊智能合约开发中,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 的关键特性包括:

  1. 键(Key)类型:可以是任何基础类型,如 uint, int, address, bool, bytes32,甚至是
    随机配图
    另一个 mapping 或数组(但数组元素必须是固定大小或 bytes32)。
  2. 值(Value)类型:可以是任何类型,包括自定义结构体。
  3. 默认值:当通过 key 访问 mapping 时,如果该 key 从未被设置过,Solidity 会返回一个默认值(uint256 默认为 0,address 默认为 address(0)bool 默认为 false)。
  4. 存储位置mapping 类型的变量存储在存储(storage)中,并且它们的数据是分散存储的,没有连续的索引。
  5. 不可直接迭代:这是本文的重点。mapping 没有长度(length)属性,也没有内置的迭代方法(如 for 循环遍历)。

为何 Mapping 不可直接遍历

Solidity 中 mapping 不可直接遍历主要是由其底层存储机制和设计哲学决定的:

  1. 存储效率与 Gas 成本:以太坊的存储是按 slot(槽位)计费的,每个 slot 32 字节。mapping 的设计允许高效地插入和查找特定键对应的值,而不需要预先分配大量连续的存储空间。mapping 可遍历,Solidity 虚拟机(EVM)将需要一种方法来“知道”哪些 slot 被实际使用了,这在设计上会变得非常复杂且低效,可能导致不必要的 Gas 消耗。
  2. 动态性与不确定性mapping 的大小是动态的,理论上可以无限增长(受限于区块 Gas 限制),EVM 无法预先知道一个 mapping 中有多少个有效的键值对,因此无法提供一个简单的迭代器。
  3. 键的不可预测性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 的值比较复杂,可以将 keyvalue 封装到一个结构体中,然后用一个数组来存储这些结构体。

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 库,它实现了可枚举的集合(包括 AddressSetUintSet),内部使用了类似方法一的技巧,但封装得更好,处理了边界情况。

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

本文由用户投稿上传,若侵权请提供版权资料并联系删除!