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ファイルという別ファイルで型定義をする
- .d.tsファイルのようなものですね。
- Ruby公式採用のrbs形式とは異なる形式としてrbi形式というのもあるそうです。rbsはSteep、rbiはSorbetというそれぞれのツールが採用していた記述形式とのこと。
- rbsファイルはrbファイルのすぐ横では無くてディレクトリを別にするのが一般的
- ここは.d.tsを.jsの横に置くTypeScript文化圏とはちょっと違う模様。
- steepという型チェックツールの仕様によるものっぽい?
- rbsファイルを1から書くのは厳しいのでツールが色々ある
- TypeScriptの
--allowJs
で定義を吐くみたいな感じですね。
- TypeScriptの
- DefinitelyTyped相当も一応存在するっぽい?
- まだ荒削り。洗練されていって欲しい雰囲気。
- Ruby言語としては型宣言を書くことを推奨していない
- Matzの想いです。
- が、TypeScriptの徒としてもっと早く「とりあえず型があるから概ね大丈夫だろう」になるように、一旦書いていく道をやっていこうとしたのが以下の記録です。
rbsファイルを作る方法
1から書いてもいいですが、ざっくり自動生成する方法が3種類くらいある模様。
以下に概要が紹介されていますが、他の記事やリリースノートでも紹介しているtypeprofと言うツールが雰囲気良さげっぽいのかなと感じました。rbsと言うツールでもいけるそうです。
実際に型宣言を書く(基本)
幸い自作の小さなライブラリがあったので、やっていきます。
ジョブなんかの経過時間を良い感じに表示するユーティリティーです。
とりあえずRuby 3.0にしますね。
するとtypeprofやrbsが使えるようになっています。
とりあえずtypeprofを使って自動生成……。sigディレクトリに生成するのが定石らしい?
結果、なんとなく書かれてますね。
# 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を導入します。
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
で、実行……
オッどうした……
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には型パラメーターがいるようですね。
Structの宣言はどこにあるのか……。
なるほど、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できるし、奇妙だが正しい定義なのか……。
雰囲気でなおして……
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
チェック!
なるほどなるほど。とりあえず通りましたね。
というわけでとりあえず動くことは確認できたので色々直していきましょう……。
検査時の型アノテーションが貧弱でつらいぞ……
freezeされているとタプルがsymbol配列に展開されてしまうのが厳しいです……。(PARTS = %i[day hour min sec].freeze
だとエラー、PARTS = %i[day hour min sec]
だと通る)
ローカル変数がFloatに型解決されているが、round()がInteger | FloatになってしまうのでIntegerが入らない……。
これはSteepのインライン型アノテーションでなんとか回避できました。
素のハッシュ(parts = {}
)だと返値型が異なるとエラー……。
型アノテーションを付けるも今度は代入時点(parts = {}
)でエラー……。
TypeScriptのasが柔軟に出来ないのでなかなか完備に型が付けられない雰囲気を感じてきました。
Array#lastがnilの可能性を指摘してくれるのは良いが、non-null assertion(TypeScriptにおける!
)が欲しいです……。
Kernel.putsとかは確かに使うことが少ないけど、定義漏れですかねえ……
Structの代入が型パラメーターuntypedでこけるのは勘弁して欲しい……。
というかStructの中で定義されたメソッドが親クラスのメソッドとしてtypeprofで生成されてますね……?
その他、トップレベルにdefとか置くとパースできなかったり、rbsのドキュメントにはあるaliasが弾かれたりと、steepの開発途上感が伺えます。
所感
ぬ~~ん……
一応型チェックが出来ないことも無いけど、現時点ではノイズが出過ぎて煩わしい感じになりますね……。
まあ中身のチェックはおいといて、とりあえず型公開するだけにするのが良さそうですかね……。
gemに型を含める
ディレクトリ構造をどうしたら型公開になるよという記述が全然見つからないですが、とりあえずsigディレクトリに入れておきます。
外から使ってテストしてみると一応動いているっぽいので、sigディレクトリにあれば読んでくれそうです。
Steepの作者さんからフィードバックをいただきました。(※追記)
なんと……
@narazaka どうもフィードバックありがとうございます。remain_timerが型が付かないのはちょっと悔しいなあ、というわけで、やってみました。 https://t.co/6p9L11dpjC
— Soutaro Matsumoto (@soutaro) 2020年12月26日
というわけで、Steep型検査をなんとかするハック集をいただきました。
コード上不自然になる修正も含まれつつなので、全部適用するかどうかは結構悩ましいソリューションですが、困ったときの字引になりそうです。
参考にさせていただきます。ありがとうございました。
実際に型宣言を書く(Rails関連)
Rails関連の小さいライブラリも自作してたので、同じくやっていきます。
インデックスはっていない日付カラム値をもとに二分探索で比較的高速に近くのレコードを探すユーティリティです。ActiveRecordモデルにincludeするタイプのやつですね。
こちらも同様に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
さてチェック……。
オッなるほどDateTimeがないとのことですね……。
libraryを読み込むオプションがあるっぽいのでSteepfileに記述……。
target :lib do signature "sig" check "lib" library "date" end
アァーActiveSupport::TimeWithZone……。
DefinitelyTyped相当?「gem_rbs」と「rbs_rails」
さて、他ライブラリに存在する型であるActiveSupport::TimeWithZoneを参照しなければなりません。
つまるところTypeScriptでいう@types
ネームスペース、DefinitelyTyped相当が必要です。
探し回るとどうも以下のリポジトリがそういう感じの模様です。
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
ワークフローの発展途上を感じます。
まあとりあえずこれでチェックしてみると……。
依存系も全部library指定しなければいけない波動を感じます。
Railsのrbsまわりをサポートする以下のライブラリ記述を参考にあらためて指定してまたチェック……。
が、ダメ。
なんかRails::DomとかRackとか参照していて、どうもrbs_railsの導入もしなければいけなさそうなのでやります。
導入手順に従って……
rake rbs_rails:copy_signature_filesを叩く……。
アァーRails導入してないからrakeのenvironmentがない……。
幸いにしてコアの処理はrake非依存だったのでそれを叩きます。
require "rbs_rails" require "pathname" RbsRails.copy_signatures(to: __dir__ + "/sig/rbs_rails/")
(WSL1でやってるのでファイルに実行権限付いてるのはスルーしてください。)
これでRails系の型定義がsig内に入ったのでとりあえず動きました。
さて、このライブラリは基本的に
class Foo < ActiveRecord::Base include FindNearDate end
という感じでActiveRecordのメソッドを使う前提で組まれているもので、無邪気にActiveRecord::Base#whereなんかを使っているわけですが、レシーバの記述がないので当然怒られます。
なのでinclude先のインターフェースを指定します。(以下のsyntax記述にありますね。)
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とかがエラーになっていたりします。難しい……。
とりあえずやはり内部のチェックは諦めて、後世のために自分のライブラリの型定義だけやっとくかという感じですね……。
実際に型宣言を書く(頑張っていい感じにする)(※追記)
以上2つの試行錯誤で、Railsとか外部事情が絡んでくるとかなり苦しいっぽいことと、詳細に型を定義するのは難しくuntyped
(any
相当)を基本とするのが良さそうという肌感が分かってきました。
次に塩梅を探るべく、ある程度のデカさはあるがそれ自身で完結しているライブラリでやってみます。
JSON Schemaに沿って型変換して出力するシリアライザです。
知見を生かして割とuntyped重視でやっていったところがこちら。
一部nilガードを適用するためにコード変更を入れていますが、ほとんどそのままで……。
特にオプションは十分詳細に指定していて……。
type check結果がこちらです。
エラーは現時点ではこれ以上消せないようなんですが、300行あまりの結構複雑なことをやっているライブラリの型チェックとして、一応多少指針になりそうなノイズ量には抑えられたのかなと思います。
それぞれのエラーは
ハッシュキーが無いという指定(TypeScriptでいう(指定ミスでした){key?: any}
の?
)が無いみたいで、{}
がキーが不一致だとはねられている- Struct代入
- Hash#mergeで型が変わった結果
なので、その辺がダメだと認識しつつだましだましいけるかなという感じですね。
VSCode拡張機能で充実する
SteepのVSCode拡張が出ているのでインストールしてみます。
誤エラーがちゃんとでてます。
solargraphの拡張などとも共存可能ですね。(上のメソッド説明がsolargraphのもの)
要所で型解決もされていることが分かります。
まあuntypedが多いんですが。
入力補完もできます。
表示速度的には普通に速い印象で、普通に使えましたね。型がある程度整えられたなら導入すると便利そうです。
ちなみにrbsファイルのシンタックスハイライトの拡張もあり、今回定義はこれを使って書きました。
所感
untypedでやっていき
全体的に型は書けはするが、TypeScriptのノリで型を厳密に定義すると検査系がノイズまみれになる感じで現時点では結構厳しいです。
ある程度はuntyped
(any
相当)で付けていくのが現実的だと思われます(特に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アプリケーションの中身にあわせて型自動生成を行うツールだったりします。(なので外部ライブラリ利用は多少目的外な節はあるのかも)
今回踏まなかったタイプのワークアラウンド。
上記でもありましたが回避方法いろいろ。
Kernel.putsは直りそう?ありがとうございます。
やっていきとのこと。
> また大本が「型を書かないでゆきたい」方針であるせいか、単に日が浅いせいかは不明ですが、DefinitelyTyped相当がまだほぼ全くありません。
— Pocke(ぽっけ) (@p_ck_) 2020年12月26日
TypeProfを使うにもgemの型は書いておく必要がある、という感じなので、これは単にまだ誰も型を書いていないだけな気がする
まあやはり書けるところから片っ端から書いていくしかないんだなあ(覚悟感)。