Simplifying Swift Data Conversion

Simplifying Swift Data Conversion

Protocol Conformance vs. Mapping Functions

Β·

8 min read

Data transformation, a common task for iOS engineers, arises in various forms throughout one's career. Whether it's translating an API response into a model object or adapting a model object for display purposes, the process of conversion is inevitable. Yet, amidst this array of scenarios, how do you determine the most suitable approach for your project? In this article, we'll delve into two prominent methods: Protocol Conformance and Mapping Functions.

In essence, it all comes down to one word: flexibility. So, Protocol Conformance or Mapping Functions? Well, as you'll soon discover.

**TDLR; It depends...πŸ˜‰

Imagine you have a set of custom data types representing different types of products in an e-commerce application. Each product type has its own set of properties and behaviors specific to that product. Here's a simplified example of two product types:

struct Book {
    let title: String
    let author: String
    let price: Double
    // Other book-specific properties and methods
}

struct Electronics {
    let name: String
    let brand: String
    let price: Double
    // Other electronics-specific properties and methods
}

Now, you want to display a list of these products in the same UITableView , UICollectionView or a SwiftUI list, and you also want to allow users to add these products to their shopping cart. How would you go about achieving this?

Protocol Conformance

"A protocol defines a blueprint of methods, properties, and other requirements that suit a particular task or piece of functionality. The protocol can then be adopted by a class, structure, or enumeration to provide an actual implementation of those requirements. Any type that satisfies the requirements of a protocol is said to conform to that protocol." - The Swift Programming Language

To go about this using protocol conformance one would first start by defining a protocol. Let's call it ProductConvertible.

protocol ProductConvertible {
    var title: String { get }
    var price: Double { get }
}

Then, you would have your Book and Electronics types conform to this protocol:

struct Book: ProductConvertible {
    let title: String
    let author: String
    let price: Double
}

struct Electronics: ProductConvertible {
    let name: String
    let brand: String
    let price: Double
}

You can then go ahead and create a ViewModel for the view that will display the products or use ProductConvertible directly.

Pros

Polymorphism - When you have a protocol like ProductConvertible, you establish a shared contract that multiple types can conform to. This allows you to treat all conforming types uniformly, providing a consistent interface for working with different data models. In the case of a list or UITableView, you can use a single data source or delegate to handle various model types effortlessly.

let products: [ProductConvertible] = [book, electronics]

Code Reusability - You can create generic functions and components that work with any type conforming to the protocol.

func displayProducts(_ products: [ProductConvertible])

Ease of Extension - When new types need to be displayed in the list or UITableView, you can simply make them conform to the existing protocol.

struct Clothing: ProductConvertible {
    let name: String
    let brand: String
    let price: Double
}

Cons

Tight Coupling - Types conforming to a protocol become tightly coupled to the protocol's requirements. Any changes to the protocol can potentially affect all conforming types.

Shared Protocol Requirements - When using protocol conformance, you need to ensure that the protocol requirements make sense for all conforming types. If some requirements are irrelevant for certain types, you may end up with unused methods or properties in your types. This can lead to unnecessary complexity. Our clean architecture coders out there will recognize this as a violation of the interface-segregation principle.

Mapping Functions

Data mapping is the process of matching fields between two different data sets. It is a crucial step when combining data, since different data sources could have different styles, formatting, or names for the same kinds of data. - Sean Dougherty

In simple terms, a mapping function is a pure function that takes one type of data (the source) and converts it into another type of data (the target). Mapping functions are characterized by their functional purity, which means they produce the same output for the same input and have no side effects. (func(A) -> B)

Pros

Modularization - Using mapping functions, you can encapsulate the transformation logic in separate functions or a dedicated mapper class, promoting modularization and clean code.

Let's add a ProductViewModel that can be used to display items in our list.

struct ProductViewModel {
    let title: String
    let description: String
    let price: String
}

We can then introduce a ProductMapper which can be used to transform/map our initial model items to the desired ProductViewModel.

enum ProductMapper {
    static func mapBookToViewModel(_ book: Book) -> ProductViewModel {
        return ProductViewModel(
            title: book.title,
            description: "Author: \(book.author)",
            price: "$\(book.price)"
        )
    }

    static func mapElectronicsToViewModel(_ electronics: Electronics) -> ProductViewModel {
        return ProductViewModel(
            title: electronics.name,
            description: "Brand: \(electronics.brand)",
            price: "$\(electronics.price)"
        )
    }
}

Now our mapping logic is encapsulated in a separate object with separate functions that can live in isolation.

Testing - Mapping functions can be easily unit-tested to ensure they correctly transform data from one type to another. This can be challenging with protocol conformance, especially if the protocol involves complex behavior. It is good practice to make mapping functions static.

Code Reusability - Mapping functions can be reused across different parts of your application. For example, you might need to convert your custom types to different standardized representations for different use cases. A single mapping function can serve multiple purposes. This is not possible when using protocol conformance as you can only have one conformance to a protocol.

struct BookMapper {
    static func mapBookToViewModel(_ book: Book) -> ProductViewModel {
        ProductViewModel(
            title: book.title,
            author: book.author,
            price: "$\(book.price)"
        )
    }

    static func mapDiscountedBookToViewModel(_ book: Book,
                                             discountPercentage: Double) -> ProductViewModel {
        let discountedPrice = book.price * (1.0 - discountPercentage / 100.0)
        return ProductViewModel(
            title: book.title,
            author: book.author,
            price: "$\(discountedPrice)"
        )
    }
}

We now have two different conversions on the same model but our mapping functions give us extra functionality.

Flexibility - If you introduce a new type, such as Clothing, you can easily create a new mapping function without modifying existing code.

struct Clothing { /* ... */ }

static func mapClothingToViewModel(_ clothing: Clothing) -> ProductViewModel {
    // Transformation logic here
}

Performance - In some cases, mapping functions can be more performant than protocol conformance, especially if the mapping involves complex calculations or data manipulation. You have more control over when and how the mapping occurs.

Let's say we need to perform complex calculations when mapping our models we can use caching and memoization to store the results of specific inputs and prevent unnecessary recalculations. We could also perform these operations over multiple threads in parallel.

This is not possible with protocol conformance as in Swift, when objects conform to a protocol, the conformance often involves an eager transformation. This means that the transformation logic specified in the protocol's methods is executed when the object is created or when the protocol methods are invoked.

For more on the performance of protocol conformance...

Loose Coupling - When you use a mapping function, the mapping logic is external to your types. Changes to your types won't directly impact the mapping function, and vice versa. However, if you introduce a new type that needs to be mapped to the target representation (ProductCellViewModel ), you will need to create a new mapping function for that type. While this does couple the mapping function to the new type, it doesn't necessarily affect the existing types.

In both cases, there is a level of coupling, but the key difference is where that coupling occurs:

  • With protocol conformance, the coupling is within the types themselves. Changes to the protocol can affect all conforming types.

  • With a mapping function, the coupling occurs when you create a new mapping function for a specific type. Existing types don't need to change when a new type is introduced.

Cons

Mapping Calls - You need to explicitly call the mapping functions when populating the list, which can lead to additional code for type checks and mappings.

let productViewModel: ProductViewModel
if let book = product as? Book {
    productViewModel = ProductMapper.mapBookToViewModel(book)
} else if let electronics = product as? Electronics {
    productViewModel = ProductMapper.mapElectronicsToViewModel(electronics)
}

Boilerplate Code - Mapping functions can introduce unnecessary boilerplate code, especially in the case of simple one-to-one mappings. In such cases, protocol conformance would be better suited. Could also be seen as an unneeded abstraction layer by some.

Code Duplication - This can lead to code duplication if similar mapping logic is implemented in multiple places. This can make it harder to maintain consistency and make updates.

Conclusion

It's essential to carefully assess your specific project requirements and consider the trade-offs when deciding whether to use mapping functions or other approaches like protocol conformance or direct property mapping. Mapping functions can be a powerful tool for complex data transformations, but they should be used with good judgment to avoid introducing unnecessary complexity into your codebase.

Next time...

Now that we've examined various methods of data transformation, a new challenge emerges. Consider this scenario: You want to trigger different actions when an item in our list is tapped. Have you ever grappled with the casting pyramid of doom?

func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
    let selectedProduct = products[indexPath.row]
    if let book = selectedProduct as? Book {
        // Perform action for Book
        performActionForBook(book)
    } else if let electronics = selectedProduct as? Electronics {
        // Perform action for Electronics
        performActionForElectronics(electronics)
    }
}

But what happens when we introduce more types? Do we simply keep adding more if let statements? There must be a more elegant solution. That's precisely what we'll delve into in our next post!

Thank you for reading. If you found this article insightful, please consider leaving a like and sharing it. πŸ‘πŸΎπŸš€