Abstraction allows us to avoid thinking about the implementation details and focus on usage. Moreover, if necessary, you can always rewrite the abstraction implementation without stressing about breaking the code that uses it. But there's another important reason to use abstraction, finding invariants.
An invariant in programming is a logical expression that defines the consistency of a state or data set.
Let's look at an example. When we supplied the constructor and selectors for rational numbers, we implicitly had the following invariants:
const num = makeRational(numer, denom);
numer === getNumer(num); // true
denom === getDenom(num); // true
// Or considering normalization
// numer / denom === getNumer(num) / getDenom(num);
By passing the numerator and denominator to the rational number constructor, we expect to get the same numbers if we apply the selectors to that rational number. This is how we determine if the abstraction is done correctly. This code is practically a test!
Invariants exist for any operation. And sometimes they're pretty tricky. For example, rational numbers can be compared to each other but not directly because the same fractions can be represented in different ways: 1/2 and 2/4. Code that doesn't take this fact into account won't work correctly:
const num1 = makeRational(2, 4);
const num2 = makeRational(8, 16);
console.log(num1 === num2); // false
The action of reducing a fraction is called normalization. This includes several operations, such as shortening a fraction, determining the sign, and moving the sign to the numerator. Normalization can be done in different ways. The most obvious one is to execute it during fraction creation, inside the makeRational()
function. The other is to perform normalization when accessing the fraction using getNumer()
and getDenom()
. The latter method has the disadvantage that normalization happens with every call. This can be avoided by using memoization.
Given the new inputs, it's clear that the invariant linking the constructor and selectors need to be modified. The getNumer()
and getDenom()
functions should return the normalized values and not the raw ones.
const num = makeRational(10, 20);
getNumer(num); // 1
getDenom(num); // 2
The abstraction not only hides the implementation from us, but it preserves the invariants. Any code without abstraction is fraught with the risk of internal transformations that can remain unnoticed:
// Traversing the constructor
// This data is not normalized because the constructor was not used
const num = { numer: 10, denom: 20 };
// Not what it is supposed to return (normalized return is expected):
getNumer(num); // 10
getDenom(num); // 20
// Direct modification
const num = makeRational(10, 20);
// there can be no normalization here, since it's a direct change
num.numer = 40
getNumer(num); // 40
getDenom(num); // 20
I.e., working with data directly and avoiding abstraction can easily break invariants provided by additional logic in the constructor or selectors. So it's important to use the code the way the authors intended.
Looking at the examples above, you may have one genuine question. Is it possible to forbid direct access to data? Generally, yes. This approach is called data hiding. Usually, languages use a special syntax to hide data. However, data hiding can be done without using special tools: it requires higher-order functions. This method implies building abstractions with anonymous functions, closures, and message passing (more in SICP). <!---If you want to learn more about this, try our JS: Compound data course.--->
Are there any more questions? Ask them in the Discussion section.
The Hexlet support team or other students will answer you.
For full access to the course you need a professional subscription.
A professional subscription will give you full access to all Hexlet courses, projects and lifetime access to the theory of lessons learned. You can cancel your subscription at any time.