例えばですね、とある文字列から記号を抜いた文字列を抜き出したくなるようなことって、タマにあるじゃないですか。
もしくは、逆に数字のみ抽出したくなったりとか。
そのようなことを、Swift を使って iOS でなるべくお手軽に実現しようという趣旨の記事になります。
読み込み中です。少々お待ち下さい
前置き
「特定の文字を除外したり抽出するには、まず除外したり抽出したい文字のセットを用意しなくちゃいけないんじゃないの? それが面倒臭いんだよね」
このようにお考えの方もいらっしゃるかも知れません。
ご安心ください。
汎用的に利用できる範囲については、ジツは CharacterSet クラスで予め定義されていますので、わざわざ自分で文字のセットを用意したりする必要はありません(任意の文字を自分で指定することも、もちろん可能です)。
どのようなものが用意されているか、CharacterSet のソースから抜粋してみましょう。
/// Returns a character set containing characters in Unicode General Category Z*, `U+000A ~ U+000D`, and `U+0085`.
public static var whitespacesAndNewlines: CharacterSet { get }
/// Returns a character set containing the characters in the category of Decimal Numbers.
public static var decimalDigits: CharacterSet { get }
/// Returns(筆者中略)in Unicode General Category L* & M*.
public static var letters: CharacterSet { get }
/// Returns(筆者中略)in Unicode General Category Ll.
public static var lowercaseLetters: CharacterSet { get }
/// Returns(筆者中略)in Unicode General Category Lu and Lt.
public static var uppercaseLetters: CharacterSet { get }
/// Returns(筆者中略)in Unicode General Category P*.
public static var punctuationCharacters: CharacterSet { get }
/// Returns(筆者中略)in Unicode General Category S*.
public static var symbols: CharacterSet { get }
ここで挙げているのは、あくまで一部です。より詳しくは、CharacterSet - Foundation | Apple Developer Documentationをご覧ください。
さて、用意されているは良いものの、コメントで書かれているように Unicode の一般(General)カテゴリの P* です L* ですと言われて、「ああ、あれね」とたちどころに得心できる人は、あんまり多くないのではないでしょうか。
Unicode の General Category については、例えば unicode.org の以下の辺りを参照してください。
まー、非常に大雑把に申し上げますと、L* がアルファベットやかな漢字等の通常の文字、M* が結合文字、N* が数字、P* が句読点や括弧等の約物、S* がシンボル、Z* が改行や空白等のセパレータという感じでしょうか。
と言われても、具体的にどのような文字が含まれているのか、実際に目で確認できないことにはピンとこない向きもあるかと思います。
そのような場合は、以下のサイトを参照すると良いかも知れません。
- Unicode Character Categories (FileFormat.Info)
環境によって表示できない文字については、画像で確認することも可能です。素晴らしいですね。
文字列から特定の文字を削除する
以上を踏まえた上で、とある文字列から特定の文字を削除するコードを考えてみましょう。
素直に filter を使っても良いのですが、ここではよりお手軽に、文字列を String.components(separatedBy: CharacterSet) を使って指定文字で String 配列に分割し、その配列を joined することで結果として削除する方法をとってみましょう。
let str = "abcXYZアイウ !#$%()*+,-./012:;=?@[\\]^_`{|}~\tabcXYZア、い。宇 !#$%()*+,-./789:;=?@[\]^_`{|}~゛†"
let excludes = CharacterSet(charactersIn: " !#$%()*+,-./789:;=?@[\]^_`{|}~゛†")
print("( そのまま ) 1:\(str)")
print("(制御文字除外) 2:\(str.components(separatedBy: CharacterSet.controlCharacters).joined())")
print("( 数字除外 ) 3:\(str.components(separatedBy: CharacterSet.decimalDigits).joined())")
print("(通常文字除外) 4:\(str.components(separatedBy: CharacterSet.letters).joined())")
print("( 約物除外 ) 5:\(str.components(separatedBy: CharacterSet.punctuationCharacters).joined())")
print("(シンボル除外) 6:\(str.components(separatedBy: CharacterSet.symbols).joined())")
print("( 空白除外 ) 7:\(str.components(separatedBy: CharacterSet.whitespaces).joined())")
print("(任意指定除外) 8:\(str.components(separatedBy: excludes).joined())")
↓ 実行結果
( そのまま ) 1:abcXYZアイウ !#$%()*+,-./012:;=?@[\]^_`{|}~ abcXYZア、い。宇 !#$%()*+,-./789:;=?@[\]^_`{|}~゛†
(制御文字除外) 2:abcXYZアイウ !#$%()*+,-./012:;=?@[\]^_`{|}~abcXYZア、い。宇 !#$%()*+,-./789:;=?@[\]^_`{|}~゛†
( 数字除外 ) 3:abcXYZアイウ !#$%()*+,-./:;=?@[\]^_`{|}~ abcXYZア、い。宇 !#$%()*+,-./:;=?@[\]^_`{|}~゛†
(通常文字除外) 4: !#$%()*+,-./012:;=?@[\]^_`{|}~ 、。 !#$%()*+,-./789:;=?@[\]^_`{|}~゛†
( 約物除外 ) 5:abcXYZアイウ $+012=^`|~ abcXYZアい宇 $+789=^`|~゛
(シンボル除外) 6:abcXYZアイウ !#%()*,-./012:;?@[\]_{} abcXYZア、い。宇 !#%()*,-./789:;?@[\]_{}†
( 空白除外 ) 7:abcXYZアイウ!#$%()*+,-./012:;=?@[\]^_`{|}~abcXYZア、い。宇!#$%()*+,-./789:;=?@[\]^_`{|}~゛†
(任意指定除外) 8:abcXYZアイウ !#$%()*+,-./012:;=?@[\]^_`{|}~ abcXYZア、い。宇
環境によって表示できない文字が含まれていたり折り返しが発生すると、Xcode のコンソールで標準出力の表示が見かけ上おかしくなる場合がありますので、ご注意ください。
さて、ここで注目すべきは、伝統的な呼び方で言うところの全角(マルチバイト)文字が半角文字と同様に処理されているところです。
英数字が半角全角関わりなく処理されていることは当然として、例えば CharacterSet.letters を指定した場合は、ちゃんとかな漢字も除外されていることが分かります。
さらに、CharacterSet.punctuationCharacters を指定した場合に、カンマやピリオドだけでなく、日本語の句読点(、。等)も処理されています。
さすがは Unicode ですね。
さらに、CharacterSet は union できるので、複数を組み合わせることも簡単です。
例えば、以下のように記述すると、記号の類いをほぼ除外できます。
let newStr = str.components(separatedBy: CharacterSet.controlCharacters
.union(CharacterSet.whitespaces)
.union(CharacterSet.punctuationCharacters)
.union(CharacterSet.symbols)
).joined()
これ、自分でイチから記号のセットを用意したりすると、大変ですからね。
必要に応じて上手いこと組み合わせて使えば、大抵のケースでお手軽に対応できるのではないでしょうか。
(ループ実行する場合は、メモリ消費を考慮して、ループ外で予め union したものを使ってください)
補足:
Xcode 等の Unicode 対応のエディタで入力&選択してみると目に見えて分かるのですが、伝統的な文字コードと異なり半角濁点「゙」(゙) も Unicode 的に結合文字として扱われるようで、例えば半角の「バ」は合成文字として扱われるので「゙」を単独で選択することができません(Safari や Chrome 等の一部の Web ブラウザでも試せます。ちなみに、伝統的に日本語 Windows で SJIS(CP932) を扱っていた事情も手伝ってか、Microsoft Edge は「゙」を単独で選択できます)。
要するに、「バ」は「バ」(バ) と同様の扱いです。
なので、CharacterSet.nonBaseCharacters を指定して半角の「゙」を除外しようとしても、「バ」が「ハ」にならないのと同様に、前の文字とくっついて削除されません。
通常は気にする必要は無いというか、むしろ望ましい結果かと思いますが、いちおう補足として。
ちなみに、全角文字の「゛(゛)」は CharacterSet.symbols で除外できますが、全角濁点の結合文字は「゙(゙)」なので悪しからず。
この辺りは、話題の性質として Web ブラウザ上では表現し難いので、もし何れかの文字を扱っていて「ん?」と思った時は、デバッガー等で String に実際に設定されている文字番号を調べてみると良いと思います。
特定の文字だけを抽出する
逆の発想として、例えば通常文字だけを抜き出したいというケースもあるかと思います。
その場合は、やはり filter を使うのが簡単でしょうか。
let str = "abcXYZアイウ !#$%()*+,-./012:;=?@[\\]^_`{|}~\tabcXYZア、い。宇 !#$%()*+,-./789:;=?@[\]^_`{|}~゛†"
print(String(str.unicodeScalars
.filter(CharacterSet.letters.contains)
.map(Character.init)))
↓ 出力結果
abcXYZアイウabcXYZアい宇
上と同様に union を利用した組み合わせにも対応可能です。
例えば、CharacterSet.letters.union(CharacterSet.decimalDigits) したものを使えば、文字+数字を抽出できます。
また、任意の文字を抽出したい場合は、上で見たように CharacterSet(charactersIn: String) で必要な文字を指定して CharacterSet のインスタンスを生成し、サンプルコードの CharacterSet.letters と入れ替えれば良いだけです。
さて、CharacterSet.letters は「Unicode General Category L* & M*」ですので、今度はここからさらに「Ll」を除外するケースを考えてみましょう(つまり、小文字だけ除く)。
上で見たように String.components(separatedBy: CharacterSet) と joined を使っても良いのですが、ここではバリエーションとしてあえて filter で書いてみましょう。
print(String(str.unicodeScalars
.filter(CharacterSet.letters.contains)
.filter({!CharacterSet.lowercaseLetters.contains($0)})
.map(Character.init)))
↓ 出力結果
XYZアイウXYZアい宇
あまり美しくない気がしますが、こんな感じになるでしょうか。
補足:
話の展開的に、どちらにせよ今回は採用しませんが、もうひとつ filter を書かなくても、CharacterSet.letters から subtracting で lowerCase だけを削除できるかと思いきや、どうも記事執筆時点では上手く動かないようです(実行するとメモリを大量に消費してアプリが落ちる。自分で作った小さな CharacterSet に対して実行しても駄目だし、ついでに symmetricDifference も同様に落ちる)。
また、CharacterSet には remove もありますが、次の話題の都合上、ここでは filter で説明しておきたかったので、今回はこれでお願いします(何が)
まぁ、話の都合を無視すれば、上の例はそもそも uppercaseLetters を指定すれば良いだけの話ですからね(笑)
さらに加工する例を、単に提示したかっただけという体で、どうかひとつ。
パフォーマンスを比較
最後に、String.components(separatedBy: CharacterSet).joined() を使った削除と、filter を使った場合のパフォーマンスを、簡単に比較してみましょう。
let str = "abcXYZアイウ !#$%()*+,-./012:;=?@[\\]^_`{|}~\tabcXYZア、い。宇 !#$%()*+,-./789:;=?@[\]^_`{|}~゛†"
print("a-1:\(str.components(separatedBy: CharacterSet.letters).joined())")
let startA1 = Date() // a-1: String の separate & join による削除 × 10 万回
for _ in 0...100000 {
let _ = autoreleasepool { () -> String in
return str.components(separatedBy: CharacterSet.letters).joined()
}
}
print("\(Date().timeIntervalSince(startA1)) secs.")
print("a-2:\(String(str.unicodeScalars.filter({!CharacterSet.letters.contains($0)}).map(Character.init)))")
let startA2 = Date() // a-2: filter による削除 × 10 万回
for _ in 0...100000 {
let _ = String(str.unicodeScalars.filter({!CharacterSet.letters.contains($0)}).map(Character.init))
}
print("\(Date().timeIntervalSince(startA2)) secs.")
print("a-3:\(String(str.unicodeScalars.filter(CharacterSet.letters.contains).map(Character.init)))")
let startA3 = Date() // a-3: filter による抽出 × 10 万回(おまけ)
for _ in 0...100000 {
let _ = String(str.unicodeScalars.filter(CharacterSet.letters.contains).map(Character.init))
}
print("\(Date().timeIntervalSince(startA3)) secs.")
↓ iPhone 6 Plus 実機での実行結果
a-1: !#$%()*+,-./012:;=?@[\]^_`{|}~ 、。 !#$%()*+,-./789:;=?@[\]^_`{|}~゛†
8.41698098182678 secs.
a-2: !#$%()*+,-./012:;=?@[\]^_`{|}~ 、。 !#$%()*+,-./789:;=?@[\]^_`{|}~゛†
26.4906960129738 secs.
a-3:abcXYZアイウabcXYZアい宇
7.26631796360016 secs.
3つ目はおまけです。
1つ目のやり方で単純に実装すると、どんどんメモリを食ってしまうので、ここでは autoreleasepool を使っています(通常は、必要が無ければ指定する必要はありません)。
思ったより差がついたように見えますが、対象の文字列があの程度の長さであれば、一度に 1 万回繰り返しても 2 秒かそこらで終わるということでもあり、果たして iOS 上で短時間にそれほど処理を繰り返すことがあるだろうかということを考慮すると、そこまで致命的な差ではないような気もします。
まぁ、対象の文字列があまり大きくなくて、且つある程度の頻度で処理をすることが見込まれている場合は、速度的な面でいえば separete & join の方が有利かも知れません、というくらいでしょうか。
また、当然ですが対象の文字列が大きければ大きいほど、処理時間は増大する道理です。
ということで、約 100KB の意味のある文字列(とあるソースコードのコピペ加工)を用意して、そちらでも計ってみました。
print("b-1:\(text100kb.components(separatedBy: CharacterSet.letters).joined().characters.count)")
let startB1 = Date() // b-1: 100KB のテキストを separate & join × 100 回
for _ in 0...100 {
let _ = autoreleasepool { () -> String in
return self.text100kb.components(separatedBy: CharacterSet.letters).joined()
}
}
print("\(Date().timeIntervalSince(startB1)) secs.")
print("b-2:\(String(text100kb.unicodeScalars.filter({!CharacterSet.letters.contains($0)}).map(Character.init)).characters.count)")
let startB2 = Date() // b-2: 100KB のテキストから filter で削除 × 100 回
for _ in 0...100 {
let _ = String(text100kb.unicodeScalars.filter({!CharacterSet.letters.contains($0)}).map(Character.init))
}
print("\(Date().timeIntervalSince(startB2)) secs.")
print("b-3:\(String(text100kb.unicodeScalars.filter(CharacterSet.letters.contains).map(Character.init)).characters.count)")
let startB3 = Date() // b-3: 100KB のテキストから filter で抽出 × 100 回(おまけ)
for _ in 0...100 {
let _ = String(text100kb.unicodeScalars.filter(CharacterSet.letters.contains).map(Character.init))
}
print("\(Date().timeIntervalSince(startB3)) secs.")
↓ iPhone 6 Plus 実機での実行結果
b-1:40950
15.2936699986458 secs.
b-2:40950
22.0291740298271 secs.
b-3:61188
11.1415829658508 secs.
100 回繰り返して 15 秒とか 22 秒なので、100KB の文字列なら 2 世代前の iPhone 6 Plus でも大体 150 ~ 200 ミリ秒くらいで処理できる感じですね。
対象の文字列のサイズが大きければ大きいほど、おそらく separate & join のパフォーマンスは落ちていくと思われます。
つまり、大きなデータを扱う可能性がある場合は、filter の方がパフォーマンスが安定し易いと言えるかも知れません。
とはいえ、iPhone で例えば MB 単位のテキストを加工するかと言ったら、そのような場面は正直それほど多くないと思いますので、このサイズがこのくらいの速度で動くなら、正直どちらでもいいんじゃないかなーという気がします。
使用頻度と処理対象文字列のサイズ(メモリ上の大きさ)を考慮しつつ、プロジェクトの方針に従って、どちらを使うか決定すれば良いのではないでしょうか。
というか、filter は not contains(除外)ではなく、3番めの contains(抽出)だと速度的にも見劣りしないので、もしかしたら2番目はもっと良い書き方があるのかも知れません。
もしご存知でしたら、そちらをお使いください。
おわりに
大した内容でもないのに、改めて裏を取ったり検証してると時間がかかって仕方ないので、他にやることがある時は手を出すべきじゃないなと思いました。
まぁ、当たり前ですけどね(笑)