Narazaka::Blog

奈良阪という人のなにか

Ruby 3.0の静的型定義をTypeScriptみたいにライブラリに書いてみた所感

Ruby 3.0が先日のクリスマス(というか昨日)にリリースされたと言うことで、型をやっていきたいと思います。

当方TypeScript大好きっ子なのでRuby 3.0で取り込まれた最も注目すべき機能は静的型チェック関係なのですが、どうもその辺についていまいちまとまって書いてある物がなかった気がするので調べたことをメモします。

Rubyの静的解析

さて、Ruby 3.0のリリースノートには「静的解析」という項目があります。 www.ruby-lang.org

曰く――

2010年代は静的型言語の時代でした。Rubyは抽象解釈を武器に、型宣言なしで静的型チェックする未来を目指します。RBSとTypeProfはその第一歩です。Rubyがもたらす誰も見たことがない静的型の世界を見守ってください — Matz

とのことですが……。

たしかに過去のMatzのRubyKaigi 2019発表にもあるように、Rubyは方向性として「型定義さえ書かない」で型チェックをしようと意気込んでいる雰囲気はあります。 logmi.jp

しかし「型宣言なしで静的型チェック」というのが解析器の性能向上によってもたらされるものであるならば、現状は未だその性能は不十分に見えます(意図を誤解してたらすみません)。

多少のヒントには使えるかもしれませんが、TypeScriptで実現されている「とりあえず型があるから概ね大丈夫だろう」みたいな体感にはほど遠い感じに思えました。

型宣言

と言うわけで、とりあえず自分の作ったライブラリの型宣言を書いていきたいですね。

型関連の文書を色々見て見た結果、以下のような感じであることが分かりました。

  • Ruby 3.0の処理系(ruby)は2.0系と同じく全く静的型アノテーションに関与しない
    • TypeScriptに対するJavaScriptみたいな感じですね
  • Rubyソース内ではなく.rbsファイルという別ファイルで型定義をする
  • rbsファイルはrbファイルのすぐ横では無くてディレクトリを別にするのが一般的
    • ここは.d.tsを.jsの横に置くTypeScript文化圏とはちょっと違う模様。
    • steepという型チェックツールの仕様によるものっぽい?
  • rbsファイルを1から書くのは厳しいのでツールが色々ある
    • TypeScriptの--allowJsで定義を吐くみたいな感じですね。
  • DefinitelyTyped相当も一応存在するっぽい?
    • まだ荒削り。洗練されていって欲しい雰囲気。
  • Ruby言語としては型宣言を書くことを推奨していない
    • Matzの想いです。
    • が、TypeScriptの徒としてもっと早く「とりあえず型があるから概ね大丈夫だろう」になるように、一旦書いていく道をやっていこうとしたのが以下の記録です。

rbsファイルを作る方法

1から書いてもいいですが、ざっくり自動生成する方法が3種類くらいある模様。

以下に概要が紹介されていますが、他の記事やリリースノートでも紹介しているtypeprofと言うツールが雰囲気良さげっぽいのかなと感じました。rbsと言うツールでもいけるそうです。

pocke.hatenablog.com

github.com

github.com

実際に型宣言を書く(基本)

幸い自作の小さなライブラリがあったので、やっていきます。

ジョブなんかの経過時間を良い感じに表示するユーティリティーです。

github.com

とりあえずRuby 3.0にしますね。

f:id:narazaka:20201226152301p:plain

するとtypeprofやrbsが使えるようになっています。

f:id:narazaka:20201226152410p:plain

とりあえずtypeprofを使って自動生成……。sigディレクトリに生成するのが定石らしい?

f:id:narazaka:20201226152523p:plain

結果、なんとなく書かれてますね。

# Classes
class RemainTimer
  @laptimes: Array[LapTime]
  @use_current: false

  attr_accessor all_count: bot
  attr_accessor estimate_laptime_size: Integer
  def initialize: (?estimate_laptime_size: Integer) -> Integer
  def start: (?Integer count, ?Time time) -> Array[LapTime]
  def progress: (untyped count, ?Time time) -> Array[LapTime]
  def remain_time: -> RemainTime

  private
  def now: -> Time
  def to_s: -> String
  def puts: (?prefix: nil, ?postfix: nil) -> nil
  def print: (?prefix: nil, ?postfix: nil) -> untyped
  def dfmt: (untyped duration) -> String

  class LapTime < Struct
    attr_accessor past_count(): Complex | Float | Integer | Rational
    attr_accessor time(): Time
  end

  class RemainTime < Struct
    attr_accessor all_duration(): (Complex | Float | Time)?
    attr_accessor past_duration(): Float | Time
    attr_accessor remain_duration(): (Complex | Float)?
    attr_accessor all_count(): nil
    attr_accessor past_count(): Complex | Float | Integer | Rational
    attr_accessor remain_count(): nil
  end
end
# Classes
class DurationFormat
  SECONDS_PER_DAY: Integer
  SECONDS_PER_HOUR: Integer
  SECONDS_PER_MINUTE: Integer
  PARTS: [:day, :hour, :min, :sec]
  PARTS_IN_SECONDS: {sec: Integer, min: Integer, hour: Integer, day: Integer}

  def self.build_parts: (untyped value) -> {day: untyped, hour: untyped, min: untyped, sec: untyped}
  def self.format: (untyped value) -> String
end

ではとりあえず型検査をかけてみたいので、Steepを導入します。

github.com

f:id:narazaka:20201226153043p:plain

Steepfileと言うのが作られるので……

# target :lib do
#   signature "sig"
#
#   check "lib"                       # Directory name
#   check "Gemfile"                   # File name
#   check "app/models/**/*.rb"        # Glob
#   # ignore "lib/templates/*.rb"
#
#   # library "pathname", "set"       # Standard libraries
#   # library "strong_json"           # Gems
# end

# target :spec do
#   signature "sig", "sig-private"
#
#   check "spec"
#
#   # library "pathname", "set"       # Standard libraries
#   # library "rspec"
# end

雰囲気でとりあえずあわせます。

target :lib do
  signature "sig"

  check "lib"
end

で、実行……

f:id:narazaka:20201226153443p:plain

オッどうした……

sig/remain_timer.rbs:20とsig/remain_timer.rbs:25を見るとどちらもStructの宣言です。

# Classes
class RemainTimer
  ...

  class LapTime < Struct # line 20
    ...
  end

  class RemainTime < Struct # line 25
    ...
  end
end

RBS形式の記述文法は以下です。Klass[T]というのが型パラメーターの書き方なので、Structには型パラメーターがいるようですね。

github.com

Structの宣言はどこにあるのか……。

f:id:narazaka:20201226154517p:plain

なるほど、rbs gemのcoreディレクトリの中に組み込みのsigが入ってるっぽいですね。

直感的にStructの型パラメーターが1つというのは謎なのだけれど、どうもEnumerableの型パラメーターのために定義されているっぽいです。

class Struct[Elem] < Object
  include Enumerable[Elem]

  type attribute_name = Symbol | String

  def initialize: (attribute_name, *attribute_name, ?keyword_init: boolish) ?{ () -> void } -> void

  def each: () { (Elem) -> untyped } -> untyped

  def self.members: () -> ::Array[Symbol]
end

たしかにStructはeachできるし、奇妙だが正しい定義なのか……。

f:id:narazaka:20201226155116p:plain

雰囲気でなおして……

  class LapTime < Struct[Integer | Time]
    def initialize: (Integer past_count, Time time) -> untyped
    attr_accessor past_count(): Integer
    attr_accessor time(): Time
  end

  class RemainTime < Struct[Numeric | Integer | nil]
    def initialize: (
      all_duration: Numeric?,
      past_duration: Numeric,
      remain_duration: Numeric?,
      all_count: Integer?,
      past_count: Integer,
      remain_count: Integer?,
    ) -> untyped
    attr_accessor all_duration(): Numeric?
    attr_accessor past_duration(): Numeric
    attr_accessor remain_duration(): Numeric?
    attr_accessor all_count(): Integer?
    attr_accessor past_count(): Integer
    attr_accessor remain_count(): Integer?
  end

チェック!

f:id:narazaka:20201226160111p:plain

なるほどなるほど。とりあえず通りましたね。

というわけでとりあえず動くことは確認できたので色々直していきましょう……。

検査時の型アノテーションが貧弱でつらいぞ……

freezeされているとタプルがsymbol配列に展開されてしまうのが厳しいです……。(PARTS = %i[day hour min sec].freezeだとエラー、PARTS = %i[day hour min sec]だと通る)

f:id:narazaka:20201226160719p:plain

ローカル変数がFloatに型解決されているが、round()がInteger | FloatになってしまうのでIntegerが入らない……。

f:id:narazaka:20201226161413p:plain

f:id:narazaka:20201226161500p:plain

これはSteepのインライン型アノテーションでなんとか回避できました。

f:id:narazaka:20201226162303p:plain

素のハッシュ(parts = {})だと返値型が異なるとエラー……。

f:id:narazaka:20201226162406p:plain

アノテーションを付けるも今度は代入時点(parts = {})でエラー……。

f:id:narazaka:20201226162526p:plain

f:id:narazaka:20201226162556p:plain

TypeScriptのasが柔軟に出来ないのでなかなか完備に型が付けられない雰囲気を感じてきました。

Array#lastがnilの可能性を指摘してくれるのは良いが、non-null assertion(TypeScriptにおける!)が欲しいです……。

f:id:narazaka:20201226162807p:plain

f:id:narazaka:20201226162724p:plain

Kernel.putsとかは確かに使うことが少ないけど、定義漏れですかねえ……

f:id:narazaka:20201226162939p:plain

Structの代入が型パラメーターuntypedでこけるのは勘弁して欲しい……。

f:id:narazaka:20201226163213p:plain

f:id:narazaka:20201226163143p:plain

というかStructの中で定義されたメソッドが親クラスのメソッドとしてtypeprofで生成されてますね……?

f:id:narazaka:20201226163806p:plain

f:id:narazaka:20201226163839p:plain

その他、トップレベルにdefとか置くとパースできなかったり、rbsのドキュメントにはあるaliasが弾かれたりと、steepの開発途上感が伺えます。

所感

ぬ~~ん……

一応型チェックが出来ないことも無いけど、現時点ではノイズが出過ぎて煩わしい感じになりますね……。

まあ中身のチェックはおいといて、とりあえず型公開するだけにするのが良さそうですかね……。

gemに型を含める

ディレクトリ構造をどうしたら型公開になるよという記述が全然見つからないですが、とりあえずsigディレクトリに入れておきます。

github.com

外から使ってテストしてみると一応動いているっぽいので、sigディレクトリにあれば読んでくれそうです。

github.com

f:id:narazaka:20201226170142p:plain

Steepの作者さんからフィードバックをいただきました。(※追記)

なんと……

というわけで、Steep型検査をなんとかするハック集をいただきました。

github.com

コード上不自然になる修正も含まれつつなので、全部適用するかどうかは結構悩ましいソリューションですが、困ったときの字引になりそうです。

参考にさせていただきます。ありがとうございました。

実際に型宣言を書く(Rails関連)

Rails関連の小さいライブラリも自作してたので、同じくやっていきます。

インデックスはっていない日付カラム値をもとに二分探索で比較的高速に近くのレコードを探すユーティリティです。ActiveRecordモデルにincludeするタイプのやつですね。

github.com

こちらも同様にtypeprofで生成しましたが、実質公開1メソッドなので楽です。

module FindNearDate
  def find_near_date: (Time | DateTime | ActiveSupport::TimeWithZone date, ?Symbol | String date_column, ?key_column: Symbol | String, ?period: Integer | Float, ?log: bool) -> ActiveRecord::Base
  VERSION: String
end

さてチェック……。

f:id:narazaka:20201226171641p:plain

オッなるほどDateTimeがないとのことですね……。

libraryを読み込むオプションがあるっぽいのでSteepfileに記述……。

target :lib do
  signature "sig"

  check "lib"

  library "date"
end

f:id:narazaka:20201226171820p:plain

アァーActiveSupport::TimeWithZone……。

DefinitelyTyped相当?「gem_rbs」と「rbs_rails

さて、他ライブラリに存在する型であるActiveSupport::TimeWithZoneを参照しなければなりません。

つまるところTypeScriptでいう@typesネームスペース、DefinitelyTyped相当が必要です。

探し回るとどうも以下のリポジトリがそういう感じの模様です。

github.com

DefinitelyTypedはpackage.jsonのdependenciesに記述するだけで良かったのですが、こちらはまだあまり整備されていません。

曰く、まずgit submoduleなどで当該リポジトリ内容をvendor/rbs/gem_rbsなどに置いて……( git submodule add https://github.com/ruby/gem_rbs.git vendor/rbs/gem_rbs )。

ツールから使う場合その中のrbsリポジトリパスを参照する……。

target :lib do
  signature "sig"

  check "lib"

  repo_path "vendor/rbs/gem_rbs/gems"
  library "date"
  library "activesupport"
end

ワークフローの発展途上を感じます。

まあとりあえずこれでチェックしてみると……。

f:id:narazaka:20201226172655p:plain

依存系も全部library指定しなければいけない波動を感じます。

Railsrbsまわりをサポートする以下のライブラリ記述を参考にあらためて指定してまたチェック……。

github.com

が、ダメ。

f:id:narazaka:20201226172955p:plain

なんかRails::DomとかRackとか参照していて、どうもrbs_railsの導入もしなければいけなさそうなのでやります。

導入手順に従って……

f:id:narazaka:20201226173418p:plain

f:id:narazaka:20201226173440p:plain

rake rbs_rails:copy_signature_filesを叩く……。

f:id:narazaka:20201226173629p:plain

アァーRails導入してないからrakeのenvironmentがない……。

幸いにしてコアの処理はrake非依存だったのでそれを叩きます。

require "rbs_rails"
require "pathname"
RbsRails.copy_signatures(to: __dir__ + "/sig/rbs_rails/")

f:id:narazaka:20201226174006p:plain

(WSL1でやってるのでファイルに実行権限付いてるのはスルーしてください。)

これでRails系の型定義がsig内に入ったのでとりあえず動きました。

f:id:narazaka:20201226174208p:plain

さて、このライブラリは基本的に

class Foo < ActiveRecord::Base
  include FindNearDate
end

という感じでActiveRecordのメソッドを使う前提で組まれているもので、無邪気にActiveRecord::Base#whereなんかを使っているわけですが、レシーバの記述がないので当然怒られます。

なのでinclude先のインターフェースを指定します。(以下のsyntax記述にありますね。)

github.com

module FindNearDate[Model] : _ActiveRecord_Relation[Model]
  def find_near_date: (Time | DateTime | ActiveSupport::TimeWithZone date, ?Symbol | String date_column, ?key_column: Symbol | String, ?period: Integer | Float, ?log: bool) -> ActiveRecord::Base
  VERSION: String
end

しかしwhere()などは解決されるようになったもののorder()の定義が無かったり、型パラメーターに制約がないので(?)public_sendとかがエラーになっていたりします。難しい……。

f:id:narazaka:20201226175558p:plain

とりあえずやはり内部のチェックは諦めて、後世のために自分のライブラリの型定義だけやっとくかという感じですね……。

実際に型宣言を書く(頑張っていい感じにする)(※追記)

以上2つの試行錯誤で、Railsとか外部事情が絡んでくるとかなり苦しいっぽいことと、詳細に型を定義するのは難しくuntypedany相当)を基本とするのが良さそうという肌感が分かってきました。

次に塩梅を探るべく、ある程度のデカさはあるがそれ自身で完結しているライブラリでやってみます。

JSON Schemaに沿って型変換して出力するシリアライザです。

github.com

知見を生かして割とuntyped重視でやっていったところがこちら。

github.com

一部nilガードを適用するためにコード変更を入れていますが、ほとんどそのままで……。

f:id:narazaka:20201226235741p:plain

特にオプションは十分詳細に指定していて……。

f:id:narazaka:20201226235831p:plain

type check結果がこちらです。

f:id:narazaka:20201227004719p:plain

エラーは現時点ではこれ以上消せないようなんですが、300行あまりの結構複雑なことをやっているライブラリの型チェックとして、一応多少指針になりそうなノイズ量には抑えられたのかなと思います。

それぞれのエラーは

  • ハッシュキーが無いという指定(TypeScriptでいう {key?: any}?)が無いみたいで、{}がキーが不一致だとはねられている(指定ミスでした)
  • Struct代入
  • Hash#mergeで型が変わった結果

なので、その辺がダメだと認識しつつだましだましいけるかなという感じですね。

VSCode拡張機能で充実する

SteepのVSCode拡張が出ているのでインストールしてみます。

marketplace.visualstudio.com

誤エラーがちゃんとでてます。

f:id:narazaka:20201227012129p:plain

solargraphの拡張などとも共存可能ですね。(上のメソッド説明がsolargraphのもの)

f:id:narazaka:20201227013215p:plain

要所で型解決もされていることが分かります。

f:id:narazaka:20201227012404p:plain

f:id:narazaka:20201227012543p:plain

f:id:narazaka:20201227012634p:plain

f:id:narazaka:20201227012903p:plain

まあuntypedが多いんですが。

入力補完もできます。

f:id:narazaka:20201227021834p:plain

f:id:narazaka:20201227022353p:plain

表示速度的には普通に速い印象で、普通に使えましたね。型がある程度整えられたなら導入すると便利そうです。

ちなみにrbsファイルのシンタックスハイライトの拡張もあり、今回定義はこれを使って書きました。

marketplace.visualstudio.com

所感

untypedでやっていき

全体的に型は書けはするが、TypeScriptのノリで型を厳密に定義すると検査系がノイズまみれになる感じで現時点では結構厳しいです。

ある程度はuntypedany相当)で付けていくのが現実的だと思われます(特にStructとかはStruct[untyped]でないと無理そう)。TypeScript 1.x系の雰囲気を思い出しましょう(?)。

DefinitelyTyped相当はほぼ未整備なので覚悟が必要

また大本が「型を書かないでゆきたい」方針であるせいか、単に日が浅いせいかは不明ですが、DefinitelyTyped相当がまだほぼ全くありません。型を記述する場合、node.d.ts相当のみの環境で型を書いていくのを覚悟する必要があるフェーズっぽいですね。

とはいえTypeScriptの初期にもきっとこういう感じだったのかもと思えば乗り越えられるかも知れません。初期にはDefinitelyTypedはもっとスカスカでしたし、今のpackage.jsonへのtypes記述に落ち着くまでtypingsとかdtsなんとかとかいろいろありましたし、やっていきです。

この先生きてゆくには

ともかく現環境では「型を書く」こと自体はさておき、「型チェック」がかなり苦痛であることはなんとなく分かりました。ただライブラリやコードの属性によっては、ある程度のノイズを許容すれば型チェック補助になる場合もありそうかなという希望的知見も得ました。

まあとはいえ辛い中で、「型を書かないぞ」という方向での開発を待っているのが良いのかはちょっと個人的には迷うとこではあります。仮に効果が薄いとしても、確定した型情報は無駄にはならない……はず……?

今後どうなるかは分かりませんが、以上Ruby 3.0から1日後のざっくりとした試行錯誤の記録でした。

その後見た記事(※追記)

今回お世話になったrbs_railsですが、単に型定義を提供するライブラリではなく、Railsアプリケーションの中身にあわせて型自動生成を行うツールだったりします。(なので外部ライブラリ利用は多少目的外な節はあるのかも)

pocke.hatenablog.com

今回踏まなかったタイプのワークアラウンド

pocke.hatenablog.com

上記でもありましたが回避方法いろいろ。

github.com

Kernel.putsは直りそう?ありがとうございます。

github.com

やっていきとのこと。

まあやはり書けるところから片っ端から書いていくしかないんだなあ(覚悟感)。