Solidity mapping + struct + array + enum
mapping 语法与嵌套映射、struct 定义与使用、动态/固定数组操作、enum 状态机模式
日期: 2026-04-14 方向: Solidity 阶段: 第一阶段:基础构建 标签: #solidity #mapping #struct #array #enum #data-structures
今日目标
| 类型 | 内容 |
|---|---|
| 学习 | mapping 语法与嵌套映射、struct 定义与使用、动态/固定数组操作、enum 状态机模式 |
| 实操 | 编写一个完整的 TodoList 合约,包含 CRUD 操作和状态枚举 |
| 产出 | 可部署的 TodoList 合约 + 数据结构选择指南 |
一、Mapping(映射)
Mapping 是 Solidity 中最重要的数据结构之一,类似于 Java 的 HashMap 或 Python 的 dict,但有关键区别。
1.1 基础语法
contract MappingDemo {
// mapping(KeyType => ValueType) visibility name;
mapping(address => uint) public balances;
mapping(string => bool) private registered;
mapping(uint => address) internal tokenOwners;
// 写入(如果 key 不存在,自动创建)
function setBalance(address user, uint amount) public {
balances[user] = amount;
}
// 读取(如果 key 不存在,返回默认值)
function getBalance(address user) public view returns (uint) {
return balances[user]; // 不存在的 key 返回 0
}
// 删除(重置为默认值,不是真正删除)
function removeBalance(address user) public {
delete balances[user]; // 值变回 0
}
}
1.2 Mapping 的关键限制
// ❌ 不能遍历 mapping
// mapping 底层是哈希表,键不是连续存储的
// for (key in balances) { ... } ← 不可能!
// ❌ 不能获取 mapping 的长度
// balances.length ← 不存在!
// ❌ 不能检查 key 是否存在
// key in balances ← 不存在!
// 只能通过值是否为默认值间接判断
// ✅ 解决方案:维护一个辅助数组
mapping(address => uint) public balances;
address[] public allUsers; // 辅助数组,记录所有存在的 key
mapping(address => bool) public userExists; // 辅助映射,防重复
function addUser(address user, uint amount) public {
if (!userExists[user]) {
allUsers.push(user);
userExists[user] = true;
}
balances[user] = amount;
}
1.3 嵌套映射
contract NestedMappingDemo {
// 二级映射:owner => (spender => allowance)
// 这就是 ERC20 的 allowance 机制!
mapping(address => mapping(address => uint)) public allowances;
function approve(address spender, uint amount) public {
allowances[msg.sender][spender] = amount;
}
function getAllowance(address owner, address spender)
public view returns (uint)
{
return allowances[owner][spender];
}
// 三级嵌套(少见但合法)
// mapping(address => mapping(address => mapping(uint => bool)))
// 例如:user => token => tokenId => owned
}
1.4 Mapping 的存储原理
// mapping 的值存储位置 = keccak256(key . slot)
// slot 是 mapping 声明时的存储槽号
// 例如:
mapping(address => uint) public balances; // slot 0
// balances[0xABC...] 的值存储在:
// keccak256(abi.encode(0xABC..., 0)) 这个位置
// 这就是为什么不能遍历——键分散在整个存储空间中
二、Struct(结构体)
Struct 用于组合多个不同类型的数据。
2.1 基础定义与使用
contract StructDemo {
// 定义结构体
struct User {
address wallet;
string name;
uint256 balance;
bool isActive;
uint256 createdAt;
}
// 存储结构体
mapping(address => User) public users;
User[] public userList;
// 创建结构体的三种方式
function createUser(string memory _name) public {
// 方式1:按属性顺序
User memory user1 = User(
msg.sender,
_name,
0,
true,
block.timestamp
);
// 方式2:按名称(推荐,更清晰)
User memory user2 = User({
wallet: msg.sender,
name: _name,
balance: 0,
isActive: true,
createdAt: block.timestamp
});
// 方式3:先创建再赋值
User memory user3;
user3.wallet = msg.sender;
user3.name = _name;
user3.balance = 0;
user3.isActive = true;
user3.createdAt = block.timestamp;
// 存入 mapping
users[msg.sender] = user2;
// 存入数组
userList.push(user2);
}
// 修改结构体
function updateBalance(address _user, uint256 _amount) public {
// storage 引用:修改会反映到实际存储
User storage user = users[_user];
user.balance = _amount;
// ⚠️ 如果用 memory 则只是修改副本,不会保存!
}
// 读取结构体
function getUser(address _user) public view returns (
string memory name,
uint256 balance,
bool isActive
) {
User storage user = users[_user];
return (user.name, user.balance, user.isActive);
}
}
2.2 storage vs memory 在 struct 中的关键区别
function dangerousUpdate(address _user) public {
// ⚠️ memory 副本 - 修改不会保存到 storage!
User memory userCopy = users[_user];
userCopy.balance = 999; // 只修改了内存副本
// users[_user].balance 没有改变!这是一个常见 bug
// ✅ storage 引用 - 修改直接影响 storage
User storage userRef = users[_user];
userRef.balance = 999; // 修改了 storage
// users[_user].balance 现在是 999
}
2.3 结构体嵌套
struct Token {
string name;
string symbol;
uint8 decimals;
}
struct Pool {
Token token0; // 嵌套结构体
Token token1;
uint256 reserve0;
uint256 reserve1;
uint256 totalLiquidity;
}
// 映射中的嵌套结构体
mapping(uint => Pool) public pools;
三、Array(数组)
3.1 固定长度数组
contract ArrayDemo {
// 固定长度数组
uint[5] public fixedArray = [1, 2, 3, 4, 5];
address[3] public topHolders;
bytes32[10] public hashes;
function getFixed() public view returns (uint[5] memory) {
return fixedArray;
}
function setFixed(uint index, uint value) public {
require(index < 5, "Out of bounds");
fixedArray[index] = value;
}
}
3.2 动态数组
contract DynamicArrayDemo {
uint[] public numbers;
address[] public participants;
string[] public names;
// push: 添加元素到末尾
function addNumber(uint _num) public {
numbers.push(_num);
}
// pop: 移除末尾元素
function removeLastNumber() public {
require(numbers.length > 0, "Array is empty");
numbers.pop();
}
// 获取长度
function getLength() public view returns (uint) {
return numbers.length;
}
// 获取元素
function getNumber(uint index) public view returns (uint) {
require(index < numbers.length, "Out of bounds");
return numbers[index];
}
// 删除元素(置零,不改变长度)
function deleteNumber(uint index) public {
require(index < numbers.length, "Out of bounds");
delete numbers[index]; // 值变为 0,但数组长度不变
}
// 高效删除(将最后一个元素移到被删位置)
function removeEfficient(uint index) public {
require(index < numbers.length, "Out of bounds");
numbers[index] = numbers[numbers.length - 1];
numbers.pop();
// ⚠️ 这会改变元素顺序!
}
// 返回整个数组(⚠️ gas 随数组大小线性增长)
function getAll() public view returns (uint[] memory) {
return numbers;
}
}
3.3 数组的 Gas 陷阱
// ❌ 危险:无界循环
function sumAll() public view returns (uint) {
uint total = 0;
for (uint i = 0; i < numbers.length; i++) {
total += numbers[i];
}
return total;
// 如果 numbers 有 10000 个元素,可能会超过 gas limit!
}
// ✅ 安全:分页处理
function sumPaginated(uint start, uint count) public view returns (uint) {
uint end = start + count;
if (end > numbers.length) end = numbers.length;
uint total = 0;
for (uint i = start; i < end; i++) {
total += numbers[i];
}
return total;
}
// ✅ 更好:维护一个累计值
uint public runningTotal;
function addWithTotal(uint _num) public {
numbers.push(_num);
runningTotal += _num;
}
3.4 memory 数组
function createMemoryArray() public pure returns (uint[] memory) {
// memory 数组必须指定长度,不能 push
uint[] memory temp = new uint[](5);
temp[0] = 10;
temp[1] = 20;
temp[2] = 30;
// temp.push(40); // ❌ memory 数组不支持 push
return temp;
}
四、Enum(枚举)
4.1 基础用法
contract EnumDemo {
// 枚举定义
enum OrderStatus {
Pending, // 0
Confirmed, // 1
Shipped, // 2
Delivered, // 3
Cancelled, // 4
Refunded // 5
}
OrderStatus public currentStatus;
// 设置状态
function confirm() public {
require(currentStatus == OrderStatus.Pending, "Not pending");
currentStatus = OrderStatus.Confirmed;
}
// 获取状态(uint形式)
function getStatusUint() public view returns (uint) {
return uint(currentStatus);
}
// 从 uint 转换
function setStatusFromUint(uint _status) public {
require(_status <= uint(type(OrderStatus).max), "Invalid status");
currentStatus = OrderStatus(_status);
}
// 获取默认值
function getDefault() public pure returns (OrderStatus) {
return type(OrderStatus).min; // Pending (0)
}
}
4.2 状态机模式
枚举与 modifier 结合,实现强类型的状态机:
contract StateMachine {
enum Phase { Registration, Voting, Tallying, Completed }
Phase public currentPhase = Phase.Registration;
address public admin;
modifier onlyInPhase(Phase _phase) {
require(currentPhase == _phase, "Wrong phase");
_;
}
modifier onlyAdmin() {
require(msg.sender == admin, "Not admin");
_;
}
constructor() {
admin = msg.sender;
}
function register() public onlyInPhase(Phase.Registration) {
// 只在 Registration 阶段可以注册
}
function vote() public onlyInPhase(Phase.Voting) {
// 只在 Voting 阶段可以投票
}
// 推进到下一阶段
function advancePhase() public onlyAdmin {
require(currentPhase != Phase.Completed, "Already completed");
currentPhase = Phase(uint(currentPhase) + 1);
}
}
五、代码实战:TodoList 合约
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
/// @title TodoList - 完整的链上待办事项管理
/// @notice 综合演示 mapping, struct, array, enum 的使用
error TodoList__NotOwner();
error TodoList__TodoNotFound(uint256 todoId);
error TodoList__InvalidTransition(uint8 from, uint8 to);
error TodoList__EmptyTitle();
error TodoList__IndexOutOfBounds(uint256 index, uint256 length);
contract TodoList {
// ============ 枚举:待办状态 ============
enum Priority { Low, Medium, High, Critical }
enum Status { Open, InProgress, Completed, Cancelled }
// ============ 结构体:待办项 ============
struct Todo {
uint256 id;
string title;
string description;
Priority priority;
Status status;
address creator;
uint256 createdAt;
uint256 updatedAt;
uint256 completedAt;
string[] tags;
}
// ============ 状态变量 ============
uint256 private nextId = 1;
// 核心存储:id => Todo
mapping(uint256 => Todo) private todos;
// 辅助索引
mapping(address => uint256[]) private userTodoIds; // 用户 => 他的 Todo ID 列表
mapping(uint256 => bool) private todoExists; // ID 是否存在
// 按状态统计
mapping(address => mapping(Status => uint256)) public statusCount;
// 全局数据
uint256 public totalTodos;
uint256[] private allTodoIds;
// ============ 事件 ============
event TodoCreated(
uint256 indexed id,
address indexed creator,
string title,
Priority priority
);
event TodoStatusChanged(
uint256 indexed id,
Status indexed oldStatus,
Status indexed newStatus
);
event TodoDeleted(uint256 indexed id, address indexed deletedBy);
// ============ 修饰符 ============
modifier onlyTodoOwner(uint256 _id) {
if (!todoExists[_id]) revert TodoList__TodoNotFound(_id);
if (todos[_id].creator != msg.sender) revert TodoList__NotOwner();
_;
}
modifier todoMustExist(uint256 _id) {
if (!todoExists[_id]) revert TodoList__TodoNotFound(_id);
_;
}
// ============ 创建 ============
/// @notice 创建新的待办项
function createTodo(
string calldata _title,
string calldata _description,
Priority _priority,
string[] calldata _tags
) external returns (uint256) {
if (bytes(_title).length == 0) revert TodoList__EmptyTitle();
uint256 todoId = nextId++;
Todo storage newTodo = todos[todoId];
newTodo.id = todoId;
newTodo.title = _title;
newTodo.description = _description;
newTodo.priority = _priority;
newTodo.status = Status.Open;
newTodo.creator = msg.sender;
newTodo.createdAt = block.timestamp;
newTodo.updatedAt = block.timestamp;
// 复制 tags 数组
for (uint i = 0; i < _tags.length; i++) {
newTodo.tags.push(_tags[i]);
}
// 更新索引
todoExists[todoId] = true;
userTodoIds[msg.sender].push(todoId);
allTodoIds.push(todoId);
totalTodos++;
statusCount[msg.sender][Status.Open]++;
emit TodoCreated(todoId, msg.sender, _title, _priority);
return todoId;
}
// ============ 读取 ============
/// @notice 获取待办详情
function getTodo(uint256 _id) external view todoMustExist(_id) returns (
string memory title,
string memory description,
Priority priority,
Status status,
address creator,
uint256 createdAt,
uint256 completedAt
) {
Todo storage t = todos[_id];
return (
t.title,
t.description,
t.priority,
t.status,
t.creator,
t.createdAt,
t.completedAt
);
}
/// @notice 获取待办的标签
function getTodoTags(uint256 _id) external view todoMustExist(_id)
returns (string[] memory)
{
return todos[_id].tags;
}
/// @notice 获取用户的所有待办 ID
function getUserTodoIds(address _user) external view returns (uint256[] memory) {
return userTodoIds[_user];
}
/// @notice 分页获取用户待办
function getUserTodosPaginated(
address _user,
uint256 _offset,
uint256 _limit
) external view returns (uint256[] memory) {
uint256[] storage ids = userTodoIds[_user];
if (_offset >= ids.length) {
return new uint256[](0);
}
uint256 end = _offset + _limit;
if (end > ids.length) end = ids.length;
uint256 resultLength = end - _offset;
uint256[] memory result = new uint256[](resultLength);
for (uint i = 0; i < resultLength; i++) {
result[i] = ids[_offset + i];
}
return result;
}
// ============ 更新 ============
/// @notice 更新待办状态
function updateStatus(uint256 _id, Status _newStatus)
external
onlyTodoOwner(_id)
{
Todo storage t = todos[_id];
Status oldStatus = t.status;
// 状态转换验证
if (!_isValidTransition(oldStatus, _newStatus)) {
revert TodoList__InvalidTransition(uint8(oldStatus), uint8(_newStatus));
}
// 更新状态计数
statusCount[msg.sender][oldStatus]--;
statusCount[msg.sender][_newStatus]++;
t.status = _newStatus;
t.updatedAt = block.timestamp;
if (_newStatus == Status.Completed) {
t.completedAt = block.timestamp;
}
emit TodoStatusChanged(_id, oldStatus, _newStatus);
}
/// @notice 更新待办内容
function updateContent(
uint256 _id,
string calldata _title,
string calldata _description,
Priority _priority
) external onlyTodoOwner(_id) {
if (bytes(_title).length == 0) revert TodoList__EmptyTitle();
Todo storage t = todos[_id];
t.title = _title;
t.description = _description;
t.priority = _priority;
t.updatedAt = block.timestamp;
}
/// @notice 添加标签
function addTag(uint256 _id, string calldata _tag)
external
onlyTodoOwner(_id)
{
todos[_id].tags.push(_tag);
todos[_id].updatedAt = block.timestamp;
}
// ============ 删除 ============
/// @notice 删除待办(软删除:标记为取消)
function cancelTodo(uint256 _id) external onlyTodoOwner(_id) {
Todo storage t = todos[_id];
require(t.status != Status.Cancelled, "Already cancelled");
statusCount[msg.sender][t.status]--;
statusCount[msg.sender][Status.Cancelled]++;
t.status = Status.Cancelled;
t.updatedAt = block.timestamp;
emit TodoDeleted(_id, msg.sender);
}
// ============ 统计查询 ============
/// @notice 获取用户各状态的待办数量
function getUserStats(address _user) external view returns (
uint256 open,
uint256 inProgress,
uint256 completed,
uint256 cancelled
) {
return (
statusCount[_user][Status.Open],
statusCount[_user][Status.InProgress],
statusCount[_user][Status.Completed],
statusCount[_user][Status.Cancelled]
);
}
// ============ 内部函数 ============
/// @dev 验证状态转换是否合法
function _isValidTransition(Status _from, Status _to)
internal
pure
returns (bool)
{
// Open → InProgress, Cancelled
if (_from == Status.Open) {
return _to == Status.InProgress || _to == Status.Cancelled;
}
// InProgress → Completed, Open (退回), Cancelled
if (_from == Status.InProgress) {
return _to == Status.Completed ||
_to == Status.Open ||
_to == Status.Cancelled;
}
// Completed 和 Cancelled 是终态
return false;
}
}
六、数据结构选择指南
| 需求 | 推荐数据结构 | 原因 |
|---|---|---|
| 通过 key 快速查找 | mapping | O(1) 读写 |
| 需要遍历所有元素 | array | mapping 不可遍历 |
| key 查找 + 遍历 | mapping + array 组合 | 兼顾两者 |
| 固定数量的属性 | struct | 清晰的数据组织 |
| 有限的离散状态 | enum | 类型安全的状态管理 |
| 嵌套关系 | 嵌套 mapping | 如 allowances |
| 排序/排名 | 链下排序或特殊数据结构 | 链上排序 gas 昂贵 |
七、关键要点总结
| 要点 | 说明 |
|---|---|
| mapping 不可遍历 | 需要配合数组使用 |
| mapping 无默认检查 | 不存在的 key 返回默认值 |
| struct storage vs memory | storage 修改影响链上,memory 是副本 |
| 动态数组 push/pop | storage 数组可以,memory 数组不可以 |
| 遍历数组有 gas 上限 | 长数组需要分页处理 |
| enum 是 uint | 底层用 uint8 表示,节省存储 |
| delete 不是真删除 | 重置为默认值,数组长度不变 |
八、常见误区
误区 1:修改 memory struct 以为会保存
function bug(uint id) public {
Todo memory t = todos[id]; // 复制到内存
t.status = Status.Completed; // 只修改了内存副本!
// todos[id] 的 status 没有改变
}
function fix(uint id) public {
Todo storage t = todos[id]; // 引用 storage
t.status = Status.Completed; // ✅ 修改了实际存储
}
误区 2:以为 delete 数组元素会缩短数组
uint[] public arr = [1, 2, 3, 4, 5];
delete arr[2]; // arr = [1, 2, 0, 4, 5],长度仍然是 5!
// 要真正移除需要手动处理
误区 3:mapping 的 key 类型限制
// ✅ 可以作为 key 的类型
mapping(address => uint) a;
mapping(uint => uint) b;
mapping(bytes32 => uint) c;
mapping(bool => uint) d;
// ❌ 不能作为 key 的类型
// mapping(string => uint) // 理论上可以但很贵
// mapping(struct => uint) // 不行
// mapping(mapping => uint) // 不行
// mapping(array => uint) // 不行
误区 4:在循环中 push 到正在遍历的数组
// ❌ 危险!
function bad() public {
for (uint i = 0; i < arr.length; i++) {
if (arr[i] == 0) {
arr.push(99); // 修改了 arr.length,可能无限循环!
}
}
}
九、面试关联
Q: Solidity 的 mapping 底层是怎么实现的?为什么不能遍历?
A: Mapping 使用 keccak256 哈希计算存储位置:slot = keccak256(key, mappingSlot)。由于键经过哈希后分散存储在整个 2^256 的存储空间中,没有任何数据结构记录"哪些 key 被使用过",所以无法遍历。要支持遍历,需要自己维护一个辅助的键数组。这也是为什么 EnumerableMap(OpenZeppelin)和 Iterable Mapping 模式存在的原因。
Q: 为什么在 DeFi 中经常看到 mapping + array 的组合?
A: 这是为了同时满足两个需求:(1) mapping 提供 O(1) 的快速查找(通过地址查余额、通过 ID 查订单);(2) array 提供可遍历性(列出所有用户、计算总量)。在 Uniswap V3 中,tick 数据用 mapping 存储,但同时维护一个有序的 tick 列表。在 Aave 中,用户的资产列表用数组存储,方便清算时遍历检查。
Q: struct 中字段的排列顺序重要吗?
A: 非常重要!EVM 的 storage 是以 32 字节(256 位)为一个 slot。多个小于 32 字节的变量可以打包在一个 slot 中,从而节省 gas。例如:struct Bad { uint8 a; uint256 b; uint8 c; } 需要 3 个 slot,但 struct Good { uint8 a; uint8 c; uint256 b; } 只需要 2 个 slot(a 和 c 共享一个 slot)。这叫做"紧凑存储"(tight packing)。
十、参考资源
| 资源 | 链接 | 说明 |
|---|---|---|
| Solidity Mapping | https://docs.soliditylang.org/en/latest/types.html#mapping-types | 官方文档 |
| Solidity Structs | https://docs.soliditylang.org/en/latest/types.html#structs | 官方文档 |
| Storage Layout | https://docs.soliditylang.org/en/latest/internals/layout_in_storage.html | 存储布局详解 |
| Solidity by Example | https://solidity-by-example.org/mapping/ | 代码示例 |
| OpenZeppelin EnumerableSet | https://docs.openzeppelin.com/contracts/4.x/api/utils#EnumerableSet | 可枚举集合 |
| Patrick Collins课程 | https://www.youtube.com/watch?v=gyMwXuJrbJQ | 全栈 Solidity 教程 |