Несколько лет назад я работал над очень большой базой кода Ruby on Rails, которая использовала константы для хранения списков состояний транзакций по кредитным картам. Например:

class Txn
  ACTIONABLE_STATES = [:authenticated, :to_settle]
  DONE_STATES = [:settled, :declined]
end

Однако у нас была ошибка, из-за которой расчетная транзакция возвращала true при вызове txn.state.in? ACTIONABLE_STATES.

txn.state
# :settled
txn.state.in? ACTIONABLE_STATES
# true

Очевидно, это было неправильно, потому что :settled не входит в ACTIONABLE_STATES! После нескольких часов поиска мы обнаружили проблемный код в совершенно другой части кодовой базы.

def some_method
  all_states = Txn::ACTIONABLE_STATES.concat(Txn::DONE_STATES)

  all_states.each do |state|
    # ... 
  end
end

Если вы уже знакомы с классом Ruby Array, вы, вероятно, уже заметили неприятную строку. #concat изменяет исходный список, даже если он не заканчивается идиоматическим !, и поскольку ACTIONABLE_STATES статически хранится в памяти, когда этот код запускается, он меняет ACTIONABLE_STATES состояние на всю оставшуюся жизнь этой виртуальной машины Ruby.

Другими словами, после выполнения some_method ACTIONABLE_STATES становится[:authenticated, :to_settle, :settled, :declined], пока вы не перезагрузите сервер.

Если у вас есть несколько модулей Ruby, работающих за балансировщиком нагрузки (как мы), это может еще больше запутать проблему. Поскольку some_method мог быть запущен только на определенных ящиках, вы можете получить случаи, когда один запрос не обнаружит ошибку, а другой идентичный будет.

Решение? Заморозьте константы.

ACTIONABLE_STATES = [:authenticated, :to_settle].freeze
DONE_STATES = [:settled, :declined].freeze

Это приведет к тому, что some_method выдаст can't modify frozen array (RuntimeError), и, если вы прошли юнит-тестирование some_method, вы, будем надеяться, поймаете это еще до того, как оно будет развернуто.

Но не теряйте бдительности! Если ваша константа имеет вложенные объекты, #freeze предотвратит мутацию только на объекте верхнего уровня. Например, если это ваш класс:

class Txn
  STATES = {
    actionable: [:authenticated, :to_settle],
    done: [:settled, :declined]
  }.freeze
end

Массивы под ключами :actionable и :done очень изменчивы! Вы тоже можете #freeze это сделать, но я бы не рекомендовал использовать сложные объекты в качестве констант, если только в этом нет необходимости. Если вы это сделаете, то обязательно #freeze их.

Все становится еще более сложным, если у вас есть константа, которая ссылается на экземпляр класса. Например:

class States
  attr_accessor :done, :actionable
  def initialize
    @actionable = [:authenticated, :to_settle]
    @done = [:settled, :declined]
  end
end
class Txn
  STATES = States.new.freeze
end

Поведение здесь очень сбивает с толку, потому что замораживаются только ссылки экземпляра, а не сами массивы.

Txn::STATES.actionable.concat(Txn::STATES.done)
Txn::STATES.actionable
# [:authenticated, :to_settle, :settled, :declined]
Txn::STATES.actionable = [:foo, :bar]
# can't modify frozen Array (FrozenError)

Да, вам, вероятно, все же следует использовать здесь #freeze, так как это снижает вероятность ранения себе в ногу. Однако я мог видеть аргумент о #freeze обеспечении ложного чувства безопасности в этом случае. Настоящий ответ - упростить ваши константы, чтобы вы не назначали им экземпляры классов, и вообще избегайте этой проблемы.

Обобщить:

  • Заморозьте свои константы.
  • При замораживании вложенного объекта замораживается только родительский объект.
  • Остерегайтесь поведения Ruby при замораживании экземпляров класса.
  • Возможно, просто используйте литералы как константы, если сможете.