第3章:数据类型与变量——Solidity的“语言基因”

说实话,刚接触Solidity那会儿,我总觉得数据类型这玩意儿没啥好讲的。不就是整型、布尔、字符串嘛,跟JavaScript差不多。结果第一次写合约就踩了坑——一个uint8的变量,存了个256进去,直接溢出回滚了。嗯,从那以后我再也不敢小看类型系统了。

这一章,咱们就聊聊Solidity的数据类型。说白了,就是搞清楚“什么数据能存、怎么存、存哪儿”。你写合约时90%的bug,其实都跟类型选择不当有关。

3.1 值类型:最基础的“砖块”

值类型,就是那些直接存“值”本身的类型。你赋值给另一个变量时,会复制一份全新的数据。我习惯叫它们“老实人”——你改你的,跟我没关系。

3.1.1 布尔类型(bool)

最简单,就两个值:truefalse。但有个坑——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;
}
小技巧:Solidity 0.8.0之后默认带溢出检查。如果你确定不会溢出,可以用 unchecked 块省gas。但——我建议新手别碰,老老实实让编译器保护你。

3.1.3 地址类型(address)

地址类型存的是以太坊地址,20字节。分两种:addressaddress 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 引用类型:复杂数据的“容器”

引用类型就复杂多了。它们存的是数据的“位置”或“引用”,而不是数据本身。你赋值时,其实是在传递引用——改一个,另一个也跟着变。

嗯,这里要注意:引用类型必须指定数据存储位置(storagememorycalldata)。这是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)

数组分固定长度和动态长度。动态数组可以 pushpop,但要注意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];
}
个人经验:映射的key可以是任何值类型,但引用类型不行(比如 mapping(string => uint) 会报错)。我习惯用 addressuint256 做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上限
重要提醒:block.timestamp 可以被矿工轻微操纵(±15秒左右)。我见过有人用它做随机数种子,结果被矿工利用。别这么干!

3.3.3 交易相关(tx)

变量 类型 说明
tx.origin address 交易发起者(最原始调用者)
tx.gasprice uint 交易的gas价格

这里有个经典坑:tx.originmsg.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;
}
省钱技巧:外部函数的数组参数用 calldatamemory 省gas。因为 calldata 不需要拷贝数据到内存。我写合约时,只要参数是只读的,一律用 calldata

3.5 避坑总结

最后,分享几个我踩过的坑:

  1. 整型溢出:0.8.0之前要手动用SafeMath,现在编译器帮你检查了。但 unchecked 块里还是会溢出。
  2. 字符串比较:不能直接用 == 比较两个string。得先转成 bytes 再比较哈希值。
  3. 映射不能遍历:如果你需要遍历所有数据,考虑用数组+映射的组合方案。
  4. 全局变量滥用block.timestamptx.origin 都有安全隐患,能不用就别用。

好了,数据类型这块儿就聊到这儿。说白了,选对类型、放对位置,你的合约就成功了一半。下一章咱们聊聊函数和修饰器——到时候你会发现,类型知识全都能用上。