Solidity语言的七种武功秘籍

前言

FISCO BCOS使用了Solidity语言进行智能合约开发。在区块链的江湖中,一直有一种传言说Solidity是一门面向区块链平台设计的图灵完备的编程语言,支持函数调用、修饰器、重载,事件、继承和库等多种高级语言的特性,在区块链社区中,拥有广泛的影响力和活跃的江湖支持。

但是,对于初入区块链武林的少侠而言,Solidity又是一门陌生的语言。在本系列之前的两篇文章中,已经为您介绍了Solidity语言的概念与演变、基础特性。本文旨在通过介绍Solidity的一些高级的特性,帮助读者快速见识七种神功秘籍,编写高质量、可复用的Solidity代码。

小李飞刀-函数和变量的类型

没有人看见过那把飞刀。没有人能够说清楚这把刀的样子。也没有人知道这把刀是从哪个方位飞过来的。因为所有见过这把刀的人都死了。这就是小李飞刀。小李飞刀,例不虚发。

在智能合约中,函数和变量的类型就是这么不起眼,可是一旦出现疏忽,就会露出致命的破绽,轻则智能合约遭受攻击,重则出现链上合约治理崩溃,合约被毁。

基于最少知道原则(Least Knowledge Principle)的经典面向对象编程原则,一个对象应该对其他对象保持最少的了解。在优秀的Solidity编程实践中,也应符合这一原则。每个合约需要清晰、合理地定义函数的可见性,暴露最少的信息给外部,做好对内部函数的可见性的管理。

同时,正确地修饰函数和变量的类型,可以给合约内部数据提供不同级别的保护,以防止程序中非预期的操作导致数据产生错误;还能提升代码的可读性,减少误解和bug,提升代码的质量;也有利于优化合约执行的成本,提升链上资源的使用效率。

守住函数操作的大门:函数可见性

Solidity有两种函数调用:

  • 内部调用:又被称为『消息调用』。常见的有合约内部函数、父合约的函数以及库函数的调用。(例如,假设A合约中存在f函数,则在A合约内部,其他函数调用f函数的调用方式为f()。)
  • 外部调用:又被称为『EVM调用』。一般为跨合约的函数调用。在同一合约内部,也可以产生外部调用。(例如,假设A合约中存在f函数,则在B合约内可通过使用A.f()调用。在A合约内部,可以用this.f()来调用。)。

函数可以被指定为 external ,public ,internal 或者 private标识符来修饰。

标识符 作用
external 不可内部调用,在接收大量数据时更为高效。
public 同时支持内部和外部调用。
internal 只支持内部调用。
private 仅在当前合约使用,且不可被继承。

基于以上表格,我们可以得出函数的可见性 public > external > internal > private。

另外,如果函数不使用上述类型标识符,那么默认情况下函数类型为 public。

综上所述,我们可以总结一下以上标识符的不同使用场景:

  • public,公有函数,系统默认。通常用于修饰可对外暴露的函数,且该函数可能同时被内部调用
  • external,外部函数,推荐在只向外部暴露的函数的场景下使用。当函数的某个参数非常大时,如果显式地将函数标记为external,可以强制将函数存储的位置设置为calldata,这会节约函数执行时所需的存储或计算资源。
  • internal,内部函数,推荐所有合约内不对合约外暴露的函数使用,可以避免因为权限暴露而被攻击的风险。
  • private,私有函数,极少数严格保护合约函数不对合约外部开放且不可被继承的场景下使用。

不过,需要特别注意的是,无论用何种标识符,即使是private,整个函数执行的过程和数据是对所有节点可见的,其他节点可以验证和重放任意的历史函数。实际上,整个智能合约所有的数据对区块链的参与节点来说都是透明的。

经常会有刚接触区块链的用户会误解在区块链上可以通过权限控制操作来控制和保护上链数据的隐私;但是这种观点是错误的,事实上,在区块链业务数据在未做特殊加密的前提下,区块链同一账本内的所有数据会经过共识后落盘到所有节点之上,智能合约能控制和保护的只有合约数据的执行权限,而链上数据本身是全局公开和相同的,并在完成共识后落盘。

在实际的合约编程实践中,如何正确地选择函数的修饰符可谓是门『必修课』,只有掌握此节的真谛方可以自如地控制合约的函数访问的权限,提升合约的安全性。

对外暴露最少的必要信息:变量的可见性

同理,对于状态变量,也需要注意可见性修饰符。状态变量不能设置为 external ,默认是 internal 。此外,当状态变量被修饰为public,编译器会生成一个名为该状态变量的函数。
例如:

pragma solidity ^0.4.0;

contract TestContract {
    uint public year = 2020;
}

contract Caller {
    TestContract c = new TestContract();
    function f() public {
        uint local = c.year();
        //expected to be 2020
    }
}

这个机制有点像Java语言里lombok库所提供的@Getter注解,默认为一个POJO类的变量生成一个get函数,大大简化了某些合约代码的书写。

变量的可见性也需要被合理地修饰,不该公开的变量就果断用private来修饰,使合约的代码更符合『最少知道』的设计原则。

精确地将函数分为三六九等:函数的类型

函数可以被声明为pure、view。

函数类型 作用
pure 承诺不读取或修改状态。
view 保证不修改状态。

那么究竟什么是读取或修改状态呢?在FISCO BCOS中,读取状态可能是:

  1. 读取状态变量。
  2. 访问 block,tx, msg 中任意成员 (除 msg.sig 和 msg.data 之外)。
  3. 调用任何未标记为 pure 的函数。
  4. 使用包含某些操作码的内联汇编。

而修改状态可能是:

  1. 修改状态变量。
  2. 产生事件。
  3. 创建其它合约。
  4. 使用 selfdestruct。
  5. 调用任何没有标记为 view 或者 pure 的函数。
  6. 使用了底层调用。
  7. 使用包含特定操作码的内联汇编。

简单来说,读取或修改了账本相关的数据。需要注意的是,在某些版本的编译器中,并没有对这两个关键字进行强制的语法检查。

推荐尽可能地使用pure和view来声明函数,例如将没有读取或修改任何状态的库函数声明为pure,这样在提升代码可读性的同时,也使合约代码更加赏心悦目,何乐而不为呢?

编译时就确定的值:状态常量

所谓的状态常量是指被声明为constant的状态变量。一旦一个状态变量被声明为constant,那么该变量的值只能为编译时确定的值,且无法再被修改。编译器一般会在编译状态就计算出这个变量的实际值,不会给这个变量预留储存空间,所以,constant只支持修饰值类型和字符串。

状态常量一般旨在定义一些含义明确的业务常量值。

凌波微步 函数修饰器(modifier)

凌波微步是一门极上乘的轻功身法,按特定顺序踏着卦象方位行进,从第一步到最后一步正好行走一个大圈。此步法精妙异常,优美绝伦,神出鬼没,一旦使出之后便能立于不败之地。恰似函数修饰器,近可抽象和简化代码,退可校验和过滤非法操作,实乃用好Solidity编程的一大绝学。

Solidity提供了一个强大的改变函数行为的语法:函数修饰器(modifier)。一旦某个函数加上了修饰器,修饰器内定义的代码就可以作为函数的装饰被执行,听起来非常像其他高级语言中装饰器的概念。

这样说起来非常抽象,让我们来看一个具体的例子:

pragma solidity ^0.4.11;

contract owned {
    function owned() public { owner = msg.sender; }
    address owner;

    // 修饰器所修饰的函数体会被插入到特殊符号 _; 的位置。
    modifier onlyOwner {
        require(msg.sender == owner);
        _;
    }
    
    // 使用onlyOwner修饰器所修饰,执行changeOwner函数前需要首先执行onlyOwner"_;"前的语句。
    function changeOwner(address _owner) public onlyOwner {
        owner = _owner;
    }
}

如上代码所示,我们定义了onlyOwner修饰器。在修饰器体内,require语句要求msg.sender必须等于owner。后面的"_;"则表示所修饰函数中的代码。

所以,代码的实际执行顺序变成了:

  1. 执行onlyOwner修饰器的语句,先执行require语句。(执行第9行)
  2. 执行changeOwner函数的语句。(执行第15行)

由于changeOwner函数加上了onlyOwner的修饰,故只有msg.sender是owner才能成功调用此函数,否则就会报错回滚。

并且,修饰器还能传入参数,例如上述的修饰器也能写成这样:

modifier onlyOwner(address sender) {
    require(sender == owner);
    _;
}

function changeOwner(address _owner) public onlyOwner(msg.sender) {
        owner = _owner;
}

同一个函数可以有多个修饰器,它们之间以空格隔开,修饰器会依次检查执行。此外,修饰器还可以被继承和重写。

由于修饰器所提供的强大功能,修饰器也常常被用来实现权限控制、输入检查、日志记录等等。

比如,我们可以定义一个跟踪函数执行的修饰器:

event LogStartMethod();
event LogEndMethod();

modifier logMethod {
    emit LogStartMethod();
    _;
    emit LogEndMethod();
}

这样,任何用logMethod修饰器来修饰的函数可以记录其函数执行前后的日志,实现日志环绕的效果。如果你已经习惯了使用Spring框架的AOP了,那么快来试试用modifier实现一个简单的AOP功能了吧。

modifier最常见的打开方式莫过于提供函数的校验器了。在实践中,常常会将合约代码中一些检查的语句抽象并定义为一个modifier,如上实例中的onlyOwner就是个最经典的权限校验器。这样一来,连检查的逻辑也能被快速复用了,小明再也不用担心智能合约里到处都是参数检查或其他校验类代码的苦恼了。

明玉功 事件(Event)

明玉功是移花宫的绝世武学,移花宫历代宫主修炼的最高内家正宗绝顶心法,神功威力玄妙而且亦可不老长春。大家都知道智能合约编程最大的痛点之一是难于调试,而用好事件机制能够保住智能合约程序员的头发,江湖甚至还有传言说能够让程序员们青春常驻。

Solidity的事件机制,是Solidity较为独有的高级特性之一。事件允许我们方便地使用 EVM 的日志基础设施。

Solidity的事件有以下作用:

  1. 记录事件定义的参数,存储到区块链交易的日志中,提供廉价的存储。
  2. 提供一种回调机制,在事件执行成功后,由节点向注册监听的SDK发送回调通知,触发回调函数被执行。
  3. 提供一个过滤器,支持参数的检索和过滤。

事件的使用方法非常简单,两步即可玩转。第一步,使用关键字『event』来定义一个事件。例如,我们定义一个函数调用跟踪的事件。建议事件的命名以特定的前缀开始或以特定的后缀结束,这样可以更方便地和函数区分。例如,在本文中统一以『Log』前缀来命名事件,以便更加简洁、清晰地与函数相区分。

event LogCallTrace(address indexed from, address indexed to, bool result);

事件在合约中可被继承。当他们被调用时,会将参数存储到交易的日志中。这些日志与地址相关联,被保存到区块链中。这里indexed用来标记参数被搜索,否则,这些参数被存储到日志的数据部分中,而无法被搜索。

第二步,在对应的函数体内触发定义的事件,在事件调用的时候,在事件名前加上『emit』关键字:

function f() public {
    emit LogCallTrace(msg.sender, this, true);
}

这样,当函数体被执行的时候,会触发LogCallTrace的执行。

最后,在FISCO BCOS的Java SDK中,合约事件推送功能提供了合约事件的异步推送机制,客户端向节点发送注册请求,在请求中携带客户端关注的合约事件的参数,节点根据请求参数对请求区块范围的Event Log进行过滤,将结果分次推送给客户端。更多细节可以参考合约事件推送功能文档。在SDK中,可以根据事件的indexed属性,根据特定值可以进行搜索。

不过,日志和事件无法被直接访问,甚至在创建的合约中也无法被直接访问。

但是,好消息是日志的定义和声明非常利于在『事后』进行追溯和导出。例如,我们可以在合约的编写中,定义和埋入足够的事件,通过WEBASE的数据导出子系统,我们可以将所有的日志导出到MySQL等数据库中,这特别适用于复杂的链上数据查询、数据分析和数据报表等功能,例如生成对账文件、生成报表、复杂业务场景的OLTP查询等场景。而数据导出子系统非常好用,因为我们还提供了一个专用的代码生成子系统来帮助分析具体的业务合约,自动生成相应的代码。

在Solidity中,事件是一个非常有用的机制,极大地方便了智能合约的开发和测试。如果说智能合约开发最大的痛点是难于debug,那么善用事件机制可以让你快速制伏Solidity开发。

九阳神功 重载

九阳真经的真谛正所谓『他强由他强,清风拂山岗』。不论敌人如何强猛、如何凶恶,尽可当他是清风拂山,明月映江,虽能加于我身,却不能有丝毫损伤。

这恰如重载是指合约可以具有多个不同参数的同名函数。对于调用者来说,可以使用相同的函数名来调用功能相同的多个不同参数的函数,这在某些场景下,可以使代码更加清晰,易于理解,相信具有一定编程经验的读者对此一定深有体会。

一个典型的重载语法如下:

pragma solidity ^0.4.25;

contract Test {
    function f(uint _in) public pure returns (uint out) {
        out = 1;
    }

    function f(uint _in, bytes32 _key) public pure returns (uint out) {
        out = 2;
    }
}

需要注意的是,每个合约只有一个构造函数,这也就意味着合约的构造函数是不支持重载的。

我们可以想像一个没有重载的世界,那一定是一个绞尽脑汁给函数起名的存在,程序员的头发又要少几根了。

吸星大法:继承

日月神教令人闻风丧胆的内功心法,可以在一瞬之间吸取其他武林高手毕生的修为,为我所用。可是,吸星大法终究过于霸道,稍有不慎亦有反噬之力。

智能合约的继承也是如此,既可以在一夜之间直接复用其他成熟的合约,但是假如复用不当,也会产生若干副作用,反过来影响实际的业务。

Solidity使用关键字『is』作为继承的关键字。例如如下代码所示, 合约B继承了合约A:

pragma solidity ^0.4.25;

contract A {
}

contract B is A {
}

继承的合约B可以访问被继承合约A的所有的非private的函数和状态变量。

Solidity中继承的底层实现原理为:当一个合约从多个合约继承时,在区块链上只有一个合约被创建,所有基类合约的代码被复制到创建的合约中。

相比于C++或Java等语言的继承机制,Solidity的继承机制更有点类似于Python,Solidity支持和Python类似的多重继承机制。因此,Solidity中可以使用一个合约来继承多个合约。不过,在某些高级语言中,比如Java,出于安全性和可靠性方面的考虑,只支持单重继承,而通过使用接口机制来实现多重继承。对于大多数场景而言,单继承的机制可以满足需求,解决问题。多继承会带来很多复杂的技术问题,例如所谓的『钻石继承』等问题,建议在实践中尽可能规避复杂的多继承。

继承简化了人们对抽象的合约模型的认识和描述,能清晰体现相关合约间的层次结构关系;继承提供了软件复用功能。这种做法能减小代码和数据的冗余度,大大增加程序的重用性。

独孤九剑:抽象类和接口

独孤九剑,不拘于招式,无招胜有招。等到通晓了这九剑,则无所施而不可,无所不出,无所不入,便是将全部变化尽数忘记,也不相干。

这恰恰契合编程中依赖倒置的原则,智能合约应该尽可能地面向接口编程,而不依赖具体的实现细节。

Solidity支持抽象合约和接口的机制。

如果一个合约,存在未实现的方法,那么它就是抽象合约。例如:

pragma solidity ^0.4.25;

contract Vehicle {
    //抽象方法
    function brand() public returns (bytes32);
}

抽象合约无法被成功编译,但可以被继承。

接口使用关键字interface,上面的抽象也可以被定义为一个接口。

pragma solidity ^0.4.25;

interface Vehicle {
    //抽象方法
    function brand() public returns (bytes32);
}

接口类似于抽象合约,但是它们不能实现任何函数。还有进一步的限制:

  1. 无法继承其他合约或接口。
  2. 无法定义构造函数。
  3. 无法定义变量。
  4. 无法定义结构体
  5. 无法定义枚举。

合适地使用接口或抽象合约有助于增强合约设计的可扩展性。但是,由于区块链EVM上计算和存储资源的限制,切忌不要过度设计,这也是从高级语言技术栈转到Solidity开发的老司机常常会陷入的天坑。

易筋经 库(Library)

少林七十二绝技之最为深奥绝妙的内功心法。据说练成者,心动而力发,一攒一放,自然而施,不觉其出而自出,如潮之涨,如雷之发。

善用库者,亦是如此,乍看之下不太起眼,可是日积月累,积累的库和应用能力也水涨船高。在软件开发中,有很多经典的原则来提升软件的质量,其中最为经典的一条原则,就是尽可能复用久经考验、反复打磨、严格测试的高质量代码。此外,复用成熟的库代码还可以提升代码的可读性、可维护性,甚至是可扩展性。

和所有主流的语言一样,在Solidity语言中,也提供了库(Library)的机制。Solidity的库有以下基本特点:

  • 用户可以像使用合约一样使用关键词library来创建合约。
  • 库既不能继承也不能被继承。
  • 库的internal函数对调用者都是可见的。
  • 库是无状态的,无法定义状态变量,但是可以访问和修改调用合约所明确提供的状态变量。

接下来,我们来看一个简单的例子,以下是FISCO BCOS社区中的一个LibSafeMath的代码库,我们对此进行了精简,只保留了加法的功能:

pragma solidity ^0.4.25;

library LibSafeMath {
  /**
  * @dev Adds two numbers, throws on overflow.
  */
  function add(uint256 a, uint256 b) internal returns (uint256 c) {
    c = a + b;
    assert(c >= a);
    return c;
  }
}

我们只需要在合约中import库的文件,然后使用L.f()的方式来调用函数,(例如LibSafeMath.add(a,b))。

接下来,我们编写一个调用这个库的测试合约,合约的内容如下:

pragma solidity ^0.4.25;

import "./LibSafeMath.sol";

contract TestAdd {

  function testAdd(uint256 a, uint256 b) external returns (uint256 c) {
    c = LibSafeMath.add(a,b);
  }
}

我们可以在FISCO BCOS控制台中测试合约的结果(控制台的介绍文章详见FISCO BCOS 控制台详解,飞一般的区块链体验),运行结果如下:

=============================================================================================
Welcome to FISCO BCOS console(1.0.8)!
Type 'help' or 'h' for help. Type 'quit' or 'q' to quit console.
 ________ ______  ______   ______   ______       _______   ______   ______   ______
|        |      \/      \ /      \ /      \     |       \ /      \ /      \ /      \
| $$$$$$$$\$$$$$|  $$$$$$|  $$$$$$|  $$$$$$\    | $$$$$$$|  $$$$$$|  $$$$$$|  $$$$$$\
| $$__     | $$ | $$___\$| $$   \$| $$  | $$    | $$__/ $| $$   \$| $$  | $| $$___\$$
| $$  \    | $$  \$$    \| $$     | $$  | $$    | $$    $| $$     | $$  | $$\$$    \
| $$$$$    | $$  _\$$$$$$| $$   __| $$  | $$    | $$$$$$$| $$   __| $$  | $$_\$$$$$$\
| $$      _| $$_|  \__| $| $$__/  | $$__/ $$    | $$__/ $| $$__/  | $$__/ $|  \__| $$
| $$     |   $$ \\$$    $$\$$    $$\$$    $$    | $$    $$\$$    $$\$$    $$\$$    $$
 \$$      \$$$$$$ \$$$$$$  \$$$$$$  \$$$$$$      \$$$$$$$  \$$$$$$  \$$$$$$  \$$$$$$

=============================================================================================
[group:1]> deploy TestAdd
contract address: 0xe2af1fd7ecd91eb7e0b16b5c754515b775b25fd2

[group:1]> call TestAdd 0xe2af1fd7ecd91eb7e0b16b5c754515b775b25fd2 testAdd 2000 20
transaction hash: 0x136ce66603aa6e7fd9e4750fcf25302b13171abba8c6b2109e6dd28111777d54
---------------------------------------------------------------------------------------------
Output
function: testAdd(uint256,uint256)
return type: (uint256)
return value: (2020)
---------------------------------------------------------------------------------------------

[group:1]>

通过以上示例的演示,我们可以清晰地了解在Solidity中应该如何使用库。

类似Python,在某些场景下,指令『using A for B;』可用于附加库函数(从库 A)到任何类型(B)。 这些函数将接收到调用它们的对象作为它们的第一个参数(像 Python 的 self 变量)。这个功能使得库的使用更加的简单、直观。

例如,我们对代码进行如下简单的修改:

pragma solidity ^0.4.25;

import "./LibSafeMath.sol";

contract TestAdd {
  // 添加using ... for ... 语句,库 LibSafeMath 中的函数被附加在uint256的类型上
  using LibSafeMath for uint256;

  function testAdd(uint256 a, uint256 b) external returns (uint256 c) {
        //c = LibSafeMath.add(a,b);
        c = a.add(b);
        //对象a直接被作为add方法的首个参数传入。
  }
}

我们可以验证一下结果依然是正确的。

=============================================================================================
Welcome to FISCO BCOS console(1.0.8)!
Type 'help' or 'h' for help. Type 'quit' or 'q' to quit console.
 ________ ______  ______   ______   ______       _______   ______   ______   ______
|        |      \/      \ /      \ /      \     |       \ /      \ /      \ /      \
| $$$$$$$$\$$$$$|  $$$$$$|  $$$$$$|  $$$$$$\    | $$$$$$$|  $$$$$$|  $$$$$$|  $$$$$$\
| $$__     | $$ | $$___\$| $$   \$| $$  | $$    | $$__/ $| $$   \$| $$  | $| $$___\$$
| $$  \    | $$  \$$    \| $$     | $$  | $$    | $$    $| $$     | $$  | $$\$$    \
| $$$$$    | $$  _\$$$$$$| $$   __| $$  | $$    | $$$$$$$| $$   __| $$  | $$_\$$$$$$\
| $$      _| $$_|  \__| $| $$__/  | $$__/ $$    | $$__/ $| $$__/  | $$__/ $|  \__| $$
| $$     |   $$ \\$$    $$\$$    $$\$$    $$    | $$    $$\$$    $$\$$    $$\$$    $$
 \$$      \$$$$$$ \$$$$$$  \$$$$$$  \$$$$$$      \$$$$$$$  \$$$$$$  \$$$$$$  \$$$$$$

=============================================================================================
[group:1]> deploy TestAdd
contract address: 0xf82c19709a9057d8e32c19c23e891b29b708c01a

[group:1]> call TestAdd 0xf82c19709a9057d8e32c19c23e891b29b708c01a testAdd 2000 20
transaction hash: 0xcc44a80784404831d8522dde2a8855606924696957503491eb47174c9dbf5793
---------------------------------------------------------------------------------------------
Output
function: testAdd(uint256,uint256)
return type: (uint256)
return value: (2020)
---------------------------------------------------------------------------------------------

[group:1]>

综上所述,我们已经介绍了如何更好地使用Solidity library的机制,来更好地复用代码。除了Solidity社区提供的大量开源、高质量的代码库以外,FISCO BCOS社区也计划准备推出一个全新的Solidity代码库,开放给社区的用户,敬请期待。当然,您也可以自己动手,编写可复用的代码库组件,并分享到社区。

总结

本文介绍了Solidity合约编写的若干高级语法特性,旨在抛砖引玉,帮助读者快速沉浸到Solidity编程的世界。

想要编写高质量、可复用的Solidity代码的诀窍就和学习其他技术一样,多看看社区优秀的代码,多动手实践编码,多多总结并不断进化。期待更多的朋友到我们的社区来分享宝贵的经验和精彩的故事,have fun :)

你可能感兴趣的:(Solidity语言的七种武功秘籍)