Side Effects in Ruby

by Jeremy D. Frens on May 1, 2016
part of the Ruby, Elixir, Variable Bindings, and Concurrency series


I’ve been learning Elixir this past year, and it was pointed out to me relatively recently that there’s a contradiction: Erlang and the BEAM1 strictly forbid side effects2, but Elixir (which uses Erlang libraries and the BEAM) allows variables to be reassigned. It turns out that the variable reassignment is a bit of smoke and mirrors.

But before explain the smoke and mirrors, this week I’m going to look at how Ruby (and most other languages with closures) handle variable scoping and side effects. Next week I’ll talk about Elixir. The week after that I’ll talk about why this all matters.

Top scope

A variable defined in an irb session (or a script) is in the top scope as a local variable:

x = 1
x # => 1
defined?(x) # => "local-variable"

We can change the value in x:

x = 1
x = 5
x # => 5

We have reassigned a value to x; all previous values of x are gone.

The assignment operator is the fundamental side effect. The term is a bit derogatory: we intend for our code to compute some value, but there’s this pesky side effect which changes the value in a variable.3 Here’s a terribly contrived example:

x = 1
3 + (x = 5) # => 8
x # => 5

We intended to calculate the 8, but we also changed the value in x.

Shadowing

Consider this function:

h = ->(x) { x + 10 }

h declares x as a parameter. Due to lexical scoping, this is a completely different variable whose name happens to be the same as one at the top level. This parameter shadows the variable at the top level, so when we use h, it does not effect the x at the top level:

x = 5

h.(5) # => 15
x # => 5

h.(20) # => 30
x # => 5

h.(-8) # => 2
x # => 5

x remains 5.

Conversely, x at the top level doesn’t affect the computation of h:

x = 10
h.(5) # => 15

x = 1665
h.(5) # => 15

x = -3
h.(5) # => 15

h.(5) always evaluates to 15.

A function can reassign a new value to its parameters:

g = ->(x) { x = x + 10 ; x }

The parameter x again shadows the top-level x. So g can’t affect the top-level x, and the top-level x can’t affect g:

x = 5

g.(10) # => 20
x # => 5

x # => -3
g.(10) # => 20

Aside: alpha conversion

An alpha conversion is a variable renaming. It comes from the lambda calculus, used to prove two functions are the same.4 You’ve probably used alpha conversion through your refactoring tool where it’s called “Rename variable”. When you rename variables in your code so that all variables have a unique name, issues of which variable is used where become clearer:

x = 5
h = ->(a) { a + 10 }
g = ->(b) { b = b + 10 ; b }

x no longer appears in either function, so obviously they can’t affect x, and x can’t affect them.

Closures

Now consider the function k:

x = 5
k = ->() { x * 2 }

In order to evaluate this function, Ruby builds a closure: the function definition plus the environment it was defined in. If all the interpreter had were the function body, then what would x mean? The function needs to be paired up with its defining environment.

Now when we change x at the top level, k computes a different result:

x = 5
k.() # => 10

x = 20
k.() # => 40

x = 12
k.() # => 24

On the other hand, k does not change x:

x = 5
k.() # => 10
x = 5

So now consider f:

x = 5
f = ->() { x = x * 2 ; x }

f uses x and reassigns it a new value. When we execute f, it behaves like k when we change x at the top level:

x = 5
f.() # => 10

x = 20
f.() # => 40

x = 12
f.() # => 24

However, f has this annoying side effect:

x = 5
f.() # => 10
x # => 10

x = 20
f.() # => 40
x # => 40

x = 12
f.() # => 24
x # => 24

f changes x at the top level.

Worse yet, f has an effect on g!

x = 5
g.() # => 10
f.() # => 10
x # => 10
g.() # => 20
f.() # => 20
x # => 20
g.() # => 30

Aside revisited

When we do an alpha conversion on f and g to get unique variable names, nothing changes.

x = 5
k = ->() { x * 2 }
f = ->() { x = x * 2 ; x }

Both functions refer to and change the same top-level x.

Why does this matter?

In two weeks we will look at why side effect matter so much. But for now consider this: would you like to compute with h and g, or would you prefer k and f? I will take h and g every time.

Next Time

These examples are pretty contrived, but I wanted textually simple examples so you can see the scoping and assignment issues. While Ruby works more with objects and less with closures, I used closures in my examples to match up better with Elixir. We’ll eventually see how these same issues pop up in more traditional object-oriented Ruby code in a later article.

Next time, we’ll look at these same examples in Elixir. I don’t want to set expectations too high, but it really will blow your mind.

Footnotes

  1. The BEAM is the Erlang virtual machine. I cannot find a good URL for the BEAM.

  2. I’m focusing solely on reassigning a value to a variable that already has a defined value. Some consider input and output to be a side effect, but that’s a discussion for a different article for someone else to write.

  3. “We intend for this medication to make you feel better, but you’re also going to get a headache.” Headache is always the first side effect of any medication; it’s also the first side effect of reassigning a variable.

  4. The idea is that ->(x) { x * 2 } and ->(y) { y * 2 } are the exact same function, and we can prove that textually by renaming the variables in one of the functions to match the other.

ruby lexical-scope