Help us learn about your current experience with the documentation. Take the survey.

Ruby 3 的陷阱

本节记录了我们在开发 Ruby 3 支持 时发现的几个问题, 这些问题导致了难以理解的细微错误或测试失败。我们鼓励每一位定期编写 Ruby 代码的 GitLab 贡献者熟悉这些问题。

要查看 Ruby 3 语言和标准库的完整变更列表,请参阅 Ruby 变更

Hash#each 一致地向 lambda 传递 2 元素数组

请看以下代码片段:

def foo(a, b)
  p [a, b]
end

def bar(a, b = 2)
  p [a, b]
end

foo_lambda = method(:foo).to_proc
bar_lambda = method(:bar).to_proc

{ a: 1 }.each(&foo_lambda)
{ a: 1 }.each(&bar_lambda)

在 Ruby 2.7 中,该程序的输出表明,向 lambda 传递哈希条目的行为会根据必需参数的数量而有所不同:

# Ruby 2.7
{ a: 1 }.each(&foo_lambda) # 打印 [:a, 1]
{ a: 1 }.each(&bar_lambda) # 打印 [[:a, 1], 2]

Ruby 3 使这种行为保持一致,并始终尝试将哈希条目作为单个 [key, value] 数组传递:

# Ruby 3.0
{ a: 1 }.each(&foo_lambda) # `foo': 参数数量错误 (给定 1,期望 2) (ArgumentError)
{ a: 1 }.each(&bar_lambda) # 打印 [[:a, 1], 2]

要编写在 2.7 和 3.0 下都能运行的代码,可以考虑以下选项:

  • 始终将 lambda 主体作为块传递:{ a: 1 }.each { |a, b| p [a, b] }
  • 解构 lambda 参数:{ a: 1 }.each(&->((a, b)) { p [a, b] })

我们建议始终显式传递块,并优先使用两个必需参数作为块参数。

有关更多信息,请参阅 Ruby 问题 12706

Symbol#to_proc 返回与 lambda 一致的签名元数据

Ruby 中常见的惯用法是使用 &:<symbol> 简写获取 Proc 对象,并将它们传递给高阶函数:

[1, 2, 3].each(&:to_s)

Ruby 将 &:<symbol> 解糖为 Symbol#to_proc。我们可以使用方法 接收器 作为其第一个参数(这里是 Integer), 并将所有方法 参数(这里为空)作为其余参数来调用它。

这种行为在 Ruby 2.7 和 Ruby 3 中是相同的。Ruby 3 的不同之处在于捕获这个 Proc 对象并检查其调用签名时。 这在编写 DSL 或使用其他形式的元编程时经常发生:

p = :foo.to_proc # 这通常通过 `&:foo` 的转换发生

# Ruby 2.7: 打印 [[:rest]] (-1)
# Ruby 3.0: 打印 [[:req], [:rest]] (-2)
puts "#{p.parameters} (#{p.arity})"

Ruby 2.7 报告此 Proc 对象有零个必需参数和一个可选参数,而 Ruby 3 报告一个必需参数和一个可选参数。 Ruby 2.7 是不正确的:第一个参数必须始终传递,因为它是 Proc 对象所表示方法的接收器, 而方法在没有接收器的情况下无法被调用。

Ruby 3 纠正了这一点:测试 Proc 对象数量或参数列表的代码现在可能会中断,需要更新。

有关更多信息,请参阅 Ruby 问题 16260

OpenStruct 不会延迟评估字段

OpenStruct 的实现在 Ruby 3 中经历了部分重写,导致行为发生变化。在 Ruby 2.7 中,OpenStruct 在方法首次访问时延迟定义方法。 在 Ruby 3.0 中,它在初始化器中急切地定义这些方法,这可能会破坏继承自 OpenStruct 并覆盖这些方法的类。

由于这些原因,不要继承 OpenStruct;理想情况下,根本不要使用它。 OpenStruct 被认为是有问题的。 在编写新代码时,优先使用 Struct,它的实现更简单,虽然灵活性较差。

RegexpRange 实例被冻结

不再需要显式冻结 RegexpRange 实例,因为 Ruby 3 在创建时会自动冻结它们。

这有一个微妙的副作用:在这些类型上存根方法调用的测试现在会失败并报错,因为 RSpec 无法存根冻结对象:

# Ruby 2.7: 有效
# Ruby 3.0: 错误: "can't modify frozen object"
allow(subject.function_returning_range).to receive(:max).and_return(42)

通过不在冻结对象上存根方法调用来重写受影响的测试。上面的示例可以重写为:

# 适用于任何 Ruby 版本
allow(subject).to receive(:function_returning_range).and_return(1..42)

使用 Ruby 3.0.2 时表测试失败

Ruby 3.0.2 有一个已知错误,当表值由整数值组成时,会导致 表测试 失败。 原因记录在 问题 337614 中。 这个问题已在 Ruby 中修复,预计该修复将包含在 Ruby 3.0.3 中。

这个问题只影响运行未打补丁的 Ruby 3.0.2 的用户。当你手动安装 Ruby 或通过 asdf 等工具安装时,很可能就是这种情况。 gitlab-development-kit (GDK) 的用户也受到此问题的影响。

构建镜像不受影响,因为它们包含解决此错误的补丁集。

如果方法被存根,DeprecationToolkit 不会捕获弃用警告

我们依赖 deprecation_toolkit 在使用 Ruby 2 中已弃用并在 Ruby 3 中删除的功能时快速失败。 从 Ruby 2 过渡到 Ruby 3 期间捕获的一个常见问题与 Ruby 3.0 中位置参数和关键字参数的分离 有关。

不幸的是,如果作者在测试中存根了这些方法,将不会捕获弃用警告。 我们通过 deprecation_toolkit 在测试中运行对此警告的自动检测, 但它依赖于 Kernel#warn 发出警告的事实,因此存根此调用将有效地移除警告调用,这意味着 deprecation_toolkit 永远不会看到弃用警告。 存根实现会移除该警告,我们永远不会捕获它,因此构建是绿色的。

有关更多上下文,请参阅 问题 364099

irbrails console 中测试

另一个陷阱是在 irb/rails c 中测试会静默弃用警告, 因为 Ruby 2.7.x 中的 irb 有一个 错误,阻止了弃用警告的显示。

在编写代码和进行代码审查时,请特别注意 f({k: v}) 形式的方法调用。 这在 Ruby 2 中是有效的,当 f 接受 Hash 或关键字参数时,但 Ruby 3 只有在 f 接受 Hash 时才认为这是有效的。 为了符合 Ruby 3,如果 f 接受关键字参数,这应该更改为以下调用之一:

  • f(**{k: v})
  • f(k: v)

RSpec with 参数匹配器对简写哈希语法失败

因为关键字参数(“kwargs”)是 Ruby 3 中的一等公民概念,关键字参数不再转换为内部 Hash 实例。 这导致当接收器采用位置选项哈希而不是 kwargs 时,RSpec 方法参数匹配器失败:

def m(options={}); end
expect(subject).to receive(:m).with(a: 42)

在 Ruby 3 中,此预期失败并显示以下错误:

  Failure/Error:

     #<subject> received :m with unexpected arguments
       expected: ({:a=>42})
            got: ({:a=>42})

发生这种情况是因为 RSpec 在这里使用 kwargs 参数匹配器,但该方法接受一个哈希。 它在 Ruby 2 中有效,因为 a: 42 首先被转换为哈希,RSpec 将使用哈希参数匹配器。

解决方法是,当我们知道方法接受选项哈希时,不要使用简写语法,而是传递实际的 Hash

# 注意键值对周围的括号
expect(subject).to receive(:m).with({ a: 42 })

有关更多信息,请参阅 RSpec 的官方问题报告