Несколько лет назад я работал над очень большой базой кода 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 при замораживании экземпляров класса.
- Возможно, просто используйте литералы как константы, если сможете.