4、映射与数组:mapping映射类型、动态数组与固定数组、数组的增删改查、映射与数组的实战应用

好,咱们今天来聊聊 Solidity 里两个最常用的数据结构——映射和数组。说实话,这两个东西你写合约几乎天天碰。我刚开始学的时候,总觉得它们跟 JavaScript 里的对象和数组差不多,结果踩了不少坑。嗯,咱们今天就把它们彻底讲透。

4.1 映射(mapping)类型

映射,说白了就是一个键值对存储结构。你可以把它想象成一个字典——你给一个 key,它返回一个 value。但跟普通字典不一样的是,Solidity 里的 mapping 是哈希表,它不存储 key,只存储 key 的哈希值对应的 value。

核心特点:

  • 所有 key 在映射中默认都存在,对应的 value 是 0 值(比如 address 是 0x0,uint 是 0)
  • 无法获取映射的长度,也无法遍历所有 key
  • 只能用于状态变量,不能用于局部变量

看个最简单的例子:

pragma solidity ^0.8.0;

contract MappingExample {
    // 定义一个映射:地址 -> 余额
    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];
    }
}

这里有个细节——public 关键字。如果你把 mapping 声明为 public,Solidity 会自动给你生成一个 getter 函数。但注意,这个 getter 只接受 key 作为参数,返回对应的 value。你不能直接遍历它。

个人经验:我习惯把 mapping 跟数组配合使用。比如要记录所有用户,我会用一个 mapping 存数据,再用一个数组存所有 key。这样既能快速查找,又能遍历。

4.2 动态数组与固定数组

数组分两种:固定大小和动态大小。你想想看,固定数组就像你买了个固定大小的停车位,动态数组就像租了个可以随时扩大的停车场。

4.2.1 固定数组

// 固定数组:长度在编译时就确定了
uint256[5] public fixedArray;  // 只能存5个元素

function setFixed(uint256 index, uint256 value) public {
    require(index < 5, "Index out of bounds");
    fixedArray[index] = value;
}

固定数组有个好处——gas 消耗是确定的。因为长度不变,Solidity 知道要分配多少存储空间。但缺点也很明显:你不能动态增加元素。

4.2.2 动态数组

// 动态数组:长度可以变化
uint256[] public dynamicArray;

function pushElement(uint256 value) public {
    dynamicArray.push(value);  // 在末尾添加
}

function popElement() public {
    dynamicArray.pop();  // 移除最后一个元素
}

function getLength() public view returns (uint256) {
    return dynamicArray.length;
}

动态数组的 push()pop() 操作是 O(1) 的,非常高效。但要注意,pop() 不会真的删除数据,它只是把 length 减 1。嗯,这里有个坑——如果你 pop 之后又 push,新元素会覆盖旧数据吗?不会,旧数据还在存储里,只是访问不到了。

我曾经踩过的坑:有一次我写了一个投票合约,用动态数组存候选人。我用了 delete 来删除某个候选人,结果发现数组长度没变,只是那个位置变成了 0。后来我才知道,delete 只是把值重置为默认值,不会改变数组长度。要真正删除,得用 pop() 或者自己写移位逻辑。

4.3 数组的增删改查

咱们来系统地看看数组的 CRUD 操作。这部分我建议你背下来,因为写合约时太常用了。

4.3.1 增加元素

// 末尾添加
arr.push(value);

// 指定位置插入(需要自己实现)
function insertAt(uint256 index, uint256 value) public {
    require(index <= arr.length, "Index out of bounds");
    arr.push(0);  // 先扩容
    for (uint256 i = arr.length - 1; i > index; i--) {
        arr[i] = arr[i - 1];
    }
    arr[index] = value;
}

为什么插入要自己实现?因为 Solidity 没有内置的 insert 方法。你想想看,在数组中间插入一个元素,后面的所有元素都得往后挪,这 gas 消耗可不小。所以实际项目中,我很少在数组中间插入数据。

4.3.2 删除元素

// 方法1:pop(只删除最后一个)
arr.pop();

// 方法2:用最后一个元素覆盖要删除的元素(高效删除)
function swapAndPop(uint256 index) public {
    require(index < arr.length, "Index out of bounds");
    arr[index] = arr[arr.length - 1];
    arr.pop();
}

// 方法3:delete(重置为默认值,不改变长度)
delete arr[index];

我个人最喜欢用「交换删除法」。为什么?因为 O(1) 时间,O(1) 额外 gas。但要注意,这种方法会改变数组元素的顺序。如果你的业务逻辑不关心顺序,那就用这个。如果关心顺序,那就得用循环移位,或者用链表结构。

4.3.3 修改元素

// 直接赋值
arr[index] = newValue;

// 批量修改
function batchUpdate(uint256[] memory indices, uint256[] memory values) public {
    require(indices.length == values.length, "Length mismatch");
    for (uint256 i = 0; i < indices.length; i++) {
        arr[indices[i]] = values[i];
    }
}

4.3.4 查询元素

// 按索引查询
function getByIndex(uint256 index) public view returns (uint256) {
    require(index < arr.length, "Index out of bounds");
    return arr[index];
}

// 按值查询(线性搜索)
function findByValue(uint256 value) public view returns (int256) {
    for (uint256 i = 0; i < arr.length; i++) {
        if (arr[i] == value) {
            return int256(i);
        }
    }
    return -1;  // 没找到
}

线性搜索在链上很贵。如果数组有 1000 个元素,最坏情况要遍历 1000 次。我建议如果查询频繁,就用 mapping 来存索引。

4.4 映射与数组的实战应用

好了,理论知识讲完了。咱们来看一个实际项目中的例子——一个简单的代币合约。这里我会把 mapping 和数组结合起来用。

pragma solidity ^0.8.0;

contract SimpleToken {
    string public name = "MyToken";
    string public symbol = "MTK";
    uint8 public decimals = 18;
    uint256 public totalSupply;

    // mapping:地址 -> 余额
    mapping(address => uint256) public balanceOf;

    // mapping:地址 -> 授权额度
    mapping(address => mapping(address => uint256)) public allowance;

    // 数组:记录所有持有者(用于遍历)
    address[] public holders;

    // mapping:地址 -> 是否在holders中(避免重复)
    mapping(address => bool) public isHolder;

    // mapping:地址 -> 在holders中的索引(用于快速删除)
    mapping(address => uint256) public holderIndex;

    event Transfer(address indexed from, address indexed to, uint256 value);
    event Approval(address indexed owner, address indexed spender, uint256 value);

    constructor(uint256 _initialSupply) {
        totalSupply = _initialSupply * 10 ** decimals;
        balanceOf[msg.sender] = totalSupply;
        _addHolder(msg.sender);
    }

    function transfer(address _to, uint256 _value) public returns (bool success) {
        require(balanceOf[msg.sender] >= _value, "Insufficient balance");
        require(_to != address(0), "Invalid address");

        balanceOf[msg.sender] -= _value;
        balanceOf[_to] += _value;

        // 如果接收方还不是持有者,添加
        if (!isHolder[_to] && balanceOf[_to] > 0) {
            _addHolder(_to);
        }

        // 如果发送方余额归零,移除
        if (balanceOf[msg.sender] == 0 && isHolder[msg.sender]) {
            _removeHolder(msg.sender);
        }

        emit Transfer(msg.sender, _to, _value);
        return true;
    }

    // 获取所有持有者(仅供外部调用,gas 很贵)
    function getHolders() public view returns (address[] memory) {
        return holders;
    }

    function _addHolder(address _addr) internal {
        isHolder[_addr] = true;
        holderIndex[_addr] = holders.length;
        holders.push(_addr);
    }

    function _removeHolder(address _addr) internal {
        uint256 index = holderIndex[_addr];
        address lastHolder = holders[holders.length - 1];

        // 用最后一个元素覆盖要删除的元素
        holders[index] = lastHolder;
        holderIndex[lastHolder] = index;

        // 删除最后一个元素
        holders.pop();

        // 清理映射
        delete isHolder[_addr];
        delete holderIndex[_addr];
    }
}

这个例子展示了什么?

  • mapping 用于快速查询:balanceOf 和 allowance 都是 O(1) 查询
  • 数组用于遍历:holders 数组可以获取所有持有者列表
  • mapping 辅助数组操作:isHolder 避免重复添加,holderIndex 实现 O(1) 删除
  • 交换删除法:_removeHolder 里用了 swap-and-pop,gas 消耗最小

避坑指南:我曾经在写一个 NFT 市场合约时,直接用数组存所有挂单,然后遍历查找。结果上链后 gas 高得离谱。后来改成 mapping + 数组的组合,查询 O(1),遍历只在必要时才做。记住:能用 mapping 就别用数组遍历

4.5 总结与最佳实践

好,咱们来收个尾。映射和数组是 Solidity 里最基础也最重要的数据结构。我个人的经验是:

  1. 查询用 mapping,遍历用数组——这是黄金法则
  2. 数组删除用 swap-and-pop——除非你特别在意顺序
  3. 动态数组的 push/pop 很便宜——但插入和删除中间元素很贵
  4. 固定数组适合已知大小的数据——比如 18 个小数位
  5. mapping 不能遍历——如果需要遍历,配合数组存 key

你想想看,这些规则其实都指向同一个核心思想:在链上,存储和计算都是有成本的。写合约跟写普通程序不一样,你得时刻想着「这笔操作要花多少 gas」。嗯,这个意识很重要,慢慢培养吧。

下一章咱们会讲结构体和枚举,到时候还会用到今天学的这些知识。先消化消化,有问题随时问我。