Skip to content

Instantly share code, notes, and snippets.

Embed
What would you like to do?

Collections

ITEM: 16: Duplicate Collections Passed as Arguments Before Mutating Them

When inserting objects into collections or passing as parameters to methods, most are passed as reference, Fixnum are passed as value.

  • Consider adding a ! to methods that will change the parameters.
  • Consider using Array#reject instea of Array#delete_if.
class Tuner
  def initialize (presets)
    @presets = presets
    clean
  end

  private

  def clean
    # Valid frequencies end in odd digits.
    @presets.delete_if {|f| f[-1].to_i.even?} # Will modify the object passed as param outside the class
  end
end
class Tuner
  def initialize (presets)
    @presets = clean(presets)
  end

  private

  def clean (presets)
    presets.reject {|f| f[-1].to_i.even?} # Will make a copy omitting odd numbers
  end
end

Copying objects

Object#clone and Object#dup produce a shallow copy of obj—the instance variables of obj are copied, but not the objects they reference. clone copies the frozen and tainted state of obj.

clone, unlike dup, will make the copy frozen if the original is frozen.
If obj has singleton methods, clone will also duplicate any singleton class, unlike dup.

On Ruby 2.4+ clone(freeze: true) can recieve the freeze param as false to make an unfrozen copy.

In general, clone and dup may have different semantics in descendant classes. While clone is used to duplicate an object, including its internal state, dup typically uses the class of the descendant object to create the new instance.

class Tuner
  def initialize (presets)
    @presets = presets.dup
    clean # Modifies the duplicate.
  end
  ...
end

When copying collections, clone and dup don't make copies of the referenced objects in the colleciton.
When writing your own class you can override the initialize_copy method to control the depth of the duplication process.

Marshall class can serialize and deserialize the collection and its elements:

irb> a = ["Monkey", "Brains"]
irb> b = Marshal.load(Marshal.dump(a))
irb> b.each(&:upcase!); b.first # "MONKEY"
irb> a.last # "Brains"

Marshall limitations:

  • Memory heavy (would need to have in memory the original, the copy and the serialized version)
  • Not all objects can be serialized and will rise TypeError exceptions
    • Objs with clousures or singleton methods can't
    • Some Ruby core classes (eg. IO, File)

ITEM 17: Use Array Method to Convert Scalar Objects into Arrays

The Array method converts its arguments into an array

Array(1..5)   #=> [1, 2, 3, 4, 5]
Array(['Nadroj', 'Retep'])   #=> ["Nadroj", "Retep"]
Array(nil)   #=> []

h = {pepperoni: 20, jalapenos: 2}
Array(h)   #=> [[:pepperoni, 20], [:jalapenos, 2]]

ITEM 18: Consider Set for Efficient Item Inclusion Checking

Set is a class from the standard library, it has to be required.
Is a container that skips duplicates.

require('set')

class Role
  def initialize(name, permissions)
    @nae, @persissoins = name, Set.new(permissions)
  end
  
  def can?(permission)
    @permissoins.include?(permission)
  end

Set characteristics:

  • Needs to be required.
  • Uses obj's eql? to check for identity. Therfore the stored objs should be able to be hash keys.
  • Unordered container.
  • Can't index individual elements.

In case of wanting an order, consider SortedSet which is also part of the standard library.

ITEM 19: Know How to Fold Collections With Reduce

def sum(enum)
  enum.reduce(0) do |accumulator, element|
    accumulator + element
  end
end

The block doesn’t make any assignments.
After each iteration, reduce throws away the previous accumulator and keeps the return value of the block as the new accumulator. If you mutate the accumulator and return nil from the block then the accumulator for the next iteration will be nil. The argument given to reduce is the starting value for the accumulator, if omitted, the first element will be used and start the iteration cycle on the second element.

A symbol can be passed which will be called as a method on the accumulator and passing the current element as the argument:

def sum(enum)
  enum.reduce(0, :+)
end

To transform an Array to a Hash

array.reduce({}) do |hash, element|
  hash.update(element => true)
end

Another optimization using reduce could be when filtering and extracting information from a collection.

users.reduce([]) do |names, user|
  names << user.name if user.age >= 21
  names
end

ITEM 20: Consider Using a Default Hash Value

With Hash.new(param) you can set param as the default value to be returned on non existing keys, instead of nil

h = Hash.new(0)
a = h[:missing_key] # 0
h.keys # []
h[:missing_key] += 1 # 43
h.keys # [:missing_key]

But the default value is mutable.

h = Has.new([])
h[:missing_key] << "Hi"
h # []
h.keys # []
h[:missing_key2] # ["Hi"]

And beware of unintended beahaviour.

h = Has.new([])
h[:day] = h[:day] << "Sunday"
h[:month] = h[:month] << "January"
h[:month] # ["Sunday", "January"]
h.default # ["Sunday". "January"]

^ This can be avoided by passing Hash::new a block, that will be invoked when needs a default value.

h = Hash.new { [] }
h[:day] = h[:day] << "Sunday"
h[:month] = h[:month] << "January"
h[:day] # ["Sunday"]

The given block can receive 2 arguments, the hash itself and the accessed key.

h = Hash.new {|hash, key| hash[key] = [] }
h[:day] << "Sunday"
h[:holidays] # []
h.keys # [:weekdays, :holidays]

^ With this solution, every time a missing key is accessed, the block create a new entry in the hash and it’ll create a new array.
The correct way to check for a key/value existance is using has_key? method.

if hash.has_key?(:day)
  ...
end

Hash#fetch can be used to establish a default value in case of a non existing key, which would be the second parameter. Or if the second parameter is ommited, it will raise an exception on nonexistent key.

  h = {}
  h[:day] = h.fetch(:day, []) << "Sunday" # ["Sunday"]
  h.fetch(:missing_key)
  KeyError: key not found: :missing_key

ITEM 21: Prefer Delegation to Inheriting from Collection Classes

A class that inherence from Array, some of its methods will return a Array instead of the inhereted class.

class LikeArray < Array; end

x = LikeArray.new([1,2,3]) # [1,2,3]
y = x.reverse # [3,2,1]
y.class #=> Array # Is an Array instead of a LikeArray

LikeArray.new([1,2,3]) == [1,2,3] #=> true # maybe not the expected behaviour 

A solution can be found on Set's implementation, it has internally a Hash but never exposes it.
Delegation allows you to declare methods which should be forwarded along to a specific instance variable.

require('forwardable')

# A Hash clone that will rise exception when accessing nonexistent key
class RaisingHash
  extend(Forwardable)
  include(Enumerable)

  def_delegators(:@hash, :[], :[]=, :delete, :each,
                         :keys, :values, :length,
                         :empty?, :has_key?)
end

It can also change the access name of the method.

def_delegator(:@hash, :delete, :erase!) # Forward self.erase! to @hash.delete

To raise an error on nonexisting key access, you can pass a block to #new

def initialize
  @hash = Hash.new do |hash, key|
    raise(KeyError, "invalid key `#{key}'!") # raising an error will be the default value
  end
end

To make a invert method that will not return a hash but an instance of RaisingHash

def invert
  other = self.class.new
  other.replace!(@hash.invert)
  other
end

protected

def replace! (hash)
  # passes the block given to #new to ensure rising error on nonexisting key
  hash.default_proc = @hash.default_proc 
  @hash = hash
end

To make the class behave as any collection class: For cloning:

def initialize_copy (other)
  @hash = @hash.dup
end

For freezing and tainting:

def freeze # same for taint and untaint
  @hash.freeze
  super
end
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment