We are back again to talk about object-oriented programming. In the first article, we talk about the history of the paradigm and some basic concepts. It's a good idea to read this article before this one, once the concepts showed there will be used to talk about the four pillars of OOP: Abstraction, Encapsulation, Inheritance, and Polymorphism.
Study Case
To better understand the four concepts, we will use an application written in Java. This case study simulates a real-world banking application to demonstrate how those principles work.
The code is on this GitHub repository.
Abstraction
Abstraction involves structuring our system into low and high-level components.
Low-level components are classes and objects close to what our application does. We can say that these components do the dirty work, dealing with requests, and changes, and sometimes coordinating a complex flow.
On the other hand, high-level components are classes that represent abstract concepts. They don’t have a specific function in the application but can be reused in different contexts, assisting the low-level components in their tasks.
In our study case, we have three classes that can represent the use of abstraction: BankAccount, IndividualAccount, and EnterpriseAccount. The class BankAccount represents a generic account, controlling all the balance updates and account changes, but it doesn't represent any specific account type. We have two low-level classes for account type: IndividualAccount for persons, and EnterpriseAccount for companies. These two classes are used across the application to execute user requests, using BankAccount as its basis.
A good OOP software is a balance between low and high-level classes. However, it’s important to note that there aren’t just two layers of components. We can have multiple layers, as many as needed for a well-structured design. A class that appears high-level from one perspective may be considered low-level from another. In the example we use above, InvididualAccount and EnterpriseAccount are low levels from the point of view of BankAccount. At the same time, these two classes are high-level components from the point of view of the other components in the application, once they are in a layer above them in our design.
Ultimately, the main reason for abstraction is to simplify the code, create more manageable and reusable classes, and hide complex functionality. A high-level class aims to make an object easy to use and understand.
A good design using abstraction separates what needs to be done from how it is accomplished. A low-level object understands what it requires from the high-level object but doesn’t need to know how it performs its tasks. This way, we create a layer separating the use of a code and its implementation. Also, if we make the right separation between those concepts, high-level classes hardly change, which makes maintenance easier.
Encapsulation
OOP is based on objects and the interactions between them. But we don't want to allow free communication between all objects, and sometimes we need to keep an object’s data, or part of it, hidden from the rest of our code. This process is known as Encapsulation.
Encapsulation can be used for different reasons. First and perhaps most important is security. As we know, an object is based on state (properties) and behavior (methods). Therefore, given its context, it must ensure its state is valid and consistent. When we hide some information, we want to control who can access and principally change the object’s state. If the state can be affected by anything, it could lead to mistakes that impact this object's integrity.
Again, the BankAccount class can give us a good example of encapsulation. We must reduce the objects that can update the user's balance
, once a wrong update of this value can leave us with big issues. So, the balance property is private and the only way to update it is using the method updateBalance
. This gives BankAccount control of the changes, and if any rule is broken, the class can deny the change.
Another benefit of Encapsulation is that it simplifies the code. We can hide complex algorithms, making only the object owner of the code aware of their implementation. The other objects don’t need to know details. They use the method provided, and how things work under the hood is irrelevant to them.
Note the Payment class in our study case. This class has the executePayment
method, which interacts with the bank account object and is used by TransferPayement and DebitPayment classes. Those classes only call the method and don't know how it works. Of course, this is not the case with a complex algorithm, but introduces the idea of hiding details from external classes.
We use the access modifiers to achieve effective encapsulation. The most common are private, protected, and public. In a good code design, nearly all the properties should be private. In some cases, when we want to grant child classes access to some properties from the parent class, those properties can be protected. This ensures that no one can directly access the object state.
The public access modifier should be used only for methods, and only a small set of them, as we don’t want to expose all the object behavior. Those methods will act as a gateway, giving access to the object's current state and providing ways to change it.
Inheritance
Inheritance is a mechanism that enables us to share properties and methods. We can create a single code that will be reused throughout the application. Then we can create classes that inherit this code, allowing them to use and redefine it as necessary.
The class that inherits is known as a child class, subclass, or even derived class. The inherited class is known as a parent class, superclass, or base class.
A parent class usually represents something abstract, which allows further definitions by the child class. When inheriting from a parent class, a child class can do one of three things:
- Use the parent code completely, without changes.
- Reuse the parent code partially by mixing it with your code
- Reset the parent code completely, and rewrite the behavior for its needs.
Inheritance can be done using an interface, a concrete, or an abstract class. The child class can also be concrete or abstract. However, an abstract child class cannot be instantiated by itself; it requires another class to inherit from it. A child class uses the override technique to modify the behavior of methods inherited from the parent class.
In our application, the classes that make deposits and withdraw use inheritance. DepositMovement and WithdrawMovement inherit from the interface Movement. The interface defines a contract and ensures a common behavior between components. The classes implement this interface, inheriting the method movementMoney
. The classes BankAccount, IndividualAccount, and EnterpriseAccount are also examples of inheritance.
Problems
Inheritance is extremely powerful and useful. However, there are also some downsides.
First, it creates a strong dependency between parent and child class that can make changes and evolutions difficult. When the parent class is modified, all the child classes below it will be affected. The deeper the inheritance hierarchy, the more complex it becomes to change and maintain the code.
Another issue is that inheritance is defined at compile time, so once the bind between classes is done, it won't change. This makes the code inflexible.
Inheritance can also break or weaken the encapsulation since a child class can access more things than is desirable. Tests can also be hard when we have an inheritance.
Composition
Composition is a technique that allows a class to reference objects of other classes in its instance variables. This establishes a "has-a" relationship between the classes, where one class contains an object of another class. This can be a better option in situations where we can use inheritance.
Composition offers solutions to some of the challenges associated with inheritance:
- We can change the behavior of a class at runtime by changing the object instance.
- We can reduce duplication but with loose coupling between classes.
- Avoid the hierarchy hell, and keep classes simple and modular.
- It's easier to test, once we could change the instance used at runtime.
- Promotes better encapsulation, once the class used in composition will expose only what it wants.
In our application, the Payment class uses composition. TransferPayment and DebitPayment don't inherit Payment. Instead, they use an instance to call the method on the base class.
Despite these benefits, inheritance should not be dismissed entirely. There are cases when it can be a good option:
- When there is a clear “is-a” relationship, that is, when a class is a specialization of another.
- When multiple classes share a significant amount of code, and you want to avoid duplication.
- When we want to extend the functionality of a code that is external to our application, as a framework or a library
As always, it depends on the specific context. The better option will depend on the requirements and constraints of the project. Composition offers more flexibility and promotes better design practices. Inheritance can be appropriate for clear hierarchical relationships and shared behavior.
Polymorphism
Polymorphism is one of the fundamental pillars of OOP, and at least for me, the most difficult to understand and explain.
First, it's closely related to inheritance. When we work with inheritance, we create a hierarchy between classes, where a parent class has a set of child classes.
In this scenario, it is common for methods in the parent class to be reused by all child classes. But how can we have a single method that accepts any child class as a parameter? Polymorphism provides a solution to this challenge.
Polymorphism allows an object to be treated as an instance of its parent class or an implemented interface. This means we can have a method with a parent class or interface as its parameter, allowing it to accept any object of the child classes. So a single method to handle various objects as if they have a common type.
However, this approach has a limitation: methods defined outside the parent class or interface cannot be accessed. If a child class defines a new behavior, it will not be accessible through polymorphism.
There are two types of polymorphism. The first type, runtime polymorphism, occurs when a child class overrides a method from the parent class. With this, we can change the method's behavior on the child class during runtime. The second type, compile-time polymorphism, occurs when a child class overloads a method from the parent class. This changes the method’s behavior before the application starts.
Again, the classes BankAccount, IndividualAccount, and EnterpriseAccount can be used as examples, once they are good examples of polymorphism. Throughout the application, some methods use an object BankAccount as a parameter. However, the objects passed to those methods are IndividualAccount or EnterpriseAccount instances, and this only works because of their inheritance relation and polymorphism. Also, we have an example of compile-time polymorphism, once the method isDocumentValid
is overridden in both concrete classes.
Polymorphism is an effective way to make code flexible, extensible, and easy to maintain, as it allows shared methods across an entire hierarchy of objects.
In the end, maybe this text became longer than I wanted it to. And I didn't go deeper at any of the four pillars. There is more to talk about and learn.
Also, the application used in the examples is to give you a concrete case of each pillar in a "real" application. When we work in production code, the challenges are more difficult, and the lines between good and bad code are thin. But if you understand these concepts, the path to good decisions may be clear.
I hope you enjoyed it! Thanks.
Top comments (0)