12
11

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で条件分岐漏れを防ぐのに有効な4つの書き方TIPS

Last updated at Posted at 2020-02-09

概要

Swiftに限らず、コードを書く中で外すことができない条件分岐。
プログラムを書く上で避けては通れない存在であるものの、実装を間違えればバグの温床ともなります。

しかしSwiftにはこの条件分岐を正しく実装するのに役立つ仕組みが文法レベルで備わっています。
そこで自分が実際に現場で運用した実績のある、ヒューマンエラーを防ぐためのSwiftでの条件分岐の書き方を解説します。

1. switch構文で極力defaultを使わない

概要

複数条件を完結に分岐せさせる事ができるswitch文。
大変便利ですし複雑な条件では if よりも使用をおすすめしたのですが、 1つだけ気をつけなければならないのは default の使用です :warning:

ご存知の通り 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%引き)を提供しよう :exclamation:」という要件が加わったとします。**
そこで上記のうち UserRank を次のように変更しました。

platinumを新規に追加
enum UserRank {
  case .platinum // 新規追加
  case .gold
  case .silver
}

これだけでビルドしてコンパイルを通すと、エラーや警告は特に発生しないため、このままリリースもできていしまいます。
ところが「GOLDよりもさらに割引した特別価格を提供」するロジックが実装漏れしています。
つまり 本来であれば下記の修正も必要だったのですが気づけなかったのです。

platinumのcaseを新たに追加する必要があった
  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
    }
  }
}

上記対応がなくても コンパイル時点では文法的に問題がないため、対応漏れに気づくことができませんでした :droplet:
これは 新規に定義された case の条件分岐を default が暗黙的にハンドリングしてしまったことに起因します。

つまり、default を使わず全 case を明示的に指定して分岐していたならば、新しい case が定義された時点で以下のようなエラーが発生して気づけていたはずです :exclamation:

defaultを使わなければ新しい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文を使え」というメッセージです。

エラーが文法チェックの段階で発生し、条件分岐の実装漏れにコンパイルで気づくことができるわけです :smile:
上記が1箇所であればまだしも、コードのあちこちに同様の分岐が散らばっている場合には漏れの被害も相当になりますよね。

このように default は便利ではありながらも、暗黙的に足りないcaseをハンドリングしてしまうため、本当に必要な分岐の実装漏れに気づきにくいリスクを発生させます。

そのため、個人的には default の使用を可能な限り(IntStringでは無理なので)控えるようチームに説明しています。

補足

なお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値でしか判別できず、海外の複数カ国内でのロジック分岐には別途国判別のロジックが必要になりますし、場合によってはサーバーサイドロジックとの整合性が壊れてきます :scream:
もし上記の実装を維持したまま新たに国判定の実装をする場合、下記のようなコードが追加されることになります。

国外で新たに国を判別することになった場合のコード
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:
    // 中国ユーザー向けロジック
  ...
  }

}

これでは isDomesticcountry が表すドメインが被ってしまいますし、後からメンテナンスをしていくときに分岐漏れなどが発生する原因になり負債になりかねません。
もし設計の段階で 2値分岐ではなくなる余地が0.1%でもあるなら、個人的には多値で分岐可能かつロジックを集約可能なenumで定義するのがベストプラクティスだと思います。

たとえば初めから国を指定するのではなく、先程の isDomestic のようなフラグをassociated value enumで定義するものありです。
こうすることで「国外」という情報とセットで「国」の情報も渡すことができますし、 switch が使えるので前述の default の不使用と合わせて条件分岐漏れをなくすことができます。

国外に別途associated-value-enumで国情報を渡す
/// サービス展開地域
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 を生成するロジックがあったとします :sun_with_face:

cloudyでの初期化を忘れたことに気づけないコード
/// 条件分岐の対象
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 文と同様に 「初期値 = デフォルト値が設定されている」という理由で条件分岐漏れに気づけません。
この場合は、デフォルト値を入れた変数ではなく、空の定数を定義した以下の実装にするのが良いと考えています。

cloudyでの初期化を忘れたことに気づけるコード
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 の手前で letvar を初期化せず宣言のみできることを最近まで知りませんでした :sweat_drops:
今までわざわざjQueryのような即時実行関数を作って返していましたが必要なかったんですね :sweat_smile:

即時関数を用いた実装は必要なかった
let x: Int = { (arg: SomeType) -> Int in
  switch arg {
    case .some:
      return 1
    ... 
  } 
}

4. enumには明示的にrawValueを指定する

概要

enumでrawValueを参照する実装がある場合、 case名と実際の値を疎結合にしておく方が予期せぬ変更の影響を受けにくいです。
例えば StringInt を継承したenumであれば、冗長と感じても最初にcase名と同じStringやIntを明示的に代入することをおすすめします。
なぜなら case名がリファクタリング等で変わったとしてもコード的に rawValue はリファクタリング前と同値な必要があるからです。
もしcase名のリネーム等をした時に rawValue まで変わってしまうと リファクタリングがしにくいコードになってしまいます。

具体例

APIにリクエストを投げるためのメソッド callSearchAPI があり、その引数にはパラメータをenumで渡せるとします。

case名とrawValueが密結合しているコード
/// 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" になります。

しかし実装後に アプリ内だけでパラメータ名をリネームしたいと思ったとします。
例えば gendergenderType とリネームする時に、下記のように定義だけを変更すると送られる値 = rawValue まで変わってしまいます。

case名を変えた結果rawValueまで変わってしまうコード
enum Parameter: String {
  case query
  // Parameter.genderType.rawValueは"genderType"になってしまう
  // サーバーサイドは依然として"gender"を要求するのでリクエストが失敗してしまう
  case genderType
}

すると サーバーサイドは依然GETパラメータ名を "gender" で受けているのでリクエストが弾かれてしまいます :boom:

このようにデフォルトでcase名をrawValueにしてくれる機能は明示的な宣言が不要で便利なのですが、 逆に変数名と値が密結合してしまう原因にもなります。
そこで、 自明ではあっても次のようにrawValueはきちんと定義しておくのが良いと思うのです。

case名の変更に強いコード
enum Parameter: String {
  case query = "query" // <- case名を変更してもrawValueは "query" のまま 
  case genderType = "gender" // <- case名を変更してもrawValueは "gender" のまま 
}

こうすることで変数名の変更に値が引っ張られて意図しない値に変わることはありません :thumbsup:

まとめ

以上個人的に条件分岐漏れを防ぐために日頃やっているTIPSを紹介しました。
条件分岐漏れはどんな現場でも毎日のように発生しているバグですし、自分もこれまで散々苦しんできました。

しかし幸いにもSwift文法は簡潔で良い設計がしやすいので、ぜひその特性を活かしてコードを書いていくことでハッピーになれるのではと思った次第です。
以上が少しでも皆さんの参考になれば幸いです。

参考

12
11
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
12
11

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?