あざらしとペンギンの問題

主に漫画、数値計算、幾何計算、TCS、一鰭旅、水族館、鰭脚類のことを書きます。

Lazy Days

くろは先生が和製キビヤックになられたことについて、謹んでお悔やみ申し上げます。

どうも、最近絵が描けていない azapen6 です。今回の本題は『遅延をスケジュールする』話です。

まずこの題目からして意味が解らないでしょう。遅延とは設定されたスケジュールから遅れることを言うので、まず言葉として矛盾しています。

一体何の話かというと、Scala における値の遅延評価をスケジュールとして設定してやるという話です。

動機

そもそもの動機は Twitter で『天之御船学園放送部♪』@anhpms として運営している bot を書くにあたって、過去のある時点に設定されたスケジュールを実行して得た結果を後から参照したいということでした。例えば、次のようなものです。

  1. 15:00 にはなこが「待って〜、猫ちゃ〜ん!」と言って走り出す。
  2. 15:30 にヒバリがはなこを見つけて「何、してるの!?」と声をかける。

はなこに何があったかはこの際置いておきます。これを実際にプログラムとして書くためには、次のような手順が必要になるでしょう。

  1. 次の手続きを 15:00 に行うように設定する:「待って〜、猫ちゃ〜ん!」とツイートする。結果としてそのツイートを表すオブジェクト status1 を受け取る。
  2. 次の手続きを 15:30 に行うように設定する:status1 に対するリプライとして「何、してるの!?」とツイートする。このとき、1のツイートは必ず存在していなければならない。
  3. 15:00 になり、1の手続きが実行される。
  4. 15:30 になり、2の手続きが実行される。

ここで重要なのは、2の手続きを実行するに当たって、1の結果が必要になるということです。リプライの対象とするツイートが必要なのです。

とりあえずのコード例

このようなプログラムを、例えば Groovy を用いて書くのは簡単です。以下、schedule(hh, mm) { proc } の記法によって、hh:mm にクロージャとして定義された手続き proc を実行するように設定することを表します。

Groovy

Groovy では次のように書くと望み通りのことができます。以下、ツイートは tweet(text)、リプライは reply(replyTo, text) というメソッドで行うこととします。ツイートに失敗した場合、あるいは replyTonull の場合は例外を投げるとします。

実のところはどちらも同じく Twitter APIstatuses/update を用いるので、リプライの相手がない場合は普通にツイートされてしまいます。reply メソッドはそのような場合を排除するものと考えてください。

def status1 = null

// 1の手続き
schedule(15, 0) {
    status1 = hanako.tweet("待って〜、猫ちゃ〜ん!")
}

// 2の手続き
schedule(15, 30) {
    status2 = hibari.reply(status1, "何、してるの!?") // ここで status1 が必要
}

ここで注意すべきは、ふたつのクロージャが変数のバインディングを共有しているということです。まず、スケジュールが設定された時点では、status1 には null が入っています。それぞれのクロージャこの status1 に対して読み書きを行うことができます。つまり、最初のクロージャで代入する変数 status1 は、その括弧の中だけに束縛された変数ではありません。よって、そこで代入を受けた status1 の値は2番目のクロージャで使うことができます。

1の手続きで Twitter への投稿が失敗した場合は例外が投げられるとします。ここでは何の例外処理もしていないので、そのままプログラムが落ちて2の手続きは実行されないでしょう。

もし何かの間違いで手続きの実行順序が逆転してしまった場合、status1 = null のまま2の手続きが実行されてしまい、やはり例外が発生して落ちます。

これらのようなエラーが発生しなければ、上のプログラムはスケジュールに従って正しく実行されるはずです。

Scala

さて、ここからが本番です。実を言うと、Scala でも大体は上と同じようにコードを書くことができます。しかし、そうしたくない理由があります。それは、『変数への代入』です。

変数への代入は関数型プログラミングにおいては原則としてすべきないと見なされています。なぜなら、代入の副作用によって関数の評価値が変わってしまう場合があるためです。関数型プログラミングでは、関数の同じ引数に対する評価値は常に同じであるべきです(参照透過性)。副作用は可能な限り避けることが望ましいので、上のような書き方は宜しくないというわけです。

変数への値の代入を最初の一度だけ行うことにすれば、そのような副作用が起こることはありません。この場合、変数はもはや変数ではなく、決まった値に対する名前でしかありません。変数への代入は名前への関連付け、アサインメントというものになります。

さて、Scala で値を宣言するキーワードは val でした。値の実質的な代入は前の groovy のコードでも一度しか行われていないので、次のように書けると思われるかもしれません。しかし実際は失敗します。なぜなら、Scala では val にしろ var にしろ宣言時に値のアサインメントを行う必要があるからです。

val status1: Status // エラー

// 1の手続き
schedule(15, 0) {
    status1 = hanako.tweet("待って〜、猫ちゃ〜ん!")
}

// 2の手続き
schedule(15, 30) {
    val status2 = hibari.reply(status1, "何、してるの!?") // ここで status1 が必要
}

もし Groovy のコードと同じように書きたいならば、1行目を var status1 : Status = null と書けばよいでしょう。しかし、これでは実質的に値が変わらないのに変数を使うことになって、Scala 的にはあまりいい気がしません。

括弧の中の status1val をつけるのもダメです。スコープが括弧の中に限定されてしまい、必要となる場所から見えないということになります。

さて、どうしたものか。

遅延評価

以上のことをサクッと解決するのが値の遅延評価です。遅延評価というのは、その値が実際に使われるときになって初めて式を評価するということです。Scala では、遅延評価する値を lazy val で宣言します。怠惰ですね。

理解するには実際に見た方が早いと思うので、簡単なコード例を示します。まず、lazy をつけない場合:

def f(x: Int) = { println("here"); x }
val a = f(1)
println("hello")
println(a)
println("bye")

これを実行すると、次のように出力されるはずです。

here
hello
1
bye

この場合では、val a = f(1) の時点で右辺が評価されるので、here は hello の前に来ます。これを遅延評価に対して先行評価と言います。

一方、lazy をつけた場合:

def f(x: Int) = { println("here"); x }
lazy val a = f(1)
println("hello")
println(a)
println("bye")

これを実行すると、次のように出力されるはずです。

hello
here
1
bye

これは、lazy val a = f(1) の時点では右辺が評価されず、後に println(a)a が要求されたときに初めて右辺が評価されて実際のアサインメントが行われます。だから here は hello の後に来るのです。

遅延評価というのはおよそこのようなものです。遅延評価という用語は、特定の言語が備える特定の機能を指すものではなく、プログラムの実行順序を決めるスキームの一種を指すものです。他にも様々な形で遅延評価を提供するものが数々のプログラム言語にあります。

遅延をスケジュールする

武器は手にしたので、あとは上の Scala のプログラムをサクッと通るようにしてやりましょう。

// 1の手続き
lazy val status1 = hanako.tweet("待って〜、猫ちゃ〜ん!")

schedule(15, 0) {
    status1
}

// 2の手続き
lazy val status2 = hibari.reply(status1, "何、してるの!?")

schedule(15, 30) {
    status2
}

はい。lazy val の評価自体が手続きに含まれるため、コメントの位置を移動させました。見た目上はスケジュールの設定のところで関数を呼んでいるような感じです。

Groovy の場合と同様に、1の手続きが実際に行われる status1 の右辺の評価で Twitter への投稿が失敗した場合は例外が投げられます。当然、そのままプログラムが落ちて2の手続きは実行されないでしょう。

しかし、もし何かの間違いで手続きの実行順序が逆転してしまった場合は Groovy の場合とは違ったことが起こります。status2 が要求されたとき、その中で status1 を要求しているので、その時点で2つのツイートが正しい順序で投稿されるのです。当然ながらスケジュールは狂いますが。

まとめ

今回は、Twitterbot でスケジュールを実行するとき、既に実行が済んでいる手続きの結果が欲しいという議題について、値の遅延評価を宣言する lazy val を使って『遅延をスケジュールする』ことによる解決方法を示しました。

サンプルコードを以下に置きます。Main.scalaprogram 関数に今回書いた内容の例を記述しています。CONSUMER_KEY などは自分で取得したものを設定してください。

GitHub - azapen6/TwitterSchedulerExample: An example code of Twitter scheduler in Scala with akka, using scalaj and json4s.

はてさて、今回の題名は一体何のことやら。