Solidityガス最適化:スマートコントラクトを安く効率的にする12のテクニック
執筆者 Usman Asim

Ethereumのガス代はエコシステムにとって長年の課題でした。単純なオンチェーントランザクションに20ドル以上のガス代を払いたいユーザーなどいるでしょうか。ここ数年のEthereumアップグレードによってユーザー向けのガスコストは大幅に下がりましたが、Solidityコードでガスを最適化することは、ユーザーに過大な負担をかけずに複雑なオンチーン処理をアプリで実現するための主要な手段であることに変わりありません。
コードレビューでのリスクを最小化する場合でも、単にクリーンなコントラクトを書く場合でも、ガス最適化を正しく行うことで、数百万人のユーザーにスケールする、安全で低コストなアプリを実現できます。このガイドでは、ガスの基本、最適化が重要な理由(ネタバレ: 未最適化のコードに比べて20〜50%のコスト削減が可能)を解説し、コード例付きで12の最適化手法を紹介します。
Solidityにおけるガスとガス最適化とは
ガスは、Ethereum上で特定の操作を実行するのに必要な計算処理量を測る単位であり、Solidityのガス最適化とは、Solidityのスマートコードの実行コストを下げるプロセスです。
Ethereum上でトランザクションを行う際には、必ずコストが発生します。データをストレージに書き込んだり、トランザクションを処理したりするコストであり、これはまさに「ガス」と呼ばれます。ガス代を払わなければ何も起こりません。車がガソリンなしでは動かないのと同じです。
コントラクトにもこのガスが必要です。スマートコントラクトをデプロイして実行する際の流れは以下の通りです。
- Solidityコードを書きます。これはEthereumのスマートコントラクト向けの、人間が読める高水準プログラミング言語です。
- コンパイラがバイトコードに変換します。 Solidityコードをコンパイルすると、コンパイラはそれを低水準命令の16進数表現であるバイトコードに変換します。このバイトコードが実際にブロックチェーン上に保存されるものです。
- バイトコードにはオペコードが含まれます。 バイトコードはオペコード(操作コード)で構成されており、これはEVMの命令セットです。Ethereumにおけるアセンブリ言語のようなものと考えてください。各オペコードは「2つの数値を加算する」「ストレージから読み取る」「別の命令にジャンプする」といった特定の操作を表します。
- EVMがオペコードを実行します。 誰かがあなたのスマートコントラクトを呼び出すと、ネットワーク上の各ノードで稼働するEthereum Virtual Machine(EVM)がバイトコードを読み取り、個々のオペコードにデコードして、一つずつ実行します。各オペコードには固定のガスコストがあります。
例えば、ADDオペコード(2つの数値を加算する)のコストは3ガスですが、SSTORE(ストレージに書き込む)のコストは少なくとも20,000ガスです。EVMは、トランザクション実行中に実行されるすべてのオペコードのガスコストを合計します。
Ethereumのガスモデルについて詳しくは、公式ドキュメントを確認してください。
ガス最適化とは、同じ処理を、より少ない操作数で行えるようにコードを調整し、実行コストを下げることです。前述の通り、すべてのトランザクションにはガスが必要です(ETHまたは各チェーンの相当通貨で支払われます)。Dencun以降、blobのおかげでデータ可用性のコストは下がりましたが、実行ガス(計算コスト)は依然として積み重なる可能性があります。最適化されたコントラクトはユーザーの費用を節約するだけでなく、コードベースが軽量になることでDoS攻撃への耐性も高まります。オペコードについてさらに詳しくは"Gas Optimizer"ドキュメントを参照してください。
なぜガス最適化が開発者にとって重要なのか
ガス代が高い = 使いにくいUXであり、特にトラフィックが急増してガス代が高騰するタイミングでは、ユーザーがアプリの利用をやめてしまう原因になります。
コードを低ガス消費になるよう最適化することで、ユーザーの手数料を下げ、UXを改善し、手数料のせいで採算が合わなかった低価値トランザクションを可能にし、ブロックガス上限に達することなく高い利用量に対応できます。
未最適化のコントラクトは20〜50%の余分なガスを消費し、コストを押し上げ、悪用の隙を生みます。DeFiのTVLが2025年10月時点で約1,500億ドルに達している中、ガス効率の良いコントラクトは「あれば良い」ものではなく、ユーザー獲得の成否を左右する競争上の優位性です。
複雑なスマートコントラクトロジックを持ち、その複雑さゆえの高い手数料をユーザーに負担させるアプリになるか、コードを最適化してユーザーのエンドエクスペリエンスをより良く、より安価にするか、どちらかです。
Solidityガス最適化の主要12テクニック
以下は、実戦で検証済みのガス最適化手法です。それぞれの例、ガス削減の仕組み、コードを解説します。RemixやHardhatで実際に試して、その違いを確認することをお勧めします。
1. 配列の代わりにマッピングを使う
Solidityにはデータのリストを保存するための主要なデータ構造が2つあります。配列とマッピングです。構文は似ていますが、用途は大きく異なり、ガスコストも大幅に違います。
配列は順序付けられた反復可能なコレクションで、要素をメモリまたはストレージに順番に格納します。すべての項目をループしたり、特定の順序を維持したりする必要がある場合に有用です。ただし、配列内で特定の項目を見つけるには反復処理が必要です。EVMは一致するものが見つかるまで各要素をチェックしなければなりません。つまり、検索操作はO(n)の計算量であり、配列が大きくなるほどコストが増します。
マッピング(ハッシュテーブルとも呼ばれます)はまったく異なる仕組みで動作します。キーバリュー構造を使い、キーから瞬時に値を取得でき、格納されている項目数に関わらずO(1)の定数時間で検索できます。これは、Solidityがハッシュ関数を使ってキーから直接ストレージスロットを計算するため、データを検索する必要がないためです。トレードオフとして、マッピングは反復可能ではなく、すべてのエントリをループしたり、別途追跡しない限りどのキーが存在するかを知ったりすることはできません。
ガスへの影響: マッピングのエントリを作成・アクセスする方が、反復処理のオーバーヘッドがない分、配列操作より大幅に安価です。配列を使うべきなのは、すべての項目を反復処理する必要がある場合や、挿入順序を維持する必要がある場合のみです。それ以外のケース、特にユーザー残高、所有権記録、キーベースの検索であれば、マッピングが明らかに優れています。
配列を使った例です(アクセスにコストがかかります):
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(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
}整数キーを使うことで、配列の反復コストをかけずに順序付きリストを模倣できます。これはID経由で高速に直接アクセスする必要がある、残高や所有権記録のようなユーザーデータで特に有効です。マッピングの内部の仕組みについて詳しくは、Solidityのマッピングドキュメントを参照してください。
2. Solidityコンパイラのオプティマイザを有効にする
Solidityコンパイラオプティマイザは、ガスコストを大幅に削減できる強力なツールですが、ユースケースに合わせた設定が必要です。オプティマイザはコードを解析し、さまざまな変換を適用することで動作します。式を単純化し、デッドコードを削除し、コストの高いジャンプ操作を排除するために小さな関数をインライン化し、重複するコードセグメントを再利用します。これらの変更はすべて、EVMが実行する必要のあるオペコード数を減らします。
ただし、「runs」パラメータによって制御される重要なトレードオフがあります。この数値は、コントラクトのライフタイムを通じて各オペコードが何回実行されると想定するかをオプティマイザに伝えるものです。オプティマイザはこの値を使い、デプロイコスト(一度だけ発生)と実行コスト(関数呼び出しのたびに繰り返し発生)という2つの相反する目標のバランスを取ります。
runsパラメータの動作:
- 低いruns(例: 200): オプティマイザはより小さいバイトコードを優先し、デプロイが安くなります。これは、コードの重複が少なく、再利用可能なコードセグメント間のジャンプが多くなることを意味します。頻繁にデプロイするが呼び出し頻度は低いコントラクト、例えばファクトリーコントラクトや使い捨てのデプロイスクリプトに最適です。
- 高いruns(例: 10,000以上): オプティマイザは実行効率を優先し、ジャンプを避けるためにコードを重複させ、関数を積極的にインライン化します。これによりバイトコードは大きくなり(デプロイコストは高くなり)ますが、関数実行は速く安価になります。DEXルーター、ステーキングコントラクト、NFTマーケットプレイスなど、トランザクション量の多いコントラクトに最適です。
デプロイが多いアプリ向けに低いruns(200)を使った例です:
module.exports = { solidity: { version: "0.8.9", settings: { optimizer: { enabled: true, // Set to true for opt runs: 200, }, }, },};デプロイが少なく、高いruns(10000)に最適化されたアプリ向けの例です:
module.exports = { solidity: { version: "0.8.9", settings: { optimizer: { enabled: true, runs: 10000, }, }, },};適切なrunsの値を選ぶ
どちらのアプローチにもトレードオフがあります。コントラクトのライフサイクルを考えてみましょう。一度だけデプロイされるが数百万回の転送トランザクションを持つガバナンストークンは、高いrunsを使うべきです。頻繁に新しいコントラクトを作成するが、それらのコントラクトはめったに呼び出されないデプロイファクトリーは、低いrunsを使うべきです。迷った場合、200は両方の懸念事項を妥当にバランスさせる安全なデフォルトです。自身のコントラクトのプロファイルでテストしてみてください。どちらもHardhatの設定やRemixで有効にできます。
3. オンチェーンデータを最小化する
オンチェーンストレージは、Solidityにおいて最もコストの高い操作です。SSTOREオペコード(ストレージへの書き込み)1回で20,000ガス以上(Gweiで計測)かかることがあり、既存のストレージスロットを変更する場合でも2,900〜5,000ガスかかります。これをコストわずか3ガンのメモリ操作と比較すれば、ストレージ最適化が重要な理由がすぐにわかるでしょう。ここでの基本原則はシンプルです。絶対に必要なものだけをオンチェーンに保存し、それ以外はすべてAPI、オラクル、またはインデックスサービスを通じてオフチェーンで処理します。
ストレージへの書き込みを減らすだけでなく、冗長なコストを避けるために操作を賢くグループ化する必要もあります。これによりコントラクトは軽量に保たれ、デプロイと実行の両方の手数料が下がるとともに、DoS攻撃に悪用され得る計算負荷の高い関数を攻撃者が悪用しにくくなります。
ストレージ変数へのデータ保存
何をストレージに保存すべきかについては厳格であるべきです。例えば、ユーザー残高、所有権記録、改ざん不可能でグローバルにアクセス可能である必要があるコントラクト状態、これらはオンチェーンに属します。ガスを最適化するなら、それ以外はすべてオフチェーンに置くべきです。例えば、外部の価格データが必要な場合は、履歴データを保存するのではなく、実行時にChainlinkのようなオラクルからそれを取得します。フロントエンド用にトランザクション履歴を追跡する必要がある場合は、配列を保存する代わりにイベントを発行します。
イベントに関する重要な落とし穴: イベントの発行は安価です(基本コストが約375ガス、トピック1つにつき375ガス)が、コントラクトは自身が発行したイベントを読み取ることはできません。イベントはあくまでインデクサーやフロントエンドによるオフチェーンでの利用のために存在します。コントラクトのロジックがアクセスする必要のあるストレージの代替としてイベントを使わないでください。
操作のバッチ処理
ユーザーに複数の個別トランザクションを送信させる代わりに、関連するアクションを1つのトランザクションにまとめましょう。これにより、あらゆるトランザクションで発生する21,000ガスの基本トランザクション手数料を節約でき、msg.senderを複数回チェックする、同じストレージ変数を繰り返しロードする、複数のトランザクション送信でcalldataコストを支払うといった冗長な処理も削減できます。
このパターンは、トークンの承認とそれに続く転送、あるいは複数のDeFi操作をアトミックに実行する(スワップしてからステークし、報酬を請求する)といった、複数ステップのプロセスで特に有効です。
バッチ送金関数の例です:
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のオーバーヘッドを削減し(関数セレクタを一度だけ渡せば済みます)、基本トランザクション手数料を操作ごとではなく一度だけ支払うことで、大幅にガスを節約します。
ループ
ループはガスの倍率器です。反復のたびに同じ操作が繰り返され、コストが線形に積み重なります。100項目に対してストレージ操作を行うループは容易に500,000ガス以上を消費する可能性があり、上限のない配列に対するループはブロックガス上限を超えることさえあり、その関数を永久に呼び出し不能にしてしまいます。
解決策はほとんどの場合、ループを完全に排除することです。O(n)の配列反復の代わりに、O(1)の定数時間検索が可能なマッピングを使いましょう。どうしても反復が必要な場合は、配列サイズを厳密に制限するか、あるいはより良い方法として、反復処理をオフチェーンに移し、ユーザーに特定のインデックスやキーを送信させます。
ガス還付についての注記
かつてSolidityはストレージのクリア(値をゼロに設定すること)に対してガス還付を提供していましたが、EIP-3529によってこれらの還付は大幅に削減されました。ストレージのクリアに対して今もわずかな還付は得られますが、もはや主要な最適化戦略ではありません。現在の還付の仕組みについて詳しくは、Ethereumのガス還付提案を参照してください。
4. インデックス付きイベントを使う
イベントは軽量なロギング機構で、ストレージ操作のわずかな一部のコスト、基本で約375ガスにインデックス付きパラメータ1つにつき375ガスを加えた程度で済み、ストレージへの書き込みにかかる20,000ガス以上と比較して大幅に安価です。イベントはトランザクションレシートトライに書き込まれます。これはコントラクトの状態ストレージとは別のものであり、オフチェーンアプリケーションが追跡する必要のある情報を記録するのに最適です。
重要な制約: イベントはコントラクトの視点から見て書き込み専用です。一度発行されると、コントラクトコードはそれを読み戻すことができません。イベントは、フロントエンド、インデクサー、モニタリングツールによる外部での利用のためだけに存在します。通知や、オフチェーンシステムが必要とする履歴記録にはイベントを使いますが、コントラクトのロジックが依存するデータには決して使わないでください。
これにより大量のガスをオフロードできます。すべてのトランザクションを高コストなストレージ配列に保存する代わりに、イベントを発行し、オフチェーンのインデクサー(The GraphやAlchemyのAPIなど)にその履歴をフロントエンド用に構築させます。
イベントの宣言と発行の方法は以下の通りです:
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);
}インデックス付きパラメータ
最大3つのパラメータをindexedとしてマークでき、これによりログクエリで検索可能になります。例えば、senderとamountをインデックス化すると、すべてのイベントをスキャンすることなく「sender = 0x123...であるすべてのイベント」を素早くフィルタリングできます。messageのようなインデックス化されていないパラメータもログには記録されますが、直接検索することはできません。
一般的な用途には、トークンの転送、所有権の変更、状態遷移、ユーザーアクティビティの追跡などがあり、オンチェーンでのアクセスは不要だがオフチェーンでの利用のために記録が必要な場面全般で使えます。詳しくは、Solidityのイベントドキュメントを確認してください。
5. 変数をパッキングする
EVMはデータを32バイトのスロットに保存し、各ストレージスロットの書き込みにはガスがかかります(新規スロットで20,000ガス以上、更新で2,900〜5,000ガス)。小さな変数を戦略的にグループ化することで、複数の変数を1つのスロットに収め、コントラクトが必要とするSSTORE操作の回数を劇的に減らすことができます。
これはスーツケースを効率的に詰めるようなものだと考えてください。アイテムを配置する順序によって、どれだけスペースを無駄にするかが決まります。変数は宣言した順序でパッキングされるため、慎重な配置が重要です。
前(3スロットにまたがってスペースを無駄にしています):
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スロットに圧縮されています):
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)
}2つのuint128変数を連続して宣言することで、それらは1つのスロットを共有し、両方の変数に書き込むたびにSSTORE操作をまるまる1回分節約できます。
パッキングの主要ルール:
- 変数は宣言順にパッキングされる
- 次の変数が現在のスロットに収まらない場合、新しいスロットが開始される
uint8、uint128、address(20バイト)のような小さい型はパッキングの絶好の候補である- 小さい型が単独で存在していても、依然として32バイトのスロットを丸ごと消費するため、常にペアにするよう試みるべき
例えば、address型の変数2つ(それぞれ20バイト)は、合計40バイトが32バイトを超えるため1つのスロットにはパッキングされません。しかし、address(20バイト)とuint96(12バイト)の組み合わせは、32バイトのスロット1つにぴったり収まります。
Solidityがストレージをどのように配置するかについて詳しくは、ストレージレイアウトドキュメントを確認してください。
6. 未使用のストレージを解放する
ストレージ変数をデフォルト値(整数の場合は0、アドレスの場合はaddress\(0\)、真偽値の場合はfalse)に戻してクリアすると、EVMはガス還付を提供します。EIP-3529によってこれらの還付は元の額から大幅に減額されましたが、クリアされたストレージスロットごとに4,800ガスの還付が今も得られます。これは、不要になったデータをクリーンアップする際の有意義な回収額です。
これが機能するのは、ストレージスロットのリセットによってブロックチェーンの状態サイズが減少するためであり、Ethereumはこのクリーンアップ行動にインセンティブを与えています。これは、コントラクトの状態を軽量かつ効率的に保ちながら、不要になったデータに対して一部のコストを回収する方法です。
変数をクリアする方法は以下の通りです:
delete myVariable; // Resets to default value and triggers refund// Or explicitly:
myInt = 0;
myAddress = address(0);
myBool = false;マッピングに関する重要な注記: deleteキーワードはマッピング全体には機能しません。マッピングはどのキーが存在するかを追跡していないためです。代わりに、個々のマッピングエントリを削除する必要があります:
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実用的なユースケース
ガス還付は、データに明確なライフサイクルがあるコントラクトで最も有用です。例えば、完了後にクリーンアップできるエスクローコントラクト、期限切れになる一時的な認可、あるいは古くなるキャッシュデータなどです。還付を追いかけるためだけにコントラクトのロジックを歪めるべきではありませんが、データが自然に不要になる場合、それをクリーンアップすることは双方にとって利益になります。
現在の還付の仕組みと制限について詳しくは、EIP-3529のガス還付ドキュメントを確認してください。
7. 特定の関数パラメータではmemoryではなくcalldataにデータを保存する
配列、文字列、構造体などの参照型の関数パラメータを宣言する際、memoryとcalldataのどちらかを選択できます。この違いを理解することで、特に大きなパラメータを持つexternal関数において、大幅にガスを節約できます。
Calldataは、トランザクションデータ内に直接存在する読み取り専用のストレージです。calldataを使うと、関数はどこにもコピーすることなく、トランザクションから直接引数を読み取ります。これはメモリ割り当てとコピー操作を完全に回避するため、最も安価な選択肢です。
一方、Memoryは、EVMがスペースを割り当て、calldataからメモリにデータをコピーすることを必要とし、複数のMLOADとMSTORE操作を実行します。このコピーのオーバーヘッドは、大きな配列や文字列では高コストになります。コピーされる要素ごとに追加のガスがかかります。
ルール: 読み取りのみが必要なexternal関数パラメータにはcalldataを使います。関数内でデータを変更する必要がある場合のみmemoryを使います。
calldataを使った例です(読み取り専用アクセスにはこちらの方が安価です):
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と比較してください(コピーの分だけコストが高くなります):
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];
}
}ガスの節約はデータサイズに応じて拡大し、100要素の配列をcalldataとして渡す場合、memoryと比較して数千ガスを節約できます。
memoryを使わなければならない場合: 関数が配列を変更したり、要素を追加したり、新しいデータ構造を構築したりする必要がある場合、calldataは不変であるためmemoryが必要です。しかし、純粋な読み取り操作であれば、calldataが常により良い選択です。
ストレージ場所の違いについて詳しくは、calldataとmemoryのドキュメントを参照してください。
8. immutableとconstantで固定値を使う
constantまたはimmutableとマークされた変数はストレージスロットをまったく使いません。その値はデプロイ時にコントラクトのバイトコードに直接埋め込まれます。これにより、これらにアクセスするたびに発生していた高コストなSLOAD操作(1回あたり2,100ガス)が不要になり、ストレージ読み取りが安価なバイトコード読み取りに置き換わります。
Constant: 値はコンパイル時に設定される必要があり、変更できません。デプロイ間で決して変わらないハードコードされた値に使います。
Immutable: 値はコンストラクタで一度だけ設定され、その後は変更できません。デプロイごとに異なる値(トークンアドレスやオーナーアドレスなど)で、デプロイ後は固定される値に使います。
両方の使い方は以下の通りです:
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
}
}ガス節約の例: 通常のストレージ変数を関数内で10回読み取る場合、SLOAD操作だけで21,000ガスかかります。constantまたはimmutableを使えば、これらの読み取りは実質的にコストゼロになります。基本的な算術演算を実行するためのガスだけで済みます。
一般的なユースケース:
- プロトコルの手数料率やパーセンテージ(
constant) - 小数点桁数やスケーリング係数といった数学的定数(
constant) - コンストラクタの引数から得られるトークンアドレス(
immutable) - コントラクトのオーナーまたは管理者アドレス(
immutable) - 変更されない外部コントラクトアドレス(
immutable)
constantとimmutableの両方の変数は、コントラクトの外側のファイルレベルでも宣言でき、同じファイル内の複数のコントラクトで再利用可能にできます。
詳しくは、constantとimmutableのドキュメントを確認してください。
9. external可視性修飾子を使う
関数の可視性修飾子は、EVMが関数呼び出しをどう処理するかに影響し、ガスコストに影響を与えます。重要な違いは、external関数がコントラクトの外部からの呼び出しに特化して最適化されている一方、public関数は外部・内部の両方の呼び出しに対応する必要があり、オーバーヘッドが加わることです。
External関数は、コントラクトの外部(トランザクションまたは他のコントラクトを介して)からのみ呼び出せます。外部から呼び出された場合、コピーせずにcalldataから直接パラメータを読み取るため、わずかに効率的です。functionName\(\)を使って外部関数を内部的に呼び出すことはできません。this.functionName\(\)を使う必要がありますが、これは高コストな外部呼び出しを生成します。
Public関数は外部からも内部からも呼び出せます。この柔軟性のため、コンパイラは両方の呼び出しタイプを処理する追加コードを生成する必要があり、外部から呼び出される場合でもわずかなガスオーバーヘッドが加わります。
一般的な目安: コントラクトの公開APIの一部であり、ユーザーや他のコントラクトからのみ呼び出される関数にはexternalを使います。コントラクト自身の関数からのみ呼び出す必要があるヘルパー関数にはinternalまたはprivateを使います。
external関数の例です(外部からの呼び出しに最適化されています):
function updateMessage(string calldata _newMessage) external returns (string memory) {
message = _newMessage;
return message;
}public(内部・外部の両方を処理します)と比較してください:
function updateMessage(string memory _newMessage) public returns (string memory) {
message = _newMessage;
return message;
}ガスの節約はささやかです。1回の呼び出しあたり通常20〜50ガス程度ですが、数千回のトランザクションにわたって積み重なります。さらに重要なのは、externalを使うことで意図が明確に示されることです。この関数はコントラクトの外部から呼び出されることを意図している、という意図です。
補足: externalバージョンでは文字列パラメータにcalldataを使っていることに注目してください。external関数はcalldata引数と非常に相性が良く、両方とも外部呼び出しに最適化されているためです。
関数の可視性とベストプラクティスについて詳しくは、可視性ドキュメントを確認してください。
10. unchecked算術演算を安全に使う
Solidity 0.8.0以降、コンパイラはすべての算術演算に対してオーバーフローとアンダーフローのチェックを自動的に追加するようになりました。これによりバグを防げますが、1回の操作あたりおよそ30〜40ガスのコストがかかります。オーバーフロー/アンダーフローが数学的にあり得ないと確信できる場合は、演算をuncheckedブロックで囲むことで、これらのチェックをスキップしてガスを節約できます。
安全な場合: 既知の範囲を持つループカウンタ、入力を検証済みの算術演算、あるいはオーバーフローが数学的にあり得ない操作。
避けるべき場合: 検証されていないユーザー提供の値、財務計算、あるいはオーバーフローが脆弱性を生み得る場面全般。
基本的な例です:
function add(uint x, uint y) external pure returns (uint) {
unchecked {
return x + y; // Skips overflow check - saves ~30 gas
}
}この手法の最も一般的なユースケースであるループカウンタを示す別の例です:
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がオーバーフローするには、配列が2^256個の要素を持つ必要がありますが、これはブロックチェーンの制約上、物理的に不可能です。これはuncheckedの完璧な候補です。
安全に関する重要な注記: uncheckedを使う場合は、問題を引き起こしかねない入力を検証するために手動でrequire文を追加してください:
function subtract(uint x, uint y) external pure returns (uint) {
require(x >= y, "Underflow prevented");
unchecked {
return x - y; // Safe because validated above
}
}uncheckedブロックによるガスの節約は、ループや頻繁に呼び出される関数において急速に積み重なります。詳細やエッジケースについては、uncheckedのドキュメントを確認してください。
11. 外部呼び出しを最小化する
他のコントラクトへの呼び出しはそれぞれ、CALLオペコードに少なくとも100ガスかかり、さらにcalldataのコストと呼び出し先のコントラクトでの状態変更に対する追加コストがかかります。これらの呼び出しは、外部コントラクトがリバートした場合に予測不能な形で失敗することもあり、高コストであると同時にリスクも伴います。外部コントラクトからデータが必要な場合は、複数の呼び出しをまとめてバッチ処理するか、結果をキャッシュして同一トランザクション内での呼び出しの重複を避けましょう。
ガス効率の良いアプローチ:
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;
}非効率なアプローチ(避けるべきです):
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ガンのSSTOREコストと外部呼び出しの頻度を天秤にかける必要があります)。
外部呼び出しに関するパターンとセキュリティ上の考慮事項について詳しくは、外部呼び出しのベストプラクティスを参照してください。
12. クリティカルパスにアセンブリを使う
タイトなループや頻繁に呼び出される関数のようなパフォーマンスがクリティカルなコードでは、Solidityコンパイラが達成できる範囲を超えて手動で最適化するために、Yulアセンブリまで踏み込むことができます。アセンブリは、メモリ管理を直接制御でき、安全性チェックをスキップでき、抽象化のオーバーヘッドを排除できます。しかし、これは諸刃の剣です。アセンブリはSolidityの安全機能をすべて回避するため、コードは読みにくくなり、非常にエラーを起こしやすくなります。
アセンブリを検討すべき場合: 高頻度の操作、複雑なビット操作、カスタムメモリレイアウト、あるいはガス単位の一つ一つが重要になる、数千回実行されるループ。
アセンブリを避けるべき場合: それ以外のあらゆる場面。ガスの節約が、バグ、セキュリティ脆弱性、保守負担の増加というリスクを正当化することはめったにありません。
アセンブリを使って配列を合計する例です:
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
}
}
}このアセンブリ版は、メモリポインタを直接操作し、境界チェックをスキップすることでガスを節約しますが、同等のSolidityコードと比較して、理解や監査がはるかに困難です。
重要な安全対策
- エッジケースを含めてアセンブリコードを徹底的にテストする
- すべての操作を説明する詳細なコメントを追加する
- アセンブリ部分をセキュリティ専門家に監査してもらう
- Solidityでの最適化を尽くした上での最後の手段としてのみアセンブリを使う
ほとんどの開発者、ほとんどのユースケースにおいて、このガイドの他の11のテクニックの方が、はるかに低いリスクでより良いガス節約を提供します。アセンブリに手を伸ばすべきなのは、コントラクトをプロファイリングし、具体的なボトルネックを特定し、ガスの節約が追加される複雑さを正当化すると確認できた場合のみです。
Yulの構文と機能について学ぶには、Yulのドキュメントを参照してください。
デプロイ前にスマートコントラクトをテストする
メインネットへのデプロイ前に、開発ツールを使ってガス使用量を厳密にテストしてください。Hardhatは、関数ごとのガスコストの詳細な内訳を生成するガスレポータープラグインを提供しています。Remix IDEはテスト中にリアルタイムでガス見積もりを表示します。最適化の取り組みは、mint、転送、スワップといったユーザー向けの関数に集中させましょう。これらは最も頻繁に呼び出され、ユーザー体験への影響が最も大きい部分です。
さらに踏み込んだテストには、Foundryのファジング機能を使って、数千のランダム化された入力に対して最適化をベンチマークし、ガスの節約が実世界の条件やエッジケースでも維持されることを確認してください。
まとめ: 最適化を始めよう
これで、Solidityコントラクトをより軽量で実行コストの低いものにするための、実戦で検証済みの12のテクニックが揃いました。ガス最適化は単にお金を節約することだけではありません。ユーザーにとってより良い体験を構築し、彼らが実際に使いたいと思うアプリケーションを作ることでもあります。
まずは手っ取り早く実装できるものから始めましょう。コンパイラオプティマイザを有効にする、配列の代わりにマッピングを使う、固定値をconstantまたはimmutableとしてマークする、といったことです。その後、コントラクトをプロファイリングしてボトルネックを見つけ、影響の大きいところにより高度なテクニックを適用してください。
さらに学ぶためのリソース:
さあ、実際に最適化を始めましょう。あなたのユーザー(とその財布)はきっと感謝するはずです。
よくある質問
Solidityスマートコントラクトのガスコストを削減する最も効果的な方法は何ですか?
最も効果の高いテクニックには、検索に配列の代わりにマッピングを使うこと、適切なrunsの設定でSolidityコンパイラオプティマイザを有効にすること、固定値をconstantまたはimmutableとしてマークすること、ストレージ操作を最小化すること、読み取り専用の関数パラメータにmemoryの代わりにcalldataを使うことが含まれます。
constantとimmutable変数を使うことは、どのようにガス最適化に役立ちますか?
constantとimmutableの値は、コストの高いストレージスロットに保存される代わりに、コントラクトのバイトコードに直接埋め込まれます。これにより、コストの高いSLOAD操作(1回あたり2,100ガス)が不要になり、安価なバイトコード読み取りに置き換わります。
データ検索になぜ配列の代わりにマッピングを使うべきなのですか?
マッピングは、データサイズに関わらずO(1)の定数時間検索を提供しますが、配列はO(n)の反復処理が必要で、配列が大きくなるほどコストが高くなります。マッピングは、ユーザー残高や所有権記録といったキーベースのアクセスパターンにおいて大幅に安価です。
Solidityコンパイラオプティマイザはどのように動作し、どのrunsの設定を使うべきですか?
オプティマイザは、式を単純化し、デッドコードを削除し、関数をインライン化することでガスを削減します。頻繁にデプロイするが呼び出し頻度は低いコントラクトには低いruns(200)を使い、実行効率がデプロイコストよりも重要になる、トランザクション量の多いコントラクトには高いruns(10,000以上)を使ってください。
calldata、memory、storageをガス最適化のために使い分ける違いは何ですか?
読み取りのみを行うexternal関数パラメータにはcalldataを使います(コピーコストを回避できます)。一時的なデータ操作にはmemoryを使います。永続的な状態が必要な場合のみstorageを使います。読み取り専用の操作では、calldataは常にmemoryより安価です。
unchecked算術ブロックはいつ安全に使うべきですか?
既知の範囲を持つループカウンタや検証済みの算術演算のように、オーバーフローが数学的にあり得ない操作にuncheckedを使います。これにより、Solidity 0.8.0で導入された自動オーバーフローチェックをスキップすることで、1回の操作あたり約30〜40ガスを節約できます。
ガスコストを削減するために、ストレージ操作をどう最適化すればよいですか?
複数の小さな変数を単一の32バイトストレージスロットにパッキングし、未使用のストレージを削除してガス還付を得(クリアされたスロットごとに4,800ガス)、関数実行中にメモリに値をキャッシュすることでストレージへの書き込みを最小化してください。
publicよりもexternal可視性修飾子を使う利点は何ですか?
external関数は、コントラクトの外部からの呼び出しに特化して最適化されており、calldataパラメータと効率的に連携するため、内部・外部の両方の呼び出しに対応する必要があるpublic関数と比較して、1回の呼び出しあたり20〜50ガスを節約できます。
関連する概要

ブロックチェーンで魔法を生み出す
Alchemyは、最も強力なweb3開発者向けプロダクトとツールを、豊富なリソース、コミュニティ、そして卓越したサポートと組み合わせて提供します。


