概要
Swiftに限らず、コードを書く中で外すことができない条件分岐。
プログラムを書く上で避けては通れない存在であるものの、実装を間違えればバグの温床ともなります。
しかしSwiftにはこの条件分岐を正しく実装するのに役立つ仕組みが文法レベルで備わっています。
そこで自分が実際に現場で運用した実績のある、ヒューマンエラーを防ぐためのSwiftでの条件分岐の書き方を解説します。
1. switch構文で極力defaultを使わない
概要
複数条件を完結に分岐せさせる事ができるswitch文。
大変便利ですし複雑な条件では if
よりも使用をおすすめしたのですが、 1つだけ気をつけなければならないのは default
の使用です
ご存知の通り default
はどの case
にも合致しない場合に呼ばれる条件文です。
しかし 一度各caseを定義した後に新たに追加したcaseについては、defaultを使っていると分岐の対応漏れを発生させるリスクとなります。
具体例
あるサービスで、利用状況に応じてユーザーにランクをつけるような機能があったとします。
そしてサービスの設計時には、仕様として「GOLD」「SILVER」の2種類のみの会員が存在したとし、 GOLD 会員に対しては割引価格を提供するロジックが存在したとします。
この時、会員ランクと会員の情報は以下のように設計されていました。
/// 会員ランク
enum UserRank {
case .gold
case .silver
}
/// ユーザー
struct User {
let userRank: UserRank
...
/// 会員ランクごとに割引または割増調整された価格を返す
///
/// - Parameters:
/// - defaultPrice: 通常価格
/// - Returns: 調整された価格
func fetchSpecialPrice(basedOn defaultPrice: Double) -> Double {
switch self.userRank {
case .gold: // GOLD会員は5%割引
return defaultPrice * 0.95
default: // SILVER会員は通常価格
return defaultPrice
}
}
}
しかしサービスを運用する中で**「GOLDの中でも特に優良なユーザーさんをプラチナ(PLATINUM)会員と認定し、GOLDよりもさらに割引した特別価格(10%引き)を提供しよう 」という要件が加わったとします。**
そこで上記のうち UserRank
を次のように変更しました。
enum UserRank {
case .platinum // 新規追加
case .gold
case .silver
}
これだけでビルドしてコンパイルを通すと、エラーや警告は特に発生しないため、このままリリースもできていしまいます。
ところが「GOLDよりもさらに割引した特別価格を提供」するロジックが実装漏れしています。
つまり 本来であれば下記の修正も必要だったのですが気づけなかったのです。
func fetchSpecialPrice(basedOn defaultPrice: Double) -> Double {
switch self.userRank {
case .platinum: // [NEW] PLATINUM会員は10%割引
return defaultPrice * 0.90
case .gold: // GOLD会員は5%割引
return defaultPrice * 0.95
default: // SILVER会員は通常価格
return defaultPrice
}
}
}
上記対応がなくても コンパイル時点では文法的に問題がないため、対応漏れに気づくことができませんでした
これは 新規に定義された case
の条件分岐を default
が暗黙的にハンドリングしてしまったことに起因します。
つまり、default
を使わず全 case
を明示的に指定して分岐していたならば、新しい case
が定義された時点で以下のようなエラーが発生して気づけていたはずです
func fetchSpecialPrice(basedOn defaultPrice: Double) -> Double {
switch self.userRank {
case .gold: // GOLD会員は5%割引
return defaultPrice * 0.95
case .silver: // SILVER会員は通常価格
return defaultPrice
}
}
}
Switch must be exhaustive,consider adding a default clause.
これは「全caseを網羅しきれていないから、(足りていないcaseの分岐を作るか)default文を使え」というメッセージです。
エラーが文法チェックの段階で発生し、条件分岐の実装漏れにコンパイルで気づくことができるわけです
上記が1箇所であればまだしも、コードのあちこちに同様の分岐が散らばっている場合には漏れの被害も相当になりますよね。
このように default
は便利ではありながらも、暗黙的に足りないcaseをハンドリングしてしまうため、本当に必要な分岐の実装漏れに気づきにくいリスクを発生させます。
そのため、個人的には default
の使用を可能な限り(Int
やString
では無理なので)控えるようチームに説明しています。
補足
なおSwift 5では @unknown
属性という新しいアノテーションが定義されました。
これは新しいcaseが増えた時に default
の使用箇所があれば漏れている可能性があると警告するというものです。
しかしこれは、 あくまでも「警告」なので、当然ですが抑制したり無視したりすることはできてしまいます。
そのため、個人的にはこの機能を使うことはあっても、上記の運用をやめることはまずないだろうと想定しています。
2. 多値分岐が発生する余地が0%ではない限りenumの使用を検討する
概要
真偽値を表す Bool
型も、ほぼ全ての言語で使われていてどんな方にも馴染みがあるのでないかと思います。
真偽値は true
と false
の2値を排他的に表すための型ですが、 もしそれ以外の値=多値での分岐が発生する余地があるなら enum
が適切です。
またSwiftのenumには、特定の case
にのみ必要な情報を渡すための仕組み(associated value)があるので、そういった制約があったとしてもenumの使用を検討すべきだと思います。
具体例
例えば国内と海外に展開することを想定して作られたあるサービスがあったとします。
その場合、下記のようにサービス内での条件分岐を Bool
で行うことは容易です。
/// ユーザーを表す
struct User {
/// 国内ならtrue 国外ならfalse
let isDomestic: Bool
...
}
let user: User = .init(isDomestic: ...)
if user.isDomestic {
// 国内ユーザー向けロジック
} else {
// 国外ユーザー向けロジック
}
しかしサービス開始時点で、将来の対応先が「海外」が未来永劫1カ国あるいは各国共通であることは、大抵の場合保証されていません。
事業が成功すれば複数カ国に展開するかもしれませんし、そうなれば言語や内部ロジックも国ごとに最適化する必要が出てくる可能性があります。
その場合 Bool
では国内か国外かの2値でしか判別できず、海外の複数カ国内でのロジック分岐には別途国判別のロジックが必要になりますし、場合によってはサーバーサイドロジックとの整合性が壊れてきます
もし上記の実装を維持したまま新たに国判定の実装をする場合、下記のようなコードが追加されることになります。
struct User {
/// 国内ならtrue 国外ならfalse
let isDomestic: Bool
/// [NEW] 国外向けに国を判定するための種別
let country: Country
...
}
/// [NEW] 国の種別
enum Country {
case .us // 米国
case .ch // 中国
}
let user: User = .init(isDomestic: ..., country: Country)
if user.isDomestic {
// 国内ユーザー向けロジック
} else {
// [NEW]
switch country {
case .us:
// 米国ユーザー向けロジック
case .ch:
// 中国ユーザー向けロジック
...
}
}
これでは isDomestic
と country
が表すドメインが被ってしまいますし、後からメンテナンスをしていくときに分岐漏れなどが発生する原因になり負債になりかねません。
もし設計の段階で 2値分岐ではなくなる余地が0.1%でもあるなら、個人的には多値で分岐可能かつロジックを集約可能なenumで定義するのがベストプラクティスだと思います。
たとえば初めから国を指定するのではなく、先程の isDomestic
のようなフラグをassociated value enumで定義するものありです。
こうすることで「国外」という情報とセットで「国」の情報も渡すことができますし、 switch
が使えるので前述の default
の不使用と合わせて条件分岐漏れをなくすことができます。
/// サービス展開地域
enum Region {
/// 国内
case domestic
/// 国外
///
/// - Parameters:
/// - country: 国
case foreign(country: ForeignCountry)
/// 国外向けの国種別
enum ForeignCountry: String {
case us
case ch
}
}
struct User {
let region: Region
}
let user: User = .init(region: .foreign(country: .us))
switch user.region {
case .domestic:
// 国内向けロジック
break
case .foreign(let country):
// 国外向け共通ロジック
switch country {
case .us:
// 米国向けロジック
break
case .ch:
// 中国向け共通ロジック
break
}
}
このような「2値と思われがちだが要件次第で多値になりうる」属性の例としては、他にも下記のようなものがあります。
- 性別 (LGBT等)
- 有料無料 (有料内に前述で示した「ランク」のような概念が発生する可能性)
- 禁煙喫煙 (分煙や「iQOSはOK」等の要件が発生する可能性)
enumの定義は増えますが、各属性値を明確に表すにはぜひこの方法を活用していきたいものです。
3. 条件分岐の直前で定数・変数を定義し分岐内で代入する
概要
こちらは自分が最近まで知らなかったこと。
前述のswitchでの分岐の話に近いですが、多値分岐のcase内で変数や定数を初期化したいときは、 条件分岐前に変数や定数の宣言だけをすることができます。
そして各分岐内で初期化をし、もしできていない分岐がある場合には初期化されないことを表す文法エラーが発生します。
error: constant 'x' used before being initialized
しかし 宣言時に初期化も行っている場合は、上記のようなエラーが発生しないので実装漏れが発生するリスクとなります。
具体例
天候条件 WeatherCondition
によって、それぞれにあった持ち物 Belonging
を生成するロジックがあったとします
/// 条件分岐の対象
enum WeatherCondition: String {
case sunny
case cloudy
case rainy
}
/// 持ち物
struct Belonging {
/// 名前
var name: String
/// 個数
var count: Int
}
/// 天候条件に合わせた持ち物を返す
///
/// - Parameters:
/// - condition: 天候条件
/// - Returns: 持ち物
func fetchBelonging(for condition: WeatherCondition) -> Belonging {
// ここで初期化するとcaseが書き漏れてもコンパイルエラーにはならなず気づかない
var name: String = "", count: Int = 0
switch condition {
case .sunny:
name = "lunch box"
count = 2
case .cloudy:
// 初期化されていないがエラーにはならない
print("Something")
case .rainy:
name = "umbrella"
count = 1
}
return .init(name: name, count: count)
}
fetchBelonging(for: .cloudy)
(説明のためにあえて冗長な実装にしています)
すると上記では前述の default
文と同様に 「初期値 = デフォルト値が設定されている」という理由で条件分岐漏れに気づけません。
この場合は、デフォルト値を入れた変数ではなく、空の定数を定義した以下の実装にするのが良いと考えています。
func fetchBelonging(for condition: WeatherCondition) -> Belonging {
// nameとcountは必ずどのcaseでも初期化される必要がある
let name: String, count: Int
switch condition {
case .sunny:
name = "lunch box"
count = 2
case .cloudy:
// 初期化されていないのでエラーになる
print("Something")
case .rainy:
name = "umbrella"
count = 1
}
return .init(name: name, count: count)
}
fetchBelonging(for: .cloudy)
これをコンパイルするとエラーにより失敗し、ロジックの分岐漏れに気づくことができます。
error: constant 'name' used before being initialized
error: constant 'count' used before being initialized
自分はそもそも switch
の手前で let
や var
を初期化せず宣言のみできることを最近まで知りませんでした
今までわざわざjQueryのような即時実行関数を作って返していましたが必要なかったんですね
let x: Int = { (arg: SomeType) -> Int in
switch arg {
case .some:
return 1
...
}
}
4. enumには明示的にrawValueを指定する
概要
enumでrawValueを参照する実装がある場合、 case名と実際の値を疎結合にしておく方が予期せぬ変更の影響を受けにくいです。
例えば String
や Int
を継承したenumであれば、冗長と感じても最初にcase名と同じStringやIntを明示的に代入することをおすすめします。
なぜなら case名がリファクタリング等で変わったとしてもコード的に rawValue
はリファクタリング前と同値な必要があるからです。
もしcase名のリネーム等をした時に rawValue
まで変わってしまうと リファクタリングがしにくいコードになってしまいます。
具体例
APIにリクエストを投げるためのメソッド callSearchAPI
があり、その引数にはパラメータをenumで渡せるとします。
/// APIをコールする
///
/// - Parameters:
/// - param: GETパラメータの種別
/// - value: GETパラメータの値
func callSearchAPI(with param: Parameter, and value: String) {
let url: "https://api.example.com?\(param.rawValue)=\(value)" // rawValueをクエリパラメータとして仕様
request(url)
}
/// APIコール時に送信するクエリパラメータの種別
/// case名 = rawValueになっている
enum Parameter: String {
case query
case gender
}
callSearchAPI(with Parameter.query, and "my search query")
(説明のためにあえてAssociated Value Enumは使っていません)
上記の例では Parameter.query.rawValue
は "query" に、 Parameter.gender.rawValue
は "gender" になります。
しかし実装後に アプリ内だけでパラメータ名をリネームしたいと思ったとします。
例えば gender
を genderType
とリネームする時に、下記のように定義だけを変更すると送られる値 = rawValue まで変わってしまいます。
enum Parameter: String {
case query
// Parameter.genderType.rawValueは"genderType"になってしまう
// サーバーサイドは依然として"gender"を要求するのでリクエストが失敗してしまう
case genderType
}
すると サーバーサイドは依然GETパラメータ名を "gender" で受けているのでリクエストが弾かれてしまいます
このようにデフォルトでcase名をrawValueにしてくれる機能は明示的な宣言が不要で便利なのですが、 逆に変数名と値が密結合してしまう原因にもなります。
そこで、 自明ではあっても次のようにrawValueはきちんと定義しておくのが良いと思うのです。
enum Parameter: String {
case query = "query" // <- case名を変更してもrawValueは "query" のまま
case genderType = "gender" // <- case名を変更してもrawValueは "gender" のまま
}
こうすることで変数名の変更に値が引っ張られて意図しない値に変わることはありません
まとめ
以上個人的に条件分岐漏れを防ぐために日頃やっているTIPSを紹介しました。
条件分岐漏れはどんな現場でも毎日のように発生しているバグですし、自分もこれまで散々苦しんできました。
しかし幸いにもSwift文法は簡潔で良い設計がしやすいので、ぜひその特性を活かしてコードを書いていくことでハッピーになれるのではと思った次第です。
以上が少しでも皆さんの参考になれば幸いです。