2. Solidity安全编码规范:变量作用域、可见性修饰符、函数修饰符的最佳实践

好,咱们进入第二个章节。变量作用域、可见性修饰符、函数修饰符——这几个东西,说白了就是智能合约的「门禁系统」。门没锁好,或者钥匙乱放,后果你懂的。

我个人习惯,写合约之前先想清楚一件事:这个变量或函数,到底该让谁看到? 想明白了,再动手写代码。不然回头审计的时候,一堆 visibility 问题等着你。

2.1 变量作用域:别让变量「越界」

Solidity 里的变量作用域,其实没那么复杂。记住三个层次就行:

  • 状态变量:写在合约最外层,永久存储在链上。谁都能读(取决于 visibility),但写操作要小心。
  • 局部变量:写在函数内部,函数跑完就消失。省 gas,但别指望它帮你存东西。
  • 全局变量:像 msg.senderblock.timestamp 这些,Solidity 自带的。直接用,别乱改(你也改不了)。

核心原则:能用局部变量就别用状态变量。 状态变量每次读写都要花 gas,而且容易成为攻击目标。

举个例子。我之前审计过一个项目,开发者在循环里反复读取状态变量,结果 gas 费高得离谱。其实完全可以用一个局部变量缓存一下:

// ❌ 不推荐:每次循环都读状态变量
function sumArray() public view returns (uint) {
    uint total = 0;
    for (uint i = 0; i < myArray.length; i++) {
        total += myArray[i];  // 每次读 myArray 都要访问存储
    }
    return total;
}

// ✅ 推荐:用局部变量缓存
function sumArray() public view returns (uint) {
    uint[] memory arr = myArray;  // 先读到内存
    uint total = 0;
    for (uint i = 0; i < arr.length; i++) {
        total += arr[i];  // 从内存读,快多了
    }
    return total;
}

嗯,这里要注意:memory 数组在函数返回后就不存在了。如果你需要持久化存储,那还是得用状态变量。

2.2 可见性修饰符:谁可以看?谁可以动?

Solidity 有四种可见性修饰符,我按「开放程度」排个序:

修饰符 谁可以调用 典型场景
public 任何人(包括外部合约) 需要公开访问的接口
external 仅外部调用(合约内部不能直接调) 节省 gas 的公开函数
internal 本合约 + 子合约 继承体系内的共享逻辑
private 仅本合约 敏感数据或内部逻辑

⚠️ 重要提醒: private 不等于「秘密」。链上数据都是公开的,只是合约代码层面不能直接读。我曾经见过有人把私钥放在 private 变量里……嗯,结果你猜到了。

我个人习惯:默认用 internalprivate,除非明确需要公开。你想想看,一个函数如果没必要暴露给外部,为什么要给它 public?多一个入口,就多一个攻击面。

这里有个小技巧:externalpublic 更省 gas。因为 public 函数的参数会被复制到内存,而 external 可以直接从 calldata 读取。如果你的函数只给外部调用,用 external 就对了。

// 省 gas 的写法
function transfer(address recipient, uint amount) external {
    // 参数直接从 calldata 读,不复制到内存
    _transfer(recipient, amount);
}

2.3 函数修饰符:给函数加点「料」

修饰符(modifier)是 Solidity 里很强大的特性。它可以在函数执行前后插入逻辑,比如权限检查、重入锁等。

最常见的用法就是 onlyOwner

modifier onlyOwner() {
    require(msg.sender == owner, "Not owner");
    _;  // 继续执行原函数
}

function withdraw() external onlyOwner {
    // 只有 owner 才能调用
    payable(owner).transfer(address(this).balance);
}

注意那个 _;,它代表「原函数的代码在这里执行」。你可以把修饰符想象成一个「包装器」——在 _; 之前是前置检查,之后是后置处理。

💡 我的经验: 修饰符里尽量只做检查,不要改状态。否则容易出问题。我曾经审计过一个项目,修饰符里改了某个状态变量,结果导致重入攻击——因为 _; 之后的代码以为状态没变,其实已经变了。

另外,修饰符的执行顺序也很重要。如果你给一个函数加了多个修饰符,它们会按「从右到左」的顺序执行。嗯,这个有点反直觉,我刚开始也搞错过:

// 执行顺序:modifierB 先执行,然后 modifierA,最后原函数
function doSomething() external modifierA modifierB {
    // ...
}

为什么会这样?因为 Solidity 把修饰符嵌套起来了。你可以理解为:modifierA 包裹了 modifierBmodifierB 再包裹原函数。所以最外层的 modifierA 先执行到 _;,然后进入 modifierB,再进入原函数。

2.4 避坑指南:我踩过的那些坑

好,最后分享几个实战中容易翻车的地方。

坑一:状态变量默认是 internal

很多人以为没写 visibility 就是 private,其实不是。状态变量默认是 internal,子合约可以直接读。如果你不想让子合约访问,记得显式写成 private

坑二:public 变量自动生成 getter 函数

这个其实挺方便的,但要注意:getter 函数是 view 类型的,不能改状态。如果你在 getter 里试图修改状态变量,编译器会报错。嗯,我见过有人想用 getter 做点「额外操作」,结果被编译器拦住了。

坑三:修饰符里的 _; 位置很重要

我曾经审计过一个合约,修饰符里在 _; 之后做了状态更新。结果函数执行到一半 revert 了,但修饰符里的状态更新已经生效了——这就导致状态不一致。记住:如果修饰符里有状态变更,尽量放在 _; 之前,或者确保 revert 时能回滚。

坑四:external 函数不能内部调用

这个其实不算坑,是设计如此。但如果你在合约内部用 this.f() 的方式调用 external 函数,会触发一次外部调用,gas 费更高。所以内部调用就用 internalpublic

2.5 总结一下

变量作用域、可见性、修饰符——这三个东西看似基础,但很多安全漏洞都出在这里。我的建议是:

  • 最小权限原则:能 private 就别 public,能 internal 就别 external。
  • 明确意图:写代码时就想清楚,这个变量/函数到底给谁用。
  • 多测试:特别是修饰符的执行顺序和 revert 行为,一定要写单元测试覆盖。

好了,这一章就到这里。下一章我们聊聊「重入攻击与防护」,那可是智能合约安全里的「经典款」了。到时候见。