The Adapter Design Pattern is a fundamental concept in software engineering that allows incompatible interfaces to work together seamlessly. In a world where systems and components often need to communicate despite their differences, understanding the various types of Adapter Design Patterns becomes essential for developers. By acting as a bridge between disparate systems, these patterns enhance code reusability and maintainability.
In this blog, we will explore the different types of Adapter Design Patterns, including the Class Adapter and Object Adapter, and their respective roles in software development. We’ll break down their structures, provide practical examples, and discuss their advantages and potential drawbacks. By the end, you’ll have a clearer understanding of how to effectively implement these patterns in your projects, making your codebase more adaptable and robust. Let’s dive into the world of Adapter Design Patterns!
Object Adapter Pattern Definition
The Object Adapter Pattern is a structural design pattern that allows objects with incompatible interfaces to collaborate by using composition rather than inheritance. Instead of modifying the existing class (adaptee), the adapter creates a bridge between the client and the adaptee by holding a reference to the adaptee. This approach enables flexible and reusable solutions without altering existing code.
In the Object Adapter Pattern, the adapter contains an instance of the adaptee and implements the interface expected by the client. It “adapts” the methods of the adaptee to fit the expected interface.
Structure of Object Adapter Pattern
- Client: The class that interacts with the target interface.
- Target Interface: The interface that the client expects.
- Adaptee: The class with an incompatible interface that needs to be adapted.
- Adapter: The class that implements the target interface and holds a reference to the adaptee, enabling the two incompatible interfaces to work together.
In this UML diagram of the Object Adapter Pattern,
- Client → Depends on → Target Interface
- Adapter → Implements → Target Interface
- Adapter → Has a reference to → Adaptee
- Adaptee → Has methods incompatible with the Target Interface
Key Points:
- Object Adapter uses composition (by containing the adaptee) instead of inheritance, which makes it more flexible and reusable.
- The adapter doesn’t alter the existing
Adaptee
class but makes it compatible with theTarget Interface
.
Simple Example of Object Adapter Pattern
Let’s consider a simple scenario where we want to charge different types of phones, but their charging ports are incompatible.
- The Client is a phone charger that expects to use a USB type-C charging port.
- The Adaptee is an old phone that uses a micro-USB charging port.
- The Adapter bridges the difference by converting the micro-USB interface to a USB type-C interface.
Step 1: Define the Target Interface
The charger (client) expects all phones to implement this interface (USB Type-C).
// Target interface that the client expects
interface UsbTypeCCharger {
fun chargeWithUsbTypeC()
}
Step 2: Define the Adaptee
This is the old phone, which only has a Micro-USB port. The charger can’t directly use this interface.
// Adaptee class that uses Micro-USB for charging
class MicroUsbPhone {
fun rechargeWithMicroUsb() {
println("Micro-USB phone: Charging using Micro-USB port")
}
}
Step 3: Create the Adapter
The adapter will “adapt” the Micro-USB phone to make it compatible with the USB Type-C charger. It wraps the MicroUsbPhone
and translates the charging request.
// Adapter that makes Micro-USB phone compatible with USB Type-C charger
class MicroUsbToUsbTypeCAdapter(private val microUsbPhone: MicroUsbPhone) : UsbTypeCCharger {
override fun chargeWithUsbTypeC() {
println("Adapter: Converting USB Type-C to Micro-USB")
microUsbPhone.rechargeWithMicroUsb() // Delegating the charging to the Micro-USB phone
}
}
Step 4: Implement the Client
The client (charger) works with the target interface (UsbTypeCCharger
). It can now charge a phone with a Micro-USB port by using the adapter.
fun main() {
// Old phone with a Micro-USB port (Adaptee)
val microUsbPhone = MicroUsbPhone()
// Adapter that makes the Micro-USB phone compatible with USB Type-C charger
val usbTypeCAdapter = MicroUsbToUsbTypeCAdapter(microUsbPhone)
// Client (USB Type-C Charger) charges the phone using the adapter
println("Client: Charging phone using USB Type-C charger")
usbTypeCAdapter.chargeWithUsbTypeC()
}
Output:
Client: Charging phone using USB Type-C charger
Adapter: Converting USB Type-C to Micro-USB
Micro-USB phone: Charging using Micro-USB port
Here,
- Client: The charger expects all phones to be charged using a USB Type-C port, so it calls
chargeWithUsbTypeC()
. - Adapter: The adapter receives the request from the client to charge using USB Type-C. It converts this request and adapts it to the
MicroUsbPhone
by callingrechargeWithMicroUsb()
internally. - Adaptee (MicroUsbPhone): The phone knows how to charge itself using Micro-USB. The adapter simply makes it compatible with the client’s expectation.
What’s Happening in Each Step
- Client: The charger (client) is asking to charge a phone via
USB Type-C
. - Adapter: The adapter intercepts this request and converts it to something the old phone understands, which is charging via Micro-USB.
- Adaptee (Micro-USB phone): The old phone proceeds with charging using its Micro-USB port.
Basically, the Object Adapter Pattern is a powerful and flexible way to make incompatible interfaces work together. By using composition in Kotlin, you can create an adapter that wraps an existing class (the Adaptee) and makes it compatible with the client’s expected interface without changing the original code. This approach ensures better maintainability, flexibility, and reusability of your code.
Class Adapter Pattern
The Class Adapter Pattern is another type of adapter design pattern where an adapter class inherits from both the target interface and the Adaptee class. Unlike the Object Adapter Pattern, which uses composition (holding an instance of the Adaptee), the Class Adapter Pattern employs multiple inheritance to directly connect the client and the Adaptee.
In languages like Kotlin, which do not support true multiple inheritance, we simulate this behavior by using interfaces. The adapter implements the target interface and extends the Adaptee class to bridge the gap between incompatible interfaces.
Before going into much detail, let’s first understand the structure of the Class Adapter Pattern.
Structure of Class Adapter Pattern
- Client: The class that interacts with the target interface.
- Target Interface: The interface that the client expects to interact with.
- Adaptee: The class with an incompatible interface that needs to be adapted.
- Adapter: A class that inherits from both the target interface and the adaptee, adapting the adaptee to be compatible with the client.
In this UML diagram of the Class Adapter Pattern,
- Client → Depends on → Target Interface
- Adapter → Inherits from → Adaptee
- Adapter → Implements → Target Interface
- Adaptee → Has methods incompatible with the target interface
Key Points:
- The Class Adapter pattern relies on inheritance to connect the
Adaptee
and theTarget Interface
. - The adapter inherits from the adaptee and implements the target interface, thus combining both functionalities.
Simple Example of Class Adapter Pattern
Now, let’s look at an example of the Class Adapter Pattern. We’ll use the same scenario: a charger that expects a USB Type-C interface but has an old phone that only supports Micro-USB.
Step 1: Define the Target Interface
This is the interface that the client (charger) expects.
// Target interface that the client expects
interface UsbTypeCCharger {
fun chargeWithUsbTypeC()
}
Step 2: Define the Adaptee
This is the class that needs to be adapted. It’s the old phone with a Micro-USB charging port.
// Adaptee class that uses Micro-USB for charging
class MicroUsbPhone {
fun rechargeWithMicroUsb() {
println("Micro-USB phone: Charging using Micro-USB port")
}
}
Step 3: Define the Adapter (Class Adapter)
The Adapter inherits from the MicroUsbPhone
(adaptee) and implements the UsbTypeCCharger
(target interface). It adapts the MicroUsbPhone
to be compatible with the UsbTypeCCharger
interface.
// Adapter that inherits from MicroUsbPhone and implements UsbTypeCCharger
class MicroUsbToUsbTypeCAdapter : MicroUsbPhone(), UsbTypeCCharger {
// Implement the method from UsbTypeCCharger
override fun chargeWithUsbTypeC() {
println("Adapter: Converting USB Type-C to Micro-USB")
// Call the inherited method from MicroUsbPhone
rechargeWithMicroUsb() // Uses the Micro-USB method to charge
}
}
Step 4: Client Usage
The Client only interacts with the UsbTypeCCharger
interface and charges the phone through the adapter.
fun main() {
// Adapter that allows charging a Micro-USB phone with a USB Type-C charger
val usbTypeCAdapter = MicroUsbToUsbTypeCAdapter()
// Client (USB Type-C Charger) charges the phone through the adapter
println("Client: Charging phone using USB Type-C charger")
usbTypeCAdapter.chargeWithUsbTypeC()
}
Output:
Client: Charging phone using USB Type-C charger
Adapter: Converting USB Type-C to Micro-USB
Micro-USB phone: Charging using Micro-USB port
Here,
- Client: The client expects all phones to be charged using the
UsbTypeCCharger
interface. - Adapter: The adapter class inherits the behavior of the
MicroUsbPhone
(adaptee) and implements theUsbTypeCCharger
interface. It converts the USB Type-C charging request and delegates it to the inheritedrechargeWithMicroUsb()
method. - Adaptee (Micro-USB phone): The
MicroUsbPhone
class has a method to recharge using Micro-USB, which is directly called by the adapter.
What’s Happening in Each Step
- Client: The client attempts to charge a phone using the
chargeWithUsbTypeC()
method. - Adapter: The adapter intercepts this request and converts it to the
rechargeWithMicroUsb()
method, which it inherits from theMicroUsbPhone
class. - Adaptee: The phone charges using the
rechargeWithMicroUsb()
method, fulfilling the request.
Actually, the Class Adapter Pattern allows you to make incompatible interfaces work together by using inheritance. In Kotlin, this involves implementing the target interface and extending the Adaptee class. While this approach is simple and performant, it’s less flexible than the Object Adapter Pattern because it binds the adapter directly to the Adaptee.
This pattern works well when you need a tight coupling between the adapter and the Adaptee, but for more flexibility, the Object Adapter Pattern is often the better choice.
Class Adapter Vs. Object Adapter
The main difference between the Class Adapter and the Object Adapter lies in how they achieve compatibility between the Target and the Adaptee. In the Class Adapter pattern, we use inheritance by subclassing both the Target interface and the Adaptee class, which allows the adapter to directly access the Adaptee’s behavior. This means the adapter is tightly coupled to both the Target and the Adaptee at compile-time.
On the other hand, the Object Adapter pattern relies on composition, meaning the adapter holds a reference to an instance of the Adaptee rather than inheriting from it. This approach allows the adapter to forward requests to the Adaptee, making it more flexible because the Adaptee instance can be changed or swapped without modifying the adapter. The Object Adapter pattern is generally preferred when more flexibility is needed, as it loosely couples the adapter and Adaptee.
In short, the key difference is that the Class Adapter subclasses both the Target and the Adaptee, while the Object Adapter uses composition to forward requests to the Adaptee.
Conclusion
Adapter Design Pattern plays a crucial role in facilitating communication between incompatible interfaces, making it an invaluable tool in software development. By exploring the various types of adapters—such as the Class Adapter and Object Adapter—you can enhance the flexibility and maintainability of your code.
As we’ve seen, each type of adapter has its unique structure, advantages, and challenges. Understanding these nuances allows you to choose the right adapter for your specific needs, ultimately leading to more efficient and cleaner code. As you continue to develop your projects, consider how the Adapter Design Pattern can streamline integration efforts and improve your software architecture. Embrace these patterns, and empower your code to adapt and thrive in an ever-evolving technological landscape. Happy coding!