はじめに
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
}
上記のようなDrinkType
にblackCoffee
という名前を追加したい場合
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
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の値を細かく設定しつつ生成しないといけないため実装が長くなりやすく、またどの値を設定しないといけないか一見分かりにくいといった問題もあり、イマイチ使い心地が悪いものになっています。
こういうときに、
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を拡張したい場合、なんちゃって拡張なら可能
場合によっては有効な場面もあるかも
- 静的プロパティで新しい名前をつける方法
- structやclassで再定義する方法
- enumで再定義する方法
ざぁこ筆者❤️ 中身スカスカ❤️ もっといい方法ある❤️ という方からの愛のあるコメントおまちしております
参考サイト
- Swift by Sundell - articles: Five powerful, yet lesser-known ways to use Swift enums
- Swift Evolution - proposals: 0192-non-exhaustive-enums
- Swift Book - LanguageGuide: Extensions