4、Solidity进阶:映射、结构体、数组、枚举、修饰符

好,咱们继续往前走。上一章我们把Solidity的基础语法过了一遍,现在该聊聊真正干活时天天用的东西了。

映射、结构体、数组、枚举、修饰符——这几个东西,说白了就是Solidity里组织数据和逻辑的“四件套”。我当年刚学的时候,觉得它们各自独立,后来才发现,它们经常组合在一起用。你想想看,一个真实的DApp,怎么可能只用单一类型?

4.1 结构体(struct)—— 自定义数据类型

结构体,就是让你把多个变量打包成一个“新类型”。

比如你要记录一个用户的信息:地址、名字、余额。用三个独立变量去存?太乱了。用结构体,清爽很多。

// 定义一个用户结构体
struct User {
    address addr;
    string name;
    uint balance;
}

定义好之后,你就可以像用uint一样用它了。

User public myUser;

function setUser(address _addr, string memory _name, uint _balance) public {
    myUser = User(_addr, _name, _balance);
}

嗯,这里要注意:结构体里的字符串类型,在函数参数里要加 memory 关键字。这是Solidity的存储位置规则,我一开始老忘,后来养成习惯:凡是传进来的复杂类型,先问自己一句“放哪?”

我的习惯:结构体定义尽量放在合约顶部,方便全局使用。如果结构体字段很多,考虑拆分成多个小结构体,避免单次存储太贵。

4.2 数组(array)—— 有序列表

数组分两种:固定长度和动态长度。固定长度数组在编译时就知道大小,动态数组可以push。

uint[5] public fixedArray;      // 固定长度,5个元素
uint[] public dynamicArray;     // 动态数组,可以push

动态数组在Solidity里特别常用,尤其是配合结构体。

User[] public users;

function addUser(address _addr, string memory _name, uint _balance) public {
    users.push(User(_addr, _name, _balance));
}

我在项目中遇到过一个问题:数组太大时,遍历会非常耗gas。有一次我写了个循环遍历所有用户,结果合约调用直接out of gas。从那以后,我尽量用映射来替代数组遍历,或者维护一个“活跃用户索引”数组。

避坑指南:Solidity里数组的 delete 操作不会真的缩小数组长度,它只是把值重置为默认值。如果你要真正移除元素,得自己写逻辑,比如把最后一个元素移到被删位置,然后pop。

4.3 映射(mapping)—— 键值对存储

映射是Solidity里最强大的数据结构之一。它就像一个字典,给你一个key,你就能找到对应的value。

mapping(address => uint) public balances;
mapping(uint => User) public userById;

映射的语法很特别:mapping(KeyType => ValueType)。KeyType不能是复杂类型(比如结构体、数组),但ValueType可以是任何类型,包括另一个映射。

我建议你养成一个习惯:能用映射就别用数组。为什么?因为映射的查找是O(1)的,而数组遍历是O(n)。在区块链上,gas就是命啊。

// 用映射存储用户信息,用地址做key
mapping(address => User) public userMap;

function getUser(address _addr) public view returns (User memory) {
    return userMap[_addr];
}

不过映射有个缺点:你不能遍历它。Solidity没有提供遍历映射的内置方法。如果你需要遍历所有用户,就得同时维护一个数组来记录所有key。

核心要点:映射 + 数组 组合使用,既能快速查找,又能遍历全部。这是很多DeFi合约的标配。

4.4 枚举(enum)—— 有限状态集合

枚举,说白了就是给一组常量起个名字。比如订单状态:待支付、已支付、已发货、已完成。

enum OrderStatus { Pending, Paid, Shipped, Completed }

OrderStatus public status;

function pay() public {
    require(status == OrderStatus.Pending, "订单状态不对");
    status = OrderStatus.Paid;
}

枚举在Solidity里本质上是uint,从0开始编号。所以 Pending = 0Paid = 1,以此类推。

我个人习惯把枚举用在状态机模式里。比如一个众筹合约:Created -> Funded -> Failed -> Completed。每个状态只能转移到特定的下一个状态,这样逻辑清晰,不容易出bug。

小技巧:枚举值不要太多,一般不超过10个。太多的话,考虑用uint直接表示状态,或者拆分成多个枚举。

4.5 修饰符(modifier)—— 代码复用利器

修饰符是Solidity里一个很优雅的设计。它让你把重复的权限检查、条件判断抽出来,放到函数前面。

modifier onlyOwner() {
    require(msg.sender == owner, "你不是合约所有者");
    _;
}

function withdraw() public onlyOwner {
    // 只有owner才能调用
    payable(owner).transfer(address(this).balance);
}

那个 _ 是占位符,表示被修饰的函数体插入的位置。你可以把修饰符想象成一个“函数包装器”。

修饰符还可以带参数:

modifier minBalance(uint _min) {
    require(balances[msg.sender] >= _min, "余额不足");
    _;
}

function transfer(address _to, uint _amount) public minBalance(100) {
    // 只有余额大于100才能转账
}

我在项目中遇到过一个问题:修饰符里如果调用了外部合约,可能会被重入攻击。所以我的原则是:修饰符里只做简单的状态检查,不要做外部调用。

避坑指南:修饰符的执行顺序是从外到内。如果你有多个修饰符,比如 function foo() public onlyOwner whenNotPaused,那么 onlyOwner 先执行,然后 whenNotPaused,最后才是函数体。

4.6 组合使用:一个完整的例子

好了,咱们把上面这些知识点串起来,写一个简单的“用户管理系统”。

contract UserManager {
    address public owner;

    enum UserLevel { Bronze, Silver, Gold }

    struct User {
        string name;
        uint points;
        UserLevel level;
        bool active;
    }

    mapping(address => User) public users;
    address[] public userList;

    modifier onlyOwner() {
        require(msg.sender == owner, "Not owner");
        _;
    }

    modifier userExists(address _addr) {
        require(users[_addr].active, "User not exists");
        _;
    }

    function addUser(address _addr, string memory _name) public onlyOwner {
        require(!users[_addr].active, "User already exists");
        users[_addr] = User(_name, 0, UserLevel.Bronze, true);
        userList.push(_addr);
    }

    function addPoints(address _addr, uint _points) public onlyOwner userExists(_addr) {
        users[_addr].points += _points;
        // 升级逻辑
        if (users[_addr].points >= 100) {
            users[_addr].level = UserLevel.Gold;
        } else if (users[_addr].points >= 50) {
            users[_addr].level = UserLevel.Silver;
        }
    }

    function getUserCount() public view returns (uint) {
        return userList.length;
    }
}

你看,结构体、映射、数组、枚举、修饰符,全用上了。这个合约虽然简单,但已经具备了真实项目的基本骨架。

最后说一句:这些数据结构不是孤立的。你写合约的时候,多想想它们怎么配合。比如映射存数据,数组存索引,枚举控状态,修饰符管权限。组合起来,就是一套完整的业务逻辑。

下一章,咱们聊聊事件和错误处理。这些东西在调试和前端交互时特别重要。