Прокачайте свой Swift с @dynamicMemberLookup
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
необходимо выполнить всего две вещи:
Отметить тип соотвествующим атрибутом (
@dynamicMemberLookup
)Реализовать
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(dynamicMember keyPath: WritableKeyPath) -> 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(dynamicMember keyPath: WritableKeyPath) -> T { get set }
}
// Реализация сервиса
class ServiceImpl: Service {
private var configuration: ServiceConfiguration
required init(configuration: ServiceConfiguration) {
self.configuration = configuration
}
subscript(dynamicMember keyPath: WritableKeyPath) -> T {
get { configuration[keyPath: keyPath] }
set { configuration[keyPath: keyPath] = newValue }
}
}
В результате, даже если наша зависимость будет иметь тип протокола, мы все так же можем динамически обращаться к свойствам ServiceConfiguration
из экземпляра сервиса, как и в предыдущем примере:
let service: Service = ServiceImpl(configuration: ...)
service.maxResuls = 30
Заключение
Атрибут dynamicMemberLookup
в Swift открывает интересные возможности для работы с типами, позволяя нам динамически извлекать свойства и создавать более выразительные API. Это упрощает чтение и понимание кода и делает его более элегантным. Тем не менее, как и с любой функциональностью, важно применять этот атрибут с умом, чтобы избежать ненужного усложнения наших типов.
Если вам интересно углубиться в детали, я рекомендую ознакомиться с предложением SE-0195, где вы найдете мотивацию и контекст, стоящие за добавлением этого атрибута в наш любимый язык.