Solidity继承
继承
继承
首先,Solidity 支持多重继承,包括多态性
多态性意味着函数调用总是执行继承层次结构中最新继承合约的合约中的同名函数(和参数类型),但是,必须使用virtual
和override
关键字在层次结构中的每个函数上明确启用
可以通过ContractName.functionName()
明确指定合约,可以在内部调用继承层次结构中更高的函数。或者直接调用继承层次中调用高一级的函数,可以使用super.functionName()
。
一个合约继承自其他合约,在区块链上只会创建一个单一的合约。所有基础合约的代码被编译到创建的合约中。这意味着,对基础合约的所有内部函数的调用都只是使用内部函数调用。
Solidity 的继承系统和 Python 的继承系统非常类似。
下面是详细的继承例子(来自 Solidity 官方文档):
// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.7.0 <0.9.0;
// 这将报告一个由于废弃的 selfdestruct 而产生的警告
contract Owned {
constructor() { owner = payable(msg.sender); }
address payable owner;
}
// 使用 `is` 从另一个合约派生。派生合约可以访问所有非私有成员,
// 包括内部函数和状态变量,但无法通过 `this` 来外部访问。
contract Destructible is Owned {
// 关键字 `virtual` 意味着该函数可以在派生类中改变其行为("重载")。
function destroy() virtual public {
if (msg.sender == owner) selfdestruct(owner);
}
}
// 这些抽象合约仅用于给编译器提供接口。
// 注意函数没有函数体。
// 如果一个合约没有实现所有函数,则只能用作接口。
abstract contract Config {
function lookup(uint id) public virtual returns (address adr);
}
abstract contract NameReg {
function register(bytes32 name) public virtual;
function unregister() public virtual;
}
// 多重继承是可能的。请注意, `Owned` 也是 `Destructible` 的基类,
// 但只有一个 `Owned` 实例(就像 C++ 中的虚拟继承)。
contract Named is Owned, Destructible {
constructor(bytes32 name) {
Config config = Config(0xD5f9D8D94886E70b06E474c3fB14Fd43E2f23970);
NameReg(config.lookup(1)).register(name);
}
// 函数可以被另一个具有相同名称和相同数量/类型输入的函数重载。
// 如果重载函数有不同类型的输出参数,会导致错误。
// 本地和基于消息的函数调用都会考虑这些重载。
// 如果您想重载这个函数,您需要使用 `override` 关键字。
// 如果您想让这个函数再次被重载,您需要再指定 `virtual` 关键字。
function destroy() public virtual override {
if (msg.sender == owner) {
Config config = Config(0xD5f9D8D94886E70b06E474c3fB14Fd43E2f23970);
NameReg(config.lookup(1)).unregister();
// 仍然可以调用特定的重载函数。
Destructible.destroy();
}
}
}
// 如果构造函数接受参数,
// 则需要在声明(合约的构造函数)时提供,
// 或在派生合约的构造函数位置以修饰器调用风格提供(见下文)。
contract PriceFeed is Owned, Destructible, Named("GoldFeed") {
function updateInfo(uint newInfo) public {
if (msg.sender == owner) info = newInfo;
}
// 在这里,我们只指定了 `override` 而没有 `virtual`。
// 这意味着从 `PriceFeed` 派生出来的合约不能再改变 `destroy` 的行为。
function destroy() public override(Destructible, Named) { Named.destroy(); }
function get() public view returns(uint r) { return info; }
uint info;
}
在上面,我们调用 Destructible.destroy()
来 “转发” 销毁请求。 这样做的方式是有问题的,从下面的例子中可以看出:
// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.7.0 <0.9.0;
// 这将报告一个由于废弃的 selfdestruct 而产生的警告
contract owned {
constructor() { owner = payable(msg.sender); }
address payable owner;
}
contract Destructible is owned {
function destroy() public virtual {
if (msg.sender == owner) selfdestruct(owner);
}
}
contract Base1 is Destructible {
function destroy() public virtual override { // 清除操作 1
Destructible.destroy(); }
}
contract Base2 is Destructible {
function destroy() public virtual override { // 清除操作 2
Destructible.destroy(); }
}
contract Final is Base1, Base2 {
function destroy() public override(Base1, Base2) { Base2.destroy(); }
}
调用 Final.destroy()
时会调用最后的派生重载函数 Base2.destroy
, 但是会绕过 Base1.destroy
(就是在这里,会少执行了一个清除操作 1
), 解决这个问题的方法是使用 super
:
// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.7.0 <0.9.0;
// 这将报告一个由于废弃的 selfdestruct 而产生的警告
contract owned {
constructor() { owner = payable(msg.sender); }
address payable owner;
}
contract Destructible is owned {
function destroy() virtual public {
if (msg.sender == owner) selfdestruct(owner);
}
}
contract Base1 is Destructible {
function destroy() public virtual override { // 清除操作 1
super.destroy(); }
}
contract Base2 is Destructible {
function destroy() public virtual override { // 清除操作 2
super.destroy(); }
}
contract Final is Base1, Base2 {
function destroy() public override(Base1, Base2) { super.destroy(); }
}
如果 Base2
调用 super
的函数,它不会简单在其基类合约上调用该函数。 相反,它在最终的继承关系图谱的上一个基类合约中调用这个函数, 所以它会调用 Base1.destroy()
(注意最终的继承序列是——从最远派生合约开始:Final, Base2, Base1, Destructible, ownerd)。 在类中使用 super 调用的实际函数在当前类的上下文中是未知的,尽管它的类型是已知的。 这与普通的虚拟方法查找类似。
函数重载
如果基函数被标记为virtual
,则可以通过继承合约来改变他的行为。被重载的函数必须在函数头中使用override
关键字。
重载函数只能将重载函数的可见性从external
改为public
。
可见性可以按照以下顺序改变为更严格的可变性:
nonpayable
可以被view
和pure
重载。view
可以被pure
重写。payable
是一个例外,不能被改变为任何其他可变性。
下面的例子演示了改变函数可变性和可见性:
// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.7.0 <0.9.0;
contract Base
{
function foo() virtual external view {}
}
contract Middle is Base {}
contract Inherited is Middle
{
function foo() override public pure {}
}
对于多重继承,必须在override
关键字后明确指定定义同一函数的最多派生基类合约。换句话说:必须指定所有定义同一函数的基类合约,并且还没有被另一个基类合约重载(在继承图的某个路径上)。 此外,如果一个合约从多个(不相关的)基类合约上继承了同一个函数,必须明确地重载它。
// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.6.0 <0.9.0;
contract Base1
{
function foo() virtual public {}
}
contract Base2
{
function foo() virtual public {}
}
contract Inherited is Base1, Base2
{
// 派生自多个定义 foo() 函数的基类合约,
// 所以我们必须明确地重载它
function foo() public override(Base1, Base2) {}
}
如果函数被定义在一个共同的基类合约中, 或者在一个共同的基类合约中有一个独特的函数已经重载了所有其他的函数, 则不需要明确的函数重载指定符。
// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.6.0 <0.9.0;
contract A { function f() public pure{} }
contract B is A {}
contract C is A {}
// 无需明确的重载
contract D is B, C {}
更准确地说,如果有一个基类合约是该签名的所有重载路径的一部分, 并且
- 该基类合约实现了该函数,并且从当前合约到该基类合约的任何路径都没有提到具有该签名的函数,
- 或者该基类合约没有实现该函数,并且从当前合约到该基类合约的所有路径中最多只有一个提到该函数, 那么就不需要重载从多个基类合约继承的函数(直接或间接)。
在这个意义上,一个签名的重载路径是一条继承图的路径, 它从所考虑的合约开始,到提到具有该签名的函数的合约结束, 而该签名没有重载。
如果您不把一个重载的函数标记为 virtual
,派生合约就不能再改变该函数的行为。
具有private
可见性的函数不能是virtual
在接口合约之外,没有实现的函数必须被标记为 virtual
。 在接口合约中,所有的函数都被自动视为 virtual
。
从Solidity 0.8.8开始,当重载一个接口函数时, 不需要 override
关键字,除非该函数被定义在多个基础上。
如果函数的参数和返回类型与变量的getter函数匹配,公共状态变量可以重载为外部函数。
// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.6.0 <0.9.0;
contract A
{
function f() external view virtual returns(uint) { return 5; }
}
contract B is A
{
uint public override f;
}
备注
虽然公共状态变量可以重载外部函数,但它们本身不能被重载。
修饰器重载
函数修改器可以相互重载。 这与函数重载的工作方式相同(除了对修改器没有重载)。 virtual
关键字必须用在被重载的修改器上, override
关键字必须用在重载的修改器上:
在多重继承的情况下,必须明确指定所有的直接基类合约。
// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.6.0 <0.9.0;
contract Base1
{
modifier foo() virtual {_;}
}
contract Base2
{
modifier foo() virtual {_;}
}
contract Inherited is Base1, Base2
{
modifier foo() override(Base1, Base2) {_;}
}
构造函数
构造函数是一个用 constructor
关键字声明的可选函数, 它在合约创建时被执行,您可以在这里运行合约初始化代码。
构造函数运行后,合约的最终代码被部署到区块链上。 部署代码的gas花费与代码长度成线性关系。 这段代码包括属于公共接口的所有函数,以及所有通过函数调用可以到达的函数。 但不包括构造函数代码或只从构造函数中调用的内部函数。
如果没有构造函数,合约将假定默认的构造函数, 相当于 constructor() {}
。
如果在构造函数中使用内部参数,合约必须被标记为abstract
因为这些参数不能从外部分配有效值,只能通过派生合约的构造函数来赋值。
以下是一个这样的例子:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
contract InternalData {
uint256 data;
constructor(uint256 _data) {
data = _data;
}
}
abstract contract AbstractContract {
InternalData internal dataContract;
constructor(InternalData _dataContract) {
dataContract = _dataContract;
}
function doSomething() public virtual;
}
contract ConcreteContract is AbstractContract {
constructor(InternalData _dataContract) AbstractContract(_dataContract) {}
function doSomething() public override {
// 使用 dataContract 的方法或属性
}
}
在这个例子中:
InternalData
是一个简单的合约,它存储了一个uint256
数据。AbstractContract
是一个抽象合约,它的构造函数接收一个InternalData
类型的参数。这个参数是一个内部参数,因为InternalData
是合约类型,不能通过常规的交易方式从外部直接赋值。因此,AbstractContract
被标记为abstract
。ConcreteContract
是AbstractContract
的一个具体实现。它在自己的构造函数中接收一个InternalData
实例,并通过调用基合约的构造函数AbstractContract(_dataContract)
将其传递给AbstractContract
。
在这个结构中,AbstractContract
不能被直接部署,因为它需要一个InternalData
实例作为内部参数。只有它的派生合约(如ConcreteContract
)能够被部署,因为它们可以在构造时提供必要的InternalData
实例。
这种模式使得合约的设计更加灵活,能够将一些初始化逻辑留给派生合约来处理,同时确保内部参数(如合约引用)能够安全地初始化。
基本构造函数的参数
所有基类合约的构造函数将按照下面解释的线性化规则被调用。 如果基类合约构造函数有参数,派生合约需要指定所有的参数。 这可以通过两种方式实现:
// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.7.0 <0.9.0;
contract Base {
uint x;
constructor(uint x_) { x = x_; }
}
// 要么直接在继承列表中指定...
contract Derived1 is Base(7) {
constructor() {}
}
// 或者通过派生构造函数的一个 "modifier"
contract Derived2 is Base {
constructor(uint y) Base(y * y) {}
}
// 或者将合约声明为abstract类型……
abstract contract Derived3 is Base {
}
// 并让下一个具体的派生合约对其进行初始化。
contract DerivedFromDerived is Derived3 {
constructor() Base(10 + 10) {}
}
两种方式:
- 直接在继承列表中给出(
is Base(7)
)- 如果构造函数参数是一个常量,并且定义了合约的行为或描述了它,那么第一种方式更方便
- 通过修改器作为派生构造函数的一部分被调用的方式(
Base(_y * _y)
)- 如果基类合约的构造函数参数依赖于派生合约的参数,则必须使用第二种方式。
在两个地方都指定参数是一个错误。
- 如果一个派生合约没有指定其所有基类合约的构造函数的参数,那么它必须被声明为 abstract 类型。
- 在这种情况下, 当另一个合约从它派生时,其他合约的继承列表或构造函数必须为所有没有指定参数的基类合约提供必要的参数 (否则,其他合约也必须被声明为 abstract 类型)。例如,在上面的代码片段中, 可以查看合约
Derived3
和DerivedFromDerived
。
- 在这种情况下, 当另一个合约从它派生时,其他合约的继承列表或构造函数必须为所有没有指定参数的基类合约提供必要的参数 (否则,其他合约也必须被声明为 abstract 类型)。例如,在上面的代码片段中, 可以查看合约
多重继承与线性化
Solidity 借鉴了 Python 的方式并且使用 “C3 线性化“ 强制一个由基类构成的 DAG(有向无环图)保持一个特定的顺序。
必须按照从 “最接近的基类”(most base-like)到 “最远的继承”(most derived)的顺序来指定所有的基类。(说人话就是:最远的父类开始) 注意,这个顺序与Python中使用的顺序相反。
另一种解释是,当一个函数被调用时, 它在不同的合约中被多次定义,给定的基类以深度优先的方式从右到左(Python中从左到右)进行搜索, 在第一个匹配处停止。如果一个基类合约已经被搜索过了,它就被跳过。
在下面的代码中,Solidity 会给出 “Linearization of inheritance graph impossible” 这样的错误。
// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.4.0 <0.9.0;
contract X {}
contract A is X {}
// 这段代码不会编译
contract C is A, X {}
代码编译出错的原因是 C
要求 X
重写 A
(因为定义的顺序是 A, X
), 但是 A
本身要求重写 X
, 这是一种无法解决的冲突。
即:第七行的意思是 A 是 X 的父类(因为 Solidity 多重继承是从基类->子类),但是第五行 A 的定义是 X 是 A 的父类,所以无法编译
由于必须明确地重载一个从多个基类合约继承的函数, 而没有唯一的重载,C3线性化在实践中不是太重要。
继承的线性化特别重要的一个领域是,当继承层次中存在多个构造函数时,也许不那么清楚。 构造函数将总是按照线性化的顺序执行,而不管它们的参数在继承合约的构造函数中是以何种顺序提供的。 比如说:
// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.7.0 <0.9.0;
contract Base1 {
constructor() {}
}
contract Base2 {
constructor() {}
}
// 构造函数按以下顺序执行:
// 1 - Base1
// 2 - Base2
// 3 - Derived1
contract Derived1 is Base1, Base2 {
constructor() Base1() Base2() {}
}
// 构造函数按以下顺序执行:
// 1 - Base2
// 2 - Base1
// 3 - Derived2
contract Derived2 is Base2, Base1 {
constructor() Base2() Base1() {}
}
// 构造函数仍按以下顺序执行:
// 1 - Base2
// 2 - Base1
// 3 - Derived3
contract Derived3 is Base2, Base1 {
constructor() Base1() Base2() {}
}
抽象合约
当合约中至少有一个函数没有被实现,或者合约没有为其所有的基本合约构造函数提供参数时, 合约必须被标记为 abstract
。
即使不是这种情况,合约仍然可以被标记为 abstract, 例如,当您不打算直接创建合约时。 抽象(abstract)合约类似于 接口(interface)合约, 但是接口(interface)合约可以声明的内容更加有限。
如下例所示,使用 abstract
关键字来声明一个抽象合约。 注意,这个合约需要被定义为 abstract,因为函数 utterance()
被声明了, 但没有提供实现(没有给出实现体 { }
)。
// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.6.0 <0.9.0;
abstract contract Feline {
function utterance() public virtual returns (bytes32);
}
这样的抽象合约不能被直接实例化。如果一个抽象合约本身实现了所有定义的功能,这也是可以的。 抽象合约作为基类的用法在下面的例子中显示:
// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.6.0 <0.9.0;
abstract contract Feline {
function utterance() public pure virtual returns (bytes32);
}
contract Cat is Feline {
function utterance() public pure override returns (bytes32) { return "miaow"; }
}
如果一个合约继承自一个抽象合约,并且没有通过重写实现所有未实现的函数,那么它也需要被标记为抽象的。
注意,没有实现的函数与函数类型不同,尽管它们的语法看起来非常相似。
没有实现内容的函数的例子(一个函数声明):
function foo(address) external returns (address);
类型为函数类型的变量的声明实例:
function(address) external returns (address) foo;
抽象合约将合约的定义与它的实现解耦,提供了更好的可扩展性和自我记录, 促进了像 模板方法 这样的模式, 并消除了代码的重复。抽象合约的作用与在接口中定义方法的作用相同。 它是抽象合约的设计者说 “我的任何孩子都必须实现这个方法” 的一种方式。
抽象合约不能用一个未实现的virtual函数来重载一个已实现的virtual函数。
接口(interface)合约
接口(interface)合约类似于抽象(abstract)合约,但是它们不能实现任何函数。并且还有进一步的限制:
- 它们不能继承其他合约,但是它们可以继承其他接口合约。
- 在接口合约中所有声明的函数必须是 external 类型的,即使它们在合约中是 public 类型的。
- 它们不能声明构造函数。
- 它们不能声明状态变量。
- 它们不能声明修饰器。
将来可能会解除这些里的某些限制。
接口合约基本上仅限于合约 ABI 可以表示的内容, 并且 ABI 和接口合约之间的转换应该不会丢失任何信息。
接口合约由它们自己的关键字表示:
// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.6.2 <0.9.0;
interface Token {
enum TokenType { Fungible, NonFungible }
struct Coin { string obverse; string reverse; }
function transfer(address recipient, uint amount) external;
}
就像继承其他合约一样,合约可以继承接口合约。
所有在接口合约中声明的函数都是隐式的 virtual
的类型, 任何重载它们的函数都不需要 override
关键字。 这并不自动意味着一个重载的函数可以被再次重载—这只有在重载的函数被标记为 virtual
时才可能。
接口合约可以从其他接口合约继承。这与普通的继承有着相同的规则。
// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.6.2 <0.9.0;
interface ParentA {
function test() external returns (uint256);
}
interface ParentB {
function test() external returns (uint256);
}
interface SubInterface is ParentA, ParentB {
// 必须重新定义test,以便断言父类的含义是兼容的。
function test() external override(ParentA, ParentB) returns (uint256);
}
在接口合约和其他类似合约的结构中定义的类型可以从其他合约中访问: Token.TokenType
或 Token.Coin
。
继承中设计到的问题
继承合约,会一块把父合约的状态变量一块继承来,那么,子合约的插槽顺序是是当所有父类合约占据完插槽位置后在开始算自身的插槽的。(即使父类的 private
类似变量也会占用子类的 slot)