左边更基础的类必须先于右边的类提前初始化
继承与派生
继承与派生是面向对象编程的基本概念。在 Solidity
中,面向的是“合约”。
继承是指子类拥有父类的特性,而派生指子类在继承父类特性的基础上,对其进行限制或扩展。
为了方便,当合约 B
继承合约 A
,我们称合约B
是合约A
的派生合约。
需要注意,当合约 B
被部署到以太坊网络上时,A
中的相关代码只会被编译打包入 B
中,而不会跟着一起被部署。
合约继承的父类通过 is
关键字指出:
// 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();
}
}
构造函数
当合约实例化时,父合约的构造函数会先被调用,然后再调用当前合约的:
// 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
处给出:
// 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;
}
}
子合约不确定,则可以选择传递参数:
// 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;
}
}
如果子合约不给出父合约构造函数参数,则必须标识子合约为抽象合约。实现该抽象合约的合约仍需要给出父合约的构造函数参数:
// 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
:
// 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
:
// 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"你好!";
}
}
我们称以上过程为 函数重写
。
需要指出的是,一旦虚函数被另一个非虚函数重写后,该函数就不能再被子合约重写了,例如下面的 ChinesePolitelyGreeting
中 sayHi
的重写是非法的:
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
虚函数,则上面的过程又变为合法:
// 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
状态变量重写:
// 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
函数重写。
函数修饰符重写
与函数类似,虚函数修饰符也可以被重写:
// 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
支持合约的多重继承。考虑下面的多重继承图:
多重继承中,存在两个容易混乱的点,需要在此处阐述清楚:
- 多重继承时,如何确定构造函数的调用顺序?是先初始化
A
还是先初始化B
? 钻石问题
:如果A
B
都定义了函数f
,在C
处调用f
时,调用的是A.f
还是B.f
?
这两个问题本质原因是多重继承导致继承图已经不是一条线了,例如,上面的钻石形继承图。因此,Solidity
使用了与 Python
一样的方法,即通过 C3线性化
,将继承关系图转换成一条直线。
下面我们以上面两个问题为例,来看 C3线性化
在多重继承中的应用。
多重继承的构造函数调用顺序
我们使用一个复杂的例子:
对应的 Solidity
代码如下:
// 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
。
我们来分析一下为什么是这个调用过程:
- 首先,
K1, K2, K3
表明必须先初始化K1
K1
又继承于A, B, C
。但是此时如果直接初始化A
,则违反了初始化原则
,因为在K3 is D, A
中,表明D
比A
更基础,因此D
应当比A
先初始化。- 因此我们应当先初始化
D
,但是D
继承于O
,因此先初始化O
。 - 初始化完
D
之后,A
可以被接着初始化,而不违反初始化原则
- 初始化
B
- 初始化
C
- 此时
A, B, C
已经按顺序初始化,接着K1
可以初始化了 - 接着要初始化
K2
,而K2 is D, B, E
,由于D
B
已被初始化,跳过,接着初始化E
- 此时
D, B, E
已经按顺序初始化,接着K2
可以初始化了 D, A
已经按顺序初始化,接着K3
可以初始化了- 最后,
K1, K2, K3
已经按顺序初始化,接着初始化Z
,初始化过程完成
以上过程得到顺序:O=>D=>A=>B=>C=>K1=>E=>K2=>K3=>Z
,即是我们上面提到的C3线性化
的结果。
Solidity
的构造函数调用顺序,按以上顺序进行。
钻石问题
首先,我们来看一下一个子类,如何调用一个父类同名函数:
// 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()
似乎没有什么问题。但是,这种方式仅仅是在线性的继承关系下,才能正常进行。当出现多重继承时,就会出现问题:
// 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
关键字来代替具体的父类:
// 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
更基础,而 Base2
与 C
更接近,因此 C
中的 super
为 Base2
。
类似的,Base2
中的 super
则为 Base1
,Base1
中的 super
则为 Base
。
我们再举一个上一节中的复杂例子:
在该多重继承下,我们调用 Z.clean()
,将按什么顺序调用?
// 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线性化
将多重继承关系,压缩成线性关系,以解决调用链的问题。