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

智能合約編寫之 Solidity 的高級特性 | 區塊鏈

本文將介紹 Solidity 的一些高級特性,幫助讀者快速入門,編寫高質量、可複用的 Solidity 代碼。

  • https://mp.weixin.qq.com/s/FR_7c-_cw7LVTdTVyq6Y3A
  • 作者:毛嘉宇

前言

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


智能合約編寫之 Solidity 的高級特性 | 區塊鏈


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

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

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

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

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

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

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

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

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

與函數一樣,對於狀態變量,也需要注意可見性修飾符。狀態變量的修飾符默認是 internal,不能設置為 external。此外,當狀態變量被修飾為 public,編譯器會生成一個與該狀態變量同名的函數。

具體可參考以下示例:

<code>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
    }
}/<code>

這個機制有點像 Java 語言裡 lombok 庫所提供的 @Getter 註解,默認為一個 POJO 類變量生成 get 函數,大大簡化了某些合約代碼的書寫。

同樣,變量的可見性也需要被合理地修飾,不該公開的變量果斷用 private 修飾,使合約代碼更符合“最少知道”的設計原則。

精確地將函數分類:函數的類型

函數可以被聲明為 pure、view,兩者的作用可見下圖。


智能合約編寫之 Solidity 的高級特性 | 區塊鏈


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

在 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 只支持修飾值類型和字符串。

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

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

Solidity 提供了強大的改變函數行為的語法: 函數修飾器(modifier)。一旦某個函數加上了修飾器,修飾器內定義的代碼就可以作為該函數的裝飾被執行,類似其他高級語言中裝飾器的概念。

這樣說起來很抽象,讓我們來看一個具體的例子:

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

如上所示,定義 onlyOwner 修飾器後,在修飾器內,require 語句要求 msg.sender 必須等於 owner。後面的 “_” 表示所修飾函數中的代碼。

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

  1. 執行 onlyOwner 修飾器的語句,先執行 require 語句。(執行第 9 行)
  2. 執行 changeOwner 函數的語句。(執行第 15 行)

由於 changeOwner 函數加上了 onlyOwner 的修飾,故只有當 msg.sender 是 owner 才能成功調用此函數,否則會報錯回滾。

同時,修飾器還能傳入參數,例如上述的修飾器也可寫成:

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

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

同一個函數可有多個修飾器,中間以空格間隔,修飾器依次檢查執行。此外,修飾器還可以被繼承和重寫。

由於其所提供的強大功能,修飾器也常被用來實現權限控制、輸入檢查、日誌記錄等。

比如,我們可以定義一個跟蹤函數執行的修飾器:

<code>event LogStartMethod();
event LogEndMethod();

modifier logMethod {
    emit LogStartMethod();
    _;
    emit LogEndMethod();
}/<code>

這樣,任何用 logMethod 修飾器來修飾的函數都可記錄其函數執行前後的日誌,實現日誌環繞效果。如果你已經習慣了使用 Spring 框架的 AOP,也可以試試用修飾器實現一個簡單的 AOP 功能。

修飾器最常見的打開方式是通過提供函數的校驗器。在實踐中,合約代碼的一些檢查語句常會被抽象並定義為一個修飾器,如上述例子中的 onlyOwner 就是個最經典的權限校驗器。這樣一來,連檢查的邏輯也能被快速複用,用戶也不用再為智能合約裡到處都是參數檢查或其他校驗類代碼而苦惱。

可以調試的日誌:合約裡的事件

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

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

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

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

  • 第一步,使用關鍵字 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 中,合約事件推送功能提供了合約事件的異步推送機制,客戶端向節點發送註冊請求,在請求中攜帶客戶端關注的合約事件參數,節點根據請求參數對請求區塊範圍的事件日誌進行過濾,將結果分次推送給客戶端。更多細節可以參考合約事件推送功能文檔。在 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 中,事件是一個非常有用的機制,如果說智能合約開發最大的難點是調試,那善用事件機制可以讓你快速制伏 Solidity 開發。

面向對象之重載

重載是指合約具有多個不同參數的同名函數。對於調用者來說,可使用相同函數名來調用功能相同,但參數不同的多個函數。在某些場景下,這種操作可使代碼更清晰、易於理解,相信有一定編程經驗的讀者對此一定深有體會。

下面將展示一個典型的重載語法:

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

需要注意的是,每個合約只有一個構造函數,這也意味著合約的構造函數是不支持重載的。

我們可以想像一個沒有重載的世界,程序員一定絞盡腦汁、想方設法給函數起名,大家的頭髮可能又要多掉幾根。

面向對象之繼承

Solidity 使用 is 作為繼承關鍵字。因此,以下這段代碼表示的是,合約 B 繼承了合約 A:

<code>pragma solidity ^0.4.25;

contract A {
}

contract B is A {
}/<code>

而繼承的合約 B 可以訪問被繼承合約 A 的所有非 private 函數和狀態變量。

在 Solidity 中,繼承的底層實現原理為:當一個合約從多個合約繼承時,在區塊鏈上只有一個合約被創建,所有基類合約的代碼被複制到創建的合約中。

相比於 C++ 或 Java 等語言的繼承機制,Solidity 的繼承機制有點類似於 Python,支持多重繼承機制。因此,Solidity 中可以使用一個合約來繼承多個合約。

在某些高級語言中,比如 Java,出於安全性和可靠性的考慮,只支持單重繼承,通過使用接口機制來實現多重繼承。對於大多數場景而言,單繼承的機制就可以滿足需求了。

多繼承會帶來很多複雜的技術問題,例如所謂的“鑽石繼承”等,建議在實踐中儘可能規避複雜的多繼承。

繼承簡化了人們對抽象合約模型的認識和描述,清晰體現了相關合約間的層次結構關係,並且提供軟件複用功能。這樣,能避免代碼和數據冗餘,增加程序的重用性。

面向對象之抽象類和接口

根據依賴倒置原則,智能合約應該儘可能地面向接口編程,而不依賴具體實現細節。

Solidity 支持抽象合約和接口的機制。

如果一個合約,存在未實現的方法,那麼它就是抽象合約。例如:

<code>pragma solidity ^0.4.25;

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

抽象合約無法被成功編譯,但可以被繼承。

接口使用關鍵字 interface,上面的抽象也可以被定義為一個接口。

<code>pragma solidity ^0.4.25; 


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

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

  1. 無法繼承其他合約或接口。
  2. 無法定義構造函數。
  3. 無法定義變量。
  4. 無法定義結構體
  5. 無法定義枚舉。

合適地使用接口或抽象合約有助於增強合約設計的可擴展性。但是,由於區塊鏈 EVM 上計算和存儲資源的限制,切忌過度設計,這也是從高級語言技術棧轉到 Solidity 開發的老司機常常會陷入的天坑。

避免重複造輪子:庫

在軟件開發中,很多經典原則可以提升軟件的質量,其中最為經典的就是儘可能複用久經考驗、反覆打磨、嚴格測試的高質量代碼。此外,複用成熟的庫代碼還可以提升代碼的可讀性、可維護性,甚至是可擴展性。

和所有主流語言一樣,Solidity 也提供了庫的機制。Solidity 的庫有以下基本特點:

  1. 用戶可以像使用合約一樣使用關鍵詞 library 來創建合約。
  2. 庫既不能繼承也不能被繼承。
  3. 庫的 internal 函數對調用者都是可見的。
  4. 庫是無狀態的,無法定義狀態變量,但是可以訪問和修改調用合約所明確提供的狀態變量。

接下來,我們來看一個簡單的例子,以下是 FISCO BCOS 社區中一個 LibSafeMath 的代碼庫。我們對此進行了精簡,只保留了加法的功能:

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

我們只需在合約中 import 庫的文件,然後使用 L.f() 的方式來調用函數(例如 LibSafeMath.add(a,b))。

接下來,我們編寫調用這個庫的測試合約,合約內容如下:

<code>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);
  }
}/<code>

在 FISCO BCOS 控制檯中,我們可以測試合約的結果(控制檯的介紹文章詳見《 FISCO BCOS 控制檯詳解,飛一般的區塊鏈體驗 》),運行結果如下:

<code>=============================================================================================
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]>/<code>

通過以上示例,我們可清晰瞭解在 Solidity 中應如何使用庫。

類似 Python,在某些場景下,指令 using A for B; 可用於附加庫函數(從庫 A)到任何類型(B)。這些函數將接收到調用它們的對象作為第一個參數(像 Python 的 self 變量)。這個功能使庫的使用更加簡單、直觀。

例如,我們對代碼進行如下簡單修改:

<code>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方法的首個參數傳入。
  }
}/<code>

驗證一下結果依然是正確的。

<code>=============================================================================================
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]>/<code>

更好地使用 Solidity 庫有助於開發者更好地複用代碼。除了 Solidity 社區提供的大量開源、高質量的代碼庫外,FISCO BCOS 社區也計劃推出全新的 Solidity 代碼庫,開放給社區用戶,敬請期待。

當然,你也可以自己動手,編寫可複用的代碼庫組件,並分享到社區。

總結

本文介紹了 Solidity 合約編寫的若干高級語法特性,旨在拋磚引玉,幫助讀者快速沉浸到 Solidity 編程世界。

編寫高質量、可複用的 Solidity 代碼的訣竅在於:多看社區優秀的代碼,多動手實踐編碼,多總結並不斷進化。期待更多朋友在社區裡分享 Solidity 的寶貴經驗和精彩故事,have fun :)

智能合約編寫之 Solidity 的高級特性 | 區塊鏈


分享到:


相關文章: