4、Solidity数据类型:值类型、引用类型与全局变量

好,咱们今天来聊聊Solidity的数据类型。说实话,这块内容看起来基础,但我在审计合约时发现,很多漏洞恰恰出在对数据类型的理解不够深。你想想看,一个地址类型用错了,可能几百万的资产就锁死了。

Solidity的数据类型,我习惯把它们分成三大类:值类型、引用类型,还有全局变量。咱们一个一个来。

4.1 值类型:最基础的积木块

值类型,说白了就是“复制粘贴”的类型。你把它赋值给另一个变量,它会把值完整地拷贝过去。两个变量从此各过各的,互不影响。

4.1.1 bool(布尔型)

布尔型只有两个值:truefalse。简单吧?但我在项目中遇到过一个问题:有人用 if (myBool == true) 这种写法。其实直接写 if (myBool) 就够了,更简洁,也更省gas。

bool public isActive = true;
bool public isPaused = false;

function toggle() public {
    isActive = !isActive;  // 取反操作
}
小技巧:Solidity里没有“真值”的概念。不像JavaScript,数字1不会自动转成true。所以别写 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
注意:Solidity 0.8.0 之后默认带溢出检查。如果你用 uint8 存了255,再加1就会revert。我曾经见过一个老项目,用的0.7版本,结果整数溢出导致代币总量变成了0……嗯,从那以后我写合约第一件事就是确认编译器版本。

4.1.3 address(地址类型)

地址类型,这是以太坊的灵魂。每个账户、每个合约,都有一个地址。

地址分两种:addressaddress 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);
}
核心要点:地址类型占20字节(160位)。别跟 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(数组)

数组分两种:固定数组和动态数组。固定数组长度在声明时就确定了,动态数组可以 pushpop

我个人建议,能用固定数组就别用动态数组。为什么?动态数组的 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;
}
经验之谈:如果你需要遍历映射,那就得额外维护一个数组来存所有的key。我做过一个NFT项目,需要查询用户持有的所有代币。我用了“映射+数组”的组合方案:映射存余额,数组存所有代币ID。虽然多花了点gas,但查询效率高了很多。

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");
    // 执行提现逻辑
}
小提示:Solidity里还有 block.number(当前区块号)、msg.value(发送的ETH数量)、gasleft()(剩余gas)等全局变量。你可以在官方文档里找到完整列表。

4.4 总结一下

数据类型是Solidity的基石。值类型像砖块,引用类型像容器,全局变量像工具。你只有把它们用对了,才能盖出稳固的合约大楼。

我个人建议,刚开始写合约时,多花点时间在数据类型上。别急着写业务逻辑。先把 uintint 分清楚,把 memorystorage 搞明白。这些基础打牢了,后面写复杂合约才不会翻车。

下一章,咱们聊聊函数修饰符和可见性。到时候我会分享一个我踩过的坑——因为搞混了 publicexternal,导致合约被恶意调用的故事。敬请期待。