Aby zachować zwykłe wartości zagnieżdżone w rekurencyjnym wyliczeniu Swift, zaimplementuj własne kodowanie i dekodowanie dla każdego rodzaju wartości. W tym przewodniku zbudujemy syntetyczny model aplikacji Mac, który przechodzi pełną konwersję w obie strony przez obiekty Foundation całkowicie w pamięci, bez dodawania opakowań dla przypadków wyliczenia.

Dlaczego syntezowane Codable zmienia reprezentację?

Załóżmy, że aplikacja na komputer Mac ma niewielki podgląd układu zawierający etykiety, wartości liczbowe, przełączniki, listy i zagnieżdżone grupy. Rekurencyjne wyliczenie może precyzyjnie opisywać te wartości. Konkretne zadanie polega tu na przekształceniu tego wyliczenia w zwykły graf obiektów Foundation i odtworzeniu go z zachowaniem obsługiwanych rodzajów wartości.

Automatyczna konformancja nie zapewnia wymaganej reprezentacji. Zaimplementowany mechanizm syntezy wyliczeń w Swift umieszcza nazwę przypadku w zewnętrznym kontenerze kluczowanym, a następnie umieszcza wartości skojarzone w kolejnym kontenerze. Wartościom skojarzonym bez etykiet przypisywane są wygenerowane klucze, takie jak _0. W rezultacie przypadek typu string otrzymuje opakowania strukturalne zamiast stawać się zwykłym ciągiem znaków. Zasady te opisano w Codable synthesis for enums with associated values.

Zmiana CodingKeys może zmienić nazwy tych opakowań, ale zachowanie syntezy oznacza zachowanie jej modelu strukturalnego. Obsługiwanym rozwiązaniem jest samodzielna implementacja init(from:) i encode(to:). Apple wyraźnie dokumentuje ręczną konformancję dla typów, których struktura w Swift różni się od ich struktury po zakodowaniu, w artykule Encoding and Decoding Custom Types.

Rekurencja i reprezentacja to odrębne decyzje. Modyfikator indirect w Swift umożliwia rekurencyjne przechowywanie wyliczeń; kształt zakodowanych danych określają własne metody kodowania. W przewodniku języka wyjaśniono, że umieszczenie indirect przed deklaracją wyliczenia stosuje pośrednictwo do jego przypadków z wartościami skojarzonymi; zobacz Enumerations.

Co dokładnie powinna zachowywać zwykła reprezentacja?

W tym przykładzie zastosuj poniższy kontrakt. Celowo obsługuje on niewielki zestaw rodzajów wartości, odpowiedni dla syntetycznego podglądu układu. Udokumentowane zasady konwersji Foundation dopuszczają ciągi znaków, liczby, tablice, słowniki i obiekty null, wymagają ciągów znaków jako kluczy słowników oraz wykluczają liczby nieskończone. Zobacz JSONSerialization.

Przypadek wyliczenia Zwykła wartość Wymóg zgodności
null NSNull Zachowaj jawną wartość null
bool Wartość logiczna Zachowaj odrębność od liczbowego zera i jedynki
number Skończona wartość Double Uwzględnij ograniczenia precyzji typu Double
string Ciąg znaków Zachowaj tekst bez konwersji na liczbę
array Tablica zwykłych wartości Zachowaj kolejność elementów i puste tablice
object Słownik z kluczami tekstowymi Zachowaj klucze, zagnieżdżone wartości i puste obiekty

Zgodność oznacza tu zachowanie tych kategorii i ich wartości w określonych granicach liczbowych. Nie wymaga zachowania konkretnego podtypu liczby Foundation ani kolejności kluczy słownika. Musi zostać zachowany również jawny wpis null: obiekt zawierający "subtitle": NSNull() ma inne znaczenie w modelu niż obiekt bez klucza subtitle.

Zanim przyjmiesz ten kontrakt, ustal, czy liczby rzeczywiście mogą współdzielić jeden przypadek Double. Apple opisuje Double jako 64-bitowy typ zmiennoprzecinkowy i udostępnia inicjalizatory konwersji dokładnej, ponieważ zwykłe konwersje mogą zaokrąglać. Jeśli Twój model wymaga dokładnego zachowania dużych liczb całkowitych, przed ich konwersją wybierz jawną politykę obsługi liczb. Dodanie przypadku dla liczby całkowitej wymaga też reguły dekodowania, ponieważ zwykłe wartości liczbowe nie zawierają informacji o tożsamości przypadków wyliczenia Swift. Zobacz Double.

Jak zaimplementować rekurencyjne wyliczenie?

Użyj aktualnego środowiska Swift na komputerze Mac z dostępnym Foundation. Kolejne trzy bloki kodu, w podanej kolejności, składają się na jeden kompletny przykład na najwyższym poziomie: wyliczenie, funkcje konwersji i wykonywalną procedurę kontrolną. Wszystkie przykładowe wartości tworzone są w pamięci. Swift nie był dostępny w środowisku badawczym, więc przykład został przejrzany, ale nie uruchomiony.

Dekoder najpierw sprawdza null, a następnie próbuje dekodować obsługiwane typy proste. Apple w SingleValueDecodingContainer udostępnia te operacje, w tym decodeNil(). Lokalna funkcja pomocnicza traktuje tylko niezgodność typu jako powód do wypróbowania kolejnego typu prostego; pozostałe błędy dekodowania są przekazywane dalej.

import Foundation

indirect enum PlainValue: Codable, Equatable {
    case null
    case bool(Bool)
    case number(Double)
    case string(String)
    case array([PlainValue])
    case object([String: PlainValue])

    init(from decoder: Decoder) throws {
        let scalar = try decoder.singleValueContainer()

        if scalar.decodeNil() {
            self = .null
            return
        }

        func matching<T: Decodable>(_ type: T.Type) throws -> T? {
            do {
                return try scalar.decode(type)
            } catch DecodingError.typeMismatch(_, _) {
                return nil
            }
        }

        if let value = try matching(Bool.self) {
            self = .bool(value)
            return
        }
        if let value = try matching(String.self) {
            self = .string(value)
            return
        }
        if let value = try matching(Double.self) {
            guard value.isFinite else {
                throw DecodingError.dataCorrupted(.init(
                    codingPath: decoder.codingPath,
                    debugDescription: "Expected a finite number."
                ))
            }
            self = .number(value)
            return
        }

        var sequence: UnkeyedDecodingContainer?
        do {
            sequence = try decoder.unkeyedContainer()
        } catch DecodingError.typeMismatch(_, _) {
            sequence = nil
        }

        if var items = sequence {
            var values: [PlainValue] = []
            while !items.isAtEnd {
                values.append(try items.decode(PlainValue.self))
            }
            self = .array(values)
            return
        }

        self = .object(try scalar.decode([String: PlainValue].self))
    }

    func encode(to encoder: Encoder) throws {
        var container = encoder.singleValueContainer()

        switch self {
        case .null:
            try container.encodeNil()
        case .bool(let value):
            try container.encode(value)
        case .number(let value):
            guard value.isFinite else {
                throw EncodingError.invalidValue(value, .init(
                    codingPath: encoder.codingPath,
                    debugDescription: "Expected a finite number."
                ))
            }
            try container.encode(value)
        case .string(let value):
            try container.encode(value)
        case .array(let values):
            try container.encode(values)
        case .object(let values):
            try container.encode(values)
        }
    }
}

Każda gałąź kodowania zapisuje bezpośrednio swoją zawartość. Gałąź null jawnie wywołuje encodeNil(), a gałęzie kolekcji przekazują obsługę do implementacji kodowania tablic i słowników. Apple opisuje zarówno operacje kodowania typów prostych, jak i ogólne w SingleValueEncodingContainer, a także konformancję kolekcji w Encoding and Decoding Custom Types.

Wykrywanie tablic jest celowo oddzielone od dekodowania ich elementów. Żądanie kontenera bez kluczy może zgłosić niezgodność typu, gdy bieżąca wartość nie jest tablicą. Po rozpoznaniu tablicy każdy element dekodowany jest zwykłym try, więc błąd dotyczący elementu jest przekazywany dalej, zamiast powodować próbę niezwiązanego dekodowania obiektu. Udokumentowane zachowanie opisano w unkeyedContainer() i UnkeyedDecodingContainer.

Ta implementacja jest przeznaczona dla ścieżki kodowania JSON w Foundation i opisanego zestawu rodzajów wartości. Przed użyciem przetestuj każdy inny koder lub dekoder pod kątem tego samego kontraktu; sama konformancja z Codable nie gwarantuje identycznego zachowania w różnych formatach reprezentacji.

Jak konwertować na obiekty Foundation całkowicie w pamięci?

Foundation udostępnia dwa przydatne punkty styku: JSONEncoder tworzy Data z wartości, którą można zakodować, a JSONDecoder odtwarza wartość, którą można zdekodować, z Data. JSONSerialization konwertuje między tą reprezentacją pośrednią a obiektami Foundation. Połączenie tych udokumentowanych operacji daje następujący most działający w pamięci. Zobacz JSONEncoder, JSONDecoder i JSONSerialization.

func foundationObject(from value: PlainValue) throws -> Any {
    let bytes = try JSONEncoder().encode(value)
    return try JSONSerialization.jsonObject(
        with: bytes,
        options: [.fragmentsAllowed]
    )
}

func plainValue(from object: Any) throws -> PlainValue {
    let bytes = try JSONSerialization.data(
        withJSONObject: object,
        options: [.fragmentsAllowed]
    )
    return try JSONDecoder().decode(PlainValue.self, from: bytes)
}

Pośrednie Data pozostaje w pamięci. Te funkcje wykonują serializację; nie są rzutowaniami bez kopiowania między wyliczeniem a kolekcjami Foundation. Utrzymuj konwersję na granicy, przy której funkcja aplikacji desktopowej rzeczywiście potrzebuje zwykłego grafu obiektów, zachowując wyliczenie do operacji na typowanym modelu.

Obie opcje .fragmentsAllowed mają znaczenie, ponieważ wartość rekurencyjna może mieć skalarny element główny. Główny ciąg znaków, wartość logiczna, liczba lub null wymagają takiego samego traktowania jak wartość zagnieżdżona. Foundation dokumentuje to zachowanie osobno dla ReadingOptions.fragmentsAllowed i WritingOptions.fragmentsAllowed.

Funkcja odwrotna przyjmuje Any, ponieważ jest to typ wejściowy API Foundation, ale jej obsługiwany zakres nadal określa powyższy kontrakt. Błędy konwersji są przekazywane wywołującemu. Nie zastępuj nieudanej konwersji pustym obiektem ani nie usuwaj elementów, których nie udało się przekonwertować, bo spowodowałoby to cichą zmianę modelu.

Jakie kontrole potwierdzają zgodność?

Sama konwersja w obie strony może nie ujawnić pierwotnego problemu: syntezowane kodowanie wyliczenia może poprawnie przeprowadzić konwersję własnej opakowanej struktury. Sprawdź zarówno równość wyliczenia, jak i zwykły kształt danych. Poniższa procedura tworzy niezależny graf Foundation, weryfikuje oczekiwany model wyliczenia, sprawdza rzeczywiste klucze elementu głównego wyniku i obsługuje każdą kategorię główną.

func runExample() throws {
    let plain: [String: Any] = [
        "title": "Tile preview",
        "enabled": true,
        "spacing": 8.5,
        "subtitle": NSNull(),
        "tiles": [
            ["label": "Circle", "size": 24.0],
            ["label": "Square", "size": 32.0]
        ]
    ]

    let expected: PlainValue = .object([
        "title": .string("Tile preview"),
        "enabled": .bool(true),
        "spacing": .number(8.5),
        "subtitle": .null,
        "tiles": .array([
            .object([
                "label": .string("Circle"),
                "size": .number(24)
            ]),
            .object([
                "label": .string("Square"),
                "size": .number(32)
            ])
        ])
    ])

    let model = try plainValue(from: plain)
    precondition(model == expected)

    let output = try foundationObject(from: model)
    guard let object = output as? [String: Any] else {
        preconditionFailure("Expected a plain object root.")
    }
    precondition(Set(object.keys) == Set(plain.keys))
    precondition(object["title"] as? String == "Tile preview")
    precondition(object["subtitle"] is NSNull)
    precondition((object["tiles"] as? [Any])?.count == 2)

    let restored = try plainValue(from: output)
    precondition(restored == expected)

    let roots: [PlainValue] = [
        .null, .bool(false), .bool(true),
        .number(0), .number(1), .number(8.5),
        .string("1"), .array([]), .object([:]),
        expected
    ]
    for root in roots {
        let graph = try foundationObject(from: root)
        let decoded = try plainValue(from: graph)
        precondition(decoded == root)
    }

    do {
        _ = try foundationObject(from: .number(.infinity))
        preconditionFailure("Expected nonfinite number rejection.")
    } catch EncodingError.invalidValue(_, _) {
        // Expected: this model requires finite numbers.
    }

    print("All compatibility checks passed.")
}

try runExample()

Oczekiwany komunikat powodzenia to All compatibility checks passed. Kontrola kluczy głównych wykrywa dodane zewnętrzne opakowanie przypadku. Niezależny oczekiwany model wychwytuje błędny wybór przypadku, w tym pomylenie wartości logicznej z liczbą oraz tekstu wyglądającego jak liczba z samą liczbą. Kontrola null potwierdza, że subtitle pozostaje obecny, natomiast kontrole elementu głównego obejmują fragmenty skalarne i odróżniają puste tablice od pustych obiektów.

W przypadku istniejącego modelu desktopowego dostosuj tę sekwencję diagnostyczną do jego syntetycznych wartości: najpierw zweryfikuj oczekiwane wyliczenie, następnie sprawdź zwykły kształt danych, potem odtwórz wyliczenie, a na końcu przetestuj przypadki brzegowe. Nadaj wynikom pośrednim nazwy, aby nieudana kontrola wskazywała, która konwersja zmieniła wartość.

Jakie są ograniczenia tego podejścia?

Głównym ograniczeniem jest kontrakt liczbowy. Pojedynczy przypadek Double nie może zagwarantować dokładnego zachowania każdej liczbowej wartości Foundation. Gdy istotna jest dokładna konwersja liczby całkowitej, przed wprowadzeniem wartości do tego modelu sprawdź, czy można ją reprezentować, albo przeprojektuj przypadki liczbowe i zasady ich dekodowania. Pomyślna serializacja nie świadczy o dokładnym zachowaniu wartości liczbowej. Dokumentacja Apple dotycząca Double rozróżnia konwersje zaokrąglane i dokładne.

Przykład jawnie odrzuca również liczby nieskończone w obu kierunkach kodowania. Dzięki temu granice wyliczenia pozostają zgodne z udokumentowanymi ograniczeniami zwykłej reprezentacji Foundation. Rekurencyjna struktura nie oznacza nieograniczonej głębokości zagnieżdżenia, więc zachowaj rozsądny rozmiar drzew modelu syntetycznego i pozwól, by błędy konwersji docierały do wywołującej funkcji.

Gotowym wynikiem dla zadania z podglądem układu jest typowane rekurencyjne wyliczenie, którego własne kodowanie zachowuje zwykłe ciągi znaków, wartości logiczne, skończone liczby, wartości null, tablice i obiekty z kluczami tekstowymi. Przy każdej zmianie przypadków lub logiki konwersji używaj kontroli kształtu danych wraz z kontrolami konwersji w obie strony; razem sprawiają, że zamierzona reprezentacja jest konkretna i możliwa do sprawdzenia.

Źródła