Skip to content
academia.sh

Lesson 07 / 12

Arrays and Hashes

Ruby's two basic collections, the array and the hash, are measured for which methods they answer themselves, which a shared module answers, and how a block decides the value of a missing key.

Published Updated

In this lesson

  • Writes the basic operations of arrays and hashes and reads their results
  • Measures with owner which class or module defines a collection method
  • Shows how a default-value block responds when a key is missing
  • Measures the difference between fetch and square brackets and shows that the block runs only when the key is absent
Contents

The Symbols and Strings lesson measured that the String class answers the message "name".to_sym, and that the resulting :name symbol can be compared with equal? because it carries the same value as a single object. The symbol’s real job did not show up in that lesson yet: being a key in a hash. This lesson takes the two basic collections, the array and the hash, and asks them the same question the course always asks: who answers the message sent to a method?

The course’s four responders do not change: class, module, block and syntax. In prose, a bare “class” is always Ruby’s Class, and the measurement category is called a “class responder”. Variables, types and loops were established as concepts in M01/K02; here an array is an ordered list of elements numbered from zero. A hash is a mapping from keys to values, and it keeps its elements in insertion order.

Basic Operations

The first measurement only shows behavior. An array is written with square brackets, << appends an element, a negative index counts from the end, and a range gives a slice. A hash is written with curly braces and the key: value form; in this form the keys are symbols.

# basics.rb — basic operations on arrays and hashes
items = [10, 20, 30]
items << 40
puts "first=#{items[0]} last=#{items[-1]} count=#{items.size} outside=#{items[10].inspect}"
puts "slice=#{items[1..2].inspect} first_two=#{items.first(2).inspect}"

person = { name: "Alice", age: 30 }
person[:city] = "Izmir"
puts "name=#{person[:name]} missing=#{person[:mail].inspect} keys=#{person.keys.inspect}"
person.each { |key, value| puts "#{key} -> #{value}" }
puts person.select { |_, value| value.is_a?(String) }.inspect
puts person.map { |key, value| key }.inspect
first=10 last=40 count=4 outside=nil
slice=[20, 30] first_two=[10, 20]
name=Alice missing=nil keys=[:name, :age, :city]
name -> Alice
age -> 30
city -> Izmir
{name: "Alice", city: "Izmir"}
[:name, :age, :city]

An index outside the array raises no error and returns nil; so does a key that is not in the hash. These two silent nil results are the ground for the second half of this lesson: what determines the result for a missing key? A hash keeps insertion order; the order on the keys line shows that city was appended at the end. select and map took the same two-parameter block form, but one returned a hash and the other an array. The reason is the ownership difference measured in the next section. CB1. The measurements were made with symbol-keyed hashes; hashes with string keys were not measured in this lesson and the results are not carried over to them.

Which Definition Carries the Method

Two methods written on the same collection can be defined in different places even when they are called the same way. method(:name).owner gives the class or module that defines that method. This lesson does not show the lookup order; it only reads the result, the owner.

# collection.rb — who owns the collection methods
items = [1, 2, 3]
pairs = { a: 1, b: 2 }

puts "items.size    #{items.method(:size).owner}"
puts "items.map     #{items.method(:map).owner}"
puts "pairs.each    #{pairs.method(:each).owner}"
puts "pairs.select  #{pairs.method(:select).owner}"
puts "pairs.fetch   #{pairs.method(:fetch).owner}"
puts "pairs.map     #{pairs.method(:map).owner}"
items.size    Array
items.map     Array
pairs.each    Hash
pairs.select  Hash
pairs.fetch   Hash
pairs.map     Enumerable

In Ruby 4.0.7, a class answered five of the methods: size and map are defined in Array, while each, select and fetch are defined in Hash. The sixth is different: pairs.map is not defined in Hash but in the Enumerable module. So the same map name is answered by the class on an array and by the module on a hash. Which definition holds which method can shift from version to version, so the result is read as a measurement of this version, not as a promise of the language. The Enumerable module is the third lesson and is only named here. In the previous section select returned a hash and map returned an array: select is Hash’s own method, so it builds its result as a hash too, while map, which comes from Enumerable, always produces an array.

Who Supplies the Value of a Missing Key

In the basic operations, person[:mail] returned nil. To change that behavior, a default value can be given when the hash is built. There are two forms: Hash.new(0) supplies a fixed value, and Hash.new { ... } supplies a block that runs when the key is missing. The block can take the key as a parameter, so the result can depend on the key. M12/K02 measured where a change to a shared default value ends up in PHP; that question is not asked here, only who produces the default value.

The course’s shared helper reply.rb is given first, verbatim. It classifies an expression with three questions in order: does defined? say it is a method call, do two different blocks give two different results, is the method’s owner a Class. It has no output block, so it is only written to disk.

# reply.rb — helpers that classify who answers a message

# Classification asks in order: is the expression a message at all (defined?),
# then do two different blocks give two different results, then who owns the method.
def responder(definition, owner: nil, result: nil, with_blocks: nil)
  return ["syntax", "defined? #{definition}"] unless definition == "method"

  if with_blocks
    first, second = with_blocks.call
    return ["block", "#{first.inspect} / #{second.inspect}"] unless first == second
  end

  kind = owner.instance_of?(Class) ? "class" : "module"
  [kind, "#{owner}, result #{result.inspect}"]
end

Now the core’s default value observation runs. Two Hash.new blocks look at the same [:x] message: one always returns 0, the other returns the text form of the key. After that, the fixed-default form and the two cases of defined? are printed.

# defaults.rb — who supplies the value of a missing key
require_relative "reply"

zero = Hash.new { 0 }
text = Hash.new { |_, key| key.to_s }

kind, evidence = responder(defined?(Hash.new), with_blocks: -> { [zero[:x], text[:x]] })
puts "#{kind}  #{evidence}"
puts Hash.new(0)[:x]
puts defined?(Hash.new)
puts defined?(Hash.new { 0 })
block  0 / "x"
0
method
expression

The first line is the core’s default value line: the responder is a block. The same [:x] message gave 0 and "x" on two hashes; the class of the object receiving the message was the same (Hash), and the only difference was the block supplied. So the answer is decided by the block, not by a class or a module. On the second line, Hash.new(0)[:x] with a fixed default also gave 0; there is no block there, the value was supplied at construction.

The last two lines show a subtlety of the rule. defined?(Hash.new) returns "method", but defined?(Hash.new { 0 }), the call with a block, returns "expression". This is why the core queries the message without a block and measures the effect of the block in the second step; if it queried the block form, the first step would count the message as syntax. CB2. The defined? result for a call with a block was measured in Ruby 4.0.7; the same result is not assumed in another version. This detail is named in the Blocks lesson.

Array Operations

The Basic Operations section wrote only << and single-index access. The other common array operations are appending, removing from the end, and slicing. The open-ended range a[1..] gives everything after the first element, and first(2) takes two elements from the front. push and << append; pop both removes the last element from the array and returns it.

# array_ops.rb — slicing, appending and removing
a = [5, 3, 8, 1]
puts "a[1..]=#{a[1..].inspect} a[-1]=#{a[-1]} first(2)=#{a.first(2).inspect}"
a.push(9)
a << 7
puts "after=#{a.inspect}"
puts "pop=#{a.pop} rest=#{a.inspect}"
a[1..]=[3, 8, 1] a[-1]=1 first(2)=[5, 3]
after=[5, 3, 8, 1, 9, 7]
pop=7 rest=[5, 3, 8, 1, 9]

The slice and first(2) each returned an array and did not change a: by the time execution reached the push line, a still held its original four elements. push(9) and << did the same job and the array grew to six elements. pop returned 7 and the array shrank to five elements: pop has two effects, returning a value and changing the array. CB19. Whether a slice is a new array or a view of the same memory was not examined; only the printed values were measured.

The In-Place Form

Many array methods have two forms: one that returns a new array and one that changes the array in place. For sorting, this pair is sort and its in-place sorting form. The difference is measured with equal?: do two variables point to the same object?

# in_place.rb — a new array versus changing the same array
a = [3, 1, 2]
b = a.sort
puts "new=#{b.inspect} a=#{a.inspect} same_object=#{a.equal?(b)}"
c = a.sort!
puts "in_place a=#{a.inspect} same_object=#{a.equal?(c)}"
puts "owner=#{a.method(:sort!).owner}"
new=[1, 2, 3] a=[3, 1, 2] same_object=false
in_place a=[1, 2, 3] same_object=true
owner=Array

sort gave a new array: b was sorted, a stayed in its original order, and equal? returned false. The in-place sorting form sorted a itself and returned the same object: a became [1, 2, 3] and equal? returned true. The owner of the in-place sorting form printed as Array, so its responder is a class; the difference between the two forms lies in whether the method’s result is a new object or the receiver itself. CB20. The measurement was made only on the sorting pair; the same pattern is not assumed for other pairs.

Order in a Hash and Nested Access

A hash keeps insertion order, and to_a turns it into an array of pairs. In nested hashes, dig follows the keys in order; if the path breaks somewhere, it returns nil instead of raising an error.

# hash_more.rb — insertion order, to_a and dig
h = { b: 1, a: 2 }
h[:c] = 3
puts h.keys.inspect
puts h.to_a.inspect
nested = { user: { address: { city: "Izmir" } } }
puts nested.dig(:user, :address, :city)
puts nested.dig(:user, :phone, :number).inspect
[:b, :a, :c]
[[:b, 1], [:a, 2], [:c, 3]]
Izmir
nil

h was first built with the keys b and a, and c was appended at the end; keys gave this order, and to_a gave the same order as pairs. There is no alphabetical ordering. dig followed three keys and found Izmir; in the second call :phone did not exist, and dig returned nil without raising an error. CB21. The order behavior was measured in Ruby 4.0.7 and only with this example.

The Shared Default Object

Giving the default value as an object instead of a block, in the form Hash.new([]), carries a trap: that object is a single one, and the same one comes back for every missing key. M12/K02 measured the behavior of a shared default value in another language; here the Ruby output is only read.

# shared.rb — a shared default object versus the block form
shared = Hash.new([])
shared[:a] << 1
shared[:b] << 2
puts "shared=#{shared.inspect} default=#{shared.default.inspect} keys=#{shared.keys.inspect}"
separate = Hash.new { |h, k| h[k] = [] }
separate[:a] << 1
separate[:b] << 2
puts separate.inspect
shared={} default=[1, 2] keys=[]
{a: [1], b: [2]}

shared[:a] << 1 received the default array for the missing key and appended to it; no key was assigned to the hash. That is why shared stayed empty, keys was [], and the default array became [1, 2]: :a and :b wrote into the same array. The block form, on the other hand, ran the block for every missing key and wrote the result into the hash with h[k] = []; the two keys got two separate arrays. The value of a missing key is supplied by a single object in the first form and by the block each time in the second. CB22.

Transforming a Hash

Producing a new hash or a single value from a hash is a frequent need. transform_values transforms the values, to_h with a block can change the keys as well, and sum comes through Enumerable. On the array side, compact drops nil elements, uniq removes duplicates, and flatten flattens a nested array.

# transform.rb — hash transformations and array cleanup
h = { a: 1, b: 2 }
puts h.transform_values { |v| v * 10 }.inspect
puts h.to_h { |key, v| [key.to_s, v] }.inspect
puts h.sum { |_, v| v }
puts h.min_by { |_, v| -v }.inspect
puts "owner=#{h.method(:transform_values).owner} #{h.method(:sum).owner} #{h.method(:to_h).owner}"
d = [3, 1, 2, 3, nil]
puts d.compact.uniq.sort.inspect
puts [[1, [2]], [3]].flatten.inspect
{a: 10, b: 20}
{"a" => 1, "b" => 2}
3
[:b, 2]
owner=Hash Enumerable Hash
[1, 2, 3]
[1, 2, 3]

transform_values multiplied only the values and the keys were kept; when the to_h block returned each pair as [key, value], the keys were turned into strings. sum added the values and gave 3, and min_by chose the pair with the smallest -v, that is, [:b, 2]. The owner line shows the distinction: transform_values and to_h are in Hash, while sum is in Enumerable. The compact, uniq and sort chain dropped the nil and the repeated 3 and gave [1, 2, 3]; flatten turned the nested array into [1, 2, 3]. CB38. These methods are not assumed to have the same owner on other receivers.

The Bounding Measurement

Does the block answer on every shortfall, or only in one specific case? The fetch method measures this directly. fetch looks for the key; if it finds it, it returns the value. If it does not, it runs the block when one is given and raises an error when none is. The measurement below counts with a counter how many times the block runs. This counter is not read as an answer to the question of how many times it ran: M10/K03 measured that question on another axis, and here the counter only shows that the block was never called.

# fetch.rb — the block answers only when the key is absent
calls = 0
pairs = { a: 1 }

found = pairs.fetch(:a) { calls += 1; 0 }
missing = pairs.fetch(:b) { |key| calls += 1; key.to_s }
puts "found=#{found.inspect} missing=#{missing.inspect} calls=#{calls}"

without_block = begin
  pairs.fetch(:b)
rescue KeyError => error
  error.class
end
puts "without_block=#{without_block}"
puts "bracket=#{pairs[:b].inspect}"
puts "owner=#{pairs.method(:fetch).owner}"
found=1 missing="b" calls=1
without_block=KeyError
bracket=nil
owner=Hash

fetch gave three different results for the same missing key. The call with a block returned "b", the call without a block raised KeyError, and square brackets returned nil. When the key existed, the counter-incrementing fetch(:a) with a block returned 1 and the counter went up only once: that increment belongs to the call whose key was absent. So the block was never called when the key was found. What the block answers is the missing key; the value of an existing key comes from the hash itself, without any block being asked.

This narrows the responder from the first section. The owner of fetch is Hash, a class, but the result is decided by the block when the key is missing. The core’s classification measures the block case in a separate step for this reason: the owner of the message is always a class or a module, and the question is where the result comes from. Like Hash.new { ... }, fetch leaves the default in the caller’s hands; the difference between the two is that the Hash.new block is bound to the hash permanently, while the fetch block belongs only to that call.

Summary

  • An array is an ordered list numbered from zero; a hash is a mapping from keys to values that keeps insertion order. An index outside the array and a missing key return nil.
  • In Ruby 4.0.7, size, map (on an array), each, select and fetch are defined in their own classes; pairs.map is in the Enumerable module.
  • This lesson’s responder is the block: Hash.new { 0 }[:x] gave 0 and Hash.new { |_, k| k.to_s }[:x] gave "x"; the class receiving the message was the same, and the difference was in the block supplied.
  • defined? returns "expression" on a call with a block; the core queries the message without a block.
  • The bounding measurement: the block answers only when the key is absent. The counter-incrementing fetch(:a) with a block returns 1 for an existing key, and the block never runs.

Next Step

In this lesson the block separated the results of two hashes, but the block itself has not yet been taken up as a topic: { |_, k| k.to_s } and { 0 } were only written. The Blocks lesson runs the same map message with two different blocks and measures how a method calls the block given to it.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ ↵ Esc navigate · open · close