Software design in object-oriented programming is governed by 4 fundamental principles, abstraction, inheritance, polymorphism and encapsulation.
Together, they form the pillars that allow you to write testable, flexible, maintainable and scalable code. But, the main problem is not knowing these tools but knowing when and how to use them.
In this article, I want to revisit in a simple way and with real-world examples how, when and why to use each of these 4 principles.
Abstraction
Are you really aware of the mechanisms that cause your computer to go from off to on when you press the ON button?
Do you, as a user, need to know all the low-level binary operations that take place when you press the power button?
Are you really aware of how your phone starts when you press the power button?
Probably not!
Because Abstraction makes technology easier to use. Most of us know that pressing the ON button on the phone will turn on the phone. And that's good enough for us.
Imagine that you needed to know the low-level electronic details to turn your phone on to use your favorite app. The learning curve would be really, though. There would not be many people who could use the phone in this case.
Abstraction is the process of taking away or removing characteristics from something to reduce it to a set of essential characteristics. The main purpose of abstraction is to hide unnecessary details from the users.
What is that mean in Object-Oriented programs? Applying abstraction means that each object should only expose a high-level mechanism for using it. This mechanism should hide internal implementation details.
Furthermore, it should only reveal operations relevant to the other objects.
Consider abstraction of printer machine
// Options for the printer mechanism
class PrinterOptions {
private int pages = 1;
private BlackAndWhite colorChoice;
private PaperSize paperSize;
private Orientation orientation;
public enum PaperSize {
A5, A4, A3, A2, A1
}
public enum Orientation {
PORTRAIT, LANDSCAPE
}
public enum BlackAndWhite {
TRUE,FALSE
}
}
// The abstraction
public class PrinterMachine {
// Private variables
// ...
public void startPrinting(PrinterOptions options) {
// Use the options
// Get access to physical layer
// Translate options into machine commands
// Execute low-level code
// ...
}
// More methods
}
Now consider that the only method that is exposed is the startPrinting
method. Does the user need to know the private properties inside the PrinterMachine
class? Does the user need to know how the print processing is implemented in the startPrinting
method?
No, absolutely not. Just like the power-button on the TV remote, it’s simplified for public use. For someone to use this abstraction, all they need to know is the existence of the startPrinting
method and how to call it. That’s it. All other details are abstracted away within the class.
PrinterOptions printerOptions = PrinterOptions.builder()
.pages(2)
.paperSize(A4)
.orientation(PORTRAIT)
.colorChoice(BLACK_AND_WHITE)
.build();
PrinterMachine printer = new PrinterMachine();
printer.startPrinting(printerOptions);
The interface offered to the user hides these implementation details. And which should always be easy to use and should rarely change. For example — if we do a software update on the printer, it will rarely affect the abstraction you actually use.
Encapsulation
Objects contain state and make decisions based on that state, which means that it's essential that each object manage its state and protect against mutations from outside classes. Failure to do so can lead to strange behaviour, bugs and so on.
Encapsulation is the technique of making state private
. Encapsulation means that the class which owns the state decides the degree to which state can be accessed and changed. This is done through the use of public
methods.
Let’s continue with our printer example:
For example, if the printer is currently running (status ON
), it is logical and valid to call turnOff()
to turn it OFF
. Likewise, if the printer is in the PRINTING
state, it's ok and accurate to call pause()
to put the machine into the PAUSED
state. But if the printer is OFF
, it wouldn't be correct to call PAUSE
.
Why?
Because the printer can’t go from OFF to PAUSE.
It doesn't make sense.
Encapsulation is the mechanism that governs these rules for us.
// Encapsulation of printer properties is made here
// by making them private and inaccessible directly
private PrinterState state;
private PageType pageMode;
// Enumerate State and Mode of the printer machine
private enum PrinterState {
OFF, ON, PAUSED, PRINTING, BLOCKED
}
private enum PageMode {
SINGLE, MULTIPLE
}
// Access to the properties is provided by public method
public String getCurrentState() {
return this.state.name();
}
public String getCurrentPageMode() {
return this.pageMode.name();
}
// State is changed only through the use of public methods mutator
public void pause() {
// we only apply the PAUSE logic if the current state is PRINTING
if (this.state == PrinterState.PRINTING) {
// Pause printing
// ...
// Set new state
this.state = PrinterState.PAUSED;
}
}
Client usage may look at something like this.
printer.startPrinting(printerOptions);
System.out.print(printer.getCurrentState()); // PRINTING
printer.pause();
System.out.print(printer.getCurrentState()); // PAUSED
Encapsulation is achieved when each object keeps its state private inside a class. Other objects don't have access to this state. Instead, they can only call a list of public methods. Thus, encapsulation can be viewed as a technique that helps us bind an object's private state and public methods.
Inheritance
Inheritance is a very tight coupling technique in OOP. In statically typed languages like Java, when your child class inherits from a parent class, you inherit every declaration in that parent class, whether you need or not.
And every user of your class then has a transitive dependency on every declaration in the parent class. This is not necessarily bad, but it is something you have to be careful with.
To use inheritance properly, you have to understand exactly what it is, and what it is not. The key to understanding Inheritance is that it provides code re-usability. In place of writing the same code, again and again, we can simply inherit the properties of one class into the other.
One example I often use to demonstrate Inheritance is the car example.
Let’s imagine we have different electronic cars in our system, but we have duplicate common properties and behaviours in each car.
Instead, what we could do is to refactor all the same properties and behaviours into a common place. The abstract class
is a good tool for this use case.
// Parent class (base class)
public abstract class ElectronicCar {
// Every car share those common properties
protected String name;
protected String model;
protected String color;
public ElectronicCar(String name, String model, String color) {
this.name = name;
this.model = model;
this.color = color;
}
// and those behaviours
public void start() {}
public void playMusic() {}
public void runAirConditioning() {}
}
And then in each subclass we can inherit for common functionality of the parent class (public
or protected
methods and properties).
public class Audi extends ElectronicCar {
public Audi(String model, String color) {
super("Audi", model, color);
}
// Audi car inherit of all the behaviours of parent ElectronicCar
// Specific behavior of Audi E-Car
public void unfoldRoof() {}
}
public class Tesla extends ElectronicCar {
public Tesla(String model, String color) {
super("Tesla", model, color);
}
// Tesla car inherit of all the behaviours of parent ElectronicCar
// Specific behaviours of Tesla E-Car
public void turnOnTablet() {}
public void turnOffTablet() {}
}
Inheritance relies on the principle of abstraction; with it, we gain the ability to abstract away (duplicated) low-level details to the parent class so that the subclasses can focus on the (unique) high-level details (unfoldRoof
,turnOnTablet
,turnOffTablet
).
It is about reuse not hierarchies
It is essential to note that concepts which exist in the real world often do not translate into useful software objects.
Abstraction are created to help us express what we need to express to meet the requirements, and to do so in a maintainable way — that’s it.
Therefore, inheritance is a tool we use upon finding opportunities for reuse, not to express real-world hierarchical similarities.
At the heart of object-oriented software development, there is a violation of real-world physics. We have a license to reinvent the world because modeling the real world in our machinery is not our goal — Rebecca Wirfs-Brock via [Object Design]
Composition and delegation
If inheritance is really just about reuse (and not about world-building), then that means we can skip the hierarchy entirely and use the techniques of composition and delegation to implement inheritance — Keep in mind that, you can use replace inheritance by dependency injection when you refactor duplications.
That means we could also refactor the duplication if we have a new car (Ferrari) in our system like this.
public class Ferrari {
private final ElectronicCar electronicCar;
// Compose using dependency injection
public Ferrari(ElectronicCar electronicCar) {
this.electronicCar = electronicCar;
}
// We can share common properties using the dependency
public String getModel() {
return this.electronicCar.getModel();
}
}
Therefore, if we encounter duplication, we have plenty of refactoring techniques:
→ Composition(Extract class)
: When one class does work for two or more classes (violate the SRP), extract related methods to its own class and compose your reliant class with the new one.→ Inheritance (Extract superclass)
: When one class does work for two or more classes (violate the SRP), extract related methods to its own class and compose your reliant class with the new one.→ Rule of Thumb - only inherit from contracts not concretions
: If you’re going to use inheritance the classic OO way, I advise that you only inherit or extend from contracts (interfaces, abstract classes) and not from concretions (classes). There’s a design method which explains why this rule makes sense (see
Responsibility-Driven Design
), but as a quick rule of thumb, you’ll know if you’re using inheritance poorly if you find yourself needing to subclass a concrete class.Polymorphism
In object-oriented programming, polymorphism means having multiple forms. It’s about designing your algorithms in such a way that we always get the same high-level behaviour, but we allow for dynamic change of low-level behaviour at the runtime.
Let’s take a real-world example that will allow me to introduce you to the idea of roles
from Responsibility-Driven Design (a good way to do Object-Oriented Programming correctly).
Think about the role of a teacher at a school.
What’s a teacher supposed to do? speak, planning courses, answer questions of students, right? So you could say those are the responsibilities
of a teacher.
Now, let’s say that there are 2 people that work at this school. Each one can take on the role of a teacher. There’s Markus who is an English teacher, and Hannah who is a physics teacher.
It’s clear that each of these people (Markus and Hannah) does their job slightly differently, but they still perform the responsibilities that a teacher should perform. That is because they are concrete classes which implement the contract of a Teacher.
Let's represent the contract using an interface.
public interface Teacher {
public void speak();
public void planningLesson();
public void answerQuestion(List<Student> students);
}
Then, we could realize the concretions by implementing the Teacher interface, handling things with slightly different behavior if we wish.
class EnglishTeacher implements Teacher {
public void speak() {
// ... Implement uniquely to english class
}
public void planningLesson() {
// ... Implement uniquely to english class
}
public void answerQuestion(List<Student> students) {
// ... Implement uniquely to english class
}
}
class PhysicsTeacher implements Teacher {
public void speak() {
// ... Implement uniquely to physics class
}
public void planningLesson() {
// ... Implement uniquely to physics class
}
public void answerQuestion(List<Student> students) {
// ... Implement uniquely to physics class
}
}
And then finally, in the code that relies on a Teacher , we'd provide a Teacher and Students to check out.
private static void giveCourse(Teacher teacher, List<Student> students) {
// ...
// Teacher have to plan courses
teacher.planningLesson();
// Speak to students (actually teaching)
teacher.speak();
// answer some questions
teacher.answerQuestion(students);
// ...
}
Note that this function relies on a Teacher, not a TeacherMarkus or a TeacherHannah (or so on). Therefore, it doesn’t matter who shows up to work to fill the role. It could be Hannah, Markus — doesn’t matter.
All that matters is that some object with the role of teacher is supplied and that fully implements the requirements of the contract for a Teacher:
Teacher markus = new EnglishTeacher();
Teacher hannah = new PhysicsTeacher();
List<Student> twoStudents = List.of(new Student(), new Student());
giveCourse(markus, twoStudents); // Valid
giveCourse(hannah, twoStudents); // Also valid since hannah is a 'Teacher'
That’s it! By relying on a contract instead of a concretion, we gain the ability to interchange for different possible implementations. This is what polymorphism is about. Dynamic runtime behaviour. Substitutability.
The idea of relying on roles/contracts can be very effective. Within the context of a clean/hexagonal/ports and adapters architecture, the most common reason you'll do this is to keep core code separate from infrastructure code using a technique called dependency inversion.
I hope you enjoyed reading this, and I'm curious to hear if this tutorial helped you. Please let me know your thoughts or opinions below in the comments section.
You can have more of my content by following me on LinkedIn • GitHub or Medium
Wrapping Up
Phew! We've covered a lot of stuff so far. There's just one more big takeaway I want to share — Abstraction, encapsulation, inheritance, and polymorphism are four of the core principles of object-oriented programming.
So what to take home?
- Abstraction lets us selectively focus on the high-level and abstract way the low-level details.
- Inheritance is about code reuse, not hierarchies.
- Encapsulation keeps state private so that we can better enforce business rules, protect model invariants, and develop a single source of truth for related data and logic.
- Polymorphism provides the ability for us to design for dynamic runtime behaviour, easy extensibility, and substitutability.
You can find code samples of this post on my GitHub here.
Thanks for stopping by!