Привет, Хабр!
На этой неделе мы поговорим ещё об одном встроенном типе 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.