recursive-open-struct
recursive-open-struct copied to clipboard
RecursiveOpenStruct#freeze does not play nice with Ruby 3.1
Summary
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, RecursiveOpenStruct
overrides #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 theOpenStruct
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.