Abstraction is one of the key concepts of object-oriented programming (OOP) languages. 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.
That’s a very generic concept that’s not limited to object-oriented programming. You can find it everywhere in the real world.
I’m a coffee addict. So, when I wake up in the morning, I go into my kitchen, switch on the coffee machine and make coffee. Sounds familiar?
Making coffee with a coffee machine is a good example of abstraction.
You need to know how to use your coffee machine to make coffee. You need to provide water and coffee beans, switch it on and select the kind of coffee you want to get.
The thing you don’t need to know is how the coffee machine is working internally to brew a fresh cup of delicious coffee. You don’t need to know the ideal temperature of the water or the amount of ground coffee you need to use.
Someone else worried about that and created a coffee machine that now acts as an abstraction and hides all these details. You just interact with a simple interface that doesn’t require any knowledge about the internal implementation.
You can use the same concept in object-oriented programming languages like Java.
Objects in an OOP language provide an abstraction that hides the internal implementation details. Similar to the coffee machine in your kitchen, you just need to know which methods of the object are available to call and which input parameters are needed to trigger a specific operation. But you don’t need to understand how this method is implemented and which kinds of actions it has to perform to create the expected result.
There are primarily two types of abstraction implemented in OOPs. One is data abstraction which pertains to abstracting data entities. The second one is process abstraction which hides the underlying implementation of a process. Let’s take a quick peek into both of these.
Data abstraction is the simplest form of abstraction. When working with OOPS, you primarily work on manipulating and dealing with complex objects. This object represents some data but the underlying characteristics or structure of that data is actually hidden from you. Let’s go back to our example of making coffee.
Let’s say that I need a special hazelnut coffee this time. Luckily, there’s a new type of coffee powder or processed coffee beans that already have hazelnut in it. So I can directly add the hazelnut coffee beans and the coffee machine treats it as just any other regular coffee bean. In this case, the hazelnut coffee bean itself is an abstraction of the original data, the raw coffee beans. I can use the hazelnut coffee beans directly without worrying about how the original coffee beans were made to add the hazelnut flavour to it.
Therefore, data abstraction refers to hiding the original data entity via a data structure that can internally work through the hidden data entities. As programmers, we don’t need to know what the underlying entity is, how it looks etc.
Where data abstraction works with data, process abstraction does the same job but with processes. In process abstraction, the underlying implementation details of a process are hidden. We work with abstracted processes that under the hood use hidden processes to execute an action.
Circling back to our coffee example, let’s say our coffee machine has a function to internally clean the entire empty machine for us. This is a process that we may want to do every once a week or two so that our coffee machine stays clean. We press a button on the machine which sends it a command to internally clean it. Under the hood, there is a lot that will happen now. The coffee machine will need to clean the piston, the outlets or nozzles from which it pours the coffee, and the container for the beans, and then finally rinse out the water and dry out the system.
A single process of cleaning the coffee machine was known to us, but internally it implements multiple other processes that were actually abstracted from us. This is process abstraction in a nutshell.
Well, this process abstraction example really got me thinking of a very futuristic coffee machine!
Now that we understand abstraction well, let’s see how we can implement it. Since I’ve spun my coffee stories so much already, let’s actually go ahead and implement the coffee machine example in Java. You do the same in any other object-oriented programming language. The syntax might be a little bit different, but the general concept is the same.
Modern coffee machines have become pretty complex. Depending on your choice of coffee, they decide which of the available coffee beans to use and how to grind them. They also use the right amount of water and heat it to the required temperature to brew a huge cup of filter coffee or a small and strong espresso.
Using the concept of abstraction, you can hide all these decisions and processing steps within your CoffeeMachine class. If you want to keep it as simple as possible, you just need a constructor method that takes a Map of CoffeeBean objects to create a new CoffeeMachine object and a brewCoffee method that expects your CoffeeSelection and returns a Coffee object.
You can clone the source of the example project at https://github.com/thjanssen/Stackify-OopAbstraction.
import org.thoughts.on.java.coffee.CoffeeException; import java.utils.Map; public class CoffeeMachine { private Map<CoffeeSelection, CoffeeBean> beans; public CoffeeMachine(Map<CoffeeSelection, CoffeeBean> beans) { this.beans = beans } public Coffee brewCoffee(CoffeeSelection selection) throws CoffeeException { Coffee coffee = new Coffee(); System.out.println(“Making coffee ...”); return coffee; } }
CoffeeSelection is a simple enum providing a set of predefined values for the different kinds of coffees.
public enum CoffeeSelection { FILTER_COFFEE, ESPRESSO, CAPPUCCINO; }
And the classes CoffeeBean and Coffee are simple POJOs (plain old Java objects) that only store a set of attributes without providing any logic.
public class CoffeeBean { private String name; private double quantity; public CoffeeBean(String name, double quantity) { this.name = name; this.quantity; } }
public class Coffee { private CoffeeSelection selection; private double quantity; public Coffee(CoffeeSelection, double quantity) { this.selection = selection; this. quantity = quantity; } }
Using the CoffeeMachine class is almost as easy as making your morning coffee. You just need to prepare a Map of the available CoffeeBeans. After that, instantiate a new CoffeeMachine object. Finally, call the brewCoffee method with your preferred CoffeeSelection.
import org.thoughts.on.java.coffee.CoffeeException; import java.util.HashMap; import java.util.Map; public class CoffeeApp { public static void main(String[] args) { // create a Map of available coffee beans Map<CoffeeSelection, CoffeeBean> beans = new HashMap<CoffeeSelection, CoffeeBean>(); beans.put(CoffeeSelection.ESPRESSO, new CoffeeBean("My favorite espresso bean", 1000)); beans.put(CoffeeSelection.FILTER_COFFEE, new CoffeeBean("My favorite filter coffee bean", 1000)); // get a new CoffeeMachine object CoffeeMachine machine = new CoffeeMachine(beans); // brew a fresh coffee try { Coffee espresso = machine.brewCoffee(CoffeeSelection.ESPRESSO); } catch(CoffeeException e) { e.printStackTrace(); } } // end main } // end CoffeeApp
You can see in this example that the abstraction provided by the CoffeeMachine class hides all the details of the brewing process. That makes it easy to use and allows each developer to focus on a specific class.
If you implement the CoffeeMachine, you don’t need to worry about any external tasks, like providing cups, accepting orders or serving the coffee. Someone else will work on that. Your job is to create a CoffeeMachine that makes good coffee.
And if you implement a client that uses the CoffeeMachine, you don’t need to know anything about its internal processes. Someone else already implemented it so that you can rely on its abstraction to use it within your application or system.
That makes the implementation of a complex application a lot easier. And this concept is not limited to the public methods of your class. Each system, component, class, and method provides a different level of abstraction. You can use that on all levels of your system to implement software that’s highly reusable and easy to understand.
Let’s dive a little bit deeper into the coffee machine project and take a look at the constructor method of the CoffeeMachine class.
import java.util.Map; public class CoffeeMachine { private Map<CoffeeSelection, Configuration> configMap; private Map<CoffeeSelection, CoffeeBean> beans; private Grinder grinder; private BrewingUnit brewingUnit; public CoffeeMachine(Map<CoffeeSelection, CoffeeBean> beans) { this.beans = beans; this.grinder = new Grinder(); this.brewingUnit = new BrewingUnit(); // create coffee configuration this.configMap = new HashMap<CoffeeSelection, Configuration>(); this.configMap.put(CoffeeSelection.ESPRESSO, new Configuration(8, 28)); this.configMap.put(CoffeeSelection.FILTER_COFFEE, new Configuration(30, 480)); } }
As you can see in the code snippet, the constructor not only stores the provided Map of available CoffeeBeans in an internal property, it also initializes an internal Map that stores the configuration required to brew the different kinds of coffees and instantiates a Grinder and a BrewingUnit object.
All these steps are not visible to the caller of the constructor method. The developer most likely doesn’t even know that the Grinder or BrewingUnit class exists. That’s another example of the abstraction that the CoffeeMachine class provides.
The classes Grinder and BrewingUnit provide abstractions on their own. The Grinder abstracts the complexity of grinding the coffee and BrewingUnit hides the details of the brewing process.
public class Grinder { public GroundCoffee grind(CoffeeBean coffeeBean, double quantityCoffee) { // ... } }
public class BrewingUnit { public Coffee brew(CoffeeSelection selection, GroundCoffee groundCoffee, double quantity) { // ... } }
That makes the implementation of the CoffeeMachine class a lot easier. You can implement the brewCoffee method without knowing any details about the grinding or brewing process. You just need to know how to instantiate the 2 classes and call the grind and brew methods.
In this example, I took the abstraction one step further and implemented 3 methods to brew the different kinds of coffee. The brewCoffee method, which gets called by the client, just evaluates the provided CoffeeSelection and calls another method that brews the specified kind of coffee.
The brewFilterCoffee and brewEspresso methods abstract the specific operations required to brew the coffee.
private Coffee brewFilterCoffee() { Configuration config = configMap.get(CoffeeSelection.FILTER_COFFEE); // grind the coffee beans GroundCoffee groundCoffee = this.grinder.grind( this.beans.get(CoffeeSelection.FILTER_COFFEE), config.getQuantityCoffee()); // brew a filter coffee return this.brewingUnit.brew( CoffeeSelection.FILTER_COFFEE, groundCoffee, config.getQuantityWater()); }
private Coffee brewEspresso() { Configuration config = configMap.get(CoffeeSelection.ESPRESSO); // grind the coffee beans GroundCoffee groundCoffee = this.grinder.grind( this.beans.get(CoffeeSelection.ESPRESSO), config.getQuantityCoffee()); // brew an espresso return this.brewingUnit.brew( CoffeeSelection.ESPRESSO, groundCoffee, config.getQuantityWater()); }
I defined both methods as private because I just want to provide an additional, internal level of abstraction. That not only makes the implementation of the brewCoffee method a lot easier, it also improves the reusability of the code.
You could, for example, reuse the brewEspresso method when you want to support the CoffeeSelection.CAPPUCCINO. You would then just need to implement the required operations to heat the milk, call the brewEspresso method to get an espresso, and add it to the milk.
A lot of times programmers often confuse abstraction with encapsulation because in reality the two concepts are quite intertwined and share a relationship between them. Abstraction, as we’ve seen pertains to hiding underlying details and implementation in a program. Encapsulation, on the other hand, describes how abstraction occurs in a program.
Abstraction is a design-level process, but encapsulation is an implementation process. Encapsulation tells us how exactly you can implement abstraction in the program. Abstraction pertains to only displaying the essential details to the user whereas encapsulation pertains to typing up all the data members and associated member functions into a single abstracted unit.
An interface is a contract a class can sign up for, meaning the class promises to implement all the methods defined in the interface. Unlike abstract classes, interfaces do not contain any method implementations, only the method signatures. A class can implement multiple interfaces, allowing for more flexible code structures. Interfaces focus solely on defining the methods that need to be implemented, without providing any details on how to implement them.
Both abstraction and interfaces define a contract for classes to follow, ensuring that certain methods are available. However, Abstraction (through abstract classes) can include both fully implemented methods and methods that subclasses must implement. On the other hand, Interfaces only declare method signatures and do not provide any implementation.
In addition, a class can inherit from only one abstract class but can implement multiple interfaces, providing more flexibility in the design.
A protocol is similar to an interface in that it defines a set of methods or properties that a class must implement. Protocols are particularly common in languages like Swift and Objective-C. It defines a blueprint of methods or properties, and any class or struct that conforms to a protocol must implement these methods or properties.
It’s worth noting that both abstraction and protocols are aimed at defining a contract or a blueprint that must be followed, thus providing a level of abstraction. However, abstraction (via abstract classes) can include both abstract methods and concrete methods (methods with implementation). On the other hand, Protocols (like interfaces) only declare the methods or properties that must be implemented, without providing any implementation details.
Additionally, Protocols are specific to certain programming languages (like Swift), while abstraction is a broader OOP concept applicable in many languages.
Polymorphism is an OOP concept where objects of different classes can be treated as objects of a common superclass or interface. It allows the same operation to be performed in different ways depending on the object it is operating on. While abstraction is about simplifying the interaction with complex systems by hiding unnecessary details, polymorphism is about enabling one interface to be used for different underlying forms (data types).
Also, abstraction is often used to create a simple and understandable interface that other code can use without needing to know the details. On the contrary, polymorphism allows that simple interface to be used with a variety of different objects, each potentially behaving differently. In terms of implementation, Furthermore, abstraction can be implemented using abstract classes or interfaces, while polymorphism is implemented through method overloading (compile-time) or method overriding (runtime).
Inheritance is an OOP concept where a new class (subclass) is created based on an existing class (superclass). The subclass inherits the attributes and methods of the superclass, allowing for code reuse and the creation of a class hierarchy. It allows for the reuse of existing code and the extension of functionalities in a hierarchical manner. Inheritance also establishes a natural relationship between classes, where one class is a specialized version of another.
In comparison, abstraction is about hiding complexity and focusing on what an object does. It can be achieved through abstract classes or interfaces, allowing different classes to define the same functionalities in different ways. On the other hand, inheritance is about creating a relationship between classes, where a subclass inherits the properties and methods of a superclass and can override or extend them. It is not about hiding complexity but about reusing and extending existing code.
Abstraction is a general concept which you can find in the real world as well as in OOP languages. Any objects in the real world that hide internal details provide an abstraction. The objects may be your coffee machine or classes in your current software project,
These abstractions make it a lot easier to handle complexity by splitting them into smaller parts. In the best case, you can use them without understanding how they provide the functionality. And that helps you to split the complexity of your next software project into manageable parts. It also enables you every morning to brew a fresh cup of amazing coffee while you’re still half asleep.
Looking to continually improve your applications? Most OOP languages are supported by Stackify’s free dynamic code profiler, Prefix, and Stackify’s full lifecycle APM, Retrace. Try both for free.
If you would like to be a guest contributor to the Stackify blog please reach out to [email protected]