第四章:Solidity进阶(上)——映射、数组、结构体、枚举与数据位置

好,咱们继续往前走。上一章我们把Solidity基础语法过了一遍,现在该聊聊真正让智能合约“活起来”的数据结构了。

我个人觉得,映射、数组、结构体和枚举这四样东西,是写合约时最常用的“积木块”。你想想看,一个合约如果没有数据组织能力,那跟一个只会算加减乘除的计算器有什么区别?

嗯,咱们一个一个来。

4.1 映射(mapping)——键值对的“字典”

映射说白了就是一个键值对数据库。在Solidity里,它的写法是 mapping(KeyType => ValueType)

我在项目中遇到过最典型的场景:记录每个地址的余额。比如一个代币合约,你肯定需要知道谁有多少钱对吧?

mapping(address => uint256) public balances;

这里有几个坑,我得提醒你:

  • 键类型有限制:键可以是基本类型(address、uint、bytes32等),但不能是结构体或映射本身。值类型则几乎什么都可以。
  • 不能遍历:映射没有长度概念,你不能像数组那样用for循环遍历所有键值对。这是设计上的取舍——为了gas效率。
  • 默认值:访问不存在的键,不会报错,而是返回该值类型的默认值(比如uint256返回0)。
⚠️ 我曾经犯过的错:写了一个众筹合约,用mapping记录用户捐款金额。结果发现有人没捐款也能查到“余额为0”——这本身没问题,但业务逻辑里我把它当成了“已捐款”的判断条件,导致空投时多发了代币。记住:mapping里“不存在”和“值为0”是两码事。

4.2 数组(array)——有序的数据集合

数组分两种:固定长度和动态长度。固定长度数组在编译时就确定了大小,动态数组则可以push和pop。

uint256[5] public fixedArray;      // 固定长度,5个元素
uint256[] public dynamicArray;     // 动态长度,可以增长

我个人习惯:能用固定长度就用固定长度,因为gas更省。但现实业务中,很多时候你根本不知道会有多少数据进来,比如一个投票合约的候选人列表。

数组操作要注意:

  • push() 在末尾添加元素,返回新长度
  • pop() 删除最后一个元素,长度减1
  • delete array[index] 只是把该位置重置为默认值,不会缩短数组长度
💡 小技巧:如果你需要频繁删除中间元素,别用数组。用mapping加一个索引列表更划算。否则每次删除都要移动后面的元素,gas会爆炸。

4.3 结构体(struct)——自定义数据类型

结构体让你能把多个相关数据打包成一个整体。比如一个用户信息:

struct User {
    string name;
    uint256 age;
    address wallet;
    bool isActive;
}

然后你可以这样用:

mapping(address => User) public users;

function addUser(string memory _name, uint256 _age) public {
    users[msg.sender] = User(_name, _age, msg.sender, true);
}

嗯,这里要注意:结构体里的字段顺序会影响存储布局。Solidity会按声明顺序紧凑排列,但每个字段仍然遵循32字节对齐规则。所以把相同类型的字段放在一起,能省点gas。

4.4 枚举(enum)——有限状态集合

枚举就是给一组常量起个名字。比如订单状态:

enum OrderStatus { Created, Paid, Shipped, Completed, Cancelled }

OrderStatus public status;

function pay() public {
    require(status == OrderStatus.Created, "订单状态错误");
    status = OrderStatus.Paid;
}

枚举在Solidity里本质上是uint8,从0开始编号。你不能直接传字符串进来,得传对应的数字。不过前端可以做个映射,让用户选“已支付”而不是“1”。

🔑 核心要点:枚举让代码可读性暴增。你想想看,如果状态用0、1、2、3、4表示,三个月后你自己都看不懂哪个是哪个。

4.5 数据位置:storage / memory / calldata

这是Solidity里最容易让人懵圈的地方。说白了,就是数据存在哪、怎么传。

位置 特点 生命周期 修改影响
storage 永久存储,写入区块链 合约生命周期 修改会永久保存
memory 临时存储,函数执行期间存在 函数调用期间 修改不影响链上数据
calldata 只读,存储函数参数 函数调用期间 不可修改

我刚开始学的时候,总搞不清什么时候用memory。后来总结了一个规律:

  • 状态变量(合约里直接声明的)默认就是storage,不用写
  • 函数参数如果是复杂类型(数组、结构体、字符串),用memory或calldata
  • 函数内部临时变量如果是复杂类型,用memory

举个例子:

function processUser(User memory _user) public {
    // _user 是 memory 副本,修改它不影响链上数据
    // 如果想修改链上数据,得用 storage 引用
    User storage storedUser = users[msg.sender];
    storedUser.age = _user.age;  // 这会真正修改链上数据
}
⚠️ 我曾经踩过的坑:写了一个批量转账函数,参数是address[] calldata。我一开始用了memory,结果发现每次调用都要复制整个数组,gas高得离谱。换成calldata后,直接引用原始数据,省了一半gas。记住:calldata是只读的,但比memory便宜得多。

4.6 综合实战:一个简单的众筹合约

咱们把今天学的知识串起来,写一个迷你众筹合约:

contract Crowdfunding {
    enum CampaignState { Active, Successful, Failed }
    
    struct Campaign {
        address creator;
        string title;
        uint256 goal;
        uint256 raised;
        CampaignState state;
        mapping(address => uint256) contributions;
    }
    
    Campaign[] public campaigns;
    
    function createCampaign(string memory _title, uint256 _goal) public {
        Campaign storage newCampaign = campaigns.push();
        newCampaign.creator = msg.sender;
        newCampaign.title = _title;
        newCampaign.goal = _goal;
        newCampaign.raised = 0;
        newCampaign.state = CampaignState.Active;
    }
    
    function contribute(uint256 _campaignId) public payable {
        Campaign storage campaign = campaigns[_campaignId];
        require(campaign.state == CampaignState.Active, "众筹已结束");
        
        campaign.contributions[msg.sender] += msg.value;
        campaign.raised += msg.value;
        
        if (campaign.raised >= campaign.goal) {
            campaign.state = CampaignState.Successful;
        }
    }
}

你看,这里用到了:

  • 枚举管理状态
  • 结构体打包数据
  • 数组存储多个活动
  • 映射记录每个人的捐款
  • storage引用直接修改链上数据

嗯,这就是Solidity进阶的第一部分。说白了,这些数据结构就是你的工具箱。用得好了,合约逻辑清晰、gas省、bug少。用不好……嗯,我当年重构过好几次代码,都是因为一开始数据结构没设计对。

下一章咱们聊继承、接口和库,那才是真正让代码复用的利器。先消化这些,别贪多。