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

Если заглянуть внутрь этих протоколов, можно увидеть методы encodeEncodable) и 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>

Чтобы протетстировать в консольном приложении нужно выполнить несколько вещей:

  1. Добавить файл Config.plist в бандл

  2. В 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.

Аналогичная логика используется и в других контейнерах:

Однако помимо декодирования простейших типов они также позволяют создавать вложенные контейнеры и кодировать сложные структуры данных.

Долго останавливаться на протоколе 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.

Полезные материалы

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