This article was originally published on RubyPigeon by Tom Dalling, and with their permission, we are sharing it here for Codeship readers.
Shared mutable state is the source of a lot of bugs. When two or more objects use the same piece of mutable data, they all have the ability to break each other in ways that can be hard to debug. If the shared data is immutable, however, these objects can not affect each other, and are effectively decoupled.
This article is a review of the options available to Rubyists regarding immutability. We'll look at the built-in features of Ruby 2.3, and a few gems.
Standard Lib Freezing
Let's start with the freeze
method from the standard library:
Object#freeze
Prevents further modifications to
obj
. ARuntimeError
will be raised if modification is attempted. There is no way to unfreeze a frozen object. See alsoObject#frozen?
.This method returns self.
a = [ "a", "b", "c" ] a.freeze a << "z"
produces:
prog.rb:3:in `<<': can't modify frozen Array (RuntimeError) from prog.rb:3
Objects of the following classes are always frozen:
Fixnum
,Bignum
,Float
,Symbol
.
The freeze
method will work for almost any object, including instances of user-defined classes:
class Foo def mutate_self @x = 5 end end f = Foo.new f.freeze f.mutate_self #=> RuntimeError: can't modify frozen Foo
The only exception is classes that inherit from BasicObject
. The freeze
method is defined on Object
, so it is not available to instances of BasicObject
:
class BasicFoo < BasicObject; end bf = BasicFoo.new bf.freeze #=> NoMethodError: undefined method `freeze' for #<BasicFoo:0x007f912b9c3060>
You'll often see freeze
used when assigning constants, to ensure that the values can't be mutated. This is because reassigning a constant variable will generate a warning, but mutating a constant value will not.
module Family NAMES = ['Tom', 'Dane'] end # mutation is allowed Family::NAMES << 'Alexander' p Family::NAMES #=> ["Tom", "Dane", "Alexander"] # reassignment triggers a warning Family::NAMES = ['some', 'other', 'people'] #=> warning: already initialized constant Family::NAMES
So if you want to ensure that your constants are actually constant, you need to freeze the value:
module Family NAMES = ['Tom', 'Dane'].freeze end`
The main issue with the freeze
method is that it is shallow, as opposed to recursive. For example, a frozen array can not have elements added, removed or replaced, but the existing elements themselves are still mutable:
module Family NAMES = ['Tom', 'Dane'].freeze end Family::NAMES.first.upcase! p Family::NAMES #=> ["TOM", "Dane"]
Frozen String Literals In Ruby 2.3
You may have noticed that symbols and numbers are automatically frozen in Ruby. For example, it is impossible to implement this add!
method:
x = 5 x.add!(2) x == 7 #=> this can't be true
In most languages, string literals are also immutable, just like numbers and symbols. In Ruby, however, all strings are mutable by default.
This is changing in the next major version of Ruby. All string literals will be immutable by default in Ruby 3, but that is still a few years away. In the meantime, this functionality can be enabled optionally since Ruby 2.3.
There is a command line option available that enables frozen string literals globally:
ruby --enable-frozen-string-literal whatever.rb
Unfortunately, this will break a lot of preexisting code and gems, because most code was written assuming that string literals are mutable.
Until older code is updated to handle frozen strings, it's better to enable this option on a per-file basis using this "magic comment" at the top of each file:
# frozen_string_literal: true greeting = 'Hello' greeting.upcase! #=> RuntimeError: can't modify frozen String
When this magic comment exists, string literals inside the file will be frozen by default, but code in other files will be unaffected.
When you actually want a mutable string, you either have to create one with String#new
, or duplicate a frozen string using String#dup
:
# frozen_string_literal: true # this string is mutable x = String.new('Hello') x.upcase! puts x #=> 'HELLO' # and so is this y = 'World'.dup y.upcase! puts y #=> 'WORLD'
The ice
It turns out that recursively freezing an object properly is a little bit tricky, but thankfully there's a gem for that. The ice_nine
gem applies the freeze
method recursively, ensuring that an object is truely frozen:
require 'ice_nine' module Family NAMES = IceNine.deep_freeze(['Tom', 'Dane']) end Family::NAMES.first.upcase! #=> RuntimeError: can't modify frozen String
The gem also provides an optional core extension that defines Object#deep_freeze
, for convenience:
require 'ice_nine' require 'ice_nine/core_ext/object' module Family NAMES = ['Tom', 'Dane'].deep_freeze end
The values Gem – Immutable Struct-like Classes
Instead of freezing mutable objects, it's often better to create objects that are immutable by default. This is where the values
gem is useful.
If you're familiar with Struct
in the standard library, the values
gem is basically the same thing, except that it is immutable by default.
Here is some example code:
require 'values' # `Value.new` creates a new class, just like `Struct` Person = Value.new(:name, :age) # The `new` class method works just like `Struct` tom = Person.new('Tom', 28) puts tom.age #=> 28 # There is also the `with` class method, that creates an # object given a hash dane = Person.with(name: 'Dane', age: 42) puts dane.age #=> 42 # You can use the `with` instance method to create new objects # based existing objects, with some attributes changed ben = tom.with(name: 'Ben') p ben #=> #<Person name="Ben", age=28> p tom #=> #<Person name="Tom", age=28> # Unlike `Struct`, objects do not have any mutating methods defined tom.name = 'Ben' #=> NoMethodError: undefined method `name=' for #<Person name="Tom", age=28>
Just like Struct
classes, these Value
classes can have custom methods:
Fungus = Value.new(:genus, :species, :common_name) do def display_name "#{common_name} (#{genus} #{species})" end end f = Fungus.new('Amanita', 'muscaria', 'Fly agaric') puts f.display_name #=> Fly agaric (Amanita muscaria)
Unlike Struct
classes, these classes will throw errors if any attributes are missing upon creation. This is a good thing, as it alerts you to potential bugs instead of silently ignoring them.
Person = Value.new(:name, :age) Person.new('Tom') #=> ArgumentError: wrong number of arguments, 1 for 2 Person.with(age: 28) #=> ArgumentError: Missing hash keys: [:name] (got keys [:age])
These classes are only shallowly immutable, just like the built-in freeze
method. The objects themselves can not be changed, but their attributes can still be mutable.
tom = Person.new('Tom', 28) tom.name.upcase! p tom #=> #<Person name="TOM", age=28>
The whole gem is only about 100 lines of code, so it's easy to understand in its entirety.
For the majority of situations where you would use Struct
, I think Value
classes are the better choice. For the rare situations where you're trying get every last drop of performance, Struct
remains the better choice, at least on MRI. That's not to say that Value
classes are slow – they have the same performance as any other Ruby class, if not better due to aggressive hashing. In MRI, the Struct
class is implemented in an unusually performant way. In other implementations, such as JRuby, there may be no different in performance.
If you don't use Struct
classes, you may be wondering why and where you would want to use either of them. The best resource I can point you to is The Value of Values by Rich Hickey. It ultimately boils down to all the benefits of value semantics, which Rich explains in detail.
The adamantium Gem – Automatic Recursive Freezing
The adamantium
gem provides automatic recursive freezing for Ruby classes via the ice_nine
gem.
require 'adamantium' class Person include Adamantium attr_reader :name, :age def initialize(name, age) @name = name @age = age end def with_name(new_name) transform do @name = new_name end end end tom = Person.new('Tom', 28) dane = tom.with_name('Dane') p tom #=> #<Person:0x007f90b182bb28 @name="Tom", @age=28 ... p dane #=> #<Person:0x007f90b0b28048 @name="Dane", @age=28 ...
Adamantium works by overriding the new
class method. After an object has been allocated and its initialize
method has been run, it is frozen using the ice_nine
gem. This means that you can mutate the object from within initialize
, but never again.
To create new immutable objects from existing ones, there is the transform
method. This works by creating a mutable clone, running a mutating block on the clone, and then deep freezing the clone before returning it. You can see an example of this in the with_name
method above.
Adamantium requires more boilerplate than the values
gem, but it does proper recursive freezing. It also has functionality for automatically memoizing and freezing the return values of methods.
The anima Gem – Includable Value Semantics
The anima
gem is basically a hybrid of the values
gem, and the adamantium
gem.
require 'anima' class Person include Anima.new(:name, :age) end tom = Person.new(name: 'Tom', age: 28) rhi = tom.with(name: 'Rhiannon') p tom #=> #<Person name="Tom" age=28> p rhi #=> #<Person name="Rhiannon" age=28>
It has the succinctness of the values
gem, and uses Adamantium for automatic recursive freezing.
Think of this as the heavy-weight version of the values
gem. It has a few more features, but it also brings in five gems as dependencies: ice_nine
, memoizable
, abstract_type
, adamantium
and equalizer
. By comparison, the values
gem has no dependencies and is implemented in a single file with about 100 lines of code.
The Hamster Gem — Persistent Data Structures
The hamster
gem provides a set of persistent data structure classes. These classes are immutable replacements for standard Ruby classes like Hash
, Array
, and Set
. They work in a similar fashion to the other gems – objects can not be modified, but you can create new objects based on existing ones.
Working with immutable values often requires a lot of cloning, like copying a whole array just to append one new element. Persistent data structures provide better performance for these kinds of operations by reducing the number of objects that need to be cloned, and reusing as many objects as possible.
For example, if you wanted to create a frozen array from an existing frozen array, you would have to do something like this in plain Ruby:
original = [1, 2, 3].freeze new_one = original.dup # makes a copy new_one << 4 new_one.freeze p original #=> [1, 2, 3] p new_one #=> [1, 2, 3, 4]
With Hamster::Vector
, this would look like:
require 'hamster' original = Hamster::Vector[1, 2, 3] new_one = original.add(4) p original #=> Hamster::Vector[1, 2, 3] p new_one #=> Hamster::Vector[1, 2, 3, 4]
In the Hamster::Vector
version, new_one
might not be a full duplicate of original
. Internally, the new_one
value might only hold a 4
plus a reference to original
. Sharing internal state this way improves both speed and memory usage, especially for large objects. This all happens automatically under the hood, so you don't have to think about it.
For an overview of this topic, I recommend another Rich Hickey talk: Persistent Data Structures and Managed References. Skip ahead to 23:49 to get to the part specifically about persistent data structures.
Virtus Value Objects
I want to quickly mention the virtus
gem, even though I recommend against using it. It has some "value object" functionality that works very similarly to the values
and anima
gems, but with extra features around type validation and coercion.
require 'virtus' class Person include Virtus.value_object values do attribute :name, String attribute :age, Integer end end tom = Person.new(name: 'Tom', age: 28) sue = tom.with(name: 'Sue') p tom #=> #<Person name="Tom" age=28> p sue #=> #<Person name="Sue" age=28>
As for why I recommend against using it, let me quote the gem's author Piotr Solnica in this reddit thread:
The reason why I’m no longer interested in working on virtus is not something I can explain easily, but I will try.
[...]
[It] has been optimized for a specific use case of storing data from a web form in order to make our lives simpler and this functionality was simply dumped into the ORM
[...]
I cargo-culted a mistake that was previously cargo-culted from ActiveRecord.
[...]
It took me a while to understand what has been really going on. Virtus is a gem that brings the legacy of DataMapper, which brings the legacy of... ActiveRecord. It’s been a long process to understand certain fundamental problems, once I have understood them, I began working on new libraries to solve those problems in a better way. The more I worked on those libraries, the more obvious it started to become that Virtus would have to be completely changed and would no longer serve the same purpose if I wanted to build it in a way that I think is correct.
Virtus tried to be a universal swiss army knife for coercions, as a natural consequence of being extracted from an ORM that shared a lot in common with ActiveRecord, it tried to do too much, with a lot of implicit behavior, weird edge cases, performance issues and complicated DSL.
[...]
Furthermore attribute DSL with lots of options is an anti-pattern. That’s what I’ve learned over time. And it doesn’t end here - lack of actual type-safety is a problem, Virtus has a strict mode but it is impossible to get it right in a library used in so many different contexts.