在 Crystal 中顯示型別
最近,我從 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_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
如果我們有一個像這樣的表達式 foo(bar.baz)
,其中我們不確定 bar
的類型,我們可以
- 將
bar
用reveal_type
包圍起來,就像這樣:foo(reveal_type(bar).baz)
- 像平常一樣建置程式。
- 查看編譯器的輸出,如下所示:
Revealed type /path/to/program.cr:14:15
bar
: (String | Nil)
如同先前提到,這個解決方案有一些注意事項,但我認為它適用於絕大多數情況。僅透過使用者程式碼就能夠擴展編譯器工具,這真是太棒了。
如果這個問題有合適的內建替代方案,能獲得一些回饋會很棒。