Crystal での型の表示
最近、Brian Hicks さんのおかげで、式の型を検査する方法として、Sorbet の reveal_type
に出会いました。それが Crystal に移植できるかどうか疑問に思いました。すぐに使える解決策をコピー&ペーストしたい場合は、結論 セクションに進んでください。
式の型を検査することは、理にかなった質問です。プログラムがコンパイルされると、コンパイラは確実に答えを知っています。
Sorbet のドキュメントから取得した比較的単純な例から始めましょう。
def maybe(x, default)
# what's x type here?
if x
x
else
default
end
end
def sometimes_a_string
rand > 0.5 ? "a string" : nil
end
maybe(sometimes_a_string, "a default value")
既存の解決策: puts debug
printf
/print
/puts
を使用したプログラムの実行のデバッグは広く使用されています。Crystal では、いくつかのバリエーションを書くことができます
puts "x = #{x.inspect} : #{x.class}"
#
# Output:
#
# x = "a string" : String
#
# or
#
# x = nil : Nil
これは、プログラムが実行されるときに、x
の実際の値と型を表示します。しかし、実行時の型ではなく、コンパイル時の型が必要です。したがって、より正確な代替手段は次のようになります。
puts "x = #{x.inspect} : #{typeof(x)}"
#
# Output:
#
# x = "a string" : (String | Nil)
#
# or
#
# x = nil : (String | Nil)
または
pp! typeof(x)
#
# Output:
#
# typeof(x) # => (String | Nil)
既存の解決策: context ツール
約8年前、Crystal はいくつかの組み込みツールを獲得しました。これらのツールの1つは、まさに私たちが探している情報を提供します。
前の def maybe
が program.cr
の先頭で定義されていると仮定すると、次のように context ツールを使用できます。
% crystal tool context -c program.cr:2:3 program.cr
1 possible context found
| Expr | Type |
--------------------------
| x | String | Nil |
| default | String |
指定されたカーソルの位置にあるすべての変数/パラメータの型が表示されるため、必要なものよりも少し多くの情報が得られます.
また、コンパイル時の情報のみを使用します。puts debug とは異なり、この場合はプログラムは実行されません。
残念ながら、このツールでは、式を事前に変数に代入しない限り、式の型を出力できません.
context ツールは、コンパイラに依存しますが、基本的にコンパイルされたプログラム全体をトラバースする独立した実装です。現在処理されていないエッジケースがいくつかあります。
最も重要な欠点は、開発者エクスペリエンスだと思います。エディタと統合されていない限り、あまり良くありません。
Crystal に reveal_type
を追加する
Sorbet の reveal_type
の開発者エクスペリエンスは素晴らしいです
- プログラムに必要な変更は単純で、任意の有効な式に適用できます。
- 同じ型チェッカーが情報を表示します。
- 内部ツールコマンドを発見する必要はありません
- 追加のエディタ統合は必要ありません
- 1回のパスで複数の
reveal_type
の記述をサポートします。
Crystal にも同じものが欲しいです🙂。
def maybe(x, default)
reveal_type(x) # what's x type here?
if x
x
else
default
end
end
% crystal build program.cr
Revealed type program.cr:2:15
x : String | Nil
または、同様の出力。
コンパイラをハッキングする前に、ユーザーコードでそれができるかどうかを確認したかったのです。
Crystal マクロはコンパイル時に出力でき、式の AST にアクセスできます。
以下は、メッセージの最初の部分を提供します。
macro reveal_type(t)
{%- loc = "#{t.filename.id}:#{t.line_number}:#{t.column_number}" %}
{%- puts "Revealed type #{loc.id}" %}
{%- puts " #{t.id}" %}
{{t}}
end
式 t
のコンパイル時の型を取得しようとすると、悪名高い「マクロ内で TypeOf を実行できません」に遭遇します。
In program.ign.cr:4:26
9 | {% puts " #{t.id} : #{typeof(t)}" %}
^
Error: can't execute TypeOf in a macro
これを克服するために、def
はマクロコードを持つことができるという事実を利用できます。
def reveal_type_helper(t : T) : T forall T
{%- puts " : #{T}" %}
t
end
macro reveal_type(t)
{%- loc = "#{t.filename.id}:#{t.line_number}:#{t.column_number}" %}
{%- puts "Revealed type #{loc.id}" %}
{%- puts " #{t.id}" %}
reveal_type_helper({{t}})
end
program.cr
の先頭にこのスニペットがあれば、すでに必要な出力が得られています。🎉
% crystal build program.cr
Revealed type /path/to/program.cr:14:15
x
: (String | Nil)
残念ながら、複数の reveal_type
呼び出しを配置すると、期待どおりに機能しません。reveal_type_helper
のマクロは、**個別の型ごとに1回だけ**実行されます。
各 reveal_type
呼び出しに対して異なる reveal_type_helper
インスタンスを強制するには、それぞれに異なる型が必要です。驚くべきことに、私たちはそれを行うことができます.
def reveal_type_helper(t : T, l) : T forall T
{%- puts " : #{T}" %}
t
end
macro reveal_type(t)
{%- loc = "#{t.filename.id}:#{t.line_number}:#{t.column_number}" %}
{%- puts "Revealed type #{loc.id}" %}
{%- puts " #{t.id}" %}
reveal_type_helper({{t}}, { {{loc.tr("/:.","___").id}}: 1 })
end
l
引数は、{ <loc>: Int32 }
型のタプルを持ちます。ここで、<loc>
は、reveal_type
マクロの呼び出し場所に依存する識別子です。🤯
注意事項
この解決策には、言及する価値のある注意事項がいくつかあります。コンパイラに適切に組み込まれた機能は、これらすべてに影響されません。本質的に、これらは次のように要約できます。
reveal_type
は、使用されているコード内に配置する必要があります- 私たちの実装は、内部コンパイラの実行順序に非常に敏感です
- 完全に再帰的な定義は処理しません
- メモリレイアウトに影響を与えるため、プログラムのセマンティクスを変更する可能性があります
それぞれの詳細と例が必要ない限り、次のセクション に進んでください。
Crystal コンパイラの仕組みのため、reveal_type
は **静的に到達可能なコード** 内に配置する必要があります。型(実際には型の制限)を持つ引数を持つ def
で開始した場合でも、その def
を呼び出す必要があります。そうでない場合、コンパイラはそれを無視します。C++ テンプレートが使用されない限り展開されないのと似ています。
必要な出力をマクロと defs に分割することは、**コンパイラの実行順序** に非常に敏感です。次のコードは、この問題の影響を受けます
"a".tap do |a|
reveal_type a
end
1.tap do |a|
reveal_type a
end
Revealed type /path/to/program.cr:2:15
a
Revealed type /path/to/program.cr:6:15
a
: String
: Int32
**再帰的** プログラムは、reveal_type_helper
の出力を隠してしまうエッジケースに遭遇する可能性があります。次のプログラムには、いくつかの dig_first
インスタンス化があります。そのため、reveal_type
マクロは各インスタンス化ごとに 1 回呼び出されますが、すべての reveal_type_helper
呼び出しは同じ t
型で同じ場所にあります。 { <loc>: Int32 }
パラメータで以前に解決した問題に再び陥ります。
def dig_first(xs)
case xs
when Nil
nil
when Enumerable
reveal_type(dig_first(xs.first))
else
xs
end
end
dig_first([[1,[2],3]])
Revealed type /path/to/program.cr:6:17
dig_first(xs.first)
Revealed type /path/to/program.cr:6:17
dig_first(xs.first)
Revealed type /path/to/program.cr:6:17
dig_first(xs.first)
Revealed type /path/to/program.cr:6:17
dig_first(xs.first)
: (Int32 | Nil)
ここでは主にコンパイル時のエクスペリエンスに関心がありますが、reveal_type_helper
呼び出しは値型の値を複製し、**実行中のプログラムのセマンティクスを変更する可能性があります**。
struct SmtpConfig
property host : String = ""
end
struct Config
property smtp : SmtpConfig = SmtpConfig.new
end
config = Config.new
config.smtp.host = "example.org"
pp! config # => Config(@smtp=SmtpConfig(@host="example.org"))
config.smtp
の周りに reveal_type
を追加すると
reveal_type(config.smtp).host = "example.org"
プログラムの出力が変更されます
config # => Config(@smtp=SmtpConfig(@host=""))
メモリレイアウトを保持する代替の reveal_type
実装を行うことができますが、前の再帰的プログラムでさえコンパイルに失敗します。いずれにせよ、以下はそのバリエーションです
def reveal_type_helper(t : T, l) : Nil forall T
{%- puts " : #{T}" %}
end
macro reveal_type(t)
{%- loc = "#{t.filename.id}:#{t.line_number}:#{t.column_number}" %}
{%- puts "Revealed type #{loc.id}" %}
{%- puts " #{t.id}" %}
%t = uninitialized typeof({{t}})
reveal_type_helper(%t, { {{loc.tr("/:.","___").id}}: 1 })
{{t}}
end
これで、他に思いつく注意事項はありません!
コンパイラのアイデア
コンパイラにより良い reveal_type
を実装することは間違いなく可能です。まずは、コンパイラのためにメソッド名を予約する必要があります.
ユーザー定義のマクロ/メソッドを必要としないため、注意事項で説明した問題の影響を受けません。
しかし、将来的にさらに多くのユースケースを可能にするために、私たちができる中間的なことがあるかもしれません。
reveal_type
マクロでは、式とその位置を表示する必要がありました。これは、AST#raise
ですでに実行されていることです。残念ながら、出力を調整することはできず、常にコンパイラエラーとして扱われます。
macro reveal_type(t)
{%- t.raise "Lorem ipsum" %}
end
In program.cr:24:1
24 | reveal_type(config.smtp).host = "example.org"
^----------
Error: Lorem ipsum
ユーザーコードで定義されたreveal_type
を維持したい場合、情報を表示するためだけにAST#raise
に似たものがあると便利だと思います。これにより、位置、式、および^-------
がすでに解決され、メッセージをカスタマイズできるようになります。さらに、
AST#raise
のようにコンパイルを中止することなく、複数の情報呼び出しを許可できます。- 特定の実行ライフサイクル(たとえば、
AST#at_exit_info
)を持つことにより、最終ノードタイプなど、追加情報にアクセスできます。 - 追加のコンパイル時ツールを試すために使用できます(例:データベースとモデルが互いに最新であるかどうかを確認する)。
これらのアイデアのいくつかは、paulcsmith(Lucky)がコンパイラの動作を拡張する方法について行ったリクエストと大きく共鳴しています。AST#at_exit_info
またはAST#info
のようなものがその点で役立つと期待しています。
結論
最終的な解決策は、開発目的でCrystalアプリに簡単に追加できます。
def reveal_type_helper(t : T, l) : T forall T
{%- puts " : #{T}" %}
t
end
macro reveal_type(t)
{%- loc = "#{t.filename.id}:#{t.line_number}:#{t.column_number}" %}
{%- puts "Revealed type #{loc.id}" %}
{%- puts " #{t.id}" %}
reveal_type_helper({{t}}, { {{loc.tr("/:.","___").id}}: 1 })
end
bar
の型が不明なfoo(bar.baz)
のような式がある場合、
foo(reveal_type(bar).baz)
のように、bar
をreveal_type
で囲みます。- 通常どおりプログラムをビルドします。
- コンパイラの出力を以下のように確認します。
Revealed type /path/to/program.cr:14:15
bar
: (String | Nil)
前述のように、このソリューションにはいくつかの注意点がありますが、大多数のケースで機能すると思います。ユーザーコードのみを介してコンパイラツールを拡張できたことは素晴らしいことでした。
これに対する適切な組み込みの代替手段が価値があるかどうかについて、フィードバックをいただければ幸いです。