1
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 1 year has passed since last update.

【Swift】enumのcaseをどうしても"拡張"したい

Posted at

はじめに

Swift三大つよつよ機能1が一角、enum。
そのenumを触っていて誰もが一度はこう思ったことと思います。
え? enumのcaseって追加できないの??

switchとの相性はバツグン、funcも生やせる、Associated Valueも付けられると、まるで何でもできそうな勢いのenumですが、2023年1月現在Swiftにおいても、
caseを外部から新しく生やすことはできません。あとからAssociated Valueをくっつけることもできません。

enum DrinkType {
    case coffee(sugar: Double, milk: Double)
    case tea
    case greanTea
}

extension DrinkType {
    case water // Enum 'case' is not allowed outside of an enum
}

Swift Evolutionではcaseが追加できるタイプのenumを作るのはどうかと提言されていたりしますが、少なくとも現在では上記のようにEnum 'case' is not allowed outside of an enumとエラーになってしまいます。

たしかに、extensionで簡単にcaseが生やせてしまうと「誰かが勝手に書いたextensionのせいで自分のコードがコンパイルを通らなくなってしまう」なんて不都合が起こってしまうわけで、よくよく考えてみると当然ですよね。

さらに言えば、上記のような危険性を犯して拡張を試みずとも、当該enumを利用しているメソッドのラッパーを独自に定義して、その中でよしなに処理してあげた方が丸い場面の方が実は多いのではないでしょうか。





caseが生やせれば便利だと思った
でも、そうはならなかった
ならなかったんだよ、
だから、この話はここでお終いなんだ

















L('ω')┘三└('ω')」

前提

そこで諦められるならこんなに心を悩ませることもなかったでしょう

限定的な場面でしょうが、どうしてもenumのcaseを追加したかったり、既存のenumの構造を変えたりしたいことだってあるはずです。
たとえば、enumの値で状態が決まっているのに他の変数でも似たようなものを管理していたりとか、enum周りの構造のせいで異様に実装が長くなってしまったりとか。

筆者の場合はSwiftが主戦場ではないライブラリを使用した際に、そこで用いられているAPIのパラメーター設定部分をそのまま利用しようとすると絶妙に使い心地が悪かったなんてことがあります。本当に追加不可能なのか、いや純粋な意味でのcase追加はできなくても、なんちゃって"拡張" なら可能なんじゃあなかろうか? などと考えていたら夜も12時間しか眠れなくなってしまいました。

そんなわけで、ここから先は筆者と同じ「enumのcaseをどうしても"拡張"したい」という方向けの内容となります。

1. 静的プロパティで新しい名前をつける方法

新しいcaseが追加できるわけではありませんが、既存のcaseに対して新しい名前をつけたい場合には有効な方法です。

enum DrinkType {
    case coffee(sugar: Double, milk: Double)
    case tea
    case greanTea
}

上記のようなDrinkTypeblackCoffeeという名前を追加したい場合

extension DrinkType: Equatable {
    static let blackCoffee: Self = .coffee(sugar: 0, milk: 0)
}

のように静的プロパティで用意してあげれば事足りるということになります。
一見、いやいやこれcaseじゃないやん! と思いますが、

// dot syntax (Implicit Member Expression) できる
let drink: DrinkType = .blackCoffee

// switch case にも組み込める
switch drink {
case .blackCoffee:
    print("Black") // Black
case let .coffee(sugar, milk):
    print("Coffee, [sugar: \(sugar)] [milk: \(milk)]")
case .tea:
    print("Tea")
case .greanTea:
    print("GreanTea")
}

使用感に関しても、XCodeのswitch構文補完が働かない以外はcaseと似た感じで使えるため可読性を損いません。
型が明示されているならImplicit Member Expressionでドット以前省略できますし、Equatableに準拠すればswitch構文にも組み込めます。

既存のcaseに対して新しい名前をつける場合、静的プロパティで代用ができそう

特にこの方法の良い点は元のenumを純粋に拡張しているという点で、余計な管理が必要ないというところにあると思います。


一方で、この方法だとAssociated Valueを使ったcaseを追加したい場合には不適となります。
Associated Valueを使う最も大きなメリットとしてswitch構文の中での値の展開がありますが、以下のようにfunctionとしてなんちゃってAssociated Valueを実装したとしてもよしなに展開してくれることはありません。

extension DrinkType {
    static let cafeAuLait: (Double) -> Self = {sugar in .coffee(sugar: sugar, milk: 0.5)}
}
// これは無理
switch drink {
case .cafeAuLait(let sugar): // 'var' binding pattern cannot appear in an expression
...
}

ちなみにAssociated Valueをもつcaseはfunctionのように利用できますが、その逆はできないようで

extension DrinkType: Equatable {
    static let newCoffee = Self.coffee // (Double, Double) -> DrinkType
}
// やっぱり無理
switch drink {
case .newCoffee: // Member 'newCoffee' is a function that produces expected type 'DrinkType'; did you mean to call it?
case let .newCoffee(sugar, milk): // Pattern variable binding cannot appear in an expression
...
}

と言われてしまいます。

⭕️ case with Associated Value → function
❌ function → case with Associated Value

2. structやclassで再定義する方法

「enumではcase追加できないから、structやclassではじめから定義してやればええんやで」という解決法。
「enumに新しいcaseを追加したい!」という要望に対してこの方法が提示されることが多く、そういうことが知りたいんじゃないんだけどな……と感じたこと数回。本来は「元が数値や文字列で表される存在で、値の違いで多様な名前がつけられうるものを管理したい場合にenumの代わりに検討する実装手段2という方が正しい気がします。

struct LongPastaType: RawRepresentable, Equatable {
    var rawValue: String
    
    static let spaghetti = LongPastaType(rawValue: "スパゲッティ")
    static let capellini = LongPastaType(rawValue: "カッペリーニ")
    static let tagliatelle = LongPastaType(rawValue: "タリアテッレ")
}

// あとから追加
extension LongPastaType {
    static let fettuccine = LongPastaType(rawValue: "フェットチーネ")
}

このように、RawRepresentableとEquatableに準拠してstaticな変数で名前をつけてあげればenumライクに使えますし、rawValueの型をStringやIntにしておけば、後からいくらでもextensionで値を追加できるというわけですね。

switch構文で使用する場合も、

let pasta: LongPastaType = .なにか
    
switch pasta {
case .spaghetti:
    print(pasta.rawValue) // スパゲッティ
case .capellini:
    print(pasta.rawValue) // カッペリーニ
case .tagliatelle:
    print(pasta.rawValue) // タリアテッレ
case .fettuccine:
    print(pasta.rawValue) // フェットチーネ
default:
    break
}

のように利用できます。そこそこシンプルに書けるのもポイントかなと思います。

たまにenumの例として方角が挙げられていることがありますが、それなんかも360°に対応して

struct Direction: RawRepresentable, Equatable {
    var rawValue: Int
    
    // 北を0°として時計回りに360°表記
    static let north = Direction(rawValue: 0)
    static let east = Direction(rawValue: 90)
    static let south = Direction(rawValue: 180)
    static let west = Direction(rawValue: 270)
}

extension Direction {
    static let southEast = Direction(rawValue: 135)
}

とするのが趣あるかもしれませんね!

一方で、

  • enumのつよみが活かせない(switch構文の補完、Associated Valueなど。1.と同様)
  • rawValueなし(名前だけ)では使えない
  • rawValueに既存のenumを対応づけて再定義する使い方はイマイチ

といった明確なデメリットもあり、なんでもかんでもenumの代わりに使えるわけではありません。
最後に関してはDrinkTypeの例(DrinkTypeを関連づけてDrinkTypeExとして再定義)で示すと、

// これを拡張したい
enum DrinkType {
    case coffee(sugar: Double, milk: Double)
    case tea
    case greanTea
}

// こんな感じになる
struct DrinkTypeEx: RawRepresentable, Equatable {
    var rawValue: DrinkType?
    
    static let coffee: (Double, Double) -> Self = {(sugar, milk) in Self(rawValue: .coffee(sugar: sugar, milk: milk))}
    static let tea = Self(rawValue: .tea)
    static let greanTea = Self(rawValue: .greanTea)
}

extension DrinkTypeEx {
    static let roastedTea = Self(rawValue: .none)
}
let drink: DrinkTypeEx = .なにか
switch drink {
case .tea:
    print(drink.rawValue) // Optional(***.DrinkType.tea)
case .greanTea:
    print(drink.rawValue) // Optional(***.DrinkType.greanTea)
case .roastedTea:
    print(drink.rawValue) // nil
default:
    break
}

のようになり、
rawValueをOptionalにしない場合は必ず既存のcaseの中から指定する必要があるため1.の方法で拡張すれば十分ですし、rawValueをOptionalにして新しく名前を生やした場合でも中身がnilなのでイマイチ使い道に困ることになります。

既存のenumを拡張するという観点では、別の手段を用いた方がいいかもしれません。

3. enumで再定義する方法

前項ではstructの場合を説明しましたが、こちらは新enumを作成してrawValueに既存enumを関連づける方法です。

// これを拡張したい
enum DrinkType {
    case coffee(sugar: Double, milk: Double)
    case tea
    case greanTea
}

// こんな感じになる
enum CustomDrinkType: RawRepresentable {
    case coffee(sugar: Double, milk: Double)
    case tea
    case greanTea
    // "新case"
    case roastedTea
    case unknown

    // CustomDrinkType -> DrinkType? の変換
    var rawValue: DrinkType? {
        switch self {
        case .coffee(sugar: let sugar, milk: let milk):
            return .coffee(sugar: sugar, milk: milk)
        case .tea: return .tea
        case .greanTea: return .greanTea
        case .roastedTea: return .none
        case .unknown: return .none
        }
    }

    // DrinkType? -> CustomDrinkType の変換
    init?(rawValue: RawValue) {
        switch rawValue {
        case .coffee(sugar: let sugar, milk: let milk):
            self = .coffee(sugar: sugar, milk: milk)
        case .tea:
            self = .tea
        case .greanTea:
            self = .greanTea
        case .none:
            self = .unknown
        }
    }
}
さらに改造した例
// これも可能
enum CustomDrinkType: RawRepresentable {
    case coffee(sugar: Double, milk: Double)
    case tea(type: TeaType = .unknown) // Associated Value
    case greanTea
    case roastedTea
    case unknown
    
    // プロパティ
    static let blackCoffee: Self = .coffee(sugar: 0, milk: 0)
    static let cafeAuLait: Self = .coffee(sugar: 0.5, milk: 0.5)
    
    // 紅茶の種類
    enum TeaType {
        case darjeeling, earlGrey, assam, ceylon, unknown
    }
    
    // CustomDrinkType -> DrinkType? の変換
    var rawValue: DrinkType? {
        switch self {
        case .coffee(sugar: let sugar, milk: let milk):
            return .coffee(sugar: sugar, milk: milk)
        case .tea: return .tea
        case .greanTea: return .greanTea
        case .roastedTea: return .none
        case .unknown: return .none
        }
    }

    // DrinkType? -> CustomDrinkType の変換
    init?(rawValue: RawValue) {
        switch rawValue {
        case .coffee(sugar: let sugar, milk: let milk):
            self = .coffee(sugar: sugar, milk: milk)
        case .tea:
            self = .tea()
        case .greanTea:
            self = .greanTea
        case .none:
            self = .unknown
        }
    }
}

新enumとして定義しつつ旧enumと関連づけられているので、使用感を残しつつ実質旧enumのラッパーとして新enumを定義しているようなイメージとなります。メリットとしては

  • enumのつよみを活かせる(switch構文の補完、Associated Valueなど)
  • 新enumの構造を旧enumから変えられる(柔軟性)

一方デメリットとしては

  • 変換部分で複雑になりやすい
  • 新enumと旧enumで混同する可能性がある

があると思います。実際ここまでしてenumを生まれ変わらせたい状況ってよっぽど元のデータ構造につらみがある場合くらいではないでしょうか。

新enumに転生させて良いならAssociated Valueをつけたりcase追加も"実質"可能


拡張例紹介

例えば以下のようなAPIがあったとして、Restaurant.cook(CookParam)が使いたい機能だとします。3

Restaurant.swift
class Restaurant {
    static func cook(_ param: CookParam) -> String {
        var message = [String]()
        
        switch param.cookType {
        case .longPasta:
            message.append("pasta: \(param.longPastaParam.longPastaType)")
            message.append("boiling time: \(param.longPastaParam.boilingTime)")
        case .shortPasta:
            message.append("pasta: \(param.shortPastaParam.shortPastaType)")
            let additionalTime: TimeInterval = param.shortPastaParam.saladFlag ? 1 : 0
            message.append("boiling time: \(param.shortPastaParam.boilingTime + additionalTime)")
        case .onigiri:
            message.append("onigiri: \(param.onigiriParam.onigiriType)")
            message.append("shape: \(param.onigiriParam.onigiriShape)")
        case .unknown:
            message.append("unknown")
        }
        
        return message.joined(separator: ", ")
    }
}

struct CookParam {
    var cookType: CookType = .unknown
    var longPastaParam: LongPastaParam = LongPastaParam()
    var shortPastaParam: ShortPastaParam = ShortPastaParam()
    var onigiriParam: OnigiriParam = OnigiriParam()
}

enum CookType {
    case longPasta, shortPasta, onigiri, unknown
}

struct LongPastaParam {
    var longPastaType: LongPastaType = .unknown
    var boilingTime: TimeInterval = 0
}

enum LongPastaType {
    case spaghetti, capellini, tagliatelle, fettuccine, unknown
}

struct ShortPastaParam {
    var shortPastaType: ShortPastaType = .unknown
    var boilingTime: TimeInterval = 0
    var saladFlag: Bool = false
}

enum ShortPastaType {
    case penne, macaroni, fusilli, farfalle, unknown
}

struct OnigiriParam {
    var onigiriType: OnigiriType = .unknown
    var onigiriShape: OnigiriShapeType = .sankaku
}

enum OnigiriType: String {
    case shake = "鮭"
    case umeboshi = "うめぼし"
    case okaka = "おかか"
    case unknown
}

enum OnigiriShapeType {
    case sankaku, maru
}

これを使おうとした場合以下のような実装になり、

var cookParam = CookParam()
var shortPastaParam = ShortPastaParam()
shortPastaParam.shortPastaType = .macaroni
shortPastaParam.boilingTime = 7.0
shortPastaParam.saladFlag = true
cookParam.cookType = .shortPasta
cookParam.shortPastaParam = shortPastaParam

print(Restaurant.cook(cookParam)) // pasta: macaroni, boiling time: 8.0

CookParamの値を細かく設定しつつ生成しないといけないため実装が長くなりやすく、またどの値を設定しないといけないか一見分かりにくいといった問題もあり、イマイチ使い心地が悪いものになっています。
こういうときに、

Restaurant+CustomCookType.swift
extension CookParam {
    init(_ cookType: CustomCookType) {
        self.cookType = cookType.rawValue
        
        switch cookType {
        case let .longPasta(type, boilingTime):
            self.longPastaParam.longPastaType = type
            self.longPastaParam.boilingTime = boilingTime
        case let .shortPasta(type, boilingTime, saladFlag):
            self.shortPastaParam .shortPastaType = type
            self.shortPastaParam.boilingTime = boilingTime
            self.shortPastaParam.saladFlag = saladFlag
        case let .onigiri(type, shape):
            self.onigiriParam.onigiriType = type
            self.onigiriParam.onigiriShape = shape
        case .unknown: break
        }
    }
}

enum CustomCookType: RawRepresentable {
    case longPasta(type: LongPastaType, boilingTime: TimeInterval)
    case shortPasta(type: ShortPastaType, boilingTime: TimeInterval, saladFlag: Bool)
    case onigiri(type: OnigiriType, shape: OnigiriShapeType)
    case unknown
    
    var rawValue: CookType {
        switch self {
        case .longPasta: return .longPasta
        case .shortPasta: return .shortPasta
        case .onigiri: return .onigiri
        case .unknown: return .unknown
        }
    }
    
    init?(rawValue: CookType) {
        switch rawValue {
        case .longPasta: self = .longPasta(type: .unknown, boilingTime: 0)
        case .shortPasta: self = .shortPasta(type: .unknown, boilingTime: 0, saladFlag: false)
        case .onigiri: self = .onigiri(type: .unknown, shape: .sankaku)
        case .unknown: self  = .unknown
        }
    }
}

としてあげれば、実装側は

let cookParam1 = CookParam(.shortPasta(type: .macaroni, boilingTime: 7, saladFlag: true))
print(Restaurant.cook(cookParam1)) // pasta: macaroni, boiling time: 8.0

let cookParam2 = CookParam(.onigiri(type: .shake, shape: .maru))
print(Restaurant.cook(cookParam2)) // onigiri: shake, shape: maru

と必要なものだけシンプルに記述できるようになります。
機能だけで考えるとCustomCookTypeとCookTypeの紐付けは不必要に見えますが、

  • 状態が網羅できる
  • 外部コード側の変化に気づける

といったメリットがあると思っています。

え!? 普通に適当なラッパーで補助してやればよろしい!? こ、こっちの方が美しいから……(震え)

まとめ

enumにcaseを後で外から新しく追加することはできない

ラッパーを独自に定義し、そこでよしなに処理してあげた方が丸い場面が多そう
まずはラッパーの実装を検討しよう

それでもどうしてもenumを拡張したい場合、なんちゃって拡張なら可能
場合によっては有効な場面もあるかも

  1. 静的プロパティで新しい名前をつける方法
  2. structやclassで再定義する方法
  3. enumで再定義する方法

ざぁこ筆者❤️ 中身スカスカ❤️ もっといい方法ある❤️ という方からの愛のあるコメントおまちしております

参考サイト

  1. そんなものはない(画像略)

  2. UIColorなんかがそんな感じですよね。

  3. 実話ベース

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