2015年10月20日

Swift 2.0でStringはどうなったか - 特殊文字などの扱い

Objective-CのNSStringの仕様は自然ではなかったので、Swiftには期待しています。
Swift2.0でStringの仕様がだいぶ変わったということで検証してみます。

昔は何が困ったか

今回のStringの仕様の検証は、昔の仕様で困っていた部分が動機になっているので、まずそれを書きます。NSStringでは文字を基本的に16ビットで扱っていますが、文字の中には16ビットで扱えないものがあり、そういうものは32ビットなど他の長さで使用します。そのごちゃごちゃはUnicodeの仕様上仕方ないのですが、NSStringではその32ビット文字を2文字としてカウントするなどUnicodeのごちゃごちゃをうまくさばききれていなくて扱いが雑な部分がありました。
欲しいものは、内部で何ビットだろうとどういうどういうデータ状態であろうと、人間が一文字と認識するものは一文字として扱うというポリシーに従ったAPIです。
Swiftが発表になったときにUnicodeとの親和性もアピールしていたので期待しています。



The Swift Programming Languageを読む

まずはいつものThe Swift Programming Languageで基本をおさらい。
・Stringはvalue typeである(reference typeではない)。
・Stringの文字列に含まれている個々の文字へのアクセスにはStringのcharactersプロパティからアクセス出来る。
・SwiftのStringは内部ではUnicode scalarをベースにしている。Unicode scalarとは文字や記号に対応する21ビットの数。U+0000からU+D7FFとU+E000からU+10FFFFがある。U+D800からU+DFFFは含まない。U+が何かというのは大変ややこしいので割愛。
・人間が文字と認識する形式がExtended Grapheme Cluster。SwiftのCharacterは一つのExtended Grapheme Clusterに対応。一つのExtended Grapheme Clusterは一つ以上のUnicode scalarで構成される
・Stringの文字数カウントはcharactersプロパティのcountプロパティで取得出来る。取得されるのはCharacterの数で、CharacterはExtended Grapheme Clusterをベースにしているので人間が文字と認識する形式で文字数が取得できる。
・String内の文字の位置(Characterの集まりとして見たときの各Characterの位置)はString.Indexという型によって表す。Stringのsubscript機能のアクセスに用いることが出来る。実態が少し疑問なので要調査。
・StringやCharacterの比較(==)はextended grapheme clusterを基に行われ、文字の意味が同じであればUnicode scalarが違っていても同一とみなす。
・StringをUTFエンコードされたデータとして取得したいときはutf8プロパティ、utf16プロパティ、unicodeScalarsプロパティを利用して取得できる。



Standard Libraryを読む

次にStandard Libraryを読みます。Standard Library Referenceも併用しながら見ていきます(便利なので)。

struct String {...}
Stringはstructとして宣言されている。

String内を見てみる(ほとんどはextension部分で定義されている)。
Stringの中でさらにstructなどが宣言されている
//かなりの抜粋
struct CharacterView : CollectionType {...}
var characters: String.CharacterView
ちょっとわかりにくいけどString内でCharacterViewというstructが宣言されていて、その宣言された型のプロパティを持つ。

そのCharacterViewをの中身を見てみる(ほとんどはextension部分で定義されている)。
CharacterViewは"Characterを集めたもの"という見方をしてよいというのがネーミング由来かと思われる。
//かなりの抜粋
struct Index {
  func successor() -> String.CharacterView.Index
  func predecessor() -> String.CharacterView.Index
}
var startIndex: String.CharacterView.Index
var endIndex: String.CharacterView.Index
subscript (i: String.CharacterView.Index) -> Character
subscript (subRange: Range<String.CharacterView.index="">) -> String.CharacterView
注目はIndex、これはstructで宣言されている。進む戻るのメソッドがあるしsubscriptの引数にも使われる。
Standard Libraryの学習用のPlayground内の情報によるとIndexは基本的にはString内の文字の位置を示すもので、文字ごとに異なるデータ量の違いを吸収してプログラマから(何文字目かなどの)文字指定を操作しやすくするコンセプトのものらしい。内部に整数を保持しているのかと思ってその数の定義箇所を確かめようとしたがちょっと見当たらず。
文字はメモリ内で何バイト使うかというのがバラバラなのでIndexに要求される機能は多くなりそう。
Indexの中身を予想すると
1先頭から何文字目か
2先頭から何バイト目か
3その文字は何バイト使っているか
などの情報を扱っていると思う。
これらはIndexの中に保持しているというより一つのString分をまるごとどこかに保持しているかもしれない。


CharacterViewはCollectionTypeプロトコルを付けているのでcountプロパティを持つ。
String内でCharacterViewに似ているものが他にも宣言されていて、
UnicodeScalarView
UTF16View
UTF8Viewがある。



基本プログラム

宣言と初期化
Unicode scalar値でStringを作ることが出来る。一文字だけならCharacterも同じように作れる。
//Character
var dollarSignChar: Character = "\u{24}"
var blackHeartChar: Character = "\u{2665}"
var sparklingHeartChar: Character = "\u{1F496}"

//1文字のString
var dollarSignStr: String = "\u{24}"
var blackHeartStr: String = "\u{2665}"
var sparklingHeartStr: String = "\u{1F496}"

//2文字のString
var dollarSigns: String = "\u{24}\u{24}"
var blackHearts: String = "\u{2665}\u{2665}"
var sparklingHearts: String = "\u{1F496}\u{1F496}"

単なる"a"は何型になるかPlaygroundでやってみる
var a = "a"
a is String//true
a is Character//false
var a: Character = "a"
a is String//false
a is Character//true
なにも明示しなければString、Characterと明示すればCharacterだった。


文字カウント
charactersプロパティのcountというプロパティで取得できます。
UTF8とUTF16のそれぞれのエンコードによる文字数(?)も取得出来る
countプロパティはCollectionTypeというプロトコルで宣言されてます。UTF8View、UTF16View、UnicodeScalarView、CharacterViewは皆、宣言にCollectionTypeプロトコルが付いています。
str.countで可能なようにして欲しい。
sparklingHeart = "\u{1F496}"//きらきらハートマーク
var numInCharacter = sparklingHeart.characters.count//1
var numInUTF8 = sparklingHeart.utf8.count//4
var numInUTF16 = sparklingHeart.utf16.count//2


subscriptの使い方
var str: String = "ABCDE"
str[str.startIndex]//"A"
//str[str.endIndex]//これは実行時エラー
str[str.endIndex.predecessor()]//"E"
str[str.startIndex.successor()]//"B"
str[str.startIndex.advancedBy(2)]//"C"
str[str.startIndex..<str.endIndex]//"ABCDE"
str[str.startIndex.successor()..<str.endIndex.predecessor()]//"BCD"
Indexを与えるとCharacterが返ってきて、Rangeを与えるとStringが返ってきます。


append(連結 )系
var str: String = "A"
str.append(Character("B"))
str.append(UnicodeScalar("C"))
str.appendContentsOf("DEF")
let charactersGHI: [Character] = ["G","H","I"]
str.appendContentsOf(charactersGHI)
StringをappendするときでもappendStringではない。appendContentsOfらしい。append出来るものはStringでなくても内部にCharacterを持つものであれば連結が可能。それもappendContentsOfというメソッド名。つまりappendContentsOfはStringを引数に持つものと内部にCharacterを持つものの両方が定義されている。名前を揃えて覚えやすくしたのか?appendStringがいいけど。


insert(挿入)系
str = "ABCDE"
str.insert(Character("c"), atIndex: str.startIndex.advancedBy(3))//ABCcDE
let charactersddd: [Character] = ["d","d","d"]
str.insertContentsOf(charactersddd, at: str.startIndex.advancedBy(5))//ABCcDdddE
let strbbb = "bbb"
str.insertContentsOf(strbbb.characters, at: str.startIndex.advancedBy(2))//ABbbbCcDdddE
insert系は2つ。Characterを受け付けるものと、"Characterの集まり"を受け付けるもの。Characterを受け付けるものはatIndexなのに"Characterの集まり"を受け付けるもののほうはat。


remove(削除)系
str = "ABCDE"
str.removeAtIndex(str.startIndex.advancedBy(2))//ABDE
str.removeRange(str.startIndex.advancedBy(1)..<str.startIndex.advancedBy(3))//AE
str.removeAll()//空
remove系は単独削除、複数削除、全て削除の3つ。全て削除の派生メソッドとしてキャパシティーを保持するかどうか指定するメソッドもある。


replace(交換)系
str = "ABCDE"
str.replaceRange(str.startIndex.advancedBy(1)..<str.startIndex.advancedBy(4), with: "bcd")
//AbcdE
str.replaceRange(str.startIndex.advancedBy(1)..<str.startIndex.advancedBy(4), with: ["い","う","え"])//AいうえE
str.replaceRange(str.startIndex.advancedBy(2)..<str.startIndex.advancedBy(3), with: "ウ")//AいウえE
replace系はStringを受け付けるものと、"Characterの集まり"を受け付けるもの。

連結、挿入、交換でStringを受け付けたり受け付けなかったり、Characterを受け付けたり受け付けなかったりしてバラバラな印象がある。今後のバージョンアップで統一されていくかもしれない。

疑問
IndexをIndex()のように作成することが出来ない。どのようにすればいいのか
引数でIndexを与えるときに簡単に書けるやり方があるか
startIndexは常に0(に該当するもの)のような気がするがこのプロパティどんなときに0以外になるのか



C言語の文字列の扱い

C言語文字列からStringインスタンスを作成するメソッドがある。クラスメソッドの
static func fromCString(cs: UnsafePointer<CChar>) -> String?
static func fromCStringRepairingIllFormedUTF8(cs: UnsafePointer<CChar>) -> (String?, hadError: Bool)
これらはnul-terminatedされたUTF-8フォーマットのものを受け付ける。
与えられたものがNULLならnilを返す。
2つのメソッドの違いはUTF-8解釈できないものがあるときに
上はnilを返す。
下はU+FFFDに置き換える。(たぶん置き換えがあったときにhadErrorがtrue)
これらのメソッドには@warn_unused_resultが付いている。@warn_unused_resultの詳細は保留。
他のエンコードされたデータから変換するメソッドは、Swift2.0のスタンダードライブラリにあるのはこれだけ。



別のエンコードのものを標準インスタンスにしたい(指定エンコードデータ→標準インスタンス)

Shift-JISなどのエンコードとの変換はNSStringのメソッドを使う方法しか見当たりませんでした。

指定エンコードデータから標準インスタンスへの変換は初期化のメソッドにいろいろあります。どれもinitにクエスチョンマークがついているのでnilを返す可能性があります。見た目はキャスト処理などもなく普通に実行していますが、メソッドが定義されているのはNSStringです。

Byteから
convenience init?(bytes bytes: UnsafePointer<void>, length len: Int,encoding encoding: UInt)
No copyバージョンのもある

NSDataから
convenience init?(data data: NSData,encoding encoding: UInt)
//使用例
let strKanji = String(data: dataKanji, encoding: NSShiftJISStringEncoding)!
びっくりマークが要ります。

unicharから
convenience init(characters characters: UnsafePointer<unichar>,length length: Int)
No copyバージョンのもある

CStringから
convenience init?(CString nullTerminatedCString: UnsafePointer<int8>,encoding encoding: UInt)

UTF8から
convenience init?(UTF8String nullTerminatedCString: UnsafePointer<int8>)
上の方で述べたようにSwiftスタンダードライブラリにもUTF8からの変換があります。

NSStringのメソッドにはクラスメソッドでインスタンスを作るやりかたもありますが、Swiftからの呼び出しはざっくり削除されてました(動作は未確認)


指定したエンコードで取り出したい(標準インスタンス→指定エンコードデータ)

こっちはシンプルです。

基本的にこれです
func dataUsingEncoding(_ encoding: UInt) -> NSData?

あいまいな変換でも許容するなら
func dataUsingEncoding(_ encoding: UInt,allowLossyConversion lossy: Bool) -> NSData?
あいまいについては次の項で確認する。

コンバート可能かどうか調べるなら
func canBeConvertedToEncoding(_ encoding: UInt) -> Bool



特殊な漢字への対応

第一水準から第四水準という日本での漢字の分類がある。数字が大きくなるほうに仕様頻度の低いものが割り当てられている。ある時期のObjective-Cでは標準インスタンスとShiftJISなどの別エンコーディングデータとの変換は第二水準まで行えた。Swift2.0の時点ではどうなっているか。といってもSwiftのスタンダードライブラリにはそのメソッドがないのでNSStringのメソッドを使う。NSStringのメソッドを使う時点で期待が薄いがやってみる。

まずは少し上で見た
func canBeConvertedToEncoding(_ encoding: UInt) -> Bool
をplayground上で実行

結果
"亜".canBeConvertedToEncoding(NSShiftJISStringEncoding)//第一水準 結果はtrue
"弌".canBeConvertedToEncoding(NSShiftJISStringEncoding)//第二水準 結果はtrue
"俱".canBeConvertedToEncoding(NSShiftJISStringEncoding)//第三水準 結果はfalse
"丂".canBeConvertedToEncoding(NSShiftJISStringEncoding)//第四水準 結果はfalse

"亜".canBeConvertedToEncoding(NSJapaneseEUCStringEncoding)//第一水準 結果はtrue
"弌".canBeConvertedToEncoding(NSJapaneseEUCStringEncoding)//第二水準 結果はtrue
"俱".canBeConvertedToEncoding(NSJapaneseEUCStringEncoding)//第三水準 結果はfalse
"丂".canBeConvertedToEncoding(NSJapaneseEUCStringEncoding)//第四水準 結果はfalse

"亜".canBeConvertedToEncoding(NSISO2022JPStringEncoding)//第一水準 結果はtrue
"弌".canBeConvertedToEncoding(NSISO2022JPStringEncoding)//第二水準 結果はtrue
"俱".canBeConvertedToEncoding(NSISO2022JPStringEncoding)//第三水準 結果はfalse
"丂".canBeConvertedToEncoding(NSISO2022JPStringEncoding)//第四水準 結果はfalse
とやはり、第三第四水準は変換が出来ない。


実際にコンバートしてみると
結果
"亜".dataUsingEncoding(NSShiftJISStringEncoding)//第一水準 結果は 889f
"弌".dataUsingEncoding(NSShiftJISStringEncoding)//第二水準 結果は 989f
"俱".dataUsingEncoding(NSShiftJISStringEncoding)//第三水準 結果はnil
"丂".dataUsingEncoding(NSShiftJISStringEncoding)//第四水準 結果はnil

"亜".dataUsingEncoding(NSJapaneseEUCStringEncoding)//第一水準 結果は b0a1
"弌".dataUsingEncoding(NSJapaneseEUCStringEncoding)//第二水準 結果は d0a1
"俱".dataUsingEncoding(NSJapaneseEUCStringEncoding)//第三水準 結果はnil
"丂".dataUsingEncoding(NSJapaneseEUCStringEncoding)//第四水準 結果はnil

"亜".dataUsingEncoding(NSISO2022JPStringEncoding)//第一水準 結果は 1b244230 211b2842=""
"弌".dataUsingEncoding(NSISO2022JPStringEncoding)//第二水準 結果は 1b244250 211b2842=""
"俱".dataUsingEncoding(NSISO2022JPStringEncoding)//第三水準 結果はnil
"丂".dataUsingEncoding(NSISO2022JPStringEncoding)//第四水準 結果はnil
変換出来ないのはnilが返ってくる。

で、気になるあいまいを許す変換メソッドを実行してみると
結果
"亜".dataUsingEncoding(NSShiftJISStringEncoding, allowLossyConversion: true)//第一水準 結果は 889f
"弌".dataUsingEncoding(NSShiftJISStringEncoding, allowLossyConversion: true)//第二水準 結果は 989f
"俱".dataUsingEncoding(NSShiftJISStringEncoding, allowLossyConversion: true)//第三水準 結果は 3f
"丂".dataUsingEncoding(NSShiftJISStringEncoding, allowLossyConversion: true)//第四水準 結果は 3f

"亜".dataUsingEncoding(NSJapaneseEUCStringEncoding, allowLossyConversion: true)//第一水準 結果は b0a1
"弌".dataUsingEncoding(NSJapaneseEUCStringEncoding, allowLossyConversion: true)//第二水準 結果は d0a1
"俱".dataUsingEncoding(NSJapaneseEUCStringEncoding, allowLossyConversion: true)//第三水準 結果は 3f
"丂".dataUsingEncoding(NSJapaneseEUCStringEncoding, allowLossyConversion: true)//第四水準 結果は 3f

"亜".dataUsingEncoding(NSISO2022JPStringEncoding, allowLossyConversion: true)//第一水準 結果は 1b244230 211b2842=""
"弌".dataUsingEncoding(NSISO2022JPStringEncoding, allowLossyConversion: true)//第二水準 結果は 1b244250 211b2842=""
"俱".dataUsingEncoding(NSISO2022JPStringEncoding, allowLossyConversion: true)//第三水準 結果は 3f
"丂".dataUsingEncoding(NSISO2022JPStringEncoding, allowLossyConversion: true)//第四水準 結果は 3f
となった。変換出来ないのは3fというデータが返って来ている。これは"?"という文字の番号。つまり変換出来ない文字には"?"が当てはめられている。

Unicodeで定義されていてもShiftJISとの変換処理のところが対応されていないと変換出来ない模様。
ただ、第三水準からランダムに取り出して試してみたときにいくつかは変換可能の結果が返ってきた。第三水準でも多少は変換できるものがあるらしい。

ちなみにUTF系に対しては普通に変換が可能
"亜".canBeConvertedToEncoding(NSUTF32StringEncoding)//第一水準 結果はtrue
"弌".canBeConvertedToEncoding(NSUTF32StringEncoding)//第二水準 結果はtrue
"俱".canBeConvertedToEncoding(NSUTF32StringEncoding)//第三水準 結果はtrue
"丂".canBeConvertedToEncoding(NSUTF32StringEncoding)//第四水準 結果はtrue

つまり
・Unicodeエンコーディングを使っているうちは第4水準まで扱いがOK
・JIS、シフトJIS、EUCのエンコードに変換が可能なのは第2水準まで(おそらくそれらのエンコードからの変換も第2水準まで、未確認)
となりました。Swift2.0の時点ではこのようですが今後変更になる可能性はあります。



異体字

葛飾区の「葛」のように一つの漢字にいろいろな細かいバリエーションがあるものがある。この違いを扱うには異体字セレクタを用いる。データ上はベースとなる番号に異体字セレクタを続ける形になる。Swiftはこれをどう処理するか。
まず、
・ノーマル
・異体字セレクタU+E0100付き
・異体字セレクタU+E0101付き
の3つでインスタンスを作りそれぞれのいろいろなフォーマットのcountプロパティを見てみる
var itaiji: String = "\u{845B}"
var itaiji0: String = "\u{845B}\u{E0100}"
var itaiji1: String = "\u{845B}\u{E0101}"
itaiji.characters.count//1
itaiji.utf8.count//3
itaiji.utf16.count//1
itaiji0.characters.count//1
itaiji0.utf8.count//7
itaiji0.utf16.count//3
itaiji1.characters.count//1
itaiji1.utf8.count//7
itaiji1.utf16.count//3
charactersはどれも1、utf8とutf16はノーマルと異体字セレクタ付きで値が変わる。UTF16で異体字セレクタは2つ分になるので上の3というのは予想通り。
異体字セレクタが付いていてもcharactersの文字カウントは"ひともじ"で行うことが確認出来た。
(フォントも含めて対応している必要があるが)playgroundでの表示でも違いが確認出来た。この字のノーマルとU+E0101付きは一緒とのこと。
"ひともじ"として扱えるならCharacterインスタンスも作れる
var itaijiChar: Character = "\u{845B}"
var itaijiChar0: Character = "\u{845B}\u{E0100}"
var itaijiChar1: Character = "\u{845B}\u{E0101}"
エラー出ず。

つまり
異体字セレクタ(異体字情報データ)は異体字セレクタとして扱っている

0 件のコメント:

コメントを投稿