Crystal 中的类型揭示
最近,我偶然发现 reveal_type
来自 Sorbet 作为一种检查表达式类型的方式,感谢 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)
现有解决方案:上下文工具
大约 8 年前,Crystal 获得了一些内置工具,其中一个工具将为我们提供我们正在寻找的确切信息。
假设前面的 def maybe
定义在 program.cr
的开头,我们可以按如下方式使用上下文工具
% crystal tool context -c program.cr:2:3 program.cr
1 possible context found
| Expr | Type |
--------------------------
| x | String | Nil |
| default | String |
它将给我们更多我们想要的东西,因为将显示给定光标位置的所有变量/参数的类型。
它也将只使用编译时信息。程序在这种情况下永远不会执行,与 puts 调试相反。
不幸的是,该工具不允许我们打印任何表达式的类型,除非我们事先将其分配给一个变量。
上下文工具是一个独立的实现,它依赖于编译器,但本质上遍历整个编译后的程序。目前有一些边缘情况没有处理。
我认为最重要的缺点是开发者体验。除非与编辑器集成,否则它不太好。
将 reveal_type
添加到 Crystal
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
有了这个代码段,我们已经有了我们想要的输出。🎉
% 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++ 模板只有在使用时才会展开一样。
将所需输出拆分到宏和 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
宏将为每个实例化调用一次,但所有 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
如果我们有一个表达式,例如 foo(bar.baz)
,我们不确定 bar
的类型,我们可以
- 用
reveal_type
包裹bar
,例如foo(reveal_type(bar).baz)
- 像往常一样构建程序。
- 查看编译器输出,如
Revealed type /path/to/program.cr:14:15
bar
: (String | Nil)
如前所述,此解决方案有一些注意事项,但我认为它适用于绝大多数情况。能够通过用户代码扩展编译器工具是件好事。
如果此方法的内置替代方案具有价值,我们将很高兴收到您的反馈。