Skip to content

Instantly share code, notes, and snippets.

@JoelQ
Last active July 14, 2017 18:55
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save JoelQ/8c474db9b3a9528c16b3d0d11b9373d3 to your computer and use it in GitHub Desktop.
Save JoelQ/8c474db9b3a9528c16b3d0d11b9373d3 to your computer and use it in GitHub Desktop.
Proposed Weekly Iteration on mutation in Ruby

Weekly Iteration - Mutation

Outline

  • Intro
  • Explain mutation vs re-assignment
  • Discuss Gotchas
    • Mutating a value in a list
    • Array constructor
    • Hash constructor
    • Mutating constants
    • Freezing only works a single level
      • Ruby 2.3 frozen string pragma
      • Ruby 3 default frozen string literals
    • Other personal experiences
  • Best practices
    • AVOID mutating arguments
    • PREFER freezing constants
    • PREFER Enumerable methods to raw manipulation
    • PREFER non-bang Ruby methods
    • AVOID the shovel operator
    • PREFER returning over mutating
    • PREFER the block format for default array/hash values
  • Discuss our experience avoiding mutation
  • Discuss FP's lack of mutation
  • Discuss benefits of avoiding mutation in Ruby

Gotchas

Mutating references in a list

[1] pry(main)> list = ["foo", "bar", "baz"]
# => ["foo", "bar", "baz"]
[2] pry(main)> bar = list[1]
# => "bar"
[3] pry(main)> bar.clear
# => ""
[4] pry(main)> list
# => ["foo", "", "baz"]

Copying around the same reference.

You want to default all the values in the array to "foo" but then want to make the last one empty.

[1] pry(main)> list = Array.new(5, "foo")
# => ["foo", "foo", "foo", "foo", "foo"]
[2] pry(main)> list[4].clear
# => ""
[3] pry(main)> list
# => ["", "", "", "", ""]

You want to default all keys to an empty array and then append to it.

[1] pry(main)> master_list = Hash.new([])
# => {}
[2] pry(main)> master_list[:evens] << 2
# => [2]
[3] pry(main)> master_list[:odds] << 3
# => [2, 3]
[4] pry(main)> master_list
# => {}
[5] pry(main)> master_list[:evens] <<= 4
# => [2, 3, 4]
[6] pry(main)> master_list
# => {:evens=>[2, 3, 4]}
[7] pry(main)> master_list[:odds] <<= 5
# => [2, 3, 4, 5]
[8] pry(main)> master_list
# => {:evens=>[2, 3, 4, 5], :odds=>[2, 3, 4, 5]}

Mutating constants

Given:

def process_image(config)
  width = config.delete(:width)
  height = config.delete(:height)
  puts "processing image #{width}x#{height}"
end
[1] pry(main)> DEFAULT_CONFIG = { width: 640, height: 480 }
# => {:width=>640, :height=>480}
[2] pry(main)> process_image(DEFAULT_CONFIG)
# processing image 640x480
# => nil
[3] pry(main)> process_image(DEFAULT_CONFIG)
# processing image x
# => nil
[4] pry(main)> DEFAULT_CONFIG
# => {}

Freezing only works one level. Children can always be mutated unless they are frozen too.

[1] pry(main)> CONSTANT = ["foo"]
# => ["foo"]
[2] pry(main)> CONSTANT << "bar"
# => ["foo", "bar"]
[3] pry(main)> CONSTANT.freeze
# => ["foo", "bar"]
[4] pry(main)> CONSTANT << "baz"
# RuntimeError: can't modify frozen Array
# from (pry):4:in `__pry__'
[5] pry(main)> CONSTANT[1] << "baz"
# => "barbaz"
[6] pry(main)> CONSTANT
# => ["foo", "barbaz"]

Re-assigning is different than mutating. Both lead to a sad place.

Approaches to avoid this

AVOID mutating collaborators or arguments

def process_image(config)
  width = configr[:width]
  height = config[:height]
  puts "processing image #{width}x#{height}"
end

PREFER Enumerable methods to manipulating arrays/hashes directly

# Good
evens = [1,2,3,4].select(&:even)

# Bad
evens = []
[1,2,3,4].each do |n|
  evens << n if n.even?
end
evens
  • PREFER non-bang ruby methods
# good
numbers = [1,2,3,4]
evens = numbers.select(&:even)

# bad
numbers = [1,2,3,4]
numbers.select!(&:even)
numbers

AVOID the shovel operator <<

PREFER the block format for array/hash constructors

[1] pry(main)> master_list = Hash.new { [] }
# => {}
[2] pry(main)> master_list[:evens] <<= 2
# => [2]
[3] pry(main)> master_list[:odds] <<= 3
# => [3]
[4] pry(main)> master_list
# => {:evens=>[2], :odds=>[3]}
@SViccari
Copy link

huh, I did not see this coming. I'm surprised that another_list[1].clear clears all the items in the array while the first example does not.

[1] pry(main)> list = ["foo", "bar", "baz"]
# => ["foo", "bar", "baz"]
[2] pry(main)> list[1].clear
# => ""
[3] pry(main)> list
# => ["foo", "", "baz"]

[4] pry(main)> another_list = Array.new(3, "foo")
# => ["foo", "foo", "foo"]
[5] pry(main)> another_list[1].clear
# => ""
[6] pry(main)> another_list
# => ["", "", ""]

@JoelQ
Copy link
Author

JoelQ commented Jul 14, 2017

Yeah, this bites people all the time.

Array.new(3, "foo")

is equivalent to

foo = "foo"
another_list = [foo, foo, foo]

all three foos are the same object, therefore mutating one mutates them all.

@paulcsmith
Copy link

I like this a lot. There were a few gotchas there that I had no idea about. I think the part about freezing constants is particularly relevant because it's a fairly common issue

@SViccari
Copy link

I think this would make a terrific episode. I love the "gotcha" examples followed by the "preferred approach" examples.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment