読者です 読者をやめる 読者になる 読者になる

Scalaの名前渡しは遅延評価ではない

scala

今日はScalaの勉強。
名前渡しって「関数の引数を遅延評価させる」のだと思っていたのですが、ちょっと違いました。

Scalaのバージョンは2.9.2です。

まずはサンプルコード

名前渡しってそもそもどういうものか、段階的にサンプルコードを書いて確認してみます。
例えば簡単なロギングクラスを作るとします。

雛形を作成
object MyLog {
  val isDebug = true

  def main(args: Array[String]) {
    log("hoge")
  }

  def log(s: String) = {
    if(isDebug) {
      println("call log")
      println(s)
    }
  }
}

本体はlogメソッドです。
このメソッドは引数に文字列を受け取り、デバッグフラグがtrueの時だけ標準出力します。
このコードを実行するとこのようになります。

call log
hoge

特に問題ありません。

名前渡しにしてみる

ログを取るための簡単なクラスは出来上がりました。
これを使って色々なところにログを仕込むことができます。
ログが必要ない時にはデバッグフラグをfalseにすればいいだけです。
試しに上記コードでisDebugをfalseにして実行してみましょう。結果は何も出力されません。
期待通りですね。

では次のような使われ方だとどうでしょうか。

object MyLog {
  val isDebug = true

  def main(args: Array[String]) {
    log(heavyFunc)
  }

  def log(s: String) = {
    if(isDebug) {
      println("call log")
      println(s)
    }
  }

  def heavyFunc() = {
    println("call heavyFunc")
    "hoge"
  }
}

heavyFuncは実際には重い処理が定義されているとします。

出力結果

call heavyFunc
call log
hoge

「call heavyFunc」が先頭に来ました。嫌な予感ですね。
isDebugをfalseにして実行すると次のようになります。

call heavyFunc

予想通り、heavyFuncは実行されました。その値を必要としないにも関わらず、です。

これを回避するには、logの呼び出し側で毎回次のような制御を加えなくてはいけません。

    if(isDebug) {
      log(heavyFunc)
    }

でも、ダサいですよね?
そこで名前渡しを使い、logメソッドの定義を次のように書き換えます。

  def log(s: => String) = {

「=>」というパラメータ?を付けました。
これで再度実行すると、今度はheavyFuncが呼ばれなくなります。
やりました。これでコードを綺麗に書けます。
「名前渡し(=>)で定義すると、その値は必要になるまで評価されない」
これは遅延評価ではないのか?その辺りはもう少しあとで記述します。

関数を渡したい場合はどうなる?

まずは名前渡しではないバージョンのコードがこちらです。

object MyLog {
  val isDebug = true

  def main(args: Array[String]) {
    log(heavyFunc)
  }

  def log(s: () => String) = {
    if(isDebug) {
      println("call log")
      println(s())
    }
  }

  def heavyFunc() = {
    println("call heavyFunc")
    "hoge"
  }
}

出力結果

call log
call heavyFunc
hoge

名前渡しにしていませんが関数を受け取る形になったため、
heavyFuncの実行は自ずと実際のログ出力時だけになりました。
しかしこのままだと、関数ではなくStringをそのまま渡す時に不便です。
例えば次の呼び出しはエラーになります。

    log("hoge")

/Users/kanno/workspace/study/scala/sample/lazy/MyLog.scala:6: error: type mismatch;
found : java.lang.String("hoge")
required: () => String
log("hoge")
^
one error found

関数(「() => String」)を期待しているのにStringが渡されたぜとご立腹です。
これを通すには呼び出し側にて次のように書かなければいけません。

    log(() => "hoge")

でも、ダサいですよね?
そこで名前渡しを使い、logメソッドの定義を次のように書き換えます。

  def log(s: => String) = {

これは一つ目の名前渡しの例と同じです。
つまり値でも関数でも渡せるようになりました。
ちなみにコップ本を読んだ感想としては、この記述力の向上が主な目的な気がしました。
「こうした指定を可能にするために作られた」とありますし。

結局、名前渡しは引数が遅延評価されるってことなの?

読み解くためにjadを使ってデコンパイルしてみます。
ちなみにjadが入っていてパスが通っていれば、vimでもプラグイン入れるとclassファイル見れて便利。
https://github.com/vim-scripts/JavaDecompiler.vim

名前渡しにするとFunction0インスタンスでラップされるだけ

違いを理解するために、まずは名前渡しでないコードをデコンパイルします。
重要な部分だけ抜き出したのがこちらです。インデントも見やすさのため修正しています。

public final class MyLog$ implements ScalaObject {

    public void main(String args[]) {
        log(heavyFunc());
    }

    public void log(String s) {
        Predef$.MODULE$.println("call log");
        Predef$.MODULE$.println(s);
    }

    public String heavyFunc() {
        Predef$.MODULE$.println("call heavyFunc");
        return "hoge";
    }
}

簡単なコードですね。
logの実行前にheavyFuncが実行されるのが分かります。

続いて名前渡しにしたコードをデコンパイルした結果がこちら。
重要部分だけ抜き出しています。インデントも見やすさのため修正しています。

    public void main(String args[]) {
        log(new Serializable() Couldn't fully decompile method <clinit> {
                    public final String apply() {
                        return MyLog$.MODULE$.heavyFunc();
                    }
                }
            );
    }

    public void log(Function0 s) {
        Predef$.MODULE$.println("call log");
        Predef$.MODULE$.println(s.apply());
    }

    public String heavyFunc() {
        Predef$.MODULE$.println("call heavyFunc");
        return "hoge";
    }
}

「MyLog$.MODULE$」は「this」と置き換えてください。
先ほどと異なり、logの引数に何やら無名クラスを渡しています。
デコンパイルできず詳細は分かりませんが、logの引数定義ではFunction0となっています。

処理をラップしたFunction0インスタンスを作ることで、処理を遅延させています。
Javaで実装するときにこういうアプローチを取ることはありますね。
これを遅延評価と呼べるかというと、「何か違う気がする」というのが感想です。
「遅延評価っぽくしている」程度でしょうか。
これが「名前渡しは遅延評価ではない」とタイトルにした理由の一つ目。

計算された値はキャッシュされない

続いて次のようにsの処理を複数回呼ぶようにコードを修正します。

  def log(s: => String) = {
    println("call log")
    println(s)
    println(s)
  }

出力結果

call log
call heavyFunc
hoge
call heavyFunc
hoge

「call heavyFunc」が再度呼ばれているので、heavyFuncの実行が再度走ったことが分かります。

デコンパイルしてみました。さきほどと違う部分だけ載せます。

    public void log(Function0 s) {
        Predef$.MODULE$.println("call log");
        Predef$.MODULE$.println(s.apply());
        Predef$.MODULE$.println(s.apply());
    }

予想通り、Function0の呼び出しを再度行います。

個人的に遅延評価なら値を再計算せずキャッシュするイメージがあったので、
これはそのイメージに反します。

一旦計算された値はキャッシュをすることが可能であり、遅延プロミスは最大で一度しか計算されないようにすることができる

遅延評価 - Wikipedia

そのため、遅延評価という言葉を使うと誤解を招きそうと思いました。
これが「名前渡しは遅延評価ではない」とタイトルにした理由の二つ目。

挙動的には「値が必要になった時に始めて評価される」で合っているとは思うんですが、
それを正確に表す用語が分かりません...。
==追記==
id:xuweiさんにコメントを頂きました。(コメント欄参照)
call-by-name、call-by-needという表現を初めて知りました。
私の遅延評価に対する理解はcall-by-needの方ですね。
[参考]
いろいろな引数渡しの方式 — 値呼び・参照呼び・名前呼び・必要呼び
遅延評価ってなんなのさ - ぐるぐる~
==追記おわり==

再計算させないためにはどうする?

一時変数に置くしかないのかな。

  def log(s: => String) = {
    val _s = s
    println("call log")
    println(_s)
    println(_s)
  }

Function0を渡しているなら、関数渡しとどう違うの?

何か名前的に同じっぽくない?
分からなくなったらデコンパイル
logの引数定義を「s: () => String」、呼び出しを「s()」に変更してデコンパイルします。

結果は省略します。
なぜなら、名前渡しと同じ内容だったからです。

とすると内部処理にほとんど違いはなくて、コード側の違いだけなのかも。
(デコンパイルできない部分もあったので、完全に同じかどうかは分からない)

まとめ

名前渡しは遅延評価を目的として使うのではなくて、「() =>」の冗長な記述をなくすために用いる。
これは独自の制御構造を作る場合を想定されている。

名前渡しにすると引数の評価は「最初に必要とされるまで」遅延されるが、
名前渡しそのものは(きっと)遅延評価を主目的としているわけではない。
内部的には以下の処理が行われていることを意識して書くこと。

  • 値は再計算されるので、何度も実行したくない場合は一時変数に置くなどする
  • 関数渡しと同じく無名クラスを作って渡している

名前渡しを使うときは次のことをちょっとは気にしましょう。

  • 名前渡しは無名クラスを作るので値渡しよりもスタックヒープを食う(と思う)
    • id:xuweiさんにご指摘頂きましたので修正
  • 名前渡しは無名クラス経由で実行されるので値渡しよりは効率が悪い(と思う)
  • 値は再計算されるので、重い処理や副作用のある処理を渡すと期待しない動作になる(かも)