智能合约开发从0到100(7)- 表达式和控制结构

260 阅读4分钟

控制结构

JavaScript 中的大部分控制结构在 Solidity 中都是可用的,除了 switchgoto。 因此 Solidity 中有 ifelsewhiledoforbreakcontinuereturn? : 这些与在 C 或者 JavaScript 中表达相同语义的关键词。

Solidity还支持 try/ catch 语句形式的异常处理,但仅用于 外部函数调用 和合约创建调用。

注意,与 C 和 JavaScript 不同, Solidity 中非布尔类型数值不能转换为布尔类型,因此 if (1) { ... } 的写法在 Solidity 中 无效

函数调用

内部函数调用

当前合约中的函数可以直接(“从内部”)调用,也可以递归调用,

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

// 编译器会有警告提示
contract C {
    function g(uint a) public pure returns (uint ret) { return f(); }
    function f() internal pure returns (uint ret) { return g(7) + f(); }
}

这些函数调用在 EVM 中被解释为简单的跳转。这样做的效果就是当前内存不会被清除,例如,函数之间通过传递内存引用进行内部调用是非常高效的。 只能在同一合约实例的函数,可以进行内部调用。

只有在同一合约的函数可以内部调用。仍然应该避免过多的递归调用, 因为每个内部函数调用至少使用一个堆栈槽, 并且最多有1024堆栈槽可用。

外部函数调用

函数也可以使用表达式 this.g(8);c.g(2); 进行调用,其中 c 是合约实例,

它是通过消息调用来进行,而不是直接的代码跳转。 请注意,不可以在构造函数中通过 this 来调用函数,因为此时真实的合约实例还没有被创建。

如果想要调用其他合约的函数,需要外部调用。对于一个外部调用,所有的函数参数都需要被复制到内存。

当调用其他合约的函数时,需要在函数调用是指定发送的 Wei 和 gas 数量,可以使用特定选项 {value: 10, gas: 10000}

不建议明确指定gas,因为操作码的 gas 消耗将来可能会发生变化

任何发送给合约 Wei 将被添加到目标合约的总余额中:

pragma solidity >=0.6.2 <0.9.0;

contract InfoFeed {
    function info() public payable returns (uint ret) { return 42; }
}

contract Consumer {
    InfoFeed feed;
    function setFeed(InfoFeed addr) public { feed = addr; }
    function callFeed() public { feed.info{value: 10, gas: 800}(); }
}

payable 修饰符要用于修饰 info 函数,否则, value 选项将不可用。

具名参数函数调用

函数调用参数也可以按照任意顺序由名称给出,如果它们被包含在 { }

参数列表必须按名称与函数声明中的参数列表相符,但可以按任意顺序排列。

pragma solidity >=0.4.0 <0.9.0;

contract C {
    mapping(uint => uint) data;

    function f() public {
        set({value: 2, key: 3});
    }

    function set(uint key, uint value) public {
        data[key] = value;
    }

}

省略函数参数名称

未使用参数的名称(特别是返回参数)可以省略。这些参数仍然存在于堆栈中,但它们无法访问。

函数声明中的参数和返回值的名称可以省略。 那些被省略的项目仍然会出现在堆栈中,但是它们无法通过名称访问。 省略名称的返回值, 仍然可以通过使用 return 语句向调用者返回一个值。

pragma solidity >=0.4.22 <0.9.0;

contract C {
    // 省略参数名称
    function func(uint k, uint) public pure returns(uint) {
        return k;
    }
}

通过 new 创建合约

使用关键字 new 可以创建一个新合约。待创建合约的完整代码必须事先知道,因此递归的创建依赖是不可能的。

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

contract D {
    uint x;
    function D(uint a) payable {
        x = a;
    }
}

contract C {
    D d = new D(4); // 将作为合约 C 构造函数的一部分执行

    function createD(uint arg) public {
        D newD = new D(arg);
    }

    function createAndEndowD(uint arg, uint amount) public payable {
                //随合约的创建发送 ether
        D newD = (new D){value:amount}(arg);
    }
}

如示例中所示,通过使用 value 选项创建 D 的实例时可以附带发送 Ether,但是不能限制 gas 的数量。 如果创建失败(可能因为栈溢出,或没有足够的余额或其他问题),会引发异常。

加“盐”的合约创建 / create2

在创建合约时,将根据创建合约的地址和每次创建合约交易时的计数器(nonce)来计算合约的地址。

如果你指定了一个可选的 salt (一个bytes32值),那么合约创建将使用另一种机制(create2)来生成新合约的地址:

它将根据给定的盐值,创建合约的字节码和构造函数参数来计算创建合约的地址。

特别注意,这里不再使用计数器(“nonce”)。 这样可以在创建合约时提供更大的灵活性:你可以在创建新合约之前就推导出(将要创建的)合约地址。 甚至是,还可以依赖此地址(即便它还不存在)来创建其他合约。一个主要用例场景是充当链下交互仲裁合约,仅在有争议时才需要创建。

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

contract D {
    uint public x;
    constructor(uint a) {
        x = a;
    }
}

contract C {
    function createDSalted(bytes32 salt, uint arg) public {
        /// 这个复杂的表达式只是告诉我们,如何预先计算地址。
        /// 这里仅仅用来说明。
        /// 实际上,你仅仅需要 ``new D{salt: salt}(arg)``.
        address predictedAddress = address(uint160(uint(keccak256(abi.encodePacked(
            bytes1(0xff),
            address(this),
            salt,
            keccak256(abi.encodePacked(
                type(D).creationCode,
                abi.encode(arg)
            ))
        )))));

        D d = new D{salt: salt}(arg);
        require(address(d) == predictedAddress);
    }
}

使用 create2 创建合约还有一些特别之处。 合约销毁后可以在同一地址重新创建。不过,即使创建字节码(creation bytecode)相同(这是要求,因为否则地址会发生变化),该新创建的合约也可能有不同的部署字节码(deployed bytecode)。 这是因为构造函数可以使用两次创建合约之间可能已更改的外部状态,并在存储合约时将其合并到部署字节码中。

赋值

解构赋值和返回多值

Solidity 内部允许元组 (tuple) 类型,也就是一个在编译时元素数量固定的对象列表,列表中的元素可以是不同类型的对象。这些元组可以用来同时返回多个数值,也可以用它们来同时给多个新声明的变量或者既存的变量

pragma solidity >=0.5.0 <0.9.0;

contract C {
    uint index;

    function f() public pure returns (uint, bool, uint) {
        return (7, true, 2);
    }

    function g() public {
        //基于返回的元组来声明变量并赋值
        (uint x, bool b, uint y) = f();
        //交换两个值的通用窍门——但不适用于非值类型的存储 (storage) 变量。
        (x, y) = (y, x);
        //元组的末尾元素可以省略(这也适用于变量声明)。
        (index,,) = f(); // 设置 index 为 7
    }
}

不可能混合变量声明和非声明变量复制, 即以下是无效的: (x, uint y) = (1, 2);

数组和结构体的复杂性

由于数据位置,赋值语义对于像数组和结构体(包括 bytesstring) 这样的非值类型来说会有些复杂。

在下面的示例中, 对 g(x) 的调用对 x 没有影响, 因为它在内存中创建了存储值独立副本。但是, h(x) 成功修改 x , 因为只传递引用而不传递副本。

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

 contract C {
    uint[20] x;

     function f() public {
        g(x);
        h(x);
    }

     function g(uint[20] memory y) internal pure {
        y[2] = 3;
    }

     function h(uint[20] storage y) internal {
        y[3] = 4;
    }
}

作用域和声明

变量声明后将有默认初始值,其初始值字节表示全部为零。任何类型变量的“默认值”是其对应类型的典型“零状态”。例如, bool 类型的默认值是 falseuintint 类型的默认值是 0 。对于静态大小的数组和 bytes1bytes32 ,每个单独的元素将被初始化为与其类型相对应的默认值。 最后,对于动态大小的数组 bytesstring 类型,其默认缺省值是一个空数组或空字符串。

对于 enum 类型, 默认值是第一个成员。

Solidity 中的作用域规则遵循了 C99(与其他很多语言一样):变量将会从它们被声明之后可见,直到一对 { } 块的结束。作为一个例外,在 for 循环语句中初始化的变量,其可见性仅维持到 for 循环的结束。

那些定义在代码块之外的变量,比如函数、合约、自定义类型等等,并不会影响它们的作用域特性。这意味着你可以在实际声明状态变量的语句之前就使用它们,并且递归地调用函数。

基于以上的规则,下边的例子不会出现编译警告,因为那两个变量虽然名字一样,但却在不同的作用域里。

pragma solidity >=0.5.0 <0.9.0;
contract C {
    function minimalScoping() pure public {
        {
            uint same;
            same = 1;
        }

        {
            uint same;
            same = 3;
        }
    }
}

请注意在下边的例子里,第一次对 x 的赋值会改变上一层中声明的变量值。如果外层声明的变量被“覆盖”(就是说被在内部作用域中由一个同名变量所替代)你会得到一个警告。

pragma solidity >=0.5.0 <0.9.0;
// 有警告
contract C {
    function f() pure public returns (uint) {
        uint x = 1;
        {
            x = 2; // 这个赋值会影响在外层声明的变量
            uint x;
        }
        return x; // x has value 2
    }
}

算术运算的检查模式与非检查模式

当对无限制整数执行算术运算,其结果超出结果类型的范围,这是就发生了上溢出或下溢出。

如果想要之前“截断”的效果,可以使用 unchecked 代码块:

// SPDX-License-Identifier: GPL-3.0
pragma solidity ^0.8.0;
contract C {
    function f(uint a, uint b) pure public returns (uint) {
        // 减法溢出会返回“截断”的结果
        unchecked { return a - b; }
    }
    function g(uint a, uint b) pure public returns (uint) {
        // 溢出会抛出异常
        return a - b;
    }
}

调用 f(2, 3) 将返回 2**256-1, 而 g(2, 3) 会触发失败异常。

unchecked 代码块可以在代码块中的任何位置使用,但不可以替代整个函数代码块,同样不可以嵌套。

错误处理及异常:Assert, Require, Revert

Solidity 使用状态恢复异常来处理错误。这种异常将撤消对当前调用(及其所有子调用)中的状态所做的所有更改,并且还向调用者标记错误。

如果异常在子调用发生,那么异常会自动冒泡到顶层(例如:异常会重新抛出),除非他们在 try/catch 语句中捕获了错误。 但是如果是在 send 和 低级别如: call, delegatecallstaticcall 的调用里发生异常时, 他们会返回 false (第一个返回值) 而不是冒泡异常

异常可以包含错误数据,以 error 示例 的形式传回给调用者。 内置的错误 Error(string)Panic(uint256) 被作为特殊函数使用,下面将解释。 Error 用于 “常规” 错误条件,而 Panic 用于在(无bug)代码中不应该出现的错误。

assert 检查异常(Panic) 和 require 检查错误(Error)

函数 assertrequire 可用于检查条件并在条件不满足时抛出异常。

assert 函数会创建一个 Panic(uint256) 类型的错误。 同样的错误在以下列出的特定情形会被编译器创建。

assert 函数应该只用于测试内部错误,检查不变量,正常的函数代码永远不会产生Panic, 甚至是基于一个无效的外部输入时。 如果发生了,那就说明出现了一个需要你修复的 bug。如果使用得当,语言分析工具可以识别出那些会导致 Panic 的 assert 条件和函数调用。

下列情况将会产生一个Panic异常: 错误数据会提供的错误码编号,用来指示Panic的类型:

  1. 0x00: 用于常规编译器插入的Panic。
  2. 0x01: 如果你调用 assert 的参数(表达式)结果为 false 。
  3. 0x11: 在 unchecked { ... } 外,如果算术运算结果向上或向下溢出。
  4. 0x12; 如果你用零当除数做除法或模运算(例如 5 / 023 % 0 )。
  5. 0x21: 如果你将一个太大的数或负数值转换为一个枚举类型。
  6. 0x22: 如果你访问一个没有正确编码的存储byte数组.
  7. 0x31: 如果在空数组上 .pop()
  8. 0x32: 如果你访问 bytesN 数组(或切片)的索引太大或为负数。(例如: x[i]i >= x.lengthi < 0).
  9. 0x41: 如果你分配了太多的内内存或创建了太大的数组。
  10. 0x51: 如果你调用了零初始化内部函数类型变量。

require 函数可以创建无错误提示的错误,也可以创建一个 Error(string) 类型的错误。 require 函数应该用于确认条件有效性,例如输入变量,或合约状态变量是否满足条件,或验证外部合约调用返回的值。

下列情况将会产生一个 Error(string) (或无错误提示)的错误:

  1. 如果你调用 require(x) ,而 x 结果为 false
  2. 如果你使用 revert() 或者 revert("description")
  3. 如果你在不包含代码的合约上执行外部函数调用。
  4. 如果你通过合约接收以太币,而又没有 payable 修饰符的公有函数(包括构造函数和 fallback 函数)。
  5. 如果你的合约通过公有 getter 函数接收 Ether 。

在下面的情况下,来自外部调用的错误数据(如果提供的话)被转发,这意味可能 Error 或 Panic 都有可能触发。

  1. 如果 .transfer() 失败。
  2. 如果你通过消息调用调用某个函数,但该函数没有正确结束(例如, 它耗尽了 gas,没有匹配函数,或者本身抛出一个异常),不包括使用低级别 callsenddelegatecallcallcodestaticcall 的函数调用。低级操作不会抛出异常,而通过返回 false 来指示失败。
  3. 如果你使用 new 关键字创建合约,但合约创建 没有正确结束

你可以选择给 require 提供一个消息字符串,但 assert 不行。

如果你没有为 require 提供一个字符串参数,它会用空错误数据进行 revert, 甚至不包括错误选择器。

在下例中,你可以看到如何轻松使用 require 检查输入条件以及如何使用 assert 检查内部错误.

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

contract Sharer {
    function sendHalf(address addr) public payable returns (uint balance) {
        require(msg.value % 2 == 0, "Even value required.");
        uint balanceBeforeTransfer = this.balance;
        addr.transfer(msg.value / 2);

        // 由于转账函数在失败时抛出异常并且不会调用到以下代码,因此我们应该没有办法检查仍然有一半的钱。
        assert(this.balance == balanceBeforeTransfer - msg.value / 2);
        return this.balance;
    }
}

在内部, Solidity 对异常执行回退操作(指令 0xfd ),从而让 EVM 回退对状态所做的所有更改。回退的原因是无法安全地继续执行,因为无法达到预期的结果。 因为我们想要保持交易的原子性,最安全的动作是回退所有的更改,并让整个交易(或至少调用)没有任何新影响。

在这两种情况下,调用者都可以使用 try/ catch 来应对此类失败,但是被调用函数的更改将始终被还原。

revert

可以使用 revert 语句和 revert 函数来直接触发回退。

revert 语句将一个自定义的错误作为直接参数,没有括号:

revert CustomError(arg1, arg2);

由于向后兼容,还有一个 revert() 函数,它使用圆括号接受一个字符串:

revert(); revert(“description”);

错误数据将被传回给调用者,以便在那里捕获到错误数据。 使用 revert() 会触发一个没有任何错误数据的回退,而 revert("description") 会产生一个 Error(string) 错误

下面的例子显示了如何使用一个错误字符串和一个自定义错误实例,他们和 revert 或相应的 require 一起使用。

contract VendingMachine {
    address owner;
    error Unauthorized();
    function buy(uint amount) public payable {
        if (amount > msg.value / 2 ether)
            revert("Not enough Ether provided.");
        // 另一个可选的方式:
        require(
            amount <= msg.value / 2 ether,
            "Not enough Ether provided."
        );

        // 以下执行购买逻辑
    }
    function withdraw() public {
        if (msg.sender != owner)
            revert Unauthorized();

        payable(msg.sender).transfer(address(this).balance);
    }
}

只要参数没有额外的附加效果,使用 if (!condition) revert(...);require(condition, ...); 是等价的,例如当参数是字符串的情况。

try/catch

外部调用的失败,可以通过 try/catch 语句来捕获,例如:

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

interface DataFeed { function getData(address token) external returns (uint value); }

contract FeedConsumer {
    DataFeed feed;
    uint errorCount;
    function rate(address token) public returns (uint value, bool success) {
        // 如果错误超过 10 次,永久关闭这个机制
        require(errorCount < 10);
        try feed.getData(token) returns (uint v) {
            return (v, true);
        } catch Error(string memory /*reason*/) {
            // This is executed in case
            // revert was called inside getData
            // and a reason string was provided.
            errorCount++;
            return (0, false);
        }  catch Panic(uint /*errorCode*/) {
            // This is executed in case of a panic,
            // i.e. a serious error like division by zero
            // or overflow. The error code can be used
            // to determine the kind of error.
            errorCount++;
            return (0, false);
        } catch (bytes memory /*lowLevelData*/) {
            // This is executed in case revert() was used。
            errorCount++;
            return (0, false);
        }
    }
}

try 关键词后面必须有一个表达式,代表外部函数调用或合约创建( new ContractName())。

在表达式上的错误不会被捕获(例如,如果它是一个复杂的表达式,还涉及内部函数调用),只有外部调用本身发生的revert 可以捕获。 接下来的 returns 部分(是可选的)声明了与外部调用返回的类型相匹配的返回变量。 在没有错误的情况下,这些变量被赋值,合约将继续执行第一个成功块内代码。 如果到达成功块的末尾,则在 catch 块之后继续执行。

  • catch Error(string memory reason) { ... }: 如果错误是由 revert("reasonString")require(false, "reasonString") (或导致这种异常的内部错误)引起的,则执行这个catch子句。
  • catch Panic(uint errorCode) { ... }: 如果错误是由 panic 引起的(如: assert 失败,除以0,无效的数组访问,算术溢出等),将执行这个catch子句。
  • catch (bytes memory lowLevelData) { ... }: 如果错误签名不符合任何其他子句,如果在解码错误信息时出现了错误,或者如果异常没有一起提供错误数据。在这种情况下,子句声明的变量提供了对低级错误数据的访问。
  • catch { ... }: 如果你对错误数据不感兴趣,你可以直接使用 catch { ... } (甚至是作为唯一的catch子句) 而不是前面几个catch子句。

为了捕捉所有的错误情况,你至少要有子句 catch { ... }catch (bytes memory lowLevelData) { ... }.