2. Solidity安全编码规范:变量作用域、可见性修饰符、函数修饰符的最佳实践
好,咱们进入第二个章节。变量作用域、可见性修饰符、函数修饰符——这几个东西,说白了就是智能合约的「门禁系统」。门没锁好,或者钥匙乱放,后果你懂的。
我个人习惯,写合约之前先想清楚一件事:这个变量或函数,到底该让谁看到? 想明白了,再动手写代码。不然回头审计的时候,一堆 visibility 问题等着你。
2.1 变量作用域:别让变量「越界」
Solidity 里的变量作用域,其实没那么复杂。记住三个层次就行:
- 状态变量:写在合约最外层,永久存储在链上。谁都能读(取决于 visibility),但写操作要小心。
- 局部变量:写在函数内部,函数跑完就消失。省 gas,但别指望它帮你存东西。
- 全局变量:像
msg.sender、block.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 变量里……嗯,结果你猜到了。
我个人习惯:默认用 internal 或 private,除非明确需要公开。你想想看,一个函数如果没必要暴露给外部,为什么要给它 public?多一个入口,就多一个攻击面。
这里有个小技巧:external 比 public 更省 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 包裹了 modifierB,modifierB 再包裹原函数。所以最外层的 modifierA 先执行到 _;,然后进入 modifierB,再进入原函数。
2.4 避坑指南:我踩过的那些坑
好,最后分享几个实战中容易翻车的地方。
坑一:状态变量默认是 internal
很多人以为没写 visibility 就是 private,其实不是。状态变量默认是 internal,子合约可以直接读。如果你不想让子合约访问,记得显式写成 private。
坑二:public 变量自动生成 getter 函数
这个其实挺方便的,但要注意:getter 函数是 view 类型的,不能改状态。如果你在 getter 里试图修改状态变量,编译器会报错。嗯,我见过有人想用 getter 做点「额外操作」,结果被编译器拦住了。
坑三:修饰符里的 _; 位置很重要
我曾经审计过一个合约,修饰符里在 _; 之后做了状态更新。结果函数执行到一半 revert 了,但修饰符里的状态更新已经生效了——这就导致状态不一致。记住:如果修饰符里有状态变更,尽量放在 _; 之前,或者确保 revert 时能回滚。
坑四:external 函数不能内部调用
这个其实不算坑,是设计如此。但如果你在合约内部用 this.f() 的方式调用 external 函数,会触发一次外部调用,gas 费更高。所以内部调用就用 internal 或 public。
2.5 总结一下
变量作用域、可见性、修饰符——这三个东西看似基础,但很多安全漏洞都出在这里。我的建议是:
- 最小权限原则:能 private 就别 public,能 internal 就别 external。
- 明确意图:写代码时就想清楚,这个变量/函数到底给谁用。
- 多测试:特别是修饰符的执行顺序和 revert 行为,一定要写单元测试覆盖。
好了,这一章就到这里。下一章我们聊聊「重入攻击与防护」,那可是智能合约安全里的「经典款」了。到时候见。