LoginSignup
3

More than 1 year has passed since last update.

Procのススメ(番外編)

Last updated at Posted at 2020-12-13

はじめに

Ruby Advent Calendar 2020 の13日目の記事です。

昨日は、@jerrywdlee さんの Rubyでロックファイルによる簡易的排他制御 でした。

昨年の11/14に開催された平成Ruby会議で、「Procのススメ」というテーマで登壇したんですが、その番外編を1年以上経った 今更 、Advent Calendarという機会に便乗して書きます。
当初発表内容に盛り込もうと思っていたけど、時間の都合で省いたトピックについて記事にまとめていきます。

※1. 知識はあるけど自分でもなかなか利用場面に恵まれないテクニックが結構あることは内緒 :secret:
※2. 時間の都合上、一部に関しては「使ってみる」 の部分は用意できませんでした :pray:

1. Enumerator::Yielder#to_proc

発表当時の構想にはなかったんですが、次の「独自に to_proc を定義」の部分の都合上、Ruby2.7 がリリースされた現在だと先に説明しておいた方が良さそうなので最初に説明しておきます。

Enumerator::Yielder#to_procとは

Enumearator::Yielder#to_proc は Ruby2.7 から追加されたメソッドです。
Enumearator::Yielder 自体がかなりマニアックなので、るりまのサンプルコードを見ながら説明していきます。

text = <<-END
Hello
こんにちは
END

enum = Enumerator.new do |y|
  text.each_line(&y)
end

enum.each do |line|
  p line
end
# => "Hello\n"
#    "こんにちは\n"

上記のコードの new に渡しているブロックのブロック引数で渡ってくるのが Enumerator::Yielder のインスタンスです。

enum = Enumerator.new do |y| # <= こいつが `Enumerator::Yielder`
  text.each_line(&y)
end

この部分を &修飾子to_proc を利用しない等価なコードを書くと以下の様になります。

enum = Enumerator.new do |y|
  text.each_line { |line| y.yield line } # <= y.yield でブロックを評価する際に line をブロック引数として渡す
end

また、Enumeratoreach でイテレーション(繰り返し処理)を実行する際に渡されるブロックが Enumerator::Yielder としてブロック引数で渡ってきます。

enum.each do |line| # <= このブロックの部分が `Enumerator::Yielder` として渡ってくる
  p line
end

つまり、要素をブロック引数にそのまま引き渡すのが Enumerator::Yielder#to_proc です。

2. 独自に to_proc を定義

&修飾子と to_proc

平成Ruby会議の際のスライドにある通り、実引数の最後で &修飾子 を使うとその実引数に対して to_proc が呼ばれます。

thumbnail

Procのススメ/recommendation-of-proc - Speaker Deck

デフォルトで to_proc が定義されているのはスライドに記載している Proc(lambda)MethodSymbolHash、それに Ruby2.7 からは先程紹介したEnumerator::Yielder が加わります。
それ以外は普通は例外が発生します。

[1, 2, 3].map(&"to_proc")
# => TypeError (wrong argument type String (expected Proc))

ただ、以下の条件を満たせば他のクラスでも &修飾子 が利用できます。

  • to_proc が定義されている
  • to_procProc(lambda) を返す
class String
  def to_proc
    -> (n) { "#{self.upcase}#{n}" }
  end
end

[1, 2, 3].map(&"to_proc")
# => ["TO_PROC1", "TO_PROC2", "TO_PROC3"]

使ってみる

to_proc を実装するテクニックを使って簡単なロガーを作ってみます。
出力対象は以下のコードです。(出力対象の設計はめちゃくちゃ適当なのでご容赦を :pray: )

module Readable;end

module Writable;end

class User
  @@role = 'general'
  include Readable

  def initialize(name)
    @name = name
  end

  def self.all;end

  def name;end
end

class Admin < User
  @@role = 'admin'
  ADDRESS = 'Tokyo'
  include Writable

  def manage;end
end

ロガーの実装は以下の通り。
デバッグ情報を色々ぶち込んだハッシュを pp で出力します。
また、今回はインスタンスではなく Logger クラスを直接渡す実装にしてみました。
&修飾子 をつける実引数が to_proc を実装していればいいので、 to_proc を特異メソッドにすれば OK です。

class Logger
  def self.to_proc
    -> (obj) do
      klass = obj.class
      pp(
        {
          inspect: obj.inspect,
          class: klass.name,
          ancestors: klass.ancestors,
          class_variables: klass.class_variables,
          constants: klass.constants,
          included_modules: klass.included_modules,
          singleton_methods: klass.singleton_methods(false),
          instance_methods: klass.instance_methods(false)
        }
      )
    end
  end
end

あとは each&修飾子 をつけた Logger を渡してやれば OK です。

[Admin.new("Alice"), User.new("Bob")].each(&Logger)
# => {:inspect=>"#<Admin:0x00007ff6c8892bc0 @name=\"Alice\">",
#     :class=>"Admin",
#     :ancestors=>[Admin, Writable, User, Readable, Object, Kernel, BasicObject],
#     :class_variables=>[:@@role],
#     :constants=>[:ADDRESS],
#     :included_modules=>[Writable, Readable, Kernel],
#     :singleton_methods=>[],
#     :instance_methods=>[:manage]}
#    {:inspect=>"#<User:0x00007ff6c8892b70 @name=\"Bob\">",
#     :class=>"User",
#     :ancestors=>[User, Readable, Object, PP::ObjectMixin, Kernel, BasicObject],
#     :class_variables=>[:@@role],
#     :constants=>[],
#     :included_modules=>[Readable, PP::ObjectMixin, Kernel],
#     :singleton_methods=>[:all],
#     :instance_methods=>[:name]}

.irbrc とか .pryrc にこんな感じの便利クラスを用意しておくと便利(かも?)

3. 関数合成(Proc#>>, Proc#<<, Method#>>, Method#<<)

関数合成について

例として、別々の処理をする lambda を返すメソッドが3つあり、それを配列に対して適用していきたいケースを考えます。

def convert_integer
  -> (n) { n.to_i }
end

def count_up
  -> (n) { n.succ }
end

def output
  -> (n) { p n }
end

愚直に each で実装すると次のようになります。

%w[1 2 3].each do |n|
  output.call(count_up.call(convert_integer.call(n)))
end

これを関数合成を使うと以下のように書けます。

%w[1 2 3].each(&convert_integer >> count_up >> output)

配列の要素に対してどのような処理が適用されていくのかが左から右に流れるように読めて読みやすくなりました。:tada:
ちなみに << もあるので逆方向の関数合成をすることもできます。

%w[1 2 3].each(&output << count_up << convert_integer)

使ってみる

先程は lambda を返すメソッドを定義していましたが、実際は以下のように定義されている事が多いかと思います。

def convert_integer(n)
  n.to_i
end

def count_up(n)
  n.succ
end

def output(n)
  p n
end

また、可読性重視のためにループごとの処理を少なくし、以下のように書くこともあると思います。

%w[1 2 3]
  .map { |n| convert_integer(n) }
  .map { |n| count_up(n) }
  .each { |n| output(n) }

ただ、これだと配列の要素が多い場合にループ回数が3倍になってしまいます。
ここで関数合成が使えます。
今回は each で実行したい処理は全てメソッドなので、Method クラスを利用します。
Method にも >><< が実装されているので、関数合成ができます。
使い方・動きは Proc#>>Proc#<< と同じです。

%w[1 2 3].each(&method(:convert_integer) >> method(:count_up) >> method(:output))

4. メソッドの引数にブロック(proc)を渡す

Proc(lambda) はオブジェクトなので、引数として渡すことができます。
これをうまく使うと、メインとなる処理の前後に前処理や後処理を必要に応じて差し込むような柔軟な実装ができます。

def hoge(before: nil, after: nil)
  before.call unless before.nil?
  yield if block_given?
  after.call unless after.nil?
end

# 上記と下記は等価なコード
def hoge(before: nil, after: nil, &block)
  before.call unless before.nil?
  block.call unless block.nil?
  after.call unless after.nil?
end

実行側は下記のようになります。

hoge(
  before: -> { p 1 },
  after: -> { p 2 }
) { p 3 }
# 1
# 3
# 2
# => 2

5. Enumerator::Lazy

Enumerator::Lazy とは

るりまを見ると下記のように書いてあります。

map や select などのメソッドの遅延評価版を提供するためのクラス。
動作は通常の Enumerator と同じですが、以下のメソッドが遅延評価を行う (つまり、配列ではなく Enumerator を返す) ように再定義されています。

これはコードを見るとピンとくるかもです。
下記は単純に奇数のみの配列を返すコードです。

[1, 2, 3].select(&:odd?)
# => [1, 3]

よく見かける mapselect 等のメソッドは Enumerator クラスに定義されています。
これらのメソッドにブロック(または Proc)を渡すと、渡したブロックを評価して配列を返します。
また、ブロックを渡さなかったときは Enumerator クラスのインスタンスを返します。
普段は意識していないかもしれませんが、下記のようなコードを書くときにこの挙動を使っています。

%w[a b c].map.with_index(1) { |char, n| "#{n}: #{char}" }
# => ["1: a", "2: b", "3: c"]

一方、Enumerator::Lazy を使う場合下記のようなコードになります。

[1, 2, 3].select.lazy
# => #<Enumerator::Lazy: #<Enumerator: [1, 2, 3]:select>>

Enumerator::Lazyforce または first または to_a が呼ばれるまで値が確定せず、ループも回りません。

def hoge(x)
  x.tap { p :hoge }
end

def fuga(x)
  x.tap { p :fuga }
end

# :hogeも:fugaも1度しか出力されない
[1, 2, 3].lazy.map(&method(:hoge)).map(&method(:fuga)).take(1).to_a
# :hoge
# :fuga
# => [1]

この挙動を利用するとループ回数を減らせるのでパフォーマンス改善に利用できそうです。
ただ、残念ながら「lazy は基本的には遅くなる」そうです。(ruby-jp情報)
Enumerator::Lazy は以下のようなものに対して利用するといいそうです。(これもruby-jp情報)

  1. 大きすぎて全体をメモリに乗せられないもの 例:数GBのファイル
  2. 終わりがわからないもの 例:ネットワーク越しにやってくるデータ
  3. 終わりがないもの 例: (Date.today..)

最後に

書くと言っておきながら1年以上ずっと書けていなかった記事をようやく書けました。
今夜は赤飯です :raised_hands: (なお実際はカレー :curry: でした)

(Proc#curry について書いていればきれいにオチつけれたのにOTL)

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
What you can do with signing up
3