LoginSignup
5
1

More than 5 years have passed since last update.

[Swift] “本当の”CharacterSetを作ったよ。

Posted at

はじめに

以前、「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で集合を定義することを考えました。そうすれば、有限だろうが無限だろうが集合を定義することはできるはずです。
CharacterComparableプロトコルに準拠しているので、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の比較結果が安定しない

CharacterComparableに準拠しているのは確かなのですが、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では、DecomposedCharacterPrecomposedCharacterという構造体を用意。それぞれ、常にNFDとNFCでnormalizeされ、比較の際にはユニコードスカラ値の配列として大小を比較する設計となっています:

DecomposedCharacter("Ä") < DecomposedCharacter("B")
// 常にtrue

PrecomposedCharacter("Ä") < PrecomposedCharacter("B")
// 常にfalse

こうしてsetのほうもBonaFideCharacterSetのほか、DecomposedCharacterSetPrecomposedCharacterSetも用意することになりました。

そんなにたくさん構造体を実装したくない

でも、同じような構造体を3つも別々で実装するなんて面倒ですよね。DRYという言葉も耳にタコができるぐらい聞きますし。
そこでまずは、CharacterExpressionというプロトコルを用意して、CharacterDecomposedCharacterPrecomposedCharacterも、そのプロトコルに準拠することにします6

そうすれば、あとは前回の記事で紹介したTotallyOrderedSet<Element>を使って次のようにするだけでよくなります:

BonaFideCharacterSet.swift
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に対する拡張も次のように簡単にできます:

StringProtocol+CharacterExpressionSet.swift
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を作ってみたというお話でした。ただ、作ってみたのはいいけど、役に立つのかどうかは分からないというオチなのでした。


  1. 非可算無限集合かな? 

  2. "Z"がU+005Aで、"["がU+005Bなので…とりあえず。 

  3. 差集合の定義から 

  4. Rangeの種類が足りない!①, Rangeの種類が足りない!② 

  5. これはSR-530修正と関連しているのかもしれません(未確認)。 

  6. 実際にはDecomposedCharacterPrecomposedCharacterは共通点が多いので、CharacterExpressionを継承したNoramalizedCharacterというプロトコルを挟んでいます。 

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