智能合約 Solidity 教學 (上) - Solidity 基礎語法教學

智能合約 Solidity 教學 (上) - Solidity 基礎語法教學
📅 發布時間: 2025-01-29 🏷️ 標籤: Solidity, 智能合約, 基礎語法, 以太坊, Remix IDE

在這篇文章中,我們會從 0 基礎開始,逐步介紹智能合約的概念、Solidity 語法,以及如何部署你的第一個智能合約。適合對區塊鏈開發有興趣但還沒有 Solidity 經驗的開發者!


目錄

Solidity 0 到 1:智能合約入門

什麼是 Solidity?

Solidity 是一種面向智能合約的編程語言,專門用於在 Ethereum(以太坊) 和其他 EVM(Ethereum Virtual Machine)兼容的區塊鏈上開發去中心化應用(DApps)。

它受到了 JavaScript、Python 和 C++ 的影響,語法簡潔且適合初學者入門。

智能合約是什麼?

智能合約(Smart Contract)是一種 自動執行的合約,當滿足特定條件時,程式會自動執行約定的內容,無需中間人。

例如:

  • 去中心化交易所(DEX) – 允許用戶直接交易代幣
  • NFT 合約 – 用於發行、管理和交易 NFT
  • 去中心化金融(DeFi)應用 – 如貸款、流動性挖礦等

當然不僅僅是這種與金融有關的產業,任何可以去中心化的應用都可以採用智能合約,好比說有些產業為了更好的紀錄各個生產鏈上下的資料,便可以用智能合約,公正無私的紀錄資料。


開始使用 Solidity

1. 安裝開發環境

目前最簡單的方式是使用線上編輯器 Remix IDE

如果你想在本地開發,可以使用:

  • Node.js(安裝 npm/pnpm)
  • Hardhat 或 Truffle(Solidity 開發框架)

另外你會需要一個加密貨幣錢包來與合約互動(放心,在本教學中不會花到任何一毛錢)

  • MetaMask 錢包(測試和與合約互動)

第一個合約 Hello World


💻 第一個智能合約:HelloWorld

🎯 學習目標:開啟 Remix IDE 並建立新檔案理解 SPDX-License、pragma、contract、變數、建構子、函式等基礎語法逐步建構並解釋每個關鍵元素
開啟 Remix

圖: 開啟 Remix IDE (https://remix.ethereum.org)

創建檔案

圖: 在 Remix 建立新檔 HelloWorld.sol

步驟 1: 完整範本程式碼

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

contract HelloWorld {
    // 儲存訊息的變數
    string public message;

    // 建構函式,合約部署時會自動執行
    constructor() {
        message = "Hello, Solidity!";
    }

    // 設定新的訊息
    function setMessage(string memory _newMessage) public {
        message = _newMessage;
    }
}

步驟 2: SPDX-License-Identifier 和 pragma solidity

// SPDX-License-Identifier: MIT  // [註解] 宣告授權協議 MIT,讓他人知道可以自由使用此程式碼,為什麼?為了符合開源規範並避免法律糾紛
pragma solidity ^0.8.0;  // [註解] 指定 Solidity 編譯器版本,為什麼?確保新舊版本相容,避免語法不相容導致的編譯錯誤

步驟 3: contract HelloWorld

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

contract HelloWorld {  // [註解] 定義合約名稱,為什麼?這是智能合約的入口,所有邏輯都在此封裝,便於部署和管理
    // ...
}  // [註解] 合約結束,為什麼?將所有變數和函式封閉在合約內,確保獨立執行

步驟 4: 狀態變數與建構子

contract HelloWorld {
    // 儲存訊息的變數  // [註解] public 變數,為什麼?自動產生 getter 函式,讓外部可讀取狀態,持久儲存在區塊鏈上
    string public message;  // [註解] string 型別儲存文字,為什麼?適合訊息內容,public 確保可查詢

    // 建構函式,合約部署時會自動執行  // [註解] constructor 特殊函式,為什麼?初始化合約狀態,只執行一次,避免預設值不正確
    constructor() {  // [註解] 無參數建構子,為什麼?部署時自動呼叫,設定初始值
        message = "Hello, Solidity!";  // [註解] 初始化變數,為什麼?提供預設訊息,讓合約一部署就有可用狀態
    }  // [註解] 建構子結束,為什麼?完成初始化,後續函式才可依賴此狀態
}

步驟 5: setMessage 函式

// ... (前面的程式碼保持不變)

    // 設定新的訊息  // [註解] 公開函式,為什麼?允許外部使用者更新狀態,實現互動功能
    function setMessage(string memory _newMessage) public {  // [註解] public 修飾符和 memory 位置,為什麼?public 允許呼叫,memory 避免永久儲存參數以節省 Gas
        message = _newMessage;  // [註解] 更新狀態變數,為什麼?實現動態修改,改變區塊鏈狀態(會消耗 Gas)
    }  // [註解] 函式結束,為什麼?完成邏輯,返回控制權給呼叫者
}

四、編譯與部署


💻 編譯與部署 HelloWorld 合約

🎯 學習目標:使用 Remix Compiler 編譯合約在 Remix VM 環境部署並測試合約理解部署後的互動介面與 Gas 消耗
點擊 Compiler

圖: 點擊 Remix 右側面板 Solidity Compiler

選擇版本並編譯

圖: 選擇正確版本 (0.8.x) 並按下 Compile HelloWorld.sol

編譯成功

圖: 沒有錯誤就會顯示綠色打勾

轉到 Deploy 面板

圖: 轉到 Deploy & Run Transactions 面板

部署設定

圖: Environment 選擇 Remix VM (Cancun),選正確合約並點擊 Deploy

部署成功

圖: 下方出現 Deployed Contracts 表示成功,注意測試 ETH 扣除(測試幣無限使用,合約永久存在區塊鏈)

查看 message

圖: 展開 HelloWorld,點擊 message 查看初始值(讀取免費)

呼叫 setMessage

圖: 輸入新訊息如「貓貓真可愛」,點擊 setMessage 更新(修改需 Gas)


五、Solidity 中的變數型別


💻 Solidity 變數型別範例

🎯 學習目標:理解 bool, uint/int, address, string, array, struct, enum 等型別認識各型別的限制與用途

步驟 1: bool 型別

bool public myBool = true;  // [註解] 布林值,為什麼?用於條件判斷如開關狀態,只存 true/false 以節省儲存空間

步驟 2: 整數型別

uint256 public myUint = 123;  // [註解] 無符號整數,為什麼?適合計數或正值,uint256 提供大範圍避免溢位
int public myInt = -123;  // [註解] 有符號整數,為什麼?允許負值,如債務計算

步驟 3: address 型別

address public myAddress = 0x1234567890123456789012345678901234567890;
myAddress.balance // 取得該錢包目前剩餘的存款  // [註解] 地址型別,為什麼?代表錢包或合約,用於轉帳或權限檢查,.balance 查詢餘額

步驟 4: string 型別

string public myString = "Hello";

myString.length; //error
myString[0]; //error  // [註解] 字串型別,為什麼?儲存文字但無 length 或索引存取,以 Gas 效率為優先,避免額外計算

步驟 5: 陣列

uint[] public numbers; // 動態大小的陣列  // [註解] 動態陣列,為什麼?大小可變,push 新增元素適合列表
uint[5] public fixedSizeNumbers; // 大小固定為 5 的陣列

numbers.push(123);  // [註解] 新增,為什麼?擴展陣列但消耗 Gas,因改變 storage

步驟 6: struct

struct Person {
    string name;
    uint age;
}  // [註解] 自定義結構,為什麼?組合多型別成一單位,如使用者資料,提升程式可讀性

Person public alice = Person("Alice", 30);  // [註解] 實例化,為什麼?初始化結構體,提供完整物件

步驟 7: enum

enum Status { Pending, Shipped, Completed, Rejected }  // [註解] 列舉,為什麼?定義有限狀態集,避免魔法數字,提升維護性

Status public currentStatus;  // [註解] 使用,為什麼?儲存狀態,易讀取如 currentStatus = Status.Shipped

變數類型:Local、State、Global

在 Solidity 中的變數根據其宣告位置可以分為 三大類

  1. Local 變數(函式內部變數)
  2. State 變數(合約狀態變數)
  3. Global 變數(區塊鏈資訊變數)

1. Local 變數

定義:

  • 宣告在函式內部
  • 僅存於函式執行期間(不會永久儲存到區塊鏈)
  • 默認存於記憶體(memory)
  • 不會影響智能合約的狀態,因此不會產生 Gas 費用

2. State 變數

定義:

  • 宣告在函式之外,屬於整個合約
  • 儲存在區塊鏈上(storage)
  • 修改 State 變數時,會消耗 Gas(因為改變了區塊鏈狀態)

3. Global 變數

定義:

  • 提供區塊鏈上的資訊
  • 不需要手動宣告,直接可用
  • 儲存方式根據類型不同,有些來自 storage,有些只是暫時變數

常見 Global 變數:

msg.sender呼叫合約的發送者地址
msg.value交易發送的 ETH 數量
block.timestamp當前區塊的時間戳
block.number當前區塊號
gasleft()剩餘 Gas 數量
tx.origin原始交易發起者
address(this).balance合約帳戶的 ETH 餘額


Data Locations:memory、storage、calldata

除了變數的類型(local、state、global),Solidity 也提供了 三種資料儲存方式
適用於陣列 (array)、結構 (struct)、字串 (string)、映射 (mapping) 等參考型別 (Reference Types)

storage表示這個資料永遠儲存於區塊鏈上頭,**改變會消耗 Gas,**剛剛提到的 State 變數就屬於這一類

memory表示這個資料只是短暫出現在記憶體上,**交易結束後消失,**剛剛提到的 Local 變數就屬於這一類

calldata只讀記憶體(交易輸入)外部函式參數專用,不允許修改external 函式的輸入參數

八、瓦斯費,什麼是 Gas?

眼尖的同學應該發現,如果你修改了區塊鏈的資料是要花錢的!
不僅如此,在以太坊上,每當你執行一筆交易(例如呼叫智能合約、轉帳等),都需要向網路支付一筆 交易手續費
這筆費用的計算方式並不是固定的,基本上對區塊鏈做越多操作會越貴。
而以太坊使用一種叫做 Gas 的單位來衡量交易在網路上執行所需的運算資源。

  • 付款時使用的是 以太幣 (ETH),當然1以太幣快 3000 美元,單位太大了,所以瓦斯費習慣以以 Gwei(10^-9 ETH)為單位計價。
  • 哪怕是相同的操作,在不同時間段的瓦斯費都不同,在尖峰時刻的瓦斯費會比較昂貴。

計算交易手續費 (以下僅供參考)

  1. Gas Used:某次交易實際執行用掉多少 Gas
  2. Gas Price:每單位 Gas 的價格,通常以 Gwei 為單位
  3. 交易手續費 (Tx Fee) = Gas Used × Gas Price

舉例:

  • 你的交易執行消耗了 50,000 Gas
  • 當前 Gas Price = 20 Gwei = 20 × 10−9^{-9} ETH
  • 交易費用 = 50,000 × (20 × 10^-9) ETH = 0.001 ETH

哪些操作會消耗 Gas?

在智能合約中,大多數操作都會消耗 Gas,尤其是對區塊鏈「狀態 (state)」造成變化的操作。
下面列出一些常見「高 Gas 消耗」的動作:

  1. 寫入/修改區塊鏈上儲存 (storage)
    • 最昂貴 的操作之一就是對合約的 storage寫入更新
    • 例如:改變一個 state variable。
    • 原因:永久儲存到區塊鏈資料庫,需要網路中所有節點同步保存。
  2. 新增資料到 mapping 或 array
    • 與一般的 storage 更新類似,只要有寫入都會消耗較多 Gas。
    • 動態陣列的 push() 也會往合約的永續狀態中寫入新元素。
  3. 刪除資料 (釋放 storage)
    • 相較寫入,可以回收部分 Gas(因為釋放空間會有 Gas Refund),但一般來說整體仍需付出操作成本。
  4. 合約部署
    • 部署(Deploy)是一項非常昂貴的操作,因為需要將整個智能合約程式碼上傳到區塊鏈。
    • 智能合約一旦部署,就成為鏈上的永久程式碼。
  5. 呼叫其他合約
    • 在合約中透過 calldelegatecallinterface 呼叫其他合約函式,需要額外的 Gas 來執行被呼叫的邏輯。
  6. 觸發事件 (Event)
    • 在合約中 emit Event 會產生一筆鏈上日誌 (Log),也會消耗 Gas。(一般比寫 storage 便宜,但還是要考量頻繁記錄事件帶來的額外開銷)
  7. 迴圈操作 (for/while loop)
    • 每次迭代都需要額外的計算成本。
    • 如果迴圈裡包含寫入 storage、呼叫其他合約、更複雜的邏輯,Gas 量會隨迴圈次數成倍增加。

哪些操作相對不會「直接」消耗 Gas?

  1. 只讀取狀態變數 (view function)
    • 用錢包或前端呼叫 viewpure 函式,不會執行交易,也不會在鏈上留下任何記錄,因此不需支付額外 Gas。
    • 但若在合約內部使用 viewpure 函式,仍然要計入該交易整體的執行成本——只是相對來說,只有計算的成本,但沒有對 storage 寫入的成本。
  2. 在函式內做記憶體層面的運算 (memory)
    • memorycalldata 中的操作只會產生交易執行中計算的 Gas,通常比修改 storage 要便宜許多,因為它不需要永久寫入區塊鏈。
注意: 雖然「讀取數據」或「單純做計算」比寫入便宜很多,但「執行交易」本身仍有基礎費用,因為必須產生交易並在鏈上被礦工/驗證者驗證。

如何節省 Gas?

  1. 減少對 storage 的寫入
    • 能在 memorycalldata 中處理就盡量不要寫進 storage
    • 例如先在 memory 中計算好,再一次性寫回 storage,或只在需要時更新 storage
  2. 考慮資料結構選擇
    • mapping 在查詢方面通常比 array 高效(尤其針對索引操作);
    • 但要根據具體邏輯權衡。
  3. 避免在迴圈中使用寫入或呼叫
    • 如果可以把大量資料處理切分成多筆交易,或使用批量更新/分段的方式,讓單次交易的迴圈不至於過大。
  4. 事件 (Event) 的使用
    • 雖然事件會消耗 Gas,但通常比 storage 寫入便宜且可以提供鏈上日誌。
    • 取代在鏈上保存大型文字資料,而是用事件紀錄,日後可從鏈下檢索到。
  5. 版本與編譯器優化
    • 使用新版本 Solidity 通常會有更多優化(如 0.8.x),
    • 在 Remix 或 Hardhat 中開啟編譯器優化 optimizer(如設置 runs = 200 或其他數值)。

、控制流程


💻 Solidity 控制流程範例

🎯 學習目標:使用 if/else 分支判斷實現 for/while 迴圈計算

步驟 1: if / else if / else

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

contract HelloWorld {
    string public result = "";

    constructor() {
        int256 val = 1;
        if (val == 0) {
            result = "Value is zero";
        } else if (val == 1) {
            result = "Value is one";
        } else {
            result = "Value is something else";
        }
    }
}

步驟 2: for 迴圈

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

contract HelloWorld {
    uint public sum = 0;

    constructor() {
        uint max = 100;
        for (uint i = 0; i < max; i++) {
            sum += i;
        }
    }
}

步驟 3: while 迴圈

// ... (前面的程式碼保持不變)

    constructor() {
        uint max = 100;
        uint i = 0;  // [註解] 初始化計數器,為什麼?while 需要手動管理迴圈變數,避免無限迴圈
        while (i < max) {  // [註解] 條件檢查,為什麼?決定是否繼續迴圈,防止 Gas 超限
            sum += i;  // [註解] 累加運算,為什麼?實現求和邏輯,每次迭代更新狀態(注意 Gas)
            i ++;  // [註解] 更新計數器,為什麼?推進迴圈進度,確保終止
        }  // [註解] 迴圈結束,為什麼?完成計算,sum 儲存結果
    }

十、函式 (Function)

函數就像是一個魔法盒子,我們可以把一系列的指令放進這個盒子裡,替這個盒子取一個名字
然後以後只要叫這個名字,就可以讓這些指令執行。

在 Solidity 中為了更方便使用者開發,我可以替函式加上一些設定


💻 Solidity 函式修飾符與類型

🎯 學習目標:理解 public, private, external 存取權限使用 view, pure, payable 功能修飾符

步驟 1: public 函式

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

contract HelloWorld {
    uint public count;

    // 這是一個 public 函式  // [註解] public 權限,為什麼?允許任何人呼叫,自動產生 getter/setter 便利外部互動
    function increment() public {  // [註解] 無參數 public 函式,為什麼?簡單更新計數,外部可觸發狀態變化
        count += 1;  // [註解] 修改狀態,為什麼?實現遞增,消耗 Gas 但提供功能
    }  // [註解] 函式結束,為什麼?原子操作,確保一致性
}

步驟 2: private 函式與變數

// ... (前面的程式碼保持不變)

    // 外部無法直接訪問該變數  // [註解] private 變數,為什麼?限制外部直接讀寫,但資料仍公開於鏈上(可爬取),用於內部邏輯
    // 請注意!該資料仍然公開於區塊鏈上頭,仍然可以爬取到該變數
    uint private innerCount;  // [註解] private 狀態變數,為什麼?隱藏內部計數,避免外部依賴

    // 這是一個 public 函式
    function increment() public {
        _increment(); // ✅ 合約內部可存取
    }

    // 這是一個 private 函式,習慣命名上最前面加上 _ 來表示這是 private
    function _increment() private {  // [註解] private 內部函式,為什麼?封裝邏輯,重用代碼而不暴露細節,提升安全性
        count += 1;
    }

步驟 3: external 函式

// ... (前面的程式碼保持不變)

    // 這是一個 public 函式
    function increment() public {
        this.externalIncrement(); // 內部調用必須透過 `this`  // [註解] 透過 this 間接呼叫,為什麼?模擬外部呼叫,但 internal 不直接支援
    }

    // 這是一個 external 函式  // [註解] external 權限,為什麼?優化 Gas,只允許外部呼叫,適合大量外部互動如 API
    function externalIncrement() external {
        count += 1;
    }

步驟 4: view 函式

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

contract ViewExample {
    uint public number = 42; // 狀態變數

    // 這個函式只是讀取狀態變數,並沒有改變它
    function getNumber() public view returns (uint) {  // [註解] view 修飾符,為什麼?保證不改狀態,讀取免交易/Gas,適合查詢
        return number;
    }
}

步驟 5: pure 函式

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

contract PureExample {
    // 這個函式純粹進行計算,沒有存取合約內的狀態變數
    function add(uint a, uint b) public pure returns (uint) {  // [註解] pure 修飾符,為什麼?純計算無狀態依賴,最大節省 Gas,可離線驗證
        return a + b;
    }
}

步驟 6: payable 函式

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

contract PayableExample {
    uint public balance;

    // 這個函式允許用戶發送 ETH 到合約
    function deposit() public payable {  // [註解] payable 修飾符,為什麼?允許接收 ETH,否則交易失敗,適合付費功能
        balance += msg.value; // msg.value 是發送的 ETH 數量  // [註解] 累加接收 ETH,為什麼?追蹤存款,使用 global 變數自動記錄
    }

    // 這個函式回傳合約內的 ETH 餘額
    function getBalance() public view returns (uint) {
        return address(this).balance;
    }
}

十一、錯誤處理 (Error Handling)


💻 Solidity 錯誤處理範例

🎯 學習目標:使用 require 檢查輸入與條件了解 revert 和 assert 的用途

步驟 1: require()

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

contract PayableExample {
    uint public balance;

    function withdraw(uint _amount) public {
        // _amount 須小於 balance 的金額,否則報錯
        require(_amount < balance, "合約中資金不足");  // [註解] require 條件檢查,為什麼?驗證輸入/狀態,失敗時 revert 並退款 Gas,常用權限/邊界檢查
        balance -= _amount;
    }

}

步驟 2: revert()

function doSomething() public {
    if (someConditionNotMet()) {
        revert("Condition not met");  // [註解] 自訂錯誤,為什麼?明確回滾狀態,提供除錯訊息,比 require 更靈活用於複雜邏輯
    }
    // ...
}

步驟 3: assert()

function testAssert(uint _x) public pure {
    assert(_x != 0); // _x 絕對不能為 0  // [註解] assert 不變量檢查,為什麼?偵測程式 bug,失敗不退 Gas(開發用),區別於使用者錯誤
}

進一步學習建議

  1. 掌握測試網部署:嘗試使用 MetaMask 連接測試網 (如 Sepolia、Goerli) 進行部署。
  2. 熟悉 Hardhat / Truffle:在本地進行單元測試與合約部署管理。
  3. 安全性考量:了解常見攻擊手法(Reentrancy, Integer Overflow 等)。
  4. 前端整合:透過 Web3.js 或 Ethers.js 連接你的 DApp 與合約。

下一篇文章