发布于 2026-01-06 0 阅读
0

冰冻宝石,或第一次收养的故事(评论 #1010)

冰镇宝石,或第一次收养的故事

评论 #1010

本文写于 Sorbet 首次公开发布几天后(2019 年 6 月 20 日)。如果您在几个月后阅读本文,文中描述的大部分内容可能已不再适用。

Stripe 终于开源了他们期待已久的 Ruby 静态类型检查器——Sorbet 。自从它首次宣布以来,我已经等了一年多了

说实话,我不太喜欢类型注解,或者更准确地说,我是“讨厌类型注解”的那一派。

但作为 Ruby 社区的一员,我很高兴看到这样的事情发生:这对 Ruby 本身来说是一次巨大的飞跃(尤其是与其他演进式特性相比,例如……) 。 管道Stripe团队做得非常棒👍!

在读了布兰登的第一印象(强烈推荐大家去看看)之后,我决定尝试一下 Sorbet,并将其融入到我的一个佳作中。

简而言之,类型检查器效果很好,但并不理想;注释很丑陋;工具还有很多需要改进的地方。

鲁巴诺克遇见雪芭

我决定在这个实验中使用rubanok:就代码量和元编程而言,它是我所有项目中最为简单的。

您可以在这里找到采用PR:https://github.com/palkan/rubanok/pull/5

bundle exec srb init

采用的第一阶段是将sorbetgem 添加到 bundle 中并生成所有必需的文件(在此处阅读更多内容并查看此过程的屏幕截图)。

可惜,事情进展得并不顺利:

$ bundle exec srb init

👋 Hey there!

...

Traceback (most recent call last):
    15: from /ruby/gems/2.6.0/gems/sorbet-0.4.4280/bin/srb-rbi:232:in `<main>'
         ...
     3: from /ruby/gems/2.6.0/gems/sorbet-0.4.4280/lib/gem_loader.rb:553:in `require'
     2: from /ruby/gems/2.6.0/gems/rspec-rails-3.8.2/lib/rspec-rails.rb:4:in `<top (required)>'
     1: from /ruby/gems/2.6.0/gems/rspec-rails-3.8.2/lib/rspec-rails.rb:6:in `<module:RSpec>' /ruby/gems/2.6.0/gems/rspec-rails-3.8.2/lib/rspec-rails.rb:8:in `<module:Rails>':
     uninitialized constant Rails (NameError)
Enter fullscreen mode Exit fullscreen mode

RSpec Rails 在其代码中引用了常量,但代码中任何地方Rails都没有要求它(它应该在这里的某个地方)。rails

据我所知,Sorbet会评估每个文件来构建.rbi你的 bundle 的配置,但它不知道如何处理此类异常。

这是一个特殊情况,应该在后台解决rspec-rails(您是否愿意提交一个 PR?)。

关于可选依赖项,也存在类似的未解决的问题。

添加rails为开发依赖项有助于解决此问题(说实话,我也不知道为什么🤷‍♂️)。

之后,sorbet/项目目录中出现了一个名为 的新文件夹,其中包含许多.rbi文件

$ find sorbet -type f | wc -l
      55
Enter fullscreen mode Exit fullscreen mode

正如文档建议的那样,您应该将此目录添加到版本控制系统中。尽管类型签名目前并不占用太多资源node_modules/例如,不像 `type` 那样),但我希望这种情况将来会有所改变原始大小(不含 `type` .git/rubanok增加了 2.1MB,从 124KB 增加到约 2.2MB。

bundle exec srb tc

首次运行类型检查命令时,不应看到任何错误:

$ bundle exec srb tc
No errors! Great job.
Enter fullscreen mode Exit fullscreen mode

这是因为 Sorbet# typed: false默认会将魔法注释添加到所有源文件中。这种严格级别仅检查关键问题(常量、语法、无效签名)。

渐进式类型检查意味着你通过逐步更改(甚至)使你的源代码具有类型# typed: false感知# typed: true能力# typed: strong

问题一:不支持的功能。

我首先为核心 Rubanok 类启用类型检查——规则

$ be srb tc
lib/rubanok/rule.rb:62: Method Rubanok::Rule#empty? redefined without matching argument count. Expected: 0, got: 1 https://srb.help/4010
    62 |    def empty?(val)
            ^^^^^^^^^^^^^^^
    lib/rubanok/rule.rb:56: Previous definition
    56 |        def empty?
Enter fullscreen mode Exit fullscreen mode

这很奇怪;我没有重复的方法定义,RuboCop 应该很容易就能发现这个问题。Sorbet 为什么会这么认为呢?原因如下:

using(Module.new do
  refine NilClass do
    def empty?
      true
    end
  end

  refine Object do
    def empty?
      false
    end
  end
end)

def empty?(val)
  return false unless Rubanok.ignore_empty_values

  val.empty?
end
Enter fullscreen mode Exit fullscreen mode

看来 Sorbet 无法识别匿名模块,而是将其内容视为“父”模块的内容。希望这很容易修复,也希望这能成为一个不错的首次贡献:

评论 #1010

感谢您提交了如此优秀的错误报告,并提供了清晰的复现步骤!对于有兴趣贡献代码的人来说,这个问题应该很容易解决:https://github.com/sorbet/sorbet/blob/master/dsl/ClassNew.cc已经处理了Class.new,它还应该处理Module.new

请注意,这个问题与改进本身无关(尽管我认为它们根本不受支持,而且短期内也不会受支持)。

我通过将细化操作移到 Rule 类之外迅速解决了这个问题。 但代价是什么?这使得我们更不清楚为什么要进行这种改进,以及改进的重点在哪里。代码也变得更加复杂。而这仅仅是个开始……

问题二:流量敏感性的局限性

# typed: true切换到另一个类“飞机”,我发现了一个有趣的案例:

$ bundle exec srb tc
lib/rubanok/plane.rb:50: Method <= does not exist on NilClass component of T.nilable(Class) https://srb.help/7003
    50 |          if superclass <= Plane
                     ^^^^^^^^^^^^^^^^^^^
  Autocorrect: Use `-a` to autocorrect
    lib/rubanok/plane.rb:50: Replace with T.must(superclass)
    50 |          if superclass <= Plane
                     ^^^^^^^^^^

lib/rubanok/plane.rb:51: Method rules does not exist on Class component of T.nilable(Class) https://srb.help/7003
    51 |            superclass.rules.dup
                    ^^^^^^^^^^^^^^^^

lib/rubanok/plane.rb:51: Method rules does not exist on NilClass component of T.nilable(Class) https://srb.help/7003
    51 |            superclass.rules.dup
                    ^^^^^^^^^^^^^^^^
  Autocorrect: Use `-a` to autocorrect
    lib/rubanok/plane.rb:51: Replace with T.must(superclass)
    51 |            superclass.rules.dup
                    ^^^^^^^^^^
Enter fullscreen mode Exit fullscreen mode

违反规定的代码:

def rules
  return @rules if instance_variable_defined?(:@rules)

  @rules =
    if superclass <= Plane
      superclass.rules.dup
    else
      []
    end
end
Enter fullscreen mode Exit fullscreen mode

这是个 bug 吗?我觉得不是:据我所知,只有一种情况下会superclass返回 ` nilfalse` BasicObject。或者如果我们重新定义这个.superclass方法的话😜

建议的解决方案——使用T.must(superclass)——并不合适:我不希望我的代码仅仅为了满足类型系统的要求而使用肮脏的技巧。

我尝试用另一种方法让 Sorbet 满意——通过解包superclass值:

def rules
  return @rules if instance_variable_defined?(:@rules)

  @rules =
    if superclass && superclass <= Plane
      superclass.rules.dup
    else
      []
    end
end
Enter fullscreen mode Exit fullscreen mode

这样做没有任何效果——错误依旧。我又试了一遍:

def rules
  return @rules if instance_variable_defined?(:@rules)

  @rules =
    if superclass
      if superclass <= Plane
        superclass.rules.dup
      else
        []
      end
    else
      []
    end
end
Enter fullscreen mode Exit fullscreen mode

还是一样 :( 最后一次尝试(我以为):

def rules
  return @rules if instance_variable_defined?(:@rules)

  x = superclass
  @rules =
    if x
      if x <= Plane
        x.rules.dup
      else
        []
      end
    else
      []
    end
end
Enter fullscreen mode Exit fullscreen mode

几乎成功:

$ bundle exec srb tc
lib/rubanok/plane.rb:53: Method rules does not exist on Class https://srb.help/7003
    53 |              x.rules.dup
                      ^^^^^^^
Errors: 1
Enter fullscreen mode Exit fullscreen mode

但为什么它无法从检查中推断出超类呢x <= Plane
如果你查看影响 Sorbet 流敏感类型定义的完整构造列表Class#<,你会发现只有`<T>` 被支持,而 `<T>` 却没有被支持Class#<<🤷‍♂️

好的。我们将其替换x <= Planex < Plane(这实际上是一个破坏性更改:有人可能会在Rubanok::Plane类本身上定义全局规则,这不是一个好主意,但是……)。

问题三:签名与模块。

添加 Rule 和 Plane 的签名过程非常顺利(代码行数从 159 行增加到 196 行)。而且我不需要修改任何代码。

然后我启用了 DSL 模块的类型检查、映射匹配

这些模块实现了特定的 Rubanok 变换并扩展了该类Rubanok::Plane

第一个问题出现在一段相当标准的 Ruby 代码中。以下是一个简化的示例:

class Rule
  sig do
      params(
        fields: T::Array[Symbol],
        activate_on: T::Array[Symbol]
      ).void
    end
  def initialize(fields, activate_on: fields)
    # ...
  end
end

module Mapping
  def map(*fields, **options, &block)
    rule = Rule.new(fields, options)
  end
end
Enter fullscreen mode Exit fullscreen mode

这段代码会引发以下类型错误:

$ be srb tc
lib/rubanok/dsl/mapping.rb:25: Passing a hash where the specific keys are unknown to a method taking keyword arguments https://srb.help/7019
    25 |        rule = Rule.new(fields, options)
                       ^^^^^^^^^^^^^^^^^^^^^^^^^
Enter fullscreen mode Exit fullscreen mode

看起来合情合理:不允许将任意哈希值作为已知关键字参数传递。

#map让我们尝试使用形状为该方法添加签名

sig do
  params(
    fields: Symbol,
    options: {
      activate_on: T::Array[Symbol]
    },
    block:  T.proc.void
  ).returns(Rule)
end
def map(*fields, **options, &block)
  rule = Rule.new(fields, options)
end
Enter fullscreen mode Exit fullscreen mode

不出所料,这并没有起到作用:

$ bundle exec srb tc
./lib/rubanok/mapping.rb:34: Passing a hash where the specific keys are unknown to a method taking keyword arguments https://srb.help/7019
    34 |    rule = Rule.new(fields, options)
                   ^^^^^^^^^^^^^^^^^^^^^^^^^
  Got T::Hash[Symbol, {activate_on: T::Array[Symbol]}] originating from:
    ./lib/rubanok/mapping.rb:33:
    33 |  def map(*fields, **options, &block)
                           ^^^^^^^^^
Enter fullscreen mode Exit fullscreen mode

Got T::Hash[Symbol, {activate_on: T::Array[Symbol]}]看起来很可疑。它从哪里找到的以 Symbol 为键的哈希值?完全不知道。

我放弃了,直接复制了该方法的关键词#map

sig do
  params(
    fields: Symbol,
    activate_on: T::Array[Symbol],
    block: T.proc.void
  )
  .returns(T::Array[Rubanok::Rule])
end
def map(*fields, activate_on: fields, &block)
  rule = Rule.new(fields, activate_on: activate_on)
  # ...
end
Enter fullscreen mode Exit fullscreen mode

这似乎不太对劲:现在我需要考虑在三个不同的地方保持这些签名同步(类型检查器肯定会对此有所帮助),但我有可能丢失这个非常关键的activate_on: fields默认值(类型检查器对此无能为力)。

如果您知道如何在不更改代码本身的情况下添加签名,请留言!

模块的第二个问题在于它们仅用于扩展Rubanok::Plane类;因此,它们“了解”并利用 Plane API 的一些细节。例如,它们使用#rules以下方法:

def map(*fields, activate_on: fields, &block)
  rule = Rule.new(fields, activate_on: activate_on)
  # ...
  rules << rule
end
Enter fullscreen mode Exit fullscreen mode

Sorbet 无法理解我们的意图;因此,它报告了以下错误:

lib/rubanok/dsl/mapping.rb:38: Method rules does not exist on Rubanok::DSL::Mapping https://srb.help/7003
    38 |        rules << rule
Enter fullscreen mode Exit fullscreen mode

我在文档中找不到与这种情况类似的内容,只有关于接口的部分有用:我将模块标记为abstract!并定义了一个抽象 #rules方法:

sig { abstract.returns(T::Array[Rubanok::Rule]) }
def rules
end
Enter fullscreen mode Exit fullscreen mode

它解决了这个错误。额外提示:看看如果删除或重命名该Plane.rules方法会发生什么:

$ bundle exec srb tc
lib/rubanok/plane.rb:36: Missing definition for abstract method Rubanok::DSL::Mapping#rules https://srb.help/5023
    36 |    class << self
            ^^^^^
    lib/rubanok/dsl/mapping.rb:47: defined here
    47 |      def rules
              ^^^^^^^^^
Enter fullscreen mode Exit fullscreen mode

问题 4:元编程。

元编程使 Ruby 成为一门如此强大的语言(也使我爱上 Ruby)。

没有元编程的 Ruby 就不是真正的 Ruby。

另一方面,这正是静态类型检查如此棘手的原因之一。我并不指望类型检查器非常智能,能够处理任何元信息;我只需要它提供一种规范的方法来处理如下所述的情况。

Matching#match模块提供的方法生成动态方法,这些方法依赖于几个 Plane 实例方法:

define_method(rule.to_method_name) do |params = {}|
  clause = rule.matching_clause(params)
  next raw unless clause

  apply_rule! clause.to_method_name, clause.project(params)
end
Enter fullscreen mode Exit fullscreen mode

雪芭不喜欢这样:

$ bundle exec srb tc
lib/rubanok/dsl/matching.rb:106: Method raw does not exist on Rubanok::DSL::Matching https://srb.help/7003
     106 |          next raw unless clause
                         ^^^

lib/rubanok/dsl/matching.rb:108: Method apply_rule! does not exist on Rubanok::DSL::Matching https://srb.help/7003
     108 |          apply_rule! clause.to_method_name, clause.project(params)
                    ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
Errors: 3
Enter fullscreen mode Exit fullscreen mode

添加抽象方法的技巧并没有奏效(因为我们需要添加实例方法,而不是单例方法)。

重新生成隐藏定义也不起作用。

我发现没有比向仓库添加自定义 RBI 文件更好的办法了:

# sorbet/meta.rbi

# typed: true

module Rubanok::DSL::Matching
  sig { returns(T.untyped) }
  def raw
  end

  sig { params(method_name: String, data: T.untyped).returns(T.untyped) }
  def apply_rule!(method_name, data)
  end
end
Enter fullscreen mode Exit fullscreen mode

又一个临时解决方案。“够了,”我想,甚至都没尝试为Rails控制器集成启用类型检查。

我尝试用一​​个更简单的例子来检验隐藏定义的工作原理:

# typed: true

class A
  def x
    "y"
  end

  define_method(:xx) do
    x * 2
  end
end
Enter fullscreen mode Exit fullscreen mode

运行后,bundle exec srb rbi hidden-definitions我发现了以下这行代码sorbet/hidden-definitions/hidden.rbi

class A
  def xx(); end
end
Enter fullscreen mode Exit fullscreen mode

所以,Sorbet 发现了这个define_method。而且,不知何故,它也变成# typed: true# typed: false。改回来之后,我得到了:

$ bundle exec srb tc

lib/rubanok/a.rb:9: Method x does not exist on T.class_of(A) https://srb.help/7003
     9 |    x * 2
            ^
    lib/rubanok/a.rb:4: Did you mean: A#x?
     4 |  def x
          ^^^^^
Errors: 1
Enter fullscreen mode Exit fullscreen mode

从错误信息可以看出,Sorbet 将其视为#xx类方法。目前有一个相关的未解决问题:#64

问题五:运行时检查。

目前为止,我只尝试让静态检查通过,但还没有尝试运行代码:

$ bundle exec rspec

NameError:
  uninitialized constant Rubanok::Rule::T 
Enter fullscreen mode Exit fullscreen mode

没错,我们的代码里有签名,但是还没有加载。

我把那require "sorbet-static"行代码添加到了主项目文件中。我有点惊讶:

$ bundle exec rspec

LoadError:
  cannot load such file -- sorbet-static
Enter fullscreen mode Exit fullscreen mode

我的错:我以为可以使用 Sorbet 而无需进行运行时检查,而这正是sorbet-staticgem 的作用。

sorbet-runtime事实证明,如果你的代码中有签名,就无法避免这种情况。

我开始越来越讨厌类型注解:我不想给 gem 添加额外的依赖项,即使类型检查的开销小于 10%(这仍然大于 0%):

好,我们玩到最后吧。

添加代码后sorbet-runtime,我能够运行代码,甚至还发现了一个“问题”:

$ bundle exec rspec

Failure/Error: rules << rule

RuntimeError:
  You must use `.implementation` when overriding the abstract method `rules`.
    Abstract definition: Rubanok::DSL::Matching at /Users/palkan/dev/rubanok/lib/rubanok/dsl/matching.rb:119
    Implementation definition: #<Class:Rubanok::Plane> at /Users/palkan/dev/rubanok/lib/rubanok/plane.rb:53
Enter fullscreen mode Exit fullscreen mode

为什么静态分析没有发现这个问题?

更新:可以通过使用 `<input>` 而不是 `<input>` 来“禁用”运行时检查,T::Sig::WithoutRuntime.sig从而sig写入签名。而且,不能用 `<input>`extend T::Sig::WithoutRuntime代替extend T::Sig`😄`。

问题 6:调试。

我是该软件的重度用户binding.pry

在调试带有类型签名的代码时,我发现很难单步执行到方法内部:

你能找到原始方法隐藏在哪里吗?

总之,或者说,这仅仅是个开始。

Sorbet 是近几年来 Ruby 最重要的发展之一。

但这距离带来开发乐趣还很远。而你可以帮助它变得更好:不妨试用一下,报告问题,或者向背后的开发者(例如Dmitry PetrashkoPaul Tarjan)说声“谢谢!”。

我会用雪芭吗?

很有可能。但只能以下述方式进行。

将所有签名添加到代码库(完全采用)后,我希望能够将它们导出到一个.rbi文件中,从而清理代码库。这样,我的 Ruby 代码就能保持不变:更简洁,更易读。

这应该不会破坏静态检查或运行时检查,也就是说,如果sorbet-runtime加载了,运行时检查就会激活;否则,不会激活。静态检查应该能够正常工作,仅仅因为 RBI 文件的存在。

PS:我现在只谈论库的开发。

附言:我也想尝试一下 Steep,并将其流程与上面描述的方法进行比较。看看我更喜欢哪一个。


请访问https://evilmartians.com/chronicles阅读更多开发文章

文章来源:https://dev.to/evilmartians/sorbetting-a-gem-or-the-story-of-the-first-adoption-3j3p