Discover the Power of the Adapter Design Pattern: Structure, Types, and Android Best Practices

Table of Contents

The Adapter Design Pattern is a developer’s secret weapon when it comes to making incompatible systems work together smoothly without altering their original code. Acting as a bridge, it allows different components to communicate effortlessly. If you’ve ever hit a roadblock where two pieces of code just wouldn’t “talk” to each other, then you’ve faced the exact challenge that the Adapter Pattern is designed to solve!

In this blog, we’re diving deep into everything about the Adapter Design Pattern—its structure, types (like Class and Object adapters), examples, real-world use cases, and how it’s applied in Android development. Whether you’re working with legacy systems or building new features, this pattern is key to simplifying integration and boosting code flexibility.

Grab a coffee mug—this blog’s going to be a big one! Get ready for a complete guide that will take your understanding of design patterns to the next level. Let’s get started!

What is the Adapter Design Pattern?

The Adapter Design Pattern helps connect two things that wouldn’t normally work together because they don’t “fit” or communicate the same way. It acts as a bridge that makes an existing class compatible with another class you need, without changing either one.

Think of it like using an adapter to plug something into an outlet that has a different shape—it allows them to work together without altering either the plug or the outlet.

Imagine you’re traveling in Europe with your US laptop. The European wall outlet provides 220 volts, while your laptop’s power adapter is designed for a standard AC plug and expects 110 volts. They’re incompatible, right? That’s where a power adapter steps in, converting the European outlet’s power to match what your laptop needs.

In software, the Adapter Pattern works in the same way. It allows two incompatible interfaces to work together without changing their core functionality. Just like the power adapter converts the outlet’s power, a software adapter “translates” between systems to make them compatible.

Instead of rewriting code, you create an adapter class that bridges the gap—keeping everything working smoothly.

In short, the Adapter Pattern is your go-to solution for making incompatible systems work together, just like your handy travel adapter!

Defination of Adapter Design Pattern

The Adapter design pattern (one of the structural design patterns) acts as a bridge between two incompatible interfaces. It allows an existing class (which has a specific interface) to be used with another class (which expects a different interface), without changing their existing code. It does this by creating an intermediary adapter class that translates the method calls from one interface to the other.

Why is the Adapter called ‘glue’ or ‘wrapper’?

Sometimes, a class has the features a client needs, but its way of interacting (interface) doesn’t match what the client expects. In these cases, we need to transform the existing interface into a new one that the client can work with, while still utilizing the original class.

Suppose you have an existing software system that requires integrating a new vendor library, but the new vendor has designed their interfaces differently from the previous vendor. What should you do? Write a class that adapts the new vendor’s interface to the one you’re expecting.

The Adapter Pattern helps us achieve this by creating a wrapper class around the original object. This wrapper is called an adapter, and the original object is known as the adaptee. The adapter acts as a bridge, allowing the client to use the adaptee’s functionality in a way that meets their needs.

To expand on this, the adapter is often referred to as “glue” because it metaphorically binds together two different interfaces, making them work smoothly as one. Similarly, it is called a “wrapper” because it encloses the original object (the adaptee) and presents a modified interface that the client can use without needing to change the original object.

The Structure of Adapter Pattern

The Adapter Design Pattern involves four components:

  1. Target (Interface): The desired interface that the client expects.
  2. Adaptee: The existing class that has the behavior we want to use but with an incompatible interface.
  3. Adapter: A wrapper class that implements the Target interface and translates the requests from the client to the Adaptee.
  4. Client: The entity that interacts with the Target interface.

Let’s revisit our example of a European wall socket and a US laptop’s AC plug for better understanding.

  • Adaptee Interface: This is the existing interface or system that needs to be adapted. It has its own methods that may not be compatible with what the client expects.
  • Target Interface: This is the interface that the client is designed to work with. The client will call methods from this interface.
  • Request Method: This is the method defined in the target interface that the client will use.
  • Adapter:
    The adapter acts as a bridge between the target interface and the adaptee interface. It implements the target interface and holds a reference to an instance of the adaptee. The adapter translates calls from the target interface into calls to the adaptee interface.
  • Translated Request Method: This method in the adapter takes the request from the client and converts it into a format that the adaptee can understand.

Now, we have a EuropeanWallSocket that provides electricity in a format incompatible with a US laptop. We will create an adapter to make them compatible.

Step 1: Define the Adaptee Interface

This is the existing interface that represents the European wall socket.

Kotlin
// Adaptee interface
interface EuropeanWallSocket {
    fun provideElectricity(): String // Provides electricity in European format
}

// Implementation of the adaptee
class EuropeanWallSocketImpl : EuropeanWallSocket {
    override fun provideElectricity(): String {
        return "220V AC from European wall socket"
    }
}

Step 2: Define the Target Interface

This is the interface that our US laptop expects.

Kotlin
// Target interface
interface USLaptop {
    fun plugIn(): String // Expects a method to plug in
}

Step 3: Create the Adapter

The adapter will implement the target interface and use an instance of the adaptee.

Kotlin
// Adapter class
class SocketAdapter(private val europeanWallSocket: EuropeanWallSocket) : USLaptop {
    override fun plugIn(): String {
        // Adapt the European socket output for the US laptop
        val electricity = europeanWallSocket.provideElectricity()
        return "Adapting: $electricity to 110V AC for US laptop"
    }
}

Step 4: Client Code

Now, the client can use the USLaptop interface without worrying about the underlying EuropeanWallSocket.

Kotlin
fun main() {
    // Create an instance of the adaptee (European socket)
    val europeanSocket = EuropeanWallSocketImpl()

    // Use the adapter to connect the US laptop
    val socketAdapter = SocketAdapter(europeanSocket)

    // Plug in the US laptop using the adapter
    println(socketAdapter.plugIn())
}
Here,
  1. Adaptee: The EuropeanWallSocket interface and its implementation, EuropeanWallSocketImpl, represent a wall socket that provides electricity in the European format (220V AC).
  2. Target: The USLaptop interface defines the method the laptop uses to connect to a power source.
  3. Adapter: The SocketAdapter class implements the USLaptop interface and contains an instance of EuropeanWallSocket. It adapts the output from the European wall socket to a format that the US laptop can understand (converting it to 110V AC).
  4. Client: In the main function, we create an instance of the EuropeanWallSocketImpl, wrap it in the SocketAdapter, and call the plugIn method to simulate plugging in the US laptop.

Output

When you run this code, it will output:

Kotlin
Adapting: 220V AC from European wall socket to 110V AC for US laptop

This example is only for demonstration purposes, illustrating how the Adapter Pattern allows a US laptop to work with a European wall socket by adapting the interface, making the systems compatible without altering their original functionality.

Bridging the Gap: How the Adapter Pattern Facilitates Communication

Have you ever wondered how the Adapter Pattern bridges the gap? The answer lies in the use of object composition and the principle that the pattern binds the client to an interface rather than an implementation.

Delegation serves as the vital link that connects an Adapter to its Adaptee, facilitating seamless communication between the two. Meanwhile, interface inheritance defines the contract that the Adapter class must follow, ensuring clarity and consistency in its interactions.

Look at the previous example above: the client code binds to the USLaptop interface, not to the specific implementation of the adapter or the Adaptee. This design allows for flexibility; if you need to adapt to a different type of socket in the future, you can create a new adapter that implements the same USLaptop interface without changing the client code.

The Target and the Adaptee—often an older, legacy system—are established before the Adapter is introduced. The Adapter acts as a bridge, allowing the Target to utilize the Adaptee’s functionality without modifying its original structure. This approach not only enhances flexibility, but also elegantly encapsulates complexity, enabling developers to create more adaptable systems.

Adapter Pattern Variants

There are two common variants of the Adapter pattern:

  • Object Adapter: The adapter holds an instance of the adaptee and delegates requests to it.
  • Class Adapter: The adapter inherits from both the target and adaptee classes. However, Kotlin (like Java) does not support multiple inheritance, so this variant is less commonly used in Kotlin.

Object Adapters and Class Adapters use two different methods to adapt the Adaptee: composition and inheritance.

Let’s look at each one individually and discuss their differences.

Object Adapter Pattern

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

  1. Client: The class that interacts with the target interface.
  2. Target Interface: The interface that the client expects.
  3. Adaptee: The class with an incompatible interface that needs to be adapted.
  4. 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 the Target 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.

  1. The Client is a phone charger that expects to use a USB type-C charging port.
  2. The Adaptee is an old phone that uses a micro-USB charging port.
  3. 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).

Kotlin
// 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.

Kotlin
// 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.

Kotlin
// 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.

Kotlin
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:

Kotlin
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 calling rechargeWithMicroUsb() internally.
  • Adaptee (MicroUsbPhone): The phone knows how to charge itself using Micro-USB. The adapter simply makes it compatible with the client’s expectation.

Now, let’s look at another type, the Class Adapter Pattern.

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

  1. Client: The class that interacts with the target interface.
  2. Target Interface: The interface that the client expects to interact with.
  3. Adaptee: The class with an incompatible interface that needs to be adapted.
  4. 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 the Target 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.

Kotlin
// 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.

Kotlin
// 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.

Kotlin
// 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.

Kotlin
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:

Kotlin
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 the UsbTypeCCharger interface. It converts the USB Type-C charging request and delegates it to the inherited rechargeWithMicroUsb() method.
  • Adaptee (Micro-USB phone): The MicroUsbPhone class has a method to recharge using Micro-USB, which is directly called by the adapter.

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.

Real-World Examples

We’ll look at more real-world examples soon, but before that, let’s first explore a structural example of the Adapter Pattern to ensure a smooth understanding.

Adapter Pattern: Structural Example

Since we’ve already seen many code examples, there’s no rocket science here. Let’s jump straight into the code and then go over its explanation.

Kotlin
// Target interface that the client expects
interface Target {
    fun request()
}

// Adaptee class that has an incompatible method
class Adaptee {
    fun delegatedRequest() {
        println("This is the delegated method.")
    }
}

// Adapter class that implements Target and adapts Adaptee
class Adapter : Target {
    private val delegate = Adaptee() // Composition: holding an instance of Adaptee

    // Adapting the request method to call Adaptee's delegatedRequest
    override fun request() {
        delegate.delegatedRequest()
    }
}

// Test class to demonstrate the Adapter Pattern
fun main() {
    val client: Target = Adapter() // Client interacts with the Adapter through the Target interface
    client.request() // Calls the adapted method
}

////////////////////////////////////////////////////////////

// OUTPUT

// This is the delegated method.

In the code above,

  • Target interface: The interface that the client expects to interact with.
  • Adaptee class: Contains the method delegatedRequest(), which needs to be adapted to the Target interface.
  • Adapter class: Implements the Target interface and uses composition to hold an instance of Adaptee. It adapts the request() method to call delegatedRequest().
  • Client: Uses the adapter by interacting through the Target interface.

Here, the Adapter adapts the incompatible interface (Adaptee) to the interface the client expects (Target), allowing the client to use the Adaptee without modification.

Adapting an Enumeration to an Iterator

In the landscape of programming, particularly when dealing with collections in Kotlin and Java, we often navigate between legacy enumerators and modern iterators. In Java, the legacy Enumeration interface features straightforward methods like hasMoreElements() to check for remaining elements and nextElement() to retrieve the next item, representing a simpler time. In contrast, the modern Iterator interface—found in both Java and Kotlin—introduces a more robust approach, featuring hasNext(), next(), and even remove() (In Kotlin, the remove() method is part of the MutableIterator<out T> interface) for effective collection management.

Old world Enumerators & New world Iterators

Despite these advancements, many applications still rely on legacy code that exposes the Enumeration interface. This presents developers with a dilemma: how to seamlessly integrate this outdated system with newer code that prefers iterators. This is where the need for an adapter emerges, bridging the gap and allowing us to leverage the strengths of both worlds. By creating an adapter that implements the Iterator interface while wrapping an Enumeration instance, we can provide a smooth transition to modern coding practices without discarding the functionality of legacy systems.

Let’s examine the two interfaces

Adapting an Enumeration to an Iterator begins with examining the two interfaces. The Iterator interface includes three essential methods: hasNext(), next(), and remove(), while the older Enumeration interface features hasMoreElements() and nextElement(). The first two methods from Enumeration map easily to Iterator‘s counterparts, making the initial adaptation straightforward. However, the real challenge arises with the remove() method in Iterator, which has no equivalent in Enumeration. This disparity highlights the complexities involved in bridging legacy code with modern practices, emphasizing the need for an effective adaptation strategy to ensure seamless integration of the two interfaces.

Designing the Adapter

To effectively bridge the gap between the old-world Enumeration and the new-world Iterator, we will utilize methods from both interfaces. The Iterator interface includes hasNext(), next(), and remove(), while the Enumeration interface offers hasMoreElements() and nextElement(). Our goal is to create an adapter class, EnumerationIterator, which implements the Iterator interface while internally working with an existing Enumeration. This design allows our new code to leverage Iterators, even though an Enumeration operates beneath the surface. In essence, EnumerationIterator serves as the adapter, transforming the legacy Enumeration into a modern Iterator for your codebase, ensuring seamless integration and enhancing compatibility.

Dealing with the remove() Method

The Enumeration interface is a “read-only” interface that does not support the remove() method. This limitation implies that there is no straightforward way to implement a fully functional remove() method in the adapter. The best approach is to throw a runtime exception, as the Iterator designers anticipated this need and implemented an UnsupportedOperationException for such cases.

EnumerationIterator Adapter Code

Now, let’s look at how we can convert all of this into code.

Kotlin
import java.util.Enumeration
import java.util.Iterator

// EnumerationIterator class implementing Iterator
// Since we are adapting Enumeration to Iterator, 
// the EnumerationIterator must implement the Iterator interface 
// -- it has to look like the Iterator.
class EnumerationIterator<T>(private val enumeration: Enumeration<T>) : Iterator<T> {
    
    // We are adapting the Enumeration, using composition to store it in an instance variable.
    
    // hasNext() and next() are implemented by delegating to the corresponding methods in the Enumeration. 
    
    // Checks if there are more elements in the enumeration
    override fun hasNext(): Boolean {
        return enumeration.hasMoreElements()
    }

    // Retrieves the next element from the enumeration
    override fun next(): T {
        return enumeration.nextElement()
    }

    // For remove(), we simply throw an exception.
    override fun remove() {
        throw UnsupportedOperationException("Remove operation is not supported.")
    }
}

Here,

  • Generic Type: The EnumerationIterator class is made generic with <T> to handle different types of enumerations.
  • Constructor: The constructor takes an Enumeration<T> object as a parameter.
  • hasNext() Method: This method checks if there are more elements in the enumeration.
  • next() Method: This method retrieves the next element from the enumeration.
  • remove() Method: This method throws an UnsupportedOperationException, indicating that the remove operation is not supported.

Here’s how we can use it,

Kotlin
fun main() {
    val list = listOf("Apple", "Banana", "Cherry")
    val enumeration: Enumeration<String> = list.elements()

    val iterator = EnumerationIterator(enumeration)
    
    while (iterator.hasNext()) {
        println(iterator.next())
    }
}

Here, you can see how the EnumerationIterator can be utilized to iterate over the elements of an Enumeration. Please note that the elements() method is specific to classes like Vector or Stack, so ensure you have a valid Enumeration instance to test this example.

While the adapter may not be perfect, it provides a reasonable solution as long as the client is careful and the adapter is well-documented. This clarity ensures that developers understand the limitations and can work with the adapter effectively.

Adapting an Integer Set to an Integer Priority Queue

Transforming an Integer Set into a Priority Queue might sound tricky since a Set inherently doesn’t maintain order, while a Priority Queue relies on element priority. However, by using the Adapter pattern, we can bridge this gap. The Adapter serves as an intermediary, allowing the Set to be used as if it were a Priority Queue. It adds the necessary functionality by reordering elements based on their priority when accessed. This way, you maintain the uniqueness of elements from the Set, while enabling the prioritized behavior of a Priority Queue, all without modifying the original structures. This approach enhances code flexibility and usability.

I know some of you might still be a little confused. Before we dive into the adapter code, let’s quickly revisit the basics of priority queues and integer sets. After that, we’ll walk through how we design the adapter, followed by the code and explanations.

What is a Priority Queue?

A Priority Queue is a type of queue in which elements are dequeued based on their priority, rather than their insertion order. In a typical queue (like a regular line), the first element added is the first one removed, which is known as FIFO (First In, First Out). However, in a priority queue, elements are removed based on their priority—typically the smallest (or sometimes largest) value is removed first.

  • Example of Priority Queue Behavior: Imagine a hospital emergency room. Patients aren’t necessarily treated in the order they arrive; instead, the most critical cases (highest priority) are treated first. Similarly, in a priority queue, elements with the highest (or lowest) priority are processed first.In a min-priority queue, the smallest element is dequeued first. In a max-priority queue, the largest element is dequeued first.

What is an Integer Set?

A Set is a collection of unique elements. In programming, an Integer Set is simply a set of integers. The key characteristic of a set is that it does not allow duplicate elements and typically has no specific order.

  • Example of Integer Set Behavior: If you add the integers 3, 7, 5, 3 to a set, the set will only contain 3, 7, 5, as the duplicate 3 will not be added again.

How Does the Integer Set Adapt to Priority Queue Behavior?

A Set by itself does not have any priority-based behavior. However, with the help of the Adapter pattern, we can make the set behave like a priority queue. The Adapter pattern is useful when you have two incompatible interfaces and want to use one in place of the other.

Here, the Set itself doesn’t manage priorities, but we build an adapter around the set that makes it behave like a Priority Queue. Specifically, we implement methods that will:

  1. Add elements to the set (add() method).
  2. Remove the smallest element (which gives it the behavior of a min-priority queue).
  3. Check the size of the set, mimicking the size() behavior of a queue.

PriorityQueueAdapter : Code 

Now, let’s see the code and its explanations

Kotlin
// Define a PriorityQueue interface
interface PriorityQueue {
    fun add(element: Any)
    fun size(): Int
    fun removeSmallest(): Any?
}

// Implement the PriorityQueueAdapter that adapts a Set to work like a PriorityQueue
class PriorityQueueAdapter(private val set: MutableSet<Int>) : PriorityQueue {

    // Add an element to the Set
    override fun add(element: Any) {
        if (element is Int) {
            set.add(element)
        }
    }

    // Get the size of the Set
    override fun size(): Int {
        return set.size
    }

    // Find and remove the smallest element from the Set
    override fun removeSmallest(): Int? {
        // If the set is empty, return null
        if (set.isEmpty()) return null

        // Find the smallest element using Kotlin's built-in functions
        val smallest = set.minOrNull()

        // Remove the smallest element from the set
        if (smallest != null) {
            set.remove(smallest)
        }

        // Return the smallest element
        return smallest
    }
}

PriorityQueue Interface:

  • We define an interface PriorityQueue with three methods:
  • add(element: Any): Adds an element to the queue.
  • size(): Returns the number of elements in the queue.
  • removeSmallest(): Removes and returns the smallest element from the queue.

PriorityQueueAdapter Class:

  • This is the adapter that makes a MutableSet<Int> work as a PriorityQueue. It adapts the Set behavior to match the PriorityQueue interface.
  • It holds a reference to a MutableSet of integers, which will store the elements.

add() method:

  • Adds an integer to the Set. Since Set ensures that all elements are unique, duplicate values will not be added.

size() method:

  • Returns the current size of the Set, which is the number of elements stored.

removeSmallest() method:

  • This method first checks if the set is empty; if so, it returns null.
  • If not, it uses the built-in Kotlin method minOrNull() to find the smallest element in the set.
  • Once the smallest element is found, it is removed from the set using remove(), and the smallest element is returned.

PriorityQueueAdapter: How It Works

Let’s walk through how the PriorityQueueAdapter works by using a simple example, followed by detailed explanations.

Kotlin
fun main() {
    // Create a mutable set of integers
    val integerSet = mutableSetOf(15, 3, 7, 20)

    // Create an instance of PriorityQueueAdapter using the set
    val priorityQueue: PriorityQueue = PriorityQueueAdapter(integerSet)

    // Add elements to the PriorityQueue
    priorityQueue.add(10)
    priorityQueue.add(5)

    // Print the size of the PriorityQueue
    println("Size of the PriorityQueue: ${priorityQueue.size()}") // Expected: 6 (15, 3, 7, 20, 10, 5)

    // Remove the smallest element
    val smallest = priorityQueue.removeSmallest()
    println("Smallest element removed: $smallest") // Expected: 3 (which is the smallest in the set)

    // Check the size of the PriorityQueue after removing the smallest element
    println("Size after removing smallest: ${priorityQueue.size()}") // Expected: 5 (remaining: 15, 7, 20, 10, 5)

    // Remove the next smallest element
    val nextSmallest = priorityQueue.removeSmallest()
    println("Next smallest element removed: $nextSmallest") // Expected: 5

    // Final state of the PriorityQueue
    println("Remaining elements in the PriorityQueue: $integerSet") // Expected: [15, 7, 20, 10]
}

Initialization:

  • We create a MutableSet of integers with values: 15, 3, 7, and 20.The PriorityQueueAdapter is initialized with this set.

Adding Elements:

  • We add two new integers, 10 and 5, using the add() method of the PriorityQueueAdapter.After adding these, the set contains the following elements: [15, 3, 7, 20, 10, 5].

Size of the PriorityQueue:

  • We check the size of the queue using the size() method. Since we have six unique elements in the set, the size returned is 6.

Removing the Smallest Element:

  • The removeSmallest() method is called.The method scans the set and finds 3 to be the smallest element.It removes 3 from the set and returns it.After removal, the set becomes: [15, 7, 20, 10, 5].

Size After Removal:

  • The size is checked again, and it returns 5, since one element (3) was removed.

Removing the Next Smallest Element:

  • The removeSmallest() method is called again.This time, it finds 5 as the smallest element in the set.It removes 5 and returns it.After removal, the set is now: [15, 7, 20, 10].

Final State of the Queue:

  • The final remaining elements in the set are printed, showing the updated state of the set: [15, 7, 20, 10].

The PriorityQueueAdapter demonstrates how we can transform a Set (which does not naturally support priority-based operations) into something that behaves like a PriorityQueue, using the Adapter design pattern. By implementing additional functionality (finding and removing the smallest element), this adapter provides a simple and effective solution to integrate a set into contexts that require a priority queue behavior.

Adapter Design Pattern in Android

The primary goal of the Adapter pattern is to enable communication between two incompatible interfaces. This becomes particularly valuable in Android development, where you frequently need to bridge data sources — such as arrays, lists, or databases — with UI components like RecyclerView, ListView, or Spinner.

So, the Adapter pattern is widely utilized in Android development. Let’s explore its applications one by one.

RecyclerView Adapter

The RecyclerView is a flexible view for providing a limited window into a large data set. The RecyclerView.Adapter serves as the bridge that connects the data to the RecyclerView, allowing for efficient view recycling and performance optimization.

Kotlin
class MyAdapter(private val itemList: List<String>) : RecyclerView.Adapter<MyAdapter.ViewHolder>() {

    class ViewHolder(view: View) : RecyclerView.ViewHolder(view) {
        val textView: TextView = view.findViewById(R.id.text_view)
    }

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
        val view = LayoutInflater.from(parent.context).inflate(R.layout.item_layout, parent, false)
        return ViewHolder(view)
    }

    override fun onBindViewHolder(holder: ViewHolder, position: Int) {
        holder.textView.text = itemList[position]
    }

    override fun getItemCount(): Int = itemList.size
}

ListView Adapter

Before RecyclerView, ListView was the primary component for displaying lists of data. The ArrayAdapter and SimpleAdapter are classic examples of adapters used with ListView. They help convert data into views.

Kotlin
class MyActivity : AppCompatActivity() {
    private lateinit var listView: ListView

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        listView = findViewById(R.id.list_view)
        val items = listOf("Item 1", "Item 2", "Item 3")
        val adapter = ArrayAdapter(this, android.R.layout.simple_list_item_1, items)
        listView.adapter = adapter
    }
}

Spinner Adapter

A Spinner is a dropdown list that allows the user to select an item from a list. The Adapter pattern is also applied here, typically through ArrayAdapter or a custom adapter to provide data to the Spinner.

Kotlin
class MySpinnerAdapter(private val context: Context, private val items: List<String>) : BaseAdapter() {

    override fun getCount(): Int = items.size

    override fun getItem(position: Int): String = items[position]

    override fun getItemId(position: Int): Long = position.toLong()

    override fun getView(position: Int, convertView: View?, parent: ViewGroup?): View {
        val textView = TextView(context)
        textView.text = getItem(position)
        return textView
    }
}

// In your activity
val spinner: Spinner = findViewById(R.id.spinner)
val items = listOf("Option 1", "Option 2", "Option 3")
val adapter = MySpinnerAdapter(this, items)
spinner.adapter = adapter

ViewPager Adapter

In ViewPager, the adapter is used to manage the pages of content. The PagerAdapter (or its subclass FragmentPagerAdapter) allows developers to create and manage the fragments that are displayed in the ViewPager.

Kotlin
class MyPagerAdapter(fm: FragmentManager) : FragmentPagerAdapter(fm) {
    private val fragments = listOf(Fragment1(), Fragment2(), Fragment3())

    override fun getItem(position: Int): Fragment = fragments[position]

    override fun getCount(): Int = fragments.size
}

// In your activity
val viewPager: ViewPager = findViewById(R.id.view_pager)
val adapter = MyPagerAdapter(supportFragmentManager)
viewPager.adapter = adapter

Custom Adapter for Data Binding

As developers, we often create custom adapters to directly bind data to views. This approach is especially beneficial when working with frameworks like Android Data Binding or when connecting complex data models to UI components.

Kotlin
// Custom Binding Adapter
@BindingAdapter("app:loadImage")
fun loadImage(view: ImageView, url: String?) {
    // Load image using a library like Glide or Picasso
    Glide.with(view.context).load(url).into(view)
}

The Adapter pattern is prevalent in various components of Android development, from UI elements like ListView, Spinner, and ViewPager to more complex data binding scenarios. It is essential for facilitating seamless communication between data sources and UI components. By implementing various adapters, we enhance code organization, reusability, and flexibility, allowing developers to create responsive and dynamic applications more efficiently.

Conclusion

The Adapter Design Pattern is a powerful tool that every developer should have in their toolkit. By bridging the gap between incompatible systems, it allows for smoother integration and greater code flexibility. Whether you’re using the Class Adapter or Object Adapter, understanding these types can significantly enhance the adaptability of your projects.

From real-world examples to its use in Android development, the Adapter Design Pattern shows its versatility in solving common coding challenges. As we’ve explored, it’s not just about making systems work together—it’s about doing so in a way that’s clean, maintainable, and future-proof.

So next time you face a compatibility issue, remember that the Adapter Pattern is here to save the day. Keep this pattern in mind, and you’ll find yourself writing more robust, adaptable, and efficient code. Now that you’ve finished your coffee, it’s time to apply what you’ve learned—happy coding!

Skill Up: Software & AI Updates!

Receive our latest insights and updates directly to your inbox

Related Posts

error: Content is protected !!