Elixir modules offer some valuable insight into designing Ruby classes.
Ruby classes combine data and behavior in a typically object-oriented way:
- data: instances of the class hold state in instance variables
- behavior: methods on the class alter state.
Elixir modules also combine data and behavior:
- data: the module’s eponymous struct defines an immutable data structure
- behavior: the module’s functions define state transformations, often taking the struct as input
Perhaps we can combine these ideas to gain some functional-style benefits in Ruby!
Data: Problem
Ruby’s mutable values open the door to errors caused by out-of-sight state changes. Here’s a simple example:
# mutate the passed-in array 😈
def cause_mayhem(array)
array << nil
end
top_scores = [98, 95, 89]
cause_mayhem(top_scores)
top_scores.max # ArgumentError: comparison of Fixnum with nil failed
Unbeknownst to the user, cause_mayhem
altered the array. It wasn’t equal to its original value anymore! This is possible with many common objects in Ruby programming , eg String
, Hash
, ActiveRecord::Base
, ActiveRecord::Relation
and ActionController::Params
.
When you pass a value to another method, you have no way to know how your value will be affected. Maybe it will be changed under your feet!
Elixir’s immutable values offer a solution to this pitfall. When you pass a value to a function, your value won’t be changed because it’s impossible to change it!
cause_mayhem = fn(list) -> [99999 | list] end
top_scores = [98, 95, 89]
cause_mayhem.(top_scores) # => [99999, 98, 95, 89]
Enum.max(top_scores) # => 98
Although cause_mayhem
returned a new list, it didn’t alter the existing list. Changing the value of an existing item is impossible with Elixir! Because of this, you never have to worry about passing your value to another function. It can’t mess up existing code!
Data: Solution
The Ruby solution is to write classes whose state is immutable.
A mutable class is one whose instance variables change during its lifetime. An immutable class is one whose instance variables never change during its lifetime.
Here’s an example of refactoring a mutable class to be immutable.
First, a mutable Counter
:
class MutableCounter
attr_reader :count
def initialize
@count = 0
end
# Adds one to the internal value
def increment
@count += 1
end
end
Now, here’s the problem with this class. It leads to unpredictable code:
counter = MutableCounter.new
counter.count # => 0
counter.increment
counter.count # => 1
cause_mayhem(counter)
counter.count # => ????
It could be mutated by cause_mayhem
… but we have no idea!
Next, an immutable Counter
class
class ImmutableCounter
attr_reader :count
def initialize(count: 0)
@count = count
end
# Return a _new_ ImmutableCounter with an incremented count
def increment
self.class.new(count: @count + 1)
end
end
No matter how you call methods on that object, its @count
will not change after initialization.
Here’s our problem code again:
counter = ImmutableCounter.new
counter.count # => 0
counter = counter.increment
counter.count # => 1
cause_mayhem(counter)
counter.count # => 1 🎊
There’s no way cause_mayhem
could alter our counter!
But, what if you want to alter the value by some other method?
Easy: just make the method return the value you want to use. Here’s a modified example:
# Increment the counter three times and return the new one
def modify_counter(counter)
counter = counter.increment
counter = counter.increment
counter = counter.increment
counter
end
# usage:
counter = ImmutableCounter.new
counter.count # => 0
# store the old counter, just for example:
previous_counter = counter
# reassign the counter
counter = modify_counter(counter)
# counter has the new value:
counter.count # => 3
# previous_counter was unchanged:
previous_counter.count # => 0
In this case, the caller must explicitly receive the new value from the function. This makes it obvious to the reader that the function returned a new, useful value!
Behavior: Problem
In Ruby, classes express behavior by exposing public methods. These methods may alter internal state (like MutableCounter#increment
). Shared code may be DRYed up by being relocated to a private method.
Here’s an example:
class BaseballTeam
# ...
def add_player(player)
@players << player
# reset cached averages, etc:
update_team_aggregates
end
end
The problem is that state changes are scattered throughout the code. Some are visible inline, some are out-of-sight. This makes BaseballTeam
harder to understand.
To learn the behavior of add_player
, must also know the behavior of update_team_aggregates
. Any part of the BaseballTeam
’s internal state could have been altered in any way! At the end of the method body, there’s no guarantee that @players
contains the same objects it at the start of the method body. 😢.
In Elixir, any behavior that would mutate an object actually creates a new object. The analogous code is:
defmodule BaseballTeam do
def add_player(team, player) do
players = [player | team.players]
[avg_batting_avg, avg_salary, avg_pitching_record] = calculate_aggregates(players)
%{team | players: players, avg_batting_avg: avg_batting_avg, avg_salary: avg_salary, avg_pitching_record: avg_pitching_record}
end
end
In this case, it’s clear exactly which keys of the BaseballTeam
struct are updated when a players is added. It’s impossible for calculate_aggregates
to alter any other part of the team
!
Behavior: Solution
The Ruby solution is to write methods as pure functions, that is, methods which use their arguments as their only input (no accessing self
) and provide a return value as their only output (no side-effects).
Here’s a rewritten Ruby example:
class BaseballTeam
# ...
def add_player(player)
@players << player
@avg_batting_avg, @avg_salary, @avg_pitching_record = calculate_aggregates(@players)
end
end
In this case, it’s obvious which members of the team
’s internal state will be modified by add_player
. However, a developer could break the purely functional contract of calculate_aggregates
.
To avoid that, refactor BaseballTeam
to be a composition of @players
and @aggregates
:
class BaseballTeam
class AggregateStats
def initialize(players)
# ...
end
# ...
end
# ...
def add_player(player)
@players << player
@aggregates = AggregateStats.new(@players)
end
# Aggregate methods delegate to the AggregateStats object:
def avg_salary
@aggregates.avg_salary
end
end
Further defensive techniques could be taken, such as:
- Creating a new
@players
array instead of mutating the existing one. - Freezing
@players
to prevent other code from changing it
Those measures would guarantee correct state, but they may be “overkill” for some uses!
Conclusion
I can’t magically transform my Ruby app into an Elixir app, but I can take some of the lessons learned from Elixir and apply them to Ruby code! Plus, Ruby gives us the ability to mutate state when necessary (for example, when performance is critical).