キャストをする時、裏側で何が起こっているのか?【Swift】
- 2020.03.24
- テクノロジー
先日、キャスト(Type Casting)について原点的な疑問にぶつかった。
let a: Int = 28
let b = a as Any
let c = b as! Int
print(c - 1) // 27
このようにInt型で定義した値を、Any型にキャストし、再度Int型にキャストしたとする。
この時、Int型へのforce castは無事成功し、変数 c は整数型として扱うことが出来る。
一見ありがたい挙動にも見える反面、Any型へのキャストを行った後にも変数 b がInt型としての値の情報を保持していることに関して不思議に思った。
これはSwiftの言語仕様として一体どのような仕組みになっているのだろうか。Swiftという言語において型システム周辺のデータがどのように表現されているのか、そして実行時の型の振る舞いについて、詳しく見てみよう。
SILから見るキャストの挙動
Swiftに詳しい @Kuniwak さんに上記の質問をしたところ「SILを読めばわかりそう」と言って、手取り足取り色々教えて頂いた。それをベースに補足しながら解説しようと思う。
SILとはSwift Intermediate Languageの略で、Swiftのコードをバイナリにコンパイルする時に出てくる中間表現だ。この中間表現を解読することによって、コンパイラの気持ちをしっかりと反映した思索が可能になる。
高級なSwiftの表現のままだと人間の言葉に近すぎて隠蔽されてしまっている情報があるので、中間表現であるSILを読むことによってより低レベルな情報を得ようというわけだ。
とりあえずSILを読む
以下のコマンドを叩くことでSILを得ることが出来る。(SILのコードの部分は流し見をして、必要に応じて戻って読むというスタイルをおすすめします。)
$ cat ./cast.swift
func makeInt() -> Int {
let a: Int = 28
let b = a as Any
let c = b as! Int
return c
}
$ swiftc -emit-sil -Onone -o ./cast.sil ./cast.swift
得た出力から、該当する関数の部分を下記に抜粋した。
// makeInt()
sil hidden @$s4cast7makeIntSiyF : $@convention(thin) () -> Int {
bb0:
%0 = integer_literal $Builtin.Int64, 28 // user: %1
%1 = struct $Int (%0 : $Builtin.Int64) // users: %5, %2
debug_value %1 : $Int, let, name "a" // id: %2
%3 = alloc_stack $Any, let, name "b" // users: %15, %14, %7, %4
%4 = init_existential_addr %3 : $*Any, $Int // user: %5
store %1 to %4 : $*Int // id: %5
%6 = alloc_stack $Any // users: %13, %9, %7
copy_addr %3 to [initialization] %6 : $*Any // id: %7
%8 = alloc_stack $Int // users: %10, %12, %9
unconditional_checked_cast_addr Any in %6 : $*Any to Int in %8 : $*Int // id: %9
%10 = load %8 : $*Int // users: %11, %16
debug_value %10 : $Int, let, name "c" // id: %11
dealloc_stack %8 : $*Int // id: %12
dealloc_stack %6 : $*Any // id: %13
destroy_addr %3 : $*Any // id: %14
dealloc_stack %3 : $*Any // id: %15
return %10 : $Int // id: %16
} // end sil function '$s4cast7makeIntSiyF'
上のSILはあまり複雑なことをしていないので、全く知らなくてもなんとなく何が書いてあるのかわかると思う。メモリ領域を割り当てたり、変数の定義をしているなと読み取れる。
そのうち、キャストに関係がありそうな部分を詳しく調べることにする。
Any型へのキャストは
%4 = init_existential_addr %3 : $*Any, $Int // user: %5
ここでなされており、
Int型へのキャストは
unconditional_checked_cast_addr Any in %6 : $*Any to Int in %8 : $*Int // id: %9
ここでなされている。
init_existential_addr とは何か
「init_existential_addr Swift」で検索をするとSILの読み方の公式ドキュメント(swift/SIL.rst)がヒットする。(以下の2つのブロックは引用)
sil-instruction ::= 'init_existential_addr' sil-operand ',' sil-type
%1 = init_existential_addr %0 : $*P, $T
// %0 must be of a $*P address type for non-class protocol or protocol composition type P
// $T must be an AST type that fulfills protocol(s) P
// %1 will be of type $*T', where T' is the maximally abstract lowering of type T
Partially initializes the memory referenced by %0 with an existential container prepared to contain a value of type $T. The result of the instruction is an address referencing the storage for the contained value, which remains uninitialized.
Int型からAny型へのキャストの場合に当てはめて、これらを翻訳してみる。
init_existential_addr によって、Any型の変数 b のポインタが指すメモリがexistential containerと共に部分的に初期化される。返り値はexistential containerが保持する値へのアドレスとなる。
という感じだ。Any型として初期化する際に、Int型の値を保持することができるようなexistential containerというものが生成されていることがわかる。
このコンテナのおかげで、Any型にキャストした後にもInt型の情報を失うことはなかったと言えるだろう。
さて、Existential Containerの実装についてより詳しく見るためにSwiftのソースコードで検索を行い、以下の構造体を見つけることが出来る。
/// The basic layout of an opaque (non-class-bounded) existential type.
template <typename Runtime>
struct TargetOpaqueExistentialContainer {
TargetValueBuffer<Runtime> Buffer;
ConstTargetMetadataPointer<Runtime, TargetMetadata> Type;
const TargetWitnessTable<Runtime> **getWitnessTables() {
return reinterpret_cast<const TargetWitnessTable<Runtime> **>(this + 1);
}
const TargetWitnessTable<Runtime> *const *getWitnessTables() const {
return reinterpret_cast<const TargetWitnessTable<Runtime> *const *>(this + 1);
}
...
/// Project out a pointer to the value stored in the container.
///
/// *NOTE* If the container contains the value inline, then this will return a
/// pointer inside the container itself. Otherwise, it will return a pointer
/// to out of line memory.
const OpaqueValue *projectValue() const;
...
};
BufferというフィールドがExistential Containerが保持しているデータの中身(又はポインタ)でTypeというフィールドがMetadata型の値である。
projectValue()という関数が中身の値を返す関数だと書いている。この関数が、実際の値を取り出す際には使われていそうな予感がした。
Existential Containerについての詳しいことはこの記事を参照してください。
unconditional_checked_cast_addr とは何か
SILを呼んでいてわからない単語が出てきたら、とりあえず先程の SIL.rst で検索を書けると良い。この sil-instruction を検索してみると、特に目新しい情報は出てこなかった。
unsafeBitCast() という関数がメモリ上の表現だけを見てキャストするように、こういったキャストは先程得られた値からunsafeに実行されているとすると特段疑問に思うこともない。
projectValue()などの関数によってExistential Containerが保存していた値を取り出し、型情報との整合性を見て、その値を返すのだろうと予想して次に進むことにする。
ProtocolとClassの違い
これまでの話は全て、protocolへのキャストの話をしていた。(そのことについて最初はあまり気にしていなかった。)
しかしprotocolへのキャストと親クラスへのアップキャストはSILを読み解くと全然違うことに気が付いた。
$ cat dog.swift
class Dog {}
class Doberman: Dog {
var kawaisa = 3
}
func getKawaisa() -> Int {
let a = Doberman()
a.kawaisa = 5
let b = a as Dog
let c = b as! Doberman
return c.kawaisa
}
$ swiftc -emit-sil -Onone -o ./dog.sil ./dog.swift
上を実行したところ、クラスの定義がある分、より大きなSILが出力されることになる。関数部分のSILは以下の通りだ。
// getKawaisa()
sil hidden @$s3dog10getKawaisaSiyF : $@convention(thin) () -> Int {
bb0:
%0 = metatype $@thick Doberman.Type // user: %2
// function_ref Doberman.__allocating_init()
%1 = function_ref @$s3dog8DobermanCACycfC : $@convention(method) (@thick Doberman.Type) -> @owned Doberman // user: %2
%2 = apply %1(%0) : $@convention(method) (@thick Doberman.Type) -> @owned Doberman // users: %18, %9, %8, %6, %7, %3
debug_value %2 : $Doberman, let, name "a" // id: %3
%4 = integer_literal $Builtin.Int64, 5 // user: %5
%5 = struct $Int (%4 : $Builtin.Int64) // user: %7
%6 = class_method %2 : $Doberman, #Doberman.kawaisa!setter.1 : (Doberman) -> (Int) -> (), $@convention(method) (Int, @guaranteed Doberman) -> () // user: %7
%7 = apply %6(%5, %2) : $@convention(method) (Int, @guaranteed Doberman) -> ()
strong_retain %2 : $Doberman // id: %8
%9 = upcast %2 : $Doberman to $Dog // users: %17, %12, %11, %10
debug_value %9 : $Dog, let, name "b" // id: %10
strong_retain %9 : $Dog // id: %11
%12 = unconditional_checked_cast %9 : $Dog to $Doberman // users: %16, %14, %15, %13
debug_value %12 : $Doberman, let, name "c" // id: %13
%14 = class_method %12 : $Doberman, #Doberman.kawaisa!getter.1 : (Doberman) -> () -> Int, $@convention(method) (@guaranteed Doberman) -> Int // user: %15
%15 = apply %14(%12) : $@convention(method) (@guaranteed Doberman) -> Int // user: %19
strong_release %12 : $Doberman // id: %16
strong_release %9 : $Dog // id: %17
strong_release %2 : $Doberman // id: %18
return %15 : $Int // id: %19
} // end sil function '$s3dog10getKawaisaSiyF'
upcastというsil-instructionが使用されていることがわかる。ただし、このsil-instructionについてSILの先程のドキュメントを参照するも、「親クラスにアップキャストする」ぐらいの情報量しかでてこない。
となると、あるクラスを継承したサブクラスの初期化のタイミングで何かアップキャストをしたとしてもデータを失うことがないような構造が入っているのだろうか。先程出力したSILの中でクラスの初期化部分を抜粋する。
// Doberman.__allocating_init()
sil hidden @$s3dog8DobermanCACycfC : $@convention(method) (@thick Doberman.Type) -> @owned Doberman {
bb0(%0 : $@thick Doberman.Type):
%1 = alloc_ref $Doberman // user: %3
// function_ref Doberman.init()
%2 = function_ref @$s3dog8DobermanCACycfc : $@convention(method) (@owned Doberman) -> @owned Doberman // user: %3
%3 = apply %2(%1) : $@convention(method) (@owned Doberman) -> @owned Doberman // user: %4
return %3 : $Doberman // id: %4
} // end sil function '$s3dog8DobermanCACycfC'
// Doberman.init()
sil hidden @$s3dog8DobermanCACycfc : $@convention(method) (@owned Doberman) -> @owned Doberman {
// %0 // users: %5, %9, %2
bb0(%0 : $Doberman):
%1 = alloc_stack $Doberman, let, name "self" // users: %13, %2, %15, %16
store %0 to %1 : $*Doberman // id: %2
%3 = integer_literal $Builtin.Int64, 3 // user: %4
%4 = struct $Int (%3 : $Builtin.Int64) // user: %7
%5 = ref_element_addr %0 : $Doberman, #Doberman.kawaisa // user: %6
%6 = begin_access [modify] [dynamic] %5 : $*Int // users: %7, %8
store %4 to %6 : $*Int // id: %7
end_access %6 : $*Int // id: %8
%9 = upcast %0 : $Doberman to $Dog // user: %11
// function_ref Dog.init()
%10 = function_ref @$s3dog3DogCACycfc : $@convention(method) (@owned Dog) -> @owned Dog // user: %11
%11 = apply %10(%9) : $@convention(method) (@owned Dog) -> @owned Dog // user: %12
%12 = unchecked_ref_cast %11 : $Dog to $Doberman // users: %14, %17, %13
store %12 to %1 : $*Doberman // id: %13
strong_retain %12 : $Doberman // id: %14
destroy_addr %1 : $*Doberman // id: %15
dealloc_stack %1 : $*Doberman // id: %16
return %12 : $Doberman // id: %17
} // end sil function '$s3dog8DobermanCACycfc'
Dobermanクラスのプロパティの初期化が %3 ~ %8 で行われた後、自身(self)をDogクラスにupcastし、Dogクラスの初期化関数を呼び、それをunchecked_ref_castでDobermanクラスにダウンキャストしている。
この時、メモリのヒープ領域に保存されたオブジェクトの中身はupcastによってDobermanクラスの情報を失うことはないということが確認されるが、実際にどのようなデータ構造によって実装がなされているかは、SILと僕の現在の知識では理解できなかった。
プロトコルの場合にExsitential Containerを使用してSILレベルで実現されていたデータの保持に対して、クラスの場合にはより低レベルなデータ構造に落とし込まれてデータの保持が実現されていると見えた。
type(of:)メソッドとdynamic type
原点的な最初の疑問に対して、また別のアプローチによって理解を深めてみたい。
そもそも型推論時の型と実行時の型について、両者の違いを見てみよう。
type(of:)メソッドの挙動
Swift のインタープリターを起動する。
1> let a: Int = 28
2> let b = a as Any
3> let c = b as! Int
4> type(of: a)
$R1: Int.Type = Int
5> type(of: b)
$R2: Any.Type = Int
6> type(of: c)
$R3: Int.Type = Int
これを見ると、そもそもAnyにキャストしたとしてもtype(of:)メソッドで得られる型はIntであることがわかる。
type(of:)メソッドのドキュメントを読むと、型にはdynamic typeとstatic typeの2種類があり、dynamic typeというのは実行時の実際の型、static typeというのはコンパイル時の型であるという説明が書かれている。
そして、type(of:)メソッドで得られる型はdynamic typeなのである。
また、static typeは型推論によってコンパイル時に決定されるのだろうと予想される。では、dynamic typeはどのように表現されるのだろうか。今回の疑問を別角度から解く鍵はここに存在するような気がしたので、もう少しこのtype(of:)メソッドについて調べてみた。
あと、Existential Containerの話とどこかで交わることをそれとなく期待していたが、Existential Containerはコンパイル時の話で、type(of:)メソッドの実装はRuntimeの話なので、その期待は達成されえないもののような気がした。
type(of:)メソッドの実装
type(of:)メソッドの型は下記であると公式ドキュメントにかかれている。
func type<T, Metatype>(of value: T) -> Metatype
型が T である値を受け取って、その型のMetatypeを返すのだろうとざっくり理解できる。ではSwiftのコードで実際にどのように表現されているのだろうか。
Swiftのソースコード内でこの関数を検索していると、
@_transparent
@_semantics("typechecker.type(of:)")
public func type<T, Metatype>(of value: T) -> Metatype { ... }
という関数がヒットするが、この関数には中身はない。実際に呼ばれている関数に辿り着くために、typechecker.type(of:)で検索をする。このあたりから、僕には全体像が掴みにくくなってきた。
Expr *ExprRewriter::finishApply(ApplyExpr *apply, Type openedType, ConstraintLocatorBuilder locator, ConstraintLocatorBuilder calleeLocator) { ... }
この関数の内部で、type(of:)の挙動が記述されている。C++は全く書けないが、気合で解読していく。
DynamicTypeExpr型というdynamic typeを表現しているようなオブジェクトが生成されているのを見た。
様々な引数の値からDynamicTypeExpr型の値を生成し、それを使ってExpr型の値を生成して返すというのがこの関数のしていることらしい。
このExpr型というのは
/// Expr - Base class for all expressions in swift.
とあるように、Swiftの表現(どこまでの範囲を指すのかは知らない)の基礎となるクラスのようで、Type型のフィールドも持っている。getType()やsetType()というメソッドもあった。
(2018/3/24 追記:↑expressionsは表現ではなく式のことであり、公式ドキュメントに詳細の解説がなされている。)
そして、Expr型を継承して定義されているDynamicTypeExpr型を確認してみると、コメントに以下のように書かれていた。
/// DynamicTypeExpr - "type(of: base)" - Produces a metatype value.
///
/// The metatype value comes from evaluating an expression then retrieving the
/// metatype of the result.
このクラスを使うと、Expr型の値を評価してメタタイプを返すことが出来る、というざっくりとした和訳で終わりにしたい。
もっと内部に潜り込めば、型がどのように表現されているのか見ることが出来ると思うのだが、なにせ関連するよくわからないオブジェクトだらけで調べるのが困難を極めてきたから、この方向で調べるのは一旦終わりにする。
SwiftのソースコードやSILを読むのは楽しいので今後も挑戦したい。
-
前の記事
映画「マチネの終わりに」の石に秘められた意味。感想と考察。 2019.11.05
-
次の記事
SwiftUIでNavigationViewのtitleに画像を載せる 2020.06.25