静かなる名辞

pythonとプログラミングのこと


【python】区切り文字を含めてsplitする

 
 正規表現によるsplitで区切り文字(あるいは区切り文字列)を含めたいときがある。デフォルトでは区切り文字は消える。

 たとえば、文を句点で分割する場合。

>>> import re
>>> string = "吾輩は猫である。名前はまだない。"
>>> re.split("。", string)
['吾輩は猫である', '名前はまだない', '']

 実は「区切り文字(列)をリストの要素にする」のか「区切り文字(列)を前の要素に(あるいは後の要素に)くっつける」のかという問題もあるので、一筋縄ではいかない。検索すると前の方ばっかり出てくるので注意。

注意

 この記事の内容は一部古いです。おそらく大半の読者が求めているであろうものは追記4にあります。そちらをご覧ください。

区切り文字をリストの要素に入れる

 これは検索してたくさん出てくる方。正規表現のグループ化というものを使えば簡単にできる。

>>> re.split("(。)", string) # 半角丸括弧でくくる
['吾輩は猫である', '。', '名前はまだない', '。', '']

 今回の例では、それが欲しいんじゃないんだけどなぁ・・・と相成る。

# こうなってほしい(空文字列はご愛嬌)
['吾輩は猫である。', '名前はまだない。', '']

区切り文字を前の要素に付ける。

 できるだけ簡単なやり方を考える。

後処理で連結する

 真っ先に思いつくのはこの方法。forループで処理する。

>>> lst = []
>>> for elem in re.split("(。)", string):
...     if elem != "。":
...         lst.append(elem)
...     else:
...         lst[-1] = lst[-1] + elem
... 
>>> lst
['吾輩は猫である。', '名前はまだない。', '']

 書き方は他にも幾らでもある。この書き方だと、区切り文字(列)が連続したときは連続が途切れるまで連結される。

 ただ、これは遅いかもしれない。

正規表現で書く

 rubyでやってるページは見つけた。
[Ruby]後読みを使ったsplitで句点(。)を残したまま文に分割 - Qiita

 ここの通りにやろうと思ったら、できませんでした・・・。

 rubyの正規表現のsplitとは仕様が違うのかなぁ。たぶん『長さゼロの文字列』へのマッチを何らかの方法で書けば同様にできると思われる。どうやるんだろうか、というかできるんだろうか。詳しい人に教えてほしい。

まとめ

 正規表現とかよくわからないけど、それに学習コスト投入するのは面倒くさいので騙し騙し適当に処理してプログラム書くことにします・・・。

追記

 findallすれば等価のことができた。

>>> re.findall(".*?。", s)
['吾輩は猫である。', '名前はまだない。']

 ただし厳密に言えばこれはsplitではない。なのでこうなる。

>>> s2 = "吾輩は猫である。名前はまだない。どこで生まれたかとんと見当がつかぬ"
>>> re.findall(".*?。", s2)
['吾輩は猫である。', '名前はまだない。']

 邪道だと思うが、

>>> s2 = "吾輩は猫である。名前はまだない。どこで生まれたかとんと見当がつかぬ"
>>> re.findall(".*?。|.*$", s2)
['吾輩は猫である。', '名前はまだない。', 'どこで生まれたかとんと見当がつかぬ', '']

 こんな方向性で工夫すればなんとかなる可能性はある。正規表現が得意な人なら上手く書く方法を思いつくんだろうなぁ。

追記2

 コメントでこういう場合の書き方を教えていただきました。

>>> s2 = "吾輩は猫である。名前はまだない。どこで生まれたかとんと見当がつかぬ"
>>> import re
>>> re.findall("[^。]+。?",s2)
['吾輩は猫である。', '名前はまだない。', 'どこで生まれたかとんと見当がつかぬ']

 やっぱりsplitとは違う気がするけど、実用的には良さそうです。

追記3

 ※この追記3は以前のバージョンの記述に基づいた内容です。バージョン3.7以降で事情が変わっているので、追記4をご参照ください。

 ドキュメントをつらつらと眺めていたら、関連する記述を見つけたのでメモ。

現在、split() は空のパターンマッチでは文字列を分割しません。例えば、次のようになります:

>>> re.split('x*', 'axbc')
['a', 'bc']

'x*' は 'a' の前、 'b' と 'c' との間、 'c' の後の 0 個の 'x' にもマッチしますが、現在これらのマッチは無視されます。正しい動作 (空のマッチでも文字列を分割し、['', 'a', 'b', 'c', ''] を返す) は、Python の将来のバージョンで実装されます。これは、後方互換生のない変更であるため、移行期間中は FutureWarning が送出されます。

空の文字列のみとマッチするパターンは、現在文字列を全く分割しません。これは望ましい動作ではないため、Python 3.5 から ValueError が送出されます:

>>> re.split("^$", "foo\n\nbar\n", flags=re.M)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  ...
ValueError: split() requires a non-empty pattern match.

re --- 正規表現操作 — Python 3.7.4rc1 ドキュメント

 将来的にはできるようになるけど、今のpythonは空のマッチはできないよ、と書いてある。

 たとえば、下記の記事のようにすると、

qiita.com

>>> import re
>>> re.split("(?<=。)", 'おはよう。こんにちは。こんばんは。')
ValueError: split() requires a non-empty pattern match.

 こうなるからできない、と。

 ただしその下に記述があるように、findall, finditerは空のマッチを含む。finditerなら位置の情報が得られる。

 なので、こんな感じで表題の通りの動作は実現できる。

>>> s = 'おはよう。こんにちは。こんばんは。'
>>> result = []
>>> before_start = 0
>>> for mobj in re.finditer("(?<=。)", 'おはよう。こんにちは。こんばんは。'):
...     result.append(s[before_start:mobj.start()])
...     before_start = mobj.start()
... 
>>> result
['おはよう。', 'こんにちは。', 'こんばんは。']

 これがさえたやり方かどうかはわからないけど、最初にやりたかったことには一番近いのかな・・・? という気がする。

追記4

 python3.7から空のマッチに対応し、上のような面倒なことが不要になりました。

バージョン 3.7 で変更: 空文字列にマッチしうるパターンでの分割をサポートするようになりました。

re --- 正規表現操作 — Python 3.7.4rc1 ドキュメント

>>> import re
>>> re.split("(?<=。)", 'おはよう。こんにちは。こんばんは。')
['おはよう。', 'こんにちは。', 'こんばんは。', '']

 ここにたどり着くまでは長い道のりでした。ようやく理想的な結果が得られました。