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

uintuint256 的简写,表示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 就两个值:truefalse。但注意,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 引用类型

这个区分太重要了。我直接说结论:

  • 值类型uintintbooladdressbytes1~bytes32enum。赋值时复制值,互不影响。
  • 引用类型stringbytes(动态)、数组结构体映射。赋值时复制引用,改一个另一个也变。
// 值类型:互不影响
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真正开始“动起来”的地方。准备好了吗?