はじめに
以前、「CharacterSetはCharacterのsetではありませんよ?」という記事を書きました。簡単に言うと、「Foundationフレームワークで提供されるCharacterSetは、CharacterのsetではなくUnicode.Scalarのsetだ」ということです。
でもやっぱり、それで投げっぱなしは良くないと思ったのです。
というわけで、作りました。Characterのsetをね。
そして、SwiftBonaFideCharacterSetという名前でGitHubに上げました。
Characterのsetを作る過程
Characterのsetを作ろうと思っても一筋縄では行かなかったので、その壁をいくつかここに記載しておこうと思います。
Characterの種類は無限大!
Unicodeのスカラ値は所詮U+0000からU+10FFFFまでの範囲内に収まります。しかし、Characterはそうはいきません。結合文字を組み合わせることでいくらでも文字(Unicode的には「拡張書記素クラスタ」)を作れます。
たとえば、U+20DDは"COMBINING ENCLOSING CIRCLE"といって、前の文字に丸をつけるための結合文字です。そして、結合文字は基本的にいくつ繋げてもいいことになっています(それに意味があるかどうかは別として)。
Swiftで書くと:
print("A".count) // -> 1
print("A\u{20DD}".count) // -> 1
print("A\u{20DD}\u{20DD}".count) // -> 1
print("A\u{20DD}\u{20DD}\u{20DD}".count) // -> 1
print("A\u{20DD}\u{20DD}\u{20DD}\u{20DD}".count) // -> 1
となります。
たとえば、Characterの集合として、{ c | c は"A"から"Z"までの文字またはその文字に結合文字が結合した文字 }を定義したいとして、この集合は(理論的には)無限集合となります1。
高々有限個の文字の集合であればSet<Character>を使えますが、汎用的なCharacterのsetを考えるなら、自分で実装するしかありません。
Rangeの種類が足りない!
というわけで、前回の記事のようにpredicateで集合を定義することを考えました。そうすれば、有限だろうが無限だろうが集合を定義することはできるはずです。
CharacterはComparableプロトコルに準拠しているので、Rangeを使えばCharacterの集合を表すことができそうです。たとえば、前述の{ c | c は"A"から"Z"までの文字またはその文字に結合文字が結合した文字 }を{ c | cは"A"以上"["未満の文字 }2というように考えれば、Swift的には"A"..<"["というRangeに含まれる文字というpredicateを集合の定義に使えば良さそうです。
しかし、今考えているのは汎用的なCharacterのsetなので、SetAlgebraに準拠させることを考えなくてはいけません。そこで問題となるのが、たとえばsubtracting(_:)といったメソッドです。
さて、突然ですが、《"A"..<"["というRangeで定義される集合》から《"A"..."C"というClosedRangeで定義される集合》を引いたらどうなるでしょう?
アルファベットでCの次はDだから…《"D"..<"["というRangeで定義される集合》ですか?
いいえ、違います。
さっき紹介したU+20DDという結合文字を使った"C\u{20DD}"というCharacterについて考えてみましょう。"C\u{20DD}"は当然"A"..<"["に含まれます。そして、"C\u{20DD}"は"C"よりも大きいので"A"..."C"には含まれません。即ち【《"A"..<"["というRangeで定義される集合》から《"A"..."C"というClosedRangeで定義される集合》を引いた集合】に"C\u{20DD}"は含まれているはずです3。
したがって、この問題の答えは{ c | cは"C"より大きく"["未満の文字 }で示される集合となります。しかし、残念ながらSwiftには下端(lower bound)を含まない範囲を表す構造体が標準では存在しません。
というわけで、そういった範囲を表す構造体を実装する必要があります。それが、以前2回に渡って記事にした4SwiftRangesです。
こうして、なんとかCharacterのsetを作ることができそうなところまで来ました。
Characterの比較結果が安定しない
CharacterがComparableに準拠しているのは確かなのですが、Swiftのバージョンによってその比較結果が違う場合があるのです5。
たとえば、"Ä"という文字があります。Unicodeにおける名前は"Latin Capital Letter A with Diaeresis"で、Aというアルファベットにウムラウト(またはトレマ)が付いた文字です。スカラ値はU+00C4が割り当てられていますが、当然"A"(U+0041)に結合文字としてのウムラウト(U+0308)が続く2つのスカラ値で表される書記素クラスタも同じ文字を表します:
"\u{00C4}" == "\u{0041}\u{0308}" // -> true
問題はこの文字が"B"より小さいのかどうかということです:
"\u{00C4}" < "B" // -> 結果は?
結論から言うと、Swift 4.1.2 (Xcode 9.4.1)ではtrueです。そして、Swift 4.2 (Xcode 10)ではfalseです。さらにいえば、(意外なことに?)Swift 4.1.50 (Xcode 10のSwift 4.1互換モード)でもfalseです。
このことを把握していればいいかもしれませんが、場合によってはSwiftのバージョンを意識せずにコードを書いてバグを生み出す可能性もあります。
というわけで、SwiftBonaFideCharacterSetでは、DecomposedCharacterとPrecomposedCharacterという構造体を用意。それぞれ、常にNFDとNFCでnormalizeされ、比較の際にはユニコードスカラ値の配列として大小を比較する設計となっています:
DecomposedCharacter("Ä") < DecomposedCharacter("B")
// 常にtrue
PrecomposedCharacter("Ä") < PrecomposedCharacter("B")
// 常にfalse
こうしてsetのほうもBonaFideCharacterSetのほか、DecomposedCharacterSetとPrecomposedCharacterSetも用意することになりました。
そんなにたくさん構造体を実装したくない
でも、同じような構造体を3つも別々で実装するなんて面倒ですよね。DRYという言葉も耳にタコができるぐらい聞きますし。
そこでまずは、CharacterExpressionというプロトコルを用意して、CharacterもDecomposedCharacterもPrecomposedCharacterも、そのプロトコルに準拠することにします6。
そうすれば、あとは前回の記事で紹介したTotallyOrderedSet<Element>を使って次のようにするだけでよくなります:
public typealias CharacterExpressionSet<C> = TotallyOrderedSet<C> where C:CharacterExpression
public typealias BonaFideCharacterSet = CharacterExpressionSet<Character>
public typealias DecomposedCharacterSet = CharacterExpressionSet<DecomposedCharacter>
public typealias PrecomposedCharacterSet = CharacterExpressionSet<PrecomposedCharacter>
そして、たとえばStringProtocolに対する拡張も次のように簡単にできます:
extension StringProtocol {
public func split<C>(separator:CharacterExpressionSet<C>,
maxSplits:Int = Int.max,
omittingEmptySubsequences:Bool = true) -> [SubSequence]
where C:CharacterExpression
{
return self.split(maxSplits:maxSplits,
omittingEmptySubsequences:omittingEmptySubsequences,
whereSeparator:{ separator.contains(C($0)) })
}
}
これでDRYは達成…ですかね?
おわりに
というわけで、“本当の”CharacterSetを作ってみたというお話でした。ただ、作ってみたのはいいけど、役に立つのかどうかは分からないというオチなのでした。
-
非可算無限集合かな? ↩
-
"Z"がU+005Aで、"["がU+005Bなので…とりあえず。 ↩
-
実際には
DecomposedCharacterとPrecomposedCharacterは共通点が多いので、CharacterExpressionを継承したNoramalizedCharacterというプロトコルを挟んでいます。 ↩