Swift — это мощный язык программирования, который сочетает в себе безопасность типов и выразительность. Однако, несмотря на свою строгую типизацию, язык предоставляет разработчикам возможность использовать динамический доступ к свойствам объекта с помощью атрибута dynamicMemberLookup. Это может быть полезно, например, для работы с динамическими данными или при создании DSL (Domain-Specific Language). С помощью этого атрибута мы можем обращаться к свойствам экземпляра типа, даже если эти свойства явно в нем не определены.

При работе с этим атрибутом важно понимать, что он применим только к типам (struct, enum, class, actor, protocol), поэтому, например, данный код вызовет ошибку компиляции:

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

Для использования dynamicMemberLookup необходимо выполнить всего две вещи:

  1. Отметить тип соотвествующим атрибутом (@dynamicMemberLookup)

  2. Реализовать subscript, через который мы будем получать интересующие нас данные

Упрощение работы с динамическими данными

Атрибут dynamicMemberLookup хорошо применим при работе с динамическими структурами данных, то есть такими, чье внутреннее строение формируется по какому-либо правилу, но количество элементов, их взаиморасположение и взаимосвязи могут динамически изменяться во время выполнения программы (например Dictionary). Использование dynamicMemberLookup позволяет обращаться к свойствам объекта, как если бы они были статически определены. Это позволяет сделать код более читаемым и удобным.

Рассмотрим применение атрибута через вот такой базовый пример интерпретации словаря в JSON структуру с возможностью использовать точечную нотацию для получения значения по ключу:

@dynamicMemberLookup
struct JSON {

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

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

Несмотря на то, что у объекта JSON нет открытых свойств, благодаря использованию dynamicMemberLookup мы можем получать значение по ключу из внутреннего словаря следующим образом:

let json = JSON(from: ["name": "Malil", "age": 21])
print(json.name) // "Malil"
print(json.age)  // 21
Скрытый текст

В этом случае будет отсутствовать какое-либо автодополнение кода, поскольку свойства name и age не определены для объекта и извлекаются динамически. Поэтому при запросе ключа можно допустить ошибку в именовании.

По большей части, это все является синтаксическим сахаром. В subscript мы определили аргумент типа String, по которому достаем из словаря data значение и возвращаем его. Компилятор просто дает нам возможность более красиво извлекать данные, поэтому эти две записи будут эквивалентны по результату:

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

Гибкость и расширяемость API

С помощью dynamicMemberLookup у нас есть возможность легко добавлять новые свойства или изменять существующие без необходимости вносить изменения в интерфейс наших типов. Это позволяет создавать более гибкие и расширяемые API.

Представим, что мы пишем сервис, который должен иметь некоторую начальную конфигурацию, параметры которой будут в определенной степени влиять на то, как этот сервис выполняет свою работу. Опустим детали логики, которая в нем могла бы быть и базово опишем класс такого сервиса и модель его конфигурации:

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

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

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

Предположим, что мы хотим иметь возможность менять параметры, заданные в начальной конфигурации уже после создания сервиса. Для этого сейчас нам необходимо выполнить простое действие:

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

На первый взгляд, всё выглядит замечательно. Однако, если углубиться в детали, становится очевидно, что вместо прямого обращения к сервису мы вынуждены использовать посредника — свойство configuration в цепочке вызовов. Было бы более удобно просто сказать сервису: "Теперь максимальное количество результатов, которое ты можешь вернуть, равно X".

Чтобы сделать API этого сервиса более интуитивным и удобным, мы воспользуемся атрибутом dynamicMemberLookup. Для безопасного доступа к интересующим нас свойствам объекта ServiceConfiguration мы применим WritableKeyPath, который позволит не только безопасно обращаться к свойствам, но и записывать в них значения (если вам интересно узнать больше о том, что такое KeyPath и как с ним работать, обязательно загляните в документацию). Итого получим следующее:

@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 }
    }
}

Теперь свойство configuration можно отметить как private , сделав вовсе недоступным для обращения. Вместо этого, мы можем напрямую обратиться к любому свойству ServiceConfiguration прямо из экземпляра сервиса, а благодаря использованию KeyPath у нас еще и сохраняется автодополнение кода, что делает его полностью безопасным:

let service = ServiceImpl(configuration: ...)

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

Однако, поскольку вы, дорогие читатели, являетесь разработчиками высокой культуры, то, безусловно, избегаете использования конкретных реализаций сервисов в качестве зависимостей и предпочитаете работать с абстракциями в виде протоколов (Dependency Inversion). В связи с этим возникает интересный вопрос: как же добавить объекту возможность динамического обращения к свойствам при взаимодействии с ним через протокол?

Решение на самом деле очень простое. Мы уже знаем, что необходимо для реализации возможностей dynamicMemberLookup. Все, что нужно сделать в данном случае, — это отметить сам протокол этим атрибутом и добавить в его контракт нужный нам subscript. Таким образом, интерфейс сервиса и его реализация могут выглядеть следующим образом:

// Протокол сервиса
@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 }
    }
}

В результате, даже если наша зависимость будет иметь тип протокола, мы все так же можем динамически обращаться к свойствам ServiceConfiguration из экземпляра сервиса, как и в предыдущем примере:

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

Заключение

Атрибут dynamicMemberLookup в Swift открывает интересные возможности для работы с типами, позволяя нам динамически извлекать свойства и создавать более выразительные API. Это упрощает чтение и понимание кода и делает его более элегантным. Тем не менее, как и с любой функциональностью, важно применять этот атрибут с умом, чтобы избежать ненужного усложнения наших типов.

Если вам интересно углубиться в детали, я рекомендую ознакомиться с предложением SE-0195, где вы найдете мотивацию и контекст, стоящие за добавлением этого атрибута в наш любимый язык.

Комментарии (1)


  1. Bardakan
    08.09.2024 20:05

    а в чем преимущество перед Codable?