A Review Of Immutability In Ruby

??? words · ??? min read

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. A RuntimeError will be raised if modification is attempted. There is no way to unfreeze a frozen object. See also Object#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_nine Gem – Recursive Freezing

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.

Coming Up Next: Discipline And Functional Style

This article has only covered options that are kind of heavy-handed. They all require gems, or extra code.

The next article will be about what I call “functional style” – using discipline to avoid mutation, instead of enforcing immutability. It requires no extra gems, and no extra code. Stay tuned!

Got questions? Comments? Milk?

Shoot an email to [email protected] or hit me up on Twitter (@tom_dalling).

← Previously: Talking Ruby And TDD With Viking Code School

Next up: Avoid Mutation – Functional Style In Ruby →

Join The Pigeonhole

Don't miss the next post! Subscribe to Ruby Pigeon mailing list and get the next post sent straight to your inbox.