第3章:数据类型与变量——Solidity的“语言基因”
说实话,刚接触Solidity那会儿,我总觉得数据类型这玩意儿没啥好讲的。不就是整型、布尔、字符串嘛,跟JavaScript差不多。结果第一次写合约就踩了坑——一个uint8的变量,存了个256进去,直接溢出回滚了。嗯,从那以后我再也不敢小看类型系统了。
这一章,咱们就聊聊Solidity的数据类型。说白了,就是搞清楚“什么数据能存、怎么存、存哪儿”。你写合约时90%的bug,其实都跟类型选择不当有关。
3.1 值类型:最基础的“砖块”
值类型,就是那些直接存“值”本身的类型。你赋值给另一个变量时,会复制一份全新的数据。我习惯叫它们“老实人”——你改你的,跟我没关系。
3.1.1 布尔类型(bool)
最简单,就两个值:true 和 false。但有个坑——Solidity里没有“真值”概念。你不能像在JavaScript里那样写 if (1),必须写 if (x == true)。
bool public isActive = true;
bool public isPaused = false;
function toggle() external {
isActive = !isActive; // 取反
}
false。我见过有人忘记初始化,结果条件判断一直走false分支,排查了半天。
3.1.2 整型(int / uint)
整型分有符号(int)和无符号(uint)。后面跟的数字表示位数,从8到256,步长8。比如 uint8 范围0~255,uint256 范围0~2^256-1。
我个人习惯:能用 uint256 就别用 uint8。为什么?因为EVM操作256位数据最省gas。你省那点存储空间,反而可能因为类型转换多花gas。
uint256 public totalSupply = 1000000;
int256 public balance = -500; // 允许负数
function add(uint256 a, uint256 b) public pure returns (uint256) {
return a + b;
}
unchecked 块省gas。但——我建议新手别碰,老老实实让编译器保护你。
3.1.3 地址类型(address)
地址类型存的是以太坊地址,20字节。分两种:address 和 address payable。后者才能接收ETH转账。
我在项目中遇到过一个问题:想把合约里的ETH转出去,结果地址类型没加 payable,编译直接报错。后来养成了习惯——只要涉及转账,一律用 address payable。
address public owner = msg.sender;
address payable public feeReceiver = payable(0x123...);
function sendEther() external {
feeReceiver.transfer(1 ether);
}
3.1.4 字节类型(bytes1 ~ bytes32)
固定长度的字节数组。比如 bytes1 存1个字节,bytes32 存32个字节。常用于哈希值、标识符等场景。
bytes32 public hash = keccak256("hello");
bytes1 public firstByte = 0x42;
3.1.5 枚举类型(enum)
枚举就是给一组常量起个名字。底层其实存的是uint8,从0开始编号。
enum Status { Pending, Active, Closed }
Status public currentStatus = Status.Pending;
function activate() external {
currentStatus = Status.Active;
}
3.2 引用类型:复杂数据的“容器”
引用类型就复杂多了。它们存的是数据的“位置”或“引用”,而不是数据本身。你赋值时,其实是在传递引用——改一个,另一个也跟着变。
嗯,这里要注意:引用类型必须指定数据存储位置(storage、memory、calldata)。这是Solidity跟其他语言最大的不同。
3.2.1 字符串(string)
字符串本质是动态字节数组,但你不能直接索引访问。想操作字符串?得先转成 bytes。
string public greeting = "Hello, World!";
function getLength() public view returns (uint) {
return bytes(greeting).length;
}
function setGreeting(string memory _newGreeting) external {
greeting = _newGreeting;
}
string 存大量文本,结果gas费高得离谱。记住——链上存储很贵,能用 bytes32 就别用 string。
3.2.2 数组(array)
数组分固定长度和动态长度。动态数组可以 push 和 pop,但要注意gas消耗。
uint256[5] public fixedArray; // 固定长度
uint256[] public dynamicArray; // 动态长度
function addElement(uint256 value) external {
dynamicArray.push(value);
}
function removeLast() external {
dynamicArray.pop();
}
你想想看,为什么很多合约用 mapping 而不是数组?因为数组遍历会消耗大量gas,而且数据量大了之后根本跑不动。我一般只在数据量确定且很小的时候用数组。
3.2.3 映射(mapping)
映射是Solidity里最常用的数据结构。它像是一个哈希表,key到value的映射。注意:映射不能遍历,不能获取长度。
mapping(address => uint256) public balances;
mapping(address => mapping(uint256 => bool)) public userActions;
function setBalance(address _user, uint256 _amount) external {
balances[_user] = _amount;
}
function getBalance(address _user) public view returns (uint256) {
return balances[_user];
}
mapping(string => uint) 会报错)。我习惯用 address 或 uint256 做key,简单高效。
3.3 全局变量:Solidity给你的“免费工具”
全局变量是Solidity内置的,你不需要声明就能直接用。它们提供了区块链的上下文信息。说白了,就是帮你回答三个问题:谁在调用?现在什么时间?这个区块啥情况?
3.3.1 交易相关(msg)
| 变量 | 类型 | 说明 |
|---|---|---|
| msg.sender | address | 调用者地址 |
| msg.value | uint | 发送的ETH数量(wei) |
| msg.data | bytes | 完整的调用数据 |
| msg.sig | bytes4 | 函数选择器(前4字节) |
function deposit() external payable {
require(msg.value > 0, "必须发送ETH");
balances[msg.sender] += msg.value;
}
3.3.2 区块相关(block)
| 变量 | 类型 | 说明 |
|---|---|---|
| block.number | uint | 当前区块高度 |
| block.timestamp | uint | 当前区块时间戳(秒) |
| block.difficulty | uint | 当前区块难度 |
| block.gaslimit | uint | 当前区块gas上限 |
3.3.3 交易相关(tx)
| 变量 | 类型 | 说明 |
|---|---|---|
| tx.origin | address | 交易发起者(最原始调用者) |
| tx.gasprice | uint | 交易的gas价格 |
这里有个经典坑:tx.origin 和 msg.sender 的区别。前者是整条调用链的起点,后者是直接调用者。我曾经在权限检查时用了 tx.origin,结果被中间合约钓鱼攻击。记住——权限检查永远用 msg.sender。
3.4 数据位置:storage、memory、calldata
这是Solidity最独特的地方。引用类型必须指定数据存在哪儿。
- storage:存在链上,永久保存。修改会消耗gas。
- memory:存在内存中,函数执行完就释放。临时数据用这个。
- calldata:只读的调用数据,不可修改。外部函数的参数默认用这个。
function processArray(uint256[] calldata _input) external {
// _input 是 calldata,只读
uint256[] memory temp = new uint256[](_input.length);
// temp 是 memory,可以修改
for (uint i = 0; i < _input.length; i++) {
temp[i] = _input[i] * 2;
}
// 存到 storage
storedArray = temp;
}
calldata 比 memory 省gas。因为 calldata 不需要拷贝数据到内存。我写合约时,只要参数是只读的,一律用 calldata。
3.5 避坑总结
最后,分享几个我踩过的坑:
- 整型溢出:0.8.0之前要手动用SafeMath,现在编译器帮你检查了。但
unchecked块里还是会溢出。 - 字符串比较:不能直接用
==比较两个string。得先转成bytes再比较哈希值。 - 映射不能遍历:如果你需要遍历所有数据,考虑用数组+映射的组合方案。
- 全局变量滥用:
block.timestamp和tx.origin都有安全隐患,能不用就别用。
好了,数据类型这块儿就聊到这儿。说白了,选对类型、放对位置,你的合约就成功了一半。下一章咱们聊聊函数和修饰器——到时候你会发现,类型知识全都能用上。