appbrew Tech Blog

appbrewのエンジニアチームの日々です

Property Wrapperを使ってDecodableにデフォルト値を与える

こんにちは

AppBrewのはるふです。 この記事は Swift Advent Calendar 2020 の25日目の記事になります!

メリークリスマス!

毎年アドカレ参加しつつも「SwiftじゃなくてiOSやん」という内容を書いていることが多いのですが、今年は弊社のメンバー(@r_plus)から教えて頂いて実際に使っているSwiftネタがあったので紹介します。

Decodable

Swiftではstruct/classがDecodableなメンバだけを持っている場合、Decodableに準拠させるだけで特別な設定も何もなくJSONなどからデコードできるようになります。

struct Spot: Decodable {
    var id: Int
    var name: String
}

Optionalにしておけば、存在しないプロパティも表現することができます。

struct Spot: Decodable {
    var id: Int
    var name: String
    var isGoodPlace: Bool?
}

{ "id": 300, "name": "AppBrew" }

{ "id": 300, "name": "AppBrew", "isGoodPlace": true }

どちらのJSONもデコードすることができ、プロパティが存在しない場合はnilになります。

※ Codableについてより詳しくは以前Qiitaに書いたことがあるのでご参照ください。

Codableについて色々まとめた[Swift4.x] - Qiita

デフォルト値に落としたい

ただし、Booleanの場合などは特にnilではなくデフォルト値にしたいこともあります。

ひとつの方法として、DTO(Data Transfer Object)と内部データとで別々にオブジェクトを定義して、DTOはOptionalに、内部データではnon-Optionalにして変換するのも考えられます。

しかしもし共通のオブジェクトを使う場合は、以下のような手動実装をする必要があります。

struct Spot: Decodable {
    var id: Int
    var name: String
    var isGoodPlace: Bool

    enum CodingKeys: String, CodingKey {
        case id
        case name
        case isGoodPlace
    }

    init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)
        id = try container.decode(Int.self, forKey: .id)
        name = try container.decode(String.self, forKey: .name)
        isGoodPlace = (try? container.decode(Bool.self, forKey: .isGoodPlace)) ?? false
    }
}

これだと、高々デフォルト値のためにコード量が数倍になり、メンテコストも上がってしまいます。

@Zero - KeyedCodable

KeyedCodableというOSSがあり、Codableの簡略化のための実装をいくつも提供してくれています。

github.com

この中の @Zero というものがあり、以下のようにアノテーションしておくとDecode時の初期値として0が入ります。

{ "name": "Jan" }

struct ZeroTestCodable: Codable {
    var name: String                    // Jan
    
    @Zero var secondName: String        // "" - empty string
    @Zero var numberOfChildren: Int     // 0
    @Zero var point: Point              // Point(x: 0.0, y: 0.0)
}

struct Point: Codable {
    let x: Float
    let y: Float
}

0以外入れられないのは難点なんですが、かなりかっこよく実装を省略できています。

※ Swift 5.1で導入されたProperty Wrappersという機能を使っています

EmptyDecoded

LIPSでは必要な機能のみ、EmptyDecoded として独自に拡張させて入れています。

まずデフォルト値を0の代わりに、以下のような空のイニシャライザの結果として定義しました。

public protocol EmptyInitializable {
    init()
}
extension Int: EmptyInitializable {}
extension String: EmptyInitializable {}
extension Array: EmptyInitializable {}
extension Bool: EmptyInitializable {}

これはIntでは0、Arrayでは空配列、Boolではfalseになっています。 心配ではあるので自動テストで確認しています。

これで独自の型について独自にデフォルト値を定義しつつ、もとからある型に対しても大して負担なく初期値を定義できます。 (場合により切り替えられないのは相変わらず難点ではあります)

PropertyWrapperは以下のように定義します。

@propertyWrapper
public struct EmptyDecoded<T: Decodable & EmptyInitializable>: Decodable {
   public var wrappedValue: T

    public init(wrappedValue: T) {
        self.wrappedValue = wrappedValue
    }

    public init(from decoder: Decoder) throws {
        guard let container = try? decoder.singleValueContainer(),
            let value = try? container.decode(T.self) else {
                wrappedValue = T()
                return
        }
        wrappedValue = value
    }
}
extension EmptyDecoded: Equatable where T: Equatable {}
extension EmptyDecoded: Hashable where T: Hashable {}

extension KeyedDecodingContainer {
    public func decode<T>(_ type: EmptyDecoded<T>.Type, forKey key: K) throws -> EmptyDecoded<T> where T: Decodable & EmptyInitializable {
        try EmptyDecoded<T>(from: superDecoder(forKey: key))
    }
}

すると以下の実装だけで、先程のメンバ抜けJSONをデコードすることができるようになります!

struct Spot: Decodable {
    var id: Int
    var name: String
    @EmptyDecoded
    var isGoodPlace: Bool
}

propertyWrapperなのでletなかったり、Equatable自動実装のために別で準拠させていたり、そういう制約もあります。

まとめたGistはこちら→EmptyDecoded.swift · GitHub

Javaやってると

Gsonみたいな @SerializedName("is_good_place") とか、もっと色々見通しよく名前やデフォルト値をかける日が来ると楽で良いですね。

We’re Hiring

(最後にちょっとだけ会社の宣伝を失礼します) LIPSではエンジニアを募集しています! 言語に関わらず、プロダクトグロースに興味がある方、経験のある方是非お話しましょう!

www.wantedly.com

www.wantedly.com