SafeOstruct
A fast, dependency-free OpenStruct alternative. Published as the safe_ostruct gem.
SafeOstruct gives you the ergonomics of OpenStruct (dynamic attributes, method-style access) at near-Hash speed. Attribute reads and writes are real methods on cached per-key-set "shape" classes, not method_missing dispatch, so they run at Struct speed (within ~10% of raw Hash access). Undefined attributes return nil instead of raising NoMethodError, which makes it a natural fit for API response objects, test doubles, and data with optional fields.
Installation
Add to your Gemfile:
gem "safe_ostruct"
Or install directly:
gem install safe_ostruct
Usage
require "safe_ostruct"
obj = SafeOstruct.new(name: "John Doe")
# Method-style and hash-style access, with symbols or strings
obj.name # => "John Doe"
obj[:name] # => "John Doe"
obj["name"] # => "John Doe"
# Add attributes dynamically
obj.address = "123 Main St"
obj[:city] = "Springfield"
# Undefined attributes return nil, never NoMethodError
obj.age # => nil
obj[:age] # => nil
# Remove attributes
obj.delete_field(:address)
obj.address # => nil
# Convert to a hash (a fresh copy, safe to mutate)
obj.to_h # => { name: "John Doe", city: "Springfield" }
# OpenStruct API parity
obj == SafeOstruct.new(name: "John Doe", city: "Springfield") # => true
obj.dig(:name) # => "John Doe"
obj.each_pair { |key, value| ... }
obj.inspect # => #<SafeOstruct name="John Doe", city="Springfield">
It also works as a JSON.parse object class for dot-notation access to parsed JSON:
require "json"
parsed = JSON.parse('{"user": {"name": "Jane"}}', object_class: SafeOstruct)
parsed.user.name # => "Jane"
And as a base class for simple result objects:
class ResponseResult < SafeOstruct
def success?
self[:status] == "success"
end
end
How it works
SafeOstruct.new returns an instance of a cached subclass generated per unique key set (a "shape"). The shape class carries real attr_accessor methods backed by instance variables, so obj.name is an ordinary inline-cached method call instead of method_missing dispatch. Creating many structs with the same keys reuses one class, so the method cache stays intact (the classic OpenStruct problem). The shape cache is capped at SafeOstruct::SHAPE_CACHE_LIMIT (1000) classes per base class; beyond that, instances degrade gracefully to method_missing-based access, so arbitrary key sets (e.g. from JSON) cannot create classes unboundedly.
method_missing remains as a fallback for attributes added after creation and for undefined attributes, which always return nil.
Behavior notes
These behaviors are intentional and covered by the test suite.
- Every undefined method returns
nil.obj.anythingreturnsnilrather than raisingNoMethodError. This includes typos, so prefer SafeOstruct where absent-means-nil is the semantics you want, and avoid it where a typo should fail loudly. to_hreturns a fresh hash. Mutating the returned hash does not affect the struct.respond_to?is true for shape attributes (getters and setters, even afterdelete_field), and true for dynamically added attributes while they hold a value. It stays false for attributes that were never set..classis an anonymous shape subclass. Useis_a?(SafeOstruct)(or your own base class) for type checks, not.class ==. Structs with equal attributes are==regardless of shape.- Keys that are not valid method names (e.g.
"foo-bar") or that collide with existing methods (e.g.:to_h,:class) are stored and fully accessible hash-style (obj[:"foo-bar"]), just not via dot access. Keys starting with__are reserved for internal storage. - Marshal is not supported because shape classes are anonymous. Serialize via
obj.to_hinstead. - Subclass instance variables appear in
to_h. Attribute storage is instance variables, so a subclass that memoizes internal state into an ivar will see it into_h; prefix internal ivars with__to exclude them.
Performance
Attribute access runs at Struct speed because shape attributes are real attr_accessor methods. Results from benchmark/ips.rb (Ruby 3.2.2, x86_64-linux, YJIT disabled, higher is better):
| Operation | Hash | Struct | SafeOstruct | OpenStruct |
|---|---|---|---|---|
| Defined attribute read | 61.0M i/s | 58.3M i/s | 55.6M i/s | 28.1M i/s |
| Attribute write | 42.1M i/s | n/a | 53.0M i/s | 20.0M i/s |
| Instantiation (3 attributes) | 13.6M i/s | 10.1M i/s | 2.0M i/s | 0.16M i/s |
| Undefined attribute read | n/a | n/a | 6.2M i/s | 7.2M i/s |
| Dynamically added attribute read | n/a | n/a | 6.9M i/s | n/a |
Hash-style read ([]) |
56.7M i/s | n/a | 20.0M i/s | 28.9M i/s |
Highlights:
- Reads are within 10% of raw Hash access and effectively at Struct speed. Writes are faster than
Hash#[]=. - Instantiation is ~12x faster than OpenStruct, which defines singleton methods per instance. It is slower than a bare Struct because of the shape-cache lookup; the cost is repaid after about two attribute accesses per object.
- Attributes added after creation (and undefined reads) go through the
method_missingfallback at ~6-7M i/s, comparable to OpenStruct's lazily defined accessors. - YJIT narrows the remaining gaps further.
Run the benchmark yourself:
bundle exec ruby benchmark/ips.rb
Migrating from 1.x
Version 2.0.0 changed the storage model from a single internal hash to shape classes. Interface-compatible, with these semantic differences:
to_hreturns a fresh hash instead of the live internal hash; code that mutated the struct throughto_hmust use[]=instead.respond_to?now reflects shape accessors (true for initial attributes and their setters, even afterdelete_field)..classis an anonymous shape subclass rather thanSafeOstructitself;is_a?(SafeOstruct)still holds.Marshal.dumpno longer works; useto_h.
Development
bundle install
bundle exec rake # runs rspec + rubocop
License
The gem is available as open source under the terms of the MIT License.