Boost your Swift with @dynamicMemberLookup

Swift is a powerful programming language that combines type safety and expressiveness. However, despite its strong typing, the language provides developers with the ability to use dynamic access to object properties using the attribute dynamicMemberLookup. This can be useful, for example, for working with dynamic data or when creating a DSL (Domain-Specific Language). Using this attribute, we can access the properties of a type instance, even if these properties are not explicitly defined in it.

When working with this attribute, it is important to understand that it only applies to types (struct, enum, class, actor, protocol), so, for example, this code will cause a compilation error:

class MyClass { }
@dynamicMemberLookup extension MyClass { } // '@dynamicMemberLookup' attribute cannot be applied to this declaration

For use dynamicMemberLookup There are only two things you need to do:

  1. Mark the type with the appropriate attribute (@dynamicMemberLookup)

  2. To implement subscriptthrough which we will receive the data we are interested in

Simplifying work with dynamic data

Attribute dynamicMemberLookup is well suited for working with dynamic data structures, that is, those whose internal structure is formed according to some rule, but the number of elements, their relative positions and relationships can dynamically change during program execution (for example Dictionary). Usage dynamicMemberLookup allows you to access an object's properties as if they were statically defined. This makes the code more readable and convenient.

Let's consider the use of an attribute through this basic example of interpreting a dictionary into a JSON structure with the ability to use dot notation to obtain a value by key:

@dynamicMemberLookup
struct JSON {

    // Внутренний словарь для хранения ключей и значений
    private var data: [String: Any]
    
    init(from data: [String : Any]) {
        self.data = data
    }

    // Необходимый для использования атрибута сабскрипт
    subscript(dynamicMember member: String) -> Any? {
        data[member]
    }
}

Despite the fact that the object JSON no open properties, thanks to the use of dynamicMemberLookup we can get a value by key from the internal dictionary as follows:

let json = JSON(from: ["name": "Malil", "age": 21])
print(json.name) // "Malil"
print(json.age)  // 21
Hidden text

In this case, there will be no code completion because the properties name And age are not defined for the object and are retrieved dynamically. Therefore, when requesting a key, it is possible to make a naming error.

For the most part, this is all syntactic sugar. subscript we defined a type argument Stringwhich we take from the dictionary data value and return it. The compiler simply gives us a way to extract data more beautifully, so these two entries will be equivalent in result:

json[dynamicMember: "name"] // "Malil"
json.name // "Malil"

API flexibility and extensibility

By using dynamicMemberLookup We have the ability to easily add new properties or change existing ones without having to change the interface of our types. This allows us to create more flexible and extensible APIs.

Let's imagine that we are writing a service that must have some initial configuration, the parameters of which will to some extent influence how this service performs its work. Let's skip the details of the logic that might be in it and describe the class of such a service and its configuration model in a basic way:

// Структура с параметрами конфигурации сервиса
struct ServiceConfiguration {
    var maxResuls: Int
}

// Класс сервиса
class ServiceImpl {
    
    var configuration: ServiceConfiguration

    init(configuration: ServiceConfiguration) {
        self.configuration = configuration
    }
}

Let's assume that we want to be able to change the parameters set in the initial configuration after the service has been created. To do this, we now need to perform a simple action:

let service = ServiceImpl(configuration: ...)
service.configuration.maxResuls = 30

At first glance, everything looks great. However, if you delve into the details, it becomes obvious that instead of directly accessing the service, we are forced to use an intermediary – the property configuration in the call chain. It would be more convenient to just tell the service: “Now the maximum number of results you can return is X.”

To make the API of this service more intuitive and convenient, we will use the attribute dynamicMemberLookup. For secure access to the properties of the object that interest us ServiceConfiguration we will apply WritableKeyPathwhich will allow you to not only safely access properties, but also write values ​​to them (if you are interested in learning more about what is KeyPath and how to work with it, be sure to check out documentation). As a result we get the following:

@dynamicMemberLookup 
class ServiceImpl {
    
    private var configuration: ServiceConfiguration
    
    init(configuration: ServiceConfiguration) {
        self.configuration = configuration
    }
  
    // Сабскрипт для чтения и изменения свойств `configuration` через `WritableKeyPath`
    subscript<T>(dynamicMember keyPath: WritableKeyPath<ServiceConfiguration, T>) -> T {
        get { configuration[keyPath: keyPath] }
        set { configuration[keyPath: keyPath] = newValue }
    }
}

Now the property configuration can be marked as private making it completely inaccessible. Instead, we can directly access any property ServiceConfiguration directly from the service instance, and thanks to the use of KeyPath We also have code autocompletion, which makes it completely safe:

let service = ServiceImpl(configuration: ...)

// Эта запись изменяет `maxResuls` у свойства `configuration` внутри `ServiceImpl`
service.maxResuls = 30

However, since you, dear readers, are developers of high culture, you certainly avoid using concrete service implementations as dependencies and prefer to work with abstractions in the form of protocols (Dependency Inversion). In this regard, an interesting question arises: how to add the ability to dynamically access properties to an object when interacting with it through a protocol?

The solution is actually very simple. We already know what is needed to implement the capabilities dynamicMemberLookup. All we need to do in this case is to mark the protocol itself with this attribute and add the one we need to its contract. subscript. Thus, the service interface and its implementation may look like this:

// Протокол сервиса
@dynamicMemberLookup 
protocol Service: AnyObject {
    init(configuration: ServiceConfiguration)
    subscript<T>(dynamicMember keyPath: WritableKeyPath<ServiceConfiguration, T>) -> T { get set }
}

// Реализация сервиса
class ServiceImpl: Service {
    
    private var configuration: ServiceConfiguration
    
    required init(configuration: ServiceConfiguration) {
        self.configuration = configuration
    }
    
    subscript<T>(dynamicMember keyPath: WritableKeyPath<ServiceConfiguration, T>) -> T {
        get { configuration[keyPath: keyPath] }
        set { configuration[keyPath: keyPath] = newValue }
    }
}

As a result, even if our dependency is of protocol type, we can still dynamically access properties ServiceConfiguration from a service instance, as in the previous example:

let service: Service = ServiceImpl(configuration: ...)
service.maxResuls = 30

Conclusion

Attribute dynamicMemberLookup in Swift opens up interesting possibilities for working with types, allowing us to dynamically extract properties and create more expressive APIs. This makes code easier to read and understand, and makes it more elegant. However, as with any functionality, it is important to use this attribute wisely to avoid unnecessarily complicating our types.

If you are interested in going into more detail, I recommend checking out the offer SE-0195where you will find the motivation and context behind the addition of this attribute to our beloved language.

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *