Ruby3.1 静的解析の導入で開発体験を向上させる (RBS, TypeProf)|Offers Tech Blog
まえがき
こんにちは、Offers を運営している株式会社 overflow CTO の 大谷旅人 です。
小ネタです。
弊社では Ruby/Rails をバックエンドの開発言語として採用しており、その柔軟性は開発の大きな助けとなっている面がありつつも、コードベース全体の規模増加や保守効率を考えて環境自体の見直しや、段階的な新環境への移行も行っています。
その中で、今回は Ruby での開発体験を向上させるために行っていた、静的型解析の導入に関してのお話です。
型安全性な環境(TypeScript,Rust,etc..)から Ruby に戻ってくるとやはりあっちは機構が勝手にチェックしてくれていいぞいいぞと思うわけで、
C#が dynamic 型で静的型言語なのに動的型付していたり, Python が type hints で型検査してたりを見ると、どうにかこうにか導入したいと日々思っているわけであります。
Ruby3.1.x~ 静的解析の現状(型解析、検査)
2010年代は静的型言語の時代でした。Rubyは抽象解釈を武器に、型宣言なしで静的型チェックする未来を目指します。RBSとTypeProfはその第一歩です。Rubyがもたらす誰も見たことがない静的型の世界を見守ってください — Matz
Ruby では、3.0 から型定義情報を提供する RBS という仕組みと TypeProf という型解析ツールがバンドルされるようにました。また、3.1 からは TypeProf の Experimental IDE support も提供されています。その他、Steep や Sorbet など取り巻くツールはいくつかあり、それらの関係性は以下の URL などを御覧ください。
TypeProf と Steepの違い
| xxx | 型推論 | 型検査 | IDEサポート | 解析速度 | 
|---|---|---|---|---|
| TypeProf | 強い | 弱い | あり | 遅い | 
| Steep | 弱い | 強い | あり | 早い | 
出典: https://rubykaigi.org/2021-takeout/presentations/mametter.html
現状について
重要なのは、型解析と検査する仕組みが言語に標準搭載され、型宣言をコード内に記述することなく静的解析行える下地ができたということです(しかも IDE サポートもあり)。
今回は、Ruby3.1.1 環境で、TypeProf & Experimental IDE support を使用した型解析&検査を、実際のプロダクトに導入していきます。(Ruby2.6 以降であれば同じようなことは Steep を使うことでできます)
導入手順
TypeProf のみを静的解析に使うようであれば、既存の Ruby 構造を保ったまま型解析を可能にすることを意識されているため、導入は簡単です。また、依存ライブラリの RBS ファイルの準備も RBS Collection manager ができたことで非常に楽になってます。
gem 'rbs_rails', require: false
gem 'typeprof', require: false
以下、コンソール実行
$ bundle install
$ rbs collection init
$ rbs collection install
これだけで完了です。お手軽 3min です。
IDE(VSCode)サポートは以下をインストールで完了。
静的解析例
どのような体験が待っているのか、参考に以下のコードを例に紹介します。
module Test
  def cache_key_of_xxxxxxx
    "xxxxxxxxxxxxxxxxx"
  end
  def build_cache_xxxxxxxxxxx
    # String型データ 未定義メソッド呼び出し
    self.cache_key_of_xxxxxxx.floor(1)
    return true
  end
  def hoge
    count_data = {}
    # typo
    if couta_data.size == 0
      return 1
    end
    # (中略)
    100.times do |i|
      # Hash型定義を忘れて、Array代入
      count_data ||= []
      if i % 2 == 0
        count_data << 1
      else
        count_data << 2
      end
    end
    count_data
  end
  def flow_sensitive_xxxx(x)
    if x.is_a?(Integer)
      10
    else
      "10"
    end
  end
  def generics_duck_type_container
    10.times.map do |i|
      if i % 2 == 0
        "aaaaa"
      else
        111111
      end
    end
  end
end
型推論 - 分岐

分岐があるコードでも両方が並列に実行され Integer | String と正しく?解析できています。
但し現在では、現時点では is_a?、respond_to? 、case で型指定されたパターン記述のみに対応するなど限定されているようです。
型推論 - コンテナ型

内包データが分岐で変わる(無理のあるコード)記述ですが、Array[Integer|String]と解析されています。
型検査 - 定義エラー

String 型と推論された変数へ対して未定義メソッドを呼び出したことでの検査エラーが発生しています。
テスト実行時点で察知されるものが、こうして TypeProf インタプリタ上で実行されることにより事前察知できるのは時間削減になって良きです。
型検査 - Typo

couta_data と typo っているために、未定義だよと検査エラーが発生しています。
高確率で typo する単語はどうしてもあるので、これも事前察知できて良いです。
型検査 - 代入ミス

Hash 型定義した変数を Array 型として誤って扱っていたコードで検査エラーが発生しています。
さっと導入しただけでも、上記のような例を筆頭に!! 型による防御的プログラミングの世界がいまここに!!
現段階での導入可否
と、まるでうまくいったかのように書いてますが、実際には弊社では導入できておりません。
素の Ruby で書かれた PORO(Plain Old Ruby Object)なクラスでは比較的うまく働くものの、その他多くのクラスでは型定義情報(RBS)が乏しいため全体でエラー発生しており解消のための整備と手直しにそれなりの時間が必要で道半ばです。

(本文見せられないけど赤すぎてVirtualBoyに熱中した少年時代を思い出す)
その後の維持に関しても、スキーマ駆動のように型定義決めた上でチーム開発するなど一定のルールの上で型を保ち続ける必要があります。
このルールを Ruby の柔軟さを享受しつつ運用するためには、適用するスコープを限定する必要もあるなという印象です(最初は concerns 以下 or service クラスのみとか)
ただしそのコストを払ったとしてもやはり型安全?な世界は、開発体験向上するだけでなく信頼性、安全性も高まることは確かです。
現段階でも、型情報さえうまくメンテできれば解析の速度含めて十分実用的であることは分かったので、引き続き取り組みを続けていきます。
そして弊社では、一緒にこの取組を支援してくれる方も大募集中です。
Discussion