智能合约编程语言-solidity快速入门(下)

时间:2022-07-25
本文章向大家介绍智能合约编程语言-solidity快速入门(下),主要内容包括其使用实例、应用技巧、基本知识点总结和需要注意事项,具有一定的参考价值,需要的朋友可以参考一下。

上一篇:智能合约编程语言-solidity快速入门(上)


solidity区块及交易属性

在介绍区块及交易属性之前,我们需要先知道solidity中自带了一些全局变量和函数,这些变量和函数可以认为是solidity提供的API,这些 API 主要表现为Solidity 内置的特殊的变量及函数,它们存在于全局命名空间里,主要分为以下几类:

  1. 有关区块和交易的属性
  2. ABI编码函数
  3. 有关错误处理
  4. 有关数学及加密功能
  5. 有关地址和合约

我们在编写智能合约的时候就可以通过这些API来获取区块和交易的属性(Block And Transaction Properties),简单来说这些API主要用来提供一些区块链当前的信息,下表列出常用的一些API:

API

描述

blockhash(uint blockNumber) returns (bytes32)

返回给定区块号的哈希值,只支持最近256个区块,且不包含当前区块

block.coinbase (address)

获取当前块矿工的地址

block.difficulty (uint)

获取当前块的难度

block.gaslimit (uint)

获取当前块的gaslimit

block.number (uint)

获取当前区块的块号

block.timestamp (uint)

获取当前块的Unix时间戳(从1970/1/1 00:00:00 UTC开始所经过的秒数)

gasleft() (uint256)

获取剩余gas

msg.data (bytes)

获取完整的调用数据(calldata)

msg.gas (uint)

获取当前还剩的gas(已弃用)

msg.sender (address)

获取当前调用发起人的地址

msg.sig (bytes4)

获取调用数据(calldata)的前四个字节(例如为:函数标识符)

msg.value (uint)

获取这个消息所附带的以太币,单位为wei

now (uint)

获取当前块的时间戳(实际上是block.timestamp的别名)

tx.gasprice (uint)

获取交易的gas价格

tx.origin (address)

获取交易的发送者(全调用链)

注意:

msg的所有成员值,如msg.sender,msg.value的值可以因为每一次外部函数调用,或库函数调用发生变化(因为msg就是和调用相关的全局变量)。 不应该依据 block.timestamp, now 和 block.blockhash来产生一个随机数(除非你确实需要这样做),这几个值在一定程度上被矿工影响(比如在×××合约里,不诚实的矿工可能会重试去选择一个对自己有利的hash)。 对于同一个链上连续的区块来说,当前区块的时间戳(timestamp)总是会大于上一个区块的时间戳。为了可扩展性的原因,你只能查最近256个块,所有其它的将返回0.

接下来使用代码演示一下常用的全局变量:

pragma solidity ^0.4.17;

contract SolidityAPI {

    function getSender() public constant returns(address) {
        // 获取当前调用发起人的地址
        return msg.sender;
    }

    function getValue() public constant returns(uint) {
        // 获取这个消息所附带的以太币,单位为wei
        return msg.value;
    }

    function getBlockCoinbase() public constant returns(address) {
        // 获取当前块矿工的地址
        return block.coinbase;
    }

    function getBlockDifficulty() public constant returns(uint) {
        // 获取当前块的难度
        return block.difficulty;
    }

    function getBlockNumber() public constant returns(uint) {
        // 获取当前区块的块号
        return block.number;
    }

    function getBlockTimestamp() public constant returns(uint) {
        // 获取当前块的Unix时间戳
        return block.timestamp;
    }

    function getNow() public constant returns(uint) {
        // 获取当前块的时间戳
        return now;
    }

    function getGasprice() public constant returns(uint) {
        // 获取交易的gas价格
        return tx.gasprice;
    }
}

ABI编码函数

ABI全称Application Binary Interface,翻译过来就是:应用程序二进制接口,是调用智能合约函数以及合约之间函数调用的消息编码格式定义,也可以理解为智能合约函数调用的接口说明。类似Webservice里的SOAP协议一样;也就是定义操作函数签名,参数编码,返回结果编码等。

简单来说从外部施加给以太坊的行为都称之为向以太坊网络提交了一个交易, 调用合约函数其实是向合约地址(账户)提交了一个交易,这个交易有一个附加数据,这个附加的数据就是ABI的编码数据。因此要想和合约交互,就离不开ABI数据。

solidity 提供了以下函数,用来直接得到ABI编码信息,如下表:

函数

描述

abi.encode(...) returns (bytes)

计算参数的ABI编码

abi.encodePacked(...) returns (bytes)

计算参数的紧密打包编码

abi. encodeWithSelector(bytes4 selector, ...) returns (bytes)

计算函数选择器和参数的ABI编码

abi.encodeWithSignature(string signature, ...) returns (bytes)

等价于 abi.encodeWithSelector(bytes4(keccak256(signature), ...)

通过ABI编码函数可以在不用调用函数的情况下,获得ABI编码值,下面通过一段代码来看看这些方式的使用:

pragma solidity ^0.4.24;

contract testABI {
    uint storedData;

    function set(uint x) public {
        storedData = x;
    }

    function abiEncode() public constant returns (bytes) {
            // 计算 1 的ABI编码
        abi.encode(1);  

                //计算函数set(uint256) 及参数1 的ABI 编码
        return abi.encodeWithSignature("set(uint256)", 1); 
    }
}

solidity错误处理

在很多编程语言中都具有错误处理机制,在solidity中自然也不例外,solidity最开始的错误处理方式是使用throw以及if … throw,后来因为这种方式会消耗掉所有剩余的gas,所以目前throw的方式已经被弃用,改为使用以下函数进行错误处理:

函数

描述

assert(bool condition)

用于判断内部错误,条件不满足时抛出异常

require(bool condition)

用于判断输入或外部组件错误,条件不满足时抛出异常

require(bool condition, string message)

同上,多了一个错误信息

revert()

终止执行并还原改变的状态

revert(string reason)

同上,提供一个错误信息

solidity中的错误处理机制和其他大多数编程语言不一样,solidity是通过回退状态来进行错误处理的,就像数据库事务一样,也就是说solidity没有try-catch这种捕获异常的方式。在发生异常时solidity会撤销当前调用(及其所有子调用)所改变的状态,同时给调用者返回一个错误标识。但是消耗的gas不会回退,会正常消耗掉。

solidity之所以使用这种方式处理错误,是因为区块链就类似于全球共享的分布式事务性数据库(公链)。全球共享意味着参与这个网络的每一个人都可以读写其中的数据,如果没有这种事务一般的错误处理机制就会导致一些操作成功一些操作失败,所带来的结果就是数据的混乱、不一致。所以使用这种事务一般的错误处理机制可以保证一组调用及其子调用要么成功要么失败回滚,就像啥事都没有发生一样,solidity错误处理就是要保证每次调用都是具有事务性的。


大概了解了solidity的错误处理机制后,我们来看看如何在solidity中进行错误处理。从上表中可以看到solidity提供了两个函数assert和require来进行条件检查,如果条件不满足则抛出异常。assert函数通常用来检查(测试)内部错误,而require函数来检查输入变量或合同状态变量是否满足条件以及验证调用外部合约返回值。

另外,如果我们正确使用assert,使用一些solidity分析工具就可以帮我们分析出智能合约中的错误,帮助我们发现合约中有逻辑错误的bug。

assert和require两个函数实际上也就对应着两种类型的异常 ,即assert类型异常及require类型异常。当发生assert类型异常时,会消耗掉所有提供的gas,而require类型异常则不会消耗。当发生require类型的异常时,Solidity会执行一个回退操作(指令0xfd)。当发生assert类型的异常时,Solidity会执行一个无效操作(指令0xfe)。

在上述的两种情况下,EVM都会撤回所有的状态改变。是因为期望的结果没有发生,就没法继续安全执行。必须保证交易的原子性(一致性,要么全部执行,要么一点改变都没有,不能只改变一部分),所以需要撤销所有操作,让整个交易没有任何影响。

自动产生assert类型异常的场景:

  1. 如果越界,或负的序号值访问数组,如i >= x.length 或 i < 0时访问x[i]
  2. 如果序号越界,或负的序号值时访问一个定长的bytesN。
  3. 被除数为0, 如5/0 或 23 % 0。
  4. 对一个二进制移动一个负的值。如:5<<i; i为-1时。
  5. 整数进行可以显式转换为枚举时,如果将过大值,负值转为枚举类型则抛出异常
  6. 如果调用未初始化内部函数类型的变量。
  7. 如果调用assert的参数为false

自动产生require类型异常的场景:

  1. 调用throw
  2. 如果调用require的参数为false
  3. 如果你通过消息调用一个函数,但在调用的过程中,并没有正确结束(gas不足,没有匹配到对应的函数,或被调用的函数出现异常)。底层操作如call,send,delegatecall或callcode除外,它们不会抛出异常,但它们会通过返回false来表示失败。
  4. 如果在使用new创建一个新合约时出现第3条的原因没有正常完成。
  5. 如果调用外部函数调用时,被调用的对象不包含代码。
  6. 如果合约没有payable修饰符的public的函数在接收以太币时(包括构造函数,和回退函数)。
  7. 如果合约通过一个public的getter函数(public getter funciton)接收以太币。
  8. 如果.transfer()执行失败

除了可以两个函数assert和require来进行条件检查,另外还有两种方式来触发异常:

  • revert函数可以用来标记错误并回退当前调用
  • 使用throw关键字抛出异常(从0.4.13版本,throw关键字已被弃用,将来会被淘汰。)

当子调用中发生异常时,异常会自动向上“冒泡”。 不过也有一些例外:send,和底层的函数调用call, delegatecall,callcode,当发生异常时,这些函数返回false。

注意:在一个不存在的地址上调用底层的函数call,delegatecall,callcode 也会返回成功,所以我们在进行调用时,应该总是优先进行函数存在性检查。

在下面通过一个示例来说明如何使用require来检查输入条件,代码中使用了require函数检查msg.value的值是否为偶数,此时我们设置value值为2,可以正常的运行sendHalf函数:

详细的日志如下:

接着我们测试异常的情况,将value改成1,即不能被2整除的数,执行sendHalf函数后,控制台输出的错误日志如下,从错误日志中我们可以看到此次交易被reverted到一个初始的状态:

然后我们再来看一个示例,使用assert函数检查内部错误:

pragma solidity ^0.4.20;

contract Sharer {
    function sendHalf(address addr) public payable returns(uint balance){
        // 仅允许偶数
        require(msg.value % 2 == 0); 
        uint balanceBeforeTransfer = this.balance;

        addr.transfer(msg.value / 2);
        // 检查当前的balance是否为转移之前的一半,不符合条件则会抛出异常
        assert(this.balance == balanceBeforeTransfer - msg.value / 2);
        return this.balance;
    }
}

solidity 函数参数

本小节我们来介绍一下solidity中的函数参数,与其他编程语言一样,solidity 函数可以提供参数作为输入并且函数类型本身也可以作为参数,与JavaScript和C不同的是,solidity还可以返回任意数量的返回值作为输出。

1.输入参数,输入参数的声明方式与变量相同, 未使用的参数可以省略变量名称。假设我们希望合约中的某个函数被外部调用时,传入两个整型参数,那么就可以这样写:

pragma solidity ^0.4.16;

contract Test {
    function inputParam(uint a, uint b) public {
        // ...
    }
}

2.输出参数,输出参数的声明和输入参数一样,只不过它接在returns之后,也就是函数的返回值,只不过在solidity中函数的返回值可以像输入参数一样被处理。假设我们希望返回两个结果,两个给定整数的和以及积,可以这样写:

pragma solidity ^0.4.16;

contract Test {
    function testOutput(uint a, uint b) public returns (uint sum, uint mul) {
        sum = a + b;
        mul = a * b;
    }
}

可以省略输出参数的名称,也可以使用return语句指定输出值,return可以返回多个值。(当返回一个没有赋值的参数时,默认为0)

输入参数和输出参数可以在函数内表达式中使用,也可以作为被赋值的对象, 如下示例:

contract Test {
    function testOutput(uint a, uint b) public returns (uint c) {
        a = 1;
        b = 2;
        c = 3;
    }
}

3.命名参数,调用某个函数时传递的参数,可以通过指定名称的方式传递,使用花括号{}包起来,参数顺序任意,但参数的类型和数量要与定义一致,这与Python中的关键字参数一样的。如:

pragma solidity ^0.4.0;

contract Test {
    function a(uint key, uint value) public {
        // ...
    }

    function b() public {
        // 命名参数
        a({value: 2, key: 3});
    }
}

4.参数解构,当一个函数有多个输出参数时,可以使用元组(tuple)来返回多个值。元组(tuple)是一个数量固定,类型可以不同的元素组成的一个列表(用小括号表示),使用return (v0, v1, …, vn) 语句,就可以返回多个值,返回值的数量需要和输出参数声明的数量一致。当函数返回多个值时,可以使用多个变量去接收,此时元组内的元素就会同时赋值给多个变量,这个过程就称之为参数解构。如下示例:

function a() public pure returns (uint, bool, uint) {
    // 使用元组返回多个值
    return (7, true, 2);
}

function b() public {
    uint x;
    bool y;
    uint z;

    // 使用元组给多个变量赋值
    (x, y , z)  = a();
}

solidity 流程控制语句

solidity 的流程控制语句与其他大多数语言一致,拥有if、else、while、do、for、break、continue、return以及三元表达式 ? :等流程控制语句,这些语句在solidity中的含义与其他语言是一致的这里就不再详细赘述了,不过要注意的是solidity中没有switch和goto语句。

以下使用一个简单的例子演示一下这些流程控制语句的使用方式,代码如下:

pragma solidity ^0.4.20;

contract Test {
    function testWhile() public constant returns(uint){
        uint i = 0;
        uint sumOfAdd = 0;

        while(true) {
            i++;

            if (i > 10){
                break;
            }

            if (i % 2 == 0) {
                continue;
            } else {
                sumOfAdd += i;
            }
        }

        sumOfAdd = sumOfAdd > 20 ? sumOfAdd + 10 : sumOfAdd;

        return sumOfAdd;
    }

    function testForLoop() public constant returns(uint) {
        uint sum = 0;
        for (uint i = 0; i < 10; i++) {
            sum +=i;
        }

        return sum;
    }
}

solidity 权限修饰符

大多数的语言都会有权限修饰符,尽管它们都不尽相同,在 solidity 中有public、private、external以及internal四种权限修饰符,接下来我们看看四种权限修饰符的作用。

1.public

public所修饰的函数称为公开函数,是合约接口的一部分,可以通过内部,或者消息来进行调用。对于public类型的状态变量,会自动创建一个访问器,这个访问器其实是一个函数。solidity 中的函数默认是public的

我们来看一个公开函数的例子,在remix上我们可以看到并执行公开的函数:


2.private

表示私有的函数和状态变量,仅在当前合约中可以访问,在继承的合约内不可以访问,也不可以被外部访问

例如我们来写一个私有函数,并且进行部署,此时会发现在外部是看不到这个函数的:


3.external

表示外部函数,与public修饰的函数有些类似,也是合约接口的一部分,但只能使用消息调用,不可以直接通过内部调用,值得注意的是external函数消耗的gas比public函数要少,所以当我们一个函数只能被外部调用时尽量使用external修饰

同样的,我们来看一个简单的例子,代码如下:


4.internal

使用此修饰符修饰的函数和状态变量只能通过内部访问,例如在当前合约中调用,或继承的合约中调用。solidity 中的状态变量默认是internal的

如下示例:


solidity 函数调用

在上一小节中,我们介绍了 solidity 中的权限修饰符,其中涉及到了内部函数调用和外部函数调用的概念,所以这一节我们进一步介绍这两个概念。

1.内部函数调用(Internal Function Calls)

内部调用,不会创建一个EVM消息调用。而是直接调用当前合约的函数,也可以递归调用。

如下面这个的例子:

pragma solidity ^0.4.20;

contract Test {
    function a(uint a) public pure returns (uint ret) {
       // 直接调用
       return b();
    }

    function b() internal pure returns (uint ret) {
       // 直接调用及递归调用
       return a(7) + b();    
    }
}

这些函数调用被转换为EVM内部的简单指令跳转(jumps)。 这样带来的一个好处是,当前的内存不会被回收。在一个内部调用时传递一个内存型引用效率将非常高的。当然,仅仅是同一个合约的函数之间才可通过内部的方式进行调用。


2.外部函数调用(External Function Calls)

外部调用,会创建EVM消息调用。表达式this.sum(8);number.add(2);(这里的number是一个合约实例)是外部调用函数的方式,它会发起一个消息调用,而不是EVM的指令跳转。需要注意的是,在合约的构造器中,不能使用this调用函数,因为当前合约还没有创建完成

其它合约的函数必须通过外部的方式调用。对于一个外部调用,所有函数的参数必须要拷贝到内存中。当调用其它合约的函数时,可以通过选项.value(),和.gas()来分别指定要发送的以太币(以wei为单位)和gas值。如下示例:

pragma solidity ^0.4.20;

contract InfoFeed {
    // 必须使用`payable`关键字修饰,否则不能通过`value()`函数来接收以太币
    function info() public payable returns (uint ret) { 
        return 42; 
    }
}

contract Consumer {
    InfoFeed feed;

    function setFeed(address addr) public {
      // 这句代码进行了一个显示的类型转换,表示给定的地址是合约`InfoFeed`类型,这里并不会执行构造器的初始化。
      // 在进行显式的类型强制转换时需要非常小心,不要调用一个未知类型的合约函数
      feed = InfoFeed(addr);
    }

    function callFeed() public {
      // 附加以太币及gas来调用info,注意这里仅仅是对发送的以太币和gas值进行了设置,真正的调用是后面的括号()
      feed.info.value(10).gas(800)();
    }
}

注:调用callFeed时,需要预先存入一定量的以太币,不然可能会因余额不足报错。

在与外部合约交互时需要注意的事项:

如果我们不知道被调用的合约源代码,那么和这些合约的交互就会有潜在的风险,即便被调用的合约继承自一个已知的父合约(因为继承仅仅要求正确实现接口,而不关注实现的内容)。因为和这些合约交互时,就相当于把自己控制权交给被调用的合约,对方几乎可以利用它做任何事。此外, 被调用的合约可以改变调用合约的状态变量,所以在编写函数时需要注意可重入性漏洞问题


solidity 函数

solidity 有以下四种函数:

  • 构造函数
  • 视图函数(constant / view)
  • 纯函数(pure)
  • 回退函数

1.构造函数:

构造函数在合约创建的时候运行,我们通常会在构造函数做一些初始化的操作,构造函数也是可以有参数的

如下示例:


2.视图函数(constant / view):

使用 constant 或者 view 关键字修饰的函数就是视图函数,视图函数不会修改合约的状态变量。constant 与 view 是等价的,constant 是view 的别名,constant在计划Solidity 0.5.0版本之后会弃用(constant这个词有歧义,view 也更能表达返回值可视),所以在新版的solidity中推荐优先使用view

视图函数有个特点就是在remix执行后可以直接看到返回值:

一个函数如果它不修改状态变量,应该声明为视图函数,以下几种情况被认为修改了状态变量:

  • 写状态变量
  • 触发事件(events)
  • 创建其他的合约
  • call调用附加了以太币
  • 调用了任何没有view或pure修饰的函数
  • 使用了低级别的调用(low-level calls)
  • 使用了包含特定操作符的内联汇编

3.纯函数(pure):

纯函数是使用 pure 关键字修饰的函数,纯函数不会读取状态变量,也不会修改状态变量

如下示例:

以下几种情况被认为是读取了状态:

  • 读状态变量
  • 访问了 this.balance
  • 访问了block、tx、msg 的成员 (msg.sig 和 msg.data除外)
  • 调用了任何没有pure修饰的函数
  • 使用了包含特定操作符的内联汇编

4.回退函数:

回退函数实际上是一个匿名函数,并且是一个只能被动调用的函数,一个合约中只能有一个回退函数。通常当我们的一个智能合约需要接收以太币的时,就需要实现回退函数,而且回退函数的实现应该尽量的简单

如下示例:

如果没有实现回退函数,其他合约是无法往该合约发送以太币的:

回退函数会在以下情况被调用:

  • 发送以太币
  • 被外部调用了一个不存在的函数