Чтобы модель Swift Codable оставалась совместимой с более старыми представлениями в памяти, сделайте действительно отсутствующие значения необязательными и явно задайте резервные значения при декодировании. Одного значения по умолчанию в объявлении недостаточно, чтобы успешно декодировать отсутствующее поле.
Это руководство предназначено для разработчика приложений для Mac, который обновляет простую модель компоновки рабочего стола. Конкретная задача — позволить обновлённой модели принимать старые синтетические представления, сохраняя заданные значения и сообщая о несовместимых типах. Во всех примерах значения создаются и используются в памяти.
Что меняется, когда поле становится необязательным или получает значение по умолчанию?
Codable объединяет кодирование и декодирование. Swift может синтезировать эти реализации, если типы хранимых свойств поддерживают необходимые протоколы. Это избавляет от рутинного кода, однако изменение, которое по-прежнему компилируется, может повлиять на то, какие представления принимает модель. Apple описывает как автоматическое соответствие протоколу, так и пользовательские реализации в статье Кодирование и декодирование пользовательских типов.
Рассмотрим плитку компоновки с обязательными заголовком и подзаголовком. Теперь вы хотите сделать подзаголовок необязательным, а для нового количества столбцов задать значение по умолчанию — три. Это два отдельных решения: у подзаголовка может не быть значения, а количество столбцов после декодирования всегда должно иметь пригодное для использования значение.
Для обычных изменяемых свойств, включённых в синтезированное декодирование, Swift считает, что необязательное свойство допускает отсутствие ключа или значение null. Инициализатор в объявлении не служит резервным значением при декодировании: необязательное свойство, инициализированное ненулевым значением, всё равно может декодироваться как nil, а необязательное свойство со значением по умолчанию в объявлении может завершить декодирование ошибкой, если ключ отсутствует. В объяснении синтеза Codable команда Swift описывает это намеренное поведение.
В таблице решений ниже эти правила преобразованы в политику для поля. Предполагается, что поле имеет целочисленный тип и присутствующее значение можно преобразовать в Int. Для двух последних строк требуется пользовательское декодирование.
| Политика для поля | Ключ отсутствует | Явное значение null | Допустимое целое число | Несовместимый тип |
|---|---|---|---|---|
Синтезированное var columns: Int |
Ошибка | Ошибка | Использовать значение | Ошибка |
Синтезированное var columns: Int = 3 |
Ошибка | Ошибка | Использовать значение | Ошибка |
Синтезированное var columns: Int? |
nil |
nil |
Использовать значение | Ошибка |
| Пользовательское резервное значение при отсутствии и null | 3 |
3 |
Использовать значение | Ошибка |
| Пользовательское резервное значение только при отсутствии | 3 |
Ошибка | Использовать значение | Ошибка |
Различие обусловлено методами контейнера. decode(_:forKey:) требует наличия записи и ненулевого значения, которое можно преобразовать. decodeIfPresent(_:forKey:) возвращает nil, если ключ отсутствует или имеет значение null, но всё равно выдаёт ошибку при несовместимом типе. Оператор ?? 3, применённый к этому результату, задаёт резервное значение.
Реализуйте и проверьте политику совместимости в памяти
Используйте этот пример в среде выполнения Swift на Mac, где доступен Foundation и поддерживаются инструкции верхнего уровня, например в существующем playground. Выполните весь блок целиком. JSONEncoder из Foundation создаёт представление типа Data, а JSONDecoder на его основе создаёт новое типизированное значение. В примере не используются ни литеральный документ с закодированными данными, ни внешний ввод.
Выбранная политика задана явно: title остаётся обязательным; если subtitle отсутствует или имеет значение null, оно становится nil; если columns отсутствует или имеет значение null, оно становится равным трём. Присутствующее значение столбца несовместимого типа по-прежнему вызывает ошибку. Пользовательский инициализатор отвечает за декодирование, а кодирование синтезируется с учётом CodingKeys модели. Apple описывает такой способ настройки в статье Кодирование и декодирование пользовательских типов.
Это полноценная программа, включающая синтетические тестовые данные и проверки. В исследовательской среде Swift был недоступен, поэтому пример сверили с документированными API, но не запускали.
import Foundation
struct LegacyTile: Codable {
var title: String
var subtitle: String
}
struct Tile: Codable, Equatable {
static let defaultColumns = 3
var title: String
var subtitle: String?
var columns: Int = Tile.defaultColumns
enum CodingKeys: String, CodingKey {
case title, subtitle, columns
}
}
extension Tile {
init(from decoder: Decoder) throws {
let values = try decoder.container(keyedBy: CodingKeys.self)
title = try values.decode(String.self, forKey: .title)
subtitle = try values.decodeIfPresent(String.self, forKey: .subtitle)
columns = try values.decodeIfPresent(Int.self, forKey: .columns)
?? Self.defaultColumns
}
}
struct MinimalTile: Encodable {
var title: String
}
struct NullTile: Encodable {
enum CodingKeys: String, CodingKey {
case title, subtitle, columns
}
func encode(to encoder: Encoder) throws {
var values = encoder.container(keyedBy: CodingKeys.self)
try values.encode("Circle", forKey: .title)
try values.encodeNil(forKey: .subtitle)
try values.encodeNil(forKey: .columns)
}
}
struct WrongColumnsTile: Encodable {
var title: String
var columns: String
}
let encoder = JSONEncoder()
let decoder = JSONDecoder()
// An older representation keeps its subtitle and gains the fallback.
let legacyBytes = try encoder.encode(
LegacyTile(title: "Circle", subtitle: "Blue")
)
let upgraded = try decoder.decode(Tile.self, from: legacyBytes)
precondition(upgraded.title == "Circle")
precondition(upgraded.subtitle == "Blue")
precondition(upgraded.columns == 3)
// Missing keys and explicit nulls follow the chosen policy.
let minimalBytes = try encoder.encode(MinimalTile(title: "Circle"))
let minimal = try decoder.decode(Tile.self, from: minimalBytes)
precondition(minimal.subtitle == nil)
precondition(minimal.columns == 3)
let nullBytes = try encoder.encode(NullTile())
let explicitNull = try decoder.decode(Tile.self, from: nullBytes)
precondition(explicitNull == minimal)
// A supplied nondefault value survives an encode/decode cycle.
let supplied = Tile(title: "Square", subtitle: "Green", columns: 5)
let suppliedBytes = try encoder.encode(supplied)
let restored = try decoder.decode(Tile.self, from: suppliedBytes)
precondition(restored == supplied)
// Incompatible types must remain visible as decoding failures.
let wrongBytes = try encoder.encode(
WrongColumnsTile(title: "Circle", columns: "wide")
)
do {
_ = try decoder.decode(Tile.self, from: wrongBytes)
preconditionFailure("Expected a type mismatch")
} catch DecodingError.typeMismatch(_, let context) {
precondition(context.codingPath.last?.stringValue == "columns")
}
// A newer representation may no longer satisfy an older reader.
let currentBytes = try encoder.encode(minimal)
do {
_ = try decoder.decode(LegacyTile.self, from: currentBytes)
preconditionFailure("Expected the required subtitle to be missing")
} catch DecodingError.keyNotFound(let key, _) {
precondition(key.stringValue == "subtitle")
}
print("All in-memory compatibility checks passed.")
Ожидаемый итоговый вывод: All in-memory compatibility checks passed. Неожиданные ошибки передаются дальше, а не преобразуются в успешный результат. Тестовые данные намеренно охватывают разные представления: MinimalTile не содержит некоторых полей, а NullTile использует encodeNil(forKey:) для создания записей со значением null.
Если оставить init(from:) в расширении, структура сохранит доступность своего инициализатора со списком свойств, что позволит примеру создать Tile(title:subtitle:columns:). Пользовательский инициализатор декодирования присваивает значения всем хранимым свойствам экземпляра, поэтому к моменту возврата результат полностью инициализирован.
Следует ли подставлять значение по умолчанию при null или оставить ошибку?
Выбирайте вариант в соответствии со смыслом поля. Для этой иллюстративной компоновки разумно считать, что и отсутствие поля, и значение null означают стандартное количество столбцов. Но это всё равно решение на уровне приложения: decodeIfPresent намеренно объединяет эти два состояния, и резервное значение приводит их к одному результату. Это следствие явно указано в документированном поведении возвращаемого значения.
Если отсутствие означает «использовать стандартную компоновку», но переданное значение null должно приводить к ошибке, замените только присваивание columns внутри init(from:) программы на этот фрагмент. Все остальные предпосылки и определения остаются прежними.
// Replacement fragment inside Tile.init(from:).
if values.contains(.columns) {
columns = try values.decode(Int.self, forKey: .columns)
} else {
columns = Self.defaultColumns
}
contains(_:) сообщает, существует ли запись, в том числе запись со значением null. Поэтому явное значение null попадает в ветку decode и приводит к valueNotFound; значение по умолчанию применяется только при отсутствии ключа. При такой изменённой политике проверка фикстуры с null в исходной программе должна ожидать эту ошибку, а не равенство с minimal.
Не заменяйте выражение присваивания на (try? values.decode(Int.self, forKey: .columns)) ?? 3, если только намеренно не хотите игнорировать все ошибки декодирования. Это выражение также подставляет три вместо переданного значения несовместимого типа. Использование try decodeIfPresent сохраняет различие между допустимым отсутствием значения и представлением, которое невозможно интерпретировать для этого поля, как указано в контракте ошибок метода декодирования.
Работает ли совместимость в обоих направлениях?
Проверяйте каждый декодер отдельно. Программа показывает, что обновлённый декодер принимает старое представление. Она также демонстрирует ограничение: старый декодер по-прежнему требует subtitle, поэтому он отклоняет новое представление, в котором это поле отсутствует. Изменение свойства Swift на необязательное в новой модели не меняет требований старого типа.
При синтезированном кодировании необязательных свойств значения nil опускаются благодаря условному кодированию. В документации Swift о encodeIfPresent(_:forKey:) указано, что этот метод кодирует только ненулевые значения. Можно закодировать явное значение null с помощью encodeNil, но это всё равно не удовлетворит старому необязательному свойству String, которому требуется значение.
Если старый декодер должен оставаться пригодным к использованию, задайте явное представление, соответствующее его требованиям. В этом синтетическом примере адаптер может создать LegacyTile с выбранным подзаголовком перед кодированием. Решение о том, должно ли отсутствующее значение подзаголовка заменяться какой-либо конкретной строкой, принимается отдельно на уровне продукта; не придумывайте такой смысл молча внутри общего резервного значения.
Также различайте сохранение значения и сохранение представления. В примере отсутствующий подзаголовок и явное значение null преобразуются в одно и то же значение Swift: nil. Поэтому модель не может восстановить, какой из этих двух вариантов она получила. Аналогично, если декодировать отсутствующее количество столбцов как три, то при повторном кодировании модели будет указано конкретное количество столбцов. Это прямые следствия выбранной модели и политики декодирования.
Что следует проверить перед изменением модели?
При любом изменении поля используйте небольшую матрицу совместимости. Начните со старого представления, а затем меняйте по одному полю за раз. Так успешный цикл кодирования и декодирования не скроет ошибку, возникающую только при отсутствии ключа.
- Старый ввод: убедитесь, что обновлённый декодер принимает его и сохраняет имеющиеся значения.
- Отсутствующее поле: проверьте, что результатом становится именно предусмотренное необязательное значение, резервное значение или ошибка.
- Явное значение null: проверьте его отдельно от отсутствия ключа.
- Переданное значение: используйте значение, отличное от значения по умолчанию, чтобы убедиться, что декодирование его не перезаписывает.
- Несовместимый тип: убедитесь, что по-прежнему возникает ошибка, если политика преобразования не задана.
- Старый декодер: проверьте, соответствует ли новое закодированное представление его требованиям.
Не удаляйте поле из CodingKeys только ради резервного значения. Apple поясняет, что исключённые свойства не входят в закодированное или декодированное представление и для автоматического синтеза декодирования должны иметь значения по умолчанию. Это подходит, если свойство намеренно остаётся вне представления; при этом переданное значение такого свойства не будет прочитано. См. рекомендации Apple по ключам кодирования.
Размещайте правило совместимости рядом с логикой декодирования поля: необязательное свойство — для случая, когда отсутствие имеет смысл; явное резервное значение — для допустимого отсутствия; обычное декодирование с ошибкой — для обязательных значений. Тогда синтетические данные в памяти позволят проверить предполагаемое поведение при каждом изменении модели.
Источники
- Кодирование и декодирование пользовательских типов — Apple
- JSONEncoder — Apple
- JSONDecoder — Apple
- Декодирование отсутствующих в JSON необязательных значений — Форумы Swift
- decodeIfPresent(_:forKey:) — Apple
- decode(_:forKey:) — Apple
- contains(_:) — Apple
- encodeNil(forKey:) — Apple
- encodeIfPresent(_:forKey:) — Apple