All good things come to an end. Except when they’re foundational. The internet has a new favourite meme that’ll probably be covered in dust by the time you read this article, but you don’t have to leave OOP to pass in your SAVED MEMES folder. I OOP, and you should too!
What is it?
Object-oriented programming is a programming paradigm created to deal with the increasing complexity of large software systems.
We’ve all heard advice like only you can complete yourself because, for better or worse, we don’t always know how our relationships will change. Still, we can anticipate the more entangled or codependent we are, the more complex and fragile a relationship becomes.
As programs grow, they face a similar problem. A minor change in a program can lead to a domino effect of errors due to dependencies throughout the program. Just like the general guidance is we should continue to “be our own person” or have “our own purpose” in a relationship, we can apply object-oriented programming to design programs that are cohesive and well-oiled machines of many small, contained parts rather than big blobs of dependency.
In this series, we’ll enquire into the very basics of object-oriented programming in Ruby.
Why use it?
- It builds durable programs. We’ve touched on how large programs can become complex and fragile. We can break them down into containers that interact with one another, not depend, and are easy to maintain.
- It makes code reusable and flexible. When we design atomically and reduce dependencies in our code, we give way for it to be useful in more than a single context. We can ensure our code is DRY and configurable.
- It models the real world. We can use idiomatic language and logic to represent objects, relationships, and behaviours. We can think at a high level of abstraction and solve problems in a clear, systematic way.
Now let’s look at the core concepts of object-oriented programming!
The Core Four
My favourite pastime is making ice cream at home. People are always surprised by this. You know how to make ice cream?! But all I have to do is mix together a few ingredients, pour it into my ice cream machine, and voilà! Of course I know how to make ice cream!
Well, I know how to follow an easy recipe and use my machine.
But I don’t know how the machine works internally, how it makes ice cream. I don’t know how it magically turns a liquid mix into a creamy, mouth-watering delicacy. I don’t know how it magically stops churning once said delicacy is at the perfect consistency. I just press a “start” button and wait.
Someone else, an angel of a person at Cuisinart, worried about the magic for me. And they hid it. I can interact with my machine’s simple interface and make magical ice cream — and gelato and sorbet and frozen yogurt! — without having to understand the machine’s internal implementation.
Abstraction is just that. I like this definition from Stackify: “Its main goal is to handle complexity by hiding unnecessary details from the user. That enables the user to implement more complex logic on top of the provided abstraction without understanding or even thinking about all the hidden complexity.”
Edward V. Burard writes, in functional abstraction, “we might say that it is important to be able to add items to a list, but the details of how that is accomplished are not of interest and should be hidden.” In data abstraction, “we would say that a list is a place where we can store information, but how the list is actually implemented (e.g., as an array or as a series of linked locations) is unimportant and should be hidden.”
As abstraction increases, complexity decreases.
Any sort of abstraction is where good design begins. In an inheritance hierarchy, for example, the most abstract concepts are at the top. More concrete concepts build upon the abstractions toward the bottom, where implementation details and commonalities are introduced. Then, the details are completely refined in order to provide a focused representation to the user. And, as one result, we can construct simple interfaces for our users that remain the same even if the magic changes.
One time, I tried using my brother’s ice cream machine (yes, we each have our own). He one-upped me by buying a slightly higher model, and its implementation details are probably different from mine. But I didn’t care about that. I mean, I was a little jealous, but I didn’t need to care about that. I was able to interact with its interface and make magical ice cream in more or less the same way. There may or may not be some added magic, but it didn’t affect me as a user.
As abstraction increases, complexity decreases. We become concerned with smaller yet more important volumes of information. We extract essential details and ignore the inessential. Abstraction makes our programs feel like a piece of cake — or better yet, a scoop of ice cream.
When we recount a story to someone, they may ask for just the facts or highlights. In other words, they ask for an abstraction. But if abstraction tells us some information is more important than other, how do we handle the unimportant? If it discerns the details essential to our story, how do we absolve our story from the inessential?
It’s not important nor essential for me to know how my ice cream machine magically dictates the time it takes for each mix to reach the perfect consistency. Not only is that information abstracted from me, though, it’s encapsulated.
At its core, encapsulation is the bundling of data and methods that work on that data as a single unit and treating it as such. In Ruby, classes and modules do just this!
Clearly then, encapsulation isn’t synonymous with hiding information. The walls of encapsulation can vary from opaque to transparent: Ruby allows private, protected, and public access modifiers in its class definitions. Still, we can leverage encapsulation to package information in such a way as to make visible what’s important and to hide what’s not.
We can control access to information like the internal representation, or state, of objects by providing a public interface to our users. That is, by selectively exposing the data and methods the users need to interact with a class and its objects.
Often, encapsulation refers to the way in which objects hold their state in a physical or logical container. A user can’t access this state directly but can interact with it through the public interface we provide. That means we can take a piece of private state and bundle it with a public method to give the user read or write access to it.
I can’t see how my ice cream machine’s timer is implemented, but I can get its current state and set it via the machine’s public interface. I can interact with the timer without the risk of manipulating its implementation details. That could lead to drippy ice cream! And should those details change in a new model, they’re encapsulated in such a way the interface likely won’t.
If abstraction makes our programs feel like a scoop of ice cream, encapsulation lets us savour it without worrying about the calories.
The most magical part about my ice cream machine is it can make ice cream, gelato, sorbet, and frozen yogurt! Each type of mix has unique properties and ingredients. I might prepare my ice cream mix on a hot stove with tea leaves, and I’d blend my sorbet mix with frozen fruits. But I pour them into the machine, press the “start” button, and wait all the same.
Imagine if I had to use a different machine for each type of mix, or I had to reconfigure my machine’s settings whenever I want to try a new flavour. And what if there was no guarantee I’d get a frozen dessert if my machine doesn’t like the ingredients I put in? Maybe it’d break if it detected a hint of chocolate. (No, not my chocolate!)
Code can become similarly fragile when it handles interaction between data by relying on specific details about their types and implementations, which, in turn, make the types and implementations hard to modify.
Polymorphism allows data to be represented as many different types. We can design data types in such a way various types are treated like a single type and accessed through a uniform interface. This interface is type-agnostic, then, and leaves the implementation details to concrete types able to provide their own unique implementation of the interface.
If it walks like a duck and quacks like a duck, then it must be a duck.
As a result, we can work with various data types in the same way even if their individual implementations are drastically different. We can rely on public interfaces to pass messages between them so our code will still work if the implementations change or completely new data is used.
Say my ice cream machine provides a public, polymorphic interface
make_frozen_dessert to objects of the abstract base class
FrozenDessertMix and its subclasses,
FrozenYogurtMix. Each mix can be made into a frozen dessert, but how the mix is processed and what it returns are defined by its type. We know
make_frozen_dessert will work on any mix and return a frozen dessert. It’s just that what happens under the hood with gelato mix is different from the magic that happens with ice cream mix.
make_frozen_dessert is a method of the class
FrozenDessertMix, we can assume some of its functionality is reused and some of it is customized or redefined for
FrozenDessertMix's subclasses. It can be adapted to our needs and overloaded with extra features or parameters like
toppings. Or, it can call a helper method like
churn_mix, which is able to churn any mix type but will churn gelato mix more slowly than ice cream mix based on the gelato mix’s internal representation. This way, my gelato mix will return a smoother frozen dessert than my ice cream mix.
Since I don’t have to worry about how to implement my gelato mix in my ice cream machine, I can have my gelato and eat it too! And I can sleep at night knowing my machine will work even if it detects ten loads of chocolate.
At this point, I need to make clear GELATO IS NOT ICE CREAM. We’ve seen it’s implemented differently. But I’ll admit they’re equally mouth-watering and similar enough they can both be classified as a frozen dessert. After all, in our programs, if it walks like a duck and quacks like a duck, then it must be a duck. (Really though, gelato is not ice cream.)
Inheritance helps us to keep logic in one place by extracting common behaviours from specialized classes to a base class, or forming specialized classes from a base class that already exists. This results in a structured hierarchy of classes that share a set of data and behaviours.
In our polymorphism example,
FrozenYogurtMix inherit from
FrozenDessertMix. They have access to the same
make_frozen_dessert method defined by
FrozenDessertMix rather than individual
make methods. Yet, we’ve seen
GelatoMix can implement
Inheritance is a large and practical concept that is better understood with application. We’ll explore it in more depth later in the series.
That’s the Scoop!
This is the first article in a four-part series on the basics of object-oriented programming in Ruby. In the next article, we’ll define our first class step by step and explore the core concepts more practically: