コールバックと、ポリモーフィズムと、それからコルーチンを構造的に見る

6 min読了の目安(約5900字IDEAアイデア記事

※この記事はQiita/Qrunch記事の再録です。

この記事では、コールバックはポリモーフィズムの特別な場合であるという見方もできること、その意味ではそんなに難しい話ではないことを説明します。
あと、関数呼び出しを視覚的にブロックみたいなもので表現すると、ポリモーフィズムやコルーチンを使ってどういう事をやっているのか何となくわかった気になれるので、そのような表現を試みます。
ちょっと見方に変化をつけることで、難しそうな概念が身近になるといいなというような試みです。

コールバックのおさらいと、ご利益

const someFunction = (someCallable) => {
  /* nagai syori */
  const isContinued = confirm('nagai syori ga owatta yo! tudukeru?')
  someCallable(isContinued)
}

someFunction((isContinued) => {
  if (isContinued) {
    alert('Yeah!')
  } else {
    alert('Boo!')
  }
})

これは何の変哲もないコールバックです。someFunctionの引数として、無名の関数を渡してやることで、someFunctionの処理の中で無名の関数が呼ばれるようにします。
技術的に、このような渡し方が便利な場面はいくつかありますが、特に際立つものとしては非同期処理が終わった直後に確実にコールバック関数を呼び出させることができるということでしょうか。
例えば、JavaScriptにはsetTimeout()という、指定した時間がだいたい経過した後に引数に指定した関数を呼ぶような関数があります。このsetTimeout()を使って、以下のようなコードを書くと、0〜1000ミリ秒の間の時間が経過した後にalertが表示されます。
(setTimeout自体も一種のコールバックみたいなもので、適当な時間が経過した後に指定する処理を行わせるという機能性を実現しているのですが、一旦置いておきます。)

const baka = () => {
  alert('Yeah!')
}

setTimeout(baka, 1000 * Math.random())

この乱数を使っている部分は、実際には通信などで微妙な時間が経過する場合のシミュレーションだと思ってください。
では、このbaka()という関数をsetTimeoutで実行した"直後"に処理を行わせるには、どうすればよいでしょうか。これは、baka()に手を加えないとすれば、じつはとても難しいです。一方で、baka()に手を加えれば簡単で、

const baka = () => {
  alert('Yeah!')
  alert('Hyahha-!!!')  // 後続の処理
}

このようにしてしまえば、baka()の主処理の後で、無駄な間が無く、かつ確実に後続の処理が行われます。
しかし、一般論としては、例えば通信処理Xが終わった直後に、ある場面では処理Aを行わせたり、別の場面では処理Bを行わせたり、というような事が必要になります。つまり、場面によって行いたい処理が変わるというような場合です。
また、baka()の中に何でもかんでも書こうとしても、baka()はバカなので一つの責務しか果たすことができず、処理を覚えることもできません。

このように、前段の処理Xに対して、ある場面ではA、別の場面ではB、という事をXの呼び出し元で手軽に制御できるようにすることが、コールバックの大きなメリットなのでした。Xの処理の中で次の処理を呼び出すことにすれば、その処理は確実に呼び出され、かつその処理を引数で指定すれば、柔軟に処理を行わせる事ができます。

const functionX = (someFunction) => {
  // 以下の行は非同期的に実行されるが、処理が終わったら必ず直後にコールバックが呼ばれる
  setTimeout(() => {
    // nanika syori
    someFunction('X')
  }, 1000 * Math.random())
}

const functionA = (value) => alert(value + ' no atoni A dayo!')
const functionB = (value) => alert(value + ' no atoni B desu!')

functionX(functionA)
functionX(functionB)
// これらの順序は必ずしもAが先ではないが、Xの処理の後にコールバックが確実に呼ばれることは保証される

この書き方=関数を引数として渡すという考え方は、特に非同期処理で強みを持ちますが、非同期処理以外でも有効です。

オブジェクトを引数に渡すことと、ポリモーフィズムと

さて、さきほどのコード断片において、関数の代わりに、メソッド(=関数のプロパティのこと)を持つオブジェクトを渡して同じことをやってみます。

const functionX = (someObject) => {
  // 以下の行は非同期的に実行されるが、処理が終わったら必ず直後にコールバックが呼ばれる
  setTimeout(() => {
    // nanika syori
    someObject.someFunction('X')
  }, 1000 * Math.random())
}

const objA = {
  someFunction(value) {alert(value + ' no atoni A dayo!')}
}
const objB = {
  someFunction(value) {alert(value + ' no atoni B desu!')}
}
functionX(objA)
functionX(objB)
// これらの順序は必ずしもAが先ではないが、Xの処理の後にコールバックが確実に呼ばれることは保証される

なんと、普通にオブジェクトを引数として渡していますが、やっていることはコールバックと全く同じですね!
というのも、オブジェクトというのは、ざっくり「データと関数を組にしたもの」のことでした。そこで、引数にオブジェクトを渡すという事は、関数に余分なものを付け加えて渡しているというような見方もできるのですね。そのような見方をすると、実はコールバック、もっというと高階関数というのは、意外と簡単なことをやっているようにも見えるのでした。

ところで、functionX()の中ではsomeFunctionというメソッド(関数)を呼び出していますが、これはオブジェクトによって一般に内容が異なる関数で、このようにオブジェクトに応じて同じ名前の異なる関数を呼び出させる仕組みを(特に型付き言語の枠組みにおいて)ポリモーフィズムと呼ぶのでした。
そのような観点では、コールバックはポリモーフィズムの特殊な場合(データを持たず、呼び出しが可能なオブジェクトを渡した場合)と思うこともできるのでした。

オブジェクトを渡して、より複雑な処理を表現すること

コールバックは、基本的には処理が終わった後に呼ばれるものでした。
一般にある関数Xが一つの関数Yを引数に取るとき、Xの中でYを呼ぶ場所は最初でも最後でも良く、複数回呼ぶこともできますが、Yという一つの関数しか使えません(一つの関数、と言ってしまったので、当たり前なのですが...)
一方で、オブジェクトZを渡すとき、Zが複数のメソッドを持つことを期待する場合があります。
例えば、次のような処理を考えることができます。

const functionX = (someObject) => {
  // begin ...
  const beginning = someObject.begin()
  // X begin ...
  alert('X dayo!')
  const middle = beginning + ' X '
  // ... X end
  return someObject.end(middle)
  // ... end
}

const objZ = {
  begin() {return 'begin'},
  end(value) {return value + 'end'}
}
console.log(functionX(objZ))  // begin X end

引数のsomeObject(=objZ)のbegin(), end()で挟み込むというような事ができています。関数一つよりも器用な処理ですね。模式的に図を描くと、以下のようになります。


(図中のXの処理というのは、主な処理のことで、左側は全体がfunctionXの中です)

ところで、これはbegin()とend()を2つの引数として渡しているのと本質的に同じではないかという見方をすることもできます。これは、ある意味その通りだと思います。
しかし、もう少しパターンが増えた一般の場合を想定してみましょう。例えばYとZに増やした時、
beginY()
endY()
beginZ()
endZ()
という4つの関数がバラバラに定義されていて、都度beginYとendY、beginZとendZが対になるようにプログラマが注意するよりは、objYの中にY用のbeginとend、objZの中にZ用のbeginとendを定義して、引数で渡したオブジェクトのメソッドを使うようにした方が、自然に記述することができるということです。

でもそんなに複雑な処理ばかりが必要なわけではない

しかし、これまでの話をひっくり返すようですが、単に処理Xを行った後にAやBの部分だけを調整したい、それだけで十分、という事がよくあります。そのような時は、データの無いオブジェクトであるところの関数を渡すだけで十分なのでした。

ポリモーフィズムとは少し違った形の"行き来する処理"コルーチン

じつは、ポリモーフィズムで実現した構造を、上記のbegin()やend()のような複数のメソッドに頼らずに、一つの関数でやってしまう方法があります。
さきほど上の方で、Yという一つの関数しか使えませんと書いた部分について、雑にいうと「関数を途中まで処理させて、yieldという記述があった時にreturnに類似のことを行うが、処理を終了してしまうのではなく、途中のままで保持しておく」というようなやり方があります。
これがコルーチンです。
コルーチンという名前は、サブルーチンとの比較からつけられたもので、「ルーチンXがルーチンYを呼び出すという、言わばYがXのサブというようなあり方ではなくて、XとYが共に同じようなポジションで互いに呼び出し合う」というニュアンスで、コワーカー=coworkerなどの接頭辞であるcoをルーチンにつけたものです。
上でやったことと同じようなことをやってみます。

const functionX = (someObject) => {
  // begin ...
  const beginning = someObject.next().value
  // X begin ...
  alert('X dayo!')
  const middle = beginning + ' X '
  // ... X end
  return middle + someObject.next().value
  // ... end
}

const objY = function* () {
  yield 'begin'
  yield 'end'
}
console.log(functionX(objY()))  // begin X end

yieldまで実行したら、次にnext()が呼ばれた時は次のyieldまで実行する、というような流れになっています。模式的な図は以下のとおりです。

(図では便宜上①でも中断位置から再開としていますが、①の時点では最初からになります)

まとめ

コールバックと、ポリモーフィズムと、それからコルーチン、
みんなちがって、みんないい。

★姉妹記事:図解「generator・native coroutine・with」 〜 関心やコードを分離する文法と、処理順序・構造

免責事項

  • JavaScriptのfunctionは実際にはデータに相当するものも持っています。
  • 単にオブジェクトのメソッド呼び出しをすればポリモーフィズムと言えるのかというと、非常に微妙なところで、またダックタイピングと言うべきではないかという考え方もあると思いますが、便宜上ポリモーフィズムで統一しています。
  • 深夜テンションで書いているので、その他変なところがあればそっとコメントしてください。