コンテンツにスキップ
GitHubリポジトリ フォーラム RSSニュースフィード

Crystal での型の表示

Brian J. Cardiff

最近、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 maybeprogram.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)を持つことにより、最終ノードタイプなど、追加情報にアクセスできます。
  • 追加のコンパイル時ツールを試すために使用できます(例:データベースとモデルが互いに最新であるかどうかを確認する)。

これらのアイデアのいくつかは、paulcsmithLucky)がコンパイラの動作を拡張する方法について行ったリクエストと大きく共鳴しています。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)のような式がある場合、

  1. foo(reveal_type(bar).baz)のように、barreveal_typeで囲みます。
  2. 通常どおりプログラムをビルドします。
  3. コンパイラの出力を以下のように確認します。
Revealed type /path/to/program.cr:14:15
  bar
    : (String | Nil)

前述のように、このソリューションにはいくつかの注意点がありますが、大多数のケースで機能すると思います。ユーザーコードのみを介してコンパイラツールを拡張できたことは素晴らしいことでした。

これに対する適切な組み込みの代替手段が価値があるかどうかについて、フィードバックをいただければ幸いです。