本文的目的不是重复官方的 Solidity Style Guide(你应该去阅读它)。相反,本文旨在记录在代码审查或审计中经常出现的、偏离风格指南的常见问题。这里列出的一些条目并不在风格指南中,而是 Solidity 开发者常见的风格错误。
前两行
1. 包含 SPDX-License-Identifier
当然,没有它你的代码也能编译,但你会收到一个警告,所以加上它来消除警告吧。
2. 除非编写库,否则请固定 Solidity pragma 版本
你可能见过类似下面的 pragma:
pragma solidity ^0.8.0;
和
pragma solidity 0.8.21;
你应该在什么时候使用哪一个?如果你是编译和部署合约的人,你清楚自己使用的 Solidity 版本,因此为了清晰起见,你应该将 Solidity 版本固定为你正在使用的编译器版本。
另一方面,如果你正在创建一个供他人扩展的库(就像 OpenZeppelin 和 Solady 所做的那样),你不应该固定 pragma,因为你不知道最终用户会使用哪个编译器版本。
导入 (Imports)
3. 在 import 语句中显式设置库的版本
与其这样做:
import "@openzepplin/contracts/token/ERC20/ERC20.sol";
不如这样做:
import "@openzeppelin/[email protected]/token/ERC20/ERC20.sol";
你可以通过点击 Github 左侧的分支下拉菜单并点击 tags(标签),然后选择最新发布版本来获取最新版本。请使用最新的干净版本(非 rc,即非候选发布版本)。

如果你不对导入进行版本控制,一旦底层库更新,你的代码就有可能无法编译或表现出意外行为。
4. 使用命名导入而不是导入整个命名空间
与其这样做:
import "@openzeppelin/[email protected]/token/ERC20/ERC20.sol";
不如这样做:
import {ERC20} from "@openzeppelin/[email protected]/token/ERC20/ERC20.sol";
如果在导入的文件中定义了多个合约或库,你会污染命名空间。如果编译器优化器没有将其移除(你不应该依赖这一点),这将导致死代码(dead code)。
5. 删除未使用的导入
如果你使用像 Slither 这样的智能合约安全工具,它会自动捕获这个问题。但请务必移除这些未使用的导入。不要害怕删除代码。
合约层级
6. 应用合约级别的 NatSpec
NatSpec(自然语言规范)的目的在于提供一种易于人类阅读的内联文档。
下面展示了一个合约的 NatSpec 示例。
/// @title Liquidity token for Foo protocol
/// @author Foo Incorporated
/// @notice Notes for non-technical readers/
/// @dev Notes for people who understand Solidity
contract LiquidityToken {
}
7. 按照风格指南布局合约结构
函数应首先按“外部性(externality)”排序,其次按“状态更改性(state-changingness)”排序。
它们的排列顺序应如下:receive 和 fallback 函数(如果适用)、external 函数、public 函数、internal 函数和 private 函数。
在这些分组中,payable 函数放在最上面,然后是 non-payable,接着是 view,最后是 pure。
contract ProperLayout {
// type declarations, e.g. using Address for address
// state vars
address internal owner;
uint256 internal _stateVar;
uint256 internal _starteVar2;
// events
event Foo();
event Bar(address indexed sender);
// errors
error NotOwner();
error FooError();
error BarError();
// modifiers
modifier onlyOwner() {
if (msg.sender != owner) {
revert NotOwner();
}
_;
}
// functions
constructor() {
}
receive() external payable {
}
falback() external payable {
}
// functions are first grouped by
// - external
// - public
// - internal
// - private
// note how the external functions "descend" in order of how much they can modify or interact with the state
function foo() external payable {
}
function bar() external {
}
function baz() external view {
}
function qux() external pure {
}
// public functions
function fred() public {
}
function bob() public view {
}
// internal functions
// internal view functions
// internal pure functions
// private functions
// private view functions
// private pure functions
}
常量
8. 用常量替换魔法数字
如果你看到数字 100 就这样出现在代码中,它代表什么?100%?还是 100 个基点?
通常,数字应该作为常量写在合约的顶部。
9. 如果数字用于衡量 Ether 或时间,请使用 Solidity 关键字
与其写成
uint256 secondsPerDay = 60 * 60 * 24;
不如写成
1 days
与其写成
require(msg.value == 10**18 / 10, "must send 0.1 ether");
不如写成
require(msg.value == 0.1 ether, "must send 0.1 ether");
10. 使用下划线使大数字更具可读性
与其这样做:
uint256 private constant BASIS_POINTS_DENOMINATOR = 10000
不如这样做:
uint256 private constant BASIS_POINTS_DENOMINATOR = 10_000
函数
11. 从不会被重写的函数中移除 virtual 修饰符
virtual 修饰符表示“可被子合约重写”。但是如果你确信不会重写该函数(因为你是部署者),那么这个修饰符就是多余的。直接删除它即可。
12. 将函数修饰符放在正确的顺序:可见性、可变性、virtual、override、自定义修饰符
以下是正确的示例:
// visibility (payability), [virtual], [override], [custom]
function foo() public payable onlyAdmin {
}
function bar() internal view virtual override onlyAdmin {
}
13. 正确使用 NatSpec
有时被称为“Solidity 注释风格”,它的正式名称是 NatSpec:
其规则与合约的 NatSpec 类似,不同之处在于我们还需要根据函数参数和返回值来指定 params(参数)和 returns(返回值)。
这是一种不用长变量名就能很好地描述参数含义的方法。
/// @notice Deposit ERC20 tokens
/// @dev emits a Deposit event
/// @dev reverts if the token is not allowlisted
/// @dev reverts if the contract is not approved by the ERC20
/// @param token The address of the ERC20 token to be deposited
/// @param amount The amount of ERC20 tokens to deposit
/// @returns the amount of liquidity tokens the user receives
function deposit(address token, uint256 amount) public returns (uint256) {
}
// If the contract inherits functions, you can also inherit their NatSpec
/// @inheritdoc Lendable
function calculateAccumulatedInterest(address token, uint256 since) public override view returns (uint256 interest) {
}
对于 dev 标签,最好注明它可以进行哪些状态改变,例如触发事件、发送 Ether、selfdestruct 等。
Etherscan 会读取 notice 和 param 的 NatSpec。

在下面代码的截图中,你可以看到 Etherscan 是从哪里获取这些信息的。

整体代码整洁度
14. 删除被注释掉的代码
这应该不言自明。如果代码被注释掉了,它就只是杂乱的垃圾代码。
15. 仔细思考变量命名
命名是编写优秀代码最困难的方面之一,但它对可读性的提升将产生奇效。
一些提示:
- 避免使用像“user”这样的“通用名词”。要更加精确,例如“admin”、“buyer”、“seller”。
- “data”一词通常是含糊不清的标志。用“userAccount”来代替“userData”。
- 不要用两个不同的名词来指代同一个现实世界中的实体。例如,如果“depositor”和“liquidityProvider”在现实中指的是同一实体,请坚持使用其中一个术语,不要在代码中混合使用。
- 在变量名中包含单位。使用“interestRatesBasisPoints”或“feeInWei”来代替“interestRate”。
- 改变状态的函数名中应该包含一个动词。
- 在使用下划线区分 internal 变量和函数,以及覆盖状态变量的函数参数时,要保持一致。如果变量名前加下划线代表“internal”,请确保它在其他上下文中不会被用来表示其他含义(例如,与状态变量同名的函数参数)。
- 使用“get”来查看数据,使用“set”来改变数据是广泛遵循的编程惯例。考虑将其纳入你的代码中。
- 写完代码后,离开电脑,15 分钟后再回来,问问自己每个变量和函数的命名是否尽可能精确。这种刻意练习会比任何清单对你更有帮助,因为你比任何人都更清楚代码库的意图。
组织大型代码库的其他技巧
- 如果你有大量的 storage 变量,你可以将所有的 storage 变量定义在一个单一的合约中,然后通过继承该合约来访问这些 storage 变量。
- 如果你的函数需要传递大量参数,请使用 struct 来传递这些信息。
- 如果你需要大量的导入,你可以将所有的文件和类型导入到一个 Solidity 文件中,然后再导入该文件(这需要你有意打破关于命名导入的规则)。
- 使用库(libraries)将同一类别的函数分组在一起,从而缩小文件体积。
组织大型代码库是一门艺术。最好的学习方法是研究成熟的大型项目代码库。
通过 RareSkills 了解更多
这份清单被用于我们高级 Solidity bootcamp 的代码审查中。
首发于 2024 年 8 月 12 日