Cadenceのかんどころ(2)ストレージ
前回
前回はFlowブロックチェーンの基礎であるトランザクションと、Cadence言語の基本的な考え方であるリソース指向について紹介した。今回は、Flowのアカウントがどのようにストレージを扱うのかについて紹介したいと思う。
アカウント・ストレージ
EthereumをはじめとするEVM互換のブロックチェーンと同じく、Cadenceでもアカウントごとに用意されたストレージ(記憶領域)が存在する。しかしEthereumと違うのはストレージに種類があり、その種類ごとに使われ方が違うという点だ。
Cadenceのアカウント・ストレージは大きく分けて2種類ある。普通のStorageとCapabilityStorageだ。StorageにはAny型のオブジェクト(つまりストラクト型とリソース型両方)のうちStorableなものを保管でき、CapabilityStorageにはCapability型のオブジェクト(後述)を保管できる。
Storableなオブジェクトとは、関数型(Function
)、参照型(Reference
)、アカウント型(PublicAccount
, AuthAccount
)、トランザクション型(Transaction
)を除くすべてのAny型のオブジェクトである。全てのリソース型のオブジェクトはStorableなのでStorageに保管することができる。エッジケースの話になるが、StorageとCapabilityStorageの両方に保管できるオブジェクトはCapability型(後述)のオブジェクトだけになる。Capability型はストラクト型およびAny型を継承しているからだ。
次にアカウントストレージとやり取りするコードの紹介に移る。アカウントストレージの普通のStorageに保管するためにはsave関数を使う。
fun someFunc(acct: AuthAccount) {
acct.save(value, to: path)
}
ここで出てくるpathという概念についてまず説明する。
ストレージ内の場所を指し示す値はPath型と呼ばれる型をもつ変数が保持する。Path型には二つの子クラスが存在し、それぞれStoragePath型とCapabilityPath型と呼ばれる。その名前が意味する通り、それぞれがStorage領域と、CapabilityStorage領域を指し示すパスの値を保持する。Storage型は/storage/<Identifier>
という書式で書かれ、CapabilityPath型は/public/<Identifier>
もしくは/private/<Identifier>
という書式で表される。勘のいい読者には推測できるかもしれないが、publicとprivateの場合で、CapabilityPath型にも二つの子クラスが定義されていて、それぞれPublicPath型、PrivatePath型と呼ばれる。パスをクオーテーションマーク”
で囲いたくなるが、そうするとString型になってしまうためやってはいけない。また、パスもオブジェクトなので変数に代入して扱える。
let path1: StoragePath = /storage/Hoge
let path2: PublicPath = /public/Hoge
let path3: PrivatePath = /private/Hoge
トランザクション実行中に作成した変数・オブジェクトはそのトランザクションの実行終了とともに跡形もなく消えてしまう。そういう変数の値をストレージにセーブしておけば、後で必要な時に取り出すことができるようになる。
ストレージの値の実体
マニアックな話になるが、Cadenceでのストレージの値の扱われ方も調べたので紹介する。なぜそんなことに興味を持ったかと言うと、EVMではストレージの値の扱い方が結構複雑であるからだ。EVMオペコード(マシン語のようなもの)で書かれた処理が人間にとって分かりづらいのは当たり前だが、そのような低級言語がないCadenceではどのようにストレージの値を扱っているかに興味が湧いた。同じように複雑なのだろうか?
その前にEVMの複雑さについて軽く説明させて頂く。EVMが扱う値は、スタック、メモリ、コールデータ、ストレージなどいくつかの場所に保存され、その場所ごとに保管フォーマットが違う。そのため、その間で値をやり取りする際には形式を変換するなどしないといけない。普段こういったことはSolidity、Vyperなどの高級言語を書くときには意識しなくて良いように、コンパイラが頑張ってくれている。Solidityで宣言した構造体が何バイトなのかは特に気にしないだろう。しかしその裏では、例えば、EVMのストレージは一つのスロット(値を区切る単位)が32バイトという制限があるため、32バイトに収まらないオブジェクトをストレージに保管する場合は、複数回の書き込み(sstore)に分けてやるといった裏方の仕事が行なわれている。Solidityから見る変数の値の見え方と、裏側で動くEVMでの見え方には違いがあるのだ。これを考えずにdelegatecallを誤って使ってしまうと、SWC112という脆弱性を含んだコードになってしまう恐れがある。
一方、Cadenceでは値が変数にあれどストレージにsaveされど、ランタイム上では同じ形式で表現されている。CadenceランタイムはGoで書かれているのだが、変数もストレージ変数もその値は内部的にはValueインターフェースで表現されているのだ。これはインタプリタ言語であるCadenceだからできる芸当とも言えるが、この違いは面白い。ストレージにsaveすることはそういう変数に代入しているに過ぎないということだ。もう少し専門的な言葉を使うと、値がストレージに保管されることの実体は、永遠(削除しない限り)のエクステント(extent。変数の生存期間のこと)を持つストレージ変数に代入(リソース型の場合は”移動”)することだと言える。そのため、Cadenceの値はEVMのようにストレージ用の形式に変換することなしにストレージに保管することができる。Cadenceのランタイムから見ると、普通の変数が保持する値もストレージで保持する値も、Valueインターフェースを実装したgo上の構造体であるという見え方は変わらないのである。
ストレージの使いかた
…さて、少し局所的に深堀りしすぎたので話を戻そう。
パスの書式(/<domain>/<Identifier>
)はファイルシステムのフォルダー構造のように見えるものの、フォルダーを作って階層構造を作れるわけではなく、すべてのオブジェクトはドメイン(storage
, private
, public
)直下のIdentifier
で指し示される場所にbindする。つまり、acct.save(obj, to: /storage/Obj)
という構文は、感覚としては、acct.storage.Obj = obj
と理解してよい。
また、リソース型のオブジェクトがStorageに保管される場合にも、上記リソース型の制約は適用される。つまりリソースオブジェクトはStorageから出し入れしても複製されたり、暗黙的にdestroyされることはない。
次に実際にストレージとやり取りするコードを見てみよう。
let player: Player = HelloWorld.Player(name: "Bob", age: 1)
let vault: @Vault <- HelloWorld.createVault()
acct.save(p, to: /storage/Player)
acct.save(<- vault, to: /storage/Vault)
変数acct
はAuthAccount型と呼ばれるアカウント型の変数で、それに属するsave関数よってオブジェクトを指定したパスに保管できる。AuthAccount型はSolidityでいうmsg.origin
のようなアカウントで、つまりこのコントラクトを実行するトランザクションに署名したアカウントを表す。しかしmsg.origin
と異なるのは、msg.origin
がグローバル変数でプログラムのどの部分からでも呼び出せるのに対して、AuthAccount型の変数にアクセスするためには、下のコードのようにトランザクション・スクリプトのエントリーポイントで引数として渡されたAuthAccount型の引数を、明示的に呼び出し先に渡さないといけないことだ。
transaction {
prepare(acct: AuthAccount) {
SomeContract.methodThatTakesAuthAccount(acct: acct)
}
}
AuthAccount型だけが扱える特権的なメンバ関数(すぐに紹介する)が実行されうる関数には、AuthAccount型の引数を渡さないといけないため、CadenceではAuthAccountが呼ばれる個所がトップレベルから順繰りに可視化される。これはコントラクトの書き手にとっても読み手にとってもありがたい。ある意味で、セキュリティの向上に寄与しているとも言えるだろう。
余談だがこういった記述方法は、Solanaのコントラクトでも見られ、スマートコントラクトを書くプログラミング言語やフレームワークでは今後も重要視される考え方になるのではと考えられる。
ちなみに、AuthAccount型の権限がない普通のアカウント型もある。これはPublicAccount型と呼ばれ、getAccount(address)
というグローバル関数でいつでもどこでも呼び出せる。
pub fun someFunc() {
let acct = getAccount(0xXXXXXX...) // get public account object at address 0xXXXXXX...
}
AuthAccount型とPublicAccount型の大きな違いは、呼べるメンバ関数の種類にある。
こちらにあるとおり、AuthAccount型のメンバには、署名鍵の編集や、ストレージへの保管(save
)、ストレージからのオブジェクト取り出し(load
)、複製(copy
)などの重要な操作が含まれている一方、PublicAccount型のメンバにはそのような操作は含まれていない。
fun save<T>(_ value: T, to: StoragePath)
fun load<T>(from: StoragePath): T?
fun copy<T: AnyStruct>(from: StoragePath): T?
save
、 load
、copy
関数のシグネチャを見ればわかる通り、これらの関数はStoragePath型(/storage/<Identifier>
)に保管するオブジェクトを扱う。
copy関数のシグネチャの型引数T
はAnyStruct
を継承していることになっている。これはつまりcopy関数で複製できる対象はストラクト型のみであることを意味している。リソース型は複製できないという制約がここにも表れている。load関数はその命名からは読み込む操作に見えるが、実際は取り出し操作を行っている。つまりストレージからオブジェクトを読み出して、ストレージからは削除している。裏側のインタプリタの動きを見るとcopyもloadも同じ関数を使っているが、loadの場合だけフラグを立てて、読み込み後にnilを保管する操作を行っていることが分かる。
読み込みだけを行いたい場合は、ストラクト型ならcopy関数もしくはborrow関数、リソース型ならborrow関数を使う。borrow関数は以下のように定義されている。borrow関数もAuthAccount型にのみ提供された特権的な操作になる。
fun borrow<T: &Any>(from: StoragePath): T?
実際使うときは以下のようになる。
// ストラクト型のPlayerをborrowする
let player = acct.borrow<&Player>(from: /storage/Player)
// リソース型のVaultをborrowする
let vault = acct.borrow<&Vault>(from: /storage/Vault)
borrow関数はオブジェクトを参照型で返している点でloadやcopyと異なる。参照型についてはすぐに説明する。
CapabilityPath型のパス内、つまりCapabilityStorage内のオブジェクトを扱うには、Capabilityオブジェクトを作成するlink
関数や、Capabilityオブジェクトを読み込むgetCapability
関数を使わないといけない。Capabilityについても後述する。
fun link<T: &Any>(_ newCapabilityPath: CapabilityPath, target: Path): Capability<T>?
fun getCapability<T>(_ path: CapabilityPath): Capability<T>
また、さらっとスルーしてきたが、T?
という風にクエスチョンマーク?
が型名の後ろについていると、それはT
の**Optional型**と呼ばれる。typescriptなどでもお馴染みの記法だろう。これはT
かnil
のどちらでもありえる型を表している。Optional型の値の後ろに??
や!
といったNil合体演算子(Nil-Coalescing Operator)を添えることで、nil
の場合の評価方法を簡潔に記述できる。
let vault = acct.borrow<&Vault>(from: /storage/Vault) ?? panic("failed to borrow Vault")
おわりに
ストレージの概念の説明は思った以上に長くなってしまった。次はオブジェクトを値渡しせず参照したいときに重宝される参照型について話す。
Discussion