Привет, Хабр!
На этой неделе мы поговорим ещё об одном встроенном типе Swift - Codable. Думаю, все, кто писал клиент-серверные приложения, сталкивались с этим протоколом: он позволяет преобразовывать наши структуры в бинарные данные и обратно. Однако, полагаю, немногие задумывались, как этот привычный механизм работает под капотом. Сегодня я постараюсь рассказать об этом.

Codable
Codable - это не самостоятельный протокол, а всего лишь typealias для объединения двух протоколов: Decodable и Encodable. Они позволяют декодировать и кодировать данные соответственно.
public typealias Codable = Encodable & Decodable
Чтобы объявить тип, поддерживающий Codable:
struct User: Codable { let userID: Int let name: String let secondName: String }
При необходимости можно подписать типы отдельно под Decodable или Encodable:
struct User: Decodable { let userID: Int let name: String let secondName: String } struct Home: Encodable { let address: String let postalCode: Int }
Если заглянуть внутрь этих протоколов, можно увидеть методы encode (в Encodable) и init(from:) (в Decodable):
public protocol Encodable { func encode(to encoder: any Encoder) throws } public protocol Decodable { init(from decoder: any Decoder) throws }
Но в примерах выше мы их не реализовали. Это связано с тем, что базовые типы Swift «из коробки» поддерживают Codable. Поэтому Swift может автоматически сгенерировать реализацию этих методов для наших структур. То же правило действует и для перечислений:
enum Place: Codable { case museum case cafe case custom(String) }
Но не для всех перечислений, если перечисление с ассоциированным значением, то оно должно быть также подписано под Codable
Более того, Swift способен вывести реализацию методов протокола и для кастомных типов, если они также подписаны под Codable:
struct Home: Codable { let address: Address let numberOfLevels: Int } struct Address: Codable { let city: String let street: String let houseNumber: Int }
С этим понятно, однако давайте разберем по отдельности Encodable и Decodable
Encodable
Начнём с Encodable. Как уже было сказано, он отвечает за преобразование данных в набор байтов, который чаще всего возвращается в виде объекта Data.
struct User: Encodable { let userID: Int let userName: String } let user = User(userID: 1, userName: "Username") let encoder = JSONEncoder() if let jsonData = try? encoder.encode(user) { let jsonString = String(data: jsonData, encoding: .utf8) ?? "{}" print(jsonString) // {"userID":1,"userName":"Username"} }
У JSONEncoder есть несколько встроенных настроек. Например:
автоматический перевод ключей в
snake_case:
encoder.keyEncodingStrategy = .convertToSnakeCase
форматирование дат:
encoder.dateEncodingStrategy = .iso8601
выбор способа кодирования
Data:
encoder.dataEncodingStrategy = .base64
обработка особых случаев чисел с плавающей точкой: бесконечностей и
NaN:
encoder.nonConformingFloatEncodingStrategy = .convertToString( positiveInfinity: "+infinity", negativeInfinity: "-infinity", nan: "NaN" )
Все параметры выше относятся только к
JSONEncoder.
Помимо JSONEncoder, в стандартной библиотеке есть также PropertyListEncoder, который позволяет сериализовать данные в формате XML или в бинарном виде:
let encoder = PropertyListEncoder() encoder.outputFormat = .xml if let xmlData = try? encoder.encode(user) { let xmlString = String(data: xmlData, encoding: .utf8) ?? "{}" print(xmlString) }
Результат в формате XML:
<?xml version="1.0" encoding="UTF-8"?> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> <plist version="1.0"> <dict> <key>userID</key> <integer>1</integer> <key>userName</key> <string>Egor</string> </dict> </plist>
У обоих энкодеров есть и полезное свойство userInfo, которое позволяет передавать дополнительный контекст для кодирования:
extension CodingUserInfoKey { static let shouldEncrypt = CodingUserInfoKey(rawValue: "shouldEncrypt")! } struct User: Encodable { let userID: Int let userName: String enum CodingKeys: String, CodingKey { case userName = "user_name" case userID = "user_id" } func encode(to encoder: Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) if let shouldEncrypt = encoder.userInfo[.shouldEncrypt] as? Bool, shouldEncrypt { try container.encode("encrypted_\(userName)", forKey: .userName) } else { try container.encode(userName, forKey: .userName) } try container.encode(userID, forKey: .userID) } } let encoder = PropertyListEncoder() encoder.outputFormat = .xml encoder.userInfo[.shouldEncrypt] = true let data = try encoder.encode(User(userID: 1, userName: "Username")) print(String(data: data, encoding: .utf8)!)
<?xml version="1.0" encoding="UTF-8"?> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> <plist version="1.0"> <dict> <key>user_id</key> <integer>1</integer> <key>user_name</key> <string>encrypted_Username</string> </dict> </plist>
Теперь, разобравшись с кодированием, давайте перейдём к обратному процессу - декодированию.
Decodable
Decodable - это обратная операция по отношению к Encodable.
Он отвечает за то, чтобы декодировать бинарные данные обратно в Swift-типы, восстанавливая структуру объекта из закодированного формата (например, JSON или plist).
Ключевую роль в этом процессе играет знакомый класс JSONDecoder.
struct User: Codable { let userID: Int let userName: String } let encoder = JSONEncoder() let data = try encoder.encode(User(userID: 1, userName: "Username")) let decoder = JSONDecoder() let user = try decoder.decode(User.self, from: data) print(user) // User(userID: 1, userName: "Username")
У JSONDecoder есть свои настройки, большая часть из них такая же как у JSONEncoder, их мы пропустим. Но у него также и свои, например:
allowsJSON5
decoder.allowsJSON5 = true
Позволяет использовать расширения JSON5, такие как:
комментарии (
//или/* ... */),ключи без кавычек,
одинарные кавычки для строк и т. д.
assumesTopLevelDictionary
decoder.assumesTopLevelDictionary = true
Если включено, декодер предполагает, что корневой элемент JSON - словарь ({}).
Это помогает предотвратить ошибки, если вместо словаря случайно подали массив или примитив.
Другими словами, если у нас такой JSON и этот флаг не включен, то это ошибка, так как у нас нет {} на верхнем уровне
"userID": 1, "userName": "username"
Если включен, то все будет корректно.
Пример для самостоятельного запуска:
let json = #""" "userID": 1, "userName": "username" """#.data(using: .utf8)! struct User: Decodable { let userID: Int let userName: String } let decoder = JSONDecoder() decoder.assumesTopLevelDictionary = true // <---- можно попробовать убрать и посмотреть что будет let user = try decoder.decode(User.self, from: json) print(user)
Помимо декодера для JSON, Swift предоставляет ещё один встроенный декодер - PropertyListDecoder. Как и его антагонист, который кодирует данные, он также поддерживает как XML-plist, так и бинарные версии.
Пример:
struct Config: Codable { let apiKey: String let baseURL: String let retryCount: Int } let decoder = PropertyListDecoder() let path = FileManager.default.currentDirectoryPath + "/Config.plist" let url = URL(fileURLWithPath: path) let data = try Data(contentsOf: url) let config = try decoder.decode(Config.self, from: data) print(config.apiKey) // ABCDEFG123456 print(config.baseURL) // https://api.example.com print(config.retryCount) // 3
Если содержимое Config.plist выглядит так:
<?xml version="1.0" encoding="UTF-8"?> <plist version="1.0"> <dict> <key>apiKey</key> <string>ABCDEFG123456</string> <key>baseURL</key> <string>https://api.example.com</string> <key>retryCount</key> <integer>3</integer> </dict> </plist>
Чтобы протетстировать в консольном приложении нужно выполнить несколько вещей:
Добавить файл Config.plist в бандл
В Build Phases добавить через + "New Copy Files Phase" и добавить туда Config.plist
Контейнеры
Мы уже рассмотрели, как на практике работает процесс кодирования и декодирования, теперь давайте копнем чуть глубже и разберёмся, из чего эти операции состоят. Начнём с кодирования.
Сам Encoder - это протокол, который определяет базовый интерфейс для кодировщиков. Внутри он оперирует так называемыми контейнерами:
public protocol Encoder { var codingPath: [any CodingKey] { get } func container<Key>(keyedBy type: Key.Type) -> KeyedEncodingContainer<Key> func unkeyedContainer() -> any UnkeyedEncodingContainer func singleValueContainer() -> any SingleValueEncodingContainer }
codingPath- массив, содержащий путь ключей кодирования, пройденный до текущего места в процессе кодирования. Он помогает отследить, где именно в структуре данных мы сейчас находимся.container<Key>(keyedBy:)- возвращает контейнер индексируемый ключами, который используется для хранения значений в виде словаря.unkeyedContainer()- возвращает контейнер, применяемый для хранения последовательностей, таких как массивы.singleValueContainer()- создаёт контейнер одиночного значения, предназначенный для кодирования простых типов (чисел, строк, булевых и т. д.).
Для понимания, как это работает, рассмотрим протокол SingleValueEncodingContainer:
public protocol SingleValueEncodingContainer { var codingPath: [any CodingKey] { get } mutating func encodeNil() throws mutating func encode(_ value: Bool) throws mutating func encode(_ value: String) throws mutating func encode(_ value: Double) throws mutating func encode(_ value: Float) throws mutating func encode(_ value: Int) throws mutating func encode(_ value: Int8) throws mutating func encode(_ value: Int16) throws mutating func encode(_ value: Int32) throws mutating func encode(_ value: Int64) throws @available(SwiftStdlib 6.0, *) mutating func encode(_ value: Int128) throws mutating func encode(_ value: UInt) throws mutating func encode(_ value: UInt8) throws mutating func encode(_ value: UInt16) throws mutating func encode(_ value: UInt32) throws mutating func encode(_ value: UInt64) throws @available(SwiftStdlib 6.0, *) mutating func encode(_ value: UInt128) throws mutating func encode<T: Encodable>(_ value: T) throws }
Как видно, контейнер предоставляет перегрузки метода encode для всех базовых типов, а также метод encodeNil() для кодирования nil.
Помимо этого, последняя функция encode<T: Encodable>(_ value: T) вызывается для типов, которые не имеют отдельной перегрузки.
Через SingleValueEncodingContainer кодируются также значения перечислений (enum), если они реализуют протокол RawRepresentable.
Аналогичная логика используется и в других контейнерах:
KeyedEncodingContainer- работает со словарями;UnkeyedEncodingContainer- предназначен для массивов.
Однако помимо декодирования простейших типов они также позволяют создавать вложенные контейнеры и кодировать сложные структуры данных.
Долго останавливаться на протоколе Decoder не вижу смысла, так как он имеет аналогичную структуру, только выполняет обратную операцию - извлекает данные из контейнеров и восстанавливает их в объект.
Ключевая особенность, которая заключена в процессе кодирования и декодирования, заключается в том, что во время этого процесса создаются контейнеры для каждого значения рекурсивно. Каждый уровень вложенности получает свой контейнер, что предотвращает перезапись данных между элементами. Рекурсия продолжается, пока мы не дойдём до базовых типов, обрабатываемых через SingleValueEncodingContainer.
Этот процесс можно представить в виде дерева, где каждый узел - это контейнер, а листья - простейшие значения вроде String, Int или Bool.
Например, для массива это выглядит так:
extension Array: Encodable where Element: Encodable { public func encode(to encoder: any Encoder) throws { var container = encoder.unkeyedContainer() for element in self { try container.encode(element) } } }
Сама генерация кода, который соответствует протоколу Encodable выглядит так:
Вначале у нас генерируются ключи CodingKey для каждого поля структуры
struct User: Codable { let userID: Int let userName: String private enum CodingKeys: CodingKey { case userID case userNanme } }
Затем генерируется метод encode(to:):
func encode(to encoder: Encoder) throws { var container = encdoer.container(keyedBy: CodingKeys.self) try container.encode(userID, forKey: .userID) try container.encode(userName, forKey: .userName) }
Для процесса декодинга происходит нечто подобное, однако вместо метода encode генерируется инциализатор:
init(from decoder: Decoder) throws { var container = try decoder.container(keyedBy: CodingKeys.self) userID = try container.decode(userID, forKey: .userID) userName = try container.decode(userName, forKey: .userName) }
Ручная поддержка Codable
Само собой все, что описано выше, можно сделать руками, но Swift, как правило, довольно успешно справляется с этим без нашего участия. Пожалуй, один из немногих кейсов, где это может понадобиться - когда мы не владеем типом и мы не можем его подписать под протокол Codable. В таком случае мы можем руками реализовать требования:
import CoreLocation struct Address: Codable { let coorditate: CLLocationCoordinate2D let name: String enum CodingKeys: String, CodingKey { case name } enum CoorditateCodingKeys: String, CodingKey { case lat, lon } func encode(to encoder: any Encoder) throws { var nameContainer = encoder.container(keyedBy: CodingKeys.self) var coorditateContainer = encoder.container(keyedBy: CoorditateCodingKeys.self) try nameContainer.encode(name, forKey: .name) try coorditateContainer.encode(coorditate.latitude, forKey: .lat) try coorditateContainer.encode(coorditate.longitude, forKey: .lon) } init(from decoder: any Decoder) throws { let nameContainer = try decoder.container(keyedBy: CodingKeys.self) let coordinateContainer = try decoder.container(keyedBy: CoorditateCodingKeys.self) self.name = try nameContainer.decode(String.self, forKey: .name) self.coorditate = CLLocationCoordinate2D( latitude: try coordinateContainer.decode(Double.self, forKey: .lat), longitude: try coordinateContainer.decode(Double.self, forKey: .lon) ) } init(coorditate: CLLocationCoordinate2D, name: String) { self.coorditate = coorditate self.name = name } } let address = Address( coorditate: CLLocationCoordinate2D(latitude: 55.7558, longitude: 37.6173), name: "Moscow" ) do { let encoder = JSONEncoder() encoder.outputFormatting = .prettyPrinted let jsonData = try encoder.encode(address) let jsonString = String(data: jsonData, encoding: .utf8)! print(jsonString) let decoder = JSONDecoder() let decodedAddress = try decoder.decode(Address.self, from: jsonData) print(decodedAddress) } catch { print(error) }
Выводы
Подводя итоги, в этой статье мы разобрали один из самых часто используемых типов в Swift — Codable. Он позволяет удобно кодировать и декодировать данные, упрощая работу с сетевыми запросами и файлами, а также делая её безопаснее. Мы рассмотрели практическое применение Codable, а также заглянули под капот — в процесс создания контейнеров и автоматическую генерацию кода в Swift.
