4、Solidity智能合约基础(上):Solidity版本与编译器、合约结构、状态变量与局部变量、基本数据类型(uint、address、string、bool)、映射(mapping)与数组(array)、构造函数与修饰符(modifier)、事件(event)的声明与触发
好,咱们正式开始写智能合约了。
这一章是 Solidity 的入门基础,说白了就是盖房子的砖头和水泥。你把这些基础搞扎实了,后面写复杂合约才不会翻车。我个人习惯是,每学一个新语言,先搞清楚它的「骨架」长什么样——也就是合约结构。
4.1 Solidity 版本与编译器
Solidity 这语言迭代挺快的。我记得我刚入行那会儿还在用 0.4.x,现在已经到 0.8.x 了。版本号不是随便写的,它直接决定了你能用什么语法、有什么安全机制。
每个 Solidity 源文件的第一行,几乎都是版本声明:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.18;
这里有两个东西要注意:
- SPDX 许可证:这是开源规范。不加的话,有些编译器会报警告。我一般用 MIT,省事。
- pragma solidity:声明编译器版本。
^0.8.18表示兼容 0.8.18 及以上,但不到 0.9.0。为什么?因为大版本升级往往有破坏性变更,你不想合约突然编译不过吧?
4.2 合约结构
一个 Solidity 合约,其实就是一个类。你想想看,它有状态、有函数、有构造函数。基本骨架长这样:
contract MyContract {
// 状态变量
uint public myNumber;
// 构造函数
constructor() {
myNumber = 42;
}
// 函数
function setNumber(uint _newNumber) public {
myNumber = _newNumber;
}
}
结构很清晰对吧?从上到下依次是:状态变量声明 → 构造函数 → 自定义函数。我建议你也按这个顺序写,团队协作时别人一眼就能找到关键信息。
4.3 状态变量与局部变量
这两个概念特别重要,很多新手搞混。
- 状态变量:写在合约顶层,永久存储在区块链上。每次调用合约,它都在。
- 局部变量:写在函数内部,函数执行完就消失。不占链上存储,省 gas。
举个例子:
contract Demo {
uint public storedData; // 状态变量,存在链上
function doSomething(uint input) public {
uint temp = input + 1; // 局部变量,函数结束就没了
storedData = temp;
}
}
嗯,这里要注意:状态变量操作是要花 gas 的,而且很贵。局部变量几乎免费。所以能不用状态变量就别用,能缓存就缓存。我在项目中遇到过有人把循环计数器都写成状态变量,结果一笔交易花了 0.1 ETH 的 gas……老板脸都绿了。
4.4 基本数据类型
Solidity 的数据类型不多,但每个都有讲究。
| 类型 | 说明 | 示例 |
|---|---|---|
uint |
无符号整数,默认 uint256 | uint public count = 100; |
address |
以太坊地址,20字节 | address public owner = msg.sender; |
string |
字符串,动态长度 | string public name = "NFT"; |
bool |
布尔值,true/false | bool public isActive = true; |
这里我多说两句:
- uint:可以指定位数,比如 uint8、uint256。位数越小越省 gas。但别为了省 gas 用 uint8 存大数,会溢出。我吃过这个亏。
- address:0.8.x 版本后,address 分成了 address 和 address payable。后者才能接收 ETH。如果你要转币,记得用 payable。
- string:不能直接比较大小,也不能直接用 == 比较(除非用 keccak256 哈希)。操作字符串很贵,能不用就不用。
- bool:简单,但注意 Solidity 里没有「假值」的概念,if 条件里必须显式写布尔表达式。
4.5 映射(mapping)与数组(array)
这两个是 Solidity 里最常用的数据结构。说白了,一个用来查,一个用来存列表。
映射(mapping)
映射就像字典,键值对。声明方式:
mapping(address => uint) public balances;
这表示:给定一个地址,查到他有多少余额。映射的特点是:
- 不能遍历(没有 length 或 keys 方法)
- 所有键默认都有值(uint 默认 0,bool 默认 false)
- 非常省 gas,因为它是哈希表结构
我一般用映射来存用户数据、权限、余额等。但如果你需要遍历所有用户,那就得用数组。
数组(array)
数组分两种:固定长度和动态长度。
uint[10] public fixedArray; // 固定长度
uint[] public dynamicArray; // 动态长度
动态数组可以 push、pop、获取 length。但注意:遍历数组很贵,动辄几十万 gas。我建议:
- 能用映射就别用数组
- 如果必须用数组,加一个映射来记录索引位置,方便删除
4.6 构造函数与修饰符(modifier)
构造函数
构造函数在合约部署时执行一次,之后就不能再调用了。通常用来初始化 owner、设置初始参数等。
address public owner;
constructor() {
owner = msg.sender;
}
注意:0.8.x 之后,构造函数不再需要 function 关键字,直接写 constructor 就行。老版本里是 function ContractName(),现在别那么写了。
修饰符(modifier)
修饰符是 Solidity 的一大特色。它可以在函数执行前后插入逻辑,常用于权限控制。
modifier onlyOwner() {
require(msg.sender == owner, "Not owner");
_;
}
function withdraw() public onlyOwner {
// 只有 owner 能调用
}
那个 _; 是关键,它表示「继续执行原函数」。你可以把 modifier 想象成一个包装器。我习惯把常用的权限控制、重入锁、暂停开关都写成 modifier,代码干净很多。
_; 之后又写了 return,结果函数执行了两遍。记住:_; 之后不要再加 return,除非你真的知道自己在干嘛。
4.7 事件(event)的声明与触发
事件是 Solidity 和前端通信的桥梁。合约里发生什么事,前端可以通过事件监听知道。
声明事件:
event Transfer(address indexed from, address indexed to, uint256 value);
触发事件:
function transfer(address to, uint256 amount) public {
// ... 转账逻辑
emit Transfer(msg.sender, to, amount);
}
这里有几个要点:
- indexed:最多三个参数可以加 indexed,加了之后前端可以按这个参数过滤事件。比如监听某个地址的所有转账。
- emit:触发事件的关键字。老版本用
Transfer(...)直接写,现在必须加 emit。 - 事件不存链上:事件日志存在区块的日志里,但合约不能读取。它只用于外部监听。
我个人习惯是:所有状态变更操作都触发事件。这样前端可以实时更新 UI,也方便调试。你想想看,如果合约里改了数据但没发事件,前端怎么知道?只能轮询,那体验就差了。