第四节: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;
}
关键点:结构体操作时,一定要搞清楚 storage 和 memory 的区别。用 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 合约时,你几乎每天都在组合使用它们。
下一节我们会深入函数修饰符和错误处理——这些东西能让你写出更安全、更优雅的合约。到时候见。