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

Rubyの型アノテーションの現状についていくつか思うこと(翻訳)

概要

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

参考: 週刊Railsウォッチ20230531: Rubyの型アノテーションの現状についていくつか思うこと

Rubyの型アノテーションの現状についていくつか思うこと(翻訳)

...を軍の訓練施設で書いているうちに、ついつい5,000ワードになってしまった。

今これをスマホで書いている、それもウクライナ軍の訓練施設にある、200人を越える戦友たちと過ごしている兵舎で。もっぱら訓練と訓練の合間の短い時間(たいてい夜と日曜だが)をこの作業に当てている。
率直に言うと、軍に入隊して以来、まさかRubyについて何か書く時間やインスピレーションを得られるとは思ってもみなかったが、今ここでこうして書いている。

最近、Redditの/r/rubyで興味深い長大な議論を見かけました。議論の冒頭にある以下の記事↓は、Rubyの型アノテーションツールであるSorbetについて、というかそれを使おうとするのをやめることにした理由を述べています。

議論はたちまち「そもそも型付けとは」「そもそも型アノテーションとは」「そもそも動的・静的言語とは」といった一般的な話に広がりました。

この議論を見ているうちに、私が執筆中の某Ruby本(なお出版は勝利の日までおあずけです)で、Rubyの型アノテーション周りの状況について私の考えをいろいろ書いていたことを思い起こし、その中のいくつかを共有したくてたまらなくなりました。

以下の文章は、Rubyistのみならず、プログラミング言語の設計に熱中している人々にとっても興味をそそられる内容かもしれません(し、そうでもないかもしれません)。ここでは主に、設計上のいくつかの決定、そう決定せざるをえなかった理由、そしてその結果どうなったかについて述べます。

本記事は、執筆したときの事情により、厳密な定義よりも「素人的な」定義を優先しているため、やや正確性を欠いたものになっています。また、歴史的な記述もあまり正確ではないでしょう。ここでは、残されているドキュメントや議論のログと照合することをあまりせずに、Rubyの設計における決定や議論について自分が思い出すままに書き綴っています。

それでも、本記事が皆さんにとって興味深いトピックになることを願っています。

🔗 「こうだったかもしれない」機能

数年前のRuby 3リリースを目前に控えた頃、多くの期待がRubyに寄せられていました。主な期待は、後方互換性を完全に維持しながら「Ruby 2.0と比較して3倍増しのパフォーマンス」(いわゆるRuby 3x3イニシアティブ)を得るというものでした。しかしまた、「巨大数(big digit)」のリリースによって、新しいクールな機能が求められていることも明らかでした。

現代の高級言語が提供すべき基本機能は、ここ10年で著しく変動しています。従来は学術的・実験的なものとされていた多くの要素がメインストリームでも注目されるようになってきたのです。たとえば「ファーストクラスlambdaやクロージャ」「代数型」「パターンマッチング」「アクターによるコンカレンシー」などがそうです。

動的言語における型アノテーションもまた、少なくともこのコミュニティの一部では、上のリストに該当するとみなされたようです。

後者の型アノテーションが健全なアイデアかどうかについては、もう少し後で書くことにします。さしあたって触れておきたいのは「"型アノテーションを何とかしなきゃ"というプレッシャー」と「Rubyにおける実に素晴らしく心優しき終身独裁者1であるYukihiro MatsumotoことMatzが、型アノテーションというアイデアをまったく好んでいない」という2点です。
次期Ruby 3.0についてMatzが何度か行ったプレゼンをかすかに覚えています。いつものMatzと同様の控えめな口調でしたが、この問題について「そうした要望があるのはわかるが、個人的にどうも好きになれない」とコメントし、アノテーションが基本的に冗長かつ「DRYではない」ことに懸念を表明していました。

その時点で言語内の具体的な型アノテーション構文に関する提案が議論されたという記憶はないのですが、既にRubyコミュニティはDSLやツールを用いて型アノテーションを実現するための実験を進めていました。ちなみに、こうした進め方はRubyの人たちにとってごく普通のことで、Rubyに備わっている機能の多くは、こうした実験やDSLやプロトタイプとして誕生したのです。

まず思い出されるのは、RubyKaigi 2017で盛んに議論されていたSoutaro MatsumotoによるSteepです。Steepのアイデアは、Rubyらしい(ただしRubyそのものではない)言語を用いる別ファイルで型アノテーションを行うというものでした。

String#splitメソッドで考えてみましょう。このメソッドは「セパレータ(文字列または正規表現)」とオプションの「分割数の上限値」という2つのパラメータを受け取ります。

class String
  def split(separator, limit = nil)
    # ...実装...

...このメソッドに対応するSteepの宣言は以下のような感じになります。

# .rbsという別ファイルに記述する
class String
  def split : (String, ?Integer) -> Array<String>
            | (Regexp, ?Integer) -> Array<String>

もうひとつの注目すべきツールは、その1年後ぐらいにStripeがリリースしたSorbetです。Sorbetを支えているのは、「コード内DSL」、すなわちメソッド定義に併記することが期待される有効なRubyステートメントです。

class String
  extend T::Sig # このクラスに`sig`メソッドを追加する

  sig { params(separator: T.any(String, Regexp), limit: T.nilable(Integer)).returns(T::Array[String]) }
  def string(separator, limit = nil)
    # ...実装

Ruby以外の開発者向けの解説: 上のコードはsigというメソッドを呼び出しているだけです。このsigメソッドはコードブロック(これは他の言語におけるlambdaに似ています)を受け取ります。このコードブロックの内部ではparamsメソッドが呼び出され、そのメソッドにはシグネチャが渡されています。つまりこの構文はすべてRubyとして有効であり、動作するためのトリックを追加する必要はありません。

現時点で言うと、Sorbetの構文は、(私の目には)言語内型アノテーションがどのような課題を解決する必要があるかという興味深い実験に思えます。また、(これも私の目にはですが!)この機能は言語の構文でネイティブサポートすべきであるという証明であるように思えます。DSLバージョンはそこそこ読みやすいのですが、まだこなれておらず、冗長です。

Ruby 3.0より前のこうした実験によって、設計としての側面が大きく前面に出て来ました。そして私もご多分に漏れず、これが実現(=言語の構文と一体化)したときにどんな姿になるかを想像して心が躍ったものです。

🔗 ...しかし、そうはならなかった

2020年のクリスマスにRuby 3がついにリリースされ、パターンマッチング2など、多くの機能が盛り込まれました。これらの強力さと設計概念の明晰さは、今後いくつかバージョンを重ねれば明らかになるでしょう。Ruby 3x3で約束されたパフォーマンスについてもどうにか達成できた感じですが、その測定方法については相反する意見がいくつか見られます。

型アノテーションについては、コミュニティからのプレッシャーと、Matzの型アノテーションに対する消極的な姿勢との妥協の産物が、RBSでした。これについて公開の場でどんな議論がなされたのかあまり思い出せないのですが、RBSはRuby風の一種の型アノテーション用言語で、.rbsという別ファイルに保存する形になりました。Steepが先行していたアイデアや構文が公式のものになったのです。

初期には型分析、型チェック、型推論を導入するためのTypeProfツールのエコシステムができ、やがてコミュニティがサポートする形で標準ライブラリおよびサードパーティライブラリ向けの型サポート用gem_rbs_collectionリポジトリも立ち上がりました。しかし最終的に、型アノテーションは"Rubyツール"の一部になったものの、Rubyという言語(および思考の表現手段としてのRuby)とは一体化しませんでした。

それと同時に、SorbetのDSLやツールセットの作者たちは、RBSへの置き換えではなく、SorbetのDSLやツールセットの開発を継続することを決定しました。その結果、Sorbetは今ではRBIという独自の型アノテーション用ファイル形式(コードにアノテーションを含めないサードパーティライブラリを伴います)を採用し、これはSorbetが用いているコード内DSLと同じです。すなわち、RBSと違ってRBIは有効なRubyコードです。また、多くの人気gemを対象にしたSorbet互換の型定義ファイル用リポジトリもできました。

今日に至るまで、ツール群やRuby型アノテーションに関する知識は豊富になり、開発も活発になっていますが、言語そのものからは切り離されています。

私はこうしたツール群の包括的な概要や、それらのツールがどのようにコラボしたり競合したりするかについては説明しないつもりです。しょうもない理由なのですが、私は"ツールとしての型"には積極的な興味をそそられないのです。


私の本業(もちろん軍に入隊する前の本業です)では、半ば冗談のような「チーフコードエディタ」という役職に付いており(まるで雑誌の編集長: chief magazine editorみたいな肩書ですが)、コミュニケーション、コードレビュー、ガイド執筆、ツール周りなどを通じて、製品の巨大なコードベースの読みやすさとメンテナンス性を維持する責任を負っています。

私の主なツールは、コードを文書として扱うことと、言語的直感に沿って個別のメソッドやクラスにおける小さなストーリーを壊さないよう維持すると同時に、システムの大きな構造についての大きなストーリーをも明瞭にすることです。すなわち、意図を可能な限り明確かつ直接的に表現することです。

私のOSS活動やブログ執筆でももっぱら同じコンセプト、すなわちRubyの直感的かつ明晰なコードに注力しています。私は、本業から切り出したいくつかの小さな実用gemの開発に関わっています。

また、オープンデータアクセス用ライブラリや、コードの移植や、著作にも注力しています。

そのどれについても、言語を用いてどこまで可能な限り明快な表現に到達できるかが私の主な取り組みである点は共通しています。

その観点からすると、Rubyと一体化していない型アノテーション言語には興味をそそられません(Ruby DSLとして実装されたSorbetも、独立したマイクロ言語です)。
アノテーションやその他の宣言の構文が、さまざまな洞察に富んだ詩的な韻を織りなして、日々扱っている言語の状況を別の角度で考えさせてくれるわけでもなければ、普段コードで使っているフレーズやイディオムが強力になるわけでもありません。

もちろん、Sorbetのようなツールが実用目的であることも、そうしたツールが目指している最終目標が、私の「明快なコード」アプローチと同じ「長期的なメンテナンス性」であることも完全に承知しています。ただ、そこに至る道筋が違うだけなのです。

しかしそれでも、型アノテーションを言語の本質的な一部としてうまく一体化する方法が、その片鱗だけでも見つかっていればよかったのに、という残念な気持ちを抑えきれません。

「型アノテーションには興味を惹かれるけど、SorbetもRBSも今ひとつしっくりこない」という思いは、かなり広く見受けられるように思えます。そう思う理由は人それぞれかもしれません(Redditで議論のきっかけになった元記事では3つのまったく異なる理由が挙げられていました)が、この疑問は共通しているように見受けられます。

しかしながら...

🔗 型アノテーションは本当に必要だったのか?

Rubyコミュニティで型付けや型アノテーションに関する議論が持ち上がるたびに「thisみたいなものが欲しければ他の言語にすれば?」的なコメントをよく見かけます(率直に言うと、パターンマッチングのように最終的にRubyにうまく統合された新機能でも同じような議論が持ち上がりがちでした)。

そうした意見は多くの場合、「これは神聖にして不可侵なパラダイムである」と断定口調になるか、さもなければ「やろうと思えばできるけど、(明晰さやシンプルさが)損なわれる害の方がどんなメリットよりも大きいのではないか」といった形を取ります。

果たしてそうなのでしょうか?

私は、これまでプログラミング言語における「意味の表現方法」や表現手段の設計空間について考えることに多くの時間を費やした人間として、Rustのように輝く新しい言語から、(Rubyの)積年のライバルであるPythonに至るまで、多くの現代的なプログラミング言語の開発状況や設計を追いかけてきました。

後者であるPythonは数年前に型アノテーション構文を取り入れ、そこから多くの概念がうまく育って実を結んだようです。

私は例の「スペルチェッカー再構築」プロジェクトでPython流の方法に触れたことがあり、そのときの開発体験は実に快適でした。

そのときはこのような感じでした。

まず、そのスペルチェッカー(商用レベルのHunspellの移植版で、複雑なアルゴリズムのセットを備えていました)のドラフト版を完全に動的な方法で「ほぼ動く」状態にこぎつけました。

次に、隅々まで磨きをかけました。特殊なケースに対応し、何がどれに依存しているかを明確にし(移植の動機はアルゴリズムを明快に示すことでした)、フローを合理化し...といった具合です。

その作業中、ぽつりぽつりと「自分用のメモ」として型アノテーションを書いておけば、システムを形式的にバリデーションできる、という体験をしたことで、ツールの素晴らしさを実感できました。型アノテーションを書いたコードのほとんどが「明快さ」を増し、無駄な記述が増えることもありませんでした。


実は、Rubyには型アノテーションのふりをした不完全なシステムがいくつもあります。

# @param separator [String, Regexp]
# @param limit [Integer]
def split(separator, limit = nil)
  • Grapeは、(他の多くのAPIファーストWebフレームワークと同様に)HTTP API定義の一部として型を宣言できます
params do
  requires :id, type: Integer
  optional :text, type: String, regexp: /\A[a-z]+\z/
end
put ':id' do
  # ...
  • GraphQLをサポートするRubyライブラリや、その他の多くのスキーマベースプロトコルでは、Ruby DSLのスキーマ部で型を表現できます

  • 呼び出し可能オブジェクト(callable object)で宣言的な型付き引数を使うパターン(Interactor、Actions、Command、または単なるService Objectパターン)は多くのコードベースで使われています。主に使われている宣言方法は、以下のようなdry-types gemによるものです。

class CreateUser < ApplicationObject
  parameters do
    required(:name).filled(:string)
    required(:email).filled(Types::Email)
    required(:age).filled(:integer)
    optional(:phone).maybe(:string)
  end
  # ...

引用元

  • Active Recordのバリデーションすら、numericalityバリデーションなどのアドホックな型システムとみなせます。
  • Rubyのコアドキュメントにも、メソッドのパラメータや戻り値の型を記述する非形式的な合意が広く見られます。

この調子でまだまだ続けられますが、少なくとも「この値はXX型である」という情報を明示する標準的な方法が存在する方がよい場合があることは、既に部分的に証明されたように思えます。

現代のPythonでは、こうしたケースをすべて(しかもさらに多くのケースを)十分深く統合された標準的な方法でカバーできます。Pythonのドキュメント生成、スキーマバリデーションHTTP APIシグネチャの推論CLI呼び出しの生成では、「型をどんな形で指定するか」という問題は既に解決されているのです

いくつかの例を見ていると、確実にうらやましい気持ちになります。特定のライブラリでの限定的な解決ではなく、文字通り「解決済みである」という手応えがうらやましくてたまりません。

最終的な型チェック機能は、「Pythonほど高度な使い方をしなくても」非常に有用なものになるでしょう。

さて、私はMatzの「(型チェックは)DRYではない」という気持ちに深く共感するものであります。

ドキュメントでYARDの型記法を利用しているコードベースでは、以下のような実に無駄な記述を嫌になるほど見かけます。

# @param organization [Organization] Organization to process
def process_organization(organization)

私たち開発者は、コード内で同じことが何度も繰り返し書かれることを嫌います。そうした繰り返しは読むときも邪魔ですし、しまいには書かれている内容まで疑わしくなり、繰り返しの内容を完全に理解して明確になるまで読み返すはめになるのですから、嫌うのは当然です。

こうした繰り返しはしばしば定型文(boilerplate)と呼ばれます(重症になると、こうした定型文をすべて消し去りたい衝動にかられ、「短く簡潔に書けるけど、どこから来たのかわかりにくい」なぞなぞのようなコード片を量産してしまうこともあります)。

しかしどれほど明確に定義されたAPIであっても、以下のような疑問の余地が残されているものであり、そこにもう少し記法を追加すれば幸せになれるのです。

  • パラメータや戻り値はnullable(nilを許容する)か?
  • この引数名は複数形だが、配列やDBスコープは受け取れるのか?どんなものなら渡せるのか?
  • サービスメソッドにclientという引数があるが、これはClientモデルか?Stripe::Clientか?それともHTTPクライアントのことか?

そうしたものを宣言する(理想的には自動チェックできる)手段が欲しいときはいくらでもあります。

そういうわけで、いつしか私はこう信じるようになりました。言語を「思考を表現するツール」とみなした場合、型アノテーションは「あらゆる」言語で表現力を大きく高めるツールになりうる。ただしそれは、言語そのものと自然な形で一体化した場合に限られる、と。

型アノテーションが利用可能になったときにIDEの可能性が広がるという話題については、ここでは触れません。理由は、第1に説明するまでもなく自明であること、第2に、RBSやSorbetなどの「言語外部の」型アノテーションでもサポート可能だからです(そしてきっとそうなります)。

🔗 どんな方法なら可能か?

Ruby言語の開発プロセスは、他の言語とかなり異なっています。

このプロセスがどのように機能するかについては、昨年以下のシリーズ記事で説明しました(これらの記事では、開発プロセスに参加しようとする人の個人的な思い入れについても述べています)。

Ruby言語の開発プロセスの主な特徴は、きわめて非形式的であることです。PythonのPEPやRustのRFC的なものはなく、bug/discussion trackerがあるだけです。この非形式的なプロセスでは、言語のコアに影響するあらゆる事柄に関して、Matzのセンスと直感が頼りです。

全般に、コードの書き味に直結するコアのメソッドやクラスの追加変更よりも、たとえばIO::Bufferのような特殊なクラスの追加や内部構造の変更の方が(JITやGCの変更のように相当大規模であっても)スムーズに進む傾向があります。

言うまでもありませんが、型アノテーションの追加のような目に見えて巨大な変更は、それを真剣に検討してみるだけでも、人並み外れた説得力とクリエイティブな構文が要求されます。

解決が必要なこの課題がいかに巨大であるかを深く理解するために、Rubyに「自然な」型アノテーション記法を導入するために解決すべき設計空間と問題点について概説してみようと思います(ここでは仮に、Matzの「いかなる種類の型アノテーションも追加するつもりはない」という発言がなかったとします)。

この記事を読んでいるあなたが他の言語のプログラマーであれば、自分たちの言語ならこの問題にどのように対処するだろうかと考えてみることをおすすめします。言語開発者が設計でどのようにして決定を下すかについて興味深い洞察を得られるかもしれません。


第1に、そして最も重要なのは、メソッド定義構文に型アノテーションを導入する方法が必要だということです(ローカル変数の定義など他にも必要な場所はいろいろ考えられますが、メンタルトレーニングをこれ以上複雑にしないでおきましょう)。

多くの言語では、name: TypeType nameのどちらかの記法を採用しています。前者のname: Typeは、どちらかというと設計要素がRubyに似ている言語で普及しています。
しかしRubyでは、残念ながら前者の構文はキーワード引数(名前付き引数)のデフォルト値で既に使われてしまっています。

# 以下はRubyのかなりシンプルなメソッド定義
class File
  def self.readlines(name, chomp: false)
    # ファイル名から行を読み込み、`chomp: true`なら改行文字を削除する
  end
end

# Usage:
File.readlines('README.txt')              #=> ["First line\n", "Second line\n", ...]
File.readlines('README.txt', chomp: true) #=> ["First line", "Second line", ...]

私の限られた知識によれば、Rubyのような形で名前付き引数を使う言語はかなりレアです。
Python、Kotlin、C#では、呼び出し時に引数名を指定するかどうかは呼び出し側が決めるので、メソッド定義でジレンマは生じません。たとえばPythonでは以下のようになります。

  # 同じメソッドが以下のように定義されている可能性がある
  def readlines(name, chomp=False):
    # ...

  # ...これは以下のように呼び出せる
  readlines('README.txt', chomp=True)
  # 以下の呼び出しも可能(引数に名前を付けるかどうかは呼び出し側が決める)
  readlines('README.txt', True)

  # つまり、Pythonではこの構文に型を追加したければ以下のように書くだけでよい
  def readlines(name: str, chomp: bool = False):
    # ...

TypeScriptの名前付き引数は、辞書のデストラクチャリング的な手法で模倣されるようになっており、型付けの問題は辞書全体で解決されます。

  function readlines(name, {chomp=false}) {
    // ...
  }
  // 呼び出し:
  readlines(name, {chomp: true})
  // 型を指定した場合
  function readlines(name, {chomp=false}: {chomp: boolean}) {
    // ...
  }

しかし私たちの選択肢は限られています。
Rubyで利用できそうなのは、どうやらType nameという記法のようです。

def readlines(String name, Boolean chomp: false)

正直に言うと、もし仮に私がこの構文を本当に提案しようとしたら、私の心が「ほとんどのRubyistたちにキモいと却下されそう」と叫びだすことでしょう。

それはさておき、次の問題に進むことにしましょう。

次に必要なのは、型構文そのものです。

Rubyでは「すべてはオブジェクトであり、(数値のようなプリミティブ値すら)独自のクラスを持つ」という言葉が知られているので、「ClassNameのように書けばよい」という単純明快な決定で済みそうに見えます。それとも...違うのでしょうか?

多くの言語の型構文では、以下の定義も可能です。

  • union型
    • 例: StringまたはRegexp
  • nullable型
    • Optional(String)String | NilClassより簡潔なString?ショートハンド記法
  • アドホック型
    • 限定された値セットで構成される
    • 例: role: ["admin", "manager", "user"];
  • パラメータ型(parametrized type) /ジェネリクス
    • 例:「文字列の配列」
    • 例:「文字列1個と数値1個だけを含む配列」(Rubyには独立したヘテロジニアスなタプル型がないので配列を代わりに使う)

「Rubyのクラスで表現できないなら、どれも不要だ」という考え方もあるでしょう。しかし型アノテーションで使えるものがRubyに元からある「自然な」クラスだけに限定されると、型推論や型チェックの能力が著しく制限されることになります。「array.firstは、配列の要素として可能な型の要素を1個返すか、nilを返す」という比較的シンプルな概念すら、表現もバリデーションもできなくなります。

その他にも、Rubyの方法に特有の厄介な小問題がたくさんあります。たとえばブーリアン値ひとつとってみても、問題が少なくとも2つ増えます。

  1. Rubyのtruefalseは、それぞれTrueClassクラスとFalseClassクラスに属しており、共通のベースクラスがありません。今さらBooleanクラスを導入すべき(言語の変更範囲をさらに広げるべき)なのでしょうか?

  2. 他の動的言語と異なり、Rubyではfalsenilだけが"falsy"な値とみなされています。これが極めて有用であることが知られてきたので、ブーリアンではないがブーリアン的な値を受け渡しするケースがたくさんあります(例: trueの代わりにnilでもfalseでもない値を渡すことはまったく問題ありません)。より厳密な型付けのためにこれを諦めるとなると、後方互換性のない思想的変更が生じてしまいます。後方互換性を維持するには、foo: Booleanishのような追加の型宣言が必要になるかもしれません。

そして忘れてはならないのはダックタイピングです。ダックタイピングとは、「引数がそれらのメソッドを持っている限り、引数がどんなクラスであるかをこのメソッドでは気にしない」というものです。これを解決する方法としては、「インターフェイス」か「プロトコル」を宣言するのが一般的です。

RBSではこの方法を使っています(Sorbetはこの方法のサポートを継続的拒否していますが)。

たとえば、「File.openメソッドは、文字列に変換可能なものは何でもファイル名として受け取れる」という記述があるとしましょう。これをRBSで表現すると以下のようになるはずです。

interface _Stringable
  def to_str(): () -> String
end

def open(name: _Stringable)

これや、これと似たプロトコル(引数が1個のみを期待する、または場合によっては2〜3個を期待する)はRubyでは非常に広く用いられているので、そこにまったく別の「インターフェイス」宣言を導入して解決しようとすると、Rubyistの直感から見て極めて冗長に感じられます。

YARDの型システムは、以下のように「メソッドに応答するものなら何でもよい」という宣言を許すことで、この事実を認めています。

# @param name [#to_str]
def open(name)

ここに挙げた問題はすべて解決可能であり、ある程度までならRBSやSorbetで解決可能です。私は単に、徹底的に現実性をチェックしようとしているだけです。

ここまでお読みの方に次の問題を考えていただきたいと思います。これらの型付け文は、独立した式として意味を持つのでしょうか?それともメソッド定義などのような特定の場所でしか意味を持たないのでしょうか?

ズバリ、以下は有効でしょうか?

# 何らかの架空の構文で定義された
# 「文字列またはnullable整数の配列」が変数に入る
my_type_variable = Array[String] | Integer?

この問題は非常に重要です。上のコードを許すと、型で利用可能な構文が極端に制約されてしまいます。だからこそ、この構文がこれまで一度も使われてこなかったのです。
ジェネリクスの問題だけを取り上げても、以下の書き方が使えなくなってしまいます

  • Array[String]: 既に有効な構文、Array.[]クラスメソッドを呼び出す。
  • Array<String>: 既に有効な構文、(Array < String) > ...コードがない...として扱われる(サブクラスやスーパークラス用のクラス比較演算子が存在する
  • Array(String): Array()メソッドに引数Stringを渡したものとして扱われる
  • Array{String}: Array()Stringを返すだけのブロック引数を渡したものとして扱われる

ではどうすればいいでしょう?(候補としてはおそらくArray<String>がやはりベストだと思いますが、パーサーが複雑になり、ジェネリクスの型定義と区別がつくようにスペースや語彙素(lexeme)の順序に依存することになります。しかしパーサーが複雑になると、おそらくですが他にも困ったことが起きるでしょう

しかもこれは、優秀な型システムに不可欠な要素のひとつに過ぎません!

この後のボーナスセクションで後述しますが、実はRubyでパターンマッチングを導入したときにはArray[something]という構文を使いました。
しかしこれが可能になったのは、パターンマッチング固有の構文要素がパターンマッチ文内でしか利用できないようになっていて意味が曖昧にならないからこそです(意味が「異なる」Array[something]は、その中では無効です)。よい機会なのでここで触れておきたいと思います。

しかし型の式が独立していなければならない理由とは何でしょうか?それは「メタプログラミング」のためです!

メタプログラミングは、この文脈で極めて重要になります。

Rubyでは、メタプログラミングのための明確なツールは(PythonやJavaScriptに比べて)それほど多くはありません。

たとえば、Rubyオブジェクトには実は属性(attributes)というものがなく、あるのはメソッドだけです。ここがPythonやJavaScriptと大きく異なる点です。オブジェクトのあらゆるデータは、最終的にはprivateなのです。

つまり、以下のコードは

class User
  attr_accessor :name
  # ...

実際にはattr_accessorというメソッド:name引数を渡して呼び出しています。このメソッドはアクセサメソッドを2つ定義し、たとえば上のコードは以下と同等のコードを動的に生成します。

class User
  def name
    @name # @<識別子>はRubyでインスタンス変数名(常にprivate)を表す
  end

  def name=(val)
    @name = val
  end
  # ...

別の例: Rubyの主要なフレームワークであるRails(および他の多くのライブラリ)では、以下のようなDSLがたくさん使われています。

class Organization < ActiveRecord::Base
  has_many :users

このhas_manyも同様に、いくつかのことを行うメソッドに過ぎません。Organizationクラスにusersメソッド(組織の全ユーザーを対象とするDBスコープを返す)やadd_user(u)メソッドなどを定義する作業もその中に含まれます。また、このメソッドは「Userモデルで表されるDBのusersテーブルと組織を、organization_id外部キーで関連付ける」といった多くの便利な推測も行ってくれます。

ここから、以下のような疑問点が浮上します(このリストはまったく網羅的ではありませんのであしからず)。

  • 型をその場で生成・派生するにはどうすればよいのか?
    この疑問が生じる理由は、型付けされたメソッドを使いたい場合、has_many :users:usersというシンボルからUser型の宣言へプログラム的に進む何らかの方法が必要になるからです(このことから上述の点が証明されます: すなわち型宣言は、その場で生成したり他のメソッドに渡したりできるファーストクラスの式でなければならないのです)。

  • そうしたDSLの中には、型宣言を含める必要が生じるものもあれば(attr_accessor name: Stringと表すか、何らかの他の方法で)、型を生成・派生するものもあります(上述のように)。
    Rubyの型システムは、そしてRubyの型システムを使うあらゆるツールは、本質的にどちらのケースもサポートすべきです

  • システム全体がうまく機能するために、言語設計で選択しなければならない「小さな革命」や再評価がいくつ必要になるのでしょうか?
    つまり、2つの小さな例のうちattr_accessorで言うなら、attr_accessorで昔から普及しているメソッドの再定義が既に必要になりますが、それをどうやって後方互換性を損なわずに行うのかについては、私には見当も付きません。

もちろん、言語で型付けをサポートする対象を限定して、それ以外は諦めるという選択肢も残されています。Sorbetはそういう選択を行うことが多く、(私が理解する範囲においては)SorbetのメンテナーであるStripeは既にRubyのサブセットのみを使うというポリシーを打ち立てています。

これは明らかに、言語のコア機能が自ら容認できるものではありません。

「これは型付け可能」「それは型付けできない」という手法は、おそらくコミュニティでこれまでにない深刻な分裂を引き起こす可能性があります。「型アノテーションしたコードを好み、その結果、最も表現力が高い動的な機能を敬遠する」派閥と、それ以外の派閥に分断されてしまうかもしれません。

正直なところ、SorbetとそれをサポートするRubyの重鎮企業によって、そうした事態が今この時期に起きるかもしれないという恐れを少しばかり抱いています。あるいは、将来「我々には型が必要だ、そのために必要なら何でも犠牲にしよう」というマインドセットに基づいた試みで起きるかもしれず、そうなるとRubyが本筋から外れてしまうかもしれません。


このメンタルトレーニングを終えてどんな教訓を得たでしょうか?

率直に言うと、私はこのメンタルトレーニングを読者とともに行おうと努めました。本記事のドラフトを書き始めたときから、Rubyで可能な型付け構文について何かまとまったアイデアを思い付けるのではないかと期待していました。

しかし私の試みによって言語設計の複雑さのレベルの理解が進んだ結果、実現が極めて難しくなってしまいました(私はこの界隈で最も賢い人物からほど遠いことは明白なので、実現"不可能"とまでは言いませんが)。

余談のその後について: Ruby 3.0の開発中には、この点は公の場で深く検討されていませんでしたが(私はこのトピックに関してコアチーム内部でどんな議論があったかを知りません)、"正しい"型アノテーションが導入されなかったことには、どうやら(Redditの一部のコメントで指摘していたような)「単に誰も考えていなかった」説よりもほんの少し深いわけがありそうな気がしています。

🔗 ボーナス: マルチプルディスパッチとパターンマッチング

ここまであえて触れないようにしていましたが、型宣言の可能な構文に関する疑問のひとつは、型によるメソッドのオーバーロードは可能だろうかというものです。Rubyには、引数の個数や型に応じて挙動を変えるメソッドが(特にコアクラスに)たくさんあります。
たとえば、Array#[]では以下のプロトコルがサポートされています。

ary = [1, 2, 3, 4, 5]
ary[0]          #=> 1          (整数の添字のみ)
ary[1, 3]       #=> [2, 3, 4]  (開始値、長さ)
ary[1..2]       #=> [2, 3]     (整数の範囲)
ary[(0..3) % 2] #=> [1, 3]     (0番目から3番目までの2番目ずつの要素)

型定義が初期設計の一部に組み込まれている言語であれば、以下のようにオーバーロードされたメソッドを定義するのが典型的なソリューションです。

# これは有効なRubyではない!
def [](index: Integer)
  # 実装1
end

def [](start: Integer, length: Integer)
  # 実装2
end

# ...などなど

ただしRustなど一部の言語は意識的にオーバーロードを避けていますが。

Rubyでそのようなメソッドを実装するには、以下のようにメソッド本体でifcaseを書くだけでできます。

def [](*args)
  if args.count == 1 && args.first.is_a?(Integer)
    # 添え字の場合の処理
  elsif args.count == 2
    # 開始値と長さの場合の処理
  # ...などなど
end

Ruby 3.0でパターンマッチングが導入されたおかげで、以下のような「パターンによるディスパッチ」が利用可能になりました。

def [](*args)
  case args
  in [Integer => index]
    # 添字なので`index`変数を処理する
  in [Integer => start, Integer => length]
    # ...
  in [Range => range]
    # ...

同じ構文を、シグネチャが1つしかないメソッドの引数バリデーションや引数の取り出しにも使えます。

def connect(db, options)
  options => Hash[user: {login:, password:}]
  # optionsの構造が正しければ、login変数とpassword変数を設定する
  # ...そうでない場合は渡された引数が誤っていることを「NoMatchingPattern」が通知する
end

ただしこの構文と手法は、型チェックとはまったく別物であることにご注意ください。少なくともコードを直接読む限りでは、[]メソッドの2つの定義(オーバーロードされる架空のものとパターンマッチングによる現実のもの)には大した違いはなく、どちらもメソッドで可能なシグネチャを明示するという点でかなり宣言的に見えます。

パターンマッチングに基づく定義は、完全な型システムを構築して上述の複雑な問題をすべて解決するとまではいかなくてもも、「現実に必要なもの」にさらに近づいているように見えるかもしれません。

しかし、「本文にパターンマッチを書く」手法は、メタプログラミングやドキュメント生成などにおけるメリットがまったくありません。メソッド本文に書いたものはあくまでメソッド本文どまりであり、しかもメソッド実行中しか(さもなければ、極めて高度な静的解析ツールを使わなければ)利用できません。

たとえば、Array#[]が上述のようにトップレベルのcase/inステートメントで定義されている場合は(実はCで実装されているのでそうではありませんが)、受け取り可能なシグネチャについてかなり情報が豊富かつ宣言的に見えるはずですが、実はリフレクションではそれらにアクセスできません。たとえば、[1, 2, 3].method(:[]).parametersからは、それが*argsを受け取るということしかわかりません。


ここから思いついた画期的(っぽい)アイデアは、パターンマッチング構文とメソッド定義構文を何らかの形で組み合わせれば、「古典的な」型アノテーションよりも多少なりとも読みやすくなるというものです。RubyのいとこにあたるElixirがそれっぽいことをやっています。

たとえば、上の例を以下のように書けるとしたらどうでしょう(仮に、例の「パターンによるディスパッチ」でメソッドを定義するdefpという新しいキーワードが使えるとします)。

defp []
in (Integer => index)
  # 実装1
in (Integer => start, Integer => lengths)
  # 実装2
in (Range => range)
  # 実装3
end
# 「DB接続」の例:
defp connect(db, options => Hash[user: {login:, password:}])
  # ...実装、アクセスのためにloginとpasswordを取り出す
end

そしてこれをファーストクラスのメタ情報として公開するとします。

[1, 2, 3].method(:[]).signatures
# => [#<Method::Signature (Integer index)>,
#     #<Method::Signature(Integer start, Integer length),
#     ....などなど
[1, 2, 3].method(:[]).signatures.first.parameters #=> [{name: :index, pattern: Integer}]

これは私の「Rubyの直感」を「ほぼ」いい感じにくすぐってくれます。近い将来に形になりそうではありませんが、かといって不可能というわけでもありません3。この方法では総合的な型システムを必要としませんが、首のひねり方次第で強みとも弱みとも受け取れるでしょう。

皆さんはどうお考えですか?

訳注

その後、元記事をヒントに以下のdefpキーワードが提案されています(現時点ではオープン)。


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

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

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

関連記事

Ruby: “今この時期に”プログラミング言語の進化に参加することについて(翻訳)

Ruby 3.2のData#initializeがキーワード引数も位置引数も渡せる設計になった理由(翻訳)

Ruby: パターンマッチングをカスタムオブジェクトで実装するときの注意点(翻訳)


  1. 訳注: BDFL(Benevolent Dictator For Life)-- 優しい終身の独裁者 - Wikipedia 
  2. 原注: 当初はRuby 2.7で実験的機能として導入されましたが、3.0になって大きく様変わりしました。 
  3. 原注: でもパターンがValue Objectでなくなるとまたしてもこの問題に立ち返ってしまうんですけどね 🙃 

CONTACT

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