第四讲:Solidity进阶——地址类型、映射、结构体、数组、事件

好,咱们继续往前走。上一章我们把Solidity基础语法过了一遍,现在该碰点真正干活的东西了。

这一章我带你深入四个核心概念:地址类型映射(mapping)结构体(struct)数组(array)事件(event)。说白了,这些就是你写DApp时每天都要打交道的工具。

我个人习惯把这一章称为「智能合约的骨架」——没有它们,你的合约就是一坨不能动的代码。

1. 地址类型(address)——区块链上的身份证

地址类型是Solidity里最特殊的类型之一。它代表一个以太坊账户,可以是外部账户(EOA),也可以是合约账户。

先看基本用法:

// 声明一个地址变量
address public owner;
address payable public receiver; // 可以接收ETH的地址

// 地址常量
address constant BURN_ADDRESS = 0x000000000000000000000000000000000000dEaD;

这里有个关键点:addressaddress payable的区别。后者多了transfersend方法,可以转ETH。我刚开始写合约时经常搞混,结果转账函数编译不过去……嗯,后来就养成习惯了:只要涉及收钱的地址,一律用address payable

核心方法:

  • .balance — 查询地址余额(单位wei)
  • .transfer(amount) — 转账(2300 gas,安全)
  • .send(amount) — 转账(返回bool,不抛异常)
  • .call{value: amount}("") — 底层调用(推荐方式)

避坑指南:我曾经在项目里用transfer给合约转账,结果对方合约的fallback函数消耗超过2300 gas,交易直接回滚了。后来我改用call模式,配合重入锁,才彻底解决。记住:transfersend已经过时了,现在官方推荐用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记录注册和停用操作

好了,这一章的内容就到这。下一章我们会深入函数修饰符和错误处理,到时候你会看到这些基础类型如何在实际场景中发挥作用。

记住:写合约就像搭积木,地址、映射、结构体、数组、事件就是你的基础积木块。把它们玩熟了,后面的路就好走了。