4、Solidity数据类型:值类型、引用类型与全局变量
好,咱们今天来聊聊Solidity的数据类型。说实话,这块内容看起来基础,但我在审计合约时发现,很多漏洞恰恰出在对数据类型的理解不够深。你想想看,一个地址类型用错了,可能几百万的资产就锁死了。
Solidity的数据类型,我习惯把它们分成三大类:值类型、引用类型,还有全局变量。咱们一个一个来。
4.1 值类型:最基础的积木块
值类型,说白了就是“复制粘贴”的类型。你把它赋值给另一个变量,它会把值完整地拷贝过去。两个变量从此各过各的,互不影响。
4.1.1 bool(布尔型)
布尔型只有两个值:true 和 false。简单吧?但我在项目中遇到过一个问题:有人用 if (myBool == true) 这种写法。其实直接写 if (myBool) 就够了,更简洁,也更省gas。
bool public isActive = true;
bool public isPaused = false;
function toggle() public {
isActive = !isActive; // 取反操作
}
if (1) 这种代码,编译器会报错。
4.1.2 int 和 uint(整数型)
整数型分两种:有符号的 int 和无符号的 uint。区别就在于能不能表示负数。
我个人习惯,能用 uint 的地方绝不用 int。为什么?因为合约里大部分场景都是非负的——余额、数量、时间戳,哪个会是负数?用 uint 还能多出一倍的表示范围。
| 类型 | 位数 | 范围 |
|---|---|---|
| uint8 | 8 | 0 ~ 255 |
| uint256 | 256 | 0 ~ 2^256-1 |
| int256 | 256 | -2^255 ~ 2^255-1 |
uint8 存了255,再加1就会revert。我曾经见过一个老项目,用的0.7版本,结果整数溢出导致代币总量变成了0……嗯,从那以后我写合约第一件事就是确认编译器版本。
4.1.3 address(地址类型)
地址类型,这是以太坊的灵魂。每个账户、每个合约,都有一个地址。
地址分两种:address 和 address payable。后者可以接收以太币,前者不行。你想想看,如果你想把ETH转给一个普通地址,必须用 payable 类型。
address public owner;
address payable public treasury;
function setTreasury(address payable _newTreasury) public {
treasury = _newTreasury;
}
function withdraw() public {
// 只有payable地址才能调用transfer
treasury.transfer(address(this).balance);
}
bytes32 搞混了,那是32字节。
4.1.4 bytes32(固定大小字节数组)
bytes32 是固定长度的字节数组,长度就是32字节。它常用来存哈希值、标识符、或者一些短文本。
我记得有一次,一个项目方想把用户昵称存到链上。他们用了 string,结果每次修改都要花很多gas。我建议他们改成 bytes32,因为昵称一般不超过32个字符。gas费直接降了一半。
bytes32 public constant NAME_HASH = keccak256("MyToken");
bytes32 public merkleRoot;
function setMerkleRoot(bytes32 _root) public {
merkleRoot = _root;
}
4.2 引用类型:复杂数据的容器
引用类型就不一样了。它存的是数据的“位置”,而不是数据本身。你赋值的时候,其实是在复制引用。两个变量可能指向同一块内存。
4.2.1 string(字符串)
字符串在Solidity里有点特殊。它本质上是一个动态字节数组,但你不能直接索引访问。说白了,string 就是给人类看的,bytes 才是给机器用的。
string public name = "MyNFT";
string public symbol = "MNFT";
function setName(string memory _newName) public {
name = _newName;
}
==。你需要把两个字符串先转成 bytes,再比较哈希值。我曾经因为这个踩过坑,两个看起来一模一样的字符串,比较结果却是false——因为一个末尾多了个空格。
4.2.2 array(数组)
数组分两种:固定数组和动态数组。固定数组长度在声明时就确定了,动态数组可以 push 和 pop。
我个人建议,能用固定数组就别用动态数组。为什么?动态数组的 push 操作会消耗更多gas,而且容易导致数组无限膨胀。
// 固定数组
uint[5] public fixedArray = [1, 2, 3, 4, 5];
// 动态数组
uint[] public dynamicArray;
function addElement(uint _value) public {
dynamicArray.push(_value);
}
function removeLast() public {
dynamicArray.pop();
}
delete 直接删中间的元素。它会留下一个空位,导致数组出现“空洞”。正确的做法是:把最后一个元素移到要删的位置,然后 pop。这叫“swap and pop”模式。
4.2.3 mapping(映射)
映射,这是Solidity里最常用的数据结构。它就像一本字典,给你一个key,你就能找到对应的value。
映射有几个特点:
- 不能遍历(没有length属性)
- 所有key默认都有值(uint默认0,address默认0x0)
- key可以是任意值类型,但不能是引用类型
mapping(address => uint) public balances;
mapping(uint => address) public tokenOwners;
function mint(address _to, uint _tokenId) public {
balances[_to]++;
tokenOwners[_tokenId] = _to;
}
4.3 全局变量:Solidity给你的礼物
全局变量,也叫内置变量。你不用声明就能直接用。它们记录了当前交易和区块的信息。
4.3.1 msg.sender(消息发送者)
msg.sender 是调用当前函数的地址。注意,它不一定是交易发起者。如果A合约调用了B合约,那在B合约里,msg.sender 是A合约的地址,而不是用户的地址。
这个区别很重要。我在审计时见过一个合约,用 msg.sender 做权限控制,结果被中间合约攻击了。正确的做法是:如果需要用户地址,用 tx.origin;如果需要直接调用者,用 msg.sender。
function onlyOwner() public {
require(msg.sender == owner, "Not owner");
// 只有owner才能执行
}
function safeMint() public {
// msg.sender就是铸造者
_mint(msg.sender, totalSupply() + 1);
}
4.3.2 block.timestamp(区块时间戳)
block.timestamp 是当前区块的时间戳,单位是秒。它常用来做时间锁、拍卖截止、质押周期等。
嗯,这里要注意:矿工可以轻微调整时间戳(大概15秒的浮动)。所以别用它来做精确到秒的逻辑。我一般会放宽到分钟级别。
uint public constant LOCK_PERIOD = 7 days;
mapping(address => uint) public lastDeposit;
function deposit() public payable {
lastDeposit[msg.sender] = block.timestamp;
}
function withdraw() public {
require(block.timestamp >= lastDeposit[msg.sender] + LOCK_PERIOD, "Still locked");
// 执行提现逻辑
}
block.number(当前区块号)、msg.value(发送的ETH数量)、gasleft()(剩余gas)等全局变量。你可以在官方文档里找到完整列表。
4.4 总结一下
数据类型是Solidity的基石。值类型像砖块,引用类型像容器,全局变量像工具。你只有把它们用对了,才能盖出稳固的合约大楼。
我个人建议,刚开始写合约时,多花点时间在数据类型上。别急着写业务逻辑。先把 uint 和 int 分清楚,把 memory 和 storage 搞明白。这些基础打牢了,后面写复杂合约才不会翻车。
下一章,咱们聊聊函数修饰符和可见性。到时候我会分享一个我踩过的坑——因为搞混了 public 和 external,导致合约被恶意调用的故事。敬请期待。