こんにちは。iOSエンジニアの@kitasukeです。
「SwiftをSILで読んでみる」第1回は「varとletをSILで読んでみる」を紹介します。 まだ理解不足なところもあるので、間違いがあればご指摘頂けると嬉しいです。
var
とlet
は良く使用するのでご存知だとは思いますが、それぞれの性質を説明します。
Swiftのvar
はvariables
と言われる通り、変数です。値を変数に何回でも代入できます。
var x: Int
x = 1
x = 10
Swiftのlet
はconstants
と言われる通り、定数です。値を一度代入して初期化するとそれ以降は値を変更できません。
let x: Int
x = 1
x = 10 // error
それでは、上記の性質がSILでどのように表現されているかを見てみましょう。
今回は単純にInt
を返すメソッドを例として使用します。それぞれのメソッドで違う箇所は、x
がvar
で宣言されているかlet
で宣言されているかのみです。
var.swift
func number() -> Int {
var x: Int
x = 1
return x
}
let.swift
func number() -> Int {
let x: Int
x = 1
return x
}
さて、swiftcコマンドでraw SILを出力してみましょう。出力された結果の全てが関連しているわけではないので、分からない箇所があっても特に問題ないです。
$swiftc -emit-silgen var.swift -o var.silgen
var.swift のraw SILは下記の通りです。関連箇所のみ抜粋しています。
var.silgen
%0 = alloc_box ${ var Int }, var, name "x"
%1 = mark_uninitialized [var] %0 : ${ var Int }
%2 = project_box %1 : ${ var Int }, 0
%3 = metatype $@thin Int.Type
%4 = integer_literal $Builtin.Int2048, 1
// function_ref Int.init(_builtinIntegerLiteral:)
%5 = function_ref @$SSi22_builtinIntegerLiteralSiBi2048__tcfC :
$@convention(method) (Builtin.Int2048, @thin Int.Type) -> Int
%6 = apply %5(%4, %3) : $@convention(method) (Builtin.Int2048, @thin Int.Type) -> Int
%7 = begin_access [modify] [unknown] %2 : $*Int
assign %6 to %7 : $*Int
end_access %7 : $*Int
%10 = begin_access [read] [unknown] %2 : $*Int
%11 = load [trivial] %10 : $*Int
end_access %10 : $*Int
destroy_value %1 : ${ var Int }
return %11 : $Int
こちらが let.swift のraw SILです。こちらも関連箇所のみ抜粋しています。
$swiftc -emit-silgen let.swift -o let.silgen
let.silgen
%0 = alloc_stack $Int, let, name "x"
%1 = mark_uninitialized [var] %0 : $*Int
%2 = metatype $@thin Int.Type
%3 = integer_literal $Builtin.Int2048, 1
// function_ref Int.init(_builtinIntegerLiteral:)
%4 = function_ref @$SSi22_builtinIntegerLiteralSiBi2048__tcfC :
$@convention(method) (Builtin.Int2048, @thin Int.Type) -> Int
%5 = apply %4(%3, %2) : $@convention(method) (Builtin.Int2048, @thin Int.Type) -> Int
assign %5 to %1 : $*Int
%7 = load [trivial] %1 : $*Int
dealloc_stack %0 : $*Int
return %7 : $Int
ざっと見てみると少し違いがありますね。var
の場合とlet
の場合で特徴となる部分を抜粋してみます。
var.silgen ではalloc_box
やbegin_access
という命令が使用されています。
%0 = alloc_box ${ var Int }, var, name "x"
%1 = mark_uninitialized [var] %0 : ${ var Int }
%2 = project_box %1 : ${ var Int }, 0
%7 = begin_access [modify] [unknown] %2 : $*Int
end_access %7 : $*Int
%10 = begin_access [read] [unknown] %2 : $*Int
end_access %10 : $*Int
destroy_value %1 : ${ var Int }
let.silgenではalloc_box
ではなく、alloc_stack
という命令が使用されています。
%0 = alloc_stack $Int, let, name "x"
%1 = mark_uninitialized [var] %0 : $*Int
dealloc_stack %0 : $*Int
これらの命令がどういう意味なのか調べると、SILにおけるvar
とlet
の挙動も分かりそうです。
Allocates a reference-counted @box on the heap large enough to hold a value of type T, along with a retain count and any other metadata required by the runtime. The result of the instruction is the reference-counted @box reference that owns the box. The project_box instruction is used to retrieve the address of the value inside the box.
The box will be initialized with a retain count of 1; the storage will be uninitialized. The box owns the contained value, and releasing it to a retain count of zero destroys the contained value as if by destroy_addr. Releasing a box is undefined behavior if the box’s value is uninitialized. To deallocate a box whose value has not been initialized, dealloc_box should be used.
alloc_box
は、リファレンスカウンタ方式のボックスが ヒープ に確保されます。ボックスの初期化時に参照カウントが1になり、参照カウントが0になると解放されます。
Allocates uninitialized memory that is sufficiently aligned on the stack to contain a value of type T. The result of the instruction is the address of the allocated memory.
If a type is runtime-sized, the compiler must emit code to potentially dynamically allocate memory. So there is no guarantee that the allocated memory is really located on the stack.
alloc_stack marks the start of the lifetime of the value; the allocation must be balanced with a dealloc_stack instruction to mark the end of its lifetime. All alloc_stack allocations must be deallocated prior to returning from a function. If a block has multiple predecessors, the stack height and order of allocations must be consistent coming from all predecessor blocks. alloc_stack allocations must be deallocated in last-in, first-out stack order.
The memory is not retainable. To allocate a retainable box for a value type, use alloc_box.
alloc_stack
は、スタック に確保されます。こちらはリファレンスカウンタ方式ではありません。メソッドのスコープ内で解放する必要があります。
両者で大きく違うのは、ライフタイムです。例えばクロージャを使用する場合は、スコープ外で変数の値を変更する可能性もあります。その場合はalloc_box
による参照カウンタで変数を管理する必要があります。一方で、メソッド内でのみ使うローカル変数はスコープを抜けると解放するのでalloc_stack
を使います。
このように、var
は単に何回も値を代入できる性質もありますが、定義されたスコープ外でも値を代入できると分かりました。
ただ、よくよく考えてみると今回使用したメソッドではvar
はスコープ内でしか使われていません。その場合もalloc_box
を使うのが正しいのでしょうか?それでは、raw SILの次のステップとなるcanonical SILを読んでみましょう。
下記のswiftcコマンドでcanonical SILを出力してみましょう。
$swiftc -emit-sil var.swift -o var.sil
var.sil
%0 = alloc_stack $Int, var, name "x"
%1 = integer_literal $Builtin.Int64, 1
%2 = struct $Int (%1 : $Builtin.Int64)
%3 = begin_access [modify] [static] %0 : $*Int
store %2 to %3 : $*Int
end_access %3 : $*Int
%6 = begin_access [read] [static] %0 : $*Int
end_access %6 : $*Int
dealloc_stack %0 : $*Int
return %2 : $Int
先程のvar.silgenの出力結果とかなり変わりました。そして予想通り、alloc_box
がalloc_stack
に置き換わってますね。これはcanonical SILを出力する過程で行われる最適化の影響です。具体的にはAllocBoxToStack
モジュールで、不要なalloc_box
からalloc_stack
にプロモーションしています。
https://github.com/apple/swift/blob/master/lib/SILOptimizer/Transforms/AllocBoxToStack.cpp
実装に関する説明はこちらの記事が詳しいので、興味のある方はご覧になってください。 https://blog.waft.me/2018/01/11/swift-sil-2/
let.swiftのcanonical SILも出力してみましょう。
$swiftc -emit-sil let.swift -o let.sil
let.sil
%0 = alloc_stack $Int, let, name "x"
%1 = integer_literal $Builtin.Int64, 1
%2 = struct $Int (%1 : $Builtin.Int64)
store %2 to %0 : $*Int
dealloc_stack %0 : $*Int
return %2 : $Int
こちらは特に大きな変化は無いですね。かなり単純なメソッドなので、最適化できるところが少なかったのでしょう。
今回は普段よく使うvar
とlet
をSILで読んでみました。SILでどのように表現されているかを見て、Swiftのライフタイムにおける値の扱いについての気づきもありましたし、Swift Compilerが良い感じに最適化してくれていることも知りました。単純だと思っていた文法も、実は様々なことがSwift Compiler内で考慮されていて非常に面白かったです。このおかげで明日からはよりSwiftらしいコードを書けるような気がしました。皆さんもSwiftで気になることがあればSILを読んでみましょう。