Solidity 基础:contract、state 和 function

1. 概览

Solidity 是在 Ethereum 及其他 EVM 兼容链上编写智能合约的主要语言。本文帮助你掌握三个最基础的构建块:contract、state variable 和 function —— 通过一个简单的 Wallet 合约来讲解。读完后,你将能够编写具有状态存储的合约、使用 modifier 控制访问权限,并用 Hardhat 编写第一个测试。

2. 什么是 Contract

在 Solidity 中,contract 类似于 Java 中的 class —— 它定义了将被部署到区块链上成为独立地址的 state(数据)和 behavior(函数)。

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

contract Wallet {
    // state variables 和 function 将在这里
}
  • // SPDX-License-Identifier 这一行声明许可证,编译器在缺少它时会发出警告。
  • pragma solidity ^0.8.24 将编译器版本锁定为 0.8.24 及以上(但低于 0.9.0)。
  • 每个合约部署后都会获得一个独立的地址(address),就像一个在链上永久存活的“对象”。

注意: 生产环境中不要使用过于宽松的 pragma,如 >=0.8.0 <0.9.0 —— 新编译器可能会改变默认行为(例如溢出检查)。

3. State variable —— 链上存储的数据

State variable 是直接写入区块链存储的变量,在函数调用之间持久存在,写入时消耗 gas。

contract Wallet {
    address public owner;
    uint256 public balance;
    mapping(address => uint256) public allowance;

    constructor() {
        owner = msg.sender;
    }
}
  • address public owner: 存储合约所有者的地址。关键字 public 会自动生成一个免费的 getter 函数 owner()。
  • uint256 public balance: 余额,256 位无符号整数类型 —— 这是 Solidity 中最常用的数字类型。
  • mapping(address => uint256): 类似于 HashMap<Address, BigInteger>,但无法遍历 key,只能按 key 查询。
  • constructor() 在部署时恰好执行一次,msg.sender 是发起部署交易的地址。

3.1. 区分 storage、memory 和 calldata

function updateAllowance(address spender, uint256 amount) external {
    // amount 位于 calldata(只读,gas 便宜)
    allowance[spender] = amount; // 写入 storage
}
  • storage: 链上的永久数据(state variable)。
  • memory: 函数执行期间的临时数据,函数结束后被清除。
  • calldata: 类似 memory 但只读,用于 external 函数的参数 —— 比 memory 更节省 gas。

注意: 对于 array、struct、string 类型的参数忘记声明 memory/calldata 会导致编译器错误 —— 这不同于 Java,Java 中你无需关心内存区域。

4. 函数和可见性

Solidity 中的函数必须明确声明可见性,没有像 Java 那样的默认“包私有”。

contract Wallet {
    address public owner;
    uint256 public balance;

    constructor() {
        owner = msg.sender;
    }

    function deposit() external payable {
        balance += msg.value;
    }

    function withdraw(uint256 amount) external {
        require(msg.sender == owner, "Wallet: not owner");
        require(amount <= balance, "Wallet: insufficient balance");

        balance -= amount;
        (bool success, ) = owner.call{value: amount}("");
        require(success, "Wallet: transfer failed");
    }

    function getBalance() external view returns (uint256) {
        return balance;
    }
}
  • external: 只能从合约外部调用(例如 MetaMask 钱包、其他合约)—— 当参数是大型 array/string 时比 public 更节省 gas。
  • payable: 允许函数接收交易中附带的 ETH;msg.value 是随交易发送的 wei 数量。
  • view: 承诺不修改 state,离线调用时(通过 eth_call)不消耗 gas。
  • require(condition, "message"): 检查条件,如果为假则回滚整个交易并显示消息 —— 类似于抛出 IllegalArgumentException,但会回滚 state 和已使用的 gas(除了检查本身消耗的 gas)。
  • owner.call{value: amount}(""): 推荐的 ETH 发送方式,而不是 transfer()/send(),因为它不受 2300 gas 的硬性限制。

四个可见性级别:

可见性 从合约内部调用 从子合约调用 从外部调用
private ✅ ❌ ❌
internal ✅ ✅ ❌
public ✅ ✅ ✅
external ❌(需使用 this.foo()) ❌ ✅

5. Modifier —— 复用检查条件

在多个函数中重复 require(msg.sender == owner, ...) 表明应该将其提取为 modifier。

contract Wallet {
    address public owner;
    uint256 public balance;

    modifier onlyOwner() {
        require(msg.sender == owner, "Wallet: not owner");
        _;
    }

    constructor() {
        owner = msg.sender;
    }

    function withdraw(uint256 amount) external onlyOwner {
        require(amount <= balance, "Wallet: insufficient balance");
        balance -= amount;
        payable(owner).transfer(amount);
    }
}
  • modifier onlyOwner() 定义一个检查块;_; 是原始函数体将被插入的位置。
  • 在 withdraw 声明后附加 onlyOwner 使代码读起来像“只有钱包所有者才能提取资金” —— 比在每个函数中重复 require 更清晰。
  • Modifier 可以接收参数,例如 modifier minAmount(uint256 amount) { require(amount > 0, "..."); _; }。

6. Event —— 为链下记录日志

合约无法直接向前端返回历史数据;event 用于记录日志,dApp 可以监听这些日志。

contract Wallet {
    event Deposited(address indexed from, uint256 amount);
    event Withdrawn(address indexed to, uint256 amount);

    uint256 public balance;

    function deposit() external payable {
        balance += msg.value;
        emit Deposited(msg.sender, msg.value);
    }
}
  • event Deposited(...) 声明日志结构,最多 3 个参数可以用 indexed 修饰以便过滤。
  • emit Deposited(...) 将日志写入交易收据,gas 成本远低于保存到 storage。
  • 前端使用 ethers.js 等库监听 contract.on("Deposited", ...) 来实时更新 UI。

7. 学习 Solidity 时的常见错误

  • 混淆 transfer/send/call 发送 ETH: transfer 限制为 2300 gas,容易在接收合约具有复杂逻辑(代理、多签)时失败。使用 call 并配合 require 检查结果,如第 4 部分的示例。
  • 忘记 require 验证输入: 缺少验证会导致合约被利用(例如负数提取、溢出 —— 虽然从 Solidity 0.8 开始溢出时会自动回滚,但业务逻辑仍需单独验证)。
  • 在错误的场景下比较 external 和 public: 对于只从外部调用的函数使用 public 会在参数是数组/字符串时浪费更多 gas,因为 Solidity 必须将数据复制到 memory 而不是直接从 calldata 读取。
  • 在可能的情况下不声明 view/pure: 导致区块浏览器、钱包等工具无法识别只读函数,用户误认为需要支付 gas。

何时不应该使 function public: 如果函数永远不会从其他合约或继承中调用,使用 external 来节省 gas 并限制攻击面。

8. 使用 Hardhat 编写测试

为 Hardhat 项目添加依赖(Node.js):

npm install --save-dev hardhat @nomicfoundation/hardhat-toolbox

测试 withdraw 行为,只允许所有者:

const { expect } = require("chai");
const { ethers } = require("hardhat");

describe("Wallet", function () {
  it("只有所有者才能提取资金", async function () {
    const [owner, stranger] = await ethers.getSigners();
    const Wallet = await ethers.getContractFactory("Wallet");
    const wallet = await Wallet.deploy();

    await wallet.connect(owner).deposit({ value: ethers.parseEther("1") });

    await expect(
      wallet.connect(stranger).withdraw(ethers.parseEther("1"))
    ).to.be.revertedWith("Wallet: not owner");

    await expect(
      wallet.connect(owner).withdraw(ethers.parseEther("1"))
    ).to.changeEtherBalance(owner, ethers.parseEther("1"));
  });
});
  • ethers.getContractFactory("Wallet") 编译并加载合约的 artifact。
  • wallet.connect(stranger) 模拟来自非所有者地址的交易。
  • revertedWith("...") 断言交易使用来自 require 的正确消息回滚。
  • changeEtherBalance 检查所有者的 ETH 余额在提取后恰好增加 1 ETH —— 这是一种常见的测试方式,而不仅仅检查返回值。

运行测试:

npx hardhat test
Wallet
  ✔ 只有所有者才能提取资金 (120ms)

1 passing (120ms)

9. 总结

你已经了解了 Solidity 的四个基础概念:contract 作为部署单元、state variable 使用三个内存区域(storage/memory/calldata)存储链上数据、function 配合 visibility 和 modifier 控制访问权限,以及 event 与链下通信。这些概念是你进阶学习 inheritance、interface 或 ERC-20/ERC-721 标准等高级主题的基础 —— 所有这些都建立在这四个基本构建块之上。

solidityblockchainsmart-contractethereumweb3