区块链从小白到精通(七):Solidity 语法精讲——数据类型、函数、修饰器与事件

📚 本文是「区块链从小白到精通」系列第 7 篇 | 上一篇:《区块链从小白到精通(六):智能合约入门------10 分钟写出你的第一个 Solidity 合约》 | 下一篇:《区块链从小白到精通(八):从零实现一个标准 ERC-20 代币(完整可运行代码)》

前言

上一篇你已经在 Remix 里完成了"写-编-部-调"的完整闭环,但那个 Counter 合约实在太朴素:一个状态变量、两个函数,没有权限、没有事件、没有错误处理。

真实的合约------无论是代币、NFT 还是 DeFi 协议------用到的语法集合是相对固定的。本篇把日常开发 90% 场景会用到的 Solidity 语法一次讲透:数据类型与数据位置、mapping 与 struct、函数可见性、modifier、event、错误处理、继承与接口。每一条都配可运行代码,最后用一个综合的"众筹合约"把全部知识点串起来。

下一篇将直接用这些语法实战 ERC-20,所以本篇请务必跟着敲一遍。

值类型与引用类型

Solidity 的类型系统分两大阵营,区别在于"赋值时拷贝谁":

值类型(Value Types)------赋值即拷贝整个值:

类型 说明 示例
uint256 / int256 无符号/有符号整数,可用 uint8 到 uint256(8 的倍数步进) uint128 amount = 100;
bool 布尔 bool active = true;
address 20 字节的以太坊地址 address owner;
address payable 可接收转账的地址(多 .transfer()/.send() 成员) address payable recipient;
bytes32 固定长度字节序列(存哈希很常用) bytes32 root;
enum 枚举状态 enum Status { Pending, Done }

引用类型(Reference Types) ------赋值时拷贝的是"引用"(指向数据的位置),所以必须显式指定数据位置(下一节重点):

类型 说明
string 动长字符串
bytes 动长字节数组
数组 T[] 定长 T[N] 或动长 T[]
struct 自定义结构体
mapping 键值对哈希表(见下文,行为特殊)

新手最常出错的点:uint 不写位数默认是 uint256;address 和 address payable 不能隐式互转;外部传入的地址先用 require(to != address(0)) 校验零地址------转错零地址等于资产销毁。

数据位置:storage、memory、calldata

这是 Solidity 独有的概念,也是新手第一个"懵点"。引用类型声明时必须指定它住在哪里:

位置 类比 生命周期 Gas 成本
storage 仓库:链上持久化 合约存在多久它就多久 极贵(写一个新槽约 2 万 Gas)
memory 草稿纸:函数执行期间的临时副本 函数结束即销毁 便宜
calldata 快递单:只读的函数入参 函数结束即销毁 最便宜(不可修改)

用代码感受三者的规则:

solidity 复制代码
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

contract DataLocation {
    uint256[] public warehouse;               // 状态变量,必然是 storage(上链)

    // calldata:入参只读,最省 Gas,但函数里不能改它
    function readFirst(uint256[] calldata nums) external pure returns (uint256) {
        return nums[0];                       // ✅ 读
        // nums[0] = 1;                       // ❌ calldata 不可写
    }

    // memory:要修改入参的副本时用 memory
    function double(uint256[] memory nums) external pure returns (uint256) {
        nums[0] = nums[0] * 2;                // ✅ 改的是副本,不影响调用方
        return nums[0];
    }

    function addToWarehouse(uint256[] calldata nums) external {
        for (uint256 i = 0; i < nums.length; i++) {
            warehouse.push(nums[i]);          // calldata → storage:真正写入链上
        }
    }
}

三条实用规则记住即可:

  1. 状态变量永远是 storage(它就是"链上的数据"本身);
  2. 外部函数的数组/结构体参数默认 calldata ,明确标注 calldata 比 memory 更省 Gas;
  3. 要在函数里修改数据就声明 memory(会创建副本);要永久修改链上数据就直接操作状态变量。

mapping 与 struct:合约里的"数据库表"

绝大多数合约的账本就是一张张 mapping。结合 struct,可以搭出丰富的链上数据结构:

solidity 复制代码
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

contract UserRegistry {
    struct User {
        string name;        // 昵称
        uint32  createdAt;  // 注册时间(uint32 够用到 2106 年,省 Gas)
        bool    active;
    }

    mapping(address => User) private _users;        // 地址 → 用户档案
    address[] private _userList;                     // 注册名单(mapping 无法遍历,配一个数组)

    function register(string calldata _name) external {
        require(bytes(_name).length > 0, "empty name");
        require(_users[msg.sender].createdAt == 0, "already registered");

        _users[msg.sender] = User({
            name: _name,
            createdAt: uint32(block.timestamp),
            active: true
        });
        _userList.push(msg.sender);
    }

    function getUser(address _who) external view returns (User memory) {
        return _users[_who];
    }

    function userCount() external view returns (uint256) {
        return _userList.length;
    }
}

关于 mapping 必须知道的三件事:

  1. 它像一张哈希表 :key => value,查询是 O(1);但无法遍历、无法知道 size、无法删除(可以把值清零,但键还在)。所以需要"列表"时,像上面那样配一个数组;
  2. 不存在的键返回默认值 :_users[随机地址] 不会报错,返回零值结构体。这就是为什么判断"是否注册"要查某个具体字段(如 createdAt == 0);
  3. mapping 本身必须在 storage (不能作为函数参数/返回值在 memory 里出现),public mapping 自动生成 getter(入参是 key)。

函数:可见性与状态可变性

Solidity 函数签名里有两个"开关组",拼在一起构成完整语义:

可见性(谁能调用):

关键字 合约内部 子合约 外部
private ✅ ❌ ❌
internal ✅ ✅ ❌
public ✅ ✅ ✅
external ❌(需 this.xxx()) ❌ ✅

external 只能被外部调用,参数直接走 calldata,省 Gas------专职对外的接口函数用它;public 内外通吃但稍贵。注意:可见性控制的是代码访问,不是安全边界 ------链上一切数据与调用都是公开的,private 变量只是别的合约读不到,任何人都能在区块浏览器/节点上看到它的值。秘密(哪怕是一张牌)绝不要存进合约。

状态可变性(这个函数动不动账本):

关键字 语义 是否花 Gas
无标记 要写状态 ✅(发交易)
view 只读状态,不改 ❌(外部直接调用免费)
pure 连状态都不读(纯计算) ❌(外部直接调用免费)
payable 允许随调用附带 ETH ✅
solidity 复制代码
uint256 public total;

function add(uint256 v) external { total += v; }     // 写状态:发交易
function get() external view returns (uint256) { return total; }  // 只读:免费
function square(uint256 v) external pure returns (uint256) { return v * v; } // 纯计算
function deposit() external payable { }              // 能收 ETH

modifier:给函数装"门禁"

modifier 是包装函数的"前置/后置逻辑",最典型的用途是权限控制:

solidity 复制代码
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

contract OwnableExample {
    address public owner;

    constructor() {
        owner = msg.sender;              // 部署者即 owner
    }

    modifier onlyOwner() {
        require(msg.sender == owner, "not owner");   // 门禁检查
        _;                                            // 放行:执行被修饰的函数体
    }

    function setOwner(address _new) external onlyOwner {
        owner = _new;
    }

    function anyoneCanCall() external {}          // 无门禁
    function onlyBossCanCall() external onlyOwner {}  // 有门禁
}

_; 是修饰器的"占位符":函数体(以及后续其他 modifier)会插在它所在的位置。多个 modifier 按声明顺序依次包裹,形成洋葱结构。

OpenZeppelin 的 Ownable 合约把这套逻辑打磨得更完善(含所有权转移事件、零地址保护),生产环境直接继承它即可。

event:链上的"公开广播"

状态变量存的是"当前值",而 event 存的是"发生过什么"。事件写入交易日志(Log),合约自己读不到,但链下的任何人/程序都能高效检索------这是合约与外界沟通的官方渠道:

solidity 复制代码
event Deposited(address indexed who, uint256 amount);   // indexed 参数可被链下按值过滤

function deposit() external payable {
    require(msg.value > 0, "zero value");
    emit Deposited(msg.sender, msg.value);
}

三个使用要点:

  1. 每次状态变更都应发事件:前端靠事件刷新 UI,数据服务靠事件建索引(The Graph 这类索引器全靠解析日志),链下对账也靠它;
  2. indexed 参数会被放进特殊的 topics 存储,最多 3 个,可被高效过滤("查某人所有的存入记录"就靠它);
  3. 事件比 storage 便宜几个数量级:只需要"展示/存证"而不需要被合约代码读回的数据(如历史操作记录),用事件别用 storage。

错误处理:require、revert 与自定义 error

Solidity 有三种"中断并回滚"的方式,回滚 = 本次调用的所有状态修改作废(但 Gas 已消耗):

solidity 复制代码
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

contract ErrorHandling {
    error InsufficientBalance(uint256 requested, uint256 available);  // 自定义 error
    error NotOwner();

    address public owner = msg.sender;
    mapping(address => uint256) public balances;

    modifier onlyOwner() {
        if (msg.sender != owner) revert NotOwner();    // 自定义 error + revert
        _;
    }

    function withdraw(uint256 amount) external {
        // ① require:校验输入/前置条件(传统写法,带字符串)
        require(amount > 0, "amount must be positive");

        // ② 自定义 error:省 Gas(不存长字符串)+ 可携带结构化参数
        uint256 available = balances[msg.sender];
        if (amount > available) {
            revert InsufficientBalance(amount, available);
        }

        balances[msg.sender] = 0;
    }

    function adminReset() external onlyOwner {
        // ③ assert:断言"绝不该发生"的内部不变量,触发即说明代码有严重 bug
        assert(address(this).balance >= 0);
    }
}

选型口诀:输入与前置条件用 require;带参数描述的业务失败用自定义 error;不可能发生的内部错误用 assert。 0.8 之后的社区主流是自定义 error------更省 Gas、类型安全、错误信息结构化,本系列后续代码也统一用这种风格。

继承与接口:复用的正确姿势

Solidity 通过 is 实现继承(支持多继承,按"从基类到派生"的顺序书写),通过 interface 定义纯接口(函数体留空,合约实现它们):

solidity 复制代码
interface IPriceOracle {
    function getPrice() external view returns (uint256);
}

contract MyDapp is OwnableExample, IPriceOracle {
    IPriceOracle public immutable oracle;      // 引用外部合约

    constructor(address _oracle) {
        oracle = IPriceOracle(_oracle);        // 传入外部合约地址,即可调用其接口
    }

    function getPrice() external pure override returns (uint256) {
        return 1;
    }
}

接口正是上一篇讲过的"标准"的实现方式:下一篇的 IERC20 就是一个 interface,谁实现了它谁就是标准代币。override、virtual(可被重写标记)等细节,用到时编译器会明确提示,不必死记。

综合实战:把所有语法装进一个众筹合约

下面这个 CrowdFunding 合约覆盖了本篇全部知识点,建议逐行读完再自己敲一遍:

solidity 复制代码
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

contract CrowdFunding {
    // 自定义 error(省 Gas、结构化)
    error AlreadyEnded();
    error GoalReached();
    error NotContributor();
    error NothingToRefund();

    enum State { Ongoing, Refunding }          // 枚举状态

    struct Contribution {                       // 结构体 + 数据位置意识
        uint256 amount;
        uint64  at;
    }

    address public immutable owner;             // 部署后不可再改,省读费
    uint256 public immutable deadline;          // 截止时间戳
    uint256 public goal;
    uint256 public raised;

    mapping(address => Contribution[]) private _contributions;  // 每人可多次出资
    address[] private _contributors;

    event Contributed(address indexed who, uint256 amount, uint256 total);   // 事件
    event Refunded(address indexed who, uint256 amount);

    modifier onlyOwner() {                      // 门禁
        if (msg.sender != owner) revert NotOwner();
        _;
    }

    constructor(uint256 _durationSeconds, uint256 _goal) {
        owner = msg.sender;
        deadline = block.timestamp + _durationSeconds;
        goal = _goal;
    }

    function contribute() external payable {    // 收 ETH 必须 payable
        if (block.timestamp >= deadline) revert AlreadyEnded();
        if (msg.value == 0) revert NothingToRefund();

        if (_contributions[msg.sender].length == 0) {
            _contributors.push(msg.sender);     // 记录首个出资
        }
        _contributions[msg.sender].push(Contribution(msg.value, uint64(block.timestamp)));
        raised += msg.value;

        emit Contributed(msg.sender, msg.value, raised);
    }

    function myContributionCount() external view returns (uint256) {   // view
        return _contributions[msg.sender].length;
    }

    function refund() external {                // 未达标可退
        if (block.timestamp < deadline) revert AlreadyEnded();
        if (raised >= goal) revert GoalReached();

        Contribution[] memory mine = _contributions[msg.sender];  // memory:读出副本
        if (mine.length == 0) revert NotContributor();

        uint256 totalToRefund;
        for (uint256 i = 0; i < mine.length; i++) {
            totalToRefund += mine[i].amount;
        }
        require(totalToRefund > 0, "nothing to refund");

        delete _contributions[msg.sender];      // Effects:先改状态(防重入,第十篇细讲)

        (bool ok, ) = msg.sender.call{value: totalToRefund}(""); // Interactions:后转账
        require(ok, "refund failed");
        emit Refunded(msg.sender, totalToRefund);
    }

    function contributorCount() external view returns (uint256) {
        return _contributors.length;
    }
}

这个合约里你能找到本篇每一个概念:struct + mapping 的数据模型、calldata/memory 的位置选择、view/payable、modifier 门禁、event 广播、自定义 error、enum 状态。也埋了一个悬念:refund 里"先清账、后转账"的顺序注释------为什么必须这样写,第十篇"重入攻击"会给你一个惊心动魄的答案。

写在最后

本篇知识地图:

  1. 类型:值类型拷贝值,引用类型拷贝引用;秘密不可上链;
  2. 数据位置:storage 是仓库、memory 是草稿纸、calldata 是快递单,位置决定生命周期与 Gas;
  3. mapping:O(1) 账本,不能遍历,不存在的键返回零值;
  4. 函数:可见性 × 状态可变性两组开关;external 接口最省;
  5. modifier / event / 自定义 error:门禁、广播、省 Gas 报错三板斧;
  6. 继承与接口 :is 复用代码,interface 定义标准。

语法弹药已装满,下一篇进入系列最硬核的实战:《区块链从小白到精通(八):从零实现一个标准 ERC-20 代币(完整可运行代码)》------不用任何第三方库,手写一个钱包和交易所都认的标准代币,并部署到测试网真实转账。

实验室见。

相关推荐
Pniubi1 小时前
力扣24. 两两交换链表中的节点(递归)
算法·leetcode·链表
OxYGC1 小时前
[Web3] 智能合约 03: 函数/事件/继承/接口/异常处理
web3·区块链·智能合约
꧁细听勿语情꧂2 小时前
直接选择排序,堆排序,冒泡排序,递归快速排序、hoare版本、挖坑法、lomuto前后指针,非递归快速排序
c语言·开发语言·数据结构·算法
俊俊几年的2 小时前
动态规划(Dynamic Programming)详解:从入门到 LeetCode Hot 100
linux·c++·算法·leetcode·嵌入式·动态规划·c
小雉系统11 小时前
BcoreOS安全通信架构:FsSession 抗算法后门安全设计
算法·安全·架构
唐墨12315 小时前
linux kernel 比较数据大小的宏定义
linux·算法
拂拉氏16 小时前
【知识讲解-题目讲解】 统计满足K约束的子字符串数量2 (滑动窗口+前缀和+二分查找+分析)
算法·前缀和·二分查找·滑动窗口
通信小呆呆20 小时前
电子战抗干扰技术:当“不被干扰”成为一种系统
算法·通信·mimo·isac