概要
@escaping
なクロージャでは、クロージャの中で参照型の self
のプロパティにアクセスするときなど明示的に self
を書かなければいけません。これに対して、Swift Concurrency で使われる Task.init
に渡すクロージャでは @escaping
であるにも関わらず暗黙的に self
をキャプチャするので self
を書かなくてもいいという例外的な振る舞いをします。その背景と注意点についてまとめます。
この記事中での動作検証は Xcode 14 Beta 3 で行っています。
@escaping
なクロージャと self
本題に入る前に、まずは @escaping
と self
の関係について確認していきます。ある関数がクロージャを引数で受け取るとき、そのクロージャをその場ではなくあとで実行する可能性がある場合は引数に @escaping
属性をつける必要があります。具体的にあとで実行する可能性があるというのはどういうときかというと、
- 受け取ったクロージャをプロパティに保存するとき
- 受け取ったクロージャを非同期で実行するとき
- さらに別の関数の
@escaping
な引数に渡すとき
が挙げられます。
以下の例では、
-
executeTwice
に渡すoperation
はその場ですぐ実行されるので@escaping
が不要 -
saveOperationForLaterUse
に渡すoperation
はプロパティとして保持されるため@escaping
が必要 -
executeAsync
に渡すoperation
はDispatchQueue.main.async
の@escaping
なクロージャの中で実行されるため@escaping
が必要- ちなみに
DispatchQueue.main.async
のクロージャが@escaping
になっているのは非同期で実行されるため
- ちなみに
です。
final class Executor {
private var operation: (() -> Void)?
// その場で実行するので @escaping は不要
func executeTwice(operation: () -> Void) {
operation()
operation()
}
// プロパティに保存するので @escaping が必要
func saveOperationForLaterUse(operation: @escaping () -> Void) {
self.operation = operation
}
// DispatchQueue.main.async の `@escaping` なクロージャの中で実行するので @escaping が必要
func executeAsync(operation: @escaping () -> Void) {
DispatchQueue.main.async {
operation()
}
}
}
@escaping
なクロージャの中で self
のプロパティを参照する必要があり、かつその self
が値型ではなく参照型の場合は明示的に self
をつけてアクセスする必要があります。たとえば以下のように saveOperationForLaterUse
に渡すクロージャの中では number
とはアクセスできず self.number
と書く必要があります。
final class ViewController: UIViewController {
private let executor = Executor()
private var number: Int = 0
override func viewDidLoad() {
super.viewDidLoad()
executor.executeTwice {
// @escaping ではないので self が必要ない
print(number)
}
executor.saveOperationForLaterUse {
// @escaping なので self が必要
print(self.number)
}
}
}
このような制約があるのは、開発者にクロージャが self
をキャプチャしていることを意識させるためです。なぜクロージャが @escaping
の場合だけ意識しないといけないかというと、 @escaping
なクロージャが self
をキャプチャすると循環参照が発生し、メモリリークを引き起こす可能性があるためです。
実際、上記の実装では ViewController
が Executor
を、 Executor
が operation
を、 operation
が self
を介して ViewController
を参照するので循環参照になり、 ViewController
が UI 上でポップされたとしてもメモリから解放されないという問題が起こります。これを避けるためには、以下のように weak self
を使ってクロージャから self
を強参照しないことにより循環参照を断ち切る必要があります。
// ...
executor.saveOperationForLaterUse { [weak self] in
print(self?.number)
}
// ...
ここで self.number
ではなく単純に number
とアクセスできてしまうと、クロージャが self
をキャプチャしている=メモリリークを避けるために weak self
が必要であること自体に気づきづらいので明示的に self.number
と書く必要があるというわけです。
@escaping
でないクロージャについては、その場で実行されるだけで他のオブジェクトから強参照されることがないので循環参照が発生しないことから self
をつけなくてもコンパイルが通ります。
@_implicitSelfCapture
による暗黙的な self
のキャプチャ
Swift Concurrency では、同期的な処理から非同期な関数を呼ぶために Task.init(priority:operation:)
を使います。この Task.init
に渡すクロージャである operation
引数は非同期に実行されるため @escaping
属性がついています。前項で説明したように普通の @escaping
なクロージャの中では明示的に self
を書く必要がありますが、実は Task.init
に渡すクロージャのみ例外的に self
を書かなくてもよいことになっています。これは、 @escaping
でないクロージャと同様に暗黙的に self
がキャプチャされるということを意味しています。
たとえば、以下のような書き方ができるということです。
final class ViewController: UIViewController {
var number: Int = 0
override func viewDidLoad() {
Task {
try await Task.sleep(nanoseconds: 3 * 1_000_000_000)
// @escaping なクロージャの中なのに
// self.number と書かなくてもコンパイルエラーにならない!
print(number)
}
}
}
前項で説明したように、 @escaping
なクロージャで self
を書かないといけない制限には、メモリリークを避けるために self
のキャプチャを開発者に意識させるという理由がありました。それにも関わらず Task.init
においてその制限があえて外されているのは、 Task.init
が循環参照を作らないためです。
Task.init
に渡されるクロージャは非同期に実行されるという理由で @escaping
属性がついていますが、クロージャがプロパティに保持された前項の Executor.saveOperationForLaterUse
のような例とは異なり、すぐに実行されて実行完了後にはメモリから解放されます。つまり、上記の例のクロージャは3秒間だけ self
を介して ViewController
を参照し続けますが、実行が完了し次第クロージャが解放されて ViewController
も参照されなくなるので ViewController
が必要もないのに永続的にメモリ上に残ってしまうということは起こりません。循環参照が発生しない以上、開発者は Task.init
に渡したクロージャが self
を参照するかどうかを意識する必要がないため、記述の簡潔さのために self
を書かずに self
のプロパティにアクセスすることができるようになっています。
この暗黙的な self
のキャプチャは、 Task.init
に渡されるクロージャに @_implicitSelfCapture
をつけることで実現されています。
アンダースコアで始まるアトリビュートは主に swift コンパイラや標準ライブラリでの利用が想定されているのでアプリケーションで使われることは推奨されていませんが、もちろん自分でも使うことができ、 以下の executeAsync
の operation
は @_implicitSelfCapture
によって self
を暗黙的にキャプチャするため、プロパティにアクセスするときに self
を書かなくてもよくなります。
final class Executor {
func executeAsync(@_implicitSelfCapture operation: @escaping () -> Void) {
DispatchQueue.main.async {
operation()
}
}
}
final class ViewController: UIViewController {
private let executor = Executor()
private var number: Int = 0
override func viewDidLoad() {
super.viewDidLoad()
executor.executeAsync {
// self.number と書かなくてもコンパイルエラーにならない!
print(number)
}
}
}
暗黙的な self
のキャプチャの注意点
以上のように、基本的には Task.init
のクロージャから self
が参照されても問題なく、そのために @_implicitSelfCapture
が使用されているのですが、注意した方がよいポイントもあります。
weak self
をつけても self
を強参照してしまうことに気づきづらい
まず、 Task.init
にあえて weak self
をつけた場合でも self
を強参照してしまう場合があり、それに気づきづらいことが挙げられます。前項の例ではクロージャが ViewController
を参照し続けるのは3秒間だけなので問題ないと書きましたが、逆に言うと ViewController
の viewDidLoad
が呼ばれた後すぐに UI 上でポップされても3秒間は ViewController
がメモリ上に保持された上で print
が実行されることになります。もしこれが許容できないと言う場合、簡単な対処として以下のように weak self
をつけることが考えられます。これにより、クロージャが ViewController
のリファレンスカウントを増やさなくなるので UI 上でポップされると ViewController
はすぐ解放されます。
final class ViewController: UIViewController {
var number: Int = 0
override func viewDidLoad() {
super.viewDidLoad()
Task { [weak self] in
try await Task.sleep(nanoseconds: 3 * 1_000_000_000)
print(self?.number)
}
}
}
ただ、 weak self
をつけた上でも相変わらず暗黙的に self
がキャプチャされることに注意する必要があります。例えば以下のようにクロージャの中で別のプロパティである otherNumber
に直接アクセスした場合、これは self.otherNumber
のことを意味するので self
を強参照することになり、 ViewController
の寿命が3秒間伸びる元の振る舞いに戻ってしまいます。
final class ViewController: UIViewController {
var number: Int = 0
var otherNumber: Int = 42
override func viewDidLoad() {
super.viewDidLoad()
Task { [weak self] in
try await Task.sleep(nanoseconds: 3 * 1_000_000_000)
print(self?.number)
// self を強参照してしまっている
print(otherNumber)
}
}
}
weak self
にしたからには self?.otherNumber
とするべきなのですが、 Task.init
では self.otherNumber
ではなく単に otherNumber
とアクセスできてしまうので self
を参照していることに気付くのが難しくなっていると思います。このように weak self
をしたにも関わらず意図せず self
を強参照しないように注意しておく必要があります。
関連して、 weak self
した場合によくクロージャの先頭で guard let self = self else { return }
の処理を挟むと思いますが、これを Task.init
でやってしまうと weak self
した意味がほとんど無くなってしまいます。
final class ViewController: UIViewController {
var number: Int = 0
var otherNumber: Int = 42
override func viewDidLoad() {
super.viewDidLoad()
Task { [weak self] in
guard let self = self else { return }
try await Task.sleep(nanoseconds: 3 * 1_000_000_000)
print(self.number)
}
}
}
上のコードでは Task.init
の先頭で guard let
で強参照に戻すことにより Task
が実行されてすぐクロージャが self
を強参照することになってしまいます。これは、最初から weak self
しない場合と実質的に同じ意味になり、実際上のコードでは ViewController
が UI 上でポップされた後も3秒間メモリ上に残ってしまいます。
通常の @escaping
クロージャの weak self
は循環参照を避けるために行うので先頭で guard let self
しても意味があるのですが、 Task.init
のクロージャに weak self
をつける目的はあくまで実行中に self
を強参照しないことなので、実行の最初に guard let self
すると意味がなくなってしまうということです。
Task.init
で weak self
したく、かつ self
を強参照に戻す必要がある場合は、以下のようにその直前で強参照に戻すのがよいと思います。
final class ViewController: UIViewController {
var number: Int = 0
var otherNumber: Int = 42
override func viewDidLoad() {
super.viewDidLoad()
Task { [weak self] in
try await Task.sleep(nanoseconds: 3 * 1_000_000_000)
// 必要になる直前で強参照に戻す
guard let self = self else { return }
print(self.number)
}
}
}
Task.init
が実行され続ける場合はメモリリークにつながる
さらに重要な問題になり得るのが、 Task.init
の中で無限ループが作られたとき、循環参照がないにも関わらず永続的なメモリリークが発生してしまう点です。ここまでの例では、 self
の参照によって ViewController
の寿命が伸びるのはあくまで Task
が実行中のみで、その Task
が3秒間で実行終了するため大きな問題はありませんでした。逆に言うと、 Task
の実行が永久に終わらない場合は ViewController
への参照が永久に続いてしまうため、循環参照がないにも関わらず ViewController
がメモリリークします。
Task
の実行が永久に終わらないということが実際に発生する場面として、 Task.init
の中で終わらない AsyncSequence
を for await
で購読する場合があります。以下の for await
は ViewController
が UI 上でポップされても関係なく続くため、 Task
は実行され続けます。その Task
が number
を介して ViewController
を参照しているため、 ViewController
がずっとメモリ上に残り続けてしまいます。
final class ViewController: UIViewController {
var number: Int = 0
override func viewDidLoad() {
super.viewDidLoad()
Task {
for await _ in NotificationCenter.default.publisher(for: UIApplication.willResignActiveNotification).values {
print(number)
}
}
}
}
このメモリリークを防ぐための方法として、まずは以下のように weak self
をつけることが思いつくかもしれません。
// ...
override func viewDidLoad() {
super.viewDidLoad()
Task { [weak self] in
for await _ in NotificationCenter.default.publisher(for: UIApplication.willResignActiveNotification).values {
print(self?.number)
}
}
}
// ...
これにより、 UI 上でポップされると ViewController
の強参照がなくなるので ViewController
はメモリから解放されます。ただ、その場合でも Task
は相変わらず実行され続けることに気をつけなければいけません。 Task
も実行され続ければリソースを使ってしまいますし、実行され続ける Task
が通知を受け取ると想定外に処理を実行してしまうことも問題です。画面上にない ViewController
が処理を実行することで非常に見つけづらいバグの元になることも考えられるためです。上記の例では print
しているだけなのでどうでものいいですが、例えば購読処理の中で API リクエストや DB へのアクセスをしている場合は大きな問題になることもあり得ます。 ViewController
がメモリリークする場合はインスタンスが同時に複数存在する状況に容易になり得るので、それと同じ数の Task
が同時に DB へのアクセスしてしまうとリソースの無駄というだけではなくクラッシュやデータの不整合を引き起こすかもしれません。
ViewController
と Task
の両方を適切にメモリから解放するためには以下のように適切なタイミングで Task
をキャンセルするのがよいと思います。以下の実装だと、 ViewController
が UI 上でポップされて viewWillDisappear
が呼ばれると Task
がキャンセルされるため実行が終了し、それにより ViewController
への参照もなくなるのでメモリリークは発生しなくなります。
final class ViewController: UIViewController {
var number: Int = 0
var task: Task<Void, Never>?
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
task = Task {
for await _ in NotificationCenter.default.publisher(for: UIApplication.willResignActiveNotification).values {
print(number)
}
}
}
override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
task?.cancel()
}
}
この変更の際に Task.init
を呼ぶ場所を viewDidLoad
から viewWillAppear
に移動しています。これは viewDidLoad
で購読処理を行うままだと、 ViewController
が別の view controller を present → viewWillDisappear
が呼ばれて Task
がキャンセルされる → present していた別の view controller を dismiss して再度 ViewControler
が表示される、という際に Task
が実行されていない状態になってしまい、 for await
内の購読処理が実行されないためです。これを避けるためにキャンセルを実行する viewWillDisappear
と対応するライフサイクルである viewWillAppear
で Task.init
を実行することにより、再度 ViewController
が表示された際に Task.init
が実行されるので画面に表示されている間は常に購読が走っている状態を作ることができると思います。
まとめ
-
Task.init
に渡すクロージャは@escaping
であるにも関わらず@_implicitSelfCapture
によって暗黙的にself
をキャプチャする - このような仕様になっているのは
Task.init
が循環参照を作らないことから、開発者がクロージャからのself
の参照を意識する必要がないため - 注意点
-
weak self
を使った上で意図せずself
を強参照してしまうことがあるので気を付ける -
Task.init
の中でAsyncSequence
を購読する場合などは循環参照と関係なくメモリリークが発生し得るので気を付ける
-