跳至內容
GitHub 儲存庫 論壇 RSS 新聞提要

在 Crystal 中顯示型別

Brian J. Cardiff

最近,我從 Sorbet 發現了 reveal_type,這是一種檢查表達式型別的方法,感謝 Brian Hicks。我想知道是否可以將其移植到 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 除錯

廣泛使用 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 獲得了一些內建工具,其中一個工具會給我們我們正在尋找的確切資訊。

假設先前的 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 除錯相反。

不幸的是,該工具不允許我們列印任何表達式的型別,除非我們先前將其分配給變數。

context 工具是一個單獨的實作,它依賴於編譯器,但本質上是遍歷整個已編譯的程式。目前有一些邊緣案例尚未處理。

我認為最重要的缺點是開發人員體驗。除非它與編輯器整合,否則它不是很好。

在 Crystal 中新增 reveal_type

Sorbet 的 reveal_type 的開發人員體驗非常棒

  • 對程式所需的修改很簡單,可以套用於任何有效的表達式。
  • 顯示資訊的型別檢查器是同一個。
  • 無需發現內部工具命令
  • 無需額外的編輯器整合
  • 它支援在一次傳遞中多個 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 中的巨集 每個不同的型別只執行一次

為了針對每個 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++ 範本在未被使用時不會展開。

將所需的輸出在巨集和 def 之間拆分對編譯器的執行順序非常敏感。以下程式碼會遇到這個問題:

"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 巨集會針對每個執行個體呼叫一次,但所有 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 of Lucky 關於如何擴展編譯器行為的要求非常相似。我期望類似於 AST#at_exit_infoAST#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

如果我們有一個像這樣的表達式 foo(bar.baz),其中我們不確定 bar 的類型,我們可以

  1. barreveal_type 包圍起來,就像這樣:foo(reveal_type(bar).baz)
  2. 像平常一樣建置程式。
  3. 查看編譯器的輸出,如下所示:
Revealed type /path/to/program.cr:14:15
  bar
    : (String | Nil)

如同先前提到,這個解決方案有一些注意事項,但我認為它適用於絕大多數情況。僅透過使用者程式碼就能夠擴展編譯器工具,這真是太棒了。

如果這個問題有合適的內建替代方案,能獲得一些回饋會很棒。