🦌

[Tips] 既知の値に対してenumを使うかstructを使うか

2023/03/12に公開
4

はじめに

Swiftで、既知の値を表現する方法について、時々コードレビューで指摘することがあるので、記事にしておこうと思います。

この記事では、既知の値に対して以下の3種類の方法を紹介します。

  • enumで実装する方法
  • structで実装する方法
  • enum + structで実装する方法

Swiftに慣れている人にとっては当たり前な内容かもしれませんが、知らない人も一定数いると感じたので、ちょっとしたTipsですが紹介します。

題材のコード

今回は題材として以下のような設定画面を考えます。

この設定画面には各セルに対応するItemがあり、SwiftUIのListを使って、以下のように書かれているとします。

let itmes: [Items] = ...
List(item) { item in
    // itemを使って、セルを作る
}

セルの種類は以下の3種類とします。

  • アカウント
  • お問い合わせ
  • 利用規約

また、各セルは以下の3つの値で構成されるとします。

  • タイトル
  • アイコン画像 (今回の場合はSF Symbolsの名前)
  • 詳細

さて、このような場合にItemをどう定義するかです。

1. enumで実装する方法

まずは「セルは3種類」という性質に注目してみましょう。
すると、enumを使って、次のようなコードが思いつきます。

enum Item: Identifiable {
    case account, report, term

    var id: String {
        switch self {
        case .account: return "account"
        case .report: return "report"
        case .term: return "term"
        }
    }

    var title: String {
        switch self {
        case .account: return "アカウント設定"
        case .report: return "お問い合わせ"
        case .term: return "利用規約"
        }
    }

    var iconName: String {
        switch self {
        case .account: return "person"
        case .report: return "exclamationmark.bubble"
        case .term: return "doc"
        }
    }

    var description: String? {
        switch self {
        case .account: return nil
        case .report: return "バグ報告/要望はこちらから"
        case .term: return nil
        }
    }
}

悪くないと思いますが、少しswitch文が多いことが気になります。
例えば、accountのセルにどのような値が設定されているのか確認したいときは各プロパティのswitch文を全て読む必要があり、ちょっと辛いです。

2. structで実装する方法

では次にstructで実装してみましょう。

struct Item: Identifiable {
    let id: String
    let title: String
    let iconName: String
    let description: String?
}

extension Item {
    static let account = Item(
        id: "account",
        title: "アカウント設定",
        iconName: "person",
        description: nil
    )

    static let report = Item(
        id: "report",
        title: "お問い合わせ",
        iconName: "exclamationmark.bubble",
        description: "バグ報告/要望はこちらから"
    )

    static let term = Item(
        id: "term",
        title: "利用規約",
        iconName: "doc",
        description: nil
    )
}

static letで定義しているのはenumと同じような書き心地を実現するためです。
こうすることで、比較的enumと似たように書けます。

// こんな風に書けたり、
let item: Item = .account 
// こんな風に書けます
if item == .account {
    // ...
}

さて、structにすることの何が嬉しいのでしょうか?
それはさっき述べたように、accountの値を確認する時、各プロパティがどのような値を持っているか一目でわかることです。

static let account = Item(
    id: "account",
    title: "アカウント設定",
    iconName: "person",
    description: nil
)

このコードを読むだけで、accountというItemの値を一度に確認できます。
enumの場合、同様のことを確認するのに全てのswitch文を読む必要があったことを思うと、可読性がグッと上がったと感じるのではないでしょうか。

structの弱点

さて、上のstructの例をみると、「struct最高!」と感じるかもしれませんが、上の方法は完全ではありまん。
もちろんデメリットもあります。

それは「switch文で全てを網羅していることを保証できない」という点です。
enumだった場合は

switch item { 
case .account: //...
case .report: //...
case .term: //...
}

と書けたのですが、structの場合は

switch item { 
case .account: //...
case .report: //...
case .term: //...
default: // ← これが必要になる
}

とdefaultのケースを書く必要が出てきます。
もちろん、これが重要かどうかはケースバイケースですが、無視することはできない弱点です。

もしこのデメリットを受け入れることができない場合、enumとstructの両方を使った、ハイブリッドな方法が考えられます。

3. enum + structで実装する方法

Itemをenumにし、各caseの値をItem.Modelとしてstructで定義する方法です。

enum Item: Identifiable {
    case account, report, term

    struct Model {
        let id: String
        let title: String
        let iconName: String
        let description: String?
    }

    var id: String { model.id }

    var model: Model {
        switch self {
        case .account: return .account
        case .report: return .report
        case .term: return .term
        }
    }
}

extension Item.Model {
    static let account = Item.Model(
        id: "account",
        title: "アカウント設定",
        iconName: "person",
        description: nil
    )

    static let report = Item.Model(
        id: "report",
        title: "お問い合わせ",
        iconName: "exclamationmark.bubble",
        description: "バグ報告/要望はこちらから"
    )

    static let term = Item.Model(
        id: "term",
        title: "利用規約",
        iconName: "doc",
        description: nil
    )
}

こうすることで、switch文で全ケース網羅していることを保証しつつ、関連する値を一箇所にまとめることができます。

switch item { 
case .account: //...
case .report: //...
case .term: //... 
} // defaultがいらない!
// accountの設定も一目でわかる!
static let account = Item.Model(
    id: "account",
    title: "アカウント設定",
    iconName: "person",
    description: nil
)

もちろん、各値にアクセスする際はmodelを経由する必要がありますが、これくらいは必要経費と感じるだけのメリットがある気がします。

// modelを経由する必要がある
let title = item.model.title

どの方法を使うべきか?

自分がコードを書く時は以下のように考えます。

  1. とりあえずenumで書く
  2. enumのcomputed property内でswitch文が増えたら、structにすることを検討する
  3. switch文で全てのケースを網羅していることを保証する必要がある場合は、enum + structにする

おわりに

この記事では、既知の値の表現方法について、紹介しました。
今回紹介したどの方法を使うかはケースバイケースかと思います。
ただ、今回紹介した方法を知っておくと、コードを書くときの良い選択肢になるのではないかと思います。

以上、ちょっとしたTipsの紹介でした。

(追記) 4. struct + enumで実装する方法

Twitterでおもちメタルさんが共有してくれた内容です。
https://twitter.com/omochimetaru/status/1634884483890692101?s=20

なるほど、これはとても良いアイデアだと思います。
今までの例を用いるとこんな感じになると思います。

struct Item: Identifiable {
    let id: String
    let kind: Kind // ここにkindが追加されている
    let title: String
    let iconName: String
    let description: String?

    enum Kind {
        case account, report, term
    }
}

extension Item {
    static let account = Item(
        id: "account",
        kind: .account,
        title: "アカウント設定",
        iconName: "person",
        description: nil
    )

    static let report = Item(
        id: "report",
        kind: .report,
        title: "お問い合わせ",
        iconName: "exclamationmark.bubble",
        description: "バグ報告/要望はこちらから"
    )

    static let term = Item(
        id: "term",
        kind: .term,
        title: "利用規約",
        iconName: "doc",
        description: nil
    )
}

このアイデアの良いところは、「3. enum + structで実装する方法」で必要だった、modelのアクセスが不要になるところです。

// modelのアクセスが必要ない!
let title = item.title

とても良さそうですね!
ちょっと注意が必要そうなのは、kindとItemの定義が1:1の対応を取れているかどうか若干気をつける必要がありそうです。
また、kindにcaseを追加してもコンパイルエラーとして検出できないのもちょっとだけ気にはなります。
とはいえ、これらはこじつけみたいな懸念点であって、全体的にメリットの大きいアイデアだと思います。
どうしても気になるならユニットテストを書くなどすればいいとも思います。
これは試したことがなかったので、今後自分の開発でも選択肢としてもっておきたいと思います!

Discussion

rizumitarizumita

Modelへのアクセスを不要にしたいのでしたら「3. enum + structで実装する方法 & KeyPathによるDynamic Member Lookup」で可能ですね。modelを隠蔽できると思います。

@dynamicMemberLookup enum Item: Identifiable {
…略
    var id: String { model.id } // idは必要
    subscript<U>(dynamicMember keyPath: KeyPath<Model, U>) -> U {
       model[keyPath: keyPath]
    }

みたいな感じでしょうか。

matsujimatsuji

そうですね、dynamicMemberLookupもひとつの手だと思います。
ただ、function呼び出しには使えなかったり、アクセスしてるプロパティを辿るのに若干手間がかかったり、メリットとデメリットだと今回の場合、若干デメリットが勝つかなと思います。
もちろんケースバイケースなので、modelへのアクセスが多すぎる場合などは検討してみてもいいですが、若干敷居は高い気がします。

rizumitarizumita

「既知の値に対して」ということでしたので提案しました。より複雑な関数呼びだしの場合はstaticでのModelインスタンスの保持の形は使いにくい可能性があるので、enumでの表現は避けるかもしれません。もしくはModelでのメソッドの実装を以下のようにすればDynamic Member Lookup でitem.a()とすることはできます。

var a: () -> ()
matsujimatsuji

確かに、簡単な関数ならクロージャー形式として持たせるのはアリかもしれませんね。
なるほど、ありがとうございます。
dynamicMemberLookupも選択肢として良さそうですね!