Solidity 教學: 型別 Types & 變數 Variables

本篇預計閱讀時間 20 分鐘。

在開始之前先來思考底下的練習題,閱讀後即可練習完成。

練習題

  • 練習題 1

    • uint 在其後宣告完 bits 之後該整數的數值範圍為__________。

    • int 在其後宣告完 bits 之後該整數的數值範圍為__________。

  • 練習題 2

    • 請問如何在 Solidity 之中表示浮點數?

  • 練習題 3

    • 請問狀態變數 State variables 預設的可視性是 public 還是 private?

  • 練習題 4

    • 請舉出 3 種我們「如何用來節省 String gas 花費的儲存型態。」

  • 練習題 5

    • 請問全局訊息 Global Variables 指的是何者提供的資訊?

  • 練習題 6

    • 請舉例「如何在 Solidity 之中進行字串比較。」

  • 練習題 7

    • 請問在一個 .sol 檔案裡面,我們可以有幾個 contract?

  • 練習題 8

    • 在宣告 address 時加上 __________ ,代表可用於支付的地址。

  • 練習題 9

    • <account>.balance 回傳的單位是 __________。

  • 練習題 10

    • Solidity 是強型別語言還是弱型別語言,並簡單說明它們兩者的差別。

 

Solidity 有別於 Python、Javascript 這樣的弱型別語言,它是一個更像是 C/C++、Java 的強型別語言。每次在宣告變數時或者定義函數回傳值時,都需要去標明定義型態。

在 Solidity 中所有變數都會被預設地初始化:

  • 所以型態裡面不存在 null 或 undefined

  • (u)int = 0

  • bool = false

  • string = ""

其中所有可視性為 public 的變數都會自動生成一個相同名稱的 Getter Function,我們在 Remix IDE 可以看見它的存在。就像是我們在上一個章節所做的 Hello World 合約一樣,我們可以點選 REMIX 上的 Hello World 變數來看見 “Hello World!” 這個變數回傳值。

而參考型別 (reference types) 的變數需要標明記憶體配置 (memory location: memory 或 storage),這部分在後面章節會詳述。

 

Integer

在 Solidity, uint 就是 uint256,為一個 256-bit 的 unsigned integer。同時也可以宣告其他 bits,或使用 int 來宣告其他的負整數。

uint 在其後宣告完 bits 之後該整數的數值範圍為 0 ~ (2n) - 1。

int 在其後宣告完 bits 之後該整數的數值範圍為 -2n-1 ~ (2n-1) - 1。

 

 

//SPDX-License-Identifier: GPL-3.0

pragma solidity >=0.7.0 <0.9.0;

contract MyInt {
    uint256 public myUint = 566778778787;
    uint32 public myUint32 = 4294967295;
    uint16 public myUint16 = 65535;
    uint8 public myUint8 = 256;

    int256 public myInt = -566778778787;
    int32 public myInt32 = -2147483648;
    int16 public myInt16 = -32768;
    int8 public myInt8 = -128;

    // 兩種表示最大值的方式
    uint8 public uint8_max = 2**8 -1;
    uint public uint_min = 0;
    
    int8 public int8_max = type(int8).max;
    int8 public int8_min = type(int8).min;
}

在 Solidity 中沒有 double 和 float 這些小數型態,也就是說如果我們要使用小數在當前單位,則要作單位換算成更小單位,如此一來就能表現成整數型態了。

 

Bool

在 Solidity 中的布林值以 true 和 false 表示,但其實 Boolean 只是一個 binary type。

//SPDX-License-Identifier: MIT
pragma solidity ^0.8.11;
contract MyBoolean {
    bool public myBoolean = false;
}

可以使用反面表述:myVar = !myVar。也可以使用邏輯運算:&&, ||。

但需要注意的是 Solidity 並不支援布林值的自動轉換,所以我們不可以在 condition statement 中(if-else, while⋯),或賦值給 bool type 變數時使用 1 和 0 分別代表 true 和 false。

Address

Address 可以被用來儲存一個以太坊地址指向的帳戶或智能合約。表面上來看是一個長度為 20 byte 的十六進制字串(等於 160 bits 或 40 hex characters),只要通過 Address Checksum Test 就會被視為 Address。任何在 Ethereum 的互動都是 Address based 的。

 

我們在宣告 address 時加上 payable 這個修飾詞(modifier)時,則此 address 就會多兩個成員(members),分別為 transfer 和 send。

address payable 代表可用於支付的地址,反之 address 就不可用於支付任何 eth,也不包含以上多的兩個成員。

Address and Msg.Object

這邊提供外部帳戶和合約帳戶這兩種 Account 的概念,EOA 比較像是我們使用 MetaMask 等錢包或媒介使用的帳戶,而 Contract Account 類似於在區塊鏈「內」自動化運作的一個帳戶。

  • Externally Owned Account(EOA)

  • 用於交易資金、初始化交易、部署智能合約與提領

  • 擁有一個私鑰來管理帳戶

  • Contract Account

  • 沒有私鑰來管理賬戶,程式碼決定了整個合約帳戶的運作

對於 Contract Account 來說,只要我們沒有在程式碼裡面寫上 owner 或相關的操作定義,這個 Contract Account 就會失去人為的控制。但反之只要我們寫上了完善、完整的函式之後,就可以透過「操控特定函式」這樣一個「發送交易」的行為來控制合約。

換句話說如果這個合約裡面有非常多供特定人士操作的合約,那這個合約從廣義來說便不是真正去中心化的!

智能合約的互動與帳戶關係

在區塊鏈上我們只能使用 EOA 初始化交易,也就是說在任何的互動過程中,只要原點是 EOA,接下來無論是 Contract Account 或 EOA 皆可與彼此交互運作。

  • 途徑常為:EOA -> Contract Account -> Contract Account ...(EOA 作為交易發起者)

  • 途徑不可為:Contract Account -> Contract Account...(Contract Account 作為交易發起者)

成員函式與合約之間互動的方法

  1. .balance:

    可以得到當前地址的餘額,以 wei 為單位。

    • <address>.balance()

  2. .transfer:

    有 gas 限制,最大 2300,這個限制可以用來防止重送攻擊(Reentrancy Attacks)

    • <address payable>.transfer(uint256 amount)

    • 此函式會從合約轉入帳號 amount 價值的以太幣。這邊需要注意當我們沒有特別宣告單位時,uint 型態變數的輸入值都會是以 wei 為單位

    • 如果執行失敗會拋出錯誤,接收對象若是合約需要注意其中是否有 fallback function 存在

    • 自智能合約傳送相對應數量(以 wei 為單位)的 ether 給指定地址。在這裡和我們平常撰寫物件導向程式語言的寫法方向不太一樣,可以稍微注意一下

  3. .send:

    有 gas 限制,最大2300

    • <address payable>.send(uint256 amount) returns (bool)

    • 此函式會從合約轉入帳號 amount 價值,wei 單位的 eth

    • 是一個屬於 .transfer 的低階部件,如果執行 .send 失敗的話不會把整個合約停止,而是會 return false。不會改變任何的「狀態」。

  4. .call:

    沒有 gas 限制,和 send 一樣,如果執行失敗不會停止而是 return false

    • 是較為適合轉入合約的狀況使用,因為 call 可以調整 gas ,也可以註明 Function Signature 來與轉入合約的 Function 做對接

    • <address>.call(bytes memory) returns (bool, bytes memory)

    • <address>.call{value: amount}( bytes memory ) 功能類似.transfer 會從合約轉入帳號 amount 價值的 eth

    • <address>.call{gas: amount}( bytes memory ) 可以調整供給的 gas 數量,並且 returns 一個 boolean 值

  5. .delegatecall:

    和 call 基本上一樣,只是使用 delegatecall 時不能使用 value 但可以附帶註明 gas

  6. .staticcall:

    和 call 基本上一樣,只是使用 staticcall 時不能改變到 contract 中的任何狀態(static)

為了確保匯款或提款時的安全性,實作以上函式時我們都必須不斷地檢查返回結果 (Return 或 Console Window 的資訊) 。

必須要知道的是 .call、.delegatecall、.staticcall 以上三者都屬於底層呼叫,可以呼叫 gas 參數,而且在發生錯誤時不會拋出異常只會回傳 false。此三者都是非常底層的函數,所以我們應該要盡量避免直接在我們的智能合約 Hard-Code Gas,除非我們十分確定為何要這麼做。

Call() 屬於一個底層的函式,也是底層對外的接口。我們能用 Call() 來對一個合約發送 message。這個函式支援任意型態的參數,它會將這些參數包裹成 32bytes 的形式傳遞給合約。

function myFunction(uint _x, address _addr)
    public
    returns(uint, uint) {
    // do something
    return (a, b);
}
// function signature 的字串中不可以有任何的空白
(bool success, bytes memory result)
= addr.call(abi.encodeWithSignature("myFunction(uint,address)", 10, msg.sender));

 

在回傳值中除了 boolean 以外,還有一個 bytes array。想要看到函式回傳結果就得使用以下方法來 decode。

(uint a, uint b) = abi.decode(result, (uint, uint));

我們還可以使用以下方法來呼叫:

address(nameReg).call{
    gas: 1000000,
    value: 1 ether
    }(abi.encodeWithSignature("myFunction(uint, address)", myIntParameter, myAddress));

 

而 delegatecall() 和 call() 基本上功能是類似的,區別在於 delegate 只會使用參數地址的代碼,其他 message 則使用當前合約的內容,像是 balance 等。

使用範例:

//SPDX-License-Identifier: GPL-3.0

pragma solidity ^0.8.11;

contract Withdraw {
    //msg.sender: 呼叫此函式的“錢包”或“合約”地址

    //初始化合約的同時,並匯入 ETH
    // Function 與 Data Structure 文章中會介紹
    constructor() payable {}
    function withdrawByTransfer() public {
        payable(msg.sender).transfer(1 ether);
    }

    function withdrawBySend() public {
        bool success = payable(msg.sender).send(1 ether);
        require(success);

    function withdrawByCall() public returns (bytes memory) {
        (bool success, bytes memory result) = payable(msg.sender).call{value:require(success);
        return result;
    }
}

如果使用 <address>.transfer(uint amount)、<address>.send(uint amount),這個代稱的合約地址中必須增加 fallback 回調函數!

Call() 需要與 fallback 和 receive 函式進行互動,當我們「呼叫一個函式,並傳了 ether 且沒有提供其他訊息」會與 receive function 對應上。當我們沒有任何 Function Signature 與之對應時就會和 fallback function 對應上。這部分在函式章節會特別詳述。  

address vs. address payable

在這裡更深入地探討 <address> 和 <address payable>。 兩者的區別僅在編譯時存在,在編譯後的合約代碼中就沒有區別了。 

而在編譯時兩者最大的區別就在型別轉換:

// address payable類型的變量可以顯式或隱式地轉換為 address 類型 
address payable addr1 = payable(msg.sender); 
address addr2 = addr1; // 正確
address addr3 = address(addr1); // 正確

// address 類型的變量只能顯式地轉換為 address payable,需要首先轉換為整數類型(例如uint160),
// 然後再將該整型值轉換為address類型,就可以得到 address payable: 
address addr1 = msg.sender; 

address payable addr2 = addr1;
// 錯誤,address不能隱式地轉換為address payable 

address payable addr3 = address(uint160(addr1));
// 正確,先轉換為uint160,然後轉換為address payable 

address payable addr4 = payable(addr1); 
// 可以直接使用 payable() 來顯式轉換

Contract & This

每一個智能合約的基本單位都是一個一個的 contract,在這裡我們將這個 contract 命名為 HelloWorld。也就是說在一個 .sol 檔案裏面,我們可以有多個contract。Contract 這個概念很像是物件導向程式設計語言中的類別,這樣去理解便可以很好地體會 Contract 的意涵。

所有的合約都可以被轉為 address 型態 , 所以當然也可以使用 address 的相關成員,像是 address(this).balance.。而 this 指的是當前的合約對象,屬於一全局變數(區塊鏈提供的訊息)。

同時我們也可以使用 selfdestruct(address recipt) 來摧毀當前合約,銷毀這個合約之後會把裡面的資金發送到特定的地址。

//SPDX-License-Identifier: MIT
pragma solidity ^0.8.11;

contract myContract{
    function get_balance() public view returns (uint){
    return address(this).balance;
    }
}

當我們創建一個合約,或者是透過 web3.js 等途徑創建合約時,某種程度上都是呼叫一個和合約同名的 constructor,又被稱作建構子。

String

在 Solidity 中和 Python 一樣可以使用雙引號 "" 或者單引號 '' 來表示字串。也同樣可以用反斜槓來代表跳脫字元 "\",並且使用各種跳脫字元的命令。

由於字串可以隱性轉換為 bytes 型態,所以理想的情況下我們在宣告字串的時候可以使用 bytes 或者 uint 來節省更多的 gas:

//SPDX-License-Identifier: MIT
pragma solidity ^0.8.11;
contract SolidityTest {
    string data_moreGas = "test";
    bytes32 data_lessGas = "test";
}

表 2-1 比較回傳內容

表 2-1 String 字串比較回傳內容

 

在 bytes32 的型別之下回傳的結果並不是我們想像中的英文單字 test,可以透過以下這個函式將其轉回 string 的型態,或說我們人類可讀的單字。

//SPDX-License-Identifier: MIT
    function bytes32ToString(bytes32 _bytes32)
        public
        pure returns (string memory) {
        uint8 i = 0;
        while(i < 32 && _bytes32[i] != 0) {
            i++;
        }
        bytes memory bytesArray = new bytes(i);
        for (i = 0; i < 32 && _bytes32[i] != 0; i++) {
            bytesArray[i] = _bytes32[i];
        }
        return string(bytesArray);
    }

從節省 gas 的角度來看,我們應該要盡量使用最小的空間來儲存字串內容, 例如 keccak256(0) == keccak256(uint8(0))、keccak256(0x12345678) == keccak256(uint32(0x12345678))。

字串常見用法

String 沒辦法直接比較,所以字串比較要用 keccak256 和 abi.encodePacked 來將其轉成可比較的 bytes 型態。相關加密法可見後續角色和全局訊息章節。

//SPDX-License-Identifier: MIT
pragma solidity ^0.8.11;

contract String {

    function compare(string memory _name) public view returns(bool){
        if (keccak256(abi.encodePacked(_name)) == keccak256(abi.encodePacked("Vitalik"))){
            return true;
        }
        else{
            return false;
        }
    }
}

如果我們想要把兩個 String 連接起來,也可以用 abi.encodePacked 來做:

//SPDX-License-Identifier: MIT
pragma solidity ^0.8.11;

contract SolidityConcat {
    function concat(string memory a, string memory b) public view returns(string memory){
        return string(abi.encodePacked(a, b));
    }
}

呼叫一個字串的長度可以使用以下方法:

//SPDX-License-Identifier: MIT
pragma solidity ^0.8.11;

contract String {
    function len(string memory _name) public view returns(uint){
        return bytes(_name).length;
    }
}

 

String vs. Bytes

  1. 兩者都屬於特殊的陣列型態

  2. String 某種程度上等於Bytes,但不具有長度和陣列取值的運算子([])

  3. String 較為昂貴

String 和 Bytes 都屬於複雜的動態陣列,bytes 可以視為普通字元陣列,而 string 的陣列元素型態是 UTF-8 的字元。

Scope

Scope 被翻譯為作用域,規範著變數的生命週期與記憶體配置方式。

  • Local

    • 如果宣告在函式內則為範圍變數

    • 並不是儲存在區塊鏈上

  • State

    • 如果宣告在函式外則為狀態變數

    • 儲存在區塊鏈上

    • 預設為 private

  • Global

    • 區塊鏈提供的訊息,又稱為全局訊息,之後會有專門的篇章講解

Local Variables

區域變數通常是指被定義在函式裡,且不能被函式外所呼叫的變數。這類變數的生命週期也僅僅存在於函式被呼叫到結束這段運作過程中。

State variables

狀態變數從我們執行它開始它就會一直存在,也就是說它們是永遠存在區塊鏈上的,所以如果要改他們就要花錢。然而 Local variable 只有在執行函數時才會存在。

狀態變數的 string 和 values type 還可以加上以下兩者修飾詞 modifier:

  • constant: 宣告了 constant 的變數在編譯後就不可以再被更改,不會佔據Storage Slot。

  • immutable: 宣告了 immutable 的變數可以在建構子(constructor)時被修改,在此之後就不可以被更改。

練習題解答

  • 練習題 1

    • uint 在其後宣告完 bits 之後該整數的數值範圍為 0 ~ 2n-1 。

    • int 在其後宣告完 bits 之後該整數的數值範圍為 -2n-1 ~ 2n-1 -1。

  • 練習題 2

    • 在 Solidity 之中沒有浮點數型態,需要使用單位換算成更小單位,便可以用整數來表示。

  • 練習題 3

    • 狀態變數 State variables 預設的可視性是 private

  • 練習題 4

    • 3 種我們可以用來節省 String gas 花費的儲存型態有 uint16, uint32, uint256...等。

  • 練習題 5

    • 全局訊息 Global Variables 指的是「區塊鏈」提供的資訊

  • 練習題 6

    • 在 Solidity 之中進行字串比較

keccak256(abi.encodePacked(_str1)) == keccak256(abi.encodePacked(_str2))

  • 練習題 7

    • 在一個 .sol 檔案裏面,我們可以有個 contract。

  • 練習題 8

    • 在宣告 address 時加上 payable ,代表可用於支付的地址。

  • 練習題 9

    • <account>.balance 回傳的單位是 Wei

  • 練習題 10

    • Solidity 是強型別語言,與弱型別語言的差別是宣告變數的時候需要表明型態。