Part 2: The big picture of OOP
This is the second in a series of two articles that provide an introduction about what object oriented programming (OOP) is. In the first article, we discussed classes, objects, attributes, behaviors, instance variables, and instance methods.
We also created a Book
class and instantiated a few Book
objects that will be referenced in this article. You can read the first article here.
The four pillars of OOP
OOP famously has four pillars — four major concepts that justify the existence of this style of programming and act as guidelines for programmers as they code in an OOP fashion.
Abstraction
Abstraction means to simplify complexity. Take our pages=
setter method. The user does not need to know that an if-else
conditional is used to check their input, or that Object#instance_of?
is used to do so. They can call the setter method and use its functionality. Going even deeper, when we call an instance method on an object, like Object#instance_of?
on an Integer
object — we don’t need to know how that method works internally. We can simply rest assured that Ruby will return true
or false
based on the calling object and the passed in argument.
Abstraction is the idea that defining classes and grouping behaviors and attributes within them allows users to work with objects made from those classes without having to get bogged down in implementation details that someone else has already taken the time to understand and approach.
Abstraction allows programmers to work at new levels of a problem. For example — with a Book
class written and ready to go, another programmer might begin work on a Library
class that interacts with Book
objects in some way. The programmer working with the Library
class might not know every detail of the Book
class, because they don’t need to.
One metaphor that comes to mind for abstraction is the idea of a heart.
This is an abstraction of a heart. A heart does not look like this. But it’s a simple, effective way of communicating about this complex organ that we cannot see.
Encapsulation
Encapsulation boils down to two fundamental principles — protecting data and grouping data and methods together.
Encapsulation is a form of data protection. We can expose only the data and functionalities that we want users to have. In our Book
class, we allow read and write access for @pages
while we only allow read access for @title
and author
. We determine what a user can see and do with an object. Additionally, we can even use methods ( Module#public
, private
, and protected
) within our classes to determine what methods we want to be available to be called on objects outside of the class definition.
Encapsulation is also the idea of grouping data and functionalities together. In the context of our Book
class, we group a set of attributes and behaviors that will be available for each object we instantiate from the Book
class. There are many levels of encapsulation within OOP. Series of instructions are grouped together in methods. Bundles of data are grouped together in objects. Methods and data are grouped together in classes.
Modules, which are similar to classes with the exception that the ::new
method cannot be called on them, are a sort of toolbox. They act as containers , or namespaces, for classes, constants, and methods. Modules can be included in a class to give classes access to additional functionalities.
Inheritance
Inheritance is the concept that classes can inherit behaviors and attributes from other classes and modules. It is the idea of categorizing relationships and hierarchies between classes.
It’s something that’s so natural for humans to do. For example, I’m looking at a laptop screen right now, so let’s work with that.
Let’s say that my laptop, austins_laptop
, is an instance of a Laptop
class, which is a type of Computer
. You could say that Laptop
is a subclass of the Computer
class.
In other words, the Laptop
class inherits from the Computer
class, which might inherit from an Electronics
class, which might inherit from a Machine
class.
In Ruby, all classes inherit from classes and modules until they reach the primordial BasicObject
. Inheritance signifies the order in which Ruby will search different containers for the methods that you call. If Ruby finds the method, it will stop searching and use it. If it doesn’t it will keep searching until there is nowhere else to look and return a NoMethodError
.
For example, if you have an Array
object and call not_a_real_method
on it, Ruby will first look in Array
, then Enumerable
, then Object
, then Kernel
, then BasicObject
and then return a NoMethodError
. You can always find the order that Ruby will search for methods (known as the method lookup chain or method lookup path) by calling the class method ::ancestors
on the class in question.
Ruby is a single inheritance language, which means that a class can only directly inherit from one superclass (while modules can be mixed into classes to provide additional functionality). Ruby allows for class inheritance and interface inheritance. Both Enumerable
and Kernel
are examples of modules. Enumerable
is mixed in to the Array
class, while Kernel
is mixed in to the Object
class.
Let’s take a step back and re-examine our Book
class we defined in the first article.
class Book
def initialize(title, author, pages)
@title = title
@author = author
@pages = pages
enddef title
@title
enddef author
@author
enddef pages
@pages
enddef pages=(new_pages)
if new_pages.instance_of?(Integer)
@pages = new_pages
else
puts 'Sorry, please enter a number'
end
end
end
Let’s create a class that inherits from Book
.
class Novella < Book; end
We can instantiate a Novella
object the same way we instantiate a Book
object. Novella
inherits all the methods defined in the Book
class, including the initialize
method.
sad_story = Novella.new('Flowers for Algernon', 'Daniel Keyes', 23)
All the behaviors available for Book
objects are now available for the object bound to sad_story
, despite the emptiness of Novella
. Subclasses can either override or extend the functionalities available in superclasses. Let’s extend the functionalities of our Novella
class.
class Novella < Book
def binge_read
puts "I read it all in one night!"
end
end
With binge_read
, we now have an available action we can take on Novella
objects that we cannot use with Book
objects. We have defined more specific behaviors for our subclass. Let’s define one more class and give it some unique functionality before we move on.
class Dictionary < Book
def learn_new_word
puts ['tenable', 'crepuscular', 'petrichor'].sample
end
end
Then we can create a Dictionary
object.
websters = Dictionary.new('1978 Edition', 'Webster Team', 14_542)
We now have two classes, Novella
and Dictionary
, that inherit from the superclass Book
. Both novellas and dictionaries are types of books that have authors, page numbers, and titles. However, in this example, we cannot learn a new word from a Novella
object, nor can we binge_read
a dictionary object. These specific behaviors are only available for the particular subclasses for which they are relevant.
Inheritance helps programmers re-use their code when needed and extract common, repeated code between classes and move it to a superclass. Rather than re-write the Book
getter and setter methods for the Novella
and Dictionary
classes, we can simply have the two classes inherit from the Book
class.
Additionally, inheritance allows programmers to extend or override the behaviors of superclasses within subclasses.
Polymorphism
Polymorphism simply refers to the fact that methods defined in one class can be defined differently in different classes. A method binge_read
can output 'I read it all in one night!'
to the screen for Novella
objects, and have a slightly different or even completely different implementation in another class. Let’s define a binge_read
method in the Dictionary
class.
class Dictionary < Book
def learn_new_word
puts ['tenable', 'crepuscular', 'petrichor'].sample
end
def binge_read
puts "Zzzzzzzz"
end
end
Our Dictionary#binge_read
method has a different implementation than our Novella#binge_read
method. We can call binge_read
on objects made from both of these classes, and the return values of each call will be different.
[sad_story, websters].each { |book| book.binge_read }
Methods with the same name can be defined in unrelated classes or overridden in a subclass to have a different implementation than a superclass.
The benefits of OOP
We’ve talked through some of the most basic ideas of OOP. We defined a few classes with some limited behaviors and attributes. We instantiated a few objects, each with their own unique instance variables, and we talked through some of the fundamental principles of the OOP paradigm. There is so much more, but we must save it for another time. Before we leave, let’s talk through some of the benefits of OOP.
Concrete thinking
OOP allows programmers to think in terms of concrete nouns and verbs. Representing seemingly opaque, intangible digital items, like a database or the internet, is much more accessible when you can use clear, concise terms to do so.
New levels of abstraction
OOP allows programmers to abstract away solutions to complex problems. Rather than trying to hold all aspects of a problem in your head at once, you can approach chunks of the problem one step at a time. As you solve more and more problems, you can forget implementation details you used to solve one problem and focus on tackling the next, even more complex problem.
Reduced Dependencies
Using classes and modules to modularize code into containers makes for programs that have fewer dependencies and are therefore easier to maintain than a program made of 1,000 lines that could fail if one typo were introduced or if one local variable escaped its intended scope.
Flexible code
Compartmentalized classes and modules can be re-used for different programs. One module of code that was used for a program could easily be tweaked or directly re-used for another program.
DRYer code
Inheritance allows programmers to extract common code between multiple classes, place it in a superclass, and have subclasses inherit from the superclass. This helps achieve one of the fundamental principles of clean code — Don’t Repeat Yourself.
Better-organized code
While organizing code into modules and classes does not result in shorter programs, it results in more clearly organized ones. Classes, methods, and constants can all be placed in well-named modules. This helps avoid name clashing errors and help programmers understand relationships between various classes and objects instantiated from those classes.
In conclusion
Thank you for taking the time to read this article. It has been my attempt to summarize the very very basic fundamentals of OOP. Numerous important topics ( attr_*
methods, the self
keyword, Ruby’s fake operators and syntactic sugar, commonly overridden methods like to_s
, working with collaborator objects, and lexical scope, to name a few) were outside the scope of this article.
I hope that you have found this useful in some way. If you have any questions about OOP or programming in general, or any feedback or suggestions, please feel free to leave a comment. If you’re a Launch School student, please feel free to reach out to me directly in Slack.
Additionally — a huge thank you to inspiring fellow Launch School student Leena and esteemed Launch School instructor *superchilled for both reading through these articles and providing immensely valuable constructive feedback.