Swift
characterset
Unicode.Scalar

[Swift] CharacterSetはCharacterのsetではありませんよ?

More than 1 year has passed since last update.

CharacterSetCharacterの set ではない

「そんなの知ってる」と思った貴方は基本的にこの記事を読む必要はありません。一方で「それってどういう意味?」と思った方がいらっしゃれば、ここではCharacterSet周りでバグを作らないために知っておいた方が良いだろう内容を書いておきますので、多少はお役に立てるかもしれません。
CharacterSet将来的に本当にCharacterのsetになる可能性はあるので、その時のために備えておきましょうという意味もあります。

おさらい: Unicode.ScalarCharacterの違い

Swiftが登場した当初、Stringを扱おうとした猛者たちの阿鼻叫喚が多数聞かれたかと思いますが、それもSwiftのバージョンが4.0となった今はだいぶ落ち着いたことでしょう。それでも一応はUnicode.ScalarCharacterの違いをおさらいしておきましょう。ただ、そもそも、Unicode自体を知っていれば理解することは特に難しいことではありません。

Unicode.Scalar

その名の通りUnicode.Scalarは「ユニコードスカラ値」を示すstructです。ユニコードスカラ値とは、WikipediaをみてくださいUnicodeが文字または文字の部品に割り当てた数値のことで、0から1114111までの1114112個あります。たとえばアルファベットの"A"はU+0041(十進数で65)です。
ただ、上にも書いた通り、スカラ値は文字だけでなく"文字の部品"にも割り当てられているというところに注意が必要です。
ひらがなの「が」を考えてみましょう。UnicodeにあるHIRAGANA LETTER GAにはU+304Cが割り当てられています。一方で、「が」は「か」に「゛(濁点)」がついたものでもあります。「か(HIRAGANA LETTER KA)」にはU+304B、「゛(COMBINING KATAKANA-HIRAGANA VOICED SOUND MARK)」はU+3099が割り当てられており1、これらを並べたものU+304B U+3099も「が」なのです。すなわち、ユニコードスカラ値を複数並べて一文字を表すということもあるのです。そして、U+304CU+304B U+3099はどちらも「が」なので同じであるとみなしてほしいと思うのが人の子でしょう。そこで登場するのがCharacterなのです。

Character

上で説明した通り、ユニコードスカラ値が1個であろうと2個であろうと5個であろうと、それが一文字を表すのであれば一文字として扱いたいですよね?そのためのstructCharacterというわけです。Characterでは、その文字がいくつのユニコードスカラ値から成り立っているかは関係なく、同じ文字を表しているならば同じとみなされます:

がという文字.swift
let ga: Character = "\u{304C}"
let kaAndVoicedSoundMark: Character = "\u{304B}\u{3099}"
print(ga == kaAndVoicedSoundMark) // Prints "true"

CharacterSetという名前は勘違いさせやすい

CharacterSetという名前はあたかもCharacterの集合を表していそうですね。しかも、init(charactersIn string: String)なんていうイニシャライザもあります:

AtoZ.swift
import Foundation
let aToZ = CharacterSet(charactersIn:"ABCDEFGHIJKLMNOPQRSTUVWXYZ")
print(aToZ.contains("A")) // Prints "true"
print(aToZ.contains("!")) // Prints "false"

このコードでは、CharacterSetCharacterの集合として振る舞っているようにもみえます。
では、さっきの「が」を使った次のコード例をみてみましょう。

がセット.swift
import Foundation
let gaSet = CharacterSet(charactersIn:"\u{304B}\u{3099}")
print(gaSet.contains("\u{304C}")) // Prints what...?

"\u{304B}\u{3099}"は上述した通り「か」+「゛」です。"\u{304C}"は「が」です。Characterの項でも示した通り、これらは同一とみなされます。即ち、CharacterSetCharacterの集合であれば、結果は"true"となるはずです。しかし、このコードを実行して表示されるのは"false"です。同じとみなされる文字が含まれているはずなのに、なぜ"false"なのでしょうか。

実はCharacterSetの正体はUnicodeScalarSetだった

CharacterSetは歴史的な経緯からそういう名前になっているだけで、実は中身はユニコードスカラ値の集合なのです。
CharacterSetの別のイニシャライザやメソッドを見てみるとそれが明らかです。

CharacterSetのイニシャライザやメソッド抜粋
convenience init<S>(_ sequence: S) where S : Sequence, Unicode.Scalar == S.Element
convenience init(arrayLiteral: Unicode.Scalar...)
init(charactersIn range: Range<Unicode.Scalar>)
init(charactersIn range: ClosedRange<Unicode.Scalar>)
func contains(_ member: Unicode.Scalar) -> Bool
@discardableResult mutating func insert(_ character: Unicode.Scalar) -> (inserted: Bool, memberAfterInsert: Unicode.Scalar)
mutating func insert(charactersIn range: ClosedRange<Unicode.Scalar>)
mutating func insert(charactersIn range: Range<Unicode.Scalar>)
@discardableResult mutating func remove(_ character: Unicode.Scalar) -> Unicode.Scalar?
mutating func remove(charactersIn range: ClosedRange<Unicode.Scalar>)
mutating func remove(charactersIn range: Range<Unicode.Scalar>)
@discardableResult mutating func update(with character: Unicode.Scalar) -> Unicode.Scalar?

うわぁ…。引数がUnicode.Scalarのメソッドはいっぱいあっても、Characterが引数になっているメソッドは一切ありません。
ちなみに、var bitmapRepresentation: Data { get }というプロパティもありますが、それが表すのはCharacterSetに含まれるユニコードスカラ値を表すDataで、先頭からnビット目が1であれば、ユニコードスカラ値 == nが当該CharacterSetに含まれるということになります。

さて、以上を踏まえて、上の"がセット.swift"を見返して見ましょう。
まずlet gaSet = CharacterSet(charactersIn:"\u{304B}\u{3099}")で作られるCharacterSetは「が」というCharacterを持つ集合ではなくU+304BU+3099という2つのUnicode.Scalarを含むことを表す集合です。
そして、次のgaSet.contains("\u{304C}")ですが、ここでの"\u{304C}"CharacterではなくUnicode.Scalarです2。即ち、gaSet.contains("\u{304C}")U+304Cというユニコードスカラ値がgaSetに含まれているかどうかを調べているに過ぎません。となると、当然、U+304CU+304BでもU+3099でもないので、結果は"false"となります。
一方、gaSet.contains("か")"true"になります。何故かは分かりますよね?

実態に即したコードは…

というわけで、CharacterSetは本当はUnicodeScalarSetだった訳です。本来は次のようなコードを書くことができれば勘違いは生まれないはずです:

がセット改.swift
// Swift 4.0現在 実装されていません。
let gaScalars = UnicodeScalarSet(unicodeScalarsIn:"\u{304B}\u{3099}")
print(gaScalars.contains("\u{304C}")) // Prints "false"
let gaCharacters = CharacterSet(charactersIn:"\u{304B}\u{3099}")
print(gaCharacters.contains("\u{304C}")) // Prints "true"

実は公式でも触れられている

StringManifestoの"Character and CharacterSet"に正にそういった問題が書いてあります(YOCKOWによる拙訳):

その名前にもかかわらず、CharacterSetは現在のところSwiftにおけるUnicodeScalar型に基づいて機能しています。即ち、Stringで使えるのはunicode scalar viewを通した時のみです。短期間でこの不調和を解決するのであれば、CharacterSetUnicodeScalarSetにリネームすればいいでしょう。長期的には、拡張書記素クラスタ(訳注: Unicodeにおける"一文字"を表すもので、SwiftでいうCharacter)のために同様の機能を提供するCharacterSetを導入するのが適切かもしれません。

おまけ

CharacterSetが実はUnicode.Scalarの集合だったということが分かったところで、次のコードを見てください:

国旗.swift
let jpFlag: Character = "🇯🇵" // Flag of Japan
let amFlag: Character = "🇦🇲" // Flag of Armenia
let paFlag: Character = "🇵🇦" // Flag of Panama

let japanAndArmenia: String = "\(jpFlag)\(amFlag)"
let paFlagSet = CharacterSet(charactersIn:"\(paFlag)")

let whichFlag = japanAndArmenia.components(separatedBy:paFlagSet).joined()
print(whichFlag) // Prints "🇯🇲" (Flag of Jamaica)

日本とアルメニアとパナマの国旗を文字列操作したらジャマイカの国旗が生まれましたとさ。何故そうなるのか?もうお分かりですね?



  1. ちなみに、U+309Bにも濁点(KATAKANA-HIRAGANA VOICED SOUND MARK)がありますが、これは"濁点という記号を表す文字"なのでU+304B U+309Bと並べても「が」ではなく「か゛」という二文字となります。 

  2. CharacterUnicode.ScalarExpressibleByStringLiteralというprotocolに準拠しているので、どちらも"\u{304C}"と書けてしまうところがミスリーディングを誘っています。