【Ruby Advent Calendar 2019】ピュア Ruby で Ruby 2.7 の Numbered parameter を実装してみよう!【1日目】

Ruby Advent Calendar 2019 1日目の記事になります。
本記事では Ruby 2.7 で実装される Numbered parameter っぽい機能をピュアRuby で実装してみたいと思います。
またこの記事の実装は以下の記事を参考にして書いています。

4年以上前にこういうのが書かれていたのすごい。

Numbered parameter とは

Numbered parameter、略してナンパラです。
ナンパラは『暗黙的にブロックの引数を参照する構文』になります。
通常ブロックで引数を受け取る場合、仮引数を定義して受け取ります。

# it という名前の仮引数を定義して、それで引数を参照する
[1, 2, 3].map { |it| it.to_s + it.to_s } # => ["11", "22", "33"]

ナンパラでは仮引数を定義するのではなくて _1 という記号で『暗黙的に第一引数を参照する』事が出来るようになります。

# ナンパラを使うと仮引数を定義する事なく引数が参照できる
# _1 は第一引数を参照する
[1, 2, 3].map { _1.to_s + _1.to_s } # => ["11", "22", "33"]

ナンパラは _1 ~ _9 を使用することが出来ます。
これを利用することでより簡潔に Ruby のコードを記述する事が出来ます。

# ナンパラを使わない難解な Ruby のコード
%w(homu mami mado).map(&:upcase)
%w(homu mami mado).each(&method(:puts))
%w(homu mami mado).map(&"name is ".method(:+))


# ナンパラを使った簡潔な Ruby のコード
%w(homu mami mado).map { _1.upcase }
%w(homu mami mado).each { puts _1 }
%w(homu mami mado).map { "name is " + _1 }

今回はこの _1 をピュア Ruby でも実装してみたいと思います。

ちなみに _1 という名前は Ruby 2.6 現在でも変数名やメソッド名などで使用することが可能です。

# 変数名として定義できる
_1 = 42
_2 = _1 + _1
p _2   # => 84

# メソッド名として定義できる
def _3
  "three"
end

p _3   # => "three"

また、上記のコードは Ruby 2.7 でも引き続き動作します。
ただし、Ruby 2.7 から変数名に _1 を使用した場合は警告が出るようになったので注意して下さい。

# warning: `_1' is used as numbered parameter
_1 = 42

ちなみに本記事で書かれている Ruby のコードは Ruby 2.7 では意図する動作はしないので注意して下さい。

動作イメージ

まず簡単な動作イメージを考えます。

class X
  def triple(n, &block)
    block.call(n, n, n)
  end
end

x = X.new
p x.triple(42) { _1.to_s + _2.to_s + _3.to_s }
# => "424242"

ナンパラと同様にブロック内で _1 などを参照することを想定しています。
ポイントとしては、

  • _1 をどこで定義するのか
  • ブロックの中身を評価するコンテキストはどこになるのか
  • それをどうやって既存のメソッドに適用させるか

あたりでしょうか。
では、1つずつ実装していきましょう。

_1 を参照するためのクラスを定義する

最初にナンパラの要である _1 を定義してみましょう。
これは次のようなクラスとして定義しておきます。

module Nanpara
  class Args
    def initialize(*args)
      @args = args
    end

    def _1
      @args[0]
    end

    def _2
      @args[1]
    end

    def _3
      @args[2]
    end
  end
end

# Args.new にメソッドの引数を渡す想定
nanpara = Nanpara::Args.new("homu", "mami", "mado")

# new に渡した引数をそのまま返す
p nanpara._1    # => "homu"
p nanpara._2    # => "mami"
p nanpara._3    # => "mado"

これ自体はとてもシンプルですね。
次はこの _1 というメソッドをナンパラのように呼び出してみます。

#instance_exec を利用してブロックのコンテキストを切り替える

Ruby には #instance_exec というとても便利なメソッドが定義されています。
これは『レシーバを self としてブロックを実行する』というメソッドになります。
どういうことかよくわかりませんね。
実際の使用例を見てみましょう。

# instance_exec に渡したブロックないは "hoge" のコンテキストとして実行される
"hoge".instance_exec {
  # ここの self は "hoge" になる
  p self     # => "hoge"
  # レシーバがない場合は "hoge" のメソッドを参照する
  p length   # => 4
}

こんな感じで『"hoge" のコンテキストでブロックを実行する事』が出来ます。
この #instance_exec と先程定義した Nanpara::Args クラスを組み合わせることでナンパラのようなブロックを実行する事が出来ます。

module Nanpara
  class Args
    def initialize(*args)
      @args = args
    end

    def _1
      @args[0]
    end

    def _2
      @args[1]
    end

    def _3
      @args[2]
    end
  end
end

nanpara = Nanpara::Args.new("homu", "mami", "mado")

# nanpara のコンテキストでブロックを実行する
nanpara.instance_exec {
  # Nanpara のコンテキストとして実行される
  p self
  # => #<Nanpara:0x000055f8cd6ddb68 @args=["homu", "mami", "mado"]>

  # self を付けないで _1 を呼び出す事が出来る!!  
  p _1 + _2 + _3
  # => "homumamimado"
}


# 特定の proc で _1 を使いたい場合、Nanpara::Args を経由して呼び出す必要がある
twice = proc { _1 + _1 }
args = ["homu"]

p Nanpara::Args.new(*args).instance_exec(&twice)
# => "homuhomu"

もうナンパラじゃん!!!
と、いう感じでブロック内でナンパラっぽい構文を書くことが出来るようになります。
やったー!!!
さて、ここからちょっとコードを整理してきます。

_1 を動的に定義する

現状は _1 ~ _3 までを一つずつ定義しています。
Ruby では動的にメソッドを定義することが出来るので _1 ~ _9 まで動的にメソッドを定義するようにします。

module Nanpara
  class Args
    def initialize(*args)
      @args = args
    end

    # define_method を使って動的に _1 ~ _9 までメソッド定義する
    (1..10).each { |n|
      define_method(:"_#{n}") {
        @args[n-1]
      }
    }
  end
end

だいぶコンパクトになりましたね!

Proc にヘルパメソッドを定義する

毎回 Nanpara::Args.new(*args).instance_exec(&block) みたいに呼び出すのはちょっとつらいですね。
Proc に新しいメソッドを定義してもう少し使いやすくしてみましょう。
当然 Refinements を使ってメソッドを定義します。
Refinements についてもっと知りたい方は去年書いた記事を読んで下さい!

Refinements を使用して Proc を拡張すると以下のようになります。

module Nanpara
  class Args
    def initialize(*args)
      @args = args
    end

    (1..10).each { |n|
      define_method(:"_#{n}") {
        @args[n-1]
      }
    }
  end

  # Proc にメソッドを追加する
  # 特定のコンテキストでのみ使いたいの Refinements で定義する
  refine Proc do
    # 自身のブロック内で _1 を参照できるような Proc にして返す
    def nanparable
      # ブロックに仮引数が定義されない場合のみナンパラが使えるようにする
      tap { break proc { |*args| ::nanpara::args.new(*args).instance_exec(&self) } if parameters.empty? }
    end
  end
end

# Proc#nanparable を使えるように宣言
using Nanpara

# proc 内で _1 が使えるような proc に変換して返す
nanpara = proc { _1 + _2 + _3 }.nanparable

# あとは普通に呼び出すだけ
p nanpara.call("homu", "mami", "mado")
# => "homumamimado"


# Proc#nanparable をブロック引数に渡すことで
# 既存のメソッドに対しても _1 を使用することが出来る
p (1..10).map(&proc { _1 + _1 }.nanparable)
# => [2, 4, 6, 8, 10, 12, 14, 16, 18, 20]

これでだいぶ使い勝手がよくなりましたね!
ここから既存のメソッドで _1 が使えるように拡張できるような仕組みを考えていきたいと思います。

prepend を使ってメソッドをラップする

元々のやりたいことは以下のように既存のメソッドで _1 を使いたいことでした。

class X
  def triple(n, &block)
    block.call(n, n, n)
  end
end

x = X.new
p x.triple(42) { _1.to_s + _2.to_s + _3.to_s }
# => "424242"

X#triple というメソッドを拡張して _1 を使ったブロックを評価できるようにする事を考えてみましょう。
ここで必要なのは、

  • X#triple というメソッドを上書きする
  • 上書きしたメソッドで Proc#nanparable を呼び出す

の 2点です。
と、いうわけでこの 2点を実装すると以下のようになります。

module Nanpara
  # 省略
end

class X
  def triple(n, &block)
    block.call(n, n, n)
  end

  # prepend することで ↑ の triple よりも先に呼び出されるメソッドを定義できる
  prepend Module.new {
    # Proc#nanparable を使いたいので using しておく
    using Nanpara

    # この triple は X#triple よりも先に呼び出される
    def triple(n, &block)
      # super は X#triple を呼び出す
      # このタイミングで nanparable したブロックを渡すようにする
      super(n, &block.nanparable)
    end
  }
end

x = X.new

# prepend した module 無いのメソッドが先に呼び出される
# これにより既存の X#triple を変更する事なく _1 を使用することが出来る
p x.triple(42) { _1.to_s + _2.to_s + _3.to_s }
# => "424242"

Ruby では prepend を使用することで既存のメソッドを上書きすることなくメソッド呼び出しに処理をフックすることが出来ます。
こんな感じで既存のメソッドに対して _1 を使えるようにしていきます。

メソッドをナンパラ化するヘルパメソッドを定義する

先程のように prepend することで既存のメソッドをナンパラ化することができました。
では、既存のメソッドをナンパラ化するようなヘルパメソッドを定義してみましょう。
イメージとしては以下のような感じです。

class X
  using Nanpara

  def triple(n, &block)
    block.call(n, n, n)
  end

  # use_numbered_paramters にメソッド名を渡すとそのメソッドがナンパラ化する
  use_numbered_paramters :triple
end

クラス内で使用できるメソッドを定義する場合は Module クラスのインスタンスメソッドを定義します。
今回も Refinements を使って Module クラスに #use_numbered_paramters というメソッドを追加します。

module Nanpara
  class Args
    def initialize(*args)
      @args = args
    end

    (1..10).each { |n|
      define_method(:"_#{n}") {
        @args[n-1]
      }
    }
  end

  refine Proc do
    def nanparable
      tap { break proc { |*args| ::Nanpara::Args.new(*args).instance_exec(&self) } if parameters.empty? }
    end
  end

  # Module に対してインスタンスメソッドを定義する Refinements
  refine Module do
    using Nanpara

    # 引数のメソッドをナンパラ化する
    def use_numbered_paramters(*method_names)
      # 引数無い場合は全メソッドを対象とする
      method_names = instance_methods if method_names.empty?

      # メソッド内で prepend するぞ!  
      prepend Module.new { |mod|
        method_names.each { |name|
          define_method(name) { |*args, &block|
            super(*args, &block&.nanparable)
          }
        }
      }
    end
  end
end

class X
  using Nanpara

  def triple(n, &block)
    block.call(n, n, n)
  end

  # use_numbered_paramters にメソッド名を渡すとそのメソッドがナンパラ化する
  use_numbered_paramters :triple
end

x = X.new

# X#triple で _1 を使用することが出来る
p x.triple(42) { _1.to_s + _2.to_s + _3.to_s }
# => "424242"

ちょっと複雑ですがこんな感じで実装する事が出来ます。
また、これを利用することで既存のクラスをシュッとナンパラ化することが出来ます!!!

# Array のメソッドをナンパラ化する!
class Array
  using Nanpara
  use_numbered_paramters
end

p (1..10).to_a.shuffle.sort { _2 <=> _1 }
# => [10, 9, 8, 7, 6, 5, 4, 3, 2, 1]
p (1..10).to_a.inject(100) { _1 + _2 }
# => 155

もうナンパラじゃん。

もっと簡単にクラスをナンパラ化できるようにする

ほぼ完成なんですが、最後にもっと簡単に安全にクラスをナンパラ化する仕組みを考えてみましょう。
先程の例であれば以下のようにして任意のクラスをナンパラ化することが出来ます。

class Array
  using Nanpara
  use_numbered_paramters
end

しかし、この場合は Array を直接書き換えてしまっているので Array を参照している場所全てに影響を及ぼしてしまいます。
これはよくないですね。
そこで以下のような仕組みで『特定のコンテキストでのみナンパラ化』してみます。

module Nanpara
  class Args
    def initialize(*args)
      @args = args
    end

    (1..10).each { |n|
      define_method(:"_#{n}") {
        @args[n-1]
      }
    }
  end

  refine Proc do
    def nanparable
      tap { break proc { |*args| ::Nanpara::Args.new(*args).instance_exec(&self) } if parameters.empty? }
    end
  end

  refine Module do
    using Nanpara
    def use_numbered_paramters(*method_names)
      method_names = instance_methods if method_names.empty?
      prepend Module.new { |mod|
        method_names.each { |name|
          define_method(name) { |*args, &block|
            super(*args, &block&.nanparable)
          }
        }
      }
    end
  end

  using Nanpara
  def self.forward_use_numbered_paramters(mod)
    mod.use_numbered_paramters
  end

  def self.const_missing(klass_name)
    klass = Object.const_get(klass_name)
    ::Module.new do
      refine klass do
        # 本来であれば use_numbered_paramters を直接呼び出したいが Refinements のバグで呼び出せない…
        # Ruby 2.7 だとこの挙動は修正されていた
        # use_numbered_paramters

        # 致し方なくメソッドを経由して呼び出す
        Nanpara.forward_use_numbered_paramters(self)
      end
      # クラスメソッドにも反映させる
      refine klass.singleton_class do
        Nanpara.forward_use_numbered_paramters(self)
      end
    end
  end
end

class X
  def triple(n, &block)
    block.call(n, n, n)
  end
end

# Nanpara::クラス名 で任意のクラスをナンパラ化する
using Nanpara::X

x = X.new

# X#triple で _1 を使用することが出来る
p x.triple(42) { _1.to_s + _2.to_s + _3.to_s }
# => "424242"

上の実装であれば using Nanpara::クラス名 で任意のクラスをナンパラ化することが出来ます。
また、Refinements を利用してナンパラ化しているので次のように『任意のコンテキストでのみ』ナンパラ化することが出来ます。

module ArrayEx
  # このコンテキストでのみ Array をナンパラ化する
  using Nanpara::Array

  p (1..10).to_a.shuffle.sort { _2 <=> _1 }
  # => [10, 9, 8, 7, 6, 5, 4, 3, 2, 1]
  p (1..10).to_a.inject(100) { _1 + _2 }
  # => 155
end

# 上のコンテキスト以外では Array はナンパラ化しないので最小限の副作用で抑えられる
# Error: undefined local variable or method `_2' for main:Object (NameError)
p (1..10).to_a.shuffle.sort { _2 <=> _1 }

もう最強じゃん…。

最終的に出来上がったもの

最終的に出来上がったものがこちらになります。

module Nanpara
  class Args
    def initialize(*args)
      @args = args
    end

    (1..10).each { |n|
      define_method(:"_#{n}") {
        @args[n-1]
      }
    }
  end

  refine Proc do
    def nanparable
      tap { break proc { |*args| ::Nanpara::Args.new(*args).instance_exec(&self) } if parameters.empty? }
    end
  end

  refine Module do
    using Nanpara
    def use_numbered_paramters(*method_names)
      method_names = instance_methods if method_names.empty?
      prepend Module.new { |mod|
        method_names.each { |name|
          define_method(name) { |*args, &block|
            super(*args, &block&.nanparable)
          }
        }
      }
    end
  end

  using Nanpara
  def self.forward_use_numbered_paramters(mod)
    mod.use_numbered_paramters
  end

  def self.const_missing(klass_name)
    klass = Object.const_get(klass_name)
    ::Module.new do
      refine klass do
        Nanpara.forward_use_numbered_paramters(self)
      end
      refine klass.singleton_class do
        Nanpara.forward_use_numbered_paramters(self)
      end
    end
  end
end

ちょうど 50行でナンパラっぽいものを実装することが出来ました。
実際は Refinements で拡張するコードも含まれているのでナンパラ自体の実装は30行ぐらいしかありません。
これだけのコードでナンパラを実装できる Ruby すごくないですか?
こういう変態的なコードを書けるのが Ruby の楽しいところですよねー。
Ruby たのしー
みなさんもどんどん Ruby のよくわからないコードを書いていきましょう!

Ruby 2.7 のナンパラとの違い

最後に Ruby 2.7 のナンパラとの違いを説明しておきます。

Proc#parameters の戻り値

Ruby 2.7 のナンパラはパース時に _1 の検知を行っています。
なので使用している _1 を考慮した情報を返します。

p Proc.new { _1 + _2 }.parameters
# => [[:opt, :_1], [:opt, :_2]]

しかし、今回作成したものは動的に処理しているため Proc#parameters で取得することは出来ません。

using Nanpara::Proc

p Proc.new { _1 + _2 }.parameters
# => [[:rest, :args]]

配列を渡した時の違い

Ruby 2.7 のナンパラは _1 を使用したら |_1_1_2 を使用したら |_1, _2| という風に受け取ります。
なので配列を渡した場合に『_2 以上を使用してれば配列を展開して』受け取りますた。

p Proc.new { [_1] }.call [1, 2]      # => [[1, 2]]
p Proc.new { [_1, _2] }.call [1, 2]  # => [1, 2]

しかし、今回作成したものは可変長引数として受け取っているためです。

using Nanpara::Proc
p Proc.new { [_1] }.call [1, 2]      # => [[1, 2]]
p Proc.new { [_1, _2] }.call [1, 2]  # => [[1, 2], nil]

まとめ

と、いう感じで ピュア RubyRuby 2.7 の Numbered parameter っぽい機能を実装してみました。
完璧に同じもの!とは言えませんが 50行ぐらいの実装でそれっぽいものをつくることが出来ました。
Ruby だとコードレベルでこういうことを実現することができるのがとても面白いですね。
Ruby たのしー

ちなみにこの実装を書いたのは 2回目になり、1回目は gem として公開してあります。

こっちは 3年以上前に書いたものになります。
基本的な使い方は今回書いたコードとだいたい同じなんですが実装は結構違っているので気になる方は実装を読んでみると面白いかもしれません。
ちなみにこの gem だと _1 以外にも _yield_self_receiver などといった値にも参照することが出来ます。
そのため『自身を呼び出した再帰処理』を記述する事が出来ます。

# 再帰
fact = proc { _1 == 1 ? 1 : _1 * _self.(_1 - 1); }.use_args
p fact.call 5
# => 120

p [1, 2, 3].use_args.map { _1 + _receiver.size }
# => [4, 5, 6]

これは Ruby 2.7 の Numbered parameter にはない強みですね。
supermomonga さんが書いた元ネタの記事は 4年以上前に書かれており、その時は Numbered parameter の話は全く出ていなかったんですが、それが Ruby 2.7 で実装されるのは感慨深いものがありますね。
Ruby 2.7 のナンパラは簡単そうにみえて実は結構癖が強いので実際使われてみてどうなるのかは気になりますね。
もう 12月で Ruby 2.7 のリリースまでもうすぐですが果たして無事にナンパラをリリースする事が出来るのか…。
と、言う感じで今年の Ruby Advent Calendar を書いてみました。