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

【Swift】より優雅な範囲判定を書きたくて

Posted at

本記事は弊社が技術書典17 で無料配布する同人誌「ゆめみ大技林 '24 (2)」の寄稿です。加筆や修正などがある場合はこの記事で行います。

TL;DR

やはり if a < x < b 書きたいよね!?

背景

一つの変数が特定の範囲の中に入ってるかどうかの判定は、人生の中で誰もがやったことあるでしょう。例えば、一つの円の半径が 10 以上 100 以下かどうかを判定したいとしましょう。さてこの判定、どう書けばいいでしょうか?

半径の変数を radius とすれば、一番オーソドックスな書き方なら、if radius > 10 && radius < 100 ではないでしょうか。ところがこの書き方は 2 つの問題があると筆者が思います。一つ目はちょっと長くて読みにくいと思います、少なくとも読みやすい部類には入らないと思います。そしてもう一つは radius という変数が二回も登場していることです。些細なことと思うかもしれませんが、これはれっきとした DRY 原則違反だし、もし半径の変数が複数あったら、間違って if radiusA > 10 && radiusB < 100 と書いちゃうリスクだってあります。

というわけで、より優雅な判定の書き方を考えたいですね。

よくある改良した書き方

switch 文の活用

radius を 1 回だけ出現させたいなら、よくあるアプローチはそもそも if 文を switch 文に変える方法です。つまり上記の判定はこのように書けます:

switch radius {
case ...10,
     100...:
    // 範囲外の時の処理

default: // もしくは `case _:`
    // 範囲内の時の処理
}

このように switch 文にした場合、 radius の出現を 1 回だけに減らしたから、DRY 原則が守られ、間違って違う変数で判定するリスクも大きく減ったと思いますが、上限と下限の表現が非常にわかりにくく、どっちかというと if !(radius <= 10 && radius >= 100) の判定になっています。もちろん判定の内容は実質同じですが、読み手である我々人間には非常に不親切な書き方と言わざるを得ません。

もちろんこれをもう少し改良する余地はあると思います、例えばこれなら多少は見やすいかなと思います:

switch radius:
case ...10:
    // 範囲外の時の処理
case ..<100:
    // 範囲内の時の処理
case 100...:
    // 範囲外の時の処理
default: // 存在しないはずですが書かなければビルドエラー
    fatalError()
}

これなら、確かに順番で読んでいくと、「あー radius が 10 ~ 100 の間にあるときー」と読み取れるから、理解のしやすさは格段に上がったと思いますが、範囲外の時の処理が分けられてしまったり、本来不必要な default をどうしても書かなくてはいけない1などの問題もありますが、何より本来 1 行でできる判定がこのようにとても長い文章にしなくてはいけません。そういう意味ではやはり「読みやすい」とは違うと思います。

範囲型の活用

Swift に慣れた人なら、Range というものの存在も思い浮かぶかと思います。Swift は最初から「範囲」を表す方が存在し、そしてその範囲と数値の比較判定も実はあります。というわけで範囲を使えば、上記の判定はこう書けます:

if (10 ..< 100).contains(radius)
// もしくは
if (10 ..< 100) ~= radius

この書き方は radius の出現を 1 回に守りながら、冗長な書き方も回避できましたが、しかしこれではまた別の問題が 2 つ出たと思います。一つは switch 文以上に読みにくいと思います。この書き方はある意味ヨーダ記法2に近い形で、比較基準を左に、逆に比較対象を右にしてしまっています。そしてもう一つはかなり致命的な問題で、本来ここで行いたい判定は x > 10、つまり 10 は含まれないはずなのに、残念ながら Swift にはそれを表現できる範囲型がありません。もし radius がピッタリ 10 なら、本来 false で返されるべきなのに、この判定では true が返されてしまいます。

数学のテキストに因んだ書き方

ところで我々は学校で数学習ったときも、範囲や区間についてたくさん勉強したと思いますが、その時の書き方を思い出してみましょう。そう $0 < x < 10$ みたいな式とはよく睨み合いしていたのではないでしょうか。この書き方なら非常にシンプルでわかりやすく、「x は 0 と 10 の間にある」と誰もがすぐに結論を出せるではないでしょうか。

というわけで、この書き方を参考にすれば、if 10 < radius && radius < 100 で書くと、最初の書き方より多少は読みやすくなったのではないでしょうか。

もちろんこの書き方にも問題があり、また radius という変数が 2 回登場してしまったのです。

なかったら作ればいいじゃないか

上の書き方はすでにとてもいい線行けたと思います。これまでの書き方の中で一番わかりやすいし、そこそこシンプルだと思います。ではこれを究極的に数学のと全く同じ書き方にすれば、シンプル&わかりやすい&バグりにくいの三拍子揃った最強の書き方になるではないでしょうか。

if 10 < radius < 100

問題はこのまま書いてもビルド通らないですね…< は連結できないし、Swift の if 文は Bool しか受け付けません。

でもここで諦めたら試合終了です。忘れては行けません、Swift には「演算子オーバーロード」をサポートしているのです。つまり、我々が < を連結できるようにしちゃえばいいのです。というわけでその作り方を考えてみましょう。

基本形

まずは < を連結させたいです。通常の <ComparisonPrecedence という演算子グループに入っており、このグループは連結できないように定義されています。というわけでまずは連結できる演算子グループを作りたいと思います:

precedencegroup AssociativeComparisonPrecedence {
    associativity: left
}

こうれで左から順に連結できる演算子グループが定義できました。ただしこれではまだ足りません、一つの式に複数の演算子が出てきた時、どれを先に演算するかを決めるために演算の優先順位があり、これも演算子グループに定義しなくては行けません。今回の場合、通常の比較演算子と同じ優先順位にしたいのですが、残念ながらそれが直接指定できず、higherThanlowerThan しか使えません。というわけで実際の ComparisonPrecedence の定義3を見て、AssociativeComparisonPrecedence は下記のように演算優先順位を設定します。

    associativity: left
    higherThan: LogicalConjunctionPrecedence
    lowerThan: NilCoalescingPrecedence
}

さて、次は < をこの演算子グループに入れるところですね。

infix operator <: AssociativeComparisonPrecedence

これで、連結が許される < 演算が作られました。

次に、実際に < を連結した時の処理を定義しなくては行けません、なぜならそのまま連結させても、例えば 10 < radius < 100 と書いても、最初の radius < 10 では Bool が返され、Bool の値と 100 という数値を比較させても何が何だかわからないのです。というわけでここはこの連結に意味をなすように関数を新たに定義する必要があります。

まずは 10 < radius について考えましょう。この演算は、当然ながら通常の小なりの演算結果を返さなくてはなりません、ただしそれと同時に、演算子の右側にある radius も返さなければ、次に小なり演算ができません。というわけで今回は愚直にその両方を返すための Tuple を作って返しましょう。

extension Comparable {
    static func < (lhs: Self, rhs: Self) -> (Bool, Self) {
        return (lhs < rhs, rhs)
    }
}

これで 10 < radius の演算で、radius が 10 より大きかどうかの結果とともに、radius の値自身も一緒に返せます。

次に後半の < 100 について考えましょう。ここは前半の (Bool, Comparable) が返されたので、当然これを利用する必要があります。そして最後に if 文で使えるように、Bool を返す必要があります。というわけでもう一つの演算を定義します:

extension Comparable {
    static func < (lhs: (Bool, Self), rhs: Self) -> Bool
}

さてこの演算の実装ですが、もしそもそも前半の判定が false なら、後半だけ成立しても意味ないので false を返さなければなりません。というわけでこの処理はこのように実装する必要があります:

extension Comparable {
    static func < (lhs: (Bool, Self), rhs: Self) -> Bool {
        return (lhs.0 && lhs.1 < rhs)
    }
}

これで、前半の判定結果と後半の判定が共に true である時だけ、最終結果も true になります。というわけで早速この呼び出しを確認してみましょう:

let radius: CGFloat = 50
if 10 < radius < 100 {
    print("true") // true
}

よし、これでバッチリですね!

発展系 Part.1:さらにたくさんの < を連結させる

今回は 10 < radius < 100 という、一つの変数だけの判定に着目していますが、実際のケースでは、もしかするとさらにたくさんの変数をいっぺんに確認したい需要もあるかもしれません。例えば更に次の半径 B があって、半径 B は 半径 A より大きくなければなりません、そして半径 A と半径 B は共に 10 と 100 の間になければなりません。この時数学なら $10 < A < B < 100$ のような書き方もできますが、現在のこの実装ではまだ if 10 < radiusA < radiusB < 100 が書けません、なぜなら A < B のこの <(Bool, Comparable) の入力を受けながら、同じく (Bool, Comparable) の出力もしなければなりませんが、この実装がまだないのです。というわけでこの実装も入れましょう:

extension Comparable {
    static func < (lhs: (Bool, Self), rhs: Self) -> (Bool, Self) {
        return ((lhs.0 && lhs.1 < rhs), rhs)
    }
}

これで if 10 < radiusA < radiusB < 100 も書けますね。それどころか、radiusCradiusD も、コンパイラーが型推論できる限りどんどん追加できます。

発展系 Part.2:<= 演算も対応させる

まあ現実世界では流石に 10 < radiusA < radiusB < 100 のような 2 つ以上の変数の同時判定はまだ少ないかもしれません。しかしその数値が上限値もしくは下限値であっても大丈夫、つまり $10 ≤ A < 100$ や $10 < A ≤ 100$、もしくは更に $10 ≤ A ≤ 100$ のような判定は多々あると思います。というわけでこれも対応したいですね。これは非常に簡単で、これまでの < の実装をコピーして、<<= で置き換えればいいだけですね

infix operator <=: AssociativeComparisonPrecedence

extension Comparable {
    static func <= (lhs: Self, rhs: Self) -> (Bool, Self) {
        return (lhs <= rhs, rhs)
    }
    
    static func <= (lhs: (Bool, Self), rhs: Self) -> (Bool, Self) {
        return ((lhs.0 && lhs.1 <= rhs), rhs)
    }
    
    static func <= (lhs: (Bool, Self), rhs: Self) -> Bool {
        return (lhs.0 && lhs.1 <= rhs)
    }    
}

これで 10 <= radius < 10010 < radius <= 10010 <= radius <= 100 も、全部書けちゃいますね!

宿題:更に >>= も対応させたい!

まあ正直通常範囲を決める時、小さい方から大きい方へ書くのが一般的なので、わざわざ $100 > A > 10$ のような書き方をする人あまりいないと思います(むしろいたら近寄りたくない…)が、あくまでこれまでやってきたことのさらなる発展系で、ぜひ頭の体操として更に >>= の連結も対応させたい時どうすればいいかを考えてみてください。

ここで気をつけてほしいのは、<= の対応の時と同じように愚直に > とかをそのまま追加しては行けません、なぜならそのままでは 10 < radius > 100 みたいな式も書けてしまうのです。当然ながらこのような判定はあっては行けません、ビルドエラーで落とさなければなりません。10 < radius < 100100 > radius > 10 が書けながら、10 < radius > 10010 > radius < 100 が書けないように作る方法を考えてみてほしいです。

まとめ

シンプルだし読みやすいしバグりにくいから、この書き方を普及させたい!

  1. もちろんそもそも case 100... を省略して default を「範囲外の時の処理」とするのも一つの手ですが、こうすればまた微妙に読みやすさが損なっているような気もします…

  2. ヨーダ記法は、一般的な判定文を逆にして、例えば x が 100 である判定したいとき、if x == 100 ではなく、if 100 == x の順番にする書き方です。この記法は一般的に読みにくいですが、「代入」演算も条件判定で使えてしまう C 言語などのプログラミング言語では、間違って if x = 100 というミスが防げます。なぜなら x = 100 は普通に代入文としてビルドが通りますが、100 = x は数字リテラルに数値を代入しようとしていて、こんな代入ができないからビルドエラーになります。ただし Swift などの現代的なプログラミング言語は、代入文はそもそも条件判定に使えないことが多いため、ヨーダ記法はただ読みにくいだけの書き方として一般的に推奨されません。

  3. 例えばソースコードで import Swift を書けば、Cmd キー押しながら Swift をクリックすると、Swift のいろんなヘッダーが見れます。そこで ComparisonPrecedence を検索すると、その定義が見つかります。その定義を見れば、ComparisonPrecedenceLogicalConjunctionPrecedence より上で、NilCoalescingPrecedence より下だとわかります。

5
1
2

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