Rubyの静的解析を試した


Ruby の静的解析を試す

Ruby は 3.0 から静的解析が導入されました.

Ruby は会社でしか使っていませんが,静的チェックや推論は最低限欲しいと思っている機能です.
そのため,少し試してみました.

結論

  • まだ実際の製品に導入するレベルではない
  • Test/Unit などのテストツールの RBS は最低限必要だ
  • 型を明示しない方法は,関数型言語から発展させた方が早いかも...

型を明示しない!?

TypeScript や Python などの他言語と異なり,Ruby は型を明示することを良しとしていません.
そのため,コードに型を書かずに静的解析を行う方針を取っています.

個人的に,大賛成です.

型をコードに書いている現状は,プログラミング言語の未発達によるものです.
Rust の rust-analyzer のようにエディタが型を見せてくれれば問題ないです.

実行するコードを書かなければならない...テストだ!!

しかし,型を記述せずに,どうやって型の情報を推論するのでしょうか?
そのために,Ruby は型情報が書かれたファイル(RBS)を別に持っています.

え...結局書いてるじゃないですか!?

落ち着いて.RBS を Ruby のコードから解析して自動生成 TypeProf という Gem があります.

TypeProf は,実際に投入されるデータをコードから読み取ることによって行われます.
つまり,一度も呼ばれない関数は,推論できません(当たり前ですね).

# 関数を定義しただけでは,推論の解析は行われない
def print_hash(hash)
  p hash
  nil
end

# 関数を実行するコードがあると,解析ができる
print_hash({ a: 1, b: "taro" })
# 解析の結果(RBS)
# def print_hash: ({a: Integer, b: String} hash) -> nil

このことから,いくつかの限界が見えました.

  1. 推論対象の関数を実行するコードがなければならない
  2. 外部から任意に入力できる情報に弱い
  3. 動的な定義変更に弱い

1. 推論対象の関数を実行するコードがなければならない

実行するコードを,静的解析のために特別に用意することを求めています.
あるいはテストコードを書かなければ型推論が働かないことを示しています.
Ruby は動かすことを重要視した言語であることに変わりません.

私は他の言語では型推論をテストコードを減らすために用いてきましたが,
Ruby はテストコードを補強するために型推論が行われるようです.
(少なくとも,自分の書いた関数に対して)

2. 外部から任意に入力できる情報に弱い

想定外の入力を防ぐのに向いていないということです.
ライブラリを静的解析で完全に記述できた場合,そのライブラリを使う Ruby ユーザに正しい関数の使い方を伝える補助になるでしょう.

しかし,Ruby で作られたツールの外部からやってくる入力に対しては,何も防御できていません.
やはり Ruby はスクリプト言語です.複雑なシステム開発の保守には不向きだと思います.
( ターミナル上で心地よく書くのが Ruby だと思っています)

3. 動的な定義変更に弱い

Ruby の元々の良さと相性が悪い面ではと思います.
実行時に容易に定義を書き換えることのできる Ruby の良さは,型の定義がコロコロ変わるので推論に不利です.

今まで推論が十分効いていたのに,動的な書き換えで突然解析不能になり,
型定義のメンテナンスに時間を奪われる光景が目に浮かんでしまいます.

静的解析を重要視するなら,あまり黒魔術は使わない方がいいと思います(少なくとも今は).

ライブラリの RBS 対応は未成熟

Rails などの有名プロジェクトは RBS が提供されることもありますが,ほとんどが untyped です.
静的解析の恩恵を得れるのは,ずいぶん先だと思います.

特に困るのは,RSpec や Test/Unit などのテストツールが RBS 対応できていないことです.
テストコードを書かないと推論できないのに,RBS がないのでテストコードが静的解析の時エラー吐きまくりです.

誰か対応してほしい...

ハッシュを使ってみた

Ruby は歴史的な経緯から文字列をキーとする HashMap として,文字列とシンボルの二つを許してきました.

私は,Symbol に統一するように勧めてきましたが,その理由は静的解析でさらに補強されました.


def print_hash(hash)
    p hash
    nil
end

# 文字列の場合
print_hash({ "a"=> 1, "b"=> "name" })
# 解析の結果(RBS)
# def print_hash: (Hash[String, Integer | String] hash) -> nil

# シンボルの場合
print_hash({ a: 1, b: "name" })
# 解析の結果(RBS)
# def print_hash: ({a: Integer, b: String} hash) -> nil

キーにはシンボルを使ってください.静的解析が使用していないキーを警告してくれます.

構造体を使ってみた

ハッシュマップよりも構造体を使ってください.Ruby には構造体があります.
外部からの未知の攻撃を防ぐには,ハッシュより構造体が有利です.

TypeProf は進化しているようで,昔は無名構造体にはランダムな構造体名を割り当てていましたが,
今は代入される変数の名前を構造体名として扱ってくれます.
また, keyword_init にも対応していました.ありがたい.

# 無名構造体
Person = Struct.new(:id, :name)
Person.new(1, "taro")

# 有名構造体
Person = Struct.new('Person',:id, :name)
Person.new(1, "taro")

# keyword_init あり
Person = Struct.new(:id, :name, keyword_init: true)
Person.new(id: 1, name: "taro")

# 全て同じ解析結果になる!!(RBS)
#
# class Person < Struct[untyped]
#   attr_accessor id(): Integer
#   attr_accessor name(): String
# end

lambda を使ってみた

少し驚きましたが,現状 lambda は Proc と認識されます.
一方,Arrow演算子で記述された場合は関数の型を解釈できます.

今後は,lambda表記でも型を理解できるようになると思います.

def method_test(func, val)
  func.call(val)
end

method_test(lambda { |v|  p v }, 1)
# def method_test: (Proc func, Integer val) -> untyped

method_test(-> v { p v }, 1)
# def method_test: (^(Integer) -> Integer func, Integer val) -> Integer

クラスの継承を使ってみた

基底クラスが選ばれるかと思いましたが,直和型として解釈されるようです.
たくさん派生クラスがあったら大変ですね.

class Base
end

class A < Base
end

class B < Base
end

def call_class(cls)
end
# def call_class: (A | B cls) -> nil

特異メソッドを使ってみた

TypeProfは無視するようでした(まぁね...).

user = User.new(id, name)

def user.print_val(val)
    p val
end

今後サポートするかどうかも怪しいですが,するとしてもかなり先だと思います.

手直しした RBS は TypeProf で上書きされないの?

大丈夫.
そのために手直しした RBS を上書きせずに,新しい RBS 結果を出力するコマンドがあります.

あとは,2 つの RBS の差分をみて,手作業で更新したい部分を書き換えればいいです.

これが今の最善の方法です...たぶん.

最後に

ビジネスで積極的に導入するには,まだ早い.
気長に見守りましょう.