继承与派生

继承与派生是面向对象编程的基本概念。在 Solidity 中,面向的是“合约”。

继承是指子类拥有父类的特性,而派生指子类在继承父类特性的基础上,对其进行限制或扩展。

为了方便,当合约 B 继承合约 A,我们称合约B是合约A的派生合约。

需要注意,当合约 B 被部署到以太坊网络上时,A 中的相关代码只会被编译打包入 B 中,而不会跟着一起被部署。

合约继承的父类通过 is 关键字指出:

Animal.sol
运行
复制
// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.7.1 <0.9.0;

contract Animal {
    function run() public {}
}

// Man 继承 Animal 合约
contract Man is Animal {
    // Man 新增 think 函数
    function think() public {}
}

contract Test {
    function test() public {
        // Animal 可调用 run
        Animal a = new Animal();
        a.run();
        // Man 可调用继承自 Animal 的 run 函数
        Man man = new Man();
        man.run();
        // 以及自己新增的 think 函数
        man.think();
    }
}

构造函数

当合约实例化时,父合约的构造函数会先被调用,然后再调用当前合约的:

Constructor.sol
运行
复制
// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.7.1 <0.9.0;

contract Base {
    uint x;

    constructor() {
        x += 1;
    }
}

contract C is Base {
    constructor() {
        // Base.constructor 先调用过,x 值最终为 3
        x += 2;
    }
}

如果父合约的构造函数需要给出参数,则子合约如果使用常量给出,则可以在 is 处给出:

ConstructorLiteralArg.sol
运行
复制
// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.7.1 <0.9.0;

contract Base {
    uint x;

    constructor(uint x_) {
        x = x_;
    }
}

// Base(5) 给出 Base 构造函数参数
contract C is Base(5) {
    constructor() {
        // Base.constructor 先调用过,x 值最终为 7
        x += 2;
    }
}

子合约不确定,则可以选择传递参数:

ConstructorArgPass.sol
运行
复制
// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.7.1 <0.9.0;

contract Base {
    uint x;

    constructor(uint x_) {
        x = x_;
    }
}

contract C is Base {
    // Base(x_) 传递参数给 Base 构造函数
    constructor(uint x_) Base(x_) {
        // Base.constructor 先调用过,x 值最终为 x_ + 2
        x += 2;
    }
}

如果子合约不给出父合约构造函数参数,则必须标识子合约为抽象合约。实现该抽象合约的合约仍需要给出父合约的构造函数参数:

ConstructorAbstractContract.sol
运行
复制
// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.7.1 <0.9.0;

contract Base {
    uint x;

    constructor(uint x_) {
        x = x_;
    }
}

// 未给出 Base 构造函数参数,必须标识为抽象函数
abstract contract AbstractBase is Base {
    constructor() {
        x += 2;
    }
}

contract Implement1 is AbstractBase {
    // 给出祖先合约 Base 的构造函数参数
    constructor() Base(7) {}
}

constructor 函数只有在部署合约时被调用一次。部署完成之后,只有外部函数,以及外部函数依赖的内部函数代码会被保留。其余代码将不会保留在以太坊区块链上,包括构造函数,此举可节约部署成本。

函数重写

函数重写,即 function override

对于一个基础合约,或者一个抽象合约,可能会使用一个带有默认实现的虚函数(virtual function)。例如下例中,Greeting 类有一个虚函数 sayHi,默认实现是使用英文,返回字符串 Hi

Override.sol
运行
复制
// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.7.1 <0.9.0;

contract Greeting {
    // virtual 标志的虚函数,带有默认实现
    function sayHi() public virtual returns (string memory) {
        return "Hi!";
    }
}

contract ChineseGreeting is Greeting {
    function sayHi() public pure override returns (string memory) {
        return unicode"你好!";
    }
}

我们新建一个合约中文版的 Greeting 合约,继承于 Greeting 合约,需要重写覆盖掉原来的 sayHi 默认实现,此时可以使用关键字 override

Override.sol
运行
复制
// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.7.1 <0.9.0;

contract Greeting {
    // virtual 标志的虚函数,带有默认实现
    function sayHi() public virtual returns (string memory) {
        return "Hi!";
    }
}

contract ChineseGreeting is Greeting {
    function sayHi() public pure override returns (string memory) {
        return unicode"你好!";
    }
}

我们称以上过程为 函数重写

需要指出的是,一旦虚函数被另一个非虚函数重写后,该函数就不能再被子合约重写了,例如下面的 ChinesePolitelyGreetingsayHi 的重写是非法的:

Override2.sol
运行
复制
contract ChineseGreeting is Greeting {
    function sayHi() public pure override returns (string memory) {
        return unicode"你好!";
    }
}

contract ChinesePolitelyGreeting is ChineseGreeting {
    // sayHi 函数已经被 ChineseGreeting.sayHi 重写,因此不能再次重写
    function sayHi() public pure override returns (string memory) {
        return unicode"您好!";
    }
}

这是因为 override 只能重写 virtual 虚函数。而 ChineseGreeting.sayHi 并非虚函数。如果我们把 ChineseGreeting.syaHi 也标识为 virtual 虚函数,则上面的过程又变为合法:

Override3.sol
运行
复制
// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.7.1 <0.9.0;

contract Greeting {
    // virtual 标志的虚函数,带有默认实现
    function sayHi() public virtual returns (string memory) {
        return "Hi!";
    }
}

contract ChineseGreeting is Greeting {
    // 虚函数,且重写了 Greeting.sayHi
    function sayHi() public pure virtual override returns (string memory) {
        return unicode"你好!";
    }
}

contract ChinesePolitelyGreeting is ChineseGreeting {
    // 不是虚函数,重写了 ChineseGreeting.sayHi
    function sayHi() public pure override returns (string memory) {
        return unicode"您好!";
    }
}

外部虚函数重写

另外一个有趣的例子是外部虚函数(external virtual),可以被一个同类型的 public 状态变量重写:

ExternalOverride.sol
运行
复制
// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.7.1 <0.9.0;

contract Base {
    function zero() external virtual returns (uint256) {
        return 0;
    }
}

contract C is Base {
    // 用一个 public override 重写
    uint256 public override zero = 1;
}

这是因为 Solidity 会为公共状态变量新增一个同名的 getter 函数,该 getter 函数也是一个外部函数,且返回类型也为 uint256。因此 zero 外部函数将被这个同名 getter 函数重写。

函数修饰符重写

与函数类似,虚函数修饰符也可以被重写:

ModifierOverride.sol
运行
复制
// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.7.1 <0.9.0;

contract Base {
  // 函数体运行五次的修饰符
  modifier runFiveTime() virtual {
    for(uint i = 0; i < 5; i++) _;
  }
}

contract C is Base {
  // 重写 Base.runFiveTime 函数修饰符
  modifier runFiveTime() override {
    _; _; _; _; _;
  }
}

多重继承

Solidity 支持合约的多重继承。考虑下面的多重继承图:

多重继承

多重继承中,存在两个容易混乱的点,需要在此处阐述清楚:

  1. 多重继承时,如何确定构造函数的调用顺序?是先初始化 A 还是先初始化 B
  2. 钻石问题:如果 A B 都定义了函数 f,在 C 处调用 f 时,调用的是 A.f 还是 B.f

这两个问题本质原因是多重继承导致继承图已经不是一条线了,例如,上面的钻石形继承图。因此,Solidity 使用了与 Python 一样的方法,即通过 C3线性化,将继承关系图转换成一条直线。

下面我们以上面两个问题为例,来看 C3线性化 在多重继承中的应用。

多重继承的构造函数调用顺序

我们使用一个复杂的例子:

多重继承

对应的 Solidity 代码如下:

MultipleInheritanceConstructor.sol
运行
复制
// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.7.1 <0.9.0;

contract O {
    string[] public seq;

    constructor() {
        seq.push("O");
    }

    function getCallSeq() public view returns (string memory) {
        string memory result;
        for (uint256 i = 0; i < seq.length; i++)
            result = string.concat(result, seq[i], i + 1 < seq.length ? "=>" : "");
        return result;
    }
}

contract A is O {
    constructor() {
        seq.push("A");
    }
}

contract B is O {
    constructor() {
        seq.push("B");
    }
}

contract C is O {
    constructor() {
        seq.push("C");
    }
}

contract D is O {
    constructor() {
        seq.push("D");
    }
}

contract E is O {
    constructor() {
        seq.push("E");
    }
}

contract K1 is A, B, C {
    constructor() {
        seq.push("K1");
    }
}

contract K2 is D, B, E {
    constructor() {
        seq.push("K2");
    }
}

contract K3 is D, A {
    constructor() {
        seq.push("K3");
    }
}

contract Z is K1, K2, K3 {
    constructor() {
        seq.push("Z");
    }
}

我们使用一个字符串数组 seq 来保存构造函数的调用过程。

在上例中,Z 继承于 K1, K2, K3。其中 K1, K2, K3 的顺序是很重要的。Solidity 认为在最左边的是最基础的类,在最右边的是与当前派生类最相近的类。

那么,构造函数的调用过程就遵循这样一个原则:

初始化原则

左边更基础的类必须先于右边的类提前初始化

例如,在上例中,部署合约之后,调用 getCallSeq 函数可以查看调用顺序结果为 O=>D=>A=>B=>C=>K1=>E=>K2=>K3=>Z

我们来分析一下为什么是这个调用过程:

  1. 首先,K1, K2, K3 表明必须先初始化 K1
  2. K1 又继承于 A, B, C。但是此时如果直接初始化 A,则违反了初始化原则,因为在 K3 is D, A 中,表明 DA 更基础,因此 D 应当比 A 先初始化。
  3. 因此我们应当先初始化 D,但是 D 继承于 O,因此先初始化 O
  4. 初始化完 D 之后,A 可以被接着初始化,而不违反 初始化原则
  5. 初始化 B
  6. 初始化 C
  7. 此时 A, B, C 已经按顺序初始化,接着 K1 可以初始化了
  8. 接着要初始化 K2,而 K2 is D, B, E,由于 D B 已被初始化,跳过,接着初始化 E
  9. 此时 D, B, E 已经按顺序初始化,接着 K2 可以初始化了
  10. D, A 已经按顺序初始化,接着 K3 可以初始化了
  11. 最后,K1, K2, K3 已经按顺序初始化,接着初始化 Z,初始化过程完成

以上过程得到顺序:O=>D=>A=>B=>C=>K1=>E=>K2=>K3=>Z,即是我们上面提到的C3线性化的结果。

Solidity 的构造函数调用顺序,按以上顺序进行。

钻石问题

首先,我们来看一下一个子类,如何调用一个父类同名函数:

Diamond.sol
运行
复制
// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.7.1 <0.9.0;


contract Base {
    string[] public seq;

    // 获取记录的调用过程
    function getCallSeq() public view returns (string memory) {
        ...
    }

    function clean() public virtual {
        // 做一些 Base 相关的清理工作
        seq.push("Base");
    }
}

contract Base1 is Base {
    function clean() public virtual override {
        // 做一些 Base1 相关的一些清理工作
        seq.push("Base1");
        // 调用父类,进行父类清理工作
        // 直接使用父类的名称来调用,好像没有什么问题?
        Base.clean();
    }
}

Base 类的 clear 函数会做一些 Base 自己相关的清理工作,而 Base1 继承 Base,它本身也需要做一些与 Base1 相关的清理工作。

因此,Base1 做完自己的清理工作后,应当调用父类的 clear 函数,以进行 Base 相关的清理工作。

在上例中,直接调用 Base.clean() 似乎没有什么问题。但是,这种方式仅仅是在线性的继承关系下,才能正常进行。当出现多重继承时,就会出现问题:

Diamond.sol
运行
复制
// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.7.1 <0.9.0;


contract Base {
    ...

    function clean() public virtual {
        // 做一些 Base 相关的清理工作
        seq.push("Base");
    }
}

contract Base1 is Base {
    function clean() public virtual override {
        // 做一些 Base1 相关的一些清理工作
        seq.push("Base1");
        // 调用父类,进行父类清理工作
        // 直接使用父类的名称来调用,好像没有什么问题?
        Base.clean();
    }
}

contract Base2 is Base {
    function clean() public virtual override {
        // 做一些 Base2 相关的一些清理工作
        seq.push("Base2");
        // 调用父类,进行父类清理工作
        // 直接使用父类的名称来调用,好像没有什么问题?
        Base.clean();
    }
}

contract C is Base1, Base2 {
    function clean() public override(Base1, Base2) {
        // 做一些清理工作
        seq.push("C");
        // 分别调用父类的 clean 函数
        // 岂不是会导致 Base.clean() 被调用了两次?
        Base2.clean();
        Base1.clean();
    }
}

C 继承于 Base1 Base2 时,如果分别调用 Base2.clean() Base1.clean(),将导致 Base.clean() 被调用两次。

我们称这种多重继承导致的问题,称为钻石问题。解决这个问题的方法,仍然是 C3线性化,使用 super 关键字来代替具体的父类:

Diamond2.sol
运行
复制
// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.7.1 <0.9.0;


contract Base {
    string[] public seq;
    // 获取记录的调用过程
    function getCallSeq() public view returns (string memory) {
        ...
    }

    function clean() public virtual {
        // 做一些 Base 相关的清理工作
        seq.push("Base");
    }
}

contract Base1 is Base {
    function clean() public virtual override {
        // 做一些 Base1 相关的一些清理工作
        seq.push("Base1");
        // 调用父类,进行父类清理工作
        // super 不一定是 Base 类,而是C3线性化后,位于 Base1 前面的类
        super.clean();
    }
}

contract Base2 is Base {
    function clean() public virtual override {
        // 做一些 Base2 相关的一些清理工作
        seq.push("Base2");
        // 调用父类,进行父类清理工作
        // super 不一定是 Base 类,而是C3线性化后,位于 Base1 前面的类
        super.clean();
    }
}

contract C is Base1, Base2 {
    function clean() public override(Base1, Base2) {
        // 做一些清理工作
        seq.push("C");
        // 分别调用父类的 clean 函数
        // super 类是 Base1,还是 Base2?
        super.clean();
    }
}

以上代码,部署合约 C 后,调用 getCallSeq 将得到调用过程为:C=>Base2=>Base1=>Base。该过程即是 C3线性化 后的结果。

对于 C 而言,其 super 父类为 Base2,这是因为在 C is Base1, Base2 中,Base1 更基础,而 Base2C 更接近,因此 C 中的 superBase2

类似的,Base2 中的 super 则为 Base1Base1 中的 super 则为 Base

我们再举一个上一节中的复杂例子:

多重继承

在该多重继承下,我们调用 Z.clean(),将按什么顺序调用?

Diamond3.sol
运行
复制
// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.7.1 <0.9.0;

contract O {
    string[] public cleanSeq;

    // 获取记录的调用顺序
    function getCleanSeq() public view returns (string memory) {
        string memory result;
        for (uint256 i = 0; i < cleanSeq.length; i++)
            result = string.concat(result,cleanSeq[i], i + 1 < cleanSeq.length ? "=>" : "");
        return result;
    }

    function clean() public virtual {
        cleanSeq.push("O");
    }
}

contract A is O {
    function clean() public virtual override {
        cleanSeq.push("A");
        super.clean();
    }
}

contract B is O {
    function clean() public virtual override {
        cleanSeq.push("B");
        super.clean();
    }
}

contract C is O {
    function clean() public virtual override {
        cleanSeq.push("C");
        super.clean();
    }
}

contract D is O {
    function clean() public virtual override {
        cleanSeq.push("D");
        super.clean();
    }
}

contract E is O {
    function clean() public virtual override {
        cleanSeq.push("E");
        super.clean();
    }
}

contract K1 is A, B, C {
    function clean() public virtual override(A, B, C) {
        cleanSeq.push("K1");
        super.clean();
    }
}

contract K2 is D, B, E {
    function clean() public virtual override(D, B, E) {
        cleanSeq.push("K2");
        super.clean();
    }
}

contract K3 is D, A {
    function clean() public virtual override(D, A) {
        cleanSeq.push("K3");
        super.clean();
    }
}

contract Z is K1, K2, K3 {
    function clean() public override(K1, K2, K3) {
        cleanSeq.push("Z");
        super.clean();
    }
}

部署合约 Z 后,调用 clean 函数后,调用 getCleanSeq 获取记录的调用顺序,结果为:Z=>K3=>K2=>E=>K1=>C=>B=>A=>D=>O

我们对比上一节多重继承构造函数的调用顺序:O=>D=>A=>B=>C=>K1=>E=>K2=>K3=>Z

我们会发现,它们的顺序是相反的。这是因为构造函数需要从最基础的类,一路向下,一直初始化到最相近的类,最后才调用 Z 的构造函数,是从上到下的过程。

super 调用,是从 Z.clean 的调用开始,再到最相近的类,一路向上,一直调用到最基础的类,是从下到上的过程。

总之,Solidity 使用 C3线性化 将多重继承关系,压缩成线性关系,以解决调用链的问题。