Skip to content

A variable is a reference, not a box

Level: 101 · anyone who has been surprised that changing b changed a

One line: Assignment copies a reference, never the object, so after b = a a mutating method such as << on b is seen through a, while b = b + x and b += x bind b to a new object and leave a alone — Python has the same model with one exception: += on a list mutates it in place.

A Ruby variable holds a reference to an object; the object lives elsewhere and can have any number of names at once. There are two different things you can do with a name, and everything on this page follows from telling them apart. You can mutate the object — <<, push, upcase!, []= — and every name that refers to it sees the change. Or you can rebind the name — =, and +=, which in Ruby is never anything but b = b + x — and no other name notices. A method's parameter is a name too, so a method can change the caller's object but cannot change what the caller's variable refers to. When you want an object of your own, dup makes a new one, one level deep.

Verified output of variables_are_references_rb.rb — regenerated by tools/run_examples.py, never hand-typed.

1. two names, one object
   a = [1]; b = a; b << 2; a                        [1, 2]
   b                                                [1, 2]
   a.equal?(b)                                      true
2. + makes a new object; = rebinds the name
   b = b + [3]; a                                   [1, 2]
   b                                                [1, 2, 3]
   a.equal?(b)                                      false
3. += is always a rebinding in Ruby
   b = a; b += [4]; a                               [1, 2]
   b                                                [1, 2, 4]
   a.equal?(b)                                      false
4. the same with a string
   s = "x"; t = s; t << "y"; s                      "xy"
   t += "z"; s                                      "xy"
   t                                                "xyz"
   s.equal?(t)                                      false
5. a method receives the reference
   push_four(orig); orig                            [1, 4]
   reassign(orig) returns                           [9]
   orig after reassign                              [1, 4]
   shout(word); word  (upcase! in place)            "HI"
6. dup breaks the sharing, one level deep
   base = [1, 2]; c = base.dup; c.equal?(base)      false
   c << 9; base                                     [1, 2]
   c                                                [1, 2, 9]
   nested = [[1]]; nested.dup[0] << 2; nested       [[1, 2]]
   Marshal round trip, then [0] << 3; nested        [[1, 2]]
7. the shared-default trap
   Array.new(3, "x"), then [0] << "!"               ["x!", "x!", "x!"]
   Array.new(3) { "x" }, then [0] << "!"            ["x!", "x", "x"]

Reading the output

  • Row 1. After b = a there is one array with two names. << through b is seen through a, and equal? confirms they are one object.
  • Row 2. + builds a new array and = binds b to it; a still refers to the old one, and equal? is now false.
  • Row 3. += is b = b + [4], a rebinding, so a is untouched. Ruby has no in-place +=.
  • Row 4. The same two operations on a string: << changes the shared object (s sees "xy"), += rebinds t to a new string (s stays "xy", t becomes "xyz").
  • Row 5. push_four mutates the caller's array; reassign rebinds only its own parameter, so orig is unchanged and the new array comes back only as the return value; upcase! changes the caller's string in place.
  • Row 6. dup copies one level: c is independent of base, but the inner array of nested.dup is still the same object as nested[0]. A Marshal round trip is the stdlib deep copy.
  • Row 7. Array.new(3, "x") is one string three times over; the block form Array.new(3) { "x" } evaluates the block once per slot.

Compared with Python

Python has the same model — its FAQ calls it call by object reference (Not machine-checked here.) — and rows 1, 2, 5, 6 and 7 print the same answers: assignment shares, + and = make a new list and rebind, a function sees the caller's list but cannot rebind the caller's name, copy.copy is shallow and copy.deepcopy is deep, and [[]] * 3 is the same trap as Array.new(3, "x"). Row 3 is the difference: b += [4] on a list calls list.__iadd__, which extends the list in place and returns the same object, so a changes too and a is b stays True; in Ruby += is a rebinding on every type. Row 4 is a difference of the opposite kind: a str is immutable, so there is no <<, t[0] = "y" is a TypeError, and += can only rebind — which is why "strings are safe to share" is true in Python by construction and true in Ruby only for frozen strings.

Verified output of variables_are_references_py.py — regenerated by tools/run_examples.py, never hand-typed.

1. two names, one object
   a = [1]; b = a; b.append(2); a                   [1, 2]
   b                                                [1, 2]
   a is b                                           True
2. + makes a new object; = rebinds the name
   b = b + [3]; a                                   [1, 2]
   b                                                [1, 2, 3]
   a is b                                           False
3. += on a list mutates in place in Python
   b = a; b += [4]; a                               [1, 2, 4]
   b                                                [1, 2, 4]
   a is b                                           True
4. the same with a string: no << exists
   s = "x"; t = s; t[0] = "y"                       TypeError
   t += "y"; s                                      'x'
   t                                                'xy'
   s is t                                           False
5. a function receives the reference
   push_four(orig); orig                            [1, 4]
   reassign(orig) returns                           [9]
   orig after reassign                              [1, 4]
   shout(word); word  (upper() returns new)         'hi'
6. copy.copy breaks the sharing, one level deep
   base = [1, 2]; c = copy.copy(base); c is base    False
   c.append(9); base                                [1, 2]
   c                                                [1, 2, 9]
   nested = [[1]]; copy.copy(nested)[0].append(2)   [[1, 2]]
   copy.deepcopy, then [0].append(3); nested        [[1, 2]]
7. the shared-default trap
   [[]] * 3, then [0].append('!')                   [['!'], ['!'], ['!']]
   [[] for _ in range(3)], then the same            [['!'], [], []]

The Python FAQ's entry on writing a function with output parameters ↗ states the rule for functions; the copy module ↗ documents the shallow and deep copies of row 6.

Try it

  1. Write sharing?(a, b) that returns a.equal?(b), then trace a = [1]; b = a, b << 2, b += [3], b = a.dup and b << 4, printing a, b and sharing?(a, b) after every step. At which step does b stop being a name for a's object?
  2. Build a 3×3 grid with Array.new(3, Array.new(3, 0)), set grid[0][0] = 1 and print the grid. Explain what you see, fix it with the block form, and print grid.map(&:object_id).uniq.size before and after — a count is stable where the ids themselves are not.
Solution to 1

variables_are_references_kata_rb.rb in full — pasted here by tools/run_examples.py from the file CI runs.

# Kata: trace whether a and b share one object after each step. equal? is the
# identity test; << mutates the shared object, += and dup make a new one.

def sharing?(a, b) = a.equal?(b)

def show(step, a, b)
  puts "   #{step.ljust(12)} a = #{a.inspect.ljust(12)} b = #{b.inspect.ljust(12)} sharing? #{sharing?(a, b)}"
end

a = [1]
b = a
show "b = a", a, b
b << 2
show "b << 2", a, b
b += [3]
show "b += [3]", a, b
b = a.dup
show "b = a.dup", a, b
b << 4
show "b << 4", a, b

Verified output of variables_are_references_kata_rb.rb — regenerated by tools/run_examples.py, never hand-typed.

   b = a        a = [1]          b = [1]          sharing? true
   b << 2       a = [1, 2]       b = [1, 2]       sharing? true
   b += [3]     a = [1, 2]       b = [1, 2, 3]    sharing? false
   b = a.dup    a = [1, 2]       b = [1, 2]       sharing? false
   b << 4       a = [1, 2]       b = [1, 2, 4]    sharing? false

See also