LoginSignup
49
49

More than 5 years have passed since last update.

Swiftのコールバックを閉じ込めるデータ構造

Last updated at Posted at 2016-09-27

はじめに

非同期プログラミングを行う時など、ある関数にコールバック関数(クロージャー)を渡していく手法がSwiftの界隈では広く行われている。ただ、コールバック関数の中でさらにコールバック関数を受け取るような処理を行うと、すぐにコールバック地獄と呼ばれるコールバック関数が大量にネストする状態になって、プログラムの見通しが悪くなってしまう恐れがある。そこで、この記事ではContと呼ばれるデータ構造を紹介し、それによってコールバック関数をデータ構造に隠蔽する方法について考える。この記事で紹介するソースコードは次のリポジトリに置かれている。

この記事を読んで何か疑問に思った点や改善点などがあれば、気軽にコメントなどで教えて欲しい。

コールバック関数を使った足し算

コールバック関数を受け取るような関数は、主にネットワーク通信が発生する非同期処理で発生しがちだが、ここでは話を単純にするため、非常に簡単な「足し算」を用いてコールバック処理を考える。
まず、整数の足し算をする関数plusを考えると、次のようになる。

Plus.swift
func plus(_ x: Int, _ y: Int) -> Int {
    return x + y
}

関数plusを用いて1 + 2 + 3を実行するには次のようにする。

main.swift
plus(1, plus(2, 3))

この実行結果は次のようになる。

6

さて、この関数plusは見て分かるように2回呼び出されている。そこで関数plusを改造してコールバックを受け取るようにしたらどうなるだろうか。

Plus.swift
func plus_k(_ x: Int, _ y: Int, _ k: (Int) -> Int) -> Int {
    return k(x + y)
}

新たに追加した引数kがコールバックである。xyの足し算結果を、コールバック処理に渡して、最終的にコールバック関数の返り値を関数の返り値としている。この関数plus_kを用いて$1 + 2 + 3$を実行すると次のようになる。

main.swift
plus_k(1, 2, { r in r + 3 })

これを実行すると正しく$6$が計算されることが分かる。ところで、足し算を実装しようとしているので、{ r in r + 3 }に含まれるr + 3の部分も関数plus_kを用いて計算したい。ところがこの後にはコールバック処理が何もない。そこで、受け取った値をそのまま返す関数{ x in x }を使って次のように書き換える。

main.swift
plus_k(1, 2, { r in plus_k(r, 3, { x in x }) })

このように、何もしないコールバック関数{ x in x }を使って、plus_kのみで足し算を表現することができると分った。さて、では$1 + 2 + 3 + 4 + 5$を見ると、次のようになる。

main.swift
plus_k(1, 2, { x in
    plus_k(x, 3, { y in
        plus_k(y, 4, { z in
            plus_k(z, 5, { a in
                a
            })
        })
    })
})

このようにすれば正しく計算はできるものの、コールバック地獄になることが分かる。このように、簡単な例であってもコールバック関数を取る関数を繋げていくと、簡単にコールバックだらけになる。

コールバックを表すデータ構造Cont

さて、このようにコールバックを取る関数を使うと容易にネストが深くなるので、このコールバックを表すようなデータ構造を用いることにする。コールバックを閉じ込めるデータ構造Contは次のような定義になる。定義している関数mapflatMapについては例で解説するので、この定義で詳細に追う必要はない。

Cont.swift
struct Cont<R, A> {
    let run: ((A) -> R) -> R

    init(_ f: @escaping ((A) -> R) -> R) {
        self.run = f
    }

    func flatMap<B>(_ f: @escaping (A) -> Cont<R, B>) -> Cont<R, B> {
        return Cont<R, B> {
            k in self.run { a in f(a).run(k) }
        }
    }

    func map<B>(_ f: @escaping (A) -> B) -> Cont<R, B> {
        return Cont<R, B> {
            k in self.run { a in k(f(a)) }
        }
    }
}

まず、Cont<R, A>の型パラメータRAは何を表しているのかを説明する。型パラメータAはコールバックの引数の型であり、型パラメータRはコールバックの結果の型である。すると、関数runは型(A -> R) -> Rであり、そのままコールバックであると言える。

Contを用いた足し算の関数

さきほど定義したコールバック関数を使う足し算の関数plus_kは次のようになっていた。

Plus.swift
func plus_k(_ x: Int, _ y: Int, _ k: (Int) -> Int) -> Int {
    return k(x + y)
}

Contを用いたバージョンを作るまえに、少し関数plus_kについて考える。まずコールバック関数の引数は現在の足し算の結果であるから、Intであるし、$1 + 2 + 3 + \dots $と後続の足し算がコールバック関数であるので結果の型はIntであった。従って次のようになる。

コールバックの引数の型 コールバックの結果の型
Int Int

つまり、この場合Contの型パラメータRAはいずれもIntとなることが分かる。Contを用いた足し算の関数は次のようになる。

Plus.swift
func plus_cont(_ x: Int, _ y: Int) -> Cont<Int, Int> {
    return Cont { k in k(x + y) }
}

次のように実行できる。Contを実行して結果を得るときには、関数plus_kと同様に{ x in x }を用いる。

main.swift
plus_cont(1, 2).run { x in x }

実行結果は次のようになる。

3

$1 + 2 + 3$のように後続の処理を繋げる場合は、flatMap関数を使って次のように書けばよい。

main.swift
plus_cont(1, 2).flatMap { x in plus_cont(x, 3) }.run { x in x }

この結果は次のようになる。

6

正しく計算ができることが分かる。ただ、これではflatMapになにかの関数が入っているだけのように見え、結局複雑な計算を行うとネストが深くなりそうである。そこで、まずはplus_contをカリー化して次のような関数plus_cont_curryを作る。

Plus.swift
func plus_cont_curry(_ x: Int) -> (Int) -> Cont<Int, Int> {
    return { y in plus_cont(x, y) }
}

すると、さきほどの$1 + 2 + 3$は関数plus_cont_curryを用いて次のようになる。

main.swift
plus_cont(1, 2).flatMap(plus_cont_curry(3)).run { x in x }

さらに、$1 + 2 + 3 + 4 + 5$も次のようになる。

main.swift
plus_cont(1, 2).flatMap(plus_cont_curry(3))
               .flatMap(plus_cont_curry(4))
               .flatMap(plus_cont_curry(5))
               .run { x in x }

flatMapを表す二項演算子>>>=を作れば、さらに短くなる。

Cont.swift
precedencegroup Left {
    associativity: left
}

infix operator >>>=: Left

func >>>=<R, A, B>(_ ma: Cont<R, A>, _ f: @escaping (A) -> Cont<R, B>) -> Cont<R, B> {
    return ma.flatMap(f)
}
main.swift
(plus_cont(1, 2) >>>= plus_cont_curry(3)
                 >>>= plus_cont_curry(4)
                 >>>= plus_cont_curry(5))
                 .run { x in x }

mapとリフト

たとえば、足し算の結果を数値(Int)から文字列(String)へ変換したくなった時、mapを使うと便利である。次のようなIntからStringへ変換する関数があるとする。

String.swift
func int_to_string(_ n: Int) -> String {
    return String(n)
}

この関数を用いて、Cont<Int, String>を次のようにmapを用いて作成できる。

main.swift
let _: Cont<Int, String> = (plus_cont(1, 2) >>>= plus_cont_curry(3)).map(int_to_string)

このように、任意の関数A -> Bを用いてCont<R, A>からCont<R, B>を作ることができる。

異なる型を持つContの合成

足し算を行うCont<Int, Int>を作成したが、たとえば次のように与えられた文字列にhelloを連結して返すような関数add_helloと、文字列の文字数を数える関数countをそれぞれ次のように作ったとする。

String.swift
func add_hello(_ str: String) -> Cont<Int, String> {
    return Cont { k in k("hello" + str) }
}

func count(_ str: String) -> Cont<Int, Int> {
    let n = str.characters.count
    return Cont { k in k(n) }
}

関数add_helloCont<Int, String>を返す。一方で関数countCont<Int, Int>を返す関数である。この二つの関数を合成することができる。これらを用いて、たとえば文字列world!helloを加えた文字列の長さに、10を足すという処理を次のように書ける。

main.swift
add_hello("world!").flatMap(count).flatMap(plus_cont_curry(10)).run { x in x }

結果は次のようになる。

21

さきほどの二項演算子>>>=を使えば短くなる。

main.swift
(add_hello("world!") >>>= count >>>= plus_cont_curry(10)).run { x in x }

このように、Contの関数flatMapを用いることで、Cont<R, A>の型パラメータRが等しければ、Cont<R, B>のような型を持つ値と合成できる。

まとめ

このようにContというデータ構造を用いることでコールバックを隠蔽できることを紹介した。このContは継続モナドと呼ばれ、HaskellやScalaなどで実装や研究が盛んに行われている。Swiftでもこのようなデータ構造を用いてよりよいプログラムを書けたらよいと思う。

参考文献

49
49
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
49
49