In my own projects most of my Ruby classes are data objects that I freeze (along with their instance variables) immediately after creation. Methods can query the state of these objects but anything that involves changing state requires returning new objects (possibly of different classes).
Not mutating anything has prevented so many bugs. And as an added bonus most of my code has been able to be parallelized pretty trivially.
One style nit though;
version_int = RUBY_VERSION[0..2].sub('.', '').to_i
filepath = lambda do
File.expand_path(File.join(File.expand_path(__FILE__), "../../module_names/#{version_int}.txt"))
end
if version_int >= 18
# :nocov:
version_int -= 1 until File.file?(filepath.call)
# :nocov:
end
> In my own projects most of my Ruby classes are data objects that I freeze (along with their instance variables) immediately after creation
As of Ruby 3.2 you can use the Data class for this. Its instances are immutable by default and there is a convenient `#with` method to create modified copies.
One issue in both cases is that an attribute may be a complex object (e.g. a Hash), which not only is mutable itself, but may contain mutable objects at arbitrary nesting depths. Gems like https://github.com/dkubb/ice_nine or https://github.com/jaynetics/leto (shameless plug) can be used to "deep-freeze" such structures.
I've used 'ice_nine' previously. When returning to some Ruby work after some significant rust-gigs, I really loved the "immutable by default" idea and kept running into issues caused by accidental or unwanted mutation.
I couldn't use it though. For several reasons. I'd expect your leto (thanks!) has the same issues:
- deep-freezing large object trees added noticable lag. Makes sense, because we iterate over large structures, multiple times per code path.
- the idea of "copy on mutation" albeit a pattern I love, doesn't play nice with Ruby's GC. In apps with large data structures being pushed around (your typical REST+CRUD app, or anything related to im-/export, ETL etc) the GC now kicks in far, far more often and has more to clean up. I believe this was/is being worked on, don't know the details though.
- colleagues, especially the seniors entrenched in decade+ of Rails/Ruby work, didn't buy it, and tryd working around it with variations of "set_foo(val) { self.deep_clone.tap(&:unfreeze).tap(|s| s.foo = val).tap(&:freeze) }", which dragged a large app to screetching halt crashing servers and causing OOM kills.
I then remembered my own advice I often give:
> Don't try to make Ruby another Rust. We already have Rust, us that instead. Don't try to get Ruby to work exactly like Java. Just use Java if you need that.
> deep-freezing large object trees added noticable lag. Makes sense, because we iterate over large structures, multiple times per code path.
Yes. The main issue is that objects can reference each other in various ways, and we need to check these for each individual object. I wouldn't recommend deep-freezing large structures in this way unless they are only set up once, at boot time.
> the idea of "copy on mutation" albeit a pattern I love, doesn't play nice with Ruby's GC.
Data#with re-uses references, so it should limit GC pressure. But it's probably not convenient if you need to patch deeply nested objects.
> Don't try to make Ruby another Rust. We already have Rust, us that instead.
I think that's good advice, but it's also nice that we can make Ruby behave like a more strict language in parts of a codebase if we need only some parts to be rigid.
> but it's also nice that we can make Ruby behave like a more strict language in parts of a codebase
Certainly! But that should IMO be i) part of the language and runtime - e.g. some immutable do/end block, ii) used, and promoted by rails or at least some other popular frameworks and iii) become "idiomatic Ruby", in the sense that it's commonly known and used and iv) be default part of linters and checkers like rubocops defaults.
Otherwise it will always remain that weird idea that some people once saw, or read about, maybe tried and ran into issues, but no-one ever uses in practice.
I've seen so many good ideas like this in Ruby/Rails that ultimately failed or never really got off the ground. From the awesome "trailblazer" to the multiple attempts at async ruby.
In my own projects most of my Ruby classes are data objects that I freeze (along with their instance variables) immediately after creation. Methods can query the state of these objects but anything that involves changing state requires returning new objects (possibly of different classes).
Not mutating anything has prevented so many bugs. And as an added bonus most of my code has been able to be parallelized pretty trivially.
One style nit though;
Why not the simpler Also this logic will definitely misbehave if there's ever a Ruby 3.xx.