Tech Racho エンジニアの「?」を「!」に。
  • Ruby / Rails関連

Ruby言語の進化を追いかけて意外な構文機能を発見した話(翻訳)

概要

原著者の許諾を得て翻訳・公開いたします。

日本語タイトルは内容に即したものにしました。

Ruby言語の進化を追いかけて意外な構文機能を発見した話(翻訳)

私がこれまでまったく気づいていなかった、ある構文機能がRubyに存在します。

Rubyの進化をテーマにした前回記事を書きつつ(かつ今後出版する書籍の形式を構想しつつ)、私はRuby言語の歴史をどこまでも深く掘り下げ始めています。同時に他の言語についても調べ、あるソリューションが業界で広まったのがいつだったのか、逆にある手法が流行らなくなったのはいつだったのかを理解しようとしています。

Rubyについては、NEWSChangelogファイルに目を通したり、変更された時期を独自に構造化したリストを作成したりする作業で2時間ほど費やすことがあり、そこでちょくちょく何かを発見しています(ほとんどは些細なものですが)。そうした「発見」のほとんどは、「お、この機能が導入されたのはこのときだったのか」だったり「待てよ、いつもこうではなかったのでは?」だったりします。私は2004年(バージョン1.6/1.8)からRubyを使い続けているので、後者のような疑問が生じるときの方がどちらかというと興が乗ってきます。今では当たり前に「自然」なものとして感じられる構文が、かつてはそうではなかったことが突然思い出されると、まるで子どもの頃の記憶がありありと蘇ったような心持ちになります。

はるか昔に導入されていた構文に自分が気づいていなかったということは、めったにありません。「便利だがほとんど必要ない」メソッドならまだしも、コア機能に自分の知らないものがあったというのはさらに珍しいことです。

しかしごく最近、まさにそういう機能を見つけたのです。

🔗 その機能とは...

Ruby 1.9.1のNEWSファイル1に、以下のシンプルなメモが残されています。

オプショナル引数の後ろに必須引数を置くことが容認されるようになった。

「ちょっと待った、それは私が考えている意味でそうなの?」

Rubyでそんな書き方が可能だった覚えはないので、やってみました。

def foo(optional = -100, mandatory)
  p(optional:, mandatory:)
end

foo(1, 2) #=> {:optional=>1, :mandatory=>2}
foo(1)    #=> {:optional=>-100, :mandatory=>1}

むむ、たしかにできています。しかも説明が付く形で(オプショナル引数の後に必須引数を置く合理的な意味があればの話ですが、これについては後で説明します)。つまり、メソッドに渡される引数の個数に応じて、オプショナル引数でデフォルト値を使うべきかどうかを言語が決定しているのです。

必須引数の後ろには、オプショナル引数を複数置いても許されます。

def foo(optional1 = -100, optional2 = -200, mandatory)
  p(optional1:, optional2:, mandatory:)
end

foo(1)        #=> {:optional1=>-100, :optional2=>-200, :mandatory=>1}
foo(1, 2)     #=> {:optional1=>1, :optional2=>-200, :mandatory=>2}
foo(1, 2, 3)  #=> {:optional1=>1, :optional2=>2, :mandatory=>3}

ただし、「必須変数の後ろにオプショナル引数を置く」という構成は、おそらくメソッド定義側では一度しか出現しないでしょう。

def foo(optional1 = -100, mandatory, optional2 = -200)
#                                              ^ syntax error, unexpected '=', expecting ')'
end

なるほど、この振る舞いはたしかにシンプルですし、予測を裏切りませんが、それでも私はこんな振る舞いが存在していることに驚きました。私はRubyの機能の大半を自力で発見してきましたが、これについてはまるで気づきませんでした(私は20年間もRubyを主要言語として使い続けてきたにもかかわらず、私の主要なテーマが「Rubyでクリーンかつ表現力の豊かなコードを書く方法」であるにもかかわらず、です)。そもそも、こんな書き方が可能かもしれないという発想がありませんでした。

もちろん、私が他の言語やコンピュータサイエンス教育で得た経験では、「オプショナル(=省略可能な)引数は末尾に置く」以外の選択肢がなかった2ために、Rubyでそんなことが可能だとは気付けなかったのかもしれませんが。

しかし一方で、こんな順序の引数を必要とするAPIをすぐに思いつきません。そのようなメソッドを以下のような感じで1〜2度ほど定義したか誰かのコードで見かけた覚えが「うっすら」あった気もするのですが...

# つまり以下のような「自然」な順序で引数を使える
#   post('/foo', {'Content-Type': 'JSON'}, 'data')
#   post('/foo', 'data')
def post(endpoint, headers, body = nil)
  headers, body = nil, headers if headers && !body
  # ...
end

# ...しかし以下のように書くだけでもよいとは知らなかった
def post(endpoint, headers = nil, body)

しかし、それがいつだったのか、実際に使ったのかどうかについては、なかなか思い出せません。

そこで、この機能が導入されたときに、どのような形で正当化されたのかを知りたくなりました。

🔗 ウクライナ通信🇺🇦

ほんの少しお時間をください。私が生活しているウクライナが現在も侵略を受けていることを思い出していただくため、記事の途中にはさむことにしています。どうかお読みください。

とあるニュース記事: 本記事を書き終えようとしていたまさにそのとき、私の故郷ハルキウにある郵便会社のターミナルをロシアが爆撃しました(彼らは民間物流の麻痺と、前線近くの都市生活を耐え難いものにする目的で頻繁にこれを行っています)。

とある背景情報の断片: ロシアの捕虜収容所から帰還したウクライナ人捕虜のほぼ全員が、飢餓と拷問によってショッキングな姿になっています。クレムリンの政策であることは明らかです。

とある募金活動: ロシアの侵略により、ウクライナ領土の25%以上が爆発物で汚染されています。どうかウクライナの地雷除去にご協力をお願いします

引き続き記事をどうぞ。

🔗 理由の調査

私はRuby Changesというサイトを長年メンテしていて、そこでは言語が変更されたときの「理由や考察」を提供することを常に心がけています(例: endlessメソッドの場合)。その際、自分の推論や言語的直感だけに頼らず、変更につながった元のbug tracker(複数の場合や数年に渡る場合もあります)での議論を常にチェックしています。

調査するときの問題は、歴史を遡れば遡るほどRubyのNEWSやChangelogの記述があっさりしていて、最終的にはdiscussionチケットへのリンクも失われていることです。実はある時期まで、ほとんどの議論はbug trackerではなくメーリングリストで行われていたのです。

そういうわけで、私たちに残された唯一の手がかりはソース(コード)ということになります!

オプショナル引数の後ろに必須引数を置くことを許す機能は、パーサーによるサポートが不可欠なので、答えがありそうな場所はparse.y(Ruby構文のYACC/Bison定義)ということになります。
1.9.1でのparse.yの状態をチェックして、文法定義に埋め込まれているすべてのCコードを注意深く調べてみれば、必要なコード片が比較的すぐ見つかります。メソッド引数ノードの定義は以下のようになっています(文法に埋め込まれたCコードについてはすべて取り除いてあります)。

f_args    : f_arg ',' f_optarg ',' f_rest_arg opt_f_block_arg
            | f_arg ',' f_optarg ',' f_rest_arg ',' f_arg opt_f_block_arg
            | f_arg ',' f_optarg opt_f_block_arg
            | f_arg ',' f_optarg ',' f_arg opt_f_block_arg
            | f_arg ',' f_rest_arg opt_f_block_arg
            | f_arg ',' f_rest_arg ',' f_arg opt_f_block_arg
            | f_arg opt_f_block_arg

これは以下のように読めます。

  •  f_argsノードは、以下のシーケンスでできている
    •  f_arg: 下で必須引数として定義される名前のみ
    • ,: コンマリテラル
    • f_optarg: デフォルト値付きのオプショナル引数
    • ,: コンマリテラル
    • f_restarg: 残り引数(*rest
    • opt_f_block_arg: オプショナルブロック引数
  • または
    • f_arg: 必須引数
    • ,: コンマリテラル
    • f_optarg: オプショナル引数
    • f_restarg: 残り引数
    • ,: コンマリテラル
    • f_arg: 必須引数
    • opt_f_block_arg: オプショナルブロック引数
  • または
    • ...

といったシーケンスになります。考え方はおわかりでしょう。

ここで関心の対象となるのは、f_arg(必須引数)がf_optarg(オプショナル引数、もしくはデフォルト値付きオプショナル引数)の後に来るのがいつなのか、です。git blameすれば、そのコミットがいつ入ったのかがわかります(ead9b19)。うまいことに、このコミットはメーリングリストでの議論([ruby-dev:29014])と同一で、しかもオンライン上のミラーサイトですぐ見つかりました。このruby-devという日本語でのメーリングリストは古いものですが、Google翻訳のおかげで議論の冒頭が以下のようになっていることがわかりました。

ふと、1.9 なら (TCPServer#initialize のように) 第一引数を省
略可能なメソッドを def m(a=nil, b) と定義できるのではなかろ
うか、と想像し、試してみたのですが、できないようです。
[ruby-dev:29014] def m(a=nil, b)より

つまり、可能なユースケースとして言及されているAPIは、Ruby標準ライブラリのTCPServer#initializeであることがわかりました。これはまさにnew([hostname,] port)というプロトコルになっています(第1引数はオプショナル、第2引数は必須)。

その後の議論で、当初Matzは可能性が疑わしいとして導入をためらっていましたが、最終的に自分自身で実装しました。導入した理由は、この構文の具体的なユースケースを誰かがたくさん提供してくれたからではなく、この質問がRuby 1.9におけるより一般的な変更のエッジケースに相当することが判明したためです。つまり、可変長の引数の後ろに1個の引数(必須またはオプショナル)を置く可能性です。

# Ruby 1.8: 以下のようなAPIは構文エラー
#   handle_files '*.rb', '.*js', Compiler
def handle_files(*extensions, handler)
#                ^              ^
#        任意個数の引数   末尾は必須引数
end

# 当時のRubyで一般的なイディオム: 末尾の引数をオプション辞書にする
def handle_files(*names, options = {})
  # ...
end

# 使い方:
handle_files('README.md', 'script.rb', remove: true)
#                                      ^^^^^^^^^^^^
# 末尾の部分は`options`辞書として扱われる

この変更は、ある巨大なコミット(9b383bd)で導入されましたが、議論へのリンクは貼られていませんでした。しかしそれでも、「必須引数の前にオプショナル引数を置く」ことの不可解さに比べればマシです。この変更の主な動機は、可変長引数の後ろにoptions辞書を置くことだったようです(Matzの昔のブログ記事で確認しました)が、オプションハッシュが本物のキーワード引数に置き換わった現代になっても、他のユースケースが残っています。私は、自分たちのproductionアプリやRailsRubocopでコードベースをgrepしてみたところ、そうしたコードが見つかりました(いずれも利用頻度はきわめて穏当ですが)。

しかしこの書き方を可能にできるのであれば(かつ、最後に渡した引数だけが使われることをRubyが「正しく理解できる」のであれば)、一貫性のためにも、末尾の引数より前に可変長引数を定義する別の方法も可能にすべきではないでしょうか?

def foo(*optional, mandatory)
  p(optional:, mandatory:)
end
foo(1)    # {:optional=>[], :mandatory=>1}
foo(1, 2) # {:optional=>[1], :mandatory=>2}

オプショナル引数の場合、「可変長の個数」は0個または1個ということになります(厳密には、0〜必須引数より前に定義されたオプショナル引数の個数)。そして、その通りになったのです。

🔗 この機能が便利な場面はあるか?

既に述べたように、必須引数の前にオプショナル引数を置く形で実装したかったようなAPIをあまり思い出せません(APIの存在そのものは覚えていても)。私が他に見落としていなければ、RubyのコアAPIや標準ライブラリにはそうした例が2つあります(C言語で実装されている可能性がありますが、そうしたシグネチャが存在するということは、それが便利な場合がありえるということです)。

1つは、前述したTCPServer#initialize([hostname,] port)、もう1つはexec(外部コマンド実行用)で、exec([env, ] command_line, options = {})というシグネチャがあります(つまりサブプロセス用のENVを指定可能)。引数の順序がこのようになっているということは、シェルにおける以下のような使い方を模倣するためと思われます。

VERBOZE=1 ruby run_script.rb

上をRubyの「自然な」順序で渡すと、以下のようになるでしょう。

exec({'VERBOSE' => 1}, 'ruby run_script.rb')

上述のpost(endpoint, headers = nil, body)の「理論的な」例と同様に、どの例も、一部のAPIでは自然と認識されているもの、すなわち「引数はどんな順序にするのが自然か」vs「どの引数を省略するの自然か」という衝突を解消しているように思えます。

しかし視点を広げて、一般的な「個数が可変長の引数の後に必須引数を置く」で考えてみると、上の例で示したような使い道が見つかりそうです。また、過去記事でも言及したシンメトリーの精神に則れば、メソッドの定義側でこの構文が使えるということは、多値代入でも同じ構文が使えるということになるので、たとえば以下のように書けるわけです。

*folders, filename = '/home/zverok/blog/_posts/optional-args.md'.split('/')
folders #=> ["", "home", "zverok", "blog", "_posts"]
filename #=> "optional-args.md"

しかし、ここでもうひとつ興味深いAPIがあります。これを実装するRubyistは多くはなさそうですが、それでもRubyの機能の一部であり、「末尾の必須引数」に意義が生まれる場所でもあります。これは添字付き代入演算子の再実装であり、[]=メソッドの末尾の引数が「代入される値」で、それより前の引数部はすべてキーの一部です。

# アンパックを行う場合
class Multidimensional
  def []=(*key, value)
    p(key:, value:)
  end
end

m = Multidimensional.new
m[1, 2, 3] = 4 # {:key=>[1, 2, 3], :value=>4}

# または引数の途中にオプショナル引数を置く場合
class Matrix
  def []=(col, row=:all, value)
    puts "Assigning [#{col}:#{row}] = #{value}"
  end
end

m = Matrix.new
m[1, 2] = 3
# [1:2] = 3を代入する
m[1] = [4, 5, 6]
# [1:all] = [4, 5, 6]を代入する

🔗 他の言語はどうやっているか

オプショナル引数を(位置引数である)必須引数より前に置く形でメソッドや関数を定義できる言語は、他にほとんどありません。かつてPHPでは可能でしたが、設計上の欠陥(オプショナル引数の後ろに必須引数がある関数をfoo(1)で呼び出すと、第1引数が渡され第2引数が存在しないとみなされる)のため、PHP 8で非推奨化されました。

JavaScriptでも可能ですが、オプショナル値を使うには以下のようにundefinedを明示的に渡さなければなりません。

function foo(optional = 1, mandatory) {
  console.log({optional, mandatory})
}
foo(2)            // {optional: 2, mandatory: undefined}
foo(undefined, 2) // {optional: 1, mandatory: 2}

Python、Kotlin、Scalaは、どれもオプション引数やアンパック引数の後ろに他の必須引数を定義できますが、その代わり呼び出し側では名前付き引数しか渡せません。

// Kotlin
fun foo(i1: Int, i2: Int = 0, v: String) {
  println("foo($i1, $i2, $v)")
}

fun main() {
  // 型不一致: 推論された型はStringだが、期待されるのはInt
  // Kotlinはi2の省略を推論できない
  t.foo(1, "foo");
  // 動く:
  t.foo(1, v="foo"); // foo(1, 0, "foo")
}

ここで、上述のTCPServer([host,] port)のようなケースが他の言語でどう扱われるかを見てみると別のソリューションが見つかるかもしれません。

たとえばPythonのsocket.create_connectionでは、(host, port)タプル(tuple)を1個の引数として渡しますが、host''にできます。逆に、たとえばNodeJSのserver.listen([port[, host[, backlog]]][, callback])では単に順序を(port [, host])のように変更します。とにかく、こういうAPIが必要になることはめったにないので、あまり気にする必要はなさそうです。

ここで興味深いのは、近年の新しい言語はいずれもこの問題を回避する方向に進もうとしていることです。たとえばRust、Zig、Nim、Goはどれも、オプショナル引数を一切許可せず、代わりにパラメータ構造体を使うことをユーザーに求めています(なお、一部のフィールドでデフォルトのイニシャライザを使う場合もあります: Zigの例)。この方法や、位置引数ではなく名前付き引数で意味を示す他のアプローチは、「多数のパラメータを持ち、複雑なデフォルト値や引数順序を持つAPI」を定義するソリューションとして現在最も広く使われているようです。

それ以外の場合(JavaのServerSocketなど)は、同一メソッドに対して複数の定義を持てる可能性があります。「1個のメソッドに多数のオプション引数を持たせる」のではなく、単に「実際に渡された引数(シグネチャ)に基づいてメソッドを選ぶ」形で複数のメソッド定義を持ちます。

私が最後に興味を惹かれたのは、「"添字付き代入"演算子の再定義」を他の言語ではどう処理するかです。Pythonの場合は、単にキー全体を1個のタプルに集めることで、デフォルト値の問題やアンパックの問題を取り除いています(あるいは、メソッド内のif len(key) ...のような命令型ロジックに舞台を移すなど)。

# Python
class Multidimensional:
  def __setitem__(self, key, value):
    print(f'{key=}, {value=}')

m = Multidimensional()

m[1, 2, 3, 4] = 5
# key=(1, 2, 3, 4), value=5

しかしKotlinの場合は、実際に「トリック」を実行します。

// Kotlin
class Multidimensional {
  operator fun set(i1: Int, i2: Int = 0, v: String) {
    println("set[$i1, $i2] = $v")
  }
}

fun main() {
  var m = Multidimensional();
  // これは動く、しかもvの名前を明示的に書く必要がない!
  m[1] = "foo";      // set[1, 0] = "foo"
}

🔗 それで?

今回取り上げた構文機能は、uselessシンタックスシュガーシリーズの記事とは異なり、新しくもなければ圧倒的に表現力が豊かでもありません。私はこの構文機能には使い道があると思います(今後数か月のうちに、自分が書いたコードを再考して、この構文機能が適した場所がどこなのかを理解するつもりです)。また、アンパックされた引数の後ろに引数を1個置くことを許すという、より大きな機能には、確実に何らかの使い道があります。

言語設計がどのようにして生まれ、そこでどんな選択が行われたのか、そして他の言語とどんな点が異なっているか。そして、当時行われた選択をずっと後の時代になって解明する作業は、ある意味でミステリー小説の探偵じみているかもしれません。私は本記事で、そうしたことを示そうとしたのです。

私の記事を楽しくお読みいただければ幸いです。


お読みいただきありがとうございます。ウクライナへの軍事および人道支援のための寄付およびロビー活動による支援をお願いいたします。このリンクから、総合的な情報源および寄付を受け付けている国や民間基金への多数のリンクを参照いただけます。

すべてに参加するお時間が取れない場合は、Come Back Aliveへの寄付が常に良い選択となります。

本記事(あるいは過去の私の仕事)が有用だと思えたら、Buy Me A Coffeeサイトにある私のアカウントまでお心づけをお願いします。戦争が終わるまでの間、ここへのお支払いは(可能な場合)私や私の戦友たちが必要とする装備または上述のいずれかの基金に100%充てられます。

関連記事

Ruby研究シリーズ1: メソッド定義構文はどう決定され、どう進化したか(翻訳)

Ruby: "uselessシンタックスシュガー"シリーズ記事のあらましと予告(翻訳)


  1. Ruby 2.0より前のバージョン履歴は非常に複雑です。当時広く使われていたのは1.6であり、その次に広く使われていたのは1.8でした(なお、1.7などのマイナーバージョンは実験的とみなされていました)が、その後の「1.8のパッチバージョン」で多くの新機能が導入され、さらに次の1.9はRuby 2.0に備えていくつもの大飛躍が行われました。1.9の最初の安定版リリースは1.9.1で、続く1.9.2と1.9.3でも多数の変更が行われました。2.0以降のバージョン番号は予測が効く形で調整されました(注目すべき変更時にはマイナー番号が変更される、毎年クリスマスにリリースされる)。 
  2. つまり位置引数(positional arguments)のことです。しかし私がこれまで学んできたプログラミング言語では、すべての引数が位置引数でした。 

CONTACT

TechRachoでは、パートナーシップをご検討いただける方からの
ご連絡をお待ちしております。ぜひお気軽にご意見・ご相談ください。