4、Solidity数据类型(下):字符串、枚举、结构体、映射

好,咱们接着聊。上一节我们把Solidity的基础数据类型过了一遍,像uint、address这些,说白了就是最底层的积木块。但光有这些,写起复杂逻辑来还是有点束手束脚。今天要讲的这四种——字符串、枚举、结构体、映射,才是真正让你能搭出“城堡”的东西。

我个人习惯把这一节叫做“组合拳”。因为从这开始,你写的合约会越来越像真正的业务系统。嗯,咱们一个一个来。

4.1 字符串(string)

字符串在Solidity里,其实是个挺“别扭”的类型。为什么这么说?因为它本质上是一个动态的字节数组,但又不完全等同于bytes。我刚开始学的时候,就老搞混stringbytes的区别。

简单来说:string就是用来存UTF-8编码的文本的。你不能直接通过索引去修改某个字符,也不能直接获取它的长度(除非转成bytes)。

核心要点:Solidity中的string不支持直接拼接、修改等操作。如果你需要频繁操作字符串,建议先转成bytes,处理完再转回来。

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

contract StringDemo {
    string public greeting = "Hello, Solidity!";

    // 获取字符串长度(需要转成bytes)
    function getLength() public view returns (uint) {
        return bytes(greeting).length;
    }

    // 修改字符串(通过bytes中转)
    function setChar(uint index, bytes1 char) public {
        bytes memory strBytes = bytes(greeting);
        require(index < strBytes.length, "Index out of bounds");
        strBytes[index] = char;
        greeting = string(strBytes);
    }
}

你看,改一个字符都得绕个弯子。我在项目中遇到过需要存储用户昵称的场景,当时图省事直接用string存,结果后来要做模糊搜索,发现根本没法高效处理。最后只能把昵称哈希后存到mapping里,再用事件去链下做搜索。嗯,这里要提醒你:链上不是存大文本的地方,string一般只用来存短文本、备注、或者URL。

我的建议:如果字符串长度不确定,或者可能超过32字节,用string。如果确定长度不超过32字节,用bytes32更省gas。

4.2 枚举(enum)

枚举这东西,说白了就是给数字起个名字。比如订单状态:0代表已创建,1代表已支付,2代表已发货。你写代码的时候总不能老记着0、1、2吧?用枚举就清晰多了。

contract EnumDemo {
    enum Status { Created, Paid, Shipped, Completed, Cancelled }

    Status public currentStatus;

    constructor() {
        currentStatus = Status.Created;
    }

    function nextStatus() public {
        // 枚举可以比较,也可以转成uint
        require(currentStatus != Status.Completed, "Already completed");
        currentStatus = Status(uint(currentStatus) + 1);
    }

    function getStatusValue() public view returns (uint) {
        return uint(currentStatus);
    }
}

枚举在Solidity里底层就是uint8,从0开始递增。你想想看,用枚举写出来的代码,可读性是不是比直接写数字好太多了?我曾经接手过一个老合约,里面全是if (status == 2)这种写法,看得我头皮发麻。后来重构时全改成了枚举,维护起来舒服多了。

注意:枚举不能超过256个成员(因为底层是uint8)。不过说实话,你一般也用不了那么多。另外,枚举不能作为函数参数在外部调用中直接传递,但可以在内部使用。

4.3 结构体(struct)

结构体才是真正的“组合拳”。你可以把多个不同类型的数据打包成一个自定义类型。比如一个人,有名字、年龄、地址——用结构体就完美。

contract StructDemo {
    struct Person {
        string name;
        uint age;
        address wallet;
        bool isActive;
    }

    // 存储多个Person
    Person[] public people;
    mapping(address => Person) public personByAddress;

    function addPerson(string memory _name, uint _age) public {
        // 方式一:直接赋值
        Person memory newPerson = Person({
            name: _name,
            age: _age,
            wallet: msg.sender,
            isActive: true
        });
        people.push(newPerson);

        // 方式二:位置参数(按顺序)
        // Person memory p = Person(_name, _age, msg.sender, true);

        // 存入mapping
        personByAddress[msg.sender] = newPerson;
    }

    function updateAge(uint _newAge) public {
        // 注意:mapping返回的是storage引用,可以直接修改
        Person storage p = personByAddress[msg.sender];
        p.age = _newAge;
    }
}

这里有个坑,我刚开始踩过好几次:结构体里的数组和mapping不能作为public函数的返回值。因为Solidity不允许返回动态数组或mapping类型的结构体成员。解决办法是单独写getter函数,或者只返回部分字段。

经验之谈:结构体配合数组和mapping,基本能覆盖90%的数据存储需求。我个人习惯把结构体定义在合约最前面,方便后面引用。另外,结构体里的字段顺序会影响存储布局,把相同类型的字段放一起可以节省gas。

4.4 映射(mapping)

映射,说白了就是哈希表。在Solidity里,它是最常用的数据结构,没有之一。为什么?因为它的查询速度是O(1),而且天然支持键值对存储。

contract MappingDemo {
    // 基本映射:地址 => 余额
    mapping(address => uint) public balances;

    // 嵌套映射:地址 => (代币ID => 余额)
    mapping(address => mapping(uint => uint)) public tokenBalances;

    // 映射的值可以是结构体
    mapping(uint => Person) public personById;

    function deposit() public payable {
        balances[msg.sender] += msg.value;
    }

    function getBalance(address _addr) public view returns (uint) {
        return balances[_addr];
    }

    // 嵌套映射的使用
    function setTokenBalance(address _user, uint _tokenId, uint _amount) public {
        tokenBalances[_user][_tokenId] = _amount;
    }
}

映射有几个特点,你得记住:

  • 不能遍历:映射没有length属性,也不能用for循环遍历所有键。如果你需要遍历,得配合数组手动记录所有键。
  • 所有键默认都有值:访问不存在的键,返回对应值类型的零值(uint返回0,address返回0x0...)。
  • 不能作为函数参数或返回值:映射只能作为状态变量或局部storage引用。

我曾经踩过的坑:有一次写一个众筹合约,用mapping记录每个地址的捐款金额。结果发现有人可以反复调用提现函数,因为mapping里不存在的键返回0,而0满足提现条件。后来加了一个bool类型的mapping来标记是否已提现,才堵住这个漏洞。

4.5 综合示例:一个简单的用户管理系统

把今天学的四种类型串起来,写个简单的用户管理合约。你看,组合起来多强大:

contract UserManager {
    enum UserRole { Guest, Member, Admin, SuperAdmin }

    struct User {
        string name;
        uint age;
        UserRole role;
        bool isActive;
    }

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

    function register(string memory _name, uint _age) public {
        require(bytes(users[msg.sender].name).length == 0, "Already registered");
        
        User memory newUser = User({
            name: _name,
            age: _age,
            role: UserRole.Member,
            isActive: true
        });
        
        users[msg.sender] = newUser;
        userList.push(msg.sender);
    }

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

    function isAdmin(address _addr) public view returns (bool) {
        return users[_addr].role == UserRole.Admin || 
               users[_addr].role == UserRole.SuperAdmin;
    }
}

你看,用枚举定义角色,用结构体打包用户信息,用映射实现地址到用户的快速查找,再用数组记录所有用户地址以便遍历。四种类型配合得天衣无缝。

最后说一句:学数据类型,别死记硬背。多写几个小合约,把string、enum、struct、mapping组合起来用,慢慢就找到感觉了。下一节我们讲函数和修饰符,到时候你会看到这些数据类型在函数里怎么“活起来”。