Freezing an instance of RecursiveOpenStruct in Ruby 3.1 prevents all access to the object, even read access.
Reproducing the issue
o = RecursiveOpenStruct.new({ a: 42 })
o.freeze
puts o.a
Expected behavior
# Ruby 2.6
42
# Ruby 3.1
42
Actual behavior
# Ruby 2.6
42
# Ruby 3.1
.bundle/gems/recursive-open-struct-1.1.3/lib/recursive_open_struct.rb:142:in `define_method': can't modify frozen object: #<RecursiveOpenStruct a=42> (FrozenError)
from .bundle/gems/recursive-open-struct-1.1.3/lib/recursive_open_struct.rb:142:in `block in new_ostruct_member'
from .bundle/gems/recursive-open-struct-1.1.3/lib/recursive_open_struct.rb:140:in `class_eval'
from .bundle/gems/recursive-open-struct-1.1.3/lib/recursive_open_struct.rb:140:in `new_ostruct_member'
from .bundle/gems/recursive-open-struct-1.1.3/lib/recursive_open_struct.rb:125:in `method_missing'
from (irb):2:in `<main>'
Preliminary investigation
The issue seems to be caused by a change in behavior in the underlying OpenStruct class introduced somewhere between Ruby 2.7 and Ruby 3.0.
The gist of the problem is that, once an object is frozen, it becomes impossible to define new methods on it.
Ruby 2.7 and prior
Up unitl Ruby 2.7, OpenStruct used #method_missing to lazily define reader and writer methods on attributes.
OpenStruct#freeze has a custom implementation that then defines all methods (using OpenStruct#new_ostruct_member!) before freezing the object. That way, we make sure that the methods are defined and accessible, since creating new methods on a frozen object is forbidden.
Since RecursiveOpenStruct does not override #freeze, it conserves a similar behavior, and all is well.
Ruby 3.0 and later
Starting with Ruby 3.0, OpenStruct changed strategies when it comes to defining methods. All the methods are defined eagerly during initialization (see OpenStruct#initialize). That means that the custom implementation of #freeze is no longer required, since all methods already exist whenever the object gets frozen.
However, RecursiveOpenStructoverrides #initialize and does not call super. That, in turn, means that when an instance of RecursiveOpenStruct receives #freeze, the methods are never defined before freezing. And, when attempting to access an attribute, the attempt to lazily define the methods fails with FrozenError.
Fix proposal
I see two ways to circumvent the problem.
Calling super during initialization, in order to benefit from the OpenStruct current initialization strategy.
Override #freeze to perform the definition of all attributes before freezing the object.
Solution 1 sounds better to me, since it would make it so that future evolutions of the OpenStruct implementation would automatically get carried over to RecursiveOpenStruct. I will prepare a PR that goes that way.
In general, a good rule of thumb is to always call super when overriding a method, unless we absolutely want to get rid of the original implementation.
Summary
Freezing an instance of
RecursiveOpenStruct
in Ruby 3.1 prevents all access to the object, even read access.Reproducing the issue
Expected behavior
Actual behavior
Preliminary investigation
The issue seems to be caused by a change in behavior in the underlying
OpenStruct
class introduced somewhere between Ruby 2.7 and Ruby 3.0.The gist of the problem is that, once an object is frozen, it becomes impossible to define new methods on it.
Ruby 2.7 and prior
Up unitl Ruby 2.7,
OpenStruct
used#method_missing
to lazily define reader and writer methods on attributes.OpenStruct#freeze
has a custom implementation that then defines all methods (usingOpenStruct#new_ostruct_member!
) before freezing the object. That way, we make sure that the methods are defined and accessible, since creating new methods on a frozen object is forbidden.Since
RecursiveOpenStruct
does not override#freeze
, it conserves a similar behavior, and all is well.Ruby 3.0 and later
Starting with Ruby 3.0,
OpenStruct
changed strategies when it comes to defining methods. All the methods are defined eagerly during initialization (seeOpenStruct#initialize
). That means that the custom implementation of#freeze
is no longer required, since all methods already exist whenever the object gets frozen.However,
RecursiveOpenStruct
overrides#initialize
and does not callsuper
. That, in turn, means that when an instance ofRecursiveOpenStruct
receives#freeze
, the methods are never defined before freezing. And, when attempting to access an attribute, the attempt to lazily define the methods fails withFrozenError
.Fix proposal
I see two ways to circumvent the problem.
super
during initialization, in order to benefit from theOpenStruct
current initialization strategy.#freeze
to perform the definition of all attributes before freezing the object.Solution 1 sounds better to me, since it would make it so that future evolutions of the
OpenStruct
implementation would automatically get carried over toRecursiveOpenStruct
. I will prepare a PR that goes that way.In general, a good rule of thumb is to always call
super
when overriding a method, unless we absolutely want to get rid of the original implementation.