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 里最基础也最重要的数据结构。我个人的经验是:
- 查询用 mapping,遍历用数组——这是黄金法则
- 数组删除用 swap-and-pop——除非你特别在意顺序
- 动态数组的 push/pop 很便宜——但插入和删除中间元素很贵
- 固定数组适合已知大小的数据——比如 18 个小数位
- mapping 不能遍历——如果需要遍历,配合数组存 key
你想想看,这些规则其实都指向同一个核心思想:在链上,存储和计算都是有成本的。写合约跟写普通程序不一样,你得时刻想着「这笔操作要花多少 gas」。嗯,这个意识很重要,慢慢培养吧。
下一章咱们会讲结构体和枚举,到时候还会用到今天学的这些知识。先消化消化,有问题随时问我。