RxSwiftでクロージャも無いのにメモリリークさせてた

  • 17
    いいね
  • 0
    コメント

ラムダ式書きたくない

RxSwiftのおかげでラムダ式を書くことに疲弊した私にとって、トップレベルに置いた汎用的な変換関数を、要所要所で、ラムダ式無しで使う場面は非常に爽快です。

func deg(from rad: Double) -> Double {
    return rad * 180 / M_PI
}

angle.map(deg)
    .subscribe(onNext: { print("\($0)度") })
    .addDisposableTo(disposeBag)

汎用的だったらトップレベルでいいのですが、degに当たる関数は汎用的でなく使用箇所も限られていたので、私は次のように書くことにしました。

ViewController.swift
class ViewController : UIViewController {
    private func deg(from rad: Double) -> Double {
        return rad * 180 / M_PI
    }

    func viewDidLoad() {
        super.viewDidLoad()

        angle.map(deg)
            .subscribe(onNext: { print("\($0)度") })
            .addDisposableTo(disposeBag)

別にselfは使ってないですが関連性を考慮して使用クラスのメソッドにしました。
こいつがメモリリークを含んでいることに気付かなかった話です。

RxSwiftのメモリリーク頻出パターン

RxSwiftユーザーの皆さんなら以下のコードが駄目なことはよくご存知だと思います。

ViewController.swift
class ViewController : UIViewController {
    @IBOutlet var label: UILabel!

    func viewDidLoad() {
        super.viewDidLoad()
        stringObservable
           .subscribe(onNext: {
               self.label.text = $0
           })
           .addDisposableTo(disposeBag)

クロージャがselfをキャプチャして循環参照になり、selfが開放されなかったりdisposeBagdisposeされなかったりするやつです。
こいつは有名なのでオブザーバブル絡みのクロージャ内では反射的にselfを弱参照する癖を獲得しました。

一方で、最初のようなコードにおいては『{}がない=weakが要らない!』みたいな雑な理解になっていました。そして誕生したのが問題のコードです。

問題のコードではmapにメソッドを渡していました。
メソッドはインスタンスが存在して初めて呼び出すことができ、そのインスタンスは内部ではselfとして使うことができます。では今回のようにメソッドを引数に渡した場合、インスタンスはどう結び付けられるのでしょうか。

中間言語

関数にメソッドを渡したときどうなるのかASTを見てみます。

hoge.swift
class Hoge {
    func someFunc(_ str: String) {
        print(str)
    }

    func test() {
        let str: String? = "test"
        str.map(someFunc)
    }
}

$ swiftc -dump-ast hoge.swift

抜粋
(func_decl "someFunc(_:)" type='(Hoge) -> (String) -> ()' interface type='(Hoge) -> (String) -> ()' access=internal
  (parameter_list
    (parameter "self" type='Hoge'))
  (parameter_list
    (parameter "str" type='String'))

someFuncの定義部。引数と別にselfがパラメータとして渡っています。

抜粋
(call_expr type='()?' location=hoge.swift:8:13 range=[hoge.swift:8:9 - line:8:25] nothrow  arg_labels=_:
  (dot_syntax_call_expr type='((String) throws -> ()) throws -> ()?' location=hoge.swift:8:13 range=[hoge.swift:8:9 - line:8:13] nothrow
    (declref_expr type='(Optional<String>) -> ((String) throws -> ()) throws -> ()?' location=hoge.swift:8:13 range=[hoge.swift:8:13 - line:8:13] decl=Swift.(file).Optional.map [with String, ()] function_ref=single specialized=no)
    (declref_expr type='String?' location=hoge.swift:8:9 range=[hoge.swift:8:9 - line:8:9] decl=hoge.(file).Hoge.func decl.str@hoge.swift:7:13 function_ref=unapplied specialized=no))
  (paren_expr type='(String) throws -> ()' location=hoge.swift:8:17 range=[hoge.swift:8:16 - line:8:25]
    (function_conversion_expr implicit type='(String) throws -> ()' location=hoge.swift:8:17 range=[hoge.swift:8:17 - line:8:17]
      (dot_syntax_call_expr implicit type='(String) -> ()' location=hoge.swift:8:17 range=[hoge.swift:8:17 - line:8:17] nothrow
        (declref_expr type='(Hoge) -> (String) -> ()' location=hoge.swift:8:17 range=[hoge.swift:8:17 - line:8:17] decl=hoge.(file).Hoge.someFunc@hoge.swift:2:10 function_ref=unapplied specialized=no)
        (declref_expr implicit type='Hoge' location=hoge.swift:8:17 range=[hoge.swift:8:17 - line:8:17] decl=hoge.(file).Hoge.func decl.self@hoge.swift:6:10 function_ref=unapplied specialized=no)))))))

test内のmapしてる行。
type='(A) -> (String) -> ()'などとあって部分適用のような印象ですが、カリー化された関数に部分適用した場合は使わない引数への参照は残らないので微妙に違いますね。

SILも抜粋
swiftc -emit-silgen hoge.swift

抜粋
// function_ref Hoge.someFunc(String) -> ()
%13 = function_ref @_TFC4hoge4Hoge8someFuncFSST_ : $@convention(thin) (@owned Hoge) -> @owned @callee_owned (@owned String) -> (), loc "hoge.swift":8:17, scope 5 // user: %15
strong_retain %0 : $Hoge, loc "hoge.swift":8:17, scope 5 // id: %14
%15 = apply %13(%0) : $@convention(thin) (@owned Hoge) -> @owned @callee_owned (@owned String) -> (), loc "hoge.swift":8:17, scope 5 // user: %16
%16 = convert_function %15 : $@callee_owned (@owned String) -> () to $@callee_owned (@owned String) -> @error Error, loc "hoge.swift":8:17, scope 5 // user: %18

strong_retainとかあってそれっぽい?

func methodLike(zelf: A) -> ((String)->Void) {
    return { str in
        zelf // 使わないけどretain
        print(str)
    }
}

class A {
    func test() {
        let str: String? = "test"
        str.map(methodLike(self))
    }
}

普段使いませんがインスタンスメソッドもスタティックっぽく呼び出すことができ、A.someFunc(instance)instance.someFuncが同等な表現になります。
A.someFuncmethodLikeのように働き、結果としてzelfが必要なくても参照が残るというような感じでしょうか。

解決法いろいろ

メソッドを渡すとまずいと分かったので別の方法を考えます。selfを使わない関数という前提だったので、解決方法はいろいろあります。

ファイル内のトップレベルprivate関数

private func deg(from rad: Double) -> Double {
    return rad * 180 / M_PI
}

class ViewController : UIViewController {

トップレベルなので書く場所が限定され、記述順序にこだわりがあると微妙です。

フィールドにする

class ViewController : UIViewController {
   let deg = { (rad: Double) in rad * 180 / M_PI} 

selfを参照しないので大丈夫なようです。

staticメソッドにする

class ViewController : UIViewController {
    private static func deg(from rad: Double) -> Double {
        return rad * 180 / M_PI
    }

    func viewDidLoad() {
        super.viewDidLoad()
        angle.map(ViewController.rad)
            .subscribe(onNext: { print("\($0)度") })
            .addDisposableTo(disposeBag)

staticなのでselfを参照しません。mapに渡す際クラス名を省略できないので長くなります。

メソッド内に置く

class ViewController : UIViewController {
    func viewDidLoad() {
        super.viewDidLoad()

        func deg(from rad: Double) -> Double {
            return rad * 180 / M_PI
        }

        angle.map(deg)
            .subscribe(onNext: { print("\($0)度") })
            .addDisposableTo(disposeBag)

ラムダ式で書くのもOKです。内部でselfを参照できますが弱参照にしないともちろんリークします。

諦める

class ViewController : UIViewController {
    private func deg(from rad: Double) -> Double {
        return rad * 180 / M_PI
    }

    func viewDidLoad() {
        super.viewDidLoad()
        angle.map { [weak self] in self!.deg(from: $0) }
            .subscribe(onNext: { print("\($0)度") })
            .addDisposableTo(disposeBag)

負けた気になります。


以上、RxSwiftアドベントカレンダー内ですがもっと根本的なレベルの話でした。
RxSwiftで踏みそうな話ということでひとつ。
オブジェクト指向力0ということが露呈したので来年はどうにかしたい。


テストに使ったプロジェクト