solidity -- 合约

263 阅读10分钟

合约

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

创建合约

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

一些集成开发环境,例如 Remix, 通过使用一些UI用户界面使创建合约的过程更加顺畅。 在以太坊上通过编程创建合约最好使用 JavaScript API web3.js。 现在,我们已经有了一个叫做 web3.eth.Contract 的方法能够更容易的创建合约。

创建合约时, 合约的 构造函数 (一个用关键字 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 的方式访问。
        // 这也适用于函数,特别是在构造函数中,你只能像这样(“内部地”)调用它们,
        // 因为合约本身还不存在。
        owner = msg.sender;
        // 从 `address` 到 `TokenCreator` ,是做显式的类型转换
        // 并且假定调用合约的类型是 TokenCreator,没有真正的方法来检查这一点。
        creator = TokenCreator(msg.sender);
        name = name_;
    }

    function changeName(bytes32 newName) public {

        // 只有 creator (即创建当前合约的合约)能够更改名称 —— 因为合约是隐式转换为地址的,
        // 所以这里的比较是可行的。
        if (msg.sender == address(creator))
            name = newName;
    }

    function transfer(address newOwner) public {
        // 只有当前所有者才能发送 token。
        if (msg.sender != owner) return;
        // 我们也想询问 creator 是否可以发送。
        // 请注意,这里调用了一个下面定义的合约中的函数。
        // 如果调用失败(比如,由于 gas 不足),会立即停止执行。
        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);
    }

    function isTokenTransferOK(address currentOwner, address newOwner)
        public
        view
        returns (bool ok)
    {
        // 检查一些任意的情况。
        address tokenAddress = msg.sender;
        return (keccak256(newOwner) & 0xff) == (bytes20(tokenAddress) & 0xff);
    }
}

可见性和 getter 函数

状态变量可见性

public

对于 public 状态变量会自动生成一个getter函数(见下面)。 以便其他的合约读取他们的值。 当在用一个合约里使用是,外部方式访问 (如: this.x) 会调用getter 函数,而内部方式访问 (如: x) 会直接从存储中获取值。 Setter函数则不会被生成,所以其他合约不能直接修改其值。

internal

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

private

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

🥸 设置为 `private`和`internal`,只能防止其他合约读取或修改信息,但它仍然可以在链外查看到。

函数可见性

由于 Solidity 有两种函数调用:外部调用则会产生一个 EVM 调用,而内部调用不会, 更进一步, 函数可以确定器被内部及派生合约的可访问性,这里有 4 种可见性:

external

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

public

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

internal

内部可见性函数访问可以在当前合约或派生的合约访问,不可以外部访问。 由于它们没有通过合约的ABI向外部公开,它们可以接受内部可见性类型的参数:比如映射或存储引用。

private

private 函数和状态变量仅在当前定义它们的合约中使用,并且不能被派生合约使用。

可见性标识符的定义位置,对于状态变量来说是在类型后面,对于函数是在参数列表和返回关键字中间。

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

pragma solidity >=0.4.16 <0.9.0;

contract C {
    uint private data;

    function f(uint a) private returns(uint b) { return a + 1; }
    function setData(uint a) public { data = a; }
    function getData() public returns(uint) { return data; }
    function compute(uint a, uint b) internal 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 函数

编译器自动为所有 public状态变量创建 getter 函数。对于下面给出的合约,编译器会生成一个名为 data的函数, 该函数没有参数,返回值是一个 uint类型,即状态变量 data的值。 状态变量的初始化可以在声明时完成。

pragma solidity  >=0.4.16 <0.9.0;

contract C {
    uint public data = 42;
}

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

getter 函数具有外部(external)可见性。如果在内部访问 getter(即没有 this.),它被认为一个状态变量。 如果使用外部访问(即用 this.),它被认作为一个函数。

pragma solidity >=0.4.16 <0.9.0;

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

如果你有一个数组类型的 public状态变量,那么你只能通过生成的 getter 函数访问数组的单个元素。 这个机制以避免返回整个数组时的高成本gas。 可以使用如 myArray(0)用于指定参数要返回的单个元素。 如果要在一次调用中返回整个数组,则需要写一个函数,例如:

pragma solidity >=0.4.0 <0.9.0;

contract arrayExample {
  // public state variable
  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) 是返回单个元素。

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

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;
}

这将会生成以下形式的函数,在结构体内的映射和数组(byte 数组除外)被省略了,因为没有好办法为单个结构成员或为映射提供一个键。

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;
}

函数 修改器modifier

使用 修改器modifier可以轻松改变函数的行为。 例如,它们可以在执行函数之前自动检查某个条件。 修改器modifier是合约的可继承属性,并可能被派生合约覆盖 , 但前提是它们被标记为 virtual。 有关详细信息,请参见 Modifier 重载

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

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

    address owner;

    // 这个合约只定义一个修改器,但并未使用: 它将会在派生合约中用到。
    // 修改器所修饰的函数体会被插入到特殊符号 _; 的位置。
    // 这意味着如果是 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 的 修改器modifier m , 可以使用 C.m 去引用它,而不需要使用虚拟表查找。

只能使用在当前合约或在基类合约中定义的 修改器modifier , 修改器modifier 也可以定义在库里面,但是他们被限定在库函数使用。

如果同一个函数有多个 修改器modifier,它们之间以空格隔开,修改器modifier 会依次检查执行。

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

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

修改器modifier 或函数体中显式的 return 语句仅仅跳出当前的 修改器modifier 和函数体。 返回变量会被赋值,但整个执行逻辑会从前一个 修改器modifier 中的定义的 _ 之后继续执行。

Constant 和 Immutable 状态变量

状态变量可以声明为常量constant或不可变量immutable的。 在这两种情况下,变量都不能在合约构建后修改。 对于constant,其值必须在编译时固定,而对于immutable,它仍然可以在构造时赋值。

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

编译器不会为这些变量保留一个存储槽,每次出现都会被相应的值替换。

与常规状态变量相比,常量和不可变变量的gas成本要低得多。 对于常量变量,分配给它的表达式被复制到所有访问它的地方,并且每次都重新计算。 这允许局部优化。 不可变变量在构造时被评估一次,并且它们的值被复制到代码中访问它们的所有位置。 对于这些值,保留了 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_;
        // Assignments to immutables can even access the environment.
        maxBalance = ref.balance;
    }

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

Constant

对于constant常量变量,该值在编译时必须是常量,并且必须在声明变量的地方赋值。 任何访问存储、区块链数据(例如 block.timestampaddress(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 {
        // This calls the free function internally.
        // The compiler will add its code to the contract.
        uint s = sum(arr);
        require(s >= 10);
        found = true;
    }
}

在合约之外定义的函数仍然总是在合约的上下文中执行。 他们仍然可以调用其他合约,向它们发送 Ether 并销毁调用它们的合约等。 与合约内定义的函数的主要区别在于,自由函数不能直接访问变量 this、存储变量和不在其范围内的函数。

函数参数及返回值

与 Javascript 一样,函数可能需要参数作为输入; 而与 Javascript 和 C 不同的是,它们可能返回任意数量的参数作为输出。

函数参数(输入参数)

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

例如,如果我们希望合约接受有两个整数形参的函数的外部调用,可以像下面这样写:

pragma solidity >=0.4.16 <0.9.0;

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

返回变量

函数返回变量的声明方式在关键词 returns 之后,与参数的声明方式相同。

例如,如果我们需要返回两个结果:两个给定整数的和与积,我们应该写作:

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 语句提供返回值(单个或多个):

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

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

  • 映射,
  • 内部函数类型,
  • 位置设置为存储的引用类型,
  • 多维数组(仅适用于 ABI 编码器 v1),
  • 结构(仅适用于 ABI 编码器 v1)。

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

状态可变性

view

可以将函数声明为 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;
    }
}

Pure

函数可以声明为pure函数,在这种情况下它们承诺不读取或修改状态。 特别是,应该可以在编译时仅给定输入和 msg.data 来评估pure,而无需了解当前的区块链状态。 这意味着从不可变变量中读取可以是一个非纯操作。

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

  1. 读取状态变量。
  2. 访问 address(this).balance 或者 <address>.balance
  3. 访问 blocktx, msg 中任意成员 (除 msg.sig 和 msg.data 之外)。
  4. 调用任何未标记为 pure 的函数。
  5. 使用包含某些操作码的内联汇编。
pragma solidity >=0.5.0 <0.9.0;

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

纯函数能够使用 revert() 和 require() 在 发生错误 时去回滚潜在状态更改。

还原状态更改不被视为 “状态修改”, 因为它只还原以前在没有view 或 pure 限制的代码中所做的状态更改, 并且代码可以选择捕获 revert 并不传递还原。

这种行为也符合 STATICCALL 操作码。

特殊的函数

receive

一个合约最多有一个 receive 函数, 声明函数为: receive() external payable { ... }

不需要 function 关键字,也没有参数和返回值并且必须是 external 可见性和 payable 修饰. 它可以是 virtual 的,可以被重载也可以有 修改器modifier 。

在对合约没有任何附加数据调用(通常是对合约转账)是会执行 receive函数.例如 通过 .send()or .transfer()如果 receive函数不存在,但是有payable 的 fallback 回退函数  那么在进行纯以太转账时,fallback 函数会调用.

如果两个函数都没有,这个合约就没法通过常规的转账交易接收以太(会抛出异常).

更糟的是,receive 函数可能只有 2300 gas 可以使用(如,当使用 send 或 transfer 时), 除了基础的日志输出之外,进行其他操作的余地很小。下面的操作消耗会操作 2300 gas :

  • 写入存储
  • 创建合约
  • 调用消耗大量 gas 的外部函数
  • 发送以太币
🥸 一个没有定义 fallback 函数或  receive 函数的合约,直接接收以太币(没有函数调用,即使用 `send` 或 `transfer`)会抛出一个异常, 并返还以太币(在 Solidity v0.4.0 之前行为会有所不同)。 所以如果你想让你的合约接收以太币,必须实现receive函数(使用 payable fallback 函数不再推荐,因为payable fallback功能被调用,不会因为发送方的接口混乱而失败)。 🥸 一个没有receive函数的合约,可以作为 *coinbase 交易* (又名 *矿工区块回报* )的接收者或者作为 `selfdestruct` 的目标来接收以太币。

一个合约不能对这种以太币转移做出反应,因此也不能拒绝它们。这是 EVM 在设计时就决定好的,而且 Solidity 无法绕过这个问题。

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

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

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

// This contract keeps all Ether sent to it with no way
// to get it back.
contract Sink {
    event Received(address, uint);
    receive() external payable {
        emit Received(msg.sender, msg.value);
    }
}

Fallback

合约可以最多有一个回退函数。函数声明为: fallback () external [payable] 或 fallback (bytes calldata input) external [payable] returns (bytes memory output)

没有 function 关键字。 必须是 external 可见性,它可以是 virtual 的,可以被重载也可以有 修改器modifier 。

如果在一个对合约调用中,没有其他函数与给定的函数标识符匹配fallback会被调用. 或者在没有 receive 函数 时,而没有提供附加数据对合约调用,那么fallback 函数会被执行。

fallback 函数始终会接收数据,但为了同时接收以太时,必须标记为 payable 。

如果使用了带参数的版本, input 将包含发送到合约的完整数据(等于 msg.data ),并且通过 output 返回数据。 返回数据不是 ABI 编码过的数据,相反,它返回不经过修改的数据。

更糟的是,如果回退函数在接收以太时调用,可能只有 2300 gas 可以使用,参考 receive接收函数

与任何其他函数一样,只要有足够的 gas 传递给它,回退函数就可以执行复杂的操作

🥸 `payable` 的fallback函数也可以在纯以太转账的时候执行, 如果没有接受ether的函数,  推荐总是定义一个receive函数,而不是定义一个payable的fallback函数,

函数重载

合约可以具有多个不同参数的同名函数,称为“重载”(overloading),这也适用于继承函数。以下示例展示了合约 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

本文由mdnice多平台发布