一文聊透 Solidity 語法:助你成為智慧合約專家

語言: CN / TW / HK

我報名參加金石計劃1期挑戰——瓜分10萬獎池,這是我的第9篇文章,點選檢視活動詳情

關於區塊鏈和智慧合約開發者的區別解釋

我發現很多人都表述不清楚區塊鏈和智慧合約。我認識幾位程式設計師朋友,他們都自稱是在做區塊鏈開發,但實際上是在做智慧合約的開發。大多數外行分不清楚區塊鏈和智慧合約我能理解,但是很多從事智慧合約開發的程式設計師竟然也分不清楚,我不知道是不是表述問題還是理解問題。

區塊鏈是區塊鏈,智慧合約是智慧合約,兩者的關係就像是微信和微信小程式一樣,一個是 App 開發,一個是小程式開發,根本不一樣,不能混為一談。

據我瞭解,區塊鏈的需求沒那麼多,特別是中國這個環境下。大多數區塊鏈相關的程式設計師都是在做智慧合約開發,而不是真的在開發區塊鏈。

區塊鏈是可以用很多後端語言去開發的,比如用 Go、Node.js、Rust、Java 等。

但是智慧合約不可以隨便選擇程式語言,我這裡講的智慧合約是指以太坊智慧合約。目前它只能選擇 Solidity、Vyper、YUL、YUL+ 和 Fe 這 5 種語言。其中 solidity 最受歡迎,大多數專案和開發者都是選擇了 solidity。我們幾乎可以說 solidity 是智慧合約的首選程式語言。

這篇文章會講什麼?

這篇文章將會介紹我認為使用 Solidity 編寫智慧合約時 90% 以上的場景中能夠用到的語法和特性。

但是 Solidity 是一門完整的程式語言,想要把它徹底學明白,一篇文章肯定是不夠的。因為很多語言都被寫成了一厚厚地本書。不過通常寫程式語言的書都會非常全體、體系化地介紹語言的全部,包括那些平時壓根用不到的知識,或者一些已經落伍,語言設計上糟粕的部分。總體來說,通過一本厚厚的書來講一門程式語言,多少是從研究的角度出發的,如果你只想快速用 Solidity 開發智慧合約,不想把這門語言研究的這麼透徹,那麼本文很適合你。

同時本文會拿 solidity 和一些面向物件的語言做對比,如果你完全不懂其他程式語言,那麼本文不適合你。

面向合約

Solidity 的設計理念和麵向物件程式語言很相似,不過 Solidity 是面相合約的程式語言,如果你有面向物件程式語言的開發經驗,那麼學習 Solidity 就沒有那麼難。

Solidity 語言被設計為編寫合約的語言,目前來說也只能寫合約,所以它不像其他語言那樣可以做很多事情。

合約構成解讀

我們先來看一個最簡單的合約構成,做一個整體的感受。

``` // SPDX-License-Identifier: MIT pragma solidity ^0.8.0;

contract HelloWorld { address private owner; unit public state;

modifier onlyOwner() { require(msg.sender == owner, "only owner"); _; }

event StateChanged(unit state);

constructor() public { owner = msg.sender; }

function setState(uint _state) external onlyOwner { state = state; emit StateChanged(_state) } } ```

我簡單解釋下這個合約的程式碼,不會詳細介紹。

第 1 行是指定版本許可。

第 2 行是指定使用的語言版本。

第 4 行是宣告一個名為 HelloWorld 的合約。

第 5-6 行是狀態變數,它們會永久儲存在合約中。

第 8 -11 行是函式修改器,它可以用在函式修飾符上,可以改變函式的行為。

第 13 行是宣告一個事件,事件可以被觸發和監聽。

第 15-17 行是建構函式,在部署時會被呼叫。

第 19-22 行是聲明瞭一個名為 setState 的函式。

版本

solidity 有很多種版本,目前最新的版本是 8.x。

但是在早期比較流行的是 5.x、6.x 這兩個版本。

solidity 的版本命名規範採用 。

和其他大多數程式語言不同的是,solidity 的版本是直接寫在原始碼裡的。

在任意一個 sol 檔案的最開始,都應該是版本程式碼。

語法為:

pragma solidity 0.8.0;

如果你用過 npm 的話,那這個版本語言一定不會陌生,因為 solidity 同樣使用了 semver 版本規範。

合約

合約的概念有點像面向物件程式語言的類,屬於一等公民。

通過關鍵字 contract 建立。

語法:

``` contract MyContract {

} ```

可以通過 new 關鍵字建立合約。

new MyContract();

繼承

面向物件的語言通常會使用 extends 關鍵字來繼承,但是 solidity 沒有這樣做,它使用 is 來繼承合約。

``` contract MyContract1 { uint256 num = 2022; }

contract MyContract2 is MyContract1 {

} ```

子合約被部署時,會把所有父合約的程式碼一起打包,所以對父合約中函式的呼叫都屬於內部呼叫。

子合約可以隱式轉換父合約,合約也可以顯式轉換為地址。

address addr = address(c);

重寫函式使用 override 關鍵字。父合約中支援重寫的函式必須是 virtual 的。

``` contract Parent { function fn() public virtual {} }

contract Child is Parent { function fn() public override {} } ```

呼叫父合約中的方法,使用 super 關鍵字。

``` contract Parent { function fn() public {} }

contract Child is Parent { function fn2() public { super.fn(); } } ```

支援多重繼承。

``` contract Parent1 { function fn() public virtual {} }

contract Parent2 { function fn() public virtual {} }

contract Child is Parent1, Parent2 { function fn() public override(Parent1, Parent2) {} } ```

變數與基礎型別

變數是永久儲存在合約中的值,通常用來記錄業務資訊。

每個變數都需要宣告型別,solidity 中的型別有如下幾種:

  • string:字串型別
  • bool:布林值,true/false。
  • uint:無符號整型,有 uint 和 uint8/16/32/64/128/256 幾個型別。uint 是 uint256 的別名。
  • int:有符號整型,規則和 uint 一樣。
  • bytes:定長位元組陣列。從 bytes1 到 bytes32,byte 是 bytes1 的別名。它和陣列類似,通過下標獲取元素,通過 length 獲取成員數量。
  • address:地址型別。儲存一個 20 位元組的地址。
  • address payable:可支付的地址,有成員函式 transfer 和 send。

contract MyContract { string name = "" }

uint

對於整型變數,我們可以通過 type(x).min 和 type(x).max 來獲取某個型別的最大值和最小值。

address

address payable 可以隱式轉換到 address,但是 address 必須通過 payable(address) 這種方式顯示轉換。

address 還可以顯示轉換為 uint160 和 bytes20。

bytes 和 string

bytes 和 string 都是陣列,而不是普通的值型別。

bytes 和 byte[] 非常像,但是它在 calldata 和 memory 中會緊打包。緊打包的意思是將元素連續儲存在一起,不會按照每 32 位元組為一個單元進行儲存。

string 是變長 utf-8 編碼的位元組陣列,和 bytes 不同的是它不可以用索引來訪問。

字串沒有操作函式,一般都是通過第三方 string 庫來操作字串。

string 可以轉換為 bytes,轉換時是建立引用而不是建立拷貝。

function stringToBytes() public pure returns (bytes memory) { string memory str = "hello"; bytes memory bts = bytes(str); return bts; }

由於 bytes 和 string 很相似,所以我們在使用它們時應該有對應的原則。

  • 對於任意長度的原始位元組使用 bytes。
  • 對於任意長度的 UTF-8 字串使用 string。
  • 當需要對位元組陣列長度進行限制時,應該使用 byte1-byte32 之間的具體型別。

合理使用 bytes 可以節省 Gas 費。

變數修飾符

我們也可以為變數指定訪問修飾符。

語法是 型別 訪問修飾符(可選) 欄位名。

訪問修飾符有三種:

  • public:公開,外部可以訪問,宣告為 public 的話會自動生成 getter 函式。
  • internal:預設,只有合約自身和派生的合約可以訪問。
  • private:只有合約自身可以訪問。

solidity 中的變數與傳統語言的變數有些不同。

  1. 字串的值預設不可以包含中文。如果要使用除了英文外的其他語言,必須加 unicode 字首。

string name = unicode"小明";

結構體

使用關鍵字 struct 建立結構,有點類似 Go/C 的 struct,或者類似 TypeScript 中的 type/interface。

struct User { string name; string password; uint8 age; bool state; }

初始化結構體和呼叫函式類似,引數的順序和結構體的順序保持一致。

User user = User("章三", "123", 12, false);

訪問某一個屬性使用點號。

user.name;

屬性也可以直接賦值。

user.name = "里斯";

陣列

和 TypeScript 中的陣列語法一致,語法是 type[]。

User[] users;

訪問陣列元素,使用 array[index] 的方式。

users[0];

訪問不存在的下標,會直接報錯。

在建立陣列時可以宣告長度,如果不宣告,那就是可以動態調整大小的陣列。

uint256[10] nums;

陣列具有 pop 和 push 方法,分別用於彈出一個元素和新增一個元素。但是它們不可以用在定長陣列中。

push 方法可以不傳遞引數,這時表示它新增一個該元素型別的零值。

strs.push("1"); strs.pop();

對映

類似於很多語言中的 Map 結構。語法是 mapping(keyType => valueType)。

mapping(address => User) userMapping;

key 的型別只允許是基本型別,不可以是複雜型別,比如合約、列舉、對映和結構體。

value 的型別沒有限制。

訪問 mapping 元素,使用 mapping[key] 的方式。

userMapping[0x021221]

訪問不存在的 key,會返回 value 型別的預設值。

mapping 不可以作為公有函式的引數和返回值,只可以作為變數或者函式內的儲存或者庫函式的引數。

宣告為 public 的 mapping,會自動建立 getter 函式。KeyType 作為引數,ValueType 作為返回值。

mapping 無法被遍歷。不過有一些開源庫用一種結構來實現了可遍歷的 mapping。可以直接拿過來用。

列舉

列舉是建立使用者自定義型別的一種方式。

enum ActionChoices { GoLeft, GoRight, GoStraight, SitStill } ActionChoices choice;

列舉可以和所有的整型顯示相互轉換,但是不能隱式轉換。

uint num = uint(choice);

從整型顯示轉換到列舉型別,會在執行時檢查整數是否在列舉範圍內,超過的話會導致異常。

choice = ActionChoices(num);

列舉最少包含 1 個成員,最多可以包含 256 個成員。

列舉預設值是第一個成員。

列舉的資料表示和 C 語言是一樣的,從 0 開始的無符號整數開始遞增。

建構函式

部署合約時會由 EVM 自動呼叫建構函式,和常規的程式語言語法一致。

contract MyContract { constructor () { } }

如果在建構函式中設定引數的話,那麼在部署時需要傳入對應引數的值。

contract MyContract { constructor (uint256 initNum) { } }

建構函式不支援過載。

如果一個合約沒有建構函式,那麼會採用預設建構函式,將所有變數初始化為型別對應的預設值。

函式

語法是 function(type param) {internal|external} [pure|view|payable] [returns(paramType)]

可訪問性識別符號、狀態識別符號、函式修改器

函式可以定義在合約之外,但是隻能通過 internal 的形式訪問。

函式可以接受多個引數,也可以返回多個返回值。

函式修改器

可以放在函式宣告中,具有修改函式行為的能力。

modifier onlyOwner() { require(msg.sender == owner, "only owner"); _; }

常用的關鍵字有 require 和 _。

require 有兩個引數,第一個引數是一個 bool 值,如果為 false,那麼就會觸發錯誤,終止函式執行。第二個引數是當發生錯誤時的訊息。

_ 表示函式執行。

使用函式修改器只需要在函式的修飾符部分新增修改器的名字即可,如果要新增多個修改器,使用空格隔開。

function setState(uint _state) external onlyOwner m2 m3 { state = state; emit StateChanged(_state) }

函式修改器可以被繼承。

函式修飾符

修飾符可以用在成員屬性或者函式上,它決定了成員屬性/函式的訪問許可權,共有 4 種:

  • public:最大訪問許可權,任何人都可以呼叫。
  • private:只有合約內部可以呼叫,不可以被繼承。
  • internal:子合約可以繼承和呼叫。
  • external:外部可以呼叫,子合約可以繼承和呼叫,當前合約不可以呼叫。

external 和 public 的函式是合約的成員變數,可以通過 fn.address 來獲取地址,通過 .selector 來獲取識別符號,這也被稱作函式選擇器。

函式呼叫

函式分為內部函式與外部函式。

內部函式

只有在同一個合約內的函式可以內部呼叫,內部呼叫可以遞迴呼叫。函式呼叫在 EVM 中會被解釋為簡單地跳轉,記憶體不會被清除。

比如可以做斐波那契數列。

contract MyContract { function fibonacci(uint256 n) public returns (uint256) { if (n == 1 || n == 2) { return 1; } return fibonacci(n - 2) + fibonacci(n - 1); } }

外部呼叫

呼叫父合約的 external 方法和呼叫其他合約中的 external/public 方法,都屬於外部呼叫。

呼叫父合約的方法使用 this.fn();,呼叫外部合約的方法使用 contract.fn();。

進行外部呼叫會通過訊息呼叫,而不是簡單跳轉。

介面

與傳統語言一樣,使用關鍵字 interface。

介面可以被合約繼承。

``` interface Token { function transfer(address recipient, uint amount) external; }

contract MyToken is Token { function transfer(address recipient, uint amount) external override {} } ```

事件

定義事件:

event eventName(paramsType paramsName)

觸發事件。

emit eventName(params)

事件會被記錄到區塊鏈的 Log 中,區塊鏈的 Log 分為索引和資料。我們可以指定最多 3 個引數為 indexed,表示它們可以被索引。

前端可以通過 web3.js 來訂閱和監聽事件。

事件也可以被繼承。

控制結構

solidity 支援大多數傳統程式語言的流程控制語句。比如 if、else、while、do、for、break、continue、return。但是不支援 goto 和 switch。

solidity 支援 try/catch 做異常處理,但是隻支援外部函式呼叫和合約建立呼叫。

資料儲存位置

所有引用型別的資料(包括陣列、結構體、mapping、合約等)都有三種儲存位置。分別是:

  • 記憶體 memory:合約執行時的記憶體。
  • 儲存 storage:合約的永久儲存。
  • 呼叫資料 calldata:不可修改,函式的引數。和 memory 有些像,但和記憶體不在同一個位置。

直接宣告在合約中的變數都會儲存在 storage 中。

宣告為 external 的函式,引數必須儲存在 calldata。

在 storage 和 memory/calldata 之間進行復制,會建立獨立的拷貝。

memory 和 calldata 之間相互賦值不會建立拷貝,而是建立引用。

storage 與本地 storage 之間的賦值也只會建立引用。

``` contract MyContract { uint256[] arr1; // arr1 儲存在 storage 中

// arr2 儲存在 memory 中 function fn1(uint256[] memory arr2) public { // memory 賦值到 storage 中,建立拷貝 arr1 = arr2; // stoarge 賦值到 本地 storage 中,建立引用 uint256[] storage arr4 = arr1; // pop 會同時影響 arr1 arr4.pop(); // 清空 arr1,同時會影響 arr4 delete arr1; // storage 是靜態分配記憶體,所以不可以直接從 memory 賦值到本地 storage 中 // arr4 = arr2; // 因為沒有指向儲存位置,所以無法重置指標 // delete arr4;

  // storage 之間傳遞引用
  fn3(arr1);
  // storage 到 memory 會拷貝
  fn4(arr1);

}

// arr3 儲存在 calldata 中 function fn2(uint256[] calldata arr3) external {}

function fn3(uint256[] storage arr5) internal pure {}

function fn4(uint256[] memory arr6) public pure {}

} ```

在使用資料時,要優先考慮放在 memory 和 calldata 中。

因為 EVM 的執行空間有限。而且如果 storage 的佔用很高,Gas 費也會很貴。

單位

solidity 中有兩種單位。以太單位和時間單位。

以太單位

以太單位是以太坊獨有的單位,在其他程式語言中沒有這種單位。

1 wei 等於 1。

1 gwei 等於 1e9。

1 ether = 1e18。

用程式碼表示如下:

assert(1 wei == 1); assert(1 gwei == 1e9); assert(1 ether == 1e18);

時間單位

預設 1 等於 1 秒。

solidity 支援以下時間單位:

  • seconds:秒
  • minutes:分
  • hours:時
  • days:天
  • weeks:周
  • years:年,不推薦使用。

用程式碼表示如下:

assert(1 seconds == 1); assert(1 minutes == 60 seconds); assert(1 hours == 60 minutes); assert(1 days == 24 hours); assert(1 weeks == 7 days);

錯誤處理與異常

Solidity 使用狀態恢復異常來處理錯誤。這種異常會撤銷當前呼叫以及子呼叫中的狀態變更,並且會向呼叫者標記錯誤。

外部呼叫的異常可以被 try/catch 捕獲。

assert

assert 用在我們認為不會出現錯誤的地方,它返回 Panic(uint256) 型別的錯誤。

function buy(address payable addr) public { addr.transfer(1 ether); assert(addr.balance > 1 ether); }

require

require 通常用來條件判斷,它會建立一個 Error(string) 型別的錯誤,或者是沒有錯誤資料的錯誤。

function buy(uint amount) public { require(amount < 1, "amount must be greater than 1"); }

revert

可以用來標記錯誤並且退回當前呼叫。

require 本身也會去呼叫 revert。

function buy(uint amount) public { if(amount < 1) { revert(amount > 1, "amount must be greater than 1"); } }

區塊和交易屬性

區塊和交易屬性都是以全域性變數或者全域性函式的形式存在的。我們可以直接訪問它們。常見的屬性如下:

  • blockhash(uint blockNumber) returns (bytes32):獲取指定區塊的區塊雜湊,可用於最新的 256 個區塊,不包含當前區塊。
  • block.chainid:uint 型別,當前鏈的 id。
  • block.coinbase:address 型別,當前區塊的礦工地址。
  • block.diffculty:uint 型別,當前區塊的難度。
  • block.gaslimit:uint 型別,當前區塊的 gas 限額。
  • block.number:uint 型別,當前區塊號。
  • block.timestamp:uint 型別,從 unix epoch 到當前區塊以秒計的時間戳。
  • gasleft() returns (uint256):剩餘的 gas。
  • msg.data:bytes 型別,完整的 calldata。
  • msg.sender:address 型別,訊息傳送者(當前呼叫者)。
  • msg.sig:bytes4 型別,calldata 的前 4 個位元組,也就是函式識別符號。
  • msg.value:uint 型別,訊息傳送的 wei 數量。
  • tx.gasprice:uint 型別,當前交易的 gas 價格。
  • tx.origin:address payable 型別,交易發起者。

receive 和 fallback

receive 是一個特殊的函式,一個合約可以包含最多一個 receive 函式。

receive 沒有 function 關鍵字,必須是 external payable 的。可以是 virtual 的,可以被過載,可以新增 modifier。

我們給合約轉賬時,會去執行 receive 函式。如果轉賬時 receive 函式不存在,會去呼叫 fallback 函式。如果 fallback 函式也不存在,那麼合約不可以通過正常轉賬來接受 ether。

fallback 函式和 receive 類似,只能最多有一個 fallback 函式,必須是 external 的,可以是 virtual 的,可以被過載,可以新增 modifier。但 payable 是可選的。

fallback 方法可以接受引數,也可以返回資料。

如果呼叫某個合約的函式,但是這個函式不存在,會呼叫 fallback。

``` contract MyContract { receive() external payable {}

fallback() external {} } ```

我是程式碼與野獸,一位長期專注於 Web3 的探索者,同時也非常擅長 Web2.0 中的前後端技術。

如果你對 Web3 感興趣,可以關注我和我的專欄。我會持續更新更多 Web3 相關的高質量文章。