Ruby-refrigerator: Freeze all core Ruby classes
github.com
github.com
Note that it doesn't prevent or discourage monkey-patching, it just requires that you apply your monkey-patches before freezing the core classes. The idea here is similar to OpenBSD's pledge, where startup and runtime are considered different phases of an application's lifecycle. After startup, you limit what the runtime can do. With refrigerator, you freeze the core classes at the end of startup. So monkey patches are allowed during startup, but not at runtime.
A class added a method with a require or dynamic definition and that was cause to crash a production activity of some kind? You'd discover the attempted modification via a new FrozenError being raised unexpectedly and crashing what you were doing.
Ruby is made to let you extend core classes--Rails does it all over the place. If I put a require behind a feature flag, this is probably going to surprise me when it fails. It might also make junior devs think gems "don't work" or are buggy if you use it in development, when they work fine? How well does this play with dynamic class loading in dev work in Rails? I would think it would be problematic as you can't draw a line in the sand about when everything is loaded, so it is a safe time to "freeze."
E.g. in a project with lots of dependencies, things can break if two libs patch the same class after an update. A worse scenario: malicious code could be smuggled into core classes by any library that is compromised, e.g. to exfiltrate information at runtime. This would grant access even to information that is so sensitive that the system does not store it.
This is not the way to build long-lived software that outlives your team. This is how you create upgrade and migration headaches and make it difficult for new people to join and be productive.
Chasing down obscure behaviors and actions at a distance is not fun. Being blocked from upgrades is not fun. Having to patch a thousand failing tests is not fun.
I have serious battle scars from these bad practices.
Application and nearly all library code should not do this. (There can be exceptions for libraries, but they should be used sparingly.)
A framework like Rails? A reasonable place to do this sort of stuff, because a framework implies your entire application depends on the framework, and the framework is well managed (otherwise you wouldn’t be using it).
Like you said: “you” shouldn’t do this. I feel like your pain from this comes from someone being too clever, outside of a framework, hijacking core methods and classes.
Regarding redefinition of methods, Ruby can emit warnings when that happens (and you can make those fail your build for example). https://alchemists.io/articles/ruby_warnings
Why? What do you get from globally modifying a very important class, instead of just creating a regular method?
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
Why not the simpler version = RUBY_VERSION[0..2].sub('.', '').to_i
path = version.downto(18)
.map { |v| "#{__FILE__}/../../module_names/#{v}.txt" }
.map { |p| File.expand_path(p) }
.detect { |p| File.file?(p) }
Also this logic will definitely misbehave if there's ever a Ruby 3.xx.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.
https://docs.ruby-lang.org/en/3.2/Data.html
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 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.
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.
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.
Refinements let you "temporarily" modify a class within a lexical scope. E.g I have an experimental parser generator that heavily monkeypatches core classes, but only when lexically inside the grammar definition.
It lets you have the upsides of the footguns, but keeping them tightly controlled.
In many languages, "frozen" or immutable objects typically allow for better performance, less GC overhead, and lighter data-structures when running parallel.
But I can imagine, in Ruby (at least the default Matz' ruby runtime), where this freezing is the non-default and added to the language posterior, it makes everything slower. And I would imagine running a "freeze" loop over several hundreds, (or thousands?) of ob-classes takes some ms as well.
I would think this overhead is negligible, maybe even unmeasurable small. This is purely out of interest.
irb(main):001* class Array
irb(main):002* def hello
irb(main):003* puts "hello"
irb(main):004* end
irb(main):005> end
=> :hello
irb(main):006> [].hello
hello
This sort of monkey patching is used a lot in the ActiveSupport gem to give convenient methods such as: irb(main):001:0> 3.days.ago
=> Sat, 28 Dec 2024 13:10:24.562785251 GMT +00:00
Integer is extented with the `days` method making date maths very intuative.This gem freezes the core classes, essentially closing them, so doing the above raises an error:
irb(main):007> require 'refrigerator'
=> true
irb(main):008> Refrigerator.freeze_core
=> nil
irb(main):009* class Array
irb(main):010* def hello
irb(main):011* puts "hello"
irb(main):012* end
irb(main):013> end
(irb):10:in `<class:Array>': can't modify frozen class: Array (FrozenError)
Looking at the code in the gem, all it's doing is calling https://apidock.com/ruby/Object/freeze on all call modules. The frozen flag is an inbuilt part of the language and as far as I'm aware has a performance benefit. In fact the major verison of Ruby will have all string instances frozen by default.And tracking the mutation flags isn't free either. Though I expect not that big. Checking for the state of this flag at every possible mutation is quite some overhead, I'd presume. But no idea how heavy that'd be.
Again, compared to typical boot stuff like Rails' zeitwerk autoload mapping, it's nothing. And in runtime the only penalty will be when something tries to mutate a class obj, which I'd expect happens almost only on boot in most codebases anyway.
Though I know quite some "gems" and even older Rails implementations (mostly around AR) that would monkeypatch in runtime on instantation even. Like when a state-machine gets built on :new or when methods get added based on database-columns and types, every time such an instance is returned from a db response.
a handful of dependencies (mostly Pyramid based at the moment) & whatever dependencies those have, pull it all down & serve it out of a tarball or zip file of a portable virtualenv.
Wondering how it works with Rails or the the analysis starts after I freeze? So I could only track my app specific modifications, since thats the interesting part.