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_ostructUsage
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
endHow 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.rbMigrating 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 + rubocopReleasing
Bump SafeOstruct::VERSION, add a CHANGELOG section, then tag and push:
git tag v2.1.0 && git push origin v2.1.0The Release workflow verifies the tag matches the gem version, runs the test suite, builds the gem, and creates a GitHub Release with the CHANGELOG section as notes and the built .gem attached. If the RUBYGEMS_API_KEY repository secret is configured, it also publishes to rubygems.org (the API key must be allowed to push under your rubygems.org MFA settings).
License
The gem is available as open source under the terms of the MIT License.