返回 SC 笔记
SC Day 5

Solidity mapping + struct + array + enum

mapping 语法与嵌套映射、struct 定义与使用、动态/固定数组操作、enum 状态机模式

2026-04-14
第一阶段:基础构建
soliditymappingstructarrayenumdata-structures

日期: 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 快速查找mappingO(1) 读写
需要遍历所有元素arraymapping 不可遍历
key 查找 + 遍历mapping + array 组合兼顾两者
固定数量的属性struct清晰的数据组织
有限的离散状态enum类型安全的状态管理
嵌套关系嵌套 mapping如 allowances
排序/排名链下排序或特殊数据结构链上排序 gas 昂贵

七、关键要点总结

要点说明
mapping 不可遍历需要配合数组使用
mapping 无默认检查不存在的 key 返回默认值
struct storage vs memorystorage 修改影响链上,memory 是副本
动态数组 push/popstorage 数组可以,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)。


十、参考资源