📚 本文是「区块链从小白到精通」系列第 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:真正写入链上
}
}
}
三条实用规则记住即可:
- 状态变量永远是 storage(它就是"链上的数据"本身);
- 外部函数的数组/结构体参数默认 calldata ,明确标注
calldata比memory更省 Gas; - 要在函数里修改数据就声明 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 必须知道的三件事:
- 它像一张哈希表 :
key => value,查询是 O(1);但无法遍历、无法知道 size、无法删除(可以把值清零,但键还在)。所以需要"列表"时,像上面那样配一个数组; - 不存在的键返回默认值 :
_users[随机地址]不会报错,返回零值结构体。这就是为什么判断"是否注册"要查某个具体字段(如createdAt == 0); - mapping 本身必须在 storage (不能作为函数参数/返回值在 memory 里出现),
publicmapping 自动生成 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);
}
三个使用要点:
- 每次状态变更都应发事件:前端靠事件刷新 UI,数据服务靠事件建索引(The Graph 这类索引器全靠解析日志),链下对账也靠它;
indexed参数会被放进特殊的 topics 存储,最多 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 里"先清账、后转账"的顺序注释------为什么必须这样写,第十篇"重入攻击"会给你一个惊心动魄的答案。
写在最后
本篇知识地图:
- 类型:值类型拷贝值,引用类型拷贝引用;秘密不可上链;
- 数据位置:storage 是仓库、memory 是草稿纸、calldata 是快递单,位置决定生命周期与 Gas;
- mapping:O(1) 账本,不能遍历,不存在的键返回零值;
- 函数:可见性 × 状态可变性两组开关;external 接口最省;
- modifier / event / 自定义 error:门禁、广播、省 Gas 报错三板斧;
- 继承与接口 :
is复用代码,interface定义标准。
语法弹药已装满,下一篇进入系列最硬核的实战:《区块链从小白到精通(八):从零实现一个标准 ERC-20 代币(完整可运行代码)》------不用任何第三方库,手写一个钱包和交易所都认的标准代币,并部署到测试网真实转账。
实验室见。