2、Solidity基础语法:合约结构、状态变量与局部变量、数据类型
好,咱们正式开始写代码了。这一章是Solidity的“地基”,我当年刚学的时候,觉得这些东西太简单,结果写第一个复杂合约就踩了坑。你想想看,如果连变量存哪儿、怎么存都搞不清楚,后面写再漂亮的逻辑也是白搭。
2.1 合约的基本结构
一个Solidity合约,说白了就是一个“智能合约类”。它长什么样?我给你拆开看:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
contract MyContract {
// 状态变量
uint public myNumber;
// 构造函数
constructor() {
myNumber = 42;
}
// 函数
function setNumber(uint _newNumber) public {
myNumber = _newNumber;
}
// 事件
event NumberChanged(uint newNumber);
}
结构其实很清晰:
- 许可证声明:第一行,告诉别人这个代码的开源协议。我习惯用MIT,省事。
- 版本指令:
pragma solidity ^0.8.0;意思是兼容0.8.x版本。别小看这行,版本不对编译直接报错。 - 合约主体:
contract 合约名 { ... },所有逻辑都包在里面。 - 状态变量、函数、事件、修饰器:这些是合约的“器官”,后面会一个个讲。
我的习惯:每个合约文件只放一个合约。虽然Solidity允许一个文件写多个合约,但维护起来太痛苦了。
2.2 状态变量 vs 局部变量
这是新手最容易混淆的地方。我直接说人话:
- 状态变量:写在合约里、函数外。它会被永久存储在区块链上。你想想看,每次交易都要存它,所以很贵。
- 局部变量:写在函数里。函数执行完就消失,不占链上空间,免费。
contract VariableDemo {
uint public stateVar; // 状态变量,存在链上
function demo() public {
uint localVar = 100; // 局部变量,只在函数内有效
stateVar = localVar; // 把局部变量的值赋给状态变量
}
}
我曾经踩过的坑:有一次我在一个循环里反复修改状态变量,结果gas费高得离谱。后来改成先用局部变量计算,最后一次性赋值,gas费直接降了80%。记住:能不用状态变量就不用。
2.3 基础数据类型
Solidity的数据类型,我按使用频率给你排个序:
| 类型 | 说明 | 默认值 | 我的使用频率 |
|---|---|---|---|
uint |
无符号整数(正数) | 0 | ★★★★★ |
int |
有符号整数(可正可负) | 0 | ★★☆☆☆ |
bool |
布尔值(true/false) | false | ★★★★☆ |
address |
以太坊地址(20字节) | 0x0 | ★★★★★ |
string |
字符串 | "" | ★★★☆☆ |
bytes |
字节数组 | 0x | ★★★☆☆ |
2.3.1 uint 和 int
uint 是 uint256 的简写,表示256位的无符号整数。范围从0到2^256-1,这个数有多大?比宇宙中的原子数还多,放心用。
uint public myUint = 42;
int public myInt = -10; // int可以负数
// 注意:uint不能赋负值,否则编译报错
// uint public wrong = -1; // ❌ 错误
避坑指南:Solidity 0.8.0之后默认带溢出检查。如果你用0.7.x或更早版本,
uint加减法可能会静默溢出,那bug找起来可要命了。我建议永远用0.8.x以上版本。
2.3.2 bool 和 address
bool 就两个值:true 和 false。但注意,Solidity里没有“真值”概念——你不能把非零数字当true用。
address 是Solidity的灵魂类型。它代表一个以太坊账户地址,20字节。我项目中90%的函数都要用到它。
address public owner;
bool public isActive;
function setOwner(address _newOwner) public {
owner = _newOwner;
isActive = true;
}
// 检查地址是否为0
function isValidAddress(address _addr) public pure returns (bool) {
return _addr != address(0);
}
我个人习惯:每次接收
address参数时,我都会加一个require(_addr != address(0))检查。因为空地址转账会导致资金永久丢失,这个坑我帮别人排查过不下10次。
2.3.3 string 和 bytes
string 是UTF-8编码的字符串,bytes 是原始字节数据。两者可以互相转换,但gas消耗不同。
string public greeting = "Hello";
bytes public data = hex"1234";
// string转bytes
function toBytes(string memory _str) public pure returns (bytes memory) {
return bytes(_str);
}
// bytes转string
function toString(bytes memory _bytes) public pure returns (string memory) {
return string(_bytes);
}
注意:
string 不能直接用 length 属性获取长度(因为UTF-8字符长度可变)。如果你需要操作字符串长度,先转成 bytes 再取 .length。但要注意,bytes(_str).length 返回的是字节数,不是字符数。
2.4 值类型 vs 引用类型
这个区分太重要了。我直接说结论:
- 值类型:
uint、int、bool、address、bytes1~bytes32、enum。赋值时复制值,互不影响。 - 引用类型:
string、bytes(动态)、数组、结构体、映射。赋值时复制引用,改一个另一个也变。
// 值类型:互不影响
uint a = 10;
uint b = a; // b得到10的副本
b = 20; // a还是10,b变成20
// 引用类型:共享数据
uint[] memory arr1 = new uint[](3);
arr1[0] = 1;
uint[] memory arr2 = arr1; // arr2指向同一块内存
arr2[0] = 999; // arr1[0]也变成了999!
我项目中遇到的血泪教训:有一次我写一个DeFi合约,用结构体数组存储用户信息。在函数里把结构体赋值给局部变量,然后修改局部变量,以为原数据会变——结果没变。因为结构体是引用类型,但如果你用
storage关键字声明局部变量,它才是引用;用memory声明,它就是副本。这个细节坑了无数人。
2.4.1 数据位置:storage / memory / calldata
引用类型必须指定数据位置。三个选项:
| 位置 | 说明 | 可修改 | gas消耗 |
|---|---|---|---|
storage |
指向链上状态变量 | 是 | 高(读写链上) |
memory |
临时内存,函数执行完消失 | 是 | 低 |
calldata |
只读的输入数据 | 否 | 最低 |
contract DataLocationDemo {
uint[] public arr; // 状态变量,默认storage
// memory参数:函数内可修改,但不影响原数据
function useMemory(uint[] memory _input) public pure returns (uint) {
_input[0] = 100; // 只修改内存中的副本
return _input[0];
}
// calldata参数:只读,gas最省
function useCalldata(uint[] calldata _input) external pure returns (uint) {
// _input[0] = 100; // ❌ 不能修改
return _input[0];
}
// storage引用:直接操作状态变量
function useStorage() public {
uint[] storage localRef = arr; // localRef指向arr
localRef.push(42); // 直接修改了arr
}
}
我的建议:函数参数能用
calldata就用calldata,尤其是大数组。我优化过一个合约,把memory改成calldata后,gas费省了30%以上。但注意,calldata只能用于external函数。
2.5 本章小结
这一章内容不少,但都是硬功夫。你记住几个关键点:
- 状态变量存链上,贵;局部变量存内存,便宜。
- 值类型复制,引用类型共享——搞混了会出大bug。
- 能用
uint别用int,能用calldata别用memory。 - 地址检查不能省,空地址是魔鬼。
下一章我们讲函数和修饰器,那才是Solidity真正开始“动起来”的地方。准备好了吗?