跳至內容
0%

Solidity gas 優化:12 種讓智能合約更省 gas、更高效的技巧

Usman Asim headshot

作者 Usman Asim

發布於 2025年11月19日閱讀時間 4 分鐘

加油站插圖,代表 Solidity gas 優化

Ethereum 的 gas 費用一直是這個生態系統的痛點:有誰會想為一筆簡單的鏈上交易支付 $20+ 的 gas?雖然過去幾年 Ethereum 的多次升級大幅降低了使用者的 gas 成本,但優化 Solidity 程式碼仍然是讓你的應用能在不讓使用者破產的前提下,實現複雜鏈上操作的主要方法。

無論你是在程式碼審查中降低風險,還是單純想寫出更乾淨的合約,做好 gas 優化就代表你能打造安全、可負擔且能擴展到數百萬使用者的應用。在這篇指南中,我們會拆解 gas 的基本概念、為什麼優化很重要(劇透:相較於未優化的程式碼,可以省下 20-50% 的成本),並分享 12 種附帶程式碼範例的優化技巧。

什麼是 Solidity 中的 gas 與 gas 優化?

Gas 是衡量在 Ethereum 上執行特定操作所需計算量的單位,而 Solidity 的 gas 優化,就是讓你的 Solidity 智慧合約程式碼執行成本更低的過程。

在 Ethereum 上進行交易時,每筆交易都有其成本——寫入資料到儲存空間,或處理一筆交易的成本,這個成本就被稱為「gas」。如果你不支付 gas 費用,什麼事都不會發生,就像汽車需要汽油才能行駛一樣。

合約也需要 gas。以下是部署與執行智慧合約時發生的過程:

  1. 你撰寫Solidity 程式碼。這是為 Ethereum 智慧合約設計的高階、人類可讀的程式語言。
  2. 編譯器將其轉換成 bytecode。 當你編譯 Solidity 程式碼時,編譯器會將其轉譯成 bytecode,一種以十六進位表示的低階指令。這個 bytecode 就是實際儲存在區塊鏈上的內容。
  3. Bytecode 由 opcode 組成。 這個 bytecode 是由 opcode(操作碼)組成的,opcode 是 EVM 的指令集:可以把它想成是 Ethereum 的組合語言。每個 opcode 代表一個特定操作,例如「將兩個數字相加」、「讀取儲存空間」,或「跳到另一個指令」。
  4. EVM 執行 opcode。 當有人呼叫你的智慧合約時,網路上各節點運行的 Ethereum Virtual Machine(EVM)會讀取 bytecode,將其解碼成個別的 opcode,並逐一執行。每個 opcode 都有固定的 gas 成本。

舉例來說,ADD opcode(用來將兩個數字相加)的成本是 3 gas,而 SSTORE(用來寫入儲存空間)的成本至少是 20,000 gas。EVM 會加總交易執行過程中每個 opcode 的 gas 成本。

想了解更多 Ethereum gas 模型的內容,可以參考官方文件

Gas 優化就是調整你的程式碼,讓它用更少的操作完成同樣的工作,藉此降低執行成本。如同上面提到的,每筆交易都需要 gas(以 ETH 或其他鏈上的等價物支付)。在 Dencun 之後,得益於 blob,資料可用性的成本已經變便宜,但執行 gas(也就是運算成本)仍然可能累積成一筆不小的開銷。優化過的合約不僅能替使用者省錢,較精簡的程式碼庫也能防範 DoS 攻擊。想深入了解 opcode,可以參考 「Gas Optimizer」文件

為什麼 gas 優化對開發者來說很重要?

高 gas = 令人沮喪的使用體驗,可能讓使用者放棄你的應用,尤其是在流量暴增、gas 費用飆升的時候。

優化程式碼以降低 gas 用量,可以降低使用者的費用、提供更好的使用體驗,並讓原本因費用過高而不划算的小額交易變得可行,同時也能在高使用量時避免碰到區塊 gas 上限。

未優化的合約可能白白多消耗 20-50% 的 gas,推高成本並增加被攻擊的機會。截至 2025 年 10 月,DeFi 的總鎖倉量已接近 1,500 億美元,gas 效率高的合約已不只是「有的話不錯」,而是能左右使用者採用與否的競爭優勢。

要嘛你的應用有複雜的智慧合約邏輯,讓使用者為這種複雜度付出高額費用;要嘛你優化程式碼,讓使用者的最終體驗更好、更便宜。

12 個頂尖 Solidity gas 優化技巧

以下是經過實戰驗證的程式碼 gas 優化方法。我們會針對每個範例說明其原理、如何節省 gas,並附上程式碼。建議你在 Remix 或 Hardhat 中親自測試,感受其中的差異。

1. 使用 mapping 而非 array

Solidity 提供兩種主要的資料結構來儲存資料清單:array 與 mapping。雖然語法看起來相似,但它們的用途截然不同,gas 成本也差異極大。

Array 是有序、可迭代的集合,元素依序儲存在記憶體或儲存空間中。當你需要遍歷所有項目或維持特定順序時,array 就很有用。然而,在 array 中尋找特定項目需要迭代:EVM 必須逐一檢查每個元素,直到找到符合的項目。這代表查找操作的複雜度是 O(n),array 越大,成本就越高。

Mapping(也稱為雜湊表)的運作方式完全不同。它們採用鍵值結構,讓你可以透過鍵直接取得任何值,無論儲存了多少項目,查找時間都是常數 O(1)。這是因為 Solidity 使用雜湊函式直接從鍵計算出儲存空間位置,不需要搜尋資料。代價是 mapping 不可迭代,你無法遍歷所有項目,也無法在不另外追蹤的情況下知道有哪些鍵存在。

對 gas 的影響:建立與存取 mapping 項目的成本,遠比 array 操作便宜,因為沒有迭代的額外開銷。只有在你確實需要遍歷所有項目或維持插入順序時,才使用 array。對於其他所有情況,特別是使用者餘額、擁有權記錄,或任何以鍵為基礎的查找:mapping 明顯是更好的選擇。

以下是使用 array 的範例(存取成本較高):

solidity
Copied
string[] public cars = ["ford", "audi", "chevrolet"]; // To find "audi", you'd need to loop through the array function findCar(string memory target) public view returns (bool) { for (uint i = 0; i < cars.length; i++) { if (keccak256(bytes(cars[i])) == keccak256(bytes(target))) { return true; // Cost increases with array size } } return false; }

以下是使用 mapping 儲存相同資料的範例(查找成本便宜許多):

solidity
Copied
mapping(uint => string) public cars; constructor() { cars[101] = "Ford"; cars[102] = "Audi"; cars[103] = "Chevrolet"; } // Direct access - O(1) constant time regardless of data size function getCar(uint id) public view returns (string memory) { return cars[id]; // Single storage read, minimal gas }

使用整數作為鍵,可以在不用承擔 array 迭代成本的情況下模擬有序清單。這對於使用者資料(如餘額或擁有權記錄)特別有用,因為你需要透過 ID 快速直接存取。想更深入了解 mapping 底層的運作方式,可以參考 Solidity mapping 文件

2. 啟用 Solidity 編譯器優化器

Solidity 編譯器優化器是能大幅降低 gas 成本的強大工具,但需要根據你的實際使用情境進行設定。優化器的運作方式是分析你的程式碼並套用各種轉換:簡化表達式、移除無用的程式碼、將小型函式內聯以消除昂貴的跳轉操作,並重複使用重複的程式碼區段。這些改變都能減少 EVM 需要執行的 opcode 數量。

不過,這裡有一個由「runs」參數控制的重要取捨。這個數字告訴優化器,你預期合約中每個 opcode 在其生命週期中會被執行多少次。優化器會用這個數字,在兩個互相競爭的目標之間取得平衡:最小化部署成本(只發生一次)與最小化執行時的執行成本(每次函式呼叫都會發生)。

runs 參數的運作方式:

  • 低 runs(例如 200):優化器會優先考慮較小的 bytecode,讓部署成本更低。這代表較少的程式碼重複,以及在可重複使用的程式碼區段之間有更多跳轉。最適合你會頻繁部署但很少呼叫的合約——例如工廠合約或一次性使用的部署腳本。
  • 高 runs(例如 10,000+):優化器會優先考慮執行效率,透過重複程式碼避免跳轉,並大量內聯函式。這會產生較大的 bytecode(部署成本較高),但函式執行速度更快、成本更低。最適合交易量高的合約:例如 DEX 路由器、質押合約,或 NFT 市場。

以下是部署頻繁的應用使用低 runs(200)的範例:

javascript
Copied
module.exports = { solidity: { version: "0.8.9", settings: { optimizer: { enabled: true, // Set to true for opt runs: 200, }, }, },};

以下則是部署頻率低、針對高 runs(10000)優化的應用範例:

javascript
Copied
module.exports = { solidity: { version: "0.8.9", settings: { optimizer: { enabled: true, runs: 10000, }, }, },};

選擇合適的 runs 值

兩種做法各有取捨。想想你的合約生命週期。一個只部署一次但有數百萬筆轉帳交易的治理代幣,應該使用高 runs。而一個不斷建立新合約、但這些合約很少被呼叫的部署工廠,則應該使用低 runs。如果不確定,200 是一個能合理平衡兩者考量的安全預設值。你可以根據自己合約的特性進行測試,並在 Hardhat 設定或 Remix 中啟用任一選項。

3. 減少鏈上資料

鏈上儲存是 Solidity 中成本最高的單一操作。每次 SSTORE opcode(寫入儲存空間)的成本可能超過 20,000 gas(以 Gwei 計算),而修改既有的儲存空間位置仍需要 2,900-5,000 gas。相比之下,記憶體操作只需要 3 gas,這也是為什麼儲存優化如此重要。這裡的基本原則很簡單:只在鏈上儲存絕對必要的資料,其他一切都透過 API、oracle 或索引服務在鏈下處理。

除了減少儲存寫入,你也應該聰明地將操作分組,以避免重複的成本。這能讓你的合約更精簡,同時降低部署與執行費用,也讓攻擊者更難利用可能被用於 DoS 攻擊的高運算量函式。

將資料儲存在儲存變數中

對於什麼值得儲存要嚴格把關。舉例來說,使用者餘額、擁有權記錄,以及必須防篡改且全域可存取的合約狀態:這些都應該放在鏈上。如果要優化 gas,其他一切都應該放在鏈下。舉例來說,如果你需要外部價格資料,應該使用 Chainlink 這類 oracle 在執行時取得資料,而不是儲存歷史價格。如果你需要為前端追蹤交易歷史,應該發出事件,而不是儲存 array。

事件有一個關鍵的陷阱:雖然發出事件的成本很低(基礎約 375 gas,每個 topic 額外 375 gas),但合約無法讀取自己發出的事件。事件的存在純粹是為了讓鏈下的索引器與前端使用。絕對不要把事件當作合約邏輯需要存取的儲存空間的替代品。

批次處理操作

與其要求使用者提交多筆各自獨立的交易,不如將相關的操作打包成單一交易。這樣能省下 21,000 gas 的基礎交易費(無論交易做什麼,每筆交易都要付這個費用),也能減少重複的操作,例如多次檢查 msg.sender、重複載入相同的儲存變數,或為多次交易提交支付 calldata 成本。

這種模式對多步驟流程特別有用,例如先進行代幣授權再轉帳,或原子化地執行多個 DeFi 操作(先兌換,再質押,然後領取獎勵)。

以下是批次發送函式的範例:

solidity
Copied
Struct Call { address recipient; uint256 gas; uint256 value; bytes data; } function batchSend(Call[] memory _calls) public payable { for(uint256 i = 0; i < _calls.length; i++) { (bool _success, bytes memory _data) = _calls[i].recipient.call{ gas: _calls[i].gas, value: _calls[i].value }(_calls[i].data); if (!_success) { assembly { revert(add(0x20, _data), mload(_data)) } } } }

這種模式透過消除重複的 msg.sender 驗證、減少 calldata 開銷(函式選擇器只需傳遞一次),以及只支付一次基礎交易費而非每次操作都支付,大幅節省 gas。

迴圈

迴圈是 gas 的倍增器,每次迭代都會重複相同的操作,成本會線性累加。對 100 個項目進行儲存操作的迴圈,可能輕易消耗超過 500,000 gas,而對無邊界 array 的迴圈甚至可能超過區塊 gas 上限,讓你的函式永遠無法被呼叫。

幾乎所有情況下,解決方法都是完全消除迴圈。使用 mapping 進行常數時間 O(1) 查找,而不是 O(n) 的 array 迭代。如果真的必須迭代,請嚴格限制 array 大小,或者更好的做法是,將迭代移到鏈下,讓使用者提交特定的索引或鍵。

關於 gas 退款的說明

Solidity 過去會針對清空儲存空間(將值設為零)提供 gas 退款,但 EIP-3529 大幅減少了這些退款。雖然清空儲存空間仍然會有少量退款,但它已不再是主要的優化策略。想了解目前退款機制的詳細內容,可以參考 Ethereum gas 退款提案

4. 使用 indexed 事件

事件是一種輕量的紀錄機制,成本只是儲存操作的一小部分——基礎約 375 gas,每個 indexed 參數額外 375 gas,相較於寫入儲存空間所需的 20,000+ gas。事件會被寫入交易收據樹(transaction receipt trie),這與合約狀態儲存空間是分開的,因此非常適合用來記錄鏈下應用需要追蹤的資訊。

關鍵限制在於:從合約的角度來看,事件是只寫的。一旦發出,你的合約程式碼就無法再讀回它們。事件的存在純粹是給前端、索引器與監控工具在外部使用的。請使用事件來處理需要通知的內容或鏈下系統需要的歷史記錄,但絕對不要用它來儲存合約邏輯依賴的資料。

這能卸載大量的 gas。與其把每一筆交易都儲存在成本高昂的儲存 array 中,不如發出一個事件,讓鏈下索引器(例如 The Graph 或 Alchemy 的 API)為你的前端建立那段歷史記錄。

以下是宣告與發出事件的方式:

solidity
Copied
event MyFirstEvent(address indexed sender, uint256 indexed amount, string message); function doSomething(uint256 _amount, string memory _message) public { // Your contract logic here // Emit the event - cheap logging instead of expensive storage emit MyFirstEvent(msg.sender, _amount, _message); }

Indexed 參數

你最多可以將 3 個參數標記為 indexed,這會讓它們在 log 查詢中可被搜尋。舉例來說,將 senderamount 設為 indexed 後,你就能快速過濾出「所有 sender = 0x123... 的事件」,而不用掃描每一個事件。像 message 這種未被 indexed 的參數,仍然會被記錄,但無法直接搜尋。

常見的使用案例包括代幣轉帳、擁有權變更、狀態轉換,以及使用者活動追蹤——任何你需要供鏈下使用的紀錄,但不需要鏈上存取的地方。想了解更多細節,可以參考 Solidity 事件文件

5. 打包你的變數

EVM 以 32 位元組為單位儲存資料,每個儲存空間位置的寫入都要花費 gas(新位置需要 20,000+ gas,更新則需要 2,900-5,000 gas)。透過策略性地將小型變數分組,你可以把多個變數塞進單一個儲存空間位置,大幅減少合約所需的 SSTORE 操作次數。

可以把這想成有效率地打包行李箱:你安排物品的順序,決定了你會浪費多少空間。變數會按照你宣告的順序被打包,因此謹慎安排相當重要。

之前(浪費了 3 個位置的空間):

solidity
Copied
contract MyContract { uint128 c; // Slot 0 (uses 16 bytes, wastes 16 bytes) uint256 b; // Slot 1 (uses full 32 bytes) uint128 a; // Slot 2 (uses 16 bytes, wastes 16 bytes) }

即使資料只需要 2.5 個位置的空間,這樣仍使用了 3 個儲存空間位置。

之後(壓縮進 2 個位置):

solidity
Copied
contract MyContract { uint128 a; // Slot 0 (first 16 bytes) uint128 c; // Slot 0 (second 16 bytes) - packed together! uint256 b; // Slot 1 (full 32 bytes) }

透過連續宣告兩個 uint128 變數,讓它們共用單一個位置,每次寫入這兩個變數時就能省下整整一次 SSTORE 操作。

打包的關鍵規則:

  • 變數依宣告順序打包
  • 當下一個變數無法容納在目前的位置中時,就會開始一個新的位置
  • 較小的型別,例如 uint8uint128address(20 位元組),是打包的絕佳候選
  • 即使一個小型別單獨存在,仍會佔用完整的 32 位元組位置,所以應盡量把它們配對

舉例來說,兩個 address 變數(各 20 位元組)無法打包進同一個位置,因為 40 位元組超過了 32 位元組。但一個 address(20 位元組)加上一個 uint96(12 位元組)就能完美塞進一個 32 位元組的位置。

想更了解 Solidity 如何安排儲存空間,可以參考儲存布局文件

6. 釋放未使用的儲存空間

當你將儲存變數清空回到預設值時(整數為 0、地址為 address\(0\)、布林值為 false),EVM 會提供 gas 退款。雖然 EIP-3529 已大幅減少這些退款的原始金額,但每清空一個儲存空間位置,你仍能拿回 4,800 gas:在清理過時資料時,這是一個值得注意的回收機會。

這是因為重設儲存空間位置能縮小區塊鏈的狀態大小,所以 Ethereum 會鼓勵這種清理行為。這是一種在資料變得過時後回收部分成本的方式,同時能讓你的合約狀態保持精簡有效率。

以下是清空變數的方式:

solidity
Copied
delete myVariable; // Resets to default value and triggers refund// Or explicitly: myInt = 0; myAddress = address(0); myBool = false;

關於 mapping 的重要提醒:delete 關鍵字對整個 mapping 不起作用,因為 mapping 不會追蹤哪些鍵存在。你必須逐一刪除個別的 mapping 項目:

solidity
Copied
mapping(address => uint256) public balances; // This won't work - can't delete entire mapping// delete balances;// Instead, delete specific keys: delete balances[msg.sender]; // Clears this specific entry

實務使用案例

Gas 退款在資料有明確生命週期的合約中最有用,例如可以在完成後清理的託管合約、會過期的臨時授權,或已經失效的快取資料。不要為了追求退款而扭曲你的合約邏輯,但當資料自然變得過時時,清理它是雙贏的做法。

想了解目前的退款機制與限制,可以參考 EIP-3529 gas 退款文件

7. 對特定函式參數使用 calldata 而非 memory 儲存資料

在宣告函式參數時,對於 array、字串、struct 這類參考型別,你可以在 memorycalldata 之間做選擇。理解兩者的差異能省下可觀的 gas,尤其是對於帶有大型參數的 external 函式而言。

Calldata 是唯讀儲存空間,直接存在於交易資料中。當你使用 calldata 時,函式會直接從交易中讀取參數,不需要複製到任何地方。這是最便宜的選項,因為它完全避免了記憶體配置與複製操作。

Memory 則需要 EVM 配置空間,並將資料從 calldata 複製到記憶體中,執行多次 MLOADMSTORE 操作。當處理大型 array 或字串時,這種複製開銷會變得很昂貴:每複製一個元素都要花費額外的 gas。

規則:對於你只需要讀取的 external 函式參數,使用 calldata。只有在你需要在函式內修改資料時,才使用 memory

以下是使用 calldata 的範例(讀取專用時較便宜):

solidity
Copied
function processNumbers(uint[] calldata nums) external { for (uint i = 0; i < nums.length; i++) { // Read-only operations - no copy needed uint value = nums[i]; // Do something with value } }

相比之下,memory(因為複製而較昂貴):

solidity
Copied
function processNumbers(uint[] memory nums) external { // Data gets copied from calldata to memory first (costs gas) for (uint i = 0; i < nums.length; i++) { uint value = nums[i]; } }

Gas 節省的程度會隨資料大小增加,一個有 100 個元素的 array 用 calldata 傳遞,相較於 memory,可以省下數千 gas。

何時必須使用 memory:如果你的函式需要修改 array、對它進行附加,或建立新的資料結構,那就必須使用 memory,因為 calldata 是不可變的。但對於純讀取操作,calldata 永遠是更好的選擇。

想了解更多儲存位置之間的差異,可以參考 calldata 與 memory 文件

8. 使用 immutable 與 constant 固定值

被標記為 constantimmutable 的變數完全不使用儲存空間位置:它們的值會在部署時直接嵌入合約的 bytecode 中。這消除了每次存取時昂貴的 SLOAD 操作(每次 2,100 gas),用便宜的 bytecode 讀取取而代之。

Constant:值必須在編譯時期設定,且無法更改。用於在所有部署中都不會改變的硬編碼值。

Immutable:值在建構函式中設定一次,之後就無法更改。用於在不同部署之間會有所不同(例如代幣地址或擁有者地址),但一旦部署後就固定不變的值。

以下是兩者的使用方式:

solidity
Copied
contract MyContract { uint256 constant FEE_RATE = 10; // Set at compile time address immutable owner; // Set once at deployment constructor(address _owner) { owner = _owner; // Can only set in constructor } function calculateFee(uint256 amount) public pure returns (uint256) { return amount * FEE_RATE / 100; // No SLOAD - reads from bytecode } }

Gas 節省範例:如果你在一個函式中讀取一個普通儲存變數 10 次,光是 SLOAD 操作就要花費 21,000 gas。使用 constantimmutable,這些讀取幾乎不花任何成本:只需要執行基本算術運算的 gas。

常見使用案例:

  • 協議費率或百分比(constant
  • 數學常數,如小數位數或縮放係數(constant
  • 來自建構函式參數的代幣地址(immutable
  • 合約擁有者或管理員地址(immutable
  • 不會改變的外部合約地址(immutable

constantimmutable 變數也都可以宣告在合約之外的檔案層級,讓它們可以在同一個檔案的多個合約中重複使用。

想了解更多細節,可以參考 constant 與 immutable 文件

9. 使用 external 可見度修飾符

函式的可見度修飾符會影響 EVM 處理函式呼叫的方式,也會影響 gas 成本。關鍵差異在於:external 函式是專門針對合約外部呼叫優化的,而 public 函式必須同時處理外部與內部呼叫,因而增加了額外開銷。

External 函式只能從合約外部呼叫(透過交易或其他合約)。當從外部呼叫時,它們會直接從 calldata 讀取參數而不需複製,因此稍微更有效率。你無法用 functionName\(\) 在內部呼叫 external 函式——你需要使用 this.functionName\(\),但這會產生一次昂貴的外部呼叫。

Public 函式可以同時被外部與內部呼叫。這種靈活性要求編譯器產生額外的程式碼來處理兩種呼叫類型,即使是從外部呼叫也會增加少量的 gas 開銷。

一般的經驗法則是:對於屬於你合約公開 API、只會被使用者或其他合約呼叫的函式,使用 external。對於只有你合約自己的函式才需要呼叫的輔助函式,使用 internalprivate

以下是 external 函式的範例(針對外部呼叫優化):

solidity
Copied
function updateMessage(string calldata _newMessage) external returns (string memory) { message = _newMessage; return message; }

相比之下,public(同時處理內部與外部):

solidity
Copied
function updateMessage(string memory _newMessage) public returns (string memory) { message = _newMessage; return message; }

Gas 節省幅度不大:通常每次呼叫大約 20-50 gas,但累積數千筆交易後就會累加起來。更重要的是,使用 external 能清楚表明意圖:這個函式是設計來從合約外部呼叫的。

額外提示:注意到 external 版本對字串參數使用了 calldata 嗎?External 函式與 calldata 參數是絕佳搭配,因為兩者都是針對外部呼叫優化的。

想了解更多函式可見度與最佳實務,可以參考可見度文件

10. 安全地使用 unchecked 算術

從 Solidity 0.8.0 開始,編譯器會自動為所有算術操作加上溢位與下溢檢查。雖然這能防止 bug,但每次操作大約要花費 30-40 gas。當你確定溢位/下溢在數學上不可能發生時,可以將操作包在 unchecked 區塊中,跳過這些檢查以節省 gas。

何時安全:邊界已知的迴圈計數器、已驗證輸入的算術運算,或者溢位在數學上不可能發生的操作。

何時應避免:未經驗證的使用者提供值、金融計算,或任何溢位可能造成漏洞的地方。

以下是一個基本範例:

solidity
Copied
function add(uint x, uint y) external pure returns (uint) { unchecked { return x + y; // Skips overflow check - saves ~30 gas } }

以下是另一個展示迴圈計數器的範例,這是這項技巧最常見的使用案例:

solidity
Copied
function processArray(uint[] calldata items) external { for (uint i = 0; i < items.length;) { // Process item unchecked { ++i; // i can never realistically overflow } } }

在這個迴圈中,i 從 0 開始,每次迭代加 1。若要讓 i 溢位,array 需要有 2^256 個元素,考量到區塊鏈的限制,這在物理上是不可能的。這正是 unchecked 的絕佳應用場景。

重要的安全提醒:如果你使用 unchecked,請加上手動的 require 陳述式,來驗證可能造成問題的輸入:

solidity
Copied
function subtract(uint x, uint y) external pure returns (uint) { require(x >= y, "Underflow prevented"); unchecked { return x - y; // Safe because validated above } }

在迴圈或頻繁呼叫的函式中,unchecked 區塊節省的 gas 會迅速累積。想了解更多細節與邊界情況,可以參考 unchecked 文件

11. 減少外部呼叫

每次呼叫另一個合約,光是 CALL opcode 就要花費至少 100 gas,再加上 calldata 的額外成本,以及被呼叫合約中任何狀態變更的成本。如果外部合約發生 revert,這些呼叫也可能無法預測地失敗,讓它們既昂貴又有風險。當你需要來自外部合約的資料時,把多次呼叫打包在一起,或快取結果,以避免在同一筆交易中重複呼叫。

節省 gas 的做法:

solidity
Copied
interface IExternal { function getData() external view returns (uint); } function processData(address addr) external { // Call once and cache the result uint data = IExternal(addr).getData(); // Reuse cached value multiple times - no additional calls uint result1 = data * 2; uint result2 = data + 100; uint result3 = data / 5; }

沒效率的做法(應避免):

solidity
Copied
function processData(address addr) external { // Calling three separate times - wastes ~300 gas uint result1 = IExternal(addr).getData() * 2; uint result2 = IExternal(addr).getData() + 100; uint result3 = IExternal(addr).getData() / 5; }

如果你在一筆交易中需要多次使用相同的資料,一定要把它快取在本地變數中。如果你需要跨多筆交易使用外部資料,可以考慮把它儲存起來(不過要衡量 20,000 gas 的 SSTORE 成本相對於外部呼叫頻率是否划算)。

想了解外部呼叫的模式與安全考量,可以參考外部呼叫最佳實務

12. 對關鍵路徑使用 assembly

對於效能關鍵的程式碼,例如密集迴圈或頻繁呼叫的函式,你可以降到 Yul assembly,手動優化到超越 Solidity 編譯器所能達到的程度。Assembly 讓你能直接控制記憶體管理、跳過安全檢查,並消除抽象層帶來的開銷。然而,這是一把雙面刃——assembly 會繞過 Solidity 所有的安全機制,讓程式碼更難閱讀,也極易出錯。

何時考慮使用 assembly:高頻率操作、複雜的位元操作、自訂的記憶體布局,或執行數千次、每一單位 gas 都很重要的迴圈。

何時應避免使用 assembly:除此之外的所有情況。省下的 gas 很少能彌補增加的 bug 風險、安全漏洞,以及維護負擔。

以下是使用 assembly 對 array 求和的範例:

solidity
Copied
function sum(uint[] memory arr) public pure returns (uint s) { assembly { let len := mload(arr) // Load array length let data := add(arr, 0x20) // Skip length, point to data for { let i := 0 } lt(i, len) { i := add(i, 1) } { s := add(s, mload(add(data, mul(i, 0x20)))) // Load and sum each element } } }

這個 assembly 版本透過直接操作記憶體指標並跳過邊界檢查來節省 gas,但相較於對等的 Solidity 程式碼,理解與審查的難度大幅提高。

關鍵安全實務

  • 徹底用邊界情況測試 assembly 程式碼
  • 加上詳細的註解,說明每一個操作
  • 讓 assembly 區段接受安全專家的審查
  • 只在窮盡 Solidity 優化手段後,才把 assembly 作為最後手段

對大多數開發者與大多數使用案例來說,本指南中的其他 11 種技巧能以低得多的風險,帶來更好的 gas 節省效果。只有在你已經對合約做過效能分析、找出具體瓶頸,並確認節省的 gas 值得增加的複雜度後,才應該考慮使用 assembly。

想學習 Yul 語法與功能,可以參考 Yul 文件

部署前測試你的智慧合約

在部署到主網之前,使用開發工具嚴格測試你的 gas 用量。Hardhat 提供 gas reporter 外掛,能為每個函式產生詳細的 gas 成本分析。Remix IDE 會在你測試時即時顯示 gas 估算值。將優化心力集中在面向使用者的函式上,例如鑄造、轉帳與兌換:這些是最常被呼叫的函式,對使用者體驗的影響也最大。

想進行更深入的測試,可以使用 Foundry 的模糊測試功能,在數千個隨機輸入上為你的優化進行基準測試,確保你節省的 gas 在真實情況與邊界案例下依然有效。

總結:開始優化吧

現在你已經有 12 種經過實戰驗證的技巧,能讓你的 Solidity 合約更精簡、執行成本更低。Gas 優化不只是省錢:它關乎為使用者打造更好的體驗,並創造出他們真正想要互動的應用。

先從容易上手的部分開始:啟用編譯器優化器、用 mapping 取代 array,並將固定值標記為 constant 或 immutable。接著對你的合約進行效能分析,找出瓶頸,再把更進階的技巧用在能發揮最大效果的地方。

持續學習資源

現在就開始動手優化吧,你的使用者(以及他們的錢包)會感謝你的!

常見問題

有哪些最有效的方法可以降低 Solidity 智慧合約的 gas 成本?

最有影響力的技巧包括:用 mapping 取代 array 進行查找、啟用 Solidity 編譯器優化器並設定合適的 runs 值、將固定值標記為 constantimmutable、減少儲存操作,以及對唯讀的函式參數使用 calldata 而非 memory

使用 constantimmutable 變數對 gas 優化有什麼幫助?

constantimmutable 的值會直接嵌入合約 bytecode,而不是儲存在昂貴的儲存空間位置中,這消除了原本每次操作要花費的 2,100 gas SLOAD 操作,改用便宜的 bytecode 讀取取代。

為什麼在查找資料時應該使用 mapping 而非 array?

Mapping 無論資料量多寡,都能提供常數時間 O(1) 的查找,而 array 則需要 O(n) 的迭代,隨著 array 變大成本也會提高。對於以鍵為基礎的存取模式,例如使用者餘額或擁有權記錄,mapping 的成本明顯更低。

Solidity 編譯器優化器如何運作?我應該使用什麼樣的 runs 設定?

優化器透過簡化表達式、移除無用程式碼,以及內聯函式來降低 gas。對於你會頻繁部署但很少呼叫的合約,使用低 runs(200);對於交易量大、執行效率比部署成本更重要的合約,使用高 runs(10,000+)。

在 gas 優化上,使用 calldatamemorystorage 有什麼差別?

對於你只需要讀取的 external 函式參數,使用 calldata(避免複製成本);對於臨時性的資料操作,使用 memory;只有在需要持久狀態時,才使用 storage。對於唯讀操作,calldata 永遠比 memory 便宜。

我應該在什麼時候安全地使用 unchecked 算術區塊?

在溢位於數學上不可能發生的操作中使用 unchecked,例如邊界已知的迴圈計數器,或已驗證的算術運算。這能藉由跳過 Solidity 0.8.0 引入的自動溢位檢查,每次操作省下約 30-40 gas。

我要如何優化儲存操作以降低 gas 成本?

把多個小型變數打包進單一個 32 位元組的儲存空間位置、刪除未使用的儲存空間以取得 gas 退款(每清空一個位置可獲得 4,800 gas),並在函式執行期間把值快取在記憶體中,以減少儲存空間寫入。

使用 external 可見度修飾符相較於 public 有什麼好處?

external 函式是專門針對合約外部呼叫優化的,與 calldata 參數搭配使用效率很高,相較於必須同時處理內部與外部呼叫的 public 函式,每次呼叫可省下 20-50 gas。

Background gradient

打造區塊鏈魔法

Alchemy 結合最強大的 Web3 開發者產品與工具,並提供資源、社群與卓越的支援。