Home
đź’­

Abstraction

Let’s write some simple code to work with fractions. What makes a fraction?

A numerator and a denominator.

In our code, how should we represent a fraction? There are no wrong answers.

Two variables: a numerator and a denominator.

Can we use one variable?

Yes, but we’ll need a data structure. How about… a pair of numbers, wrapped in an array!

one_ninth = [1, 9]
We can work with this. In our example, how are we creating the fraction?

By creating an array, using [ and ].

Very good. Let’s write some functions which operate on fractions. How can we scale a fraction, by multiplying it by a whole number?

By multiplying the numerator. We leave the denominator as-is.

def fraction_scale(fraction, scale)
  new_numerator = fraction[0] * scale
  new_denominator = fraction[1]

  [new_numerator, new_denominator]
end
In our example, how are we creating the new fraction?

By creating an array, using [ and ].

In our example, how are we accessing parts of the fraction?

With array indexing operations, using [0] and [1].

Why?

Because our underlying data type is an array.

Very good. Instead of multiplying by a whole number, we’d like to multiply by another fraction.

We multiply the numerators and the denominators.

pq=abĂ—cdpq=aĂ—cbĂ—d\begin{aligned}\frac{p}{q} &= \frac{a}{b} \times \frac{c}{d} \\ \\ \frac{p}{q} &= \frac{a \times c}{b \times d} \end{aligned}
def fraction_multiply(a, b)
  new_numerator = a[0] * b[0]
  new_denominator = a[1] * b[1]

  [new_numerator, new_denominator]
end
How are we creating our new fraction?

Same as before: by creating an array, using [ and ].

How are we accessing parts of our fractions?

Same as before: with the array indexing operations [0] and [1].

We keep repeating ourselves.

We are merely reinforcing our mental model of fractions.

One more intro exercise.

Phew.

Let’s add some fractions.

This one’s trickier. We need to do a bit of arithmetic. It all starts with getting a common denominator.

pq=ab+cdpq=aâ‹…dbâ‹…d+câ‹…bbâ‹…dpq=aâ‹…d+câ‹…bbâ‹…d\begin{aligned} \frac{p}{q} =& \frac{a}{b} + \frac{c}{d} \\ \\ \frac{p}{q} =& \frac{a \cdot d}{b \cdot d} + \frac{c \cdot b}{b \cdot d} \\ \\ \frac{p}{q} =& \frac{a \cdot d + c \cdot b}{b \cdot d} \end{aligned}
def fraction_add(a, b)
  new_numerator = a[0] * b[1] + b[0] * a[1]
  new_denominator = a[1] * b[1]
  
  [new_numerator, new_denominator]
end
What is one ninth plus one ninth?

Two ninths!

And your code says the same?

Well, sort of.

p fraction_add([1, 9], [1, 9])
# => [18, 81]
Where are the ninths?

There are two buried in there. We just need to simplify by finding the great common divisor.

def fraction_add(a, b)
  new_numerator = a[0] * b[1] + b[0] * a[1]
  new_denominator = a[1] * b[1]

  # NEW: simplify
  gcd = new_numerator.gcd(new_denominator)
  [new_numerator / gcd, new_denominator / gcd]
end

p fraction_add([1, 9], [1, 9])
# => [2, 9]
Very well. We indeed have two ninths. What is one half of two ninths?

One ninth!

And your code says the same?

Sigh, almost.

p fraction_multiply([1, 2], [2, 9])
# => [2, 18]
Where are the ninths?

It’s there, we just need to simplify.

def fraction_multiply(a, b)
  new_numerator = a[0] * b[0]
  new_denominator = a[1] * b[1]

  # NEW: simplify
  gcd = new_numerator.gcd(new_denominator)
  [new_numerator / gcd, new_denominator / gcd]
end

p fraction_multiply([1, 2], [2, 9])
# => [1, 9]
We keep repeating ourselves.

We are merely reinforcing our mental model of fractions.

Let’s repeat ourselves one last time. What happens when we scale one_ninth up by nine?

We should get [1, 1], but… we don’t.

p fraction_scale(one_ninth, 9)
# => [9, 9]
Because?

We need to simplify.

def fraction_scale(fraction, scale)
  new_numerator = fraction[0] * scale
  new_denominator = fraction[1]

  # NEW: simplify
  gcd = new_numerator.gcd(new_denominator)
  [new_numerator / gcd, new_denominator / gcd]
end

p fraction_scale(one_ninth, 9)
# => [1, 1]
How are we creating a fraction?

By creating an array, using [ and ]. But each time, we gather the greatest common divisor to simplify it.

If in the future we need to divide fractions, will we remember to do this?

We have reinforced our mental model of fractions.

But… we can make mistakes.
Can we introduce an abstraction to make it easier to create fractions the “right” way?

We can try!

def create_fraction(numerator, denominator)
  gcd = numerator.gcd(denominator)
  [numerator / gcd, denominator / gcd]
end

p create_fraction(9, 9)
# => [1, 1]
Can we use it in fraction_scale?

We can try!

def fraction_scale(fraction, scale)
  new_numerator = fraction[0] * scale
  new_denominator = fraction[1]

  # NEW: use our abstraction
  create_fraction(new_numerator, new_denominator)
end

p fraction_scale(one_ninth, 9)
# => [1, 1]
How are we creating a fraction?

By calling create_fraction with a numerator and a denominator.

Can we use it in our other functions?

We can try!

def fraction_add(a, b)
  new_numerator = a[0] * b[1] + b[0] * a[1]
  new_denominator = a[1] * b[1]
  create_fraction(new_numerator, new_denominator)
end
p fraction_add([1, 9], [1, 9])
# => [2, 9]

def fraction_multiply(a, b)
  new_numerator = a[0] * b[0]
  new_denominator = a[1] * b[1]
  create_fraction(new_numerator, new_denominator)
end
p fraction_multiply([1, 2], [2, 9])
# => [1, 9]
Our ninths look perfect.

And the code is much cleaner!

How are we creating a fraction?

By calling create_fraction with a numerator and a denominator.

Is that the only way?

Oh, we’re calling fraction_add and fraction_multiply with arrays as arguments. We can clean that up.

# OLD
fraction_multiply([1, 2], [2, 9])

# NEW
fraction_multiply(
  create_fraction(1, 2),
  create_fraction(2, 9)
)
It’s a lot of characters, though.
It will be worth it. How are we creating a fraction?

By calling create_fraction with a numerator and a denominator.

No more arrays?

The arrays are there, but we are not creating them by hand.

Correct, the arrays are abstracted away. In our functions, how are we accessing parts of the fraction?

With array indexing operations, using [0] and [1].

def fraction_multiply(a, b)
  new_numerator = a[0] * b[0]
  new_denominator = a[1] * b[1]
  create_fraction(new_numerator, new_denominator)
end
If in the future we need to divide fractions, will we remember to do this?

A fraction is a numerator and a divider. It’s somewhat obvious to use [0] and [1] respectively.

What if we’d like to change how we represent fractions internally?

We can continue to change our code! How should we represent them?

How about a Hash?

Great idea, that syntax is quite nice! Our fields are nicely labeled as well.

one_ninth = {
  numerator: 1,
  denominator: 9,
}
How are we creating fractions?

By creating a Hash using {, }, and listing our fields such as numerator:.

Can we abstract away the Hash?

Oh! We can update our constructor.

def create_fraction(numerator, denominator)
  gcd = numerator.gcd(denominator)

  # OLD
  # [numerator / gcd, denominator / gcd]

  # NEW
  {
    numerator: numerator / gcd,
    denominator: denominator / gcd
  }
end

one_ninth = create_fraction(1, 9)
p one_ninth[:numerator]
# => 1
How can we access parts of the fraction?

By accessing the fields of the hash such as one_ninth[:numerator].

Why?

Because our underlying data type is a hash.

Do we need to update any create_fraction calls in our three functions fraction_scale, fraction_multiply, or fraction_add?

No. The act of writing create_fraction(a, b) has not changed. We do not need to update any call sites.

In our multiplication example, are we creating hashes manually anywhere?

No, our hashes are nicely abstracted away. Our code is nice and clean.

def fraction_multiply(a, b)
  new_numerator = a[0] * b[0]
  new_denominator = a[1] * b[1]
  create_fraction(new_numerator, new_denominator)
end

p fraction_multiply(
  create_fraction(1, 2),
  create_fraction(2, 9)
)
Does it work?

It… sadly does not.

data.rb:29:in 'Object#fraction_multiply':
  undefined method '*' for nil (NoMethodError)

  new_numerator = a[0] * b[0]
                       ^
        from data.rb:34:in '<main>'
What are a[0] and b[0]?

They are trying to access the numerator and denominator, but we’ve changed how the numerator and denominator and stored internally.

a[0] no longer makes sense. We need the :numerator field, not the 0th one.
How should we fix it?

By accessing the named fields of the hash with [ and a field such as :numerator.

def fraction_multiply(a, b)
  # OLD
  # new_numerator = a[0] * b[0]
  # new_denominator = a[1] * b[1]

  # NEW
  new_numerator = a[:numerator] * b[:numerator]
  new_denominator = a[:denominator] * b[:denominator]
  create_fraction(new_numerator, new_denominator)
end

p fraction_multiply(
  create_fraction(1, 2),
  create_fraction(2, 9)
)
# {numerator: 1, denominator: 9}
How many times do we have to update this?

In all of our functions, just as we did before we introduced create_fraction.

Why didn’t we have to update create_fraction?

We’ve abstracted away how we create fractions.

Can we write a function which, given a fraction, accesses the numerator?

We certainly know how to do that!

def create_fraction(numerator, denominator)
  gcd = numerator.gcd(denominator)
  {
    numerator: numerator / gcd,
    denominator: denominator / gcd
  }
end

# NEW: Get the numerator
def numer(fraction)
  fraction[:numerator]
end

# NEW: Get the denominator
def denom(fraction)
  fraction[:denominator]
end
Can we use it?

We can! It looks quite nice next to our creator_fraction constructor.

one_ninth = create_fraction(1, 9)
p numer(one_ninth)
# => 1
p denom(one_ninth)
# => 9
Can we use these in fraction_multiply?

We can!

def fraction_multiply(a, b)
  # OLD
  # new_numerator = a[:numerator] * b[:numerator]
  # new_denominator = a[:denominator] * b[:denominator]

  # NEW
  new_numerator = numer(a) * numer(b)
  new_denominator = denom(a) * denom(b)
  create_fraction(new_numerator, new_denominator)
end

p fraction_multiply(
  create_fraction(1, 2),
  create_fraction(2, 9)
)
# => {numerator: 1, denominator: 9}
Let’s put in a few more reps. How about our other functions?

We can use our numerator and denominator getters like so:

def fraction_scale(fraction, scale)
  new_numerator =
    numer(fraction) * scale
  new_denominator = denom(fraction)
  create_fraction(new_numerator, new_denominator)
end
p fraction_scale(one_ninth, 9)
# => { numerator: 1, denominator: 1 }

def fraction_add(a, b)
  new_numerator =
    numer(a) * denom(b) +
    numer(b) * denom(a)
  new_denominator =
    denom(a) * denom(b)
  create_fraction(new_numerator, new_denominator)
end
p fraction_add(one_ninth, one_ninth)
# => { numerator: 2, denominator: 9 }
How are we creating our new fraction?

By using create_fraction(a, b).

How are we accessing parts of our fractions?

By using the getter functions numer and denom.

No more hashes?

The hashes are there, but they are abstracted away from us.

One final set.

I’m ready.

Turns out we really want arrays for our fractions again. They appear to ever-so-slightly more efficient in our testing.

Shall I undo all of our changes?

No. This is where our constructor and getter functions really shine. How should we update them to work with our internal array representation?

We’ll start with create_fraction

def create_fraction(numerator, denominator)
  gcd = numerator.gcd(denominator)
  [numerator / gcd, denominator / gcd]
end
Excellent, just as we had before.

…And our new getter functions can be updated as follows.

def numer(fraction)
  fraction[0]
end

def denom(fraction)
  fraction[1]
end
Very well! How about our functions fraction_scale, fraction_add, and fraction_multiply?

They still work!

p fraction_scale(one_ninth, 9)
# => [1, 1]
p fraction_add(one_ninth, one_ninth)
# => [2, 9]
p fraction_multiply(
  create_fraction(1, 2),
  create_fraction(2, 9)
)
# => [1, 9]
We don’t need to update them?

We do not need to update them.

How are we creating our new fraction?

By using create_fraction(a, b).

How are we accessing parts of our fractions?

By using the getter functions numer and denom.

Why didn’t we need to update our definitions of scale, add, and multiply?

These functions are fully abstracted from the internal representation of fractions.

We have reinforced our mental model of abstraction.

đź”— Notes

The topics in this post are basic/obvious/what-have-you to many of you, but I have been served well by writing “posts Jordan ten years ago would have learned a lot from” and I hope you learned something too.
This article is a re-packaging of a chapter from one of my favorite computer programming textbooks, Structure and Interpretation of Computer Programs. Specifically Chapter 2.1 on Data Abstraction, which you can read online along with the rest of the textbook.
The prose in this post is inspired by that of The Little Schemer, another one of my favorites worth checking out. See also: The Little Typer and The Little Prover. I employed a similar style in Is this true?.
Questions or comments? Shoot me an email! I’d love to hear from you.