ひとつ前の記事では Steep に追加予定の ignore コメント機能を紹介しました。
その記事の中で、ActiveSupport::Concern で提供されている included ブロックのことを 解消の難しい型エラー と紹介しましたが、ブログ記事にまとめていく最中に解決方法を思いつきました。
そもそもどういう問題だったのかを改めて整理します。
手元のコードでは、concern 系クラスの included ブロックで型エラーが起きていました。included ブロック内では、 include 先のクラスとして コードが実行されるのですが、Steep はこのブロック内で self が切り替わっていることが認識できず、Concern クラスとして判定を進めようとするため、型エラーが発生します。
module Loginable
extend ActiveSupport::Concern
included do
helper_method :foo #=> Type `singleton(::Loginable)` does not have method `helper_method`
end
end
上記の例では singleton(::Loginable)
にメソッドがないと言っていますよね。
前回の記事では、この問題は解消が難しいので、ignore コメントで型エラーを黙らせることにしました。
実は上の「問題の整理」のところに書いたことにヒントがあります。
「include 先のクラスとしてコードが実行される」、「このブロック内で self が切り替わっている」という部分です。 つまり、ブロック内で self が切り替わっていることを Steep にヒントとして与えてあげれば、型エラーが解消するというわけです。
実際には、次のようなアノテーションコメントを書きます。
module Loginable
extend ActiveSupport::Concern
included do
# @type self: singleton(ActionController::Base)
helper_method :foo
end
end
こうやってアノテーションコメントを書くことで、Steep に対して「このブロック内では self を include 先のクラスである ActionController::Base
クラスとして扱うように」と伝えることができます。
Steep には、このように型情報を補足するためのアノテーションコメントが用意されています。RBS では表現できない、ブロック単位での型情報や変数の型情報などを Ruby のソースコード内にコメントとして記述します。
Ruby では DSL や宣言的な記述が好まれ、コンテキストの切り替わるブロックがあちこちにありますが、現在提供されている型でコンテキストスイッチが表現されていない場合は、今回のようにアノテーションコメントを併用するのがよさそうです。
また、メソッド内、ブロック内の変数の型情報などを補うのも有用です。
前回の記事で #present?
による型の絞り込みができないという話をしましたが、これもアノテーションコメントを使うことで解消できます。
num = [1, 2, 3, nil].sample #=> Integer?
if num.present?
# @type var num: Integer
num.succ #=> #present? では型が絞り込まないが、アノテーションコメントによって Integer と扱われるため、型エラーは発生しない
end
理想としては型の進化で改善してほしいところですが、adhoc な対応として、アノテーションコメントを使って回避する手もあります。
アノテーションコメントを多用するとコードが読みづらくなるという問題もありますが、型エラーを解消する手段のひとつとして覚えておくとよいでしょう。
今回はアノテーションコメントで対応することにしましたが、理想の解決方法も考えてみました。
ActiveSupport::Concern
の型に型変数を追加して、次のような定義にするのがよさそうです。受け取った T を使い、include
ブロックの self に singleton(T)
という型を与えています。
module ActiveSupport::Concern[T]
def included: (?untyped? base) { () [self: singleton(T)] -> void } -> void
end
こうすることで included ブロックの self を、Concern クラスごとに調整できるようになります。
たとえば、コントローラ系の Concern クラスの型定義では、以下のように ActiveSupport::Concern の型引数に ActionController::Base
を指定します。これにより、included ブロック内の self が自動的に (アノテーションコメントなしで) ActionController::Base
と認識されるようになります。
module MyConcern
extend ActiveSupport::Concern[ActionController::Base]
end
この提案は理想系のひとつではないかと考えましたが、すでに ActiveSupport::Concern の型は世界中で利用されています。そのため、型変数を追加してしまうと世界中のあちこちの型を書き換えが発生してしまいます。かなりドラスティックな変更で、導入に勇気が必要ですね。
このアイディアは ruby-jp slack に投稿してみたものの、実際に導入されるかどうかはわかりません。
この記事では以下の内容を紹介しました。
Happy ruby-typing life!