型定義を書いてみよう


2023年 07月 06日

こんにちは、tk0miya です。

前回の準備編では、rbs.rake を導入して Ruby の型定義を自動生成し、Rake タスクの初回実行後のエラーを解決するところまでを紹介しました。
今回は実際に型定義を書いていく部分に進んでいきます。

そもそも、型定義とは?

型定義を書いていく前に、ちょっと手を止めて型とはなにかをおさらいしましょう。

まずは以下の Ruby プログラムを見てみましょう。

def sum(x, y)
  x + y
end

a = 1
b = 2
c = sum(a, b)

このプログラムを見て、あなたはどのように理解しましたか?
たとえば、a や b はどういうデータだと思いましたか?
sum 関数は何を返し、c にはどういう値が入るでしょうか。

Ruby プログラマの人は「a や b は Integer のデータが入っている」「sum は2つの数値の合計を返す」。そのため、「c にも Integer のデータが入る」と答えるのではないでしょうか。

これが型です。

型というのはあるデータがどういう種類のデータなのか、関数がどういう入出力なのかを表すものです。

別の言葉で言い換えると、

  • あるデータはどのクラスのインスタンスなのか
  • どういうクラスや、モジュールが存在するのか
  • それぞれのクラス、モジュールにはどういうメソッドが定義されているのか
  • 各メソッドはどういうデータの引数をいくつ取り、どういうデータを返すのか

を表現したものです。

そして、型定義はこの型情報を記述言語で表現したものです。
Ruby の型定義ファイルは RBS と呼ばれる言語で記述します。

RBS では、先ほどのプログラムの型は以下のように表現します。

def sum: (Integer x, Integer y) -> Integer

a: Integer
b: Integer
c: Integer

※ 通常、ローカル変数には型定義を書かないのですが、ここでは説明のために敢えて書きました。

RBS の詳しい文法はここでは紹介を割愛します。 RBS基礎文法最速マスター – pockestrap がシンプルにまとめられたよい記事ですので、こちらを参照してください。

実際に型を書いていこう

まえがきが長くなってしまいましたが、実際に型を書いていきましょう。

型を書くステップは以下のとおりです。

  1. 型をつけたいクラスやモジュールを選ぶ
  2. sig/prototype ディレクトリ以下の型定義を sig/handwritten ディレクトリにコピーする
  3. 型定義を書き換える
  4. rbs:subtract コマンドを呼び出して、型定義情報をアップデートする
  5. rbs:validate コマンドを呼び出して、型定義をチェックする

実は、これまでの準備段階で基本的な型定義はアプリケーションから自動抽出されています。

  • rbs collection コマンド
    • 利用している gem の型定義を収集
  • rbs_rails gem
    • DB スキーマやモデルの関連に基づく型定義を抽出
    • ルーティング情報に基づくヘルパーメソッドの型定義を抽出
  • rbs prototype rb コマンド
    • アプリケーションで定義されたモジュール、クラス、メソッドの構成 (メソッドの名前、引数などの情報)を抽出
      • ただし、メソッドの各引数の型や返り値の型は不明 (untyped) と定義されている

ですので、型を書いていく際には、ツールによって自動抽出された型定義に対して、不足情報を補っていくというアプローチを取ります。

先ほど挙げたステップは、 rbs prototype rb コマンドが生成した型定義をベースに加筆修正していくというものです。

1. 型をつけたいクラスやモジュールを選ぶ

さて、型をつけていこうというときに、どこから手を付けていいのか迷ってしまいますよね。

そういうときは、アプリケーションの中でも中心となるクラスに型をつけると効果的だと思います。多くの場所で使われるモデルやサービスクラス、Concern などに型をつけていくとよいでしょう。

なお、以前に紹介した katakata_irb はメソッドチェーンでの入力支援が魅力のひとつですよね。これを強化するために、メソッドチェーンの起点になるようなクラス、メソッドに対して型をつけるのは作戦のひとつです。

※ 今回の記事では紹介しませんが、型チェッカである Steep を LSP として利用する場合は、引数の型情報を入力支援に利用するため、余裕があれば引数の型も書いていけるとなおよいですね。

2. sig/prototype ディレクトリ以下の型定義を sig/handwritten ディレクトリにコピーする

rbs prototype rb コマンドで生成されたプロトタイプの型定義ファイルを sig/handwritten ディレクトリ以下にコピーします。

たとえば、 app/models/account.rb に対する型定義は sig/prototype/app/models/account.rbs に生成されているので、 sig/handwritten/app/models/account.rbs にコピーします。

$ mkdir -p sig/handwritten/app/models
$ cp sig/prototype/app/models/account.rbs sig/handwritten/app/models

プロトタイプの型定義ファイルを直接書き換えるようにしている方々もいるようですが、わたしたちは必要な部分にだけ型をつける (なるべく自動抽出に委ねる) というアプローチで型を導入しようとしているため、プロトタイプの型定義と手書きの型定義は分けて管理しています。

このアプローチでは、メソッドが増えた場合でも、半自動的にメソッドの型定義が抽出されるため、型を書くのを手抜きできます。

ちなみに、毎回このディレクトリを掘ったり、ファイルをコピーしたりというのがかなり手間であったため、VSCode 拡張として RBS Helper というものを用意しました。
VSCode のコマンドひとつで、現在開いている .rb ファイルに対する型定義ファイルを生成する (プロトタイプの型定義があればそれをコピーする) という優れものです (自画自賛)。

3. 型定義を書き換える

先ほどコピーした型定義ファイルを書き換えます。

rbs prototype rb コマンドが生成した型定義ファイルは、以下のようにメソッドが羅列されています。

class Account
  def admin?: () -> untyped
  def enjoy: (untyped sport) -> untyped
  def favorites: () -> untyped
end

rbs prototype rb コマンドはメソッドや引数の羅列には対応していますが、型は推測できないため、引数の型や返り値の型には untyped (型不明) がセットされています。

ここに対して、ソースコードを読みながら、実際の型に入れ替えていきましょう。

サンプルとして、それぞれのメソッドに型を書いてみました。

class Account
  def admin?: () -> bool
  def enjoy: (Sport sport) -> void
  def favorites: () -> Array[SportTeam]
end

この例では

  • #admin? メソッドは判定メソッドですので boolean を返し
  • #enjoy メソッドは Sport オブジェクトを引数に取り、値は返さず(void)
  • #favorites メソッドは SportTeam オブジェクトの配列を返す

という型に書き換えています。

※ 先ほども紹介しましたが、RBS については RBS基礎文法最速マスター – pockestrap がおすすめです。

ここでは登場するすべてのメソッドの型を書き換えましたが、ソースコードの規模やご自身の余裕に応じて、必要な部分だけ書き換えるという選択をするのも良いでしょう。
untyped という型のままでもうまく動かないというわけではありません。

肩の力を抜いて、徐々に型を書いていくという考え方で良いと思います。

4. rbs:subtract タスクを呼び出して、型定義情報をアップデートする

さて、型定義を書き足したところで rbs:subtract タスクを呼び出して、型定義情報をアップデートしましょう。

さきほど、ファイルをコピーしたため、同じメソッドに対する型が 2種類存在する状態になっています。
前回セットアップした Rake タスク rbs:subtract は、手書きした型を優先して sig/prototype ディレクトリ以下の型定義を間引いてくれます。

$ bundle exec rails rbs:subtract

5. rbs:validate コマンドを呼び出して、型定義をチェックする

最後に rbs:validate コマンドを呼び出して、型定義の整合性をチェックしましょう。

不整合などがあればここでエラーが表示されます。

$ bundle exec rails rbs:validate

まとめ

今回は Ruby の型定義のメンテの仕方についてご紹介しました。
自動抽出をベースとして、必要な部分だけ型を補うというアプローチに基づいた手順です。

このアプローチには、型に不慣れなメンバーと一緒に徐々に型に慣れていくという狙いがあります。
型の自動抽出をしながら、katakata_irb や LSP などを使って型の恩恵に預かっていくと、徐々に型の便利さを発見し、少しずつ前進して行けるのでは、という期待を込めています。

一連の記事が皆さんの参考になれば幸いです。