マネーフォワード社内PRに見られるRubyの書き方について – (8) 受け渡しのパターンマッチング 3

エンジニアの澤田です。

この連載では、社内のRuby (on Rails)コードで気になった箇所の問題点やそこから発展して関連事項を議論しています。

連載6回目から受け渡しのパターンマッチングを考察しています。
パターンマッチングの文法的な要素のうち、オブジェクトを渡す側について連載6回目でまとめ、定数や変数で受ける側の要素について連載7回目でまとめました。

連載8回目の今回は、これらに関してPRで見かけたコードの紹介とそれについての議論をします。よく見かける問題のないユースケースにも触れます。1


【バックナンバー】


配列に入った引数を渡す

配列に不定数の引数が入っていて、それを別のメソッドに渡したいときには 連載6回目*foo の形を使います。

array = ["foo", "bar", "baz"]
["foo"].push(*array) # => ["foo", "foo", "bar", "baz"]
array = [["s", "t", "u"], ["x", "y", "z"]]
["r", "u", "b", "y"].difference(*array) # => ["r", "b"]
key_sequence # => [:a, :b, :c, :d, :e]
{a: {b: {c: {d: {e: :foo}}}}}.dig(*key_sequence) # => :foo

不定数の要素の入った配列をレシーバーと引数に分ける

メソッドString#prepend, String#concat, Array#concat, Array#union, Hash#merge, Hash#merge!, Array#product, Array#zipは、不定数の引数を取り、レシーバーや各引数の間に(順序以外に)本質的な役割の違いがありません(前者3つについては、筆者がお願いして不定数の引数を取れるようにしてもらいました2)。これらのレシーバーと引数に当たる概念を別別に変数に持つよりは、1つの配列の中にまとめて持っている方が自然なことがあります(1つのオブジェクトに次次と破壊的な変更を施していきたい場合は、この限りではありません)。そうすると、メソッドを適用するときに、その配列をどのようにしてレシーバーと引数に分割するかが問題になります。

これらのメソッドのうち、String#prepend, String#concat, Array#concat, Array#union, Hash#merge, Hash#merge!については、その演算に関しての単位元をレシーバーにすることによって、配列をレシーバーと引数に分割しないで済みます。

strings = ["a", "b", "cd"]
arrays = [["a", "b"], ["c"], ["d", "a"]]
hashes = [{"a" => 1}, {"b" => 2, "c" => 3}, {"d" => 4, "e" => 5}]

"".prepend(*strings) # => "abcd"
"".concat(*strings) # => "abcd"
[].concat(*arrays) # => ["a", "b", "c", "d", "a"]
[].union(*arrays) # => ["a", "b", "c", "d"]
{}.merge(*hashes) # => {"a"=>1, "b"=>2, "c"=>3, "d"=>4, "e"=>5}
{}.merge!(*hashes) # => {"a"=>1, "b"=>2, "c"=>3, "d"=>4, "e"=>5}

これは分割するときと比べて、きれいなコードのスタイルで、高速です。また、この方法は、元の配列内の1つ目のオブジェクトに破壊的な影響を与えたくない場合にも適しています。しかし、この方法はArray#product, Array#zipには使えません。

素朴にレシーバーと引数を分離するには、多重代入と*foo型変数を使います。

arrays = [["a", "b"], ["c"], ["d", "a"]]

first, *rest = arrays
first.product(*rest) # => [["a", "c", "d"], ["a", "c", "a"], ["b", "c", "d"], ["b", "c", "a"]]
first.zip(*rest) # => [["a", "c", "d"], ["b", nil, "a"]]

この方法は高速です。しかし変数付与の一手間がかかり、コードがきれいではありません。

ほぼ同等かわずかに遅い実行速度で配列の分割を避けるには、Symbol#to_procProc#call(もしくはその.()表記)を使うことも出来ます。

arrays = [["a", "b"], ["c"], ["d", "a"]]

:product.to_proc.(*arrays) # => [["a", "c", "d"], ["a", "c", "a"], ["b", "c", "d"], ["b", "c", "a"]]
:zip.to_proc.(*arrays) # => [["a", "c", "d"], ["b", nil, "a"]]

このように、Symbol#to_procによって作られるProcオブジェクトは、ここで扱っているタイプのメソッドとよく適合します。callメソッドが呼ばれた際に、1つ目のの引数をレシーバー、残りの引数を引数として元のシンボルで表されるメソッドに渡すのです。次のコード:

:foo.to_proc.call(bar_1, bar_2, ..., bar_n)

bar_1.foo(bar_2, ..., bar_n)

と同じです。

group_by, chunkなどのキーを捨てる

連載7回目で、不要なオブジェクトを受けるのに_が使えると述べました。これは規約であり、使わなくてもプログラムは正しく動きますが、他のプログラマや将来の自分がコードを読むときに余計なことに頭を使うのを防げます。この使いどころの一つは、Enumerable#group_by, Enumerable#chunkなどに連なるmapなどのコードブロックのキーに対応する部分です。group_by, chunkなどのメソッドでは、返り値のキー(や内側の配列の左側の要素)は、対応する配列の各要素の関数になっているので、情報が冗長で不要であることがあります。例えば、配列array:

array = ["a", "a", "b", "a", "a", "b", "b", "b", "a"]

の中で同一要素の連続する長さを得るには、chunkを使って、まず、同一要素ごとの塊を得ることが出来ます:

array.chunk(&:itself).to_a
# => [["a", ["a", "a"]], ["b", ["b"]], ["a", ["a", "a"]], ["b", ["b", "b", "b"]], ["a", ["a"]]]

しかし、対を表している配列の左側の要素は右側の配列の要素と同じで、不要です。これに続くmapでは、_を使ってこういう値を捨てます。

array.chunk(&:itself).map{|_, array| array.length}
# => [2, 1, 2, 3, 1]

委譲に**を使う

メソッドが受け取った仮引数を別のメソッドに丸ごと渡す(委譲する)ときがあります。そのようなとき、*fooの形で位置引数とキーワード引数を共に引き受けているコードがありました。

[見かけた書き方]
def foo(*args, &block)
  ...
  bar(*args, &block)
  ...
end

筆者は、上のようなコードを見たとき、キーワード引数を拾えていないのではないかという間違った指摘をしてしまい、そのコードを書いた人に、これで良いのだということを教えてもらったことがありました。Ruby 3になるまでは、キーワード引数はハッシュと区別されないので、上のコードで*argsに回収され、うまくいくのです。

ただし、**kwargsのような仮引数を入れたほうが、キーワード引数がある場合も考慮に入っているという意図が明確に表されるので、良いとは思います。

Ruby 3ではキーワード引数とハッシュは区別されるようになり、*argsでキーワード引数を回収できなくなります。Ruby 3でキーワード引数を含む委譲を行うには、上のコードではうまくいかず、次のようにする必要があります。

def foo(*args, **kwargs, &block)
  ...
  bar(*args, **kwargs, &block)
  ...
end

変数の交換

変数x, yがそれぞれある値を持っているとします。

x # => "foo"
y # => "bar"

これらの参照内容を交換しようとして、プログラミングに慣れていない人がたまに次のような間違い:

[見かけた書き方]
x = y # => "bar"
y = x # => "bar"

を犯す場合があります。これだとx, yが共に元のyの値になってしまいます。社内ではこのようなコードを見たことはありません。しかし、次のようにしているコードがありました。

[見かけた書き方]
tmp = x # => "foo"
x = y # => "bar"
y = tmp # => "foo"

2行目でxを上書きするときに元のxの内容をどこかに残しておくために、1行目でxの内容をtmpに退避させています。これは後に3行目のyの上書きで使っています。これは、他のプログラミング言語でよくある方法です。

しかし、Rubyではこのようにする必要はありません。 多重代入を使って次のように出来ます。

x, y = y, x

これを見て、左辺と右辺の値を左から順に対応させて上の間違った方のコードのように展開されるのではないか、それ故に上手くいかないのではないか、と心配になる人もいるかも知れません。しかし、そのようには展開されません。これは 連載6回目で扱ったように、代入式の右辺の配列リテラルの[]を省略しています。そして代入式ではまず右辺の単一のオブジェクトが評価されます。ここでは[y, x]["bar", "foo"]と評価されます。その後に、連載7回目で扱ったように、この配列が2つの変数x, yに対して分配されます。従って、この方法で問題ないのです。

配列の要素を個別に付与する

ある属性とその値など、対になっている情報が配列で得られたときに、配列全体を変数に付与してから、その中身を取り出すという操作をしているコードがありました。

[見かけた書き方]
key_and_value = "foo=bar".split("=") # => ["foo", "bar"]
key = key_and_value[0] # => "foo"
value = key_and_value[1] # => "bar"

目的の要素にたどり着くために、途中の配列の付与は必要ありません。多重代入を使って直接いけます。

key, value = "foo=bar".split("=") # => ["foo", "bar"]
key # => "foo"
value # => "bar"

いっぺんに得られる値

ある整数を別の整数で割ったときの商と余りの両方が欲しいときがあります。これらを以下のように個別に求めているコードがありました。

[見かけた書き方]
quotient = 47 / 5 # => 9
remainder = 47 % 5 # => 2

商も余りも同じわり算という演算に関連したものであり、このように個別に計算しているのはどうも無駄だという気がしてきませんか。Rubyにはこれらを同時に配列に入れて与えるメソッドdivmodがあります。これと多重代入を使うと、次のように商と余りをいっぺんかつ個別に付与できます。

quotient, remainder = 47.divmod(5) # => [9, 2]
quotient # => 9
remainder # => 2

ハッシュの復数の値を個別に付与する

ハッシュから特定の値を取り出して個別の変数に付与するコードをよく見かけます。

[見かけた書き方]
hash # => {foo: "foo", bar: "bar", baz: "baz", qux: "qux"}
a = hash[:bar]
b = hash[:qux]
c = hash[:foo]

これはHash#values_atと多重代入の組み合わせで簡潔に書けます。

a, b, c = hash.values_at(:bar, :qux, :foo)

配列の内部へのアクセス

配列の中に複雑に埋め込まれた要素にアクセスするために、配列に[]メソッドを繰り返し適用してアクセスしているコードがありました。

[見かけた書き方]
[[:a, [:b, :c]], ...].each do |array|
  a = array[0]
  b = array[1][0]
  c = array[1][1]
  ...
end

このようなときは、()による再帰的なパターンマッチングを使えます。

[[:a, [:b, :c]], ...].each do |a, (b, c)|
  ...
end

injectによるオブジェクトの加工

連載1回目連載2回目で扱ったように、単純なオブジェクトをイテレーターで連続的に加工していて、そのオブジェクトの内部構造を参照する場合は、再帰的パターンマッチングの一つの使い所です。

次のコードは、うるう日、うるう秒を考えないものとして、1億秒が何年何日何時間何分何秒かを求めます。

[60, 60, 24, 365].inject([100_000_000]) do
  |(r, *a), d|
  r.divmod(d) + a
end
# => [3, 62, 9, 46, 40]

3年62日9時間46分40秒という結果が得られました。目的の配列は(r, *a)に相当する部分です。この中で、左端の要素rはそのイテレーションで演算を施したい対象で、残りの*aはもう計算が終わって変更する必要のない部分です。計算した商と余りを配列aの左端に足していっています。

each_with_objectによるオブジェクトの加工

同様に、単純なオブジェクトをイテレーターで連続的に加工していて、イテレーションする要素の内部構造を参照する場合もあります。

次の例は、文字ごとの重み(投票数など)を表すハッシュが与えられて、文字キーを小文字にして表記ゆれを吸収した上で重みの合計を計算するコードです。

{"a" => 1, "A" => 2, "B" => 2, "c" => 3, "C" => 1}.each_with_object(Hash.new(0)) do
  |(k, v), h|
  h[k.downcase] += v
end
# => {"a"=>3, "b"=>2, "c"=>4}

加工されるオブジェクトは、値0をデフォルトとして返すハッシュHash.new(0)として生成され、ブロック中では引数hとしてイテレーションの間中引っ張り回されます。一方のイテレーションされる要素は、ハッシュのキーと値の対を表す配列です。このキーと値にk, vとして個別にアクセスするために、再帰的パターンマッチングを用いて(k, v)のようにしています。

複数のオブジェクトを伴うイテレーション

複数のオブジェクトを連れ回すときは、each_with_objectwith_objectを重ねます。このとき、各段で一まとまりの配列になるので、再帰的パターンマッチングを使います。

次の例は、配列に2回以上含まれる要素を、2回目に登場する順に抽出するコードです。

["a", "b", "c", "d", "b", "c", "a", "c"].each_with_object([]).with_object([]) do
  |(elem, first_occurrences), second_occurrences|
  if !first_occurrences.include?(elem) then first_occurrences << elem
  elsif !second_occurrences.include?(elem) then second_occurrences << elem
  end
end
# => ["b", "c", "a"]

1回登場した要素を記録する配列first_occurrencesと2回目に登場した要素を登場順に記録する配列second_occurrencesの2つを連れ回しています。欲しい結果はsecond_occurrencesなので、これがパターンマッチングの外側になるようにしています。

あるいは、効率化のために、次のようにハッシュを使うことも出来ます。

["a", "b", "c", "d", "b", "c", "a", "c"].each_with_object({}).with_object({}) do
  |(elem, first_occurrences), second_occurrences|
  if !first_occurrences[elem] then first_occurrences[elem] = true
  elsif !second_occurrences[elem] then second_occurrences[elem] = true
  end
end
.keys
# => ["b", "c", "a"]

イテレーションする要素の内部構造を判定する

array # => [1, 2, 5, 6, 7, 8, 10]の連続する番号をまとめ、"1, 2, 5–8, 10"という文字列に変型することを考えます。

array.chunk_while{|x, y| x.next == y}.to_a # => [[1, 2], [5, 6, 7, 8], [10]]

となることを使い、このto_aの代わりにmap, joinを続けて次のように出来ます。

array
.chunk_while{|x, y| x.next == y}
.map do
  |first, middle = nil, *, last|
  if middle then "#{first}–#{last}"
  elsif last then "#{first}, #{last}"
  else first
  end
end
.join(", ")
# => "1, 2, 5–8, 10"

mapのブロック引数の箇所で(1) 多重代入、(2) 連載7回目で述べたA配列を参照して要素がないときのデフォルトのnil, (3) 随意引数のデフォルト指定, (4) *による残りの要素の回収、の組み合わせを使っています。

chunk_whileの返り値の配列に埋め込まれた配列は、少なくとも1 つ要素を持ちます。配列の1つ目の要素がfirstに付与されます。要素が2つ以上あれば、lastに最後の要素が付与され、なければnilが付与されます。3つ以上要素があれば、middleに2つ目の要素が付与され、なければnilが付与されます。4つ以上要素があれば、残りは*に対応し、捨てられます。

コードブロック中のメソッド呼び出しのレシーバーや引数の省略

上の節で述べたSymbol#to_procの性質と 連載6回目で述べた&によるProcオブジェクトのコードブロックへの変換を使って、

{|arg_1, arg_2, ..., arg_n| arg_1.foo(arg_2, ..., arg_n)}

という形のコードブロックを省略して

&:foo

と書くことが出来ます。

最もよく現れる形がn = 1、つまり、引数がない場合です。

{|arg_1| arg_1.foo}

例えばこんな感じに使われます。

["a", "b", "c"].map(&:upcase) # => ["A", "B", "C"]

n = 2の場合はinjectメソッドに現れます。

{|bar_1, bar_2| bar_1.foo(bar_2)}

次のような使い方があります。

(1..6).inject(&:*) # => 720

しかし、injectは第1引数がシンボルならそれがメソッド名を表すので、次のようにも書けます。

(1..6).inject(:*) # => 720

また、以前は、次のように、埋め込まれた配列やハッシュのキーをたどっていくという使い所がありました。

hash # => {a: {b: {c: {d: {e: :foo}}}}}
key_sequence # => [:a, :b, :c, :d, :e]

key_sequence.inject(hash, &:[]) # => :foo
key_sequence.inject(hash, :[]) # => :foo

しかし、前の節で示したように、Array#dig, Hash#digメソッドの出現により、このようにする必要はなくなりました。また、この方法は、埋め込まれた途中のキーが存在する場合にしか使えないという制約もあります。

コードブロック中の関数型メソッド呼び出しの引数の省略

Method#to_procの性質と&を使って、

{|arg_1, arg_2, ..., arg_n| bar.foo(arg_1, arg_2, ..., arg_n)}

という形のメソッドの呼び出しを含むコードブロックを省略して

&bar.method(:foo)

と書くことが出来ます。

特にbarKernelの場合、これを省略して次のように書けます。

[2, [5], 1].map(&method(:Array))
# => [[2], [5], [1]]

明示的なbarが必要な場合には次のような用法があります。

[2, 3, 5].map(&Math.method(:sqrt))
# => [1.4142135623730951, 1.7320508075688772, 2.23606797749979]

barが明示的な場合は、Ruby 2.7から導入される.:foo記法を用いて次のようにも書けます。

[2, 3, 5].map(&Math.:sqrt)
# => [1.4142135623730951, 1.7320508075688772, 2.23606797749979]

ハッシュを使った写像

次のような感じのコードを見かけました。

[見かけた書き方]
array # => ["a", "b", "c", "d", "b", "c", "a", "c"]

array.map do |e| 
  case e
  when "a" then "foo"
  when "b" then "bar"
  when "c" then "baz"
  when "d" then "qux"
  end
end
# => ["foo", "bar", "baz", "qux", "bar", "baz", "foo", "baz"]

このコードでは、対応関係が制御構造としてハードコーディングされています。この関係が滅多に変わることのない固定されたものならばこれで良いですが、そうではなく、今後変化していく可能性のあるものの場合、その度に制御構造を書き換えなくてはいけません。そういう状況では、対応関係(データ)と制御構造(ロジック)を分離するほうが望ましいと考えられます。

case構文と守備範囲の近いオブジェクトにハッシュがあります。ハッシュを使って対応関係をデータとして分離するとすると、次のようなコードが考えられます。

hash = {"a" => "foo", "b" => "bar", "c" => "baz", "d" => "qux"}
array.map{|e| hash[e]}
# => ["foo", "bar", "baz", "qux", "bar", "baz", "foo", "baz"]

Hash#to_proc&を使えば、全くブロックを書く必要がありません。

array.map(&hash)
# => ["foo", "bar", "baz", "qux", "bar", "baz", "foo", "baz"]

まとめ

連載6回目、連載7回目と概念的な記事が続きましたが、今回ようやく実践的な内容に移ることが出来ました。Rubyのパターンマッチングは工夫次第で非常に多くのことが出来ます。

最後に

マネーフォワードでは、Rubyと寝食を共にしたいエンジニアを募集しています。
ご応募お待ちしています。

【採用サイト】
マネーフォワード採用サイト
Wantedly

【マネーフォワードのプロダクト】
お金の見える化サービス 『マネーフォワード ME』 iPhone,iPad Android

ビジネス向けバックオフィス向け業務効率化ソリューション 『マネーフォワード クラウド』

おつり貯金アプリ 『しらたま』

おトクが飛び出すクーポンサービス 『tock pop』

金融商品の比較・申し込みサイト 『Money Forward Mall』

くらしの経済メディア 『MONEY PLUS』

本業に集中できる新しいオンライン融資サービス 『Money Forward BizAccel』


  1. この記事はRuby開発者の一人である弊社の卜部昌平氏に目を通してもらいました。記事に間違いがあれば、それは筆者の責に帰するものです。 ↩︎

  2. https://bugs.ruby-lang.org/issues/12333 ↩︎

Pocket