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]
まとめ
と、いう感じで ピュア Ruby で Ruby 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 を書いてみました。