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

使用实例变量的模块可能是有害的

背景

Rails 在某种程度上鼓励人们到处使用模块和实例变量。例如,在控制器、助手(helpers)和视图中使用实例变量。他们还鼓励使用 ActiveSupport::Concern,这进一步加强了将所有内容保存在一个巨大的单一对象中的理念,人们可以访问这个巨大对象中的所有内容。

问题

当然,这开发起来很方便,因为我们伸手就能拿到所有东西。然而,当所选对象增长时,这会有很多缺点,同样的原因会导致它日后失控。

在同一上下文中有太多东西,我们不知道这些东西是否紧密耦合,是否相互依赖。当复杂度增长到一定程度时,很难判断,这使得跟踪代码也变得极其困难。例如,一个类可能使用 3 个不同的实例变量,所有这些变量都可以从 3 个不同的模块中初始化和操作。当这些变量开始给我们带来麻烦时,很难追踪。我们不知道哪个模块会突然改变其中一个变量。任何东西都可能触碰任何东西。

类似的问题

人们说多重继承是坏的。到处散布的多个模块与多个实例变量的混合存在同样的问题。ActiveSupport::Concern 也是如此。参见: 考虑用专用类和组合替换 concerns

还有一个类似的想法: 使用装饰器和接口隔离来解决模型过度增长的问题

注意 included 并不能解决整个问题。它们定义了依赖关系,但仍然允许各个模块通过最终巨大对象中的实例变量进行隐式通信,这就是问题所在。

解决方案

我们应该将巨大的对象拆分成多个对象,它们通过 API(即公共方法)相互通信。简而言之,组合优于继承。这样,每个较小的对象都会有自己各自有限的状态,即实例变量。如果一个实例变量出了问题,我们会非常清楚它来自那个单独的小对象,因为没有人能触碰它。

有了明确定义的 API,这将使事物耦合度更低,调试和跟踪更容易,并且对其他对象使用来说更具可扩展性,因为它们以清晰的方式通信,而不是隐式依赖。

可接受的使用

然而,在模块中使用实例变量并不总是坏的,只要它被限制在同一个模块中;也就是说,没有其他模块或对象在触碰它们,那么这就是可接受的使用。

我们特别允许使用单个实例变量配合 ||= 来设置值的情况。这看起来像:

module M
  def f
    @f ||= true
  end
end

不幸的是,很难将更复杂的规则编码到 cop 中,所以我们依靠人们的最佳判断。如果我们能找到另一个可以轻松添加到 cop 中的好模式,我们应该这样做。

如何重写并避免禁用此 cop

即使我们可以禁用 cop,我们也应该避免这样做。一些代码可以很容易地以简单形式重写。考虑这个可接受的方法:

module Gitlab
  module Emoji
    def emoji_unicode_version(name)
      @emoji_unicode_versions_by_name ||=
        JSON.parse(File.read(Rails.root.join('fixtures', 'emojis', 'digests.json')))
      @emoji_unicode_versions_by_name[name]
    end
  end
end

这个方法完全没问题,因为它已经是自包含的。没有其他方法应该使用 @emoji_unicode_versions_by_name,我们很好。然而,它仍然违反了 cop,因为它不仅仅是 ||=,而且 cop 不够智能来判断这是可以的。

另一方面,我们可以将这个方法分成两个:

module Gitlab
  module Emoji
    def emoji_unicode_version(name)
      emoji_unicode_versions_by_name[name]
    end

    private

    def emoji_unicode_versions_by_name
      @emoji_unicode_versions_by_name ||=
        JSON.parse(File.read(Rails.root.join('fixtures', 'emojis', 'digests.json')))
    end
  end
end

现在 cop 不再抱怨了。

如何禁用此 cop

将禁用注释放在同一行代码的后面:

module M
  def violating_method
    @f + @g # rubocop:disable Gitlab/ModuleWithInstanceVariables
  end
end

如果有多行,你也可以对某个部分启用和禁用:

module M
  # rubocop:disable Gitlab/ModuleWithInstanceVariables
  def violating_method
    @f = 0
    @g = 1
    @h = 2
  end
  # rubocop:enable Gitlab/ModuleWithInstanceVariables
end

注意你需要在某个地方启用它,否则该点下面的内容不会被检查。

我们现在可能需要忽略的事情

由于 Rails 助手(helpers)和邮件发送器(mailers)的工作方式,我们可能无法避免在那里使用实例变量。对于这些情况,我们目前可以忽略它们。这些模块不与其他随机对象共享,所以它们仍然有些隔离。

视图中的实例变量

它们很糟糕,因为我们无法轻易地判断是谁在使用这些实例变量(从控制器的角度来看)以及我们在哪里设置它们(从局部模板 partials 的角度来看),这使得跟踪数据依赖变得极其困难。

我们尝试使用类似这样的东西:

= render 'projects/commits/commit', commit: commit, ref: ref, project: project

在局部模板中:

- ref = local_assigns.fetch(:ref)
- commit = local_assigns.fetch(:commit)
- project = local_assigns.fetch(:project)

这样能更清楚地知道这些值来自哪里,并且我们获得了使用实例变量时无法获得的拼写检查的好处。将来,我们也应该禁止在局部模板中使用实例变量。