0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

[Swift] Swift列挙型(enum)を使用する際の問題の回避

Last updated at Posted at 2021-06-29

※この記事はSwift by Sundellの内容を日本語に翻訳したものです。

概要

Swift の enum の実装は、間違いなく、この言語が提供しなければならない最も人気があり、強力な機能の1つです。Swift列挙型は、整数ベースの定数の単純な列挙型をはるかに超えており、関連する値や高度なパターンマッチングなどをサポートしているため、さまざまな種類の問題を解決するための優れた候補になります。

ただし、特定の種類の列挙型の場合は、避けたほうがよい場合があります。これは、トリッキーな状況に陥ったり、コードが意図したよりも「慣用的」でないと感じたりする可能性があるためです。いくつかのそのようなケースと、Swift の他の言語機能のいくつかを使用してそれらをリファクタリングする方法を見てみましょう。

価値がないことを表す

例として、ポッドキャスト アプリに取り組んでおり、列挙型を使用してアプリがサポートするさまざまなカテゴリを実装したとします。その列挙型には現在、各カテゴリのケースと、カテゴリをまったく持たないポッドキャストに使用される 2 つのやや特殊なケース (none)、およびすべてのカテゴリを一度に参照するために使用できるカテゴリ(all) が含まれています。

.swift
extension Podcast {
    enum Category: String, Codable {
        case none
        case all
        case entertainment
        case technology
        case news
        ...
    }
}

次に、フィルタリングなどの機能を実装するときに、上記の列挙型を使用して、ユーザーが UI 内(Filterモデル内にカプセル化されている)で選択した値Categoryに対してパターンマッチングを実行できます。

.swift
extension Podcast {
    func matches(filter: Filter) -> Bool {
        switch filter.category {
        case .all, category:
            return name.contains(filter.string)
        default:
            return false
        }
    }
}

一見すると、上記の2つのコードは完全に正常に見えるかもしれません。しかし考えてみると、Swift にはオプショナルのような目的に合わせて作られた組み込みの言語機能があることを考えると、カテゴリの欠如を表す特定のケースnoneを現在追加しているという事実は、おそらく少し奇妙です。

したがって、代わりにPodcastモデルのcategoryプロパティをオプショナルに変更すると、欠落しているカテゴリを全く問題なく表すことができるようになります。さらに、そのような欠落値を処理する場合、Swift のオプショナルがサポートするすべての機能 (if letステートメントなど) を利用できるようになりました。

.swift
struct Podcast {
    var name: String
    var category: Category?
    ...
}

上記の変更について本当に興味深いのは、Podcast.Category値で以前使用していた網羅的なswitchステートメントが、以前と同じように機能し続けることです。これは、オプショナル型自体も実際には値の欠如を表すためにnoneケースを使用する列挙型であることが判明したためです。—つまり、次の関数のようなコードは完全に変更されないままである可​​能性があります(引数をオプショナルに変更することを除いて)。

.swift
func title(forCategory category: Podcast.Category?) -> String {
    switch category {
    case .none:
    return "Uncategorized"
    case .all:
        return "All"
    case .entertainment:
        return "Entertainment"
    case .technology:
        return "Technology"
    case .news:
        return "News"
    ...
    }
}

上記は、オプショナルがパターンマッチングコンテキスト(switchステートメントなど)で使用されるときにオプショナルを自動的にフラット化するSwiftコンパイラの魔法のおかげで機能します。これにより、オプション型自体のケースと、独自のPodcast.Category列挙型内で定義されたケースの両方をすべて同じステートメント内で処理できます。

これらは上記のタイプの状況では機能的に同じであるため、必要に応じてcase nilの代わりにcase .noneを使用することもできます。

ドメイン固有の列挙

次に、Podcast.Category列挙型のallの場合に注意を向けましょう。これも、考えてみると少し奇妙です。結局、ポッドキャストは同時にすべてのカテゴリに属する​​ことはできないため、そのallケースはフィルタリングのコンテキスト内でのみ意味があります。

したがって、そのケースをメインCategory列挙型に含めるのではなく、フィルタリングのドメインに固有の専用の型を作成しましょう。そうすれば、関心の分離を非常にうまく行うことができ、ネストされた型を使用しているため、新しい列挙型に同じCategoryネームを使用させることができますが、今回はFilterモデル内でネストされます— 次のように:

.swift
extension Filter {
    enum Category {
        case any
        case uncategorized
        case specific(Podcast.Category)
    }
}

ここでもオプショナルのアプローチを使用することを選択できたことは注目に値します。nilanyまたはuncategorizedのいずれかを表しますが、この場合は2つの候補が存在する可能性があるため、ここでは専用のケースを使用することで、間違いなく意図をより明確にしています。

上記のアプローチの本当に優れている点は、Swiftのパターンマッチング機能を使用してフィルタリングロジック全体を実装できることです。つまり、フィルタリングされたカテゴリをオンにしてから、where句を使用して各ケースに追加のロジックをアタッチします。

.swift
extension Podcast {
    func matches(filter: Filter) -> Bool {
        switch filter.category {
        case .any where category != nil,
             .uncategorized where category == nil,
             .specific(category):
            return name.contains(filter.string)
        default:
            return false
        }
    }
}

上記のすべての変更が行われたので、メインのPodcast.Category列挙型からnoneallケースを削除できます。これにより、アプリがサポートする各カテゴリのはるかに簡単なリストが残ります。

.swift
extension Podcast {
    enum Category: String, Codable {
        case entertainment
        case technology
        case news
        ...
    }
}

カスタムケースとカスタムタイプ

Podcast.Categoryのような列挙型に関しては、(ある時点で)1回限りのケースを処理するために使用できる、または将来的にサーバーサイドに追加される可能性のあるケースを適切に処理することによって上位互換性を提供するために、ある種のカスタムケースを導入することは非常に一般的です。

これを実装する方法の1つは、値が関連付けられているケースを使用することです。この場合、次のように、カスタムカテゴリのRaw 値を表すStringを使用します。

.swift
extension Podcast {
    enum Category: Codable {
        case all
        case entertainment
        case technology
        case news
        ...
        case custom(String)
    }
}

関連付けられた値は他のコンテキストでは非常に便利ですが、残念ながら、これは実際にはそれらの1つではありません。まず、このようなケースを追加することで、列挙型を Stringでバックアップできなくなります。つまり、カスタムのエンコードとデコードのコード、およびインスタンスを Raw文字列との間で変換するためのロジックを作成する必要があります。

そこで、代わりにCategory列挙型をRawRepresentable構造体に変換することで、別のアプローチを検討しましょう。これにより、Swiftの組み込みロジックを利用して、このようなタイプの文字列変換をエンコード、デコード、および処理できます。

.swift
extension Podcast {
    struct Category: RawRepresentable, Codable, Hashable {
        var rawValue: String
    }
}

必要なカスタム文字列からCategoryインスタンスを自由に作成できるようになったため、追加のコードを必要とせずに、カスタムカテゴリと将来のカテゴリの両方を簡単にサポートできます。ただし、コードの下位互換性を維持し、組み込みの現在知られているカテゴリを簡単に参照できるようにするために、これらすべてを実現する静的APIを使用して新しいタイプを拡張しましょう。

.swift
extension Podcast.Category {
    static var entertainment: Self {
        Self(rawValue: "entertainment")
    }

    static var technology: Self {
        Self(rawValue: "technology")
    }

    static var news: Self {
        Self(rawValue: "news")
    }
    
    ...

    static func custom(_ id: String) -> Self {
        Self(rawValue: id)
    }
}

上記の変更では、ある程度の追加コードを追加する必要がありましたが、最終的には、ほぼ完全に下位互換性のある、はるかに柔軟なセットアップになりました。実際、私たちが行う必要がある唯一の更新は、Category値に対して徹底的な切り替えを実行するコードを行うことです。

たとえば、以前に調べた title関数は、そのような値をオンにして、特定のカテゴリに一致するタイトルを返しました。コンパイル時に各 Category値の完全なリストを取得できなくなったため、これらのタイトルを計算するために別のアプローチを使用する必要があります。この特定のケースでは、たとえば、これらの文字列を Localizable.stringsファイルに移動し、次のようにタイトルを解決する絶好の機会と見なすことができます。

.swift
func title(forCategory category: Podcast.Category?) -> String {
    guard let id = category?.rawValue else {
        return NSLocalizedString("category-uncategorized", comment: "")
    }

    let key = "category-\(id)"
    let string = NSLocalizedString(key, comment: "")

    // Handling unknown cases by returning a capitalized version
    // of their key as a fallback title:
    guard string != key else {
        return key.capitalized
    }

    return string
}

もう1つのオプションは、Categoryタイプ自体の中でローカライズされたタイトルを解決することです。また、オプションの`titleプロパティを追加して、アプリがまだネイティブにサポートしていないカスタムカテゴリのローカライズされたタイトルをサーバーが送信できるようにすることもできます。

自動命名静的プロパティ

簡単なボーナスのヒントとして、上記の構造体ベースのアプローチの1つの欠点は、静的プロパティごとに基になる文字列のRaw値を手動で定義する必要があることですが、これはSwiftの #functionキーワードを使用して解決できます。そのキーワードは、カプセル化関数が呼び出されている関数(またはこの場合はプロパティ)の名前に自動的に置き換えられるため、列挙型を使用する場合と同じ自動Raw値マッピングが得られます。

.swift
extension Podcast.Category {
    static func autoNamed(_ rawValue: StaticString = #function) -> Self {
        Self(rawValue: "\(rawValue)")
    }
}

上記のユーティリティを使用すると、組み込みの各カテゴリAPI内でautoNamed()を呼び出すだけで、SwiftがこれらのRaw値を自動的に入力します。

.swift
extension Podcast.Category {
    static var entertainment: Self { autoNamed() }
static var technology: Self { autoNamed() }
static var news: Self { autoNamed() }
    ...

    static func custom(_ id: String) -> Self {
        Self(rawValue: id)
    }
}

ただし、#functionベースの手法を使用する場合は、上記の静的プロパティの名前を変更しないように少し注意する必要があります。名前を変更すると、そのプロパティのCategoryの基になるRaw値も変更されるためです。ただし、これは列挙型を使用する場合にも当てはまります。逆に、各 Raw文字列を手動で定義するときに発生する可能性のあるタイプミスやその他の間違いも防止できるようになります。

終わりに

Swift列挙型は素晴らしいです(実際、そのトピックだけで15以上の記事を書いています)が、構築しようとしているものに対して別の言語メカニズムの方が適している場合もあります。プロジェクトが成長し進化するにつれて、いくつかの異なるメカニズムとアプローチを切り替える必要があるかもしれません。

元の記事

Avoiding problematic cases when using Swift enums Swift by Sundell

0
1
0

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
0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?