Swift で実現した絞り込みシステムの話

10 min read読了の目安(約9000字

前書き

こんにちは、リビリンです。日本語も記事書きも初心者です。
この記事は関数型プログラミングを中心とした、 iOS アプリ内アイテムの絞り込みシステムの作り方を紹介します。

課題

こういった場面を考えましょう。

あるソシャゲの中に、カードというものがあります。カードには名前、レア度、特技などの様々な属性がついています:

class Card {
    let name: String
    let rarity: Rarity // レア度
    let skill: Skill // 特技
    // ...
}

レア度が列挙型、特技が別のクラス:

enum Rarity: CaseIterable {
    case n
    case r
    case sr
    case ssr
}

class Skill {
    let name: String
    let interval: Int // 発動間隔
    // ...
}

カードをある条件で絞り込みたい時に、Sequence.filter(isIncluded:) 関数に (Element) -> Bool クロージャ型のを渡します:

let cards: [Card] = //...
// レア度が SSR のカード
let ssrCards = cards.filter { card in
    card.rarity == .ssr
}

簡単でしょう?
次、複数の条件で絞りたい時にはどうでしょう?

// レア度が SR 或いは SSR、かつ特技がスコアボーナス
let ssrCards = cards.filter { card in
    (card.rarity == .sr || card.rarity == .ssr)
    && card.skill === scoreBonusSkill
}

まだ納得できます。
では、あらゆる条件を用意し、ユーザーに好きな条件を複数選んでもらう場合は?

// すべての条件
let allRarity = Rarity.allCases
let allSkills = //...
let allSkillInterval = Set(allSkills.map(\.interval))
// ユーザーが選ぶ
let searchName: String = "..."
let selectedRarity: [Rarity] = [.sr, .ssr]
let selectedSkill: [Skill] = //...
let selectedSkillInterval: [Int] = [9, 13]
// ...
let filteredCards = cards.filter { card in
    card.name.contains(searchName))
    && selectedRarity.contains(card.rarity)
    && selectedSkill.contains(where: { card.skill === $0 })
    && selectedSkillInterval.contains(card.skill.interval}
    // ...
}

めちゃくちゃになりましたね。
これもまだ選んだ条件に何もない状況を考慮しなかった結果、そして、もし必要な条件が10組あれば、完成したコードはもう見てられないものになるでしょう。

このような臭いコードを避けて、絞り込み機能を優雅に実現するのが今回の課題です。

改善方法

考えとして、条件という概念を構造化し、一箇所に管理するのが望ましいです。最終的に、予め作った条件を一つのフィルター変数に凝縮し、 Sequence.filter(isIncluded:) に渡す形にしたいです。

let finalFilter: (Card) -> Bool = { card in ... }
let filteredCard = cards.filter(ssrFilter)

基本定義

まず、このフィルタータイプのクロージャを Filter にリネームします:

typealias Filter<T> = (T) -> Bool

ここから、この Filter をめぐって様々な機能を拡張します。

次に、カードのそれぞれの属性が各自のフィルターを作れるようにします。例えばレア度:

extension Rarity {
    var filter: Filter<Card> {
        return { card in
            return card.rarity == self
        }
    }
}

意味としては、一つのレア度ケースが、「レア度がそのケースと一致するカードを選ぶ」という Filter を提供します。使い方は:

let ssrCards = cards.filter(Rarity.ssr.filter)

実際に複数の Filter を組み合わせて使いたいので、Filter を結合する方法を用意します:

func && <T> (f1: @escaping Filter<T>, f2: @escaping Filter<T>) -> Filter<T> {
    return { t in
        return f1(t) && f2(t)
    }
}

func || <T> (f1: @escaping Filter<T>, f2: @escaping Filter<T>) -> Filter<T> {
    return { t in
        return f1(t) || f2(t)
    }
}

関数型プログラミングに詳しくない方には分かりつらいかもしれませんが、ここでは、Filter に対して && AND演算子を再定義して、「渡された二つの Filter を同時に満足する」という新しい Filter を作ることができます。
|| OR 演算子も同じく、「渡された二つの Filter を少なくとも一つを満足する」という新しい Filter が作れます。

こうすれば、あらゆる Filter をいとも簡単に書けます:

// レア度が SR 或いは SSR、かつ特技がスコアボーナス
// という条件で絞り込むフィルター
let filter = 
    Rarity.sr.filter || Rarity.ssr.filter
    && scoreBonusSkill.filter
let result = cards.filter(filter)

整合

ここまで、基盤的なものを準備出来ました。条件という概念に戻ります。

FilterToggle

条件をユーザーに見せるには、条件の名前と、有効状態の管理が必要です。Filter を加えて、FilterToggle (以下 Toggle という)になります:

struct FilterToggle<Category> {
    
    let name: String
    let filter: Filter<Category>
    var isOn = false

    init(name: String, filter: @escaping Filter<Category>)
    { /*...*/ }

Toggle を使う時に、ユーザー操作を通って isOn の開閉を弄りながら、isOntrue であればこの条件が有効だと見なします。
使い方は:

// SR の Toggle
let srToggle = FilterToggle(name: sr.name, filter: sr.filter)
// 条件の有効状態を切り替える
srToggle.isOn.toggle()

let ssrCards = cards.filter(srToggle.filter)

FilterSet

ここで条件たちの互いの関係を振り返りましょう。ユーザーがいくつの条件から複数選んで、その条件が同じ種類でしたら OR 関係(例えばレア度が SR 或いは SSR)で、違う種類でしたら AND 関係(レア度が SR かつ特技がスコアボーナス)で組み合わせます。

同じ種類の条件を一箇所に管理する FilterSet (以下 Set という)を用意します。

struct FilterSet<Category> {
    var toggles: [FilterToggle <Category>]
}

Set の中の Toggle が全部選ばれている、或いは全部選ばれていない、という場合、この Set が無効だと見なします:

extension FilterSet {
    var isAffectable: Bool {
        ! toggles.allSatisfy({$0.isOn}) &&
        ! toggles.allSatisfy({!$0.isOn})
    }
}

そして Set 段階の Filter を作ります

extension FilterSet {
    var filter: Filter<Category> {
        var result: Filter<Category> = { _ in false } // 1
        for toggle in toggles where toggle.isOn { // 2
            result = result || toggle.filter // 3
        }
        return result
    }
}
  1. OR 関係で Filter を畳み込みたいので、初期値が false
  2. 有効である Toggle を取り出して
  3. 取った Toggle の filter を OR 関係で畳み込んで、Set 段階の Filter に合成します。

Filter Requirement

そして同じロジックで、複数の Set を FilterRequirement (以下 Requirement という)に格納します。

struct FilterRequirement<Category> {
    var sets: [FilterSet<Category>]
    
    var finalFilter: Filter<Category> {
        sets
            .filter(\.isAffectable) // 1
            .map(\.filter) // 2
            .reduce({ _ in true }, &&) // 3
    }
}
  1. 有効である Set を取り出して
  2. Set の filter に転換して
  3. AND 関係で初期値の true に畳み込みます。

ちなみに、Requirement の filter が上の Set の filter とほぼ同じロジックなのに、Swift の中で関数型プログラミングに関する最も重要な filter map reduce を使えば、コードがこのように簡潔に変わるのが Swift の醍醐味ではないかと。

こうやって、下図が示した関係のように、複数の Toggle を組み合わせて Set になり、そして複数の Set が Requirement になります。Toggle の isON を弄りながら、有効した Toggle から取ったすべての filter を Set と Requirement を通して一つの filter にまとめます。

これで準備万端。

使い方

// 1
let rarityToggles: [FilterToggle<Card>] = Rarity.allCases.map { rarity in
    FilterToggle(name: rarity.name) { card in
        card.rarity == rarity
    }
}
let skillToggles: [FilterToggle<Card>] = skills.map { ... }
// ...
// 2
let sets: [FilterSet<Card>] = 
    [rarityToggles, skillToggles...].map(FilterSet.init)
// 3
let cardFilterRequirement = FilterRequirement<Card>(sets: sets)
// 4
let filteredCards = cards.filter(cardFilterRequirement.finalFilter)
  1. カードに関する属性から、それぞれの Toggle を作って
  2. 同じ種類の Toggle を一つの Set にして
  3. 多数の Set でカードの絞り込み用の Requirement を作ります
  4. ユーザー操作から Requirement の中の Toggle たちの有効状態を調整してから、最終的に生成された Filter でカードを絞り込みます。

このやり方のメリット

新しい属性が追加された時、既存コードを変える必要がない

新しい属性の追加が容易にできます。
すべての Toggle、Set と Requirement が様々な属性変数から作られたので、列挙型の属性が追加された時、case一行を加えるだけで;サーバーから生成された動的属性でしたら、コードを変える必要さえなく自動的に対応してくれます;たとえ新しい属性の種類が現れた時でも、新しいFilterSetを書けば良い、既存コードを変える必要が全くありません。

柔軟性が高い、バグが生じ難い

Toggle が namefilter で生成されたものなので、このコードのように:

var customToggle = FilterToggle<Card>(
    name: "HP over 5000", 
    filter: { $0.hp >= 50 }
)

具体的な変数がなくても、判断ロジックのフィルターを用意すれば、様々な Toggle が自由に作れます。
またすべての型がジェネリックになったので、カードじゃなくてほかのアイテムにしても問題ありません。コンパイラーに通されたらバグが出る可能性が低く、テストもしやすいです。

コード量が少ない

実際に、カード・曲・アイドル、三つの実体に、Filter と Sorter を同じパターンで実装しています。使う時に違う属性と型を入れ替われば良いです。PropertyForFiltering のプロトコルを導入したらコード量が更に減り(詳細は下の補足に見てください)、簡潔に書くことができます。

補足

プロトコルの導入

条件として nameFilter だけに関心あるので、プロトコルを作ってコード量を減らしましょう。

protocol PropertyForFiltering {
    associatedtype Category
    var name: String { get }
    var filter: Filter<Category> { get }
}

extension Rarity: PropertyForFiltering {...}

extension FilterToggle: PropertyForFiltering {
    init<Property: PropertyForFiltering>(property: Property) 
        where Property.Category == Category {
        self.name = property.name
        self.filter = property.filter
    }
}

extension FilterSet {
    init?<Property: PropertyForFiltering>(properties: [Property])
        where Property.Category == Category
    {
        guard !properties.isEmpty else { return nil }
        toggles = properties.map(AnyFilterToggle.init)
    }
}

let cardFilterRequirement = FilterRequirement<Card>(propertySets: [
    FilterSet(properties: Rarity.allCases),
    FilterSet(properties: skills),
    // ...
])

let filteredCards = allCards.filter(cardFilterRequirement.finalFilter)

類推する

カード名前の検索も:

let text = textField.text!
let searchTextFilter: Filter<Card> = { card in
    card.name.contains(text)
}

アイテムの並び替えも:

typealias Sorter<T> = (T, T) -> Bool
protocol PropertyForSorting: Localizable {
    associatedtype Category
    var name: String { get }
    var sorter: Sorter<Category> { get }
}
class AnyPropertyForSorting<Category> {...}
class SorterRequirement<Category> {...}

let sortedCard = cards.sorted(cardSorterRequirement.finalSorter)

同じパターンで実装しています。

こういったアーキテクチャーで、すべてのカードを絞り込んで、並べ替えてから、展示しています:

実装効果

実はこの記事が自作アプリのデレガイド2の話です。
アプリ内で、カードというアイテムを収録した図鑑機能があって、今2000枚以上のカードがアプリ内で保存されています。
下図のよう、ユーザーがカードの属性に関する様々な条件を選んで、その条件に満ちたカードを見ることができます。実際にこのようなシステムが実装しています。

おわりに

この記事はアイテムの絞り込むシステムを紹介しました。書けば書くほど思った以上の量になりました。Swift の型システムが強いなので、それを頼って、関数型プログラミングの利点をどんどん活かしたいですね。
説明の不十分や、質問があれば気軽にコメントしてください!

Reference