Skip to content

Abstraction Barrier

Learning Objectives

At the end of this sub-unit, students should

  • appreciate the benefit of not breaking abstraction barrier.

Function Classification

When designing an ADT, there is a general categories of functions that are often created. In our burger example, we did not use all of these. As such, we will be using an example of rational number.

Rational Number

In mathematics, a rational number is a number that can be expressed as the quotient or fraction \({\displaystyle {\tfrac {p}{q}}}‍\) of two integers, a numerator \(p\) and a non-zero denominator \(q\).

Straight for the definition, we can see that we need to store two integers: \(p\) and \(q\). This gives us the following basic design.

Basic Rational Number Design

def make_rat(numer, denom):
  return (numer, denom)

def get_numer(rat):
  return rat[0]

def get_denom(rat):
  return rat[1]

def set_numer(rat, numer):
  return make_rat(numer, get_denom(rat))

def set_denom(rat, denom):
  return make_rat(get_numer(rat), denom)

Using this design, we can classify the functions as follows.

Function Classification

Category Description Example
Constructor Create the data make_rat(numer, denom)
Accessor Retrieve the field from data get_numer(rat)
Mutator Update the field in data set_denom(rat, denom)

Using this very basic design, we can solve anything involving our rational number. In fact, mutators may not be necessary in this case because it is simply a wrapper for make_rat in disguise. More complicated mutators are possible so we keep the classification.

As before, we can ask ourselves why not just use tuple directly? There are some subtle problems with the design which we have not address.

  • Is (1, 2) equivalent to (2, 4)?
  • Can we construct (1, 0)?

If we have simply used tuple directly without calling make_rat or other functions above, we need to change the design anywhere the tuple is used. On the other hand, if we simply used the functions above, we only need to update this function and all usages will automatically use the new design.

Improved Rational Number Design

1
2
3
4
5
6
7
def make_rat(numer, denom):
  if denom == 0:
    return 1//0               # causes error
  common = gcd(numer, denom)  # greatest common divisor
  return (numer // common, denom // common)

# : the rest are the same

But what can we do with the rational number? Not much. We still need to write all the operations we want to do with the rational number. This does not belong to the classifications above, but a general one. So let us write arithmetic operations based on the definition below.

\[ \frac{p_1}{q_1} + \frac{p_2}{q_2} \ \ \stackrel{\text{def}}{=} \ \ \frac{(p_1 q_2 + p_2 q_1)}{q_1 q_2} \]
\[ \frac{p_1}{q_1} - \frac{p_2}{q_2} \ \ \stackrel{\text{def}}{=} \ \ \frac{(p_1 q_2 - p_2 q_1)}{q_1 q_2} \]
\[ \frac{p_1}{q_1} \times \frac{p_2}{q_2} \ \ \stackrel{\text{def}}{=} \ \ \frac{p_1 p_2}{q_1 q_2} \]
\[ \frac{p_1}{q_1} \div \frac{p_2}{q_2} \ \ \stackrel{\text{def}}{=} \ \ \frac{p_1 q_2}{p_2 q_1} \]
\[ \frac{p_1}{q_1} = \frac{p_2}{q_2} \ \ \stackrel{\text{def}}{=} \ \ p_1 q_2 = p_2 q_1 \]

Operations on Rational Number

def add_rat(r1, r2):
  p1, q1 = get_numer(r1), get_denom(r1)
  p2, q2 = get_numer(r2), get_denom(r2)
  return make_rat(p1 * q2 + p2 * q1, q1 * q2)

def sub_rat(r1, r2):
  p1, q1 = get_numer(r1), get_denom(r1)
  p2, q2 = get_numer(r2), get_denom(r2)
  return make_rat(p1 * q2 - p2 * q1, q1 * q2)

def mul_rat(r1, r2):
  p1, q1 = get_numer(r1), get_denom(r1)
  p2, q2 = get_numer(r2), get_denom(r2)
  return make_rat(p1 * p2, q1 * q2)

def div_rat(r1, r2):
  p1, q1 = get_numer(r1), get_denom(r1)
  p2, q2 = get_numer(r2), get_denom(r2)
  return make_rat(p1 * q2, p2 * q1)

def eq_rat(r1, r2):
  p1, q1 = get_numer(r1), get_denom(r1)
  p2, q2 = get_numer(r2), get_denom(r2)
  return p1 * q2 == p2 * q1

Once we have these functions, we may want to limit the capability by removing certain functions. The functions we kept are often called the application programming interface (API). In a more object-oriented language, we may even want to specify which functions are available to others and which are available internally. Unfortunately, in Python, these are not easy to do.

We will now consider only the following functions available for our rational number data.

Rational Number API

  • make_rat(numer, denom): construct \(\frac{\text{numer}}{\text{denom}}\).
  • add_rat(r1, r2): \(r_1 + r_2\).
  • sub_rat(r1, r2): \(r_1 - r_2\).
  • mul_rat(r1, r2): \(r_1 \times r_2\).
  • div_rat(r1, r2): \(r_1 \div r_2\).
  • eq_rat(r1, r2): \(r_1 = r_2\).

Keeping Abstraction Barrier

Given the abstraction for the rational number above, we want to construct other data type that uses rational number. What we also want is to not break abstraction. To do that, we need to forget about the implementation details of rational number. Instead, we can only use the functions defined in the rational number API.

To make this discussion more concrete, we will construct another implementation. This construction is created using an inefficient implementation of constructor and accessors below. You do not have to understand them, you do not even need to know them. What is important is to know that you cannot assume which implementation we will be using.

Alternative Rational Number Design
def make_rat(numer, denom):
  if denom == 0:
    return 1//0               # causes error
  common = gcd(numer, denom)  # greatest common divisor
  return str(numer // common) + '/' + str(denom // common)

def get_numer(rat):
  return int(rat.split('/')[0])

def get_denom(rat):
  return int(rat.split('/')[1])

What we want to do now is to create complex number.

Complex Number

In mathematics, a complex number is an element of a number system that extends the real numbers with a specific element denoted \(i\), called the imaginary unit and satisfying the equation \({\displaystyle i^{2}=-1}\); every complex number can be expressed in the form \({\displaystyle a+bi}\), where \(a\) and \(b\) are real numbers.

However, instead of \(a\) and \(b\) being real numbers, we will be letting them be rational numbers instead. The operations we need to implement are constructor, addition, subtraction, multiplication, and equality. Besides the construcor, the operations are defined below. We will need to decide on our data representation.

\[ (a_1 + b_1i) + (a_2 + b_2i) \ \ \stackrel{\text{def}}{=} \ \ (a_1 + a_2) + (b_1 + b_2)i \]
\[ (a_1 + b_1i) - (a_2 + b_2i) \ \ \stackrel{\text{def}}{=} \ \ (a_1 - a_2) + (b_1 - b_2)i \]
\[ (a_1 + b_1i) \times (a_2 + b_2i) \ \ \stackrel{\text{def}}{=} \ \ (a_1 a_2 - b_1 b_2) + (a_1 b_2 + a_2 b_1)i \]
\[ (a_1 + b_1i) = (a_2 + b_2i) \ \ \stackrel{\text{def}}{=} \ \ (a_1 = a_2) \land (b_1 = b_2) \ \ \ [\text{where } \land \text{ is the } \mathtt{and} \text{ operation}] \]

First, let us give a preliminary design for constructor and accessors. There is a trick we can do for constructor: simply put all parameters in a tuple. The reason is rather straightforward. If the entire input parameters is needed for computation, we have not lost any parameters as we are keeping them. If some parameters are not necessary, we can simply ignore them during computation.

So keeping all the parameters is the most straightforward way to define a constructor. From there, the accessor is rather trivial. We will also introduce a notation to help us which is called a type annotation. This type annotation will be ignored by Python as it is a comment and it is intended for other programmers. We annotate a function with the following type annotation.

(param1::type1, param2::type2) -> type

The annotation after -> is the return type. These types are not Python type, but our abstract type.

Basic Complex Number Design

1
2
3
4
5
6
7
8
def make_complex(real, imag): # (real::rational, imag::rational)  ->  complex
  return (real, imag)

def get_real(comp):  # (comp::complex)  ->  rational
  return comp[0]

def get_imag(comp):  # (comp::complex)  ->  rational
  return comp[1]

Consider the following badly designed code for addition. This code breaks abstraction. At Line 6 and 7, we are assuming that our rational number abstraction is a tuple. But it may not be, that is the reason why we have abstraction.

Complex Addition

def add_complex(c1, c2): # (c1::complex, c2::complex) -> complex
  a1, b1 = get_real(c1), get_imag(c1)
  a2, b2 = get_real(c2), get_imag(c2)

  # compute (a1 + a2) and (b1 + b2)
  a = make_rat(a1[0] * a2[1] + a2[0] * a1[1], a1[1] * a2[1])
  b = make_rat(b1[0] * b2[1] + b2[0] * b1[1], b1[1] * b2[1])

  # construct result
  return make_complex(a, b)

If we run the above code with our tuple implementation of rational number, we get the correct output. Unfortunately, if we use the alternative design, it will not work. Of course, we can always force our way to make it work with a lot of implementations by detecting what the implementation is. But that is kind of a waste of time.

Instead, we should not go against the design and use the available API. We should think of it like a puzzle where we are given a limited capability and we can only solve within those capabilities. So instead of performing the addition of real numbers on our own, we need to invoke the function that performs addition of rational number. One possible good design is shown below.

Operations on Complex Number

def add_complex(c1, c2): # (c1::complex, c2::complex) -> complex
  a1, b1 = get_real(c1), get_imag(c1)
  a2, b2 = get_real(c2), get_imag(c2)
  return make_complex(add_rat(a1, a2),  # (a1 + a2)
                      add_rat(b1, b2))  # (b1 + b2)i

def sub_complex(c1, c2): # (c1::complex, c2::complex) -> complex
  a1, b1 = get_real(c1), get_imag(c1)
  a2, b2 = get_real(c2), get_imag(c2)
  return make_complex(sub_rat(a1, a2),  # (a1 - a2)
                      sub_rat(b1, b2))  # (b1 - b2)i

def mul_complex(c1, c2): # (c1::complex, c2::complex) -> complex
  a1, b1 = get_real(c1), get_imag(c1)
  a2, b2 = get_real(c2), get_imag(c2)
  return make_complex(sub_rat(mul_rat(a1, a2), mul_rat(b1, b2)),  # ((a1 * a2) - (b1 * b2))
                      add_rat(mul_rat(a1, b2), mul_rat(a2, b1)))  # ((a1 * b2) + (a2 * b1))i

def eq_complex(c1, c2): # (c1::complex, c2::complex) -> bool
  a1, b1 = get_real(c1), get_imag(c1)
  a2, b2 = get_real(c2), get_imag(c2)
  return eq_rat(a1, a2) and eq_rat(b1, b2)

With this implementation, we can actually use either implementation for rational number. Too see why, let us look at add_complex and consider all the functions used. We will annotate the functions with the type annotation we did before.

Function Type Annotation
get_real(c) (c::complex) -> rational
get_imag(c) (c::complex) -> rational
add_rat(r1, r2) (r1::rational, r2::rational) -> rational
make_complex(a, b) (r1::rational, r2::rational) -> complex

Now, we can check the variables and figure out the type based on these type annotations.

  • c1::complex from the assumption in the type annotation.
  • c2::complex from the assumption in the type annotation.
  • a1::rational because it comes from the result of get_real(c1) and c1::complex.
  • b1::rational because it comes from the result of get_imag(c1) and c1::complex.
  • a2::rational because it comes from the result of get_real(c2) and c2::complex.
  • b2::rational because it comes from the result of get_imag(c2) and c2::complex.

Finally, we consider the final return expression.

  • make_complex(add_rat(a1, a2), add_rat(b1, b2))
    • Result of add_rat(a1, a2) is rational because a1::rational, a2::rational, and add_rat returns rational when given two rational parameters.
    • Result of add_rat(b1, b2) is rational because b1::rational, b2::rational, and add_rat returns rational when given two rational parameters.
    • Result of make_complex(..., ...) is complex because lhs is rational, rhs is rational, and make_complex returns complex when given two rationals.

Notice that in our arguments above, we have never mentioned that we are using tuple or str. The only data types used are the abstract data type of rational and complex. Using this abstraction, we do not care how rational and complex are implemented.

This is the kind of argument we need to keep on reminding ourselves in order to avoid breaking abstraction. Unfortunately, the Python programming language provides no support for this. We say that Python is dynamically typed due to this. Other languages that are statically typed will perform the check automatically for us.

Due to the dynamic nature of Python, we introduce our own notation to indicate types as comment. The programmer will be the one checking manually for correctness and these comments are intended simply as a reminder. In fact, it is better to write them as documentation string as follows.

1
2
3
4
5
6
7
8
9
def add_complex(c1, c2):
  """
  (c1::complex, c2::complex) -> complex
  returns a complex number that is c1 + c2
  """
  a1, b1 = get_real(c1), get_imag(c1)
  a2, b2 = get_real(c2), get_imag(c2)
  return make_complex(add_rat(a1, a2),  # (a1 + a2)
                      add_rat(b1, b2))  # (b1 + b2)i