Categories
程式開發

智能合約編寫之 Solidity 的高級特性


前言

FISCO BCOS 使用了 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標識符來修飾。

智能合約編寫之 Solidity 的高級特性 1

基於以上表格,我們可以得出函數的可見性 public > external > internal > private。

另外,如果函數不使用上述類型標識符,那麼默認情況下函數類型為 public。

綜上所述,我們可以總結一下以上標識符的不同使用場景:

  • public,公有函數,系統默認。通常用於修飾可對外暴露的函數,且該函數可能同時被內部調用。
  • external,外部函數,推薦只向外部暴露的函數使用。當函數的某個參數非常大時,如果顯式地將函數標記為external,可以強制將函數存儲的位置設置為calldata,這會節約函數執行時所需存儲或計算資源。
  • internal,內部函數,推薦所有合約內不對合約外暴露的函數使用,可以避免因權限暴露被攻擊的風險。
  • private,私有函數,在極少數嚴格保護合約函數不對合約外部開放且不可被繼承的場景下使用。

不過,需要注意的是,無論用何種標識符,即使是private,整個函數執行的過程和數據是對所有節點可見,其他節點可以驗證和重放任意的歷史函數。實際上,整個智能合約所有的數據對區塊鏈的參與節點來說都是透明的。

剛接觸區塊鏈的用戶常會誤解,在區塊鏈上可以通過權限控制操作來控制和保護上鍊數據的隱私。

這是一種錯誤的觀點。事實上,在區塊鏈業務數據未做特殊加密的前提下,區塊鏈同一賬本內的所有數據經過共識後落盤到所有節點上,鏈上數據是全局公開且相同的,智能合約只能控制和保護合約數據的執行權限。

如何正確地選擇函數修飾符是合約編程實踐中的『必修課』,只有掌握此節真諦方可自如地控制合約函數訪問權限,提升合約安全性。

對外暴露最少的必要信息:變量的可見性

與函數一樣,對於狀態變量,也需要注意可見性修飾符。狀態變量的修飾符默認是internal,不能設置為external。此外,當狀態變量被修飾為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,兩者的作用可見下圖。

智能合約編寫之 Solidity 的高級特性 2

那麼,什麼是讀取或修改狀態呢?簡單來說,兩個狀態就是讀取或修改了賬本相關的數據。

在FISCO BCOS中,讀取狀態可能是:

  • 讀取狀態變量。
  • 訪問 block,tx, msg 中任意成員 (除 msg.sig 和 msg.data 之外)。
  • 調用任何未標記為 pure 的函數。
  • 使用包含某些操作碼的內聯彙編。

而修改狀態可能是:

  • 修改狀態變量。
  • 產生事件。
  • 創建其它合約。
  • 使用 selfdestruct。
  • 調用任何沒有標記為 view 或者 pure 的函數。
  • 使用底層調用。
  • 使用包含特定操作碼的內聯彙編。

需要注意的是,在某些版本編譯器中,並沒有對這兩個關鍵字進行強制的語法檢查。

推薦盡可能使用pure和view來聲明函數,例如將沒有讀取或修改任何狀態的庫函數聲明為pure,這樣既提升了代碼可讀性,也使其更賞心悅目,何樂而不為?

編譯時就確定的值:狀態常量

所謂的狀態常量是指被聲明為constant的狀態變量。

一旦某個狀態變量被聲明為constant,那麼該變量值只能​​為編譯時確定的值,無法被修改。編譯器一般會在編譯狀態計算出此變量實際值,不會給變量預留儲存空間。所以,constant只支持修飾值類型和字符串。

狀態常量一般用於定義含義明確的業務常量值。

面向切片編程:函數修飾器(modifier)

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。後面的”_;”表示所修飾函數中的代碼。

所以,代碼實際執行順序變成了:

  • 執行onlyOwner修飾器的語句,先執行require語句。 (執行第9行)
  • 執行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就是個最經典的權限校驗器。這樣一來,連檢查的邏輯也能被快速復用,用戶也不用再為智能合約裡到處都是參數檢查或其他校驗類代碼而苦惱。

可以debug的日誌:合約裡的事件(Event)

介紹完函數和變量,我們來聊聊Solidity其中一個較為獨有的高級特性——事件機制。

事件允許我們方便地使用 EVM 的日誌基礎設施,而Solidity的事件有以下作用:

  • 記錄事件定義的參數,存儲到區塊鏈交易的日誌中,提供廉價的存儲。
  • 提供一種回調機制,在事件執行成功後,由節點向註冊監聽的SDK發送回調通知,觸發回調函數被執行。
  • 提供一個過濾器,支持參數的檢索和過濾。

事件的使用方法非常簡單,兩步即可玩轉。

第一步,使用關鍵字『event』來定義一個事件。建議事件的命名以特定前綴開始或以特定後綴結束,這樣更便於和函數區分,在本文中我們將統一以『Log』前綴來命名事件。下面,我們用『event』來定義一個函數調用跟踪的事件:

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屬性,根據特定值進行搜索。

合約事件推送功能文檔:https://fisco-bcos-documentation.readthedocs.io/zh_CN/latest/docs/sdk/java_sdk.html#id14

不過,日誌和事件無法被直接訪問,甚至在創建的合約中也無法被直接訪問。

但好消息是日誌的定義和聲明非常利於在『事後』進行追溯和導出。

例如,我們可以在合約的編寫中,定義和埋入足夠的事件,通過WeBASE的數據導出子系統我們可以將所有日誌導出到MySQL等數據庫中。這特別適用於生成對賬文件、生成報表、複雜業務的OLTP查詢等場景。此外,WeBASE提供了一個專用的代碼生成子系統幫助分析具體的業務合約,自動生成相應的代碼。

WeBASE的數據導出子系統:
https://webasedoc.readthedocs.io/zh_CN/latest/docs/WeBASE-Collect-Bee/index.html

代碼生成子系統:
https://webasedoc.readthedocs.io/zh_CN/latest/docs/WeBASE-Codegen-Monkey/index.html

在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中可以使用一個合約來繼承多個合約。

在某些高級語言中,比如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);
}

接口類似於抽象合約,但不能實現任何函數,同時,還有進一步的限制:

  • 無法繼承其他合約或接口;
  • 無法定義構造函數;
  • 無法定義變量;
  • 無法定義結構體;
  • 無法定義枚舉。

合適地使用接口或抽象合約有助於增強合約設計的可擴展性。但是,由於區塊鏈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 代碼的訣竅在於:多看社區優秀的代碼,多動手實踐編碼,多總結並不斷進化。期待更多朋友在社區里分享 Solidity 的寶貴經驗和精彩故事,have fun 🙂

作者介紹

毛嘉宇,FISCO BCOS 核心開發者。