#OneSwap系列五之如何组织代码

.png

本文将结合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编译器会对pureview函数进行检查,如果是外部函数调用,还会使用STATICCALL指令在EVM层面进行保护,详见Solidity文档。只有payable函数才可以接收Ether转账,编译器会为payable函数插入特殊的检查逻辑。下表对状态修读写饰符进行了总结:

修饰符\读写权 是否可以读取状态 是否可以修改状态 是否可以接收Ether
pure
view
payable

对于pureview函数这里就不再展开讨论了,下面通过一个简单的合约来观察编译器为非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),分别对应动态和静态多态。有两个函数修饰符和多态相关:virtualoverride。规则也很简单:只有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指令。

由于库主要是实现逻辑,没有自己的内部状态。所以通常只需要部署一次,其他合约链接到部署好的库上即可。不过只有publicexternal函数才需要链接,对于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,以及两个合约LibDemo1LibDemo2LibDemo1调用了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

你可能感兴趣的:(#OneSwap系列五之如何组织代码)