LoginSignup
26
15

More than 1 year has passed since last update.

`Task.init` に渡すクロージャが暗黙的に `self` をキャプチャすることの背景と注意点

Last updated at Posted at 2022-07-26

概要

@escaping なクロージャでは、クロージャの中で参照型の self のプロパティにアクセスするときなど明示的に self を書かなければいけません。これに対して、Swift Concurrency で使われる Task.init に渡すクロージャでは @escaping であるにも関わらず暗黙的に self をキャプチャするので self を書かなくてもいいという例外的な振る舞いをします。その背景と注意点についてまとめます。

この記事中での動作検証は Xcode 14 Beta 3 で行っています。

@escaping なクロージャと self

本題に入る前に、まずは @escapingself の関係について確認していきます。ある関数がクロージャを引数で受け取るとき、そのクロージャをその場ではなくあとで実行する可能性がある場合は引数に @escaping 属性をつける必要があります。具体的にあとで実行する可能性があるというのはどういうときかというと、

  • 受け取ったクロージャをプロパティに保存するとき
  • 受け取ったクロージャを非同期で実行するとき
  • さらに別の関数の @escaping な引数に渡すとき

が挙げられます。

以下の例では、

  • executeTwice に渡す operation はその場ですぐ実行されるので @escaping が不要
  • saveOperationForLaterUse に渡す operation はプロパティとして保持されるため @escaping が必要
  • executeAsync に渡す operationDispatchQueue.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 をキャプチャすると循環参照が発生し、メモリリークを引き起こす可能性があるためです。

実際、上記の実装では ViewControllerExecutor を、 Executoroperation を、 operationself を介して 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 コンパイラや標準ライブラリでの利用が想定されているのでアプリケーションで使われることは推奨されていませんが、もちろん自分でも使うことができ、 以下の executeAsyncoperation@_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秒間だけなので問題ないと書きましたが、逆に言うと ViewControllerviewDidLoad が呼ばれた後すぐに 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.initweak 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 の中で終わらない AsyncSequencefor await で購読する場合があります。以下の for awaitViewController が UI 上でポップされても関係なく続くため、 Task は実行され続けます。その Tasknumber を介して 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 へのアクセスしてしまうとリソースの無駄というだけではなくクラッシュやデータの不整合を引き起こすかもしれません。

ViewControllerTask の両方を適切にメモリから解放するためには以下のように適切なタイミングで 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 と対応するライフサイクルである viewWillAppearTask.init を実行することにより、再度 ViewController が表示された際に Task.init が実行されるので画面に表示されている間は常に購読が走っている状態を作ることができると思います。

まとめ

  • Task.init に渡すクロージャは @escaping であるにも関わらず @_implicitSelfCapture によって暗黙的に self をキャプチャする
  • このような仕様になっているのは Task.init が循環参照を作らないことから、開発者がクロージャからの self の参照を意識する必要がないため
  • 注意点
    • weak self を使った上で意図せず self を強参照してしまうことがあるので気を付ける
    • Task.init の中で AsyncSequence を購読する場合などは循環参照と関係なくメモリリークが発生し得るので気を付ける

参考

26
15
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
26
15