4、映射与结构体:mapping的使用、struct的定义与实例化、在NFT合约中存储元数据

好,咱们进入第四讲。这一章要聊的,是Solidity里两个非常核心的数据结构——mappingstruct。说白了,没有它们,你的NFT合约根本没法存数据。你想想看,一个NFT要记录谁拥有它、它的名字是什么、图片链接在哪,这些信息怎么组织?嗯,就是靠mapping和struct来搞定。

4.1 mapping:键值对存储的利器

mapping,翻译过来就是“映射”。它有点像其他语言里的字典或哈希表。但Solidity里的mapping有个特点:你不能遍历它。为什么?因为遍历太耗gas了,以太坊上每一笔计算都要花钱,遍历一个巨大的mapping,gas费能把你吓哭。

我个人习惯,mapping最常用的场景就是:用一个地址去查一个值。比如,查某个地址拥有多少NFT。

// 定义一个mapping:从地址到uint256的映射
mapping(address => uint256) public balances;

// 使用
balances[msg.sender] = 10;  // 给调用者设置余额为10
uint256 bal = balances[msg.sender]; // 查询余额

这里要注意,mapping的语法是 mapping(KeyType => ValueType)。KeyType可以是内置类型,比如address、uint256,但不能是结构体或数组。ValueType就随意了,甚至可以是另一个mapping——这就是嵌套mapping。

重要概念:mapping的默认值。如果你查询一个不存在的键,Solidity不会报错,而是返回该值类型的默认值。比如uint256返回0,address返回0x0,bool返回false。这一点我在项目中踩过坑,后面会讲。

4.2 struct:把数据打包成“包裹”

struct就是结构体。它允许你把多个不同类型的变量打包成一个自定义类型。比如一个NFT的元数据,包含名字、描述、图片链接,你就可以用一个struct来装。

// 定义一个结构体
struct NFTMetadata {
    string name;
    string description;
    string imageURI;
    uint256 mintedAt;
}

定义完struct之后,你就可以像使用普通类型一样使用它。比如作为mapping的value,或者作为数组的元素。

我记得刚开始写合约时,总喜欢把所有数据都摊开来写,结果代码又长又乱。后来学会用struct,代码瞬间清爽了。你想想看,一个struct就是一个“包裹”,把相关的数据捆在一起,多清晰。

4.3 struct的实例化

定义好struct之后,怎么创建它的实例呢?Solidity给了你几种方式。

// 方式一:按顺序传参
NFTMetadata memory meta1 = NFTMetadata("CryptoPunk #1", "A rare punk", "ipfs://xxx", block.timestamp);

// 方式二:使用命名参数(推荐)
NFTMetadata memory meta2 = NFTMetadata({
    name: "CryptoPunk #2",
    description: "Another rare punk",
    imageURI: "ipfs://yyy",
    mintedAt: block.timestamp
});

我个人强烈推荐第二种方式。为什么?因为按顺序传参容易搞错顺序,尤其是字段多的时候。命名参数一目了然,代码可读性高很多。我在项目中见过有人把name和description传反了,结果NFT的名字变成了描述,描述变成了名字……嗯,那场面挺尴尬的。

小技巧:struct可以存储在storage、memory或calldata中。但要注意,mapping类型的字段不能出现在memory的struct里。如果你需要在内存中操作包含mapping的struct,那得用storage引用。

4.4 在NFT合约中存储元数据

好了,现在我们把mapping和struct结合起来,看看在真正的NFT合约里怎么用。

一个典型的ERC-721合约,需要记录每个tokenId对应的拥有者、以及它的元数据。我们可以这样设计:

contract MyNFT {
    // 定义元数据结构体
    struct NFTMetadata {
        string name;
        string description;
        string imageURI;
    }

    // mapping:tokenId => 拥有者地址
    mapping(uint256 => address) private _owners;

    // mapping:tokenId => 元数据
    mapping(uint256 => NFTMetadata) private _metadata;

    // mapping:地址 => 该地址拥有的token数量
    mapping(address => uint256) private _balances;

    // 铸造NFT
    function mint(address to, uint256 tokenId, string memory name, string memory desc, string memory uri) external {
        _owners[tokenId] = to;
        _balances[to] += 1;
        _metadata[tokenId] = NFTMetadata(name, desc, uri);
    }

    // 查询元数据
    function getMetadata(uint256 tokenId) external view returns (NFTMetadata memory) {
        return _metadata[tokenId];
    }
}

你看,这里用了三个mapping:一个存拥有者,一个存元数据,一个存余额。每个mapping都各司其职,互不干扰。

避坑指南:我曾经在项目里犯过一个错误——把元数据直接存在合约里。结果每个NFT的元数据都占用了链上存储,gas费高得离谱。后来我学乖了,元数据里的imageURI只存一个IPFS哈希或HTTP链接,真正的图片和描述文件存在链下。链上只存“指针”,不存“内容”。

4.5 mapping与struct的嵌套使用

有时候,一个mapping的value本身就是一个复杂的结构体,甚至结构体里还包含mapping。比如你想记录每个地址的多个NFT信息:

struct UserInfo {
    uint256 totalMinted;
    mapping(uint256 => NFTMetadata) ownedNFTs;
}

mapping(address => UserInfo) private _users;

这种嵌套写法在复杂合约里很常见。但要注意,结构体里的mapping只能声明,不能直接初始化。你得通过引用来操作它。

function addNFT(address user, uint256 tokenId, NFTMetadata memory meta) internal {
    UserInfo storage info = _users[user];
    info.totalMinted += 1;
    info.ownedNFTs[tokenId] = meta;
}

这里的关键是 storage 关键字。它告诉Solidity,我们操作的是链上存储的引用,而不是内存中的副本。如果你用 memory,那修改的只是临时副本,不会真正写入链上。

4.6 实战建议

最后,给你几个我在实际项目中总结的经验:

  • 能用mapping就别用数组。数组遍历太贵,mapping查询是O(1)的,gas费固定。
  • struct的字段顺序影响存储布局。Solidity会尽量把相同大小的字段打包在一起,节省存储空间。把uint256和uint256放一起,address和address放一起,别混着放。
  • 元数据尽量用URI。链上只存一个字符串链接,真正的数据存在IPFS或Arweave上。这样既省钱又灵活。
  • 别忘了删除数据。如果你实现了销毁功能,记得用 delete 关键字清理mapping和struct,否则gas费白花了。

嗯,这一章的内容就到这里。mapping和struct是Solidity的基石,你后面写任何合约都离不开它们。下一章我们会聊事件和修饰符,到时候你会看到这些数据结构如何与事件配合,实现链上数据的可查询性。

总结:mapping用于快速查找,struct用于数据打包。两者结合,就能构建出复杂而高效的数据模型。记住,链上存储很贵,能用指针就别存内容,能用mapping就别用数组。