アルゴリズムとかオーダーとか

仕事で勉強したことなどをまとめてます

Solidity Assembly入門 ~ 配列について storage/memory ~

今回の記事はSolidity Assembly入門という連載記事の第4回目です。
この連載ではSolidityのコードをコンパイルした時に生成されるopcodeについて解説していきます。
この連載ではSolidityのコードをデバッグするのに必要な知識を得られることを目的としています。
前回の記事はこちら。
y-nakajo.hatenablog.com

第4回目の今回は、Solidityの配列について説明します。公式ドキュメントはこちらです。
Solidity Documentation / Arrays

配列の定義方法

Solidityではあらゆる型に対して配列を定義できます。
ethfiddle.com

また、arrayにはmemoryとstorageの2種類が存在します。次に、この2つの違いを説明します。

memory array

f:id:y_nakajo:20180712090149p:plain
functionの中で宣言するmemoryは基本的にmemory arrayです。
functionの中でstorage arrayを宣言することもできます(* その場合はwarningが通知されます)がコードが非常に分かりにくくなるためおすすめしません。

memory arrayの宣言方法はarray定義時にmemory修飾子をつけます。
memory修飾子のついていないarray宣言はすべてstorage arrayとみなされます。

memory arrayの初期化処理では以下の手順で領域の確保が行われます。

  1. free memory addressを探す(let freeP := mload 0x40)
  2. 1のaddressに配列のlength値を保存する(mstore freeP, length)
  3. 1のaddressの+0x20以降に配列要素のための領域を確保する。つまりfree memory addressを指す0x40の値を更新する。(mstore 0x40 add(freeP, add(mul(0x20, length), 0x20)))

memory arrayの場合はlengthに続いて必要な領域が確保されます。
その後に、必要に応じて他の変数にmemory addressが割り当てられるため最初に確保した領域を広げることはできません。

storage array

f:id:y_nakajo:20180712090150p:plain
storage arrayはContractの中で永続的に残るデータです。Contractのメンバ変数として定義する場合はこちらになります。
storage arrayはdynamic arrayとして宣言した場合はpushメソッドが利用可能になります。pushメソッドによって任意に拡張が可能です。ただし任意の位置の要素を削除することはできません。size自体を縮めることはできます。
※arrayの要素を削除できない理由については過去記事を参考にしてください。
y-nakajo.hatenablog.com

storage arrayを宣言した場合、以下の手順で領域を確保します。

  1. storage addressには配列の要素数、いわゆるlengthの値を入れる。sstore(addr, length)
  2. storage address*1のkeccak256ハッシュ値を求める。let shap := (mstore(freep, addr) keccak256(freep, add(freep, 0x20)))
  3. 1で求めたハッシュ値を先頭addressとして配列の本体データを格納する。(ハッシュ値+indexがデータの格納位置) sstore(add(shap, 0x20), value)

storage arrrayの本体をハッシュ値の位置に格納する理由について調べてはいないのですが、

  • ハッシュ値をとることでstorageのaddress空間に平均的に分散させるため
  • ハッシュ値でもとまる領域は基本的に空いている(それほどの数のメンバ変数を定義してる場合はそもそもデプロイできない)
  • storage空間に平均的に分布するため、storage arrayを複数定義した場合でも、現実的な要素数では他の配列の領域に到達しない

が理由かなと思います。

初期化処理

Solidityではdynamic arrayを宣言したときに、領域確保と同時に初期化処理のためのopcodeも発行しています。
以下のサンプルコードでは生成する配列の要素数に応じて必要となるgas costが増減していることがわかります。
ethfiddle.com

lengthの変更に関しても初期化処理が行われる

stroage arrayのlengthの値を書き換えるようなコードの場合でも初期化処理が自動的に挿入されます。
サンプルコードは以下です。lengthを直接操作できるのはstorage arrayのみです。
ethfiddle.com

上記サンプルコードを実行してわかる通り、配列のsizeを大きくする時より小さくする時のほうが多量のgasを必要とします。
これは、storageでは未確保領域は初めから0であることが自明なためです。
逆にsizeを小さくする場合は使わなくなった領域を掃除する必要があるため、解放された領域を0埋めするための初期化コードが実行されます。そのため、sizeを大きくするより小さくする方がより多くのgasが消費されます。

なお、デプロイ後の最初の1回目のみgas代が高くなるのは length の値を初めて書き込みに行くために20000gas必要なためです。それ以降は変更のみなので5000gasとなります。詳しくは過去記事を参照。
y-nakajo.hatenablog.com

自動範囲チェック

Solidityで配列を宣言した場合、配列の要素にアクセスするコードには自動的に要素範囲外にアクセスしていないかチェックするためのopcodeが生成されます。
ethfiddle.com
上記コードをRemixでoptimization: trueにして実行した場合に、program counter 157 〜 168のコードがlength checkのopcodeになります。
具体的には以下の処理です。

157 PUSH1 04
159 DUP2
160 MLOAD
161 DUP2    ←ここで stackの先頭から 4 length という並びになる。
162 LT     ← 4 < length であればstackの先頭に 1 が積まれる
163 ISZERO
164 ISZERO   ← 2回 iszero するので元の値が1なら1に戻る。0なら0
165 PUSH1 a9
167 JUMPI    ← stack が a9 LTの結果の並びになっているので、LTの結果が1なら169に飛んで処理は継続される。0なら INVALIDに進むため処理が停止する
168 INVALID
169 JUMPDEST

重要な部分だけ説明を入れています。
for文で配列を扱う場合も1loopごとにこのコードが実行されるためgas代が増加します。

ちょっとしたテクニックとしてlengthの範囲を超えないことが明らかなのであればasseblyで記述することでlength checkのコードを省略できるためgas代が節約できます。(でも危ないのであまりお勧めしません)

【付録】2次元配列の宣言

付録としてmemory arrayとしての2次元配列の宣言方法と使い方を説明します。自分が使い方がよくわからずにはまったので。。。
ethfiddle.com

2次元配列では宣言時とアクセス時で要素の指定が反転する

これも公式ドキュメントに書いているのですが、solidityの2次元配列は宣言時とアクセス時で指定すべきindexの位置が反転します。
なので、上記のサンプルコードでは基本的に a > bでないとinvalid opcodeエラーが発生します。
上記のサンプルコードの例で説明すると。_ddaへアクセス可能なindexの範囲は以下になります。

_dda[0][0] ~ _dda[a-1][4] 

まとめ

Solidityの array には memory array と storage array の2つがあります。特に dynamic array (実行時に要素数が決定するもの) については両者での実現方法が全く違います。ただし、Solidityのコードを記述する場合には特に意識する必要はありません。
またSolidityではコンパイル時に自動的にlengthチェックのopcodeを生成しているため、配列の要素外にアクセスして不定な値を拾ってくる心配がないように設計されています。そのためコード量に比べて生成されるopcode量は増えてしまいます。

*1:storage変数を宣言したときに振られる連番