Swift Codable — как инициализировать необязательное свойство Enum с ошибкой

Я пытаюсь принять протокол Codable для объекта, который должен быть создан из JSON, который моя веб-служба возвращает в ответ на один из вызовов API.

Одно из свойств имеет тип перечисления и является необязательным: nil означает, что ни один из параметров, определенных enum, не выбран.

Константы enum основаны на Int и начинаются с 1, не 0:

class MyClass: Codable {

    enum Company: Int {
        case toyota = 1
        case ford
        case gm
    } 
    var company: Company?

Это связано с тем, что значение 0 в соответствующей записи JSON зарезервировано для "не задано"; то есть он должен быть сопоставлен с nil при настройке инициализации свойства company из него.

Инициализатор перечисления Swift init?(rawValue:) предоставляет эту функциональность «из коробки»: аргумент Int, который не соответствует необработанному значению ни в одном случае, приведет к сбою инициализатора и возвращению nil. Кроме того, перечисления на основе Int (и String) можно привести в соответствие с Codable, просто объявив его в определении типа:

enum Company: Int, Codable {
    case toyota = 1
    case ford
    case gm
} 

Проблема в том, что мой пользовательский класс имеет более 20 свойств, поэтому я действительно хочу избежать необходимости реализовывать init(from:) и encode(to:), вместо этого полагаясь на автоматическое поведение, полученное путем предоставления пользовательского типа перечисления CondingKeys.

Это приводит к сбою инициализации всего экземпляра класса, поскольку создается впечатление, что "синтезированный" инициализатор не может сделать вывод о том, что неподдерживаемое необработанное значение типа enum следует рассматривать как nil (даже если целевое свойство четко обозначено как необязательный, например Company?).

Я думаю, это так, потому что инициализатор, предоставленный Decodable, может бросить, но не может вернуть nil:

// This is what we have:
init(from decoder: Decoder) throws

// This is what I would want:
init?(from decoder: Decoder)

В качестве обходного пути я реализовал свой класс следующим образом: сопоставьте целочисленное свойство JSON с частным, хранимым свойством Int моего класса, которое служит только для хранения, и введите строго типизированное вычисляемое свойство, которое действует как мост между хранилищем и остальной частью моего приложения:

class MyClass {

   // (enum definition skipped, see above)

   private var companyRawValue: Int = 0

   public var company: Company? {
       set {
           self.companyRawValue = newValue?.rawValue ?? 0
           // (sets to 0 if passed nil)
       }
       get {
           return Company(rawValue: companyRawValue)
           // (returns nil if raw value is 0)
       }
   }

   enum CodingKeys: String, CodingKey {
       case companyRawValue = "company"
   }

   // etc...

Мой вопрос: есть ли лучший (более простой/элегантный) способ, который:

  1. не требует дублирования свойств, как мой обходной путь, и
  2. Не требует ли не полной реализации init(from:) и/или encode(with:), возможно, реализации их упрощенных версий, которые по большей части делегируют поведение по умолчанию (т. е. не требуют всего шаблона ручной инициализации/кодирования каждого свойство)?

Дополнение: есть третье, тоже неэлегантное решение, которое не пришло мне в голову, когда я впервые опубликовал вопрос. Он предполагает использование искусственного базового класса только для автоматического декодирования. Я не буду его использовать, а просто опишу здесь для полноты картины:

// Groups all straight-forward decodable properties
//
class BaseClass: Codable {
    /*
     (Properties go here)
     */

    enum CodingKeys: String, CodingKey {
        /*
         (Coding keys for above properties go here)
         */
    }

    // (init(from decoder: Decoder) and encode(to:) are 
    // automatically provided by Swift)
}

// Actually used by the app
//
class MyClass: BaseClass {

    enum CodingKeys: String, CodingKey {
        case company
    }

    var company: Company? = nil

    override init(from decoder: Decoder) throws {
        super.init(from: decoder)

        let values = try decoder.container(keyedBy: CodingKeys.self)
        if let company = try? values.decode(Company.self, forKey: .company) {
            self.company = company
        }

    }
}

...Но это действительно уродливый хак. Иерархия наследования классов не должна диктоваться такими недостатками.


person Nicolas Miari    schedule 06.11.2018    source источник
comment
stackoverflow.com/a/49697266/2303865 связанные   -  person Leo Dabus    schedule 06.11.2018
comment
@LeoDabus интересно ... Но я уверен, что вы не можете назначить nil на self внутри безотказного инициализатора броска ... Этот ответ присваивает фактическое значение по умолчанию ...   -  person Nicolas Miari    schedule 06.11.2018
comment
Вы должны бросить или провалить не оба. И, кстати, потерпеть неудачу молча, это не вариант   -  person Leo Dabus    schedule 06.11.2018
comment
Я знаю. Я хочу иметь возможность потерпеть неудачу и назначить nil моему необязательному свойству, но Decodable работает, бросая...   -  person Nicolas Miari    schedule 06.11.2018
comment
попробуй class MyClass: Codable { enum Company: Int, Codable { case toyota = 1, ford, gm } var company: Company? required init(from decoder: Decoder) throws { company = try Company(rawValue: decoder.singleValueContainer().decode(Int.self)) } }   -  person Leo Dabus    schedule 06.11.2018
comment
@LeoDabus, да, я знаю, что это сработает, но это именно то, чего я пытался избежать (чтобы явно реализовать init(from decoder: Decoder)); мой класс имеет много свойств помимо company...   -  person Nicolas Miari    schedule 06.11.2018
comment
Единственное, что я могу придумать, это проанализировать предстоящую json-компанию как обычное целое число и создать вычисляемое свойство, чтобы получить его значение перечисления, как вы уже показали в своем вопросе.   -  person Leo Dabus    schedule 06.11.2018
comment
@NicolasMiari Как насчет использования только try? ....? Вы можете написать if let propertyName = try? .... { do what you need }. Вам на самом деле не нужно init?(from decoder: Decoder).   -  person Lachtan    schedule 04.12.2018


Ответы (5)


Я думаю, что у меня была похожая проблема с вашей, если я правильно понимаю. В моем случае я написал обертку для каждого рассматриваемого перечисления:

struct NilOnFail<T>: Decodable where T: Decodable {

    let value: T?

    init(from decoder: Decoder) throws {
        self.value = try? T(from: decoder) // Fail silently
    }

    // TODO: implement Encodable
}

Затем используйте его следующим образом:

class MyClass: Codable {

    enum Company: Int {
        case toyota = 1
        case ford
        case gm
    } 

    var company: NilOnFail<Company>
...

Предостережение, конечно, заключается в том, что везде, где вам нужно получить доступ к значению company, вам нужно использовать myClassInstance.company.value

person Max Chuquimia    schedule 06.11.2018
comment
Ясно... по крайней мере, мой обходной путь оставляет публичный интерфейс MyClass нетронутым. - person Nicolas Miari; 06.11.2018
comment
Я думаю, возможно, я искал способ расширить или создать подкласс JSONDecoder, чтобы он мог обрабатывать неудачную инициализацию перечисления... хотя мне трудно понять, как это можно сделать. - person Nicolas Miari; 06.11.2018

Изучив документацию по протоколам Decoder и Decodable и конкретному классу JSONDecoder, я понял, что нет способа добиться именно того, что я искал. Ближе всего просто реализовать init(from decoder: Decoder) и выполнить все необходимые проверки и преобразования вручную.


Дополнительные мысли

Поразмыслив над проблемой, я обнаружил несколько проблем с моим текущим дизайном: во-первых, отображение значения 0 в ответе JSON на nil кажется неправильным.

Несмотря на то, что значение 0 имеет конкретное значение «неопределенное» на стороне API, форсируя отказоустойчивое init?(rawValue:), я, по сути, объединяю все недопустимые значения вместе. Если из-за какой-то внутренней ошибки или ошибки сервер вернет (скажем) -7, мой код не сможет это обнаружить и молча сопоставит его с nil, как если бы это был назначенный 0.

Поэтому я думаю, что правильный дизайн будет следующим:

  1. Отказаться от необязательности для свойства company и определить enum следующим образом:

    enum Company: Int {
       case unspecified = 0
       case toyota
       case ford
       case gm
    }
    

    ... точно соответствует JSON или,

  2. Сохраните необязательность, но пусть API возвращает JSON, в котором отсутствует значение для ключа «компания» (так что сохраненное свойство Swift сохраняет свое начальное значение nil) вместо возврата 0 (я полагаю, что JSON делает имеют "нулевое" значение, но я не уверен, как JSONDecoder с этим справляется)

Первый вариант требует изменения большого количества кода во всем приложении (изменение вхождений if let... на сравнения с .unspecified).

Второй вариант требует изменения серверного API, что мне неподвластно (и приведет к проблеме миграции/обратной совместимости между серверной и клиентской версиями).

Я думаю, что пока буду придерживаться своего обходного пути и, возможно, когда-нибудь в будущем приму вариант № 1...

person Nicolas Miari    schedule 06.11.2018

Я знаю, что мой ответ запоздал, но, возможно, это поможет кому-то еще.

У меня также были перечисления StringOptional, но если бы я получил от бэкэнда новое значение, которое не было включено в локальное перечисление, json не проанализировался бы, даже если перечисление было необязательным.

Вот как я это исправил, нет необходимости реализовывать какой-либо метод инициализации. Таким образом, вы также можете указать значения по умолчанию вместо nil, если это необходимо.

struct DetailView: Codable {

var title: ExtraInfo?
var message: ExtraInfo?
var action: ExtraInfo?
var imageUrl: String?

// 1
private var imagePositionRaw: String?
private var alignmentRaw: String?

// 2
var imagePosition: ImagePosition {
    ImagePosition.init(optionalRawValue: imagePositionRaw) ?? .top
}

// 3
var alignment: AlignmentType? {
    AlignmentType.init(optionalRawValue: alignmentRaw)
}

enum CodingKeys: String, CodingKey {
    case imagePositionRaw = "imagePosition",
         alignmentRaw = "alignment",
         imageUrl,
         title,
         message,
         action
}

}

(1) Вы получаете значения из бэкэнда как необработанные (строка, int - все, что вам нужно), и вы инициализируете свои перечисления из этих необработанных значений (2,3).

Если значение из бэкенда равно нулю или отличается от ожидаемого, вы возвращаете ноль (3) или значение по умолчанию (2).

--- отредактируйте, чтобы добавить расширение, используемое для инициализации перечисления:

extension RawRepresentable {
  init?(optionalRawValue: RawValue?) {
    guard let rawData = optionalRawValue else { return nil }
    self.init(rawValue: rawData)
  }
}
person Aura    schedule 21.07.2021

Вы можете попробовать SafeDecoder.

import SafeDecoder

class MyClass: Codable {

  enum Company: Int {
    case toyota = 1
    case ford
    case gm
  }
  var company: Company?
}

Тогда просто расшифруйте как необычное. Любое значение, отличное от 1,2,3, автоматически возвращается к нулю.

person canius    schedule 15.01.2020

Спасибо за подробный вопрос и ответ. Вы заставили меня переосмыслить мой подход к декодированию JSON. Имела аналогичную проблему и решила декодировать значение JSON в Int, а не добавлять логику к тому, что должно было быть DTO. После этого добавление расширения модели для преобразования значения в перечисление не имеет значения с точки зрения использования перечисления, но выглядит более чистым решением.

person khamitimur    schedule 14.03.2021