M.Hiroi's Home Page

お気楽 Ruby プログラミング入門

番外編 : 遅延評価


Copyright (C) 2011-2023 Makoto Hiroi
All rights reserved.

はじめに

一般的なプログラミング言語の場合、関数を呼び出す前に引数が評価され、その結果が関数に渡されます。これを「正格 (strict) な評価」といいます。これに対し、引数や変数の値が必要になるまで評価を行わない方法もあります。具体的には、引数や変数を参照するときに評価が行われます。これを「遅延評価 (delayed evaluation または lazy evaluation)」といいます。

プログラミング言語では純粋な関数型言語である Haskell が遅延評価です。また、Scheme でも delay と force を使って遅延評価を行うことができます。そして、その評価結果は保存されることに注意してください。再度変数や引数を参照すると、保存されている値が返されます。

なお、値の保存 (キャッシング) をしないでよければ、クロージャを使って遅延評価を行うこともできます。Ruby はクロージャ (手続きオブジェクト) をサポートしているので、遅延評価を実装することは簡単です。今回は Ruby で遅延評価を行うクラス Delay を作ってみましょう。

●遅延評価の実装

Delay.new() は遅延評価を行う処理を手続きオブジェクトで受け取ります。実行はメソッド force() で行います。このとき、評価結果がインスタンスに保存されることに注意してください。本稿では、Delay のインスタンスを「遅延オブジェクト」と呼ぶことにします。再度 force() を実行すると、保存された値が返されます。

プログラムは次のようになります。

リスト : 遅延評価

class Delay
  def initialize(&func)
    @func = func
    @flag = false
    @value = false
  end

  def force
    unless @flag
      @value = @func.call
      @flag = true
    end
    @value
  end
end

遅延評価する処理はブロック引数 &func で受け取って変数 @func にセットします。@flag は false で初期化します。メソッド force() は最初に @flag をチェックします。偽の場合、@func はまだ評価されていません。@func.call() を実行して、その返り値を @value にセットし、@flag を true に書き換えます。@flag が true ならば @func は評価済みなので @value を返します。

簡単な使用例を示します。

irb> p = Delay.new {
irb>   puts "oops!"
irb>   10 + 20
irb> }
=> #<Delay: ... @func=#<Proc: ... >, @flag=false, @value=false>
irb> p.force
oops!
=> 30
irb> p.force
=> 30

遅延オブジェクトを変数 p にセットします。このとき、手続きオブジェクトは実行されていません。p.force() を実行すると手続きオブジェクトが評価されるので、画面に oops! が表示されて計算結果の 30 が返されます。p.force() を再度実行すると、同じ式を再評価しないで @value に格納された値を返します。この場合 oops! は表示されません。

●たらいまわし関数

遅延評価の簡単な例題として「たらいまわし関数」を取り上げます。次のリストを見てください。

リスト : たらいまわし関数

def tarai(x, y, z)
  return y if x <= y
  tarai(tarai(x - 1, y, z), tarai(y - 1, z, x), tarai(z - 1, x, y))
end

def tak(x, y, z)
  return z if x <= y
  tak(tak(x - 1, y, z), tak(y - 1, z, x), tak(z - 1, x, y))
end

関数 tarai() と tak() は「たらいまわし関数」といって、再帰的に定義されています。これらの関数は、引数の与え方によっては実行に時間がかかるため、Lisp などのベンチマークに利用されることがあります。tarai() は通称「竹内関数」と呼ばれていて、日本の代表的な Lisper である竹内郁雄先生によって考案されたそうです。そして、tak() は tarai() のバリエーションで、John Macarthy 先生によって作成されたそうです。たらいまわし関数が Lisp のベンチマークで使われていたことは知っていましたが、このような由緒ある関数だとは思ってもいませんでした。

それでは、さっそく実行してみましょう。実行環境は Ubunts 22.04 LTS (WSL2, Windows 10), Core i7-2670QM 2.20GHz です。

irb> require "benchmark"
=> true
irb> load "tarai.rb"
=> true
irb> Benchmark.realtime { tarai(14, 7, 0) }
=> 24.046061818999988
irb> Benchmark.realtime { tak(20, 10, 0) }
=> 3.852315803000238

実行環境 : Ruby 3.0, Ubunts 22.04 LTS (WSL2, Windows 10), Intel Core i5-6200U 2.30GHz

このように、たらいまわし関数は引数の値が小さくても実行に時間がかかります。

●遅延評価による高速化

たらいまわし関数は遅延評価を使って高速化することができます。tarai() のプログラムを見てください。x <= y のときに y を返しますが、このとき引数 z の値は必要ありませんね。引数 z の値は x > y のときに計算するようにすれば、無駄な計算を省略することができます。ただし、tak() は遅延評価で高速化することはできません。ご注意くださいませ。

クラス Delay を使うと、たらいまわし関数は次のようになります。

リスト : Delay による遅延評価

def tarai_lazy(x, y, z)
  return y if x <= y
  tarai_lazy(tarai_lazy(x - 1, y, z),
             tarai_lazy(y - 1, z.force, Delay.new { x }),
             Delay.new { tarai_lazy(z.force - 1, x, Delay.new { y }) })
end

tarai_lazy() のプログラムを見てください。遅延評価したい処理を Delay に包んで引数 z に渡します。そして、x > y のときに引数 z を評価 (z.force()) します。すると、遅延オブジェクトが評価されて z の値を求めることができます。

たとえば、{ 0 } を Delay に渡す場合、z.force() とすると返り値は 0 になります。{ x } を渡せば、x に格納されている値が返されます。{ tarai_lazy( ... ) } を渡せば、関数 tarai_lazy() が実行されてその値が返されるわけです。

それでは実行してみましょう。

irb> Benchmark.realtime { tarai_lazy(14, 7, Delay.new {0}) }
=> 0.00015410000014526304
irb> Benchmark.realtime { tarai_lazy(140, 70, Delay.new {0}) }
=> 0.005250799999885203

tarai() の場合、遅延評価の効果はとても大きいですね。

●クロージャによる遅延評価

ところで、Delay を使わなくても、手続きオブジェクト (クロージャ) だけで遅延評価を行うことができます。次のリストを見てください。

リスト : クロージャによる遅延評価

def tarai_lazy1(x, y, z)
  return y if x <= y
  zz = z.call
  tarai_lazy1(tarai_lazy1(x - 1, y, z),
              tarai_lazy1(y - 1, zz, proc { x }),
              proc { tarai_lazy1(zz - 1, x, proc { y }) })
end
irb> Benchmark.realtime { tarai_lazy1(140, 70, proc {0}) }
=> 0.005714298999919265

遅延評価したい処理をクロージャに包んで引数 z に渡します。そして、x > y のときに引数 z の関数を呼び出します。すると、クロージャ内の処理が評価されて z の値を求めることができます。たとえば、proc {0} を z に渡す場合、z.call とすると返り値は 0 になります。proc {x} を渡せば、x に格納されている値が返されます。proc { tarai_lazy1( ... ) } を渡せば、tarai_lazy1() が実行されてその値が返されるわけです。ただし、クロージャでは評価結果を保存 (キャッシュ) できないことに注意してください。


初版 2011 年 5 月 14 日
改訂 2017 年 1 月 28 日
改訂二版 2023 年 1 月 22 日