В Swift, как и во многих других языках программирования, есть возможность получать информацию о структуре объекта в Runtime. Для этого в языке есть специальный механизм — Reflection. С его помощью можно просматривать содержимое любых сущностей, не зная о них абсолютно ничего.
Меня зовут Светлана Гладышева, я iOS-разработчик в Тинькофф. Расскажу, какие возможности есть у Reflection в Swift, в чем ограничения и подводные камни. Рассмотрим его применение на примерах и узнаем, для чего его можно использовать в повседневной работе. А еще поговорим о том, как можно отключить Reflection в проекте и на что это может повлиять.
Просмотр содержимого разных типов
Для просмотра содержимого сущностей в Swift используется специальная структура — Mirror. Использовать Mirror можно для любых типов: структур, классов, перечислений, коллекций и так далее. Создать Mirror можно так:
let mirror = Mirror(reflecting: item)
У Mirror есть поле Children, с помощью которого можно просматривать содержимое сущности. Children — это коллекция, состоящая из элементов Child. У каждого Child есть Label, название элемента, и Value — значение, которое в нем содержится. С помощью Children можно просматривать содержимое разных типов.
Для структур. Предположим, у нас есть структура User и экземпляр этой структуры:
struct User {
let name: String
let age: Int
}
let user = User(name: "Nikita", age: 25)
Мы можем посмотреть, что находится внутри User, с помощью специальной структуры — Mirror:
let mirror = Mirror(reflecting: user)
mirror.children.forEach { child in
print("Label: \(child.label), value: \(child.value)")
}
В консоль выведется:
Мы узнали, что внутри User есть поле Name со значением Nikita и поле Age со значением 25. Важно, что мы можем посмотреть только поля, — а методы объектов нет. Зато можем увидеть все поля — и публичные, и приватные.
Для классов. Предположим, у нас есть класс Validator с двумя приватными полями:
class Validator {
private let mode: ValidatorMode
private let transformer: Transformer
// …
}
ValidatorMode — это Enum с двумя вариантами: Simple и Complex, а Transformer — какой-то другой класс. Мы можем вывести информацию об объекте этого класса:
let mirror = Mirror(reflecting: validator)
mirror.children.forEach { child in
print("Label: \(child.label), value: \(child.value)")
}
В консоль выведется:
Так же как для структур, мы не можем посмотреть методы объектов, зато видим содержимое всех полей. Если внутри объекта лежит какой-то другой объект, выводится только тип объекта, но не содержимое. Если нужно узнать, что внутри такого объекта, можно создать Mirror для этого объекта и просмотреть у него Children.
Для Enum. Предположим, есть Enum ValidationType с двумя вариантами:
enum ValidationType {
case email
case phoneNumber(format: String, city: String)
}
Проверим код для кейса Email:
let mirror = Mirror(reflecting: ValidationType.email)
mirror.children.forEach { child in
print("Label: \(child.label), value: \(child.value)")
}
В консоль не выведется ничего. Потому что для Enum в качестве Children у Mirror — Associated Values. У кейса Email нет ни одного Associated Value, поэтому коллекция Children для него пустая.
У кейса PhoneNumber есть два Associated Values: Format и City. Если мы запустим такой же код для этого кейса, в консоль выведется строка:
В качестве Label у Enum — название кейса, а Value — Tuple, содержащий все Associated Values в формате «имя: значение». Если же у кейса есть только один параметр, то в Value выводится он, без имени.
Остальные типы. Для массива можно просмотреть его элементы: в качестве Label будет Nil, а Value — значения элементов массива. Для Dictionary в качестве Label будут ключи, а в качестве Value — значения. В качестве примера я перечислила всего два типа, но так можно просматривать все.
Разные типы полей. Статические и computed-поля нельзя посмотреть с помощью Mirror, а вот значения Lazy полей можно. Предположим, у какого-то класса есть ленивое поле Transformer:
private lazy var transformer: Transformer = Transformer()
Если мы напишем так:
mirror.superclassMirror?.children.forEach { child in
print("Label: \(child.label), value: \(child.value)")
}
В консоли выведется:
К ленивым полям в child.name
добавляется префикс ‘lazy_storage’.
Просмотр типов объектов и другие возможности
С помощью Mirror можно узнать не только содержимое объекта, но и его тип. Для этого у Mirror есть subjectType. Для структуры User из предыдущего раздела subjectType вернет тип User, а для класса Validator – тип Validator.
Еще у Mirror есть displayType — поле, которое показывает, как именно этот Mirror будет отображаться. DiplayStyle — это Enum, способный принимать значение Struct, Class, Enum, Tuple, Optional, Collection, Dictionary, Set и так далее. С помощью displayType мы можем узнать, как именно будет отображаться сущность: как класс, как структура или как что-то другое.
Если класс наследуется от другого класса, Children нам покажет только те поля, которые объявлены внутри самого этого класса. Если мы хотим посмотреть значения полей, которые объявлены в базовом классе, это можно сделать с помощью SuperclassMirror:
mirror.superclassMirror?.children.forEach { child in
print("Label: \(child.label), value: \(child.value)")
}
Может случиться, что у базового класса есть свой базовый класс. Получить его поля можно, используя конструкцию mirror.superclassMirror?.superclassMirror?.children
.
Чтобы получить абсолютно все поля класса, можно написать код:
var mirror: Mirror? = Mirror(reflecting: item)
repeat {
mirror?.children.forEach { child in
print("Label: \(child.label), value: \(child.value)")
}
mirror = mirror?.superclassMirror
} while mirror != nil
Поиск с помощью метода Descendant. Необязательно писать какой-то сложный код, когда нужно найти у объекта определенное поле. У Mirror есть метод Descendant, который позволяет получить значение поля с помощью MirrorPath. В качестве MirrorPath можно передать либо строку — название поля, либо Int — порядковый номер поля.
Предположим, есть две структуры:
struct User {
let name: Name
let age: Int
}
struct Name {
let firstName: String
let secondName: String
}
Создав Mirror для экземпляра User, мы можем получить значение SecondName с помощью метода Descendant:
let secondName = mirror.descendant("name", "secondName")
Или с использованием порядковых номеров нужных нам полей:
let secondName = mirror.descendant(0, 1)
Изменение представления об объекте
В Swift есть протокол CustomReflectable, который позволяет подменять mirror-объект на кастомный. Это делают, если объект Mirror по каким-то причинам не устраивает.
Нужно сделать так, чтобы сущность соответствовала протоколу CustomReflectable, чтобы изменить представление о ней. Для CustomMirror создается свой объект Mirror и в нем указывается Children со словарем вида ["label": value].
Для примера возьмем структуру Point:
struct Point {
let x: Int
let y: Int
}
Мы можем сделать так, чтобы эта структура соответствовала протоколу CustomReflectable, и добавить ей СustomMirror:
extension Point: CustomReflectable {
var customMirror: Mirror {
Mirror(self, children: ["z": x + y])
}
}
Создадим и выведем в консоль информацию о каком-то экземпляре этой структуры:
let point = Point(x: 10, y: 12)
let mirror = Mirror(reflecting: point)
mirror.children.forEach { child in
print("Label: \(child.label), value: \(child.value)")
}
Результат в консоли:
В консоль вывелось именно то, что было указано в СustomMirror. При этом информацию про настоящие поля структуры x и y мы не получили.
При вызове Mirror(reflecting: point)
для типа, который соответствует протоколу CustomReflectable, будет использован именно СustomMirror из CustomReflectable. Так, используя Reflection в Swift, мы можем получать не совсем правдивую информацию про имеющиеся у объекта поля.
Можно при создании CustomMirror указать DisplayStyle и AncestorRepresentation. С помощью AncestorRepresentation можно создать CustomMirror, который будет отображаться в качестве SuperclassMirror. А еще с его помощью можно запретить этому Mirror отображать информацию о суперклассах.
Примеры использования Reflection
С валидацией. Предположим, у нас есть класс Item, который содержит в себе много полей типа String:
struct Item {
let field1: String
let field2: String
let field3: String
let field4: String
// …
}
Мы хотим проверить, что в объекте Item все поля не пустые, то есть ни в одном из полей нет пустой строки. Напишем для этого валидатор:
class ItemValidator {
func validate(item: Item) -> Bool {
return !item.field1.isEmpty && !item.field2.isEmpty && !item.field3.isEmpty && !item.field4.isEmpty // …
}
}
Если полей у Item много, проверка внутри метода Validate будет длинной и громоздкой. Более того, при добавлении в Item нового поля можно забыть про необходимость правки метода Validate, и тогда валидатор будет работать неправильно.
Перепишем метод Validate с использованием Reflection:
func vaildate(item: Item) -> Bool {
let mirror = Mirror(reflecting: item)
for child in mirror.children {
if let stringValue = child.value as? String {
if stringValue.isEmpty {
return false
}
}
}
return true
}
Теперь в методе Validate нет длинной проверки и при добавлении в Item нового поля не нужно будет изменять этот метод.
У подхода с переписыванием метода есть и минусы. Такая проверка неочевидна другим разработчикам. Поэтому другой разработчик, не зная про использование Reflection в методе валидации, может внести изменения, которые приведут к неправильной работе программы. Например, он может добавить в Item новое поле, которое не нужно проверять, но в методе Validate оно будет проверяться.
С тестами. Предположим, у нас есть MainViewConroller, в котором есть кнопка saveButton:
class MainViewConroller: UIViewController {
private let saveButton: UIButton
// ...
func updateView() {
// ...
saveButton.isEnabled = false
}
}
Напишем тест на метод updateView, в котором проверим, что кнопка становится задизейбленной:
func testUpdateView() {
let mainViewContoller = MainViewConroller()
mainViewContoller.updateView()
XCTAssertFalse(mainViewConroller.saveButton.isEnabled)
}
Но этот код не скомпилируется, потому что поле saveButton приватное. Можно сделать saveButton не приватным, и тогда тест будет работать, но делать поля публичными только для тестов — не лучшее решение.
Напишем метод, который будет получать нужную нам кнопку с помощью Reflection:
func getSaveButton(from mainViewConroller: MainViewConroller) -> UIButton? {
let mirror = Mirror(reflecting: mainViewConroller)
if let saveButton = mirror.descendant("saveButton") as? UIButton? {
return saveButton
}
return nil
}
Перепишем тест, используя написанный метод:
func testUpdateView() {
let mainViewContoller = MainViewConroller()
mainViewContoller.updateView()
let saveButton = getSaveButton(from: mainViewContoller)
XCTAssertEqual(saveButton?.isEnabled, false)
}
Мы смогли написать тест, оставив кнопку приватной.
Другие примеры использования. Reflection может быть полезен в тех случаях, когда нужно узнать тип объекта, значение приватных полей или получить доступ ко всем полям объекта.
С помощью Reflection можно узнать, как устроены объекты из подключенных библиотек, чтобы лучше разобраться в том, как они работают. Например, можно посмотреть, какие приватные поля есть у объектов из Foundation, UIKit или SwiftUI.
Отключение Reflection
Для отключения Reflection в настройках проекта нужно изменить значение Reflection Metadata Level. Можно в Build Settings изменить значение пункта Reflection Metadata Level или в файле plist.info указать нужное значение для SWIFT_REFLECTION_METADATA_LEVEL.
В Reflection Metadata Level есть три уровня:
Можно отключить Reflection с помощью флагов -disable-reflection-metadata и -disable-reflection-names.
При отключении Reflection важно знать о том, что дебаггер использует этот механизм для своей работы. Например, функция Dump использует Reflection, чтобы отображать содержимое сущностей. LLDB использует Reflection Metadata для получения информации о типах. Memory Graph Debugger также использует Reflection Metadata для своих нужд. Из-за этого при отключении Reflection все перечисленные инструменты могут перестать корректно работать.
Отключение Reflection может привести и к другим последствиям. Например, перестает корректно работать @Published
в SwiftUI. Меняется описание объекта при использовании String(reflecting: ...)
и String(describing: ...)
. Перестает корректно работать интерполяция значений Enum: при попытке напечатать в консоль значение с помощью конструкции print("\(someEnum)")
вместо названия кейса мы получим результат вида "SomeEnum(rawValue: 2131241)".
К счастью, можно устанавливать разный Reflection Metadata Level для разных таргетов в проекте. Например, можно включить Reflection в таргете для тестов, а в основном таргете выключить. Есть возможность включать или выключать Reflection для разных модулей в многомодульном приложении.
Настройка Reflection Metadata Level содержит слово metadata потому, что Swift Runtime хранит метаданные для каждого типа, используемого в программе. В них содержится полезная информация об этом типе. Например, для классов и структур в их метаданных можно найти имена полей и их типы. Из этих метаданных Reflection и получает всю нужную информацию, с помощью которой определяется содержимое объектов.
Заключение
Reflection в Swift работает как read-only. Невозможно изменять сущности в Runtime — можно только просматривать их содержимое. По сравнению с другими языками программирования, например Java, такие возможности Reflection кажутся сильно ограниченными. Но ограничение было введено намеренно, поскольку разработчикам языка Swift было важно, чтобы этот язык был безопасным.
С помощью Reflection можно узнавать тип сущностей и читать значения приватных переменных. Но важно понимать, что использование Reflection может быть неочевидно другим разработчикам. Приватные поля относятся к внутренней реализации классов и структур, поэтому могут часто изменяться. К тому же Reflection может быть отключен по каким-то причинам, и тогда код, основанный на этом механизме, перестанет работать. Поэтому, использовать ли Reflection в своем проекте, каждый решает сам.
Пара полезных ссылок на прощание:
Статья на swift.org про то, как работает Mirror. Несмотря на то что материал довольно старый, он может помочь заглянуть внутрь и понять, как Reflection был реализован.
Barhishtan
Спасибо за статью :)
А вы сами в проекте пользуетесь рефлексией в тестах, для доступа к приватным полям, как написано в статье?
Слышал мнение, что это грязноватый подход.
Aksiomka Автор
Мы в проекте не пользуемся Reflection в тестах. Соглашусь с тем, что этот подход можно назвать грязноватым. Поэтому, как я уже писала в статье, каждый решает сам, использовать ли Reflection в своем проекте.
Barhishtan
Как раз дискутируем с командой на этот счет)
Спасибо за ответ ☺️