LoginSignup
46

More than 5 years have passed since last update.

Swift4のCodableに対応した、独自のDecoder(CSVDecoder)を実装してみよう

Last updated at Posted at 2017-06-09

はじめに

Swift4で追加された Codable
実はエンコード・デコードできるのはJSON相手だけではありません。
FoundationにはPropertyListEncoderPropertyListDecoderも存在します。
また、 Decoder Encoder プロトコルに適合した独自型を実装すれば、Codableの恩恵により、任意のデータ変換処理を型安全に取り扱うことができます。

というわけで今回は、 Decoder プロトコルの挙動理解を兼ねて、CSV(カンマ区切りテキスト)のデコーダを実装してみました。
既存の JSONEncoder/Decoder PlistEncoder/Decoder のコードを参考にしつつ、CSVの処理へと落とし込んでいます。
記述を単純化するため、拡張機能や例外処理は省略しています。「カンマを含む文字列を表現できない」「要素が足りない列があるとクラッシュする」等の問題もあり、このまま実用できるものではありません。
が、そのぶんDecoder周りの構造が見えやすいサンプルになっているかなと。

参考:
swift/JSONEncoder.swift at master · apple/swift
swift/PlistEncoder.swift at master · apple/swift

実装詳細

ゴール

name,age,isMan
ほげ,25,true
ふが,100,false

上記のような、1行目がタイトル、2行目以降がデータになっているCSVデータを、

struct Row: Codable {
    let name: String
    let age: Int
    let isMan: Bool
}

このようなstructのArray [Row] へと変換する。

コード

本体

CSVDecoder.swift
import Foundation

//===----------------------------------------------------------------------===//
// CSV Decoder
//===----------------------------------------------------------------------===//

/// `CSVDecoder` facilitates the decoding of CSV into semantic `Decodable` types.
/// structでなくclassなのは、JSONDecoderやPlistDecoderの場合にはoptionを適宜切り替えつつdecodeしていけるようにだと思う
/// 実際の Decoder プロトコルへの適合は、fileprivateな _CSVRowDecoder 型を通して行う。
open class CSVDecoder {
    // MARK: - Constructing a CSV Decoder

    public init() {}

    open func decode<T : Decodable>(_ type: T.Type, from csv: String) throws -> [T] {
        var rows = csv.components(separatedBy: .newlines)
        let titleRow = rows.removeFirst()
        return try rows.map {
            let decoder = _CSVRowDecoder(titleRow: titleRow, valueRow: $0)
            return try T(from: decoder)
        }
    }
}

fileprivate class _CSVRowDecoder: Decoder {
    let titles: [String]
    let values: [String]

    var codingPath: [CodingKey?] { return [] }

    /// Contextual user-provided information for use during encoding.
    var userInfo: [CodingUserInfoKey : Any] { return [:] }

    // MARK: - Initialization

    /// Initializes `self` with the given top-level container and options.
    init(titleRow: String, valueRow: String) {
        titles = titleRow.split(separator: ",").map { String($0) }
        values = valueRow.split(separator: ",").map { String($0) }
    }

    // MARK: - Coding Path Operations

    /// T(from:)内で、各カラムのCodingPathをプロパティに接続するために呼び出されるのはこのメソッド
    func container<Key>(keyedBy type: Key.Type) throws -> KeyedDecodingContainer<Key> {
        let container = _CSVKeyedDecodingContainer<Key>(referencing: self)
        //注: 型消去
        return KeyedDecodingContainer(container)
    }

    func unkeyedContainer() throws -> UnkeyedDecodingContainer {
        //CSVだと関係ないけど、ネストを加味して考える場合、ここで対象がdictionayあるいはarrayだとか色々見てやる必要がある

        throw DecodingError.valueNotFound(UnkeyedDecodingContainer.self,
                                          DecodingError.Context(codingPath: self.codingPath,
                                                                debugDescription: "Cannot get unkeyed decoding container -- found null value instead."))
    }

    func singleValueContainer() throws -> SingleValueDecodingContainer {
        throw DecodingError.typeMismatch(SingleValueDecodingContainer.self,
                                         DecodingError.Context(codingPath: self.codingPath,
                                                               debugDescription: "Cannot get single value decoding container -- found keyed container instead."))
    }
}

// MARK: Decoding Containers

fileprivate struct _CSVKeyedDecodingContainer<K : CodingKey> : KeyedDecodingContainerProtocol {
    typealias Key = K

    // MARK: Properties

    /// A reference to the decoder we're reading from.
    let decoder: _CSVRowDecoder

    /// Data we're reading from.
    let columns: [String : String]

    /// The path of coding keys taken to get to this point in decoding.
    var codingPath: [CodingKey?]

    // MARK: - Initialization

    /// Initializes `self` by referencing the given decoder.
    init(referencing decoder: _CSVRowDecoder) {
        self.decoder = decoder
        self.codingPath = decoder.codingPath
        columns = Dictionary(uniqueKeysWithValues: zip(decoder.titles, decoder.values))
    }

    // MARK: - KeyedDecodingContainerProtocol Methods

    var allKeys: [Key] {
        return columns.keys.flatMap { Key(stringValue: $0) }
    }

    func contains(_ key: Key) -> Bool {
        return columns[key.stringValue] != nil
    }

    func decodeIfPresent(_ type: Bool.Type, forKey key: Key) throws -> Bool? {
        // ここらへん、既存コードでは `unbox` というメソッドを通して具体処理を切り離してるんだけど、今回はダイレクトに書く
        // KeyedDecodingContainerProtocolとSingleValueDecodingContainerの具体処理を共通化したいとき、 `unbox` メソッドが効いてくるんだと思う
        return columns[key.stringValue].flatMap { Bool($0) }
    }

    func decodeIfPresent(_ type: Int.Type, forKey key: Key) throws -> Int? {
        return columns[key.stringValue].flatMap { Int($0) }
    }

    func decodeIfPresent(_ type: Int8.Type, forKey key: Key) throws -> Int8? {
        return columns[key.stringValue].flatMap { Int8($0) }
    }

    func decodeIfPresent(_ type: Int16.Type, forKey key: Key) throws -> Int16? {
        return columns[key.stringValue].flatMap { Int16($0) }
    }

    func decodeIfPresent(_ type: Int32.Type, forKey key: Key) throws -> Int32? {
        return columns[key.stringValue].flatMap { Int32($0) }
    }

    func decodeIfPresent(_ type: Int64.Type, forKey key: Key) throws -> Int64? {
        return columns[key.stringValue].flatMap { Int64($0) }
    }

    func decodeIfPresent(_ type: UInt.Type, forKey key: Key) throws -> UInt? {
        return columns[key.stringValue].flatMap { UInt($0) }
    }

    func decodeIfPresent(_ type: UInt8.Type, forKey key: Key) throws -> UInt8? {
        return columns[key.stringValue].flatMap { UInt8($0) }
    }

    func decodeIfPresent(_ type: UInt16.Type, forKey key: Key) throws -> UInt16? {
        return columns[key.stringValue].flatMap { UInt16($0) }
    }

    func decodeIfPresent(_ type: UInt32.Type, forKey key: Key) throws -> UInt32? {
        return columns[key.stringValue].flatMap { UInt32($0) }
    }

    func decodeIfPresent(_ type: UInt64.Type, forKey key: Key) throws -> UInt64? {
        return columns[key.stringValue].flatMap { UInt64($0) }
    }

    func decodeIfPresent(_ type: Float.Type, forKey key: Key) throws -> Float? {
        return columns[key.stringValue].flatMap { Float($0) }
    }

    func decodeIfPresent(_ type: Double.Type, forKey key: Key) throws -> Double? {
        return columns[key.stringValue].flatMap { Double($0) }
    }

    func decodeIfPresent(_ type: String.Type, forKey key: Key) throws -> String? {
        return columns[key.stringValue]
    }

    func decodeIfPresent(_ type: Data.Type, forKey key: Key) throws -> Data? {
        return columns[key.stringValue]?.data(using: .utf8)
    }

    func decodeIfPresent<T : Decodable>(_ type: T.Type, forKey key: Key) throws -> T? {
        // Date等のデコード方法(timeInterval, etc)を動的に指定するのもここらへん作り込む
        // cf. https://github.com/apple/swift/blob/dbe77601f348583eb892a5b9fff09327e23b00c2/stdlib/public/SDK/Foundation/JSONEncoder.swift#L30-L72

        // その他Decodableな型に対応するにはSingleValueDecodingContainerの実装が必要
        // cf. https://github.com/apple/swift/blob/dbe77601f348583eb892a5b9fff09327e23b00c2/stdlib/public/SDK/Foundation/JSONEncoder.swift#L1456
        throw DecodingError.dataCorrupted(
            DecodingError.Context(codingPath: codingPath,
                                  debugDescription: "SingleValueDecodingContainerはとりあえず置いとく")
        )
    }

    func nestedContainer<NestedKey>(keyedBy type: NestedKey.Type, forKey key: Key) throws -> KeyedDecodingContainer<NestedKey> {
        throw DecodingError.dataCorrupted(
            DecodingError.Context(codingPath: codingPath,
                                  debugDescription: "CSVでnestは考えない")
        )
    }

    func nestedUnkeyedContainer(forKey key: Key) throws -> UnkeyedDecodingContainer {
        throw DecodingError.dataCorrupted(
            DecodingError.Context(codingPath: codingPath,
                                  debugDescription: "CSVでnestは考えない")
        )
    }

    func superDecoder() throws -> Decoder {
        throw DecodingError.dataCorrupted(
            DecodingError.Context(codingPath: codingPath,
                                  debugDescription: "CSVでnestは考えない")
        )
    }

    func superDecoder(forKey key: K) throws -> Decoder {
        throw DecodingError.dataCorrupted(
            DecodingError.Context(codingPath: codingPath,
                                  debugDescription: "CSVでnestは考えない")
        )
    }
}

usage

let csv = """
name,age,isMan
ほげ,25,true
ふが,100,false
"""

let decoder = CSVDecoder()
let rows = try! decoder.decode(Row.self, from: csv)
dump(rows)

dump結果

▿ 2 elements
  ▿ CodableExample.Row
    - name: "ほげ"
    - age: 25
    - isMan: true
  ▿ CodableExample.Row
    - name: "ふが"
    - age: 100
    - isMan: false

所感

JSONDecoderAny の闇に対応しなきゃいけない関係上、結構読みづらいですが、本質的に必要な処理はそこまで複雑ではないです。
一度こういう単純なフォーマットで実装してみると、Codableの挙動を理解しやすくなりますね。

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
46