跳至内容
GitHub 仓库 论坛 RSS-新闻源

Crystal 中的类型揭示

Brian J. Cardiff

最近,我偶然发现 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
  • 它可以用于尝试使用额外的编译时工具(例如:检查数据库和模型是否彼此最新)

其中一些想法与 paulcsmithLucky 的作者)关于如何扩展编译器行为的请求非常吻合。我认为像 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. reveal_type 包裹 bar,例如 foo(reveal_type(bar).baz)
  2. 像往常一样构建程序。
  3. 查看编译器输出,如
Revealed type /path/to/program.cr:14:15
  bar
    : (String | Nil)

如前所述,此解决方案有一些注意事项,但我认为它适用于绝大多数情况。能够通过用户代码扩展编译器工具是件好事。

如果此方法的内置替代方案具有价值,我们将很高兴收到您的反馈。