第四节:Solidity基础(二)——字符串、数组、映射、结构体、枚举

好,咱们接着往下走。上一节我们把 Solidity 的基本数据类型和变量作用域捋了一遍,今天要聊的这几个东西——字符串、数组、映射、结构体、枚举——才是真正写 NFT 合约时天天打交道的核心工具。

我个人习惯把这一节叫做「合约开发的积木块」。你想想看,一个 ERC721 的 Token 元数据、一个批量转账的逻辑、一个白名单的存储……底层全是这些东西在支撑。搞懂了它们,你写合约会顺手很多。

一、字符串与字节数组

先说字符串。Solidity 里的 string 其实是个比较特殊的存在——它本质上是一个动态的 bytes 数组,但你不能直接通过索引去访问里面的字符。嗯,这一点和 JavaScript 或者 Python 很不一样。

核心区别:

  • string:用于存储 UTF-8 编码的文本,不能直接修改内部字符
  • bytes:用于存储原始字节数据,可以按索引访问和修改
  • bytes1 ~ bytes32:定长字节数组,gas 消耗更低
// 字符串声明
string public name = "CryptoPunk";
bytes public data = hex"1234";

// 字符串拼接(需要借助 abi.encodePacked)
function concatStrings(string memory a, string memory b) public pure returns (string memory) {
    return string(abi.encodePacked(a, b));
}

// 获取字符串长度
function getStringLength(string memory str) public pure returns (uint) {
    return bytes(str).length;  // 注意:返回的是字节数,不是字符数
}

我的经验:在 NFT 合约里,我几乎不用 string 做复杂的字符串操作。为什么?因为 gas 太贵了。每次修改字符串都要重新分配内存。我一般把元数据的 URL 直接存成 string,但不会去拼接或截取它。真要处理字符串逻辑,我会在链下用 JavaScript 搞定。

避坑指南:我曾经在写一个白名单验证合约时,用 keccak256(abi.encodePacked(str1, str2)) 做字符串拼接哈希。结果发现两个不同的字符串组合可能产生相同的哈希值——因为 abi.encodePacked 会压缩连续的空字节。后来我改用 abi.encode 才解决。记住:涉及哈希验证时,优先用 abi.encode

二、数组:定长与动态

数组是 Solidity 里最常用的集合类型。它分两种:定长数组和动态数组。说白了,定长数组在声明时就要确定大小,动态数组可以随时 push 和 pop。

// 定长数组
uint[5] public fixedArray = [1, 2, 3, 4, 5];

// 动态数组
uint[] public dynamicArray;

// 动态数组操作
function addElement(uint value) public {
    dynamicArray.push(value);  // 追加元素
}

function removeLast() public {
    dynamicArray.pop();  // 移除最后一个元素
}

function getLength() public view returns (uint) {
    return dynamicArray.length;
}
特性 定长数组 动态数组
声明时大小 必须指定 不需要
push/pop 不支持 支持
gas 消耗 较低(栈上存储) 较高(存储扩展)
典型场景 固定数量的配置项 动态列表、白名单

重要提醒:动态数组在 storage 中存储时,每次 push 都可能触发存储扩展,gas 消耗会突然飙升。我建议你预估好最大长度,或者用 mapping 替代大数组。

三、映射(mapping)

映射是 Solidity 里最强大的数据结构,没有之一。它本质上是一个哈希表,key 到 value 的映射。在 NFT 合约里,你几乎天天和它打交道——比如记录某个地址拥有哪些 Token。

// 基本映射
mapping(address => uint) public balances;

// 嵌套映射(二维映射)
mapping(address => mapping(uint => bool)) public userTokens;

// 映射 + 结构体
struct TokenInfo {
    string uri;
    uint price;
}
mapping(uint => TokenInfo) public tokenData;

// 映射的常用操作
function setBalance(address user, uint amount) public {
    balances[user] = amount;
}

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

function deleteBalance(address user) public {
    delete balances[user];  // 重置为默认值 0
}

我的习惯:我写映射时,一定会加一个 exists 标记。为什么?因为映射的 key 不存在时,返回的是默认值(比如 uint 返回 0)。你没法区分「这个 key 不存在」和「这个 key 的值就是 0」。所以我通常会这样写:

mapping(address => bool) public userExists;
mapping(address => uint) public userBalances;

function setBalance(address user, uint amount) public {
    userExists[user] = true;
    userBalances[user] = amount;
}

function getBalance(address user) public view returns (uint, bool) {
    return (userBalances[user], userExists[user]);
}

避坑指南:我曾经犯过一个低级错误——在循环里遍历 mapping。Solidity 的 mapping 不支持遍历,因为它没有存储 key 的列表。你没法知道 mapping 里到底有哪些 key。如果你需要遍历,必须额外维护一个数组来记录所有 key。这是很多新手会踩的坑。

四、结构体(struct)

结构体让你能把多个相关数据打包成一个自定义类型。在 NFT 合约里,我经常用结构体来表示一个 Token 的完整信息。

// 定义结构体
struct NFT {
    uint tokenId;
    string uri;
    address creator;
    uint mintTime;
    uint price;
}

// 使用结构体
mapping(uint => NFT) public nfts;

// 创建结构体实例
function mintNFT(uint tokenId, string memory uri) public {
    nfts[tokenId] = NFT({
        tokenId: tokenId,
        uri: uri,
        creator: msg.sender,
        mintTime: block.timestamp,
        price: 0
    });
}

// 或者用更简洁的方式
function mintNFT2(uint tokenId, string memory uri) public {
    nfts[tokenId] = NFT(tokenId, uri, msg.sender, block.timestamp, 0);
}

// 更新结构体中的某个字段
function setPrice(uint tokenId, uint newPrice) public {
    NFT storage nft = nfts[tokenId];  // 注意:用 storage 引用
    nft.price = newPrice;
}

关键点:结构体操作时,一定要搞清楚 storagememory 的区别。用 storage 引用可以直接修改链上数据,用 memory 只是拷贝一份到内存里,修改不会写回链上。我见过不少新手在这里翻车。

五、枚举(enum)

枚举是最简单但最实用的数据类型之一。它让你用人类可读的名字来表示一组有限的状态。在 NFT 合约里,我常用枚举来表示 Token 的状态——比如是否在售、是否被锁定等等。

// 定义枚举
enum TokenStatus { 
    Available,    // 0
    Listed,       // 1
    Sold,         // 2
    Locked        // 3
}

// 使用枚举
mapping(uint => TokenStatus) public tokenStatuses;

function listToken(uint tokenId) public {
    require(tokenStatuses[tokenId] == TokenStatus.Available, "Token not available");
    tokenStatuses[tokenId] = TokenStatus.Listed;
}

function buyToken(uint tokenId) public {
    require(tokenStatuses[tokenId] == TokenStatus.Listed, "Token not listed");
    tokenStatuses[tokenId] = TokenStatus.Sold;
}

// 枚举可以显式转换
function getStatusAsUint(uint tokenId) public view returns (uint) {
    return uint(tokenStatuses[tokenId]);  // 返回 0, 1, 2, 3
}

我的建议:枚举的默认值是第一个元素(索引 0)。所以设计枚举时,把「默认状态」放在第一个位置。比如上面的 Available 就是默认状态,这样新创建的 Token 自动就是可用的,省去了初始化步骤。

六、综合实战:一个简单的 NFT 存储合约

好了,我们把今天学的所有东西串起来,写一个简单的 NFT 存储合约。这个合约会用到结构体、映射、枚举和数组。

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

contract SimpleNFT {
    // 枚举:Token 状态
    enum Status { Available, Listed, Sold }
    
    // 结构体:Token 信息
    struct Token {
        uint id;
        string uri;
        address owner;
        Status status;
        uint price;
    }
    
    // 映射:tokenId => Token
    mapping(uint => Token) public tokens;
    
    // 数组:记录所有 tokenId(用于遍历)
    uint[] public tokenIds;
    
    // 映射:记录某个地址拥有的 tokenId 列表
    mapping(address => uint[]) public ownerTokens;
    
    // 事件
    event Minted(uint tokenId, address owner);
    event Listed(uint tokenId, uint price);
    event Sold(uint tokenId, address buyer);
    
    // 铸造
    function mint(uint tokenId, string memory uri) public {
        require(tokens[tokenId].id == 0, "Token already exists");
        
        tokens[tokenId] = Token(tokenId, uri, msg.sender, Status.Available, 0);
        tokenIds.push(tokenId);
        ownerTokens[msg.sender].push(tokenId);
        
        emit Minted(tokenId, msg.sender);
    }
    
    // 上架
    function list(uint tokenId, uint price) public {
        Token storage token = tokens[tokenId];
        require(token.owner == msg.sender, "Not owner");
        require(token.status == Status.Available, "Not available");
        
        token.status = Status.Listed;
        token.price = price;
        
        emit Listed(tokenId, price);
    }
    
    // 购买
    function buy(uint tokenId) public payable {
        Token storage token = tokens[tokenId];
        require(token.status == Status.Listed, "Not listed");
        require(msg.value >= token.price, "Insufficient payment");
        
        // 转移所有权
        address seller = token.owner;
        token.owner = msg.sender;
        token.status = Status.Sold;
        token.price = 0;
        
        // 更新 ownerTokens(简化处理,实际需要更复杂的逻辑)
        ownerTokens[msg.sender].push(tokenId);
        
        // 转账给卖家
        payable(seller).transfer(msg.value);
        
        emit Sold(tokenId, msg.sender);
    }
    
    // 获取某个地址的 Token 数量
    function balanceOf(address user) public view returns (uint) {
        return ownerTokens[user].length;
    }
    
    // 获取所有 Token ID
    function getAllTokenIds() public view returns (uint[] memory) {
        return tokenIds;
    }
}

总结一下:今天讲的这五个数据结构,是 Solidity 合约开发的基石。字符串和字节数组处理文本和原始数据,数组管理列表,映射提供高效的键值存储,结构体打包复杂数据,枚举管理状态。写 NFT 合约时,你几乎每天都在组合使用它们。

下一节我们会深入函数修饰符和错误处理——这些东西能让你写出更安全、更优雅的合约。到时候见。