冰镇宝石,或第一次收养的故事
评论 #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)
RSpec Rails 在其代码中引用了常量,但代码中任何地方Rails都没有要求它(它应该在这里的某个地方)。rails
据我所知,Sorbet会评估每个文件来构建.rbi你的 bundle 的配置,但它不知道如何处理此类异常。
这是一个特殊情况,应该在后台解决rspec-rails(您是否愿意提交一个 PR?)。
关于可选依赖项,也存在类似的未解决的问题。
添加rails为开发依赖项有助于解决此问题(说实话,我也不知道为什么🤷♂️)。
之后,sorbet/项目目录中出现了一个名为 的新文件夹,其中包含许多.rbi文件:
$ find sorbet -type f | wc -l
55
正如文档建议的那样,您应该将此目录添加到版本控制系统中。尽管类型签名目前并不占用太多资源(node_modules/例如,不像 `type` 那样),但我希望这种情况将来会有所改变。原始大小(不含 `type` .git/)rubanok增加了 2.1MB,从 124KB 增加到约 2.2MB。
bundle exec srb tc
首次运行类型检查命令时,不应看到任何错误:
$ bundle exec srb tc
No errors! Great job.
这是因为 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?
这很奇怪;我没有重复的方法定义,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
看来 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
^^^^^^^^^^
违反规定的代码:
def rules
return @rules if instance_variable_defined?(:@rules)
@rules =
if superclass <= Plane
superclass.rules.dup
else
[]
end
end
这是个 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
这样做没有任何效果——错误依旧。我又试了一遍:
def rules
return @rules if instance_variable_defined?(:@rules)
@rules =
if superclass
if superclass <= Plane
superclass.rules.dup
else
[]
end
else
[]
end
end
还是一样 :( 最后一次尝试(我以为):
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
几乎成功:
$ 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
但为什么它无法从检查中推断出超类呢x <= Plane?
如果你查看影响 Sorbet 流敏感类型定义的完整构造列表Class#<,你会发现只有`<T>` 被支持,而 `<T>` 却没有被支持Class#<<🤷♂️
好的。我们将其替换x <= Plane为x < Plane(这实际上是一个破坏性更改:有人可能会在Rubanok::Plane类本身上定义全局规则,这不是一个好主意,但是……)。
问题三:签名与模块。
添加 Rule 和 Plane 的签名过程非常顺利(代码行数从 159 行增加到 196 行)。而且我不需要修改任何代码。
这些模块实现了特定的 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
这段代码会引发以下类型错误:
$ 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)
^^^^^^^^^^^^^^^^^^^^^^^^^
看起来合情合理:不允许将任意哈希值作为已知关键字参数传递。
#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
不出所料,这并没有起到作用:
$ 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)
^^^^^^^^^
这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
这似乎不太对劲:现在我需要考虑在三个不同的地方保持这些签名同步(类型检查器肯定会对此有所帮助),但我有可能丢失这个非常关键的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
Sorbet 无法理解我们的意图;因此,它报告了以下错误:
lib/rubanok/dsl/mapping.rb:38: Method rules does not exist on Rubanok::DSL::Mapping https://srb.help/7003
38 | rules << rule
我在文档中找不到与这种情况类似的内容,只有关于接口的部分有用:我将模块标记为abstract!并定义了一个抽象 #rules方法:
sig { abstract.returns(T::Array[Rubanok::Rule]) }
def rules
end
它解决了这个错误。额外提示:看看如果删除或重命名该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
^^^^^^^^^
问题 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
雪芭不喜欢这样:
$ 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
添加抽象方法的技巧并没有奏效(因为我们需要添加实例方法,而不是单例方法)。
重新生成隐藏定义也不起作用。
我发现没有比向仓库添加自定义 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
又一个临时解决方案。“够了,”我想,甚至都没尝试为Rails控制器集成启用类型检查。
我尝试用一个更简单的例子来检验隐藏定义的工作原理:
# typed: true
class A
def x
"y"
end
define_method(:xx) do
x * 2
end
end
运行后,bundle exec srb rbi hidden-definitions我发现了以下这行代码sorbet/hidden-definitions/hidden.rbi:
class A
def xx(); end
end
所以,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
从错误信息可以看出,Sorbet 将其视为#xx类方法。目前有一个相关的未解决问题:#64。
问题五:运行时检查。
目前为止,我只尝试让静态检查通过,但还没有尝试运行代码:
$ bundle exec rspec
NameError:
uninitialized constant Rubanok::Rule::T
没错,我们的代码里有签名,但是还没有加载。
我把那require "sorbet-static"行代码添加到了主项目文件中。我有点惊讶:
$ bundle exec rspec
LoadError:
cannot load such file -- sorbet-static
我的错:我以为可以使用 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
为什么静态分析没有发现这个问题?
更新:可以通过使用 `<input>` 而不是 `<input>` 来“禁用”运行时检查,T::Sig::WithoutRuntime.sig从而sig写入签名。而且,不能用 `<input>`extend T::Sig::WithoutRuntime代替extend T::Sig`😄`。
问题 6:调试。
我是该软件的重度用户binding.pry。
在调试带有类型签名的代码时,我发现很难单步执行到方法内部:
你能找到原始方法隐藏在哪里吗?
总之,或者说,这仅仅是个开始。
Sorbet 是近几年来 Ruby 最重要的发展之一。
但这距离带来开发乐趣还很远。而你可以帮助它变得更好:不妨试用一下,报告问题,或者向背后的开发者(例如Dmitry Petrashko和Paul 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
