4、映射与结构体:mapping的使用、struct的定义与实例化、在NFT合约中存储元数据
好,咱们进入第四讲。这一章要聊的,是Solidity里两个非常核心的数据结构——mapping和struct。说白了,没有它们,你的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就别用数组。