第四讲:Solidity进阶——地址类型、映射、结构体、数组、事件
好,咱们继续往前走。上一章我们把Solidity基础语法过了一遍,现在该碰点真正干活的东西了。
这一章我带你深入四个核心概念:地址类型、映射(mapping)、结构体(struct)、数组(array)和事件(event)。说白了,这些就是你写DApp时每天都要打交道的工具。
我个人习惯把这一章称为「智能合约的骨架」——没有它们,你的合约就是一坨不能动的代码。
1. 地址类型(address)——区块链上的身份证
地址类型是Solidity里最特殊的类型之一。它代表一个以太坊账户,可以是外部账户(EOA),也可以是合约账户。
先看基本用法:
// 声明一个地址变量
address public owner;
address payable public receiver; // 可以接收ETH的地址
// 地址常量
address constant BURN_ADDRESS = 0x000000000000000000000000000000000000dEaD;
这里有个关键点:address和address payable的区别。后者多了transfer和send方法,可以转ETH。我刚开始写合约时经常搞混,结果转账函数编译不过去……嗯,后来就养成习惯了:只要涉及收钱的地址,一律用address payable。
核心方法:
.balance— 查询地址余额(单位wei).transfer(amount)— 转账(2300 gas,安全).send(amount)— 转账(返回bool,不抛异常).call{value: amount}("")— 底层调用(推荐方式)
避坑指南:我曾经在项目里用transfer给合约转账,结果对方合约的fallback函数消耗超过2300 gas,交易直接回滚了。后来我改用call模式,配合重入锁,才彻底解决。记住:transfer和send已经过时了,现在官方推荐用call。
2. 映射(mapping)——键值对数据库
映射是Solidity里最常用的数据结构。它就像一本字典——你给一个键,它返回一个值。
// 基本映射
mapping(address => uint256) public balances;
// 嵌套映射
mapping(address => mapping(uint256 => bool)) public userActions;
// 映射的值可以是结构体
mapping(uint256 => User) public users;
映射有几个特点你得记住:
- 不能遍历——映射没有length属性,也不能用for循环遍历所有键
- 所有键都存在——访问不存在的键,返回值类型的默认值(比如uint256返回0)
- 只能作为状态变量——不能在函数内部声明映射
你想想看,如果我想知道某个地址的余额,直接balances[addr]就行了,多方便。但如果你想列出所有有余额的地址……嗯,那就得自己维护一个数组来记录键了。
注意:映射的键可以是任何值类型,包括address、uint、bytes32等。但不能用结构体、数组或映射作为键。值类型则可以是任何类型,包括结构体和数组。
3. 结构体(struct)——把数据打包
结构体让你能把多个变量打包成一个自定义类型。我在项目中经常用它来表示用户、订单、投票等复杂数据。
// 定义结构体
struct User {
string name;
uint256 age;
address wallet;
bool isActive;
}
// 使用结构体
User public defaultUser = User("Alice", 25, msg.sender, true);
// 在映射中使用
mapping(address => User) public userRegistry;
// 在数组中使用
User[] public allUsers;
结构体的声明方式有两种:
// 方式一:按顺序赋值
User memory user1 = User("Bob", 30, addr, true);
// 方式二:命名参数(推荐,可读性更好)
User memory user2 = User({
name: "Charlie",
age: 28,
wallet: addr2,
isActive: false
});
我个人习惯用第二种方式。为什么?因为有一次我重构合约,调整了结构体字段顺序,结果所有按顺序赋值的代码全崩了……从那以后,我再也不敢偷懒了。
小技巧:结构体里的数组和映射不能作为成员。如果你需要存储动态数据,可以考虑用mapping嵌套结构体,或者把数组放到结构体外单独管理。
4. 数组(array)——有序数据集合
数组分两种:固定长度和动态长度。固定长度数组在编译时确定大小,动态数组可以push和pop。
// 固定长度数组
uint256[5] public fixedArray;
// 动态数组
uint256[] public dynamicArray;
// 二维数组
uint256[][] public matrix;
动态数组的常用操作:
// 添加元素
dynamicArray.push(100);
// 删除最后一个元素
dynamicArray.pop();
// 获取长度
uint256 len = dynamicArray.length;
// 删除指定位置(不保持顺序)
delete dynamicArray[index];
// 删除指定位置(保持顺序,把最后一个元素移过来)
dynamicArray[index] = dynamicArray[dynamicArray.length - 1];
dynamicArray.pop();
这里有个坑:delete只是把元素重置为默认值,数组长度不变。如果你真的想移除元素,要么用pop,要么用上面那个「交换+pop」的技巧。
我记得有一次写投票合约,用delete删除了候选人,结果遍历时发现数组长度没变,空元素导致逻辑出错……嗯,后来我改用mapping+数组的方式,才彻底解决这个问题。
性能建议:数组遍历在链上非常昂贵。如果数组长度可能超过100,建议用mapping代替。我一般只在需要有序遍历或长度确定的情况下才用数组。
5. 事件(event)——链上日志系统
事件是Solidity里最优雅的设计之一。它让你能在区块链上记录日志,而且比存储变量便宜得多。
// 定义事件
event Transfer(address indexed from, address indexed to, uint256 value);
event UserCreated(uint256 indexed userId, string name, uint256 timestamp);
// 触发事件
function transfer(address to, uint256 amount) public {
// ... 转账逻辑
emit Transfer(msg.sender, to, amount);
}
事件的关键特性:
- indexed参数——最多3个,可以被前端过滤搜索
- 不存储到状态——只记录在交易收据中,不占用合约存储空间
- 前端监听——DApp可以通过Web3.js或ethers.js监听事件
实战经验:我建议每个重要的状态变更都触发事件。为什么?因为事件是链上唯一可靠的「操作记录」。我曾经调试一个DeFi合约,全靠事件日志才追踪到一笔异常转账的来源。没有事件,你就像在黑暗中摸索。
事件和存储变量的选择:
| 特性 | 事件 | 存储变量 |
|---|---|---|
| 成本 | 低(约2000 gas/次) | 高(20000 gas+) |
| 可查询性 | 只能通过日志过滤 | 直接读取 |
| 持久性 | 永久保存在链上 | 永久保存在合约存储中 |
| 合约内访问 | 不能 | 可以 |
你想想看,如果你只需要记录「谁在什么时候做了什么」,用事件就够了。但如果你需要在合约逻辑中读取这些数据,那就得用存储变量。
综合示例:一个简单的用户管理系统
最后,我把今天讲的内容串起来,写一个完整的合约示例:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
contract UserManager {
// 结构体
struct User {
string name;
uint256 age;
bool isActive;
}
// 状态变量
address public owner;
mapping(address => User) public users;
address[] public userList;
// 事件
event UserRegistered(address indexed userAddress, string name, uint256 timestamp);
event UserDeactivated(address indexed userAddress);
constructor() {
owner = msg.sender;
}
// 注册用户
function registerUser(string memory _name, uint256 _age) public {
require(bytes(_name).length > 0, "Name cannot be empty");
require(users[msg.sender].isActive == false, "Already registered");
users[msg.sender] = User(_name, _age, true);
userList.push(msg.sender);
emit UserRegistered(msg.sender, _name, block.timestamp);
}
// 获取用户数量
function getUserCount() public view returns (uint256) {
return userList.length;
}
// 批量获取用户地址(分页)
function getUsers(uint256 _offset, uint256 _limit) public view returns (address[] memory) {
uint256 total = userList.length;
uint256 end = _offset + _limit;
if (end > total) end = total;
address[] memory result = new address[](end - _offset);
for (uint256 i = _offset; i < end; i++) {
result[i - _offset] = userList[i];
}
return result;
}
}
这个例子涵盖了今天讲的所有知识点。你可以看到:
- 用
address记录合约拥有者和用户地址 - 用
mapping存储用户信息,实现O(1)查询 - 用
struct打包用户数据 - 用
array维护用户列表,支持遍历 - 用
event记录注册和停用操作
好了,这一章的内容就到这。下一章我们会深入函数修饰符和错误处理,到时候你会看到这些基础类型如何在实际场景中发挥作用。
记住:写合约就像搭积木,地址、映射、结构体、数组、事件就是你的基础积木块。把它们玩熟了,后面的路就好走了。