iTranslated by AI

The content below is an AI-generated translation. This is an experimental feature, and may contain errors. View original article
🦌

[Tips] Whether to use enums or structs for known values

に公開
4

Introduction

In Swift, I sometimes give feedback during code reviews on how to represent known values, so I decided to write an article about it.

In this article, I will introduce the following three methods for handling known values:

  • Implementation using enum
  • Implementation using struct
  • Implementation using enum + struct

While this might be common knowledge for those experienced with Swift, I felt there are still a fair number of people who might not know it, so I'm sharing it as a small tip.

Sample Code

For this article, let's consider a settings screen like the one below.

Assume this settings screen has an Item corresponding to each cell and is written using SwiftUI's List as follows:

let itmes: [Items] = ...
List(item) { item in
    // Create a cell using the item
}

There are three types of cells:

  • Account
  • Contact Us
  • Terms of Service

Also, each cell consists of the following three values:

  • Title
  • Icon Image (SF Symbols name in this case)
  • Description

Now, let's see how to define Item in such a case.

1. Implementation using enum

First, let's focus on the characteristic "there are three types of cells."
When we do that, we might think of code like this using an 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 "Account Settings"
        case .report: return "Contact Us"
        case .term: return "Terms of Service"
        }
    }

    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 "Report bugs/requests here"
        case .term: return nil
        }
    }
}

It's not bad, but I'm a bit concerned about the number of switch statements.
For example, when you want to check what values are set for the account cell, you need to read through all the switch statements for each property, which is a bit tedious.

2. Implementation using struct

Next, let's try implementing it using a 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: "Account Settings",
        iconName: "person",
        description: nil
    )

    static let report = Item(
        id: "report",
        title: "Contact Us",
        iconName: "exclamationmark.bubble",
        description: "Report bugs/requests here"
    )

    static let term = Item(
        id: "term",
        title: "Terms of Service",
        iconName: "doc",
        description: nil
    )
}

Defining these as static let is done to achieve a similar usage feel as an enum. This allows you to write code in a way that is relatively similar to an enum.

// You can write it like this,
let item: Item = .account 
// Or like this
if item == .account {
    // ...
}

So, what is the benefit of using a struct? As mentioned earlier, it is that when you want to check the values for account, you can see at a glance what values each property holds.

static let account = Item(
    id: "account",
    title: "Account Settings",
    iconName: "person",
    description: nil
)

By reading just this code, you can check the values of the account Item all at once. Considering that in the enum case you had to read through every switch statement to confirm the same thing, you likely feel that the readability has improved significantly.

Weaknesses of struct

Now, looking at the struct example above, you might think "structs are the best!", but the above method isn't perfect. It also has its drawbacks.

Specifically, "it cannot guarantee that all cases are covered in a switch statement."
While with an enum you could write:

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

With a struct, you need to write a default case:

switch item { 
case .account: //...
case .report: //...
case .term: //...
default: // ← This becomes necessary
}

Of course, whether this matters depends on the case, but it's a weakness that cannot be ignored.

If you cannot accept this disadvantage, a hybrid method using both enum and struct can be considered.

3. Implementation using enum + struct

This is a method where Item is defined as an enum, and the values for each case are defined as a struct called Item.Model.

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: "Account Settings",
        iconName: "person",
        description: nil
    )

    static let report = Item.Model(
        id: "report",
        title: "Contact Us",
        iconName: "exclamationmark.bubble",
        description: "Report bugs/requests here"
    )

    static let term = Item.Model(
        id: "term",
        title: "Terms of Service",
        iconName: "doc",
        description: nil
    )
}

By doing this, you can ensure that the switch statement covers all cases while centralizing related values in one place.

switch item { 
case .account: //...
case .report: //...
case .term: //... 
} // No default needed!
// Account settings can be seen at a glance!
static let account = Item.Model(
    id: "account",
    title: "Account Settings",
    iconName: "person",
    description: nil
)

Of course, you need to access each value via model, but I feel the benefits are worth the extra step.

// Need to go through model
let title = item.model.title

Which method should you use?

When I write code, I think as follows:

  1. Start with an enum for now.
  2. If the number of switch statements within the enum's computed properties increases, consider switching to a struct.
  3. If you need to guarantee that all cases are covered by switch statements, use the enum + struct approach.

Conclusion

In this article, I introduced methods for representing known values.
I believe which method you use depends on the case.
However, knowing these methods will provide you with good options when writing code.

That's all for this small tip.

(Addition) 4. Implementation using struct + enum

This is content shared by Omochi Metal on Twitter.
https://twitter.com/omochimetaru/status/1634884483890692101?s=20

I see, I think this is a very good idea.
Using the previous examples, it would look like this.

struct Item: Identifiable {
    let id: String
    let kind: Kind // kind has been added here
    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: "Account Settings",
        iconName: "person",
        description: nil
    )

    static let report = Item(
        id: "report",
        kind: .report,
        title: "Contact Us",
        iconName: "exclamationmark.bubble",
        description: "Report bugs/requests here"
    )

    static let term = Item(
        id: "term",
        kind: .term,
        title: "Terms of Service",
        iconName: "doc",
        description: nil
    )
}

The great thing about this idea is that accessing the model, which was necessary in "3. Implementation using enum + struct", is no longer required.

// No need to access via model!
let title = item.title

This looks very good!
One thing to be careful about is that you might need to ensure the definitions of kind and Item maintain a 1:1 correspondence.
Also, it is slightly concerning that adding a case to kind cannot be detected as a compilation error.
That said, these are somewhat contrived concerns, and overall, it is an idea with significant benefits.
If you are really concerned, you could always write unit tests.
I had not tried this before, so I would like to keep it as an option for my future development!

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も選択肢として良さそうですね!