本文将结合OneSwap项目介绍如何组织Solidity源代码,我们将深入讨论Solidity语言所支持的各种“面向对象”特性以及库的用法,并详细介绍各种函数修饰符。
标准目录结构
OneSwap项目使用了Truffle作为开发和测试工具,因此整体的目录结构也沿用了Truffle的约定:Solidity源代码都在contracts子目录内、部署脚本在migrations子目录内、外部脚本在scripts子目录内、单元测试在test子目录内。其中contracts子目录里面又有两个子目录:所有的接口都在interfaces子目录下、所有的库在libraries子目录下。下面是OneSwap项目的整体目录结构(只展示了部分目录和文件):
oneswap/
├── contracts/
│ ├── interfaces/
│ │ ├── IERC20.sol
│ │ ├── IOneSwapFactory.sol
│ │ ├── IOneSwapPair.sol
│ │ └── ...
│ ├── libraries/
│ │ ├── OneSwapPair.sol
│ │ └── ...
│ ├── OneSwapFactory.sol
│ ├── OneSwapPair.sol
│ └── ...
├── migrations/
├── scripts/
├── test/
│ ├── OneSwapFactory.js
│ ├── OneSwapPair.js
│ └── ...
└── truffle-config.js
“面向对象”
Solidity虽然并非是严格意义上的面向对象编程(Object Oriented Programming,下文简称OOP)语言,但是在很多方面都借鉴了传统OOP语言的概念和写法。这一小节将介绍传统OOP语言的一些概念,以及这些概念在Solidity语言中的用法和实现方式。
我们都知道,类(Class)是传统OOP语言中的核心抽象单位。类的内部可以定义一系列状态变量(通常叫做字段,Field),以及操纵这些状态的方法(Method)。类只是一个模版,必须要实例化为对象(Object)之后才能使用,而对象的初始状态由构造函数(Constructor)确定。为了重用已有逻辑,避免重复代码,类可以组织成复杂的继承层次结构,子类可以继承超类的状态和方法。为了提高代码可读性和可维护性,类通常会将状态隐藏(封装)起来,对外只公布具有良好行为的方法。
Solidity语言很好的借鉴了上面这些OOP概念。例如,我们使用Solidity语言中定义合约(Contract),其概念大致相当于OOP中的类。合约中可以定义状态变量,可以定义操作状态变量的函数,还可以限制状态变量和函数的可见性。和类一样,合约也可以形成继承层次结构。不仅如此,在Solidity中我们还可以定义接口和抽象合约。除了概念上的相似性,Solidity语言还从直接从传统OOP语言里借鉴了很多语法。正因如此,熟悉其他OOP语言(例如C++、Java、JS等)的程序员很容易上手Solidity语言,写起合约来也会感觉非常自然。
由于OOP的大部分概念已经被大家所熟知,所以本文就不再深入解释这些概念了。下面结合OneSwap项目源代码介绍一下这些概念在Solidity中的用法和实现原理。
继承
除了单继承,Solidity还支持接口、抽象合约和多继承。如果A合约继承了B合约,那么我们称A合约为基础合约(Base Contract),称B合约为派生合约(Derived Contract)。派生合约将继承基础合约的全部状态变量和函数,但是只有非private状态和函数在派生合约内可见。我们将在介绍封装时详细解释函数的可见性修饰符。我们都知道,多继承会带来一些问题,例如最著名的钻石问题。和Python类似,Solidity也使用了C3线性化算法来决定继承顺序,因此声明派生合约时给定的基础合约排列顺序非常重要。总之,如果可能,应该尽量避免使用多继承。这里就不再展开讨论多继承了,具体可以参考Solidity文档。
OneSwap并没有使用多继承,但是有多处使用了接口、抽象合约和单继承。以OneSwapPair
合约为例,该合约主要提供了限价单交易逻辑,内部维护了买和卖两个订单薄,而自动作市(AMM)相关的状态和逻辑则继承自OneSwapPool
抽象合约。OneSwapPool
抽象合约则从OneSwapERC20
抽象合约继承了ERC20代币相关的状态和逻辑,这三个合约又都分别实现了各自功能所对应的接口。下面是这三个合约的定义代码(具体实现省略):
interface IOneSwapERC20 { ... }
interface IOneSwapPool { ... }
interface IOneSwapPair { ... }
abstract contract OneSwapERC20 is IOneSwapERC20 { ... }
abstract contract OneSwapPool is OneSwapERC20, IOneSwapPool { ... }
contract OneSwapPair is OneSwapPool, IOneSwapPair { ... }
下面的UML类图展示了整个OneSwapPair
合约的继承层次体系:
+---------------+
| IOneSwapERC20 |
+---------------+
△
|
+------------------+
|
+--------------+ +--------------+
| OneSwapERC20 | | IOneSwapPool |
+--------------+ +--------------+
△ △
| |
+------------------+
|
+--------------+ +--------------+
| OneSwapPool | | IOneSwapPair |
+--------------+ +--------------+
△ △
| |
+------------------+
|
+--------------+
| OneSwapPair |
+--------------+
封装
和传统OOP语言一样,Solidity语言也提供了可见性修饰符(Visibility Modifier),用来限制状态变量和函数的可见性。由于智能合约的特殊性,Solidity还提供了状态读写修饰符(Mutability Modifier),用来限制函数对于状态变量的读写,以及是否可以接收Ether付款。
可见性修饰符共4个,既可以修饰状态变量(external
除外),也可以修饰函数。这里只说明几点。第一,对于public
状态变量,Solidity编译器会自动为其生成Getter函数,因此会导致编译后的字节码体积变大。第二,external
修饰符只能用于函数,且只能通过发送消息的方式调用,具体可以参考Solidity文档。下表对这四个可见性修饰符进行了总结:
修饰符\可见性 | 合约 | 派生合约 | 其他合约 |
---|---|---|---|
private | ✓ | ✗ | ✗ |
internal | ✓ | ✓ | ✗ |
public | ✓ | ✓ | ✓ |
external | ✓(via message) | ✓(via message) | ✓ |
状态修读写饰符共3个,只能修饰函数。默认情况下,函数对状态变量有读写权。但pure
函数无法读取任何状态变量,而view
函数则无法改写状态变量。Solidity编译器会对pure
和view
函数进行检查,如果是外部函数调用,还会使用STATICCALL
指令在EVM层面进行保护,详见Solidity文档。只有payable
函数才可以接收Ether转账,编译器会为payable
函数插入特殊的检查逻辑。下表对状态修读写饰符进行了总结:
修饰符\读写权 | 是否可以读取状态 | 是否可以修改状态 | 是否可以接收Ether |
---|---|---|---|
pure | ✗ | ✗ | ✗ |
view | ✓ | ✗ | ✗ |
✓ | ✓ | ✗ | |
payable | ✓ | ✓ | ✓ |
对于pure
和view
函数这里就不再展开讨论了,下面通过一个简单的合约来观察编译器为非payable
函数插入的代码:
pragma solidity =0.6.12;
contract PayableDemo {
function f1() external {}
function f2() payable external {}
}
下面是编译后运行时字节码的反汇编结果。可以看到,编译器在进入非payable
函数之前检查了msg.value
的值,从而达到了限制函数是否可接收Ether的效果。
contract disassembler {
function f2() public return () { return(); }
function f1() public return () { return(); }
function main() public return () {
mstore(0x40,0x80);
if ((msg.data.length < 0x4)) {
label_00000026:
revert(0x0,0x0);
} else {
var0 = SHR(0xE0, msg.data(0x0));
if ((0x9942EC6F == SHR(0xE0,msg.data(0x0)))) { //ISSUE:COMMENT: Function f2()
f2();
stop();
} else if ((0xC27FC305 == var0)) { //ISSUE:COMMENT: Function f1()
require(!msg.value);
f1();
stop();
} else {
goto label_00000026;
}
}
}
}
多态
Solidiy语言支持函数的重写(Override)和重载(Overload),分别对应动态和静态多态。有两个函数修饰符和多态相关:virtual
和override
。规则也很简单:只有virtual
函数才可以在派生合约中被重写、只有override
函数才可以重写基合约中的函数。这些都比较好理解,值得说明的是,Solidity专门提供了一个语法糖,使得public
状态变量也可以使用override
修饰符。此时override
修饰符会被应用在编译器生成的getter函数上。
OneSwap项目多处使用了继承、封装、多态等OOP特性,接口实现、方法重写等也是随处可见,这里就不一一举例了。下面仅以OneSwapFactory
合约为例,展示override
修饰符在函数上的使用:
contract OneSwapFactory is IOneSwapFactory {
struct TokensInPair {
address stock;
address money;
}
address public override feeTo;
address public override feeToSetter;
address public immutable gov;
address public immutable ones;
uint32 public override feeBPS = 50;
address public override pairLogic;
mapping(address => TokensInPair) private _pairWithToken;
mapping(bytes32 => address) private _tokensToPair;
address[] public allPairs;
... // 函数省略
}
库的使用
除了继承,Solidity语言还允许我们通过库(Library)的方式来复用代码。库是一种特殊的合约,通过library
关键字定义。库不允许定义状态变量、实现接口、继承其他合约、定义payable和fallback函数。此外,调用库的external
函数时,编译器会生成DELEGATECALL
指令。
由于库主要是实现逻辑,没有自己的内部状态。所以通常只需要部署一次,其他合约链接到部署好的库上即可。不过只有public
和external
函数才需要链接,对于internal
函数编译器会进行内联优化。下面通过一个例子来演示库的使用:
pragma solidity =0.6.12;
library MyLib {
function x1234(uint256 n) internal pure returns (uint256) {
return n * 0x1234;
}
function x5678(uint256 n) public pure returns (uint256) {
return n * 0x5678;
}
}
contract LibDemo1 {
function test(uint256 n) public pure returns (uint256) {
return MyLib.x1234(n);
}
}
contract LibDemo2 {
function test(uint256 n) public pure returns (uint256) {
return MyLib.x5678(n);
}
}
我们定义了一个库MyLib
,以及两个合约LibDemo1
和LibDemo2
。LibDemo1
调用了MyLib的internal
函数,LibDemo1
调用了MyLib的public
函数。编译库和合约,并观察编译器生成的运行时字节码。可以看到,LibDemo1
的运行时字节码并没有什么特殊之处。反汇编后是下面这样:
contract disassembler {
function test( uint256 arg0) public return (var0) {
var4 = func_0000007C(arg0);
return(var4);
}
function func_0000007C( uint256 arg0) private return (var0) {
return((arg0 * 0x1234));
}
function main() public return () {
... // 代码省略
}
}
可见,编译器的确对库的internal
函数进行了优化处理,将其编译进了合约里。但是LibDemo2
的运行时字节码就有点奇怪了,里面出现了__MyLib_________________________________
:
0x6080604052348015600f57600080fd5b506004361060285760003560e01c806329e99f0714602d575b600080fd5b605660048036036020811015604157600080fd5b8101908080359060200190929190505050606c565b6040518082815260200191505060405180910390f35b600073__MyLib_________________________________63412dab59836040518263ffffffff1660e01b81526004018082815260200191505060206040518083038186803b15801560bc57600080fd5b505af415801560cf573d6000803e3d6000fd5b505050506040513d602081101560e457600080fd5b8101908080519060200190929190505050905091905056fea26469706673582212202f6ad810ce4f7299f7a53cc1cd3ecec4bcf4f366145d0bf4b5db4c537c153a9d64736f6c634300060c0033
由于__MyLib_________________________________
并不是合法的opcode,所以是没办法反汇编的。不过我们也很容易猜到,这是编译器生成的占位符,表示MyLib的实际部署地址。Solidity编译器(solc)提供了--link
选项,可以进入链接模式。该模式下可以通过--libraries
选项指定库的实际部署地址(例如--libraries MyLib:0xADD...
),对占位符进行替换,这里就不展开讨论了。
OneSwap项目中一共定义了4个库。其中SafeMath256
库用于安全数学计算,Math
库定义了min
等函数,DecFloat32
库定义Pair合约所使用的了32位浮点数,ProxyData
库封装了“immutable forwarding”模式所需要的代理数据计算逻辑。它们所提供的函数,均为internal类型。关于这些库的代码这里也不再详细介绍了,有兴趣的读者可以阅读它们的源代码。
总结
本文介绍了Solidity项目的源代码组织形式、Solidity所支持的OOP特性、各种类型的合约(接口、抽象合约、普通合约、库)、各种修饰符(可见性修饰符、状态读写修饰符、多态修饰符),以及库的使用。合理的组织源代码文件,并使用以上介绍的这些OOP特性,可以极大的提高智能合约项目的代码可读性和可复用性。在后面的文章中,我们会对EVM存储、Solidity数据结构、Solidity设计模式等进行更多介绍。
原文:《OneSwap Series 5 - How to Organize the Code》
链接:https://medium.com/@OneSwap/oneswap-series-5-how-to-organize-the-code-ca3d1f047ddf
翻译:OneSwap中文社区
主要参考资料
Solidity and object oriented programming (OOP)
Library Driven Development in Solidity