RBSを書き始めて思うこと

型に不寛容なメソッドを作るようになってしまいそう

最近、小さなスクリプトを書く際に試しにRBSを書いて、VS Codium(Visual Studio Codeからプロプライエタリーな部分を除いた物)にSteepプラグインを入れて、エディターの支援を受けながら進めるようにしてみている。それで気付いた組み込みクラスなどのRBSシグニチャーの不足があればプルリクエストも送っている。

しばらく書いてきていて思ったのだけど、型に不寛容なメソッドを書くようになってしまいそう。みんながこれに慣れると、文化が変わりそう(これは当たり前だが)。

まず型について、Rubyでは伝統的に「どのメソッドに反応するか」が型とされてきた。数値を扱う work_with_integer_like というメソッドがあったとして、引数の型は「 #to_iに反応するオブジェクト」といったように考えた。そして、メソッドの最初の方で実際に #to_i を呼んで Integer にしてから処理する。

RBSでもこれは表現できて、

interface _ToI
  def to_i: () -> Integer
end

def work_with_integer_like: (_ToI) -> void

と定義できる。この _ToI みたいによく使われる型は、RBS組み込みで定義されている( interface はRubyの言語構造としては存在しなくて、RBSの物)。

勿論、RBSでは「どのクラスのインスタンスか」という型の表現方法も使える。

def work_with_integer: (Integer) -> Integer

こちらは interface を定義する必要は無くて、クラスを書くだけ。こちらの方が書くのが簡単なので、RBSが広まると、実装に跳ね返って、こちらの方のメソッド定義が増えるのではないかと思っている。

多分「『どのメソッドに反応するか』で型を語る」という(ダックタイピングの)文化と関わってはいるけど、それだけではないまた別の文化として、「引数の型に寛容」ということがあると思っている。ファイルを扱うメソッドがあって、引数でそのファイルを受け取る際に、ファイルパスを表す文字列やオブジェクトでも、開いたファイルオブジェクトでもどちらでも受け取るメソッドは多い。

def work_with_file: (String | Pathname | File | IO | StringIO) -> String

そして、メソッドの最初の方でパス( String | Pathname )かオープン済みのファイル( File | IO | StringIO )かを判定して、少し処理を分岐させたりする。

余談だけど、パスを表す型 path がRBS組み込みで提供されているのでそれを使える。

interface _ToPath
  def to_path: () -> String
end
type string = String | _ToStr
type path = string | _ToPath

def work_with_file: (path | File | IO | StringIO) -> String

また、 FileIO の子クラスなので、チェックのみを目的とするならまとめてもよい。

def work_with_file: (path | IO | StringIO) -> String

多くの場合、ファイルか(ネットワークなどの)IOかは気にせず、 #read#write に反応さえしてくれればいいということも多いだろう。これにも _Reader_Writer が提供されている。

interface _Reader
  def read: (?int? length, ?string outbuf) -> String?
end

def work_with_file: (path | _Reader) -> String

さて、TypeScriptやRustを使うようになってから思うのだけど、僕がこれらの言語を書く時には、メソッドを定義する時にまずシグニチャー(メソッド名と引数と戻り値)を書き、それから処理の中身を書く。何を当たり前のことを言っているんだ、と思うかも知れないけど、Rubyの場合はちょっと違う。まずメソッド名と主要な引数を書き、処理を書き、書きながら引数の型を調整する、みたいなことをしている自分に気付く。「書きながら引数の方を調整」を、これまでは自分の意識の中で起こっていたからあまり気にしていなかったけど、RBSを書くようになってはっきり自覚した。

# この時点では何となくファイルパスを受け取ることを考えている
def work_with_file(file)
  content = File.read(file)
  processed = process(content)
  processed
end

# ファイルを開いた状態で呼べるようにもできたらいいかなと思い始める
def work_with_file(file)
  io = if file.respond_to?(:read)
         file
       elsif file.respond_to?(:to_path)
         File.open(file.to_path)
       else
         File.open(file.to_s) # 最終的に#to_sを呼ぶので文字列じゃなくても#to_sに反応するなら引数にできる
       end
  content = io.read
  processed = process(content)
  processed
end

「考えてから書こう」ではなく「書きながら考えよう」という態度と、「引数の型に寛容であろう」という意識があるから、こういうちょっとした調整をしながらメソッドを実装していく。でも、これをRBSにも反映させながらやるのは、結構めんどい。

めんどいとどうなるか。やらなくなる。もうこのメソッドの引数はパスだけでいいや、となる。メソッドを書く前に

def work_with_file: (path) -> String

と決めてから書き始める。

メソッドの引数を適切な形にするのは、呼び出し側の責務か、呼ばれた側の責務か、というのは昔からある議論だと思うけど、Rubyは呼ばれた側が適切に変換する責務を負うことが多い文化だと思う。けど、それが変わるのではないか。

そして、このことの結果として、ライブラリーなどのユーザー側が、事前に適切な型に(ここではFileからパスに変換)した上でメソッドに渡すという面倒を引き受け……るのではない。いや、そうなるだろうとは思うけど、ユーザー側はこれを面倒だとは思わないと思う。VS Codiumとかで書いていて、メソッドを呼ぶ出す時に引数の方を調べて、適切に変換して渡す、というのが当たり前になって無意識にやるだろう。「はっきり型を調べたわけじゃないけどそれっぽいオブジェクトを渡したら何か動いた」みたいなRubyちょい感動あるあるは無くなる。

そしてどんどん、メソッド定義の際には引数の型は一種類だけ選ぶようになる。 interface 定義の手間があることを考えると、ダックタイピングではなくクラスで型を表現することが増えていくだろうと思う。

RBS(や他の型表現)が広まるかどうかは全然分からない。けど広まった場合はこうなるんだろうなあ、そしておっさんとしては文化が変わることにちょっとした寂しさを覚えるのであった。