合约

Solidity中的合约类似于面向对象语言中的类。 它们在状态变量中包含持久的数据,以及可以修改这些变量的函数。 在不同的合约(实例)上调用一个函数将执行一个EVM函数调用, 从而切换上下文,使调用合约中的状态变量无法访问。 一个合约和它的函数需要被调用才会发生。 在以太坊中没有 "cron" 的概念,在特定的事件中自动调用一个函数。

创建合约

可以通过以太坊交易 “从外部” 或从 Solidity 合约内部创建合约。

集成开发环境,如 Remix,使用UI元素使创建过程无缝化。

在以太坊上以编程方式创建合约的一种方法是通过JavaScript API web3.js。 它有一个名为 web3.eth.Contract 的函数, 以方便创建合约。

当一个合约被创建时,它的 构造函数(constructor) (一个用 constructor 关键字声明的函数)被执行一次。

构造函数是可选的。但是只允许有一个构造函数,这意味着不支持重载。

构造函数执行完毕后,合约的最终代码被存储在区块链上。 这段代码包括所有公开和外部函数,以及所有通过函数调用可从那里到达的函数。 部署的代码不包括构造函数代码或只从构造函数调用的内部函数。

在内部,构造函数参数在合约代码之后通过 ABI编码 传递, 但是如果您使用 web3.js 则不必关心这个问题。

如果一个合约想创建另一个合约,创建者必须知道所创建合约的源代码(和二进制)。 这意味着,循环的创建依赖是不可能的。

// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.4.22 <0.9.0;


contract OwnedToken {
    // `TokenCreator` 是如下定义的合约类型。
    // 不创建新合约的话,也可以引用它。
    TokenCreator creator;
    address owner;
    bytes32 name;

    // 这是注册 creator 和设置名称的构造函数。
    constructor(bytes32 name_) {
        // 状态变量通过其名称访问,
        // 而不是通过例如 `this.owner` 的方式访问。
        // 函数可以直接或通过 `this.f` 访问。
        // 但后者提供了一个对函数的外部可视方法。
        // 特别是在构造函数中,您不应该从外部访问函数,
        // 因为该函数还不存在。
        // 详见下一节。
        owner = msg.sender;

        // 我们进行了从 `address` 到 `TokenCreator` 的显式类型转换,
        // 并假定调用合约的类型是 `TokenCreator`,
        // 没有真正的方法来验证,
        // 这并没有创建一个新的合约。
        creator = TokenCreator(msg.sender);
        name = name_;
    }

    function changeName(bytes32 newName) public {
        // 只有创建者可以改变名称。
        // 我们根据合约的地址进行比较,
        // 它可以通过显式转换为地址来检索。
        if (msg.sender == address(creator))
            name = newName;
    }

    function transfer(address newOwner) public {
        // 只有当前所有者才能发送 token。
        if (msg.sender != owner) return;

        // 我们通过使用下面定义的 `TokenCreator` 合约的一个函数
        // 来询问创建者合约是否应该进行转移。
        // 如果调用失败(例如由于气体值耗尽),
        // 这里的执行也会失败。
        if (creator.isTokenTransferOK(owner, newOwner))
            owner = newOwner;
    }
}


contract TokenCreator {
    function createToken(bytes32 name)
        public
        returns (OwnedToken tokenAddress)
    {
        // 创建一个新的 `Token` 合约并返回其地址。
        // 从JavaScript方面来看,
        // 这个函数的返回类型是 `address`,
        // 因为这是ABI中最接近的类型。
        return new OwnedToken(name);
    }

    function changeName(OwnedToken tokenAddress, bytes32 name) public {
        // 同样,`tokenAddress` 的外部类型是简单的 `address`。
        tokenAddress.changeName(name);
    }

    // 执行检查,以确定是否应该将代币转移到 `OwnedToken` 合约上。
    function isTokenTransferOK(address currentOwner, address newOwner)
        public
        pure
        returns (bool ok)
    {
        // 检查一个任意的条件,看是否应该进行转移。
        return keccak256(abi.encodePacked(currentOwner, newOwner))[0] == 0x7f;
    }
}

可见性和 getter 函数

状态变量的可见性

public

公开状态变量与内部变量的不同之处在于,编译器会自动为它们生成 getter函数, 从而允许其他合约读取它们的值。当在同一个合约中使用时,外部访问(例如 this.x)会调用getter, 而内部访问(例如 x)会直接从存储中获取变量值。 Setter函数没有被生成,所以其他合约不能直接修改其值。

internal

内部状态变量只能从它们所定义的合约和派生合约中访问。 它们不能被外部访问。 这是状态变量的默认可见性。

private

私有状态变量就像内部变量一样,但它们在派生合约中是不可见的。

警告

标记一些变量为 privateinternal,只能防止其他合约读取或修改信息,但它仍然会被区块链之外的整个世界看到。

函数的可见性

Solidity 有两种函数调用:确实创建了实际 EVM 消息调用的外部函数和不创建 EVM 消息调用的内部函数。 此外,派生合约可能无法访问内部函数。 这就产生了四种类型的函数的可见性。

external

外部函数作为合约接口的一部分,意味着我们可以从其他合约和交易中调用。 一个外部函数 f 不能从内部调用 (即 f() 不起作用,但 this.f() 可以)。

public

公开函数是合约接口的一部分,可以在内部或通过消息调用。

internal

内部函数只能从当前的合约或从它派生出来的合约中访问。 它们不能被外部访问。 由于它们没有通过合约的ABI暴露在外部,它们可以接受内部类型的参数,如映射或存储引用。

private

私有函数和内部函数一样,但它们在派生合约中是不可见的。

警告

标记一些变量为 privateinternal,只能防止其他合约读取或修改信息,但它仍然会被区块链之外的整个世界看到。

在状态变量的类型之后,以及在函数的参数列表和返回参数列表之间,都会给出可见性指定符。

// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.4.16 <0.9.0;

contract C {
    function f(uint a) private pure returns (uint b) { return a + 1; }
    function setData(uint a) internal { data = a; }
    uint public data;
}

在下面的例子中,合约 D, 可以调用 c.getData() 来检索状态存储中 data 的值, 但不能调用 f。 合约 E 是从合约 C 派生出来的,因此可以调用 compute

// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.4.16 <0.9.0;

contract C {
    uint private data;

    function f(uint a) private pure returns(uint b) { return a + 1; }
    function setData(uint a) public { data = a; }
    function getData() public view returns(uint) { return data; }
    function compute(uint a, uint b) internal pure returns (uint) { return a + b; }
}

// 这将不会编译
contract D {
    function readData() public {
        C c = new C();
        uint local = c.f(7); // 错误:成员 `f` 不可见
        c.setData(3);
        local = c.getData();
        local = c.compute(3, 5); // 错误:成员 `compute` 不可见
    }
}

contract E is C {
    function g() public {
        C c = new C();
        uint val = compute(3, 5); // 访问内部成员(从继承合约访问父合约成员)
    }
}

Getter 函数

编译器会自动为所有 公开 状态变量创建getter函数。 对于下面给出的合约,编译器将生成一个名为 data 的函数, 它没有任何输入参数,并返回一个 uint, 即状态变量 data 的值。状态变量在声明时可以被初始化。

// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.4.16 <0.9.0;

contract C {
    uint public data = 42;
}

contract Caller {
    C c = new C();
    function f() public view returns (uint) {
        return c.data();
    }
}

getter函数具有外部可见性。 如果该符号被内部访问(即没有 this.),它被评估为一个状态变量。 如果它被外部访问(即有 this.),它将被评价为一个函数。

// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.4.0 <0.9.0;

contract C {
    uint public data;
    function x() public returns (uint) {
        data = 3; // 内部访问
        return this.data(); // 外部访问
    }
}

如果您有一个数组类型的 public 状态变量, 那么您只能通过生成的getter函数检索数组的单个元素。 这种机制的存在是为了避免在返回整个数组时产生高额的气体成本。 您可以使用参数来指定要返回的单个元素,例如 myArray(0)。 如果您想在一次调用中返回整个数组,那么您需要写一个函数,例如:

// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.4.16 <0.9.0;

contract arrayExample {
    // 公开状态变量
    uint[] public myArray;

    // 编译器生成的getter函数
    /*
    function myArray(uint i) public view returns (uint) {
        return myArray[i];
    }
    */

    // 返回整个数组的函数
    function getArray() public view returns (uint[] memory) {
        return myArray;
    }
}

现在您可以使用 getArray() 来检索整个数组, 而不是使用 myArray(i),它每次调用只返回一个元素。

下一个例子稍微复杂一些:

// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.4.0 <0.9.0;

contract Complex {
    struct Data {
        uint a;
        bytes3 b;
        mapping(uint => uint) map;
        uint[3] c;
        uint[] d;
        bytes e;
    }
    mapping(uint => mapping(bool => Data[])) public data;
}

它生成了一个如下形式的函数。结构中的映射和数组(字节数组除外)被省略了, 因为没有好的方法来选择单个结构成员或为映射提供一个键:

function data(uint arg1, bool arg2, uint arg3)
    public
    returns (uint a, bytes3 b, bytes memory e)
{
    a = data[arg1][arg2][arg3].a;
    b = data[arg1][arg2][arg3].b;
    e = data[arg1][arg2][arg3].e;
}

函数修饰器

函数修饰器可以用来以声明的方式改变函数的行为。 例如,您可以使用修饰器在执行函数之前自动检查一个条件。

修饰器是合约的可继承属性,可以被派生合约重载, 但只有当它们被标记为 virtual 时,才能被重载。 详情请见 修饰器重载

// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.7.1 <0.9.0;
// 这将报告一个由于废弃的 selfdestruct 而产生的警告

contract owned {
    constructor() { owner = payable(msg.sender); }
    address payable owner;

    // 这个合约只定义了一个修饰器,但没有使用它:
    // 它将在派生合约中使用。
    // 修饰器所修饰的函数体会被插入到特殊符号 `_;` 的位置。
    // 这意味着,如果所有者调用这个函数,这个函数就会被执行,
    // 否则就会抛出一个异常。
    modifier onlyOwner {
        require(
            msg.sender == owner,
            "Only owner can call this function."
        );
        _;
    }
}

contract destructible is owned {
    // 这个合约从 `owned` 合约继承了 `onlyOwner` 修饰器,
    // 并将其应用于 `destroy` 函数,
    // 只有在合约里保存的 owner 调用 `destroy` 函数,才会生效。
    function destroy() public onlyOwner {
        selfdestruct(owner);
    }
}

contract priced {
    // 修饰器可以接受参数:
    modifier costs(uint price) {
        if (msg.value >= price) {
            _;
        }
    }
}

contract Register is priced, destructible {
    mapping(address => bool) registeredAddresses;
    uint price;

    constructor(uint initialPrice) { price = initialPrice; }

    // 在这里也使用关键字 `payable` 非常重要,
    // 否则函数会自动拒绝所有发送给它的以太币。
    function register() public payable costs(price) {
        registeredAddresses[msg.sender] = true;
    }

    function changePrice(uint price_) public onlyOwner {
        price = price_;
    }
}

contract Mutex {
    bool locked;
    modifier noReentrancy() {
        require(
            !locked,
            "Reentrant call."
        );
        locked = true;
        _;
        locked = false;
    }

    /// 这个函数受互斥量保护,这意味着 `msg.sender.call` 中的重入调用不能再次调用  `f`。
    /// `return 7` 语句指定返回值为 7,但修饰器中的语句 `locked = false` 仍会执行。
    function f() public noReentrancy returns (uint) {
        (bool success,) = msg.sender.call("");
        require(success);
        return 7;
    }
}

如果您想访问定义在合约 C 中的修饰器 m, 您可以使用 C.m 来引用它而不需要虚拟查询。 只能使用定义在当前合约或其基础合约中的修饰器。 修饰器也可以定义在库合约中,但其使用仅限于同一库合约的函数。

如果同一个函数有多个修饰器,它们之间以空格隔开,并按照所呈现的顺序进行评估运算。

修饰器不能隐式地访问或改变它们所修改的函数的参数和返回值。 它们的值只能在调用的时候明确地传递给它们。

在函数修改器中,有必要指定何时运行应用修改器的函数。 占位符语句(用单个下划线字符 _ 表示)用于表示被修改的函数主体应该插入的位置。 请注意,占位符操作符与在变量名中使用下划线作为前导或尾随字符不同, 后者是一种风格上的选择。

修饰器或函数体的显式返回只离开当前修饰器或函数体。 返回变量会被赋值,但整个执行逻辑会从前一个修饰器中定义的 _ 之后继续执行。

警告

在Solidity的早期版本中,具有修饰器的函数中的 return 语句会表现的不同。

return; 从修饰器显式返回并不影响函数返回的值。 然而,修饰器可以选择完全不执行函数主体,在这种情况下, 返回变量被设置为 默认值,就像函数有一个空主体一样。

_ 符号可以在修饰器中多次出现。每次出现都会被替换成函数体。

允许修饰器参数使用任意表达式,在这种情况下,所有从函数中可见的符号在修饰器中都是可见的。 修饰器中引入的符号在函数中是不可见的(因为它们可能因重载而改变)。

Constant 和 Immutable 状态变量

状态变量可以被声明为 constantimmutable。 在这两种情况下,变量在合约构建完成后不能被修改。 对于 constant 变量,其值必须在编译时固定, 而对于 immutable 变量,仍然可以在构造时分配。

也可以在文件级别定义 constant 变量。

编译器并没有为这些变量预留存储,它们的每次出现都会被替换为相应的常量表达式。

与普通的状态变量相比,常量变量(constant)和不可改变的变量(immutable)的气体成本要低得多。 对于常量变量,分配给它的表达式被复制到所有访问它的地方,并且每次都要重新评估, 这使得局部优化成为可能。不可变的变量在构造时被评估一次,其值被复制到代码中所有被访问的地方。 对于这些值,要保留32个字节,即使它们可以装入更少的字节。由于这个原因,常量值有时会比不可变的值更便宜。

目前,并非所有的常量和不可变量的类型都已实现。 唯一支持的类型是 字符串类型 (仅用于常量)和 值类型

// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.7.4;

uint constant X = 32**22 + 8;

contract C {
    string constant TEXT = "abc";
    bytes32 constant MY_HASH = keccak256("abc");
    uint immutable decimals;
    uint immutable maxBalance;
    address immutable owner = msg.sender;

    constructor(uint decimals_, address ref) {
        decimals = decimals_;
        // 对不可变量的赋值甚至可以访问一些全局属性。
        maxBalance = ref.balance;
    }

    function isBalanceTooHigh(address other) public view returns (bool) {
        return other.balance > maxBalance;
    }
}

Constant

对于 constant 变量,其值在编译时必须是一个常量,并且必须在变量声明的地方分配。 任何访问存储、区块链数据(例如: block.timestamp, address(this).balanceblock.number) 或执行数据( msg.valuegasleft())或者调用外部合约的表达式都是不允许的。 但可能对内存分配产生副作用的表达式是允许的,但那些可能对其他内存对象产生副作用的表达式是不允许的。 内置函数 keccak256sha256ripemd160ecrecoveraddmodmulmod 是允许的(尽管除了 keccak256,它们确实调用了外部合约)。

允许在内存分配器上产生副作用的原因是, 它应该可以构建复杂的对象,比如说查找表。 这个功能现在还不能完全使用。

Immutable

声明为 immutable 的变量比声明为 constant 的变量受到的限制要少一些。 不可变的变量可以在合约的构造函数中或在声明时被分配一个任意的值。 它们只能被分配一次,并且从那时起,即使在构造时间内也可以被读取。

编译器生成的合约创建代码将在其返回之前修改合约的运行时代码, 用分配给它们的值替换所有对不可变量的引用。 当您将编译器生成的运行时代码与实际存储在区块链中的代码进行比较时,这一点很重要。

备注

在声明时被分配的不可变量只有在合约的构造函数执行时才会被视为初始化。 这意味着您不能在内联中用一个依赖于另一个不可变量的值来初始化不可变量。 然而,您可以在合约的构造函数中这样做。

这是对状态变量初始化和构造函数执行顺序的不同解释的一种保障,特别是在继承方面。

函数

可以在合约内部和外部定义函数。

合约之外的函数,也称为 "自由函数",总是隐含着 internal可见性。 它们的代码包含在所有调用它们的合约中,类似于内部库函数。

// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.7.1 <0.9.0;

function sum(uint[] memory arr) pure returns (uint s) {
    for (uint i = 0; i < arr.length; i++)
        s += arr[i];
}

contract ArrayExample {
    bool found;
    function f(uint[] memory arr) public {
        // 这在内部调用自由函数。
        // 编译器会将其代码添加到合约中。
        uint s = sum(arr);
        require(s >= 10);
        found = true;
    }
}

备注

在合约之外定义的函数仍然总是在合约的范围内执行。 它们仍然可以调用其他合约,向它们发送以太,并销毁调用它们的合约,以及其他一些事情。 与合约内定义的函数的主要区别是,自由函数不能直接访问变量 this,存储变量和不在其范围内的函数。

函数参数和返回变量

与许多其他语言不同, 函数接受类型化的参数作为输入, 也可以返回任意数量的值作为输出。

函数参数

函数参数的声明方式与变量相同,未使用的参数名称可以省略。

例如,如果您想让您的合约接受一种带有两个整数的外部调用,您可以使用类似以下的方式:

// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.4.16 <0.9.0;

contract Simple {
    uint sum;
    function taker(uint a, uint b) public {
        sum = a + b;
    }
}

函数参数可以像任何其他局部变量一样使用,它们也可以被赋值。

返回的变量

函数的返回变量在 returns 关键字之后用同样的语法声明。

例如,假设您想返回两个结果:作为函数参数传递的两个整数的总和和乘积,那么您就使用类似的方法:

// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.4.16 <0.9.0;

contract Simple {
    function arithmetic(uint a, uint b)
        public
        pure
        returns (uint sum, uint product)
    {
        sum = a + b;
        product = a * b;
    }
}

返回变量的名字可以被省略。返回变量可以像其他本地变量一样使用, 它们被初始化为相应的 默认值, 并且在它们被(重新)赋值之前拥有这个值。

您可以明确地赋值给返回变量,然后像上面那样结束函数, 或者您可以用 return 语句直接提供返回值(单个或 多个返回值)。

// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.4.16 <0.9.0;

contract Simple {
    function arithmetic(uint a, uint b)
        public
        pure
        returns (uint sum, uint product)
    {
        return (a + b, a * b);
    }
}

如果您过早使用 return 来结束一个有返回变量的函数,您必须在返回语句中同时提供返回值。

备注

您不能从非内部函数返回某些类型。 这包括下面列出的类型和任何递归地包含它们的复合类型:

  • 映射,

  • 内部函数类型,

  • 参考类型,位置设置为 storage

  • 多维数组(仅适用于 ABI coder v1),

  • 结构体(仅适用于 ABI coder v1)。

这个限制不适用于库函数,因为它们有不同的 内部 ABI

返回多个值

当一个函数有多个返回类型时,语句 return (v0, v1, ..., vn) 可以用来返回多个值。 声明的数量必须与返回变量的数量相同,并且它们的类型必须匹配, 有可能是经过 隐式转换

状态可变性

View 函数

函数可以被声明为 view,在这种情况下,它们承诺不修改状态。

备注

如果编译器的EVM版本是Byzantium或更新的(默认), 当调用 view 函数时,会使用操作码 STATICCALL,这使得状态作为EVM执行的一部分保持不被修改。 对于库合约的 view 函数,会使用 DELEGATECALL, 因为没有组合的 DELEGATECALLSTATICCALL。 这意味着库合约中的 view 函数没有防止状态修改的运行时的检查。 这应该不会对安全产生负面影响,因为库合约的代码通常在编译时就知道了, 而且静态检查器也会进行编译时检查。

以下声明被认为是修改状态:

  1. 修改状态变量。

  2. 产生事件

  3. 创建其它合约

  4. 使用 selfdestruct

  5. 通过调用发送以太币。

  6. 调用任何没有标记为 view 或者 pure 的函数。

  7. 使用低级调用。

  8. 使用包含特定操作码的内联汇编。

// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.5.0 <0.9.0;

contract C {
    function f(uint a, uint b) public view returns (uint) {
        return a * (b + 42) + block.timestamp;
    }
}

备注

函数上的 constant 曾经是 view 的别名,但在0.5.0版本中被取消。

备注

Getter方法被自动标记为 view

备注

在0.5.0版本之前,编译器没有为 view 函数使用 STATICCALL 操作码。 这使得 view 函数通过使用无效的显式类型转换进行状态修改。 通过对 view 函数使用 STATICCALL,在EVM层面上防止了对状态的修改。

Pure 函数

函数可以被声明为 pure,在这种情况下,它们承诺不读取或修改状态。 特别是,应该可以在编译时评估一个 pure 函数,只给它的输入和 msg.data, 但不知道当前区块链状态。这意味着读取 immutable 的变量可以是一个非标准pure的操作。

备注

如果编译器的EVM版本是Byzantium或更新的(默认),则使用操作码 STATICCALL, 这并不能保证不读取状态,但至少不能修改。

除了上面解释的状态修改语句列表外,以下内容被认为是从状态中读取的:

  1. 读取状态变量。

  2. 访问 address(this).balance 或者 <address>.balance

  3. 访问 blocktxmsg 中任意成员 (除 msg.sigmsg.data 之外)。

  4. 调用任何未标记为 pure 的函数。

  5. 使用包含某些操作码的内联汇编。

// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.5.0 <0.9.0;

contract C {
    function f(uint a, uint b) public pure returns (uint) {
        return a * (b + 42);
    }
}

当一个 错误发生 时, Pure 函数能够使用 revert()require() 函数来恢复潜在的状态变化。

恢复一个状态变化不被认为是 "状态修改", 因为只有之前在没有 viewpure 限制的代码中对状态的改变才会被恢复, 并且该代码可以选择捕捉 revert 而不传递给它。

这种行为也与 STATICCALL 操作码一致。

警告

在EVM层面不可能阻止函数读取状态,只可能阻止它们写入状态 (即只有 view 可以在EVM层面执行, pure 不可以)。

备注

在0.5.0版本之前,编译器没有为 pure 函数使用 STATICCALL 操作码。 这使得在 pure 函数中通过使用无效的显式类型转换进行状态修改。 通过对 pure 函数使用 STATICCALL,在EVM层面防止了对状态的修改。

备注

在0.4.17版本之前,编译器并没有强制要求 pure 不读取状态。 这是一个编译时的类型检查,可以规避在合约类型之间做无效的显式转换, 因为编译器可以验证合约的类型不做改变状态的操作, 但它不能检查将在运行时被调用的合约是否真的属于该类型。

特殊的函数

接收以太的函数

一个合约最多可以有一个 receive 函数, 使用 receive() external payable { ... } 来声明。(没有 function 关键字)。 这个函数不能有参数,不能返回任何东西,必须具有 external 的可见性和 payable 的状态可变性。 它可以是虚拟的,可以重载,也可以有修饰器。

receive 函数是在调用合约时执行的,并带有空的 calldata。 这是在纯以太传输(例如通过 .send().transfer() )时执行的函数。 如果不存在这样的函数,但存在一个 payable 类型的 fallback函数, 这个 fallback 函数将在纯以太传输时被调用。 如果既没有直接接收以太(receive函数),也没有 payable 类型的 fallback 函数, 那么合约就不能通过不代表支付函数调用的交易接收以太币,还会抛出一个异常。

在最坏的情况下, receive 函数只有2300个气体可用(例如当使用 sendtransfer 时), 除了基本的记录外,几乎没有空间来执行其他操作。以下操作的消耗气体将超过2300气体的规定:

  • 写入存储

  • 创建合约

  • 调用消耗大量 gas 的外部函数

  • 发送以太币

警告

当以太被直接发送到一个合约(没有使用函数调用,即发送者使用 sendtransfer), 但接收合约没有定义一个接收以太的函数或一个 payable 类型的 fallback 函数,会抛出一个异常, 将以太送回(这在Solidity v0.4.0之前是不同的)。因此,如果您想让您的合约接收以太, 您必须实现一个 receive 函数(不建议使用 payable 类型的 fallback 函数来接收以太, 因为它不会因为接口混乱而失败)。

警告

没有接收以太币功能的合约可以作为 coinbase交易*(又称 *矿工区块奖励)的接收者 或作为 selfdestruct 的目的地接收以太币。

合约不能对这样的以太币转移做出反应,因此也不能拒绝它们。 这是EVM的一个设计选择,Solidity无法绕过它。

这也意味着 address(this).balance 可以高于合约中 实现的一些手工记帐的总和(即在接收以太函数中更新的累加器)。

下面您可以看到一个使用 receive 函数的Sink合约的例子。

// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.6.0 <0.9.0;

// 这个合约会保留所有发送给它的以太币,没有办法返还。
contract Sink {
    event Received(address, uint);
    receive() external payable {
        emit Received(msg.sender, msg.value);
    }
}

Fallback 函数

一个合约最多可以有一个 fallback 函数,使用 fallback () external [payable]fallback (bytes calldata input) external [payable] returns (bytes memory output) 来声明(都没有 function 关键字)。 这个函数必须具有 external 的函数可见性。 一个 fallback 函数可以被标记为 virtual,可以标记为 override,也可以有修饰器。

如果其他函数都不符合给定的函数签名,或者根本没有提供数据, 也没有 接收以太的函数,那么fallback函数将在调用合约时执行。 fallback函数总是接收数据,但为了同时接收以太,它必须被标记为 payable

如果使用带参数的版本, input 将包含发送给合约的全部数据(等于 msg.data), 并可以在 output 中返回数据。返回的数据将不会被ABI编码。 相反,它将在没有修改的情况下返回(甚至没有填充)。

在最坏的情况下,如果一个可接收以太的fallback函数也被用来代替接收功能, 那么它只有2300气体是可用的 (参见 接收以太函数 对这一含义的简要描述)。

像任何函数一样,只要有足够的气体传递给它,fallback函数就可以执行复杂的操作。

警告

如果没有 receive 函数 的存在, 一个标记为 payable 的 fallback 函数也会在普通的以太传输时执行。 如果您已经定义了一个 payable 类型的 fallback 函数, 我们仍建议您也定义一个 receive 函数接收以太,以区分以太传输和接口混淆的情况。

备注

如果您想对输入数据进行解码,您可以检查前四个字节的函数选择器, 然后您可以使用 abi.decode 与数组切片语法一起对ABI编码的数据进行解码: (c, d) = abi.decode(input[4:], (uint256, uint256)); 注意,这只能作为最后的手段,应该使用适当的函数来代替。

// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.6.2 <0.9.0;

contract Test {
    uint x;
    // 所有发送到此合约的消息都会调用此函数(没有其他函数)。
    // 向该合约发送以太币将引起异常,
    // 因为fallback函数没有 `payable` 修饰器。
    fallback() external { x = 1; }
}

contract TestPayable {
    uint x;
    uint y;
    // 所有发送到此合约的消息都会调用这个函数,
    // 除了普通的以太传输(除了receive函数,没有其他函数)。
    // 任何对该合约的非空的调用都将执行fallback函数(即使以太与调用一起被发送)。
    fallback() external payable { x = 1; y = msg.value; }

    // 这个函数是为纯以太传输而调用的,
    // 即为每一个带有空calldata的调用。
    receive() external payable { x = 2; y = msg.value; }
}

contract Caller {
    function callTest(Test test) public returns (bool) {
        (bool success,) = address(test).call(abi.encodeWithSignature("nonExistingFunction()"));
        require(success);
        // 结果是 test.x 等于 1。

        // address(test)将不允许直接调用 ``send``,
        // 因为 ``test`` 没有可接收以太的fallback函数。
        // 它必须被转换为 ``address payable`` 类型,才允许调用 ``send``。
        address payable testPayable = payable(address(test));

        // 如果有人向该合约发送以太币,转账将失败,即这里返回false。
        return testPayable.send(2 ether);
    }

    function callTestPayable(TestPayable test) public returns (bool) {
        (bool success,) = address(test).call(abi.encodeWithSignature("nonExistingFunction()"));
        require(success);
        // 结果是 test.x 等于 1,test.y 等于 0。
        (success,) = address(test).call{value: 1}(abi.encodeWithSignature("nonExistingFunction()"));
        require(success);
        // 结果是 test.x 等于 1,test.y 等于 1。

        // 如果有人向该合约发送以太币,TestPayable的receive函数将被调用。
        // 由于该函数会写入存储空间,它需要的气体比简单的 ``send`` 或 ``transfer`` 要多。
        // 由于这个原因,我们必须要使用一个低级别的调用。
        (success,) = address(test).call{value: 2 ether}("");
        require(success);
        // 结果是 test.x 等于 1,test.y 等于 2 个以太。

        return true;
    }
}

函数重载

一个合约可以有多个同名的,但参数类型不同的函数。 这个过程被称为 "重载",也适用于继承的函数。 下面的例子显示了在合约 A 范围内对函数 f 的重载。

// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.4.16 <0.9.0;

contract A {
    function f(uint value) public pure returns (uint out) {
        out = value;
    }

    function f(uint value, bool really) public pure returns (uint out) {
        if (really)
            out = value;
    }
}

重载函数也存在于外部接口中。如果两个外部可见函数仅区别于 Solidity 内的类型而不是它们的外部类型则会导致错误。

// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.4.16 <0.9.0;

// 这段代码不会编译
contract A {
    function f(B value) public pure returns (B out) {
        out = value;
    }

    function f(address value) public pure returns (address out) {
        out = value;
    }
}

contract B {
}

以上两个 f 函数重载最终都接受ABI的地址类型,尽管它们在Solidity中被认为是不同的。

重载解析和参数匹配

通过将当前范围内的函数声明与函数调用中提供的参数相匹配,可以选择重载函数。 如果所有参数都可以隐式地转换为预期类型,则选择函数作为重载候选项。 如果一个候选都没有,解析失败。

备注

返回参数不作为重载解析的依据。

// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.4.16 <0.9.0;

contract A {
    function f(uint8 val) public pure returns (uint8 out) {
        out = val;
    }

    function f(uint256 val) public pure returns (uint256 out) {
        out = val;
    }
}

调用 f(50) 会导致类型错误,因为 50 既可以被隐式转换为 uint8 也可以被隐式转换为 uint256。 另一方面,调用 f(256) 则会解析为 f(uint256) 重载, 因为 256 不能隐式转换为 uint8

事件

Solidity事件在EVM的日志功能之上给出了一个抽象。 应用程序可以通过Ethereum客户端的RPC接口订阅和监听这些事件。

事件是合约的可继承成员。当您调用它们时, 它们会导致参数被存储在交易的日志中--区块链中的一个特殊数据结构。 这些日志与合约的地址相关联,被纳入区块链, 只要有区块可以访问,就会留在那里(目前是永远,但这可能会随着Serenity升级而改变)。 日志及其事件数据不能从合约内部访问(甚至不能从创建它们的合约访问)。

有可能要求为日志提供Merkle证明, 所以如果外部实体向合约提供这样的证明,它可以检查日志是否真的存在于区块链中。 由于合约中仅能访问最近的 256 个区块哈希,所以还需要提供区块头信息。

您可以最多给三个参数添加 indexed 属性,将它们添加到一个特殊的数据结构中, 称为 "topics",而不是日志的数据部分。 一个topic只能容纳一个字(32字节),所以如果您为一个索引参数使用 引用类型, 该值的Keccak-256哈希值将被存储为一个topic中。

所有没有 indexed 属性的参数都会被 ABI 编码 到日志的数据部分。

Topics允许您用来搜索事件,例如为特定的事件来过滤一系列的区块。 您用来也可以通过发出事件的合约的地址来过滤事件。

例如,下面的代码使用web3.js subscribe("logs") 方法 来过滤与某一地址值相匹配的日志:

var options = {
    fromBlock: 0,
    address: web3.eth.defaultAccount,
    topics: ["0x0000000000000000000000000000000000000000000000000000000000000000", null, null]
};
web3.eth.subscribe('logs', options, function (error, result) {
    if (!error)
        console.log(result);
})
    .on("data", function (log) {
        console.log(log);
    })
    .on("changed", function (log) {
});

除非您用 anonymous 指定符声明事件,否则事件的签名的哈希值是topic之一。 这意味着不可能通过名字来过滤特定的匿名事件, 您只能通过合约地址来过滤。匿名事件的优点是,它们的部署和调用都比较便宜。 它还允许您声明四个索引参数,而不是三个。

备注

由于交易日志只存储事件数据而不存储类型,因此您必须知道事件的类型, 包括哪个参数被索引以及事件是否是匿名的,以便正确解析数据。 特别的是,有可能用一个匿名事件 “伪造“ 另一个事件的签名。

事件类型的成员方法

  • event.selector: 对于非匿名事件,这是一个 bytes32 值, 包含事件签名的 keccak256 哈希值,在默认topic中使用。

示例

// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.4.21 <0.9.0;

contract ClientReceipt {
    event Deposit(
        address indexed from,
        bytes32 indexed id,
        uint value
    );

    function deposit(bytes32 id) public payable {
        // 事件是用 `emit` 发出的,后面是事件的名称和括号里的参数(如果有)。
        // 任何这样的调用(甚至是深度嵌套)都可以通过过滤 `Deposit`
        // 从JavaScript API中检测出来。
        emit Deposit(msg.sender, id, msg.value);
    }
}

在JavaScript API中的使用方式如下:

var abi = /* 由编译器产生的abi */;
var ClientReceipt = web3.eth.contract(abi);
var clientReceipt = ClientReceipt.at("0x1234...ab67" /* 地址 */);

var depositEvent = clientReceipt.Deposit();

// 监视变化
depositEvent.watch(function(error, result){
    // 结果包含非索引的参数和给 `Deposit` 调用的 topics。
    if (!error)
        console.log(result);
});


// 或者通过回调立即开始监视
var depositEvent = clientReceipt.Deposit(function(error, result) {
    if (!error)
        console.log(result);
});

上面的输出看起来像下面这样(经过修剪):

{
   "returnValues": {
       "from": "0x1111…FFFFCCCC",
       "id": "0x50…sd5adb20",
       "value": "0x420042"
   },
   "raw": {
       "data": "0x7f…91385",
       "topics": ["0xfd4…b4ead7", "0x7f…1a91385"]
   }
}

了解事件类型的其他资料

错误和恢复语句

Solidity 中的错误提供了一种方便且省gas的方式来向用户解释为什么一个操作会失败。 它们可以被定义在合约内部和外部(包括接口合约和库合约)。

它们必须与 恢复语句 一起使用, 它导致当前调用中的所有变化被恢复,并将错误数据传回给调用者。

// SPDX-License-Identifier: GPL-3.0
pragma solidity ^0.8.4;

/// 转账的余额不足。需要 `required` 数量但只有 `available` 数量可用。
/// @param 可用的余额。
/// @param 需要要求的转帐金额。
error InsufficientBalance(uint256 available, uint256 required);

contract TestToken {
    mapping(address => uint) balance;
    function transfer(address to, uint256 amount) public {
        if (amount > balance[msg.sender])
            revert InsufficientBalance({
                available: balance[msg.sender],
                required: amount
            });
        balance[msg.sender] -= amount;
        balance[to] += amount;
    }
    // ...
}

错误不能被重载或覆盖,但是可以被继承。 只要作用域不同,同一个错误可以在多个地方定义。 错误的实例只能使用 revert 语句创建。

错误会创建数据,然后通过还原操作传递给调用者, 使其返回到链下组件或在 try/catch 语句 中捕获它。 需要注意的是,一个错误只能在来自外部调用时被捕获, 发生在内部调用或同一函数内的还原不能被捕获。

如果您不提供任何参数,错误只需要四个字节的数据, 您可以像上面一样使用 NatSpec 语法 来进一步解释错误背后的原因, 这并不存储在链上。这使得这同时也是一个非常便宜和方便的错误报告功能。

更具体地说,一个错误实例在被ABI编码时, 其方式与对相同名称和类型的函数的调用相同, 然后作为 revert 操作码的返回数据。 这意味着数据由一个4字节的选择器和 ABI编码 数据组成。 选择器由错误类型的签名的keccak256-hash的前四个字节组成。

备注

一个合约有可能因为同名的不同错误而恢复, 甚至因为在不同地方定义的错误而使调用者无法区分。 对于外部来说,即ABI,只有错误的名称是相关的,而不是定义它的合约或文件。

如果您能定义 error Error(string), 那么语句 require(condition, "description"); 将等同于 if (!condition) revert Error("description")。 但是请注意, Error 是一个内置类型,不能在用户提供的代码中定义。

同样,一个失败的 assert 或类似的条件将以一个内置的 Panic(uint256) 类型的错误来恢复。

备注

错误数据应该只被用来指示失败,而不是作为控制流的手段。 原因是内部调用的恢复数据默认是通过外部调用链传播回来的。 这意味着内部调用可以 ”伪造” 恢复数据,使它看起来像是来自调用它的合约。

错误类型的成员

  • error.selector: 一个包含错误类型的选择器的 bytes4 值。

继承

Solidity支持多重继承,包括多态性。

多态性意味着函数调用(内部和外部)总是执行继承层次结构中最新继承的合约中的同名函数(和参数类型)。 但必须使用 virtualoverride 关键字在层次结构中的每个函数上明确启用。 参见 函数重载 以了解更多细节。

通过使用 ContractName.functionName() 明确指定合约, 可以在内部调用继承层次结构中更高的函数。 或者如果您想在扁平化的继承层次中调用高一级的函数(见下文), 可以使用 super.functionName()

当一个合约继承自其他合约时,在区块链上只创建一个单一的合约, 所有基础合约的代码被编译到创建的合约中。 这意味着对基础合约的所有内部函数的调用也只是使用内部函数调用 ( super.f(..) 将使用 JUMP 而不是消息调用)。

状态变量的阴影被认为是一个错误。 一个派生合约只能声明一个状态变量 x, 如果在它的任何基类中没有相同名称的可见状态变量。

总的来说,Solidity 的继承系统与 Python的继承系统 非常相似,特别是关于多重继承方面,但也有一些 不同之处

详细情况见下面的例子。

// 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, 解决这个问题的方法是使用 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 可以被 viewpure 重载。 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 {}

更准确地说,如果有一个基类合约是该签名的所有重载路径的一部分, 并且(1)该基类合约实现了该函数,并且从当前合约到该基类合约的任何路径都没有提到具有该签名的函数, 或者(2)该基类合约没有实现该函数,并且从当前合约到该基类合约的所有路径中最多只有一个提到该函数, 那么就不需要重载从多个基类合约继承的函数(直接或间接)。

在这个意义上,一个签名的重载路径是一条继承图的路径, 它从所考虑的合约开始,到提到具有该签名的函数的合约结束, 而该签名没有重载。

如果您不把一个重载的函数标记为 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 Base
{
    modifier foo() virtual {_;}
}

contract Inherited is Base
{
    modifier foo() 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() {}。比如说:

// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.7.0 <0.9.0;

abstract contract A {
    uint public a;

    constructor(uint a_) {
        a = a_;
    }
}

contract B is A(1) {
    constructor() {}
}

您可以在构造函数中使用内部参数(例如,存储指针)。 在这种情况下,合约必须被标记为 abstract, 因为这些参数不能从外部分配有效的值,只能通过派生合约的构造函数来赋值。

警告

在0.4.22版本之前,构造函数被定义为与合约同名的函数。 这种语法已被废弃,在0.5.0版本中不再允许。

警告

在0.7.0版本之前,您必须指定构造函数的可见性为 internalpublic

基本构造函数的参数

所有基类合约的构造函数将按照下面解释的线性化规则被调用。 如果基类合约构造函数有参数,派生合约需要指定所有的参数。 这可以通过两种方式实现:

// 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() {}
}

// 或者通过派生构造函数的一个 "修改器"……
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 类型)。例如,在上面的代码片段中, 可以查看合约 Derived3DerivedFromDerived

多重继承与线性化

编程语言实现多重继承需要解决几个问题。 一个问题是 钻石问题 。 Solidity 借鉴了 Python 的方式并且使用 "C3 线性化" 强制一个由基类构成的 DAG(有向无环图)保持一个特定的顺序。 这最终实现我们所希望的唯一化的结果,但也使某些继承方式变为无效。 尤其是,基类在 is 后面的顺序很重要。 在下面的代码中, 您必须按照从 “最接近的基类”(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, 这是一种无法解决的冲突。

由于您必须明确地重载一个从多个基类合约继承的函数, 而没有唯一的重载,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() {}
}

继承有相同名字的不同类型成员

由于继承的原因,当合约有以下任何一对具有相同的名称时,这是一个错误:
  • 函数和修饰器

  • 函数和事件

  • 事件和修饰器

有一种例外情况,状态变量的 getter 可以重载一个外部函数。

抽象合约

当合约中至少有一个函数没有被实现,或者合约没有为其所有的基本合约构造函数提供参数时, 合约必须被标记为 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.TokenTypeToken.Coin

库合约

库合约与普通合约类似,但是它们只需要在特定的地址部署一次, 并且它们的代码可以通过 EVM 的 DELEGATECALL (Homestead 之前使用 CALLCODE 关键字)特性进行重用。 这意味着如果库函数被调用,它的代码在调用合约的上下文中执行, 即 this 指向调用合约,特别是可以访问调用合约的存储。 因为每个库合约都是一段独立的代码,所以它仅能访问调用合约明确提供的状态变量(否则它就无法通过名字访问这些变量)。 如果库函数不修改状态(也就是说,如果它们是 view 或者 pure 函数), 它们可以通过直接调用来使用(即不使用 DELEGATECALL 关键字), 这是因为我们假定库合约是无状态的。 特别的是,销毁一个库合约是不可能的。

备注

在0.4.20版本之前,有可能通过规避Solidity的类型系统来破坏库合约。 从该版本开始,库合约包含一个 保护机制, 不允许直接调用修改状态的函数(即没有 DELEGATECALL )。

库合约可以看作是使用他们的合约的隐式的基类合约。 虽然它们在继承关系中不会显式可见,但调用库函数与调用显式的基类合约十分类似 (如果 L 是库合约的话,可以使用 L.f() 调用库函数)。 当然,需要使用内部调用约定来调用内部函数,这意味着所有的内部类型都可以被传递, 类型 存储在内存 将被引用传递而不是复制。 为了在EVM中实现这一点,从合约中调用的内部库函数的代码和其中调用的所有函数将在编译时包含在调用合约中, 并使用常规的 JUMP 调用,而不是 DELEGATECALL

备注

当涉及到公共函数时,继承的类比就失效了。 用 L.f() 调用公共库函数的结果是一个外部调用(准确地说,是 DELEGATECALL )。 相反,当 A.f() 是当前合约的基类合约时, A.f() 是一个内部调用。

下面的示例说明如何使用库(但也请务必看看 using for 有一个实现 set 更好的例子)。

// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.6.0 <0.9.0;


// 我们定义了一个新的结构体数据类型,用于在调用合约中保存数据。
struct Data {
    mapping(uint => bool) flags;
}

library Set {
    // 注意第一个参数是 “storage reference”类型,
    // 因此在调用中参数传递的只是它的存储地址而不是内容。
    // 这是库函数的一个特性。如果该函数可以被视为对象的方法,
    // 则习惯称第一个参数为 `self` 。
    function insert(Data storage self, uint value)
        public
        returns (bool)
    {
        if (self.flags[value])
            return false; // 已经存在
        self.flags[value] = true;
        return true;
    }

    function remove(Data storage self, uint value)
        public
        returns (bool)
    {
        if (!self.flags[value])
            return false; // 不存在
        self.flags[value] = false;
        return true;
    }

    function contains(Data storage self, uint value)
        public
        view
        returns (bool)
    {
        return self.flags[value];
    }
}


contract C {
    Data knownValues;

    function register(uint value) public {
        // 不需要库的特定实例就可以调用库函数,
        // 因为当前合约就是 “instance”。
        require(Set.insert(knownValues, value));
    }
    // 如果我们愿意,我们也可以在这个合约中直接访问 knownValues.flags。
}

当然,您不必按照这种方式去使用库:它们也可以在不定义结构数据类型的情况下使用。 函数也不需要任何存储引用参数,库可以出现在任何位置并且可以有多个存储引用参数。

调用 Set.containsSet.insertSet.remove 都被编译为对外部合约/库的调用( DELEGATECALL )。 如果使用库,请注意实际执行的是外部函数调用。 msg.sendermsg.valuethis 在调用中将保留它们的值, (在 Homestead 之前,因为使用了 CALLCODE ,改变了 msg.sendermsg.value)。

下面的例子显示了如何使用 存储在内存中的类型 和库合约中的内部函数, 以实现自定义类型,而没有外部函数调用的开销:

// SPDX-License-Identifier: GPL-3.0
pragma solidity ^0.8.0;

struct bigint {
    uint[] limbs;
}

library BigInt {
    function fromUint(uint x) internal pure returns (bigint memory r) {
        r.limbs = new uint[](1);
        r.limbs[0] = x;
    }

    function add(bigint memory a, bigint memory b) internal pure returns (bigint memory r) {
        r.limbs = new uint[](max(a.limbs.length, b.limbs.length));
        uint carry = 0;
        for (uint i = 0; i < r.limbs.length; ++i) {
            uint limbA = limb(a, i);
            uint limbB = limb(b, i);
            unchecked {
                r.limbs[i] = limbA + limbB + carry;

                if (limbA + limbB < limbA || (limbA + limbB == type(uint).max && carry > 0))
                    carry = 1;
                else
                    carry = 0;
            }
        }
        if (carry > 0) {
            // 太差了,我们需要增加一个 limb
            uint[] memory newLimbs = new uint[](r.limbs.length + 1);
            uint i;
            for (i = 0; i < r.limbs.length; ++i)
                newLimbs[i] = r.limbs[i];
            newLimbs[i] = carry;
            r.limbs = newLimbs;
        }
    }

    function limb(bigint memory a, uint index) internal pure returns (uint) {
        return index < a.limbs.length ? a.limbs[index] : 0;
    }

    function max(uint a, uint b) private pure returns (uint) {
        return a > b ? a : b;
    }
}

contract C {
    using BigInt for bigint;

    function f() public pure {
        bigint memory x = BigInt.fromUint(7);
        bigint memory y = BigInt.fromUint(type(uint).max);
        bigint memory z = x.add(y);
        assert(z.limb(1) > 0);
    }
}

通过将库合约的类型转换为 address 类型,即使用 address(LibraryName),可以获得一个库的地址。

由于编译器不知道库合约的部署地址, 编译后的十六进制代码将包含 __$30bbc0abd4d6364515865950d3e0d10953$__ 形式的占位符。 占位符是完全等同于库合约名的keccak256哈希值的34个字符的前缀,例如 libraries/bigint.sol:BigInt, 如果该库存储在 libraries/ 目录下一个名为 bigint.sol 的文件中。 这样的字节码是不完整的,不应该被部署。占位符需要被替换成实际地址。 您可以在编译库的时候把它们传递给编译器,或者用链接器来更新已经编译好的二进制文件。 参见 库链接,了解如何使用命令行编译器进行链接。

与合约相比,库在以下方面受到限制:

  • 它们不能有状态变量

  • 它们不能继承,也不能被继承

  • 它们不能接收以太

  • 它们不能被销毁

(这些可能会在以后的时间里被解除)。

库合约中的函数签名和选择器

虽然对公共或外部库函数的外部调用是可能的,但这种调用的调用惯例被认为是 Solidity 内部的, 与常规 合约 ABI 所指定的不一样。 外部库函数比外部合约函数支持更多的参数类型,例如递归结构和存储指针。 由于这个原因,用于计算4字节选择器的函数签名是按照内部命名模式计算的, 合约ABI中不支持的类型的参数使用内部编码。

签名中的类型使用了以下标识符:

  • 值类型、非存储的 string 和非存储的 bytes 使用与合约ABI中相同的标识符。

  • 非存储数组类型遵循与合约ABI中相同的惯例,即 <type>[] 用于动态数组, <type>[M] 用于 M 元素的固定大小数组。

  • 非存储结构体用其完全等同于的名称来指代,即 C.S 代表 contract C { struct S { ... } }

  • 存储指针映射使用 mapping(<keyType> => <valueType>) storage, 其中 <keyType><valueType> 分别是映射的键和值类型的标识。

  • 其他存储指针类型使用其对应的非存储类型的类型标识符,但在其后面附加一个空格,即 storage

参数的编码与普通合约ABI相同,除了存储指针, 它被编码为一个 uint256 值,指的是它们所指向的存储槽。

与合约ABI类似,选择器由签名的Keccak256-hash的前四个字节组成。 它的值可以通过使用 .selector 成员从 Solidity 获得,如下:

// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.5.14 <0.9.0;

library L {
    function f(uint256) external {}
}

contract C {
    function g() public pure returns (bytes4) {
        return L.f.selector;
    }
}

库的调用保护

正如介绍中提到的那样,如果库的代码是通过 CALL 来执行, 而不是 DELEGATECALL 或者 CALLCODE, 那么执行的结果会被恢复, 除非是对 view 或者 pure 函数的调用。

EVM没有提供一个直接的方法让合约检测它是否被使用 CALL 调用, 但是合约可以使用 ADDRESS 操作码来找出它当前运行的 "位置"。 生成的代码将这个地址与构造时使用的地址进行比较,以确定调用的模式。

更具体地说,一个库合约的运行时代码总是以 push 指令开始, 在编译时它是一个20字节的零。 当部署代码运行时,这个常数在内存中被当前地址所取代,这个修改后的代码被存储在合约中。 在运行时,这导致部署时的地址成为第一个被推入堆栈的常数, 对于任何 非-view 和 非-pure 函数,调度器代码会将当前地址与这个常数进行比较。

这意味着一个存储在链上的库合约的实际代码,与编译器报告的 deployedBytecode 的代码不同。

Using For

指令 using A for B 可用于将函数( A) 作为运算符附加到用户定义的值类型 或作为成员函数附加到任何类型( B)。 成员函数将调用它们的对象作为第一个参数 (类似于 Python 中的 self 变量)。 运算符函数将接收操作数作为参数。

它可以在文件级别或者在合约级别的合约内部有效。

第一部分, A,可以是以下之一:

  • 一个函数列表,可选择分配运算符名称 (例如 using {f, g as +, h, L.t} for uint)。 如果未指定运算符,则该函数可以是库函数或自由函数, 并将其作为成员函数附加到类型。 否则,它必须是一个自由函数,并成为该类型上该运算符的定义。

  • 一个库合约的名称(例如 using L for uint)- 该库合约的所有非私有函数都作为成员函数附加到该类型上。

在文件级别中,第二部分, B,必须是一个明确的类型(没有数据位置指定)。 在合约内部,您也可以用 * 代替类型(例如 using L for *; ), 这样做的效果是,库合约 L 中所有的函数都会被附加到 所有 类型上。

如果您指定了一个库合约,那么该库合约中的 所有 非私有函数都会被附加到该类型上, 即使是那些第一个参数的类型与对象的类型不匹配的函数。 类型会在函数被调用的时候检查, 并执行函数重载解析。

如果您使用一个函数列表(例如 using {f, g, h, L.t} for uint ), 那么类型( uint )必须可以隐式地转换为这些函数的第一个参数。 即使这些函数都没有被调用,也要进行这种检查。 请注意,只有当 using for 位于库合约内时,才能指定私有库函数。

如果您定义了一个操作符(例如 using {f as +} for T),那么类型( T)必须是一个 用户定义的值类型,并且定义必须是一个 pure 函数。 操作符定义必须是全局的。 以下操作符可以用这种方式定义:

Category

Operator

Possible signatures

Bitwise

&

function (T, T) pure returns (T)

|

function (T, T) pure returns (T)

^

function (T, T) pure returns (T)

~

function (T) pure returns (T)

Arithmetic

+

function (T, T) pure returns (T)

-

function (T, T) pure returns (T)

function (T) pure returns (T)

*

function (T, T) pure returns (T)

/

function (T, T) pure returns (T)

%

function (T, T) pure returns (T)

Comparison

==

function (T, T) pure returns (bool)

!=

function (T, T) pure returns (bool)

<

function (T, T) pure returns (bool)

<=

function (T, T) pure returns (bool)

>

function (T, T) pure returns (bool)

>=

function (T, T) pure returns (bool)

注意,一元和二元的 - 需要单独定义。 编译器会根据操作符的调用方式选择正确的定义。

using A for B; 指令只在当前作用域(合约或当前模块/源单元)内有效, 包括其中所有的函数,在使用它的合约或模块之外没有任何效果。

当在文件级别使用该指令并应用于在同一文件中用户定义类型时, 可以在末尾添加 global 关键字。 这将使函数和操作符附加到该类型的任何可用位置(包括其他文件), 而不仅仅是在 using 语句的范围内。

下面我们将使用文件级函数来重写 库合约 部分中的 set 示例。

// SPDX-License-Identifier: GPL-3.0
pragma solidity ^0.8.13;

struct Data { mapping(uint => bool) flags; }
// 现在我们给这个类型附加上函数。
// 附加的函数可以在模块的其他部分使用。
// 如果您导入了该模块,
// 您必须在那里重复using指令,例如
//   import "flags.sol" as Flags;
//   using {Flags.insert, Flags.remove, Flags.contains}
//     for Flags.Data;
using {insert, remove, contains} for Data;

function insert(Data storage self, uint value)
    returns (bool)
{
    if (self.flags[value])
        return false; // 已经存在
    self.flags[value] = true;
    return true;
}

function remove(Data storage self, uint value)
    returns (bool)
{
    if (!self.flags[value])
        return false; // 不存在
    self.flags[value] = false;
    return true;
}

function contains(Data storage self, uint value)
    view
    returns (bool)
{
    return self.flags[value];
}


contract C {
    Data knownValues;

    function register(uint value) public {
        // 这里, Data 类型的所有变量都有与之相对应的成员函数。
        // 下面的函数调用和 `Set.insert(knownValues, value)` 的效果完全相同。
        require(knownValues.insert(value));
    }
}

也可以通过这种方式来扩展内置类型。 在这个例子中,我们将使用一个库合约。

// SPDX-License-Identifier: GPL-3.0
pragma solidity ^0.8.13;

library Search {
    function indexOf(uint[] storage self, uint value)
        public
        view
        returns (uint)
    {
        for (uint i = 0; i < self.length; i++)
            if (self[i] == value) return i;
        return type(uint).max;
    }
}
using Search for uint[];

contract C {
    uint[] data;

    function append(uint value) public {
        data.push(value);
    }

    function replace(uint from, uint to) public {
        // 这将执行库合约中的函数调用
        uint index = data.indexOf(from);
        if (index == type(uint).max)
            data.push(to);
        else
            data[index] = to;
    }
}

注意,所有的外部库调用实际都是EVM函数调用。 这意味着,如果传递内存或值类型,即使是 self 变量,也会执行复制。 只有在使用存储引用变量或调用内部库函数时,才不会执行复制。

另一个展示了如何为用户定义的类型定义自定义操作符的示例:

// SPDX-License-Identifier: GPL-3.0
pragma solidity ^0.8.19;

type UFixed16x2 is uint16;

using {
    add as +,
    div as /
} for UFixed16x2 global;

uint32 constant SCALE = 100;

function add(UFixed16x2 a, UFixed16x2 b) pure returns (UFixed16x2) {
    return UFixed16x2.wrap(UFixed16x2.unwrap(a) + UFixed16x2.unwrap(b));
}

function div(UFixed16x2 a, UFixed16x2 b) pure returns (UFixed16x2) {
    uint32 a32 = UFixed16x2.unwrap(a);
    uint32 b32 = UFixed16x2.unwrap(b);
    uint32 result32 = a32 * SCALE / b32;
    require(result32 <= type(uint16).max, "Divide overflow");
    return UFixed16x2.wrap(uint16(a32 * SCALE / b32));
}

contract Math {
    function avg(UFixed16x2 a, UFixed16x2 b) public pure returns (UFixed16x2) {
        return (a + b) / UFixed16x2.wrap(200);
    }
}