【Python】日本語のモーラ列を仮名文字のシーケンスとして管理するクラス

どれだけ需要があるのか分かりませんが、とりあえず形になったので公開します。

## 目次
[はじめに](#はじめに)
[モーラを文字列リストで扱うときの問題点](#モーラを文字列リストで扱うときの問題点)
[MoraStr クラス](#morastr-クラス)
[導入方法](#導入方法)
[モジュールの特徴](#モジュールの特徴)
[今後検討してみたいこと](#今後検討してみたいことやるとは言ってない)
[C 言語で Python モジュールを作ることについて（ライブラリ開発者向け）](#c-言語で-python-モジュールを作ることについてライブラリ開発者向け)
[おわりに](#おわりに)

## はじめに

日本語の音の長さを数える単位として[モーラ](https://ja.wikipedia.org/wiki/%E3%83%A2%E3%83%BC%E3%83%A9)があります。俳句や川柳、短歌でいう「字」はこのモーラに対応します。

Pythonで日本語の文章中に含まれるモーラを数えたり、モーラ単位で分割したりする手法については、既にいくつか記事があります。

https://qiita.com/hatt_takumi/items/b4e45c43cb565f3a970a

https://qiita.com/shimajiroxyz/items/a133d990df2bc3affc12

[[MeCabを使った575判定]](https://www.estie.jp/blog/entry/2022/11/10/110000)

分割したモーラ列を管理する方法としては、各モーラに対応する文字列のリスト（あるいはタプル）を用いるのが手軽そうです。

```Python
['チョ', 'コ', 'レ', 'ー', 'ト']                   # 'チョコレート'
['ガ', 'ッ', 'キュ', 'ー', 'シ', 'ン', 'ブ', 'ン']  # 'ガッキューシンブン'
```

たいていの場合はこれで事足りるのですが、少し不便なこともあります。

## モーラを文字列リストで扱うときの問題点
### 切り出したモーラ列を再び文字列として扱う

リストのスライスを `''.join()` で再結合する必要があります。記述量的には大したことないんですが、ちょっと見にくいです。

```Python
moras = ['ガ', 'ッ', 'キュ', 'ー', 'シ', 'ン', 'ブ', 'ン']
text = ''.join(moras[:4])                                # 4モーラ目までを文字列化
```

### 部分モーラ列の判定

対象のモーラ列の中に任意の1モーラが含まれているかどうかは、`<文字列> in <リスト>` で判定できます。

```Python
moras = ['ガ', 'ッ', 'キュ', 'ー', 'シ', 'ン', 'ブ', 'ン']
print('キュ' in moras)                                   # True
```

しかし、複数のモーラから成る「モーラ列」が含まれているかどうかを判定するには、この方法だとうまくいきません。左辺にリストを指定してもダメです。

```Python
moras = ['ガ', 'ッ', 'キュ', 'ー', 'シ', 'ン', 'ブ', 'ン']
# True を返してほしい
print('キュー' in moras)                                 # False
print(['キュ', 'ー'] in moras)                           # False
```

一番確実なやり方は、総当たりで調べることです[^1] [^2] 。

```Python
def has_moras(moras, submoras):
  mora_cnt = len(moras)
  submora_cnt = len(submoras)
  for i in range(mora_cnt - submora_cnt + 1):
    matched = True
    for j in range(submora_cnt):
      if moras[i+j] != submoras[j]:
        matched = False
        break
    if matched:
      return True
  return False
  

moras = ['ガ', 'ッ', 'キュ', 'ー', 'シ', 'ン', 'ブ', 'ン']
submoras = ['キュ', 'ー']

print(has_moras(moras, submoras))                          # True
```

少々ややこしくなりましたね。
ちなみに、通常の文字列にしてから `in` で確かめる方法だと、次のような場合にうまくいきません[^3]。

```Python
moras = ['マ', 'ッ', 'チャ', 'パ', 'フェ']
submoras = ['マ', 'ッ', 'チ']
# False を返してほしい
print(''.join(submoras) in ''.join(moras))                 # True
```

### それは本当にモーラに対応する文字列か？

これまでの例では、文字列がすでにモーラ単位で適切に分割されていることを前提としてきましたが、実行時、文字列リスト内にモーラとは対応しない余計な文字が含まれていないかどうかは、書き手が保証する必要があります。そうでないと、気付かないうちに間違った結果を得てしまうかもしれません。

```Python
suffix = list('GPT')
moras = ['チャ', 'ッ', 'ト']
new_moras = moras + suffix
# 日本語としてのモーラ数がほしいが…
print(len(new_moras))                                     # 6
```

上のような単純な例だとすぐに気付くかと思いますが、関数やモジュールをまたいで文字列リストのやり取りをする場合には注意が必要です。

## MoraStr クラス

ここで、私が作った `MoraStr` クラスの紹介です。
`MoraStr` クラスは、上に挙げたような面倒な処理やチェックを内部でやってくれます。そのため、モーラ列を扱う記述を簡素化できます。

### MoraStr クラスの基本的な使い方

インストールに関しては[こちら](#導入方法)。
仮名文字列（カタカナかひらがな）をコンストラクタに渡して使います。以下はREPLでの実行例です。

```Python
# MoraStrクラスをインポート
>>> from morastrja import MoraStr

# インスタンスの作成
>>> MoraStr('モーラ')
MoraStr('モ' 'ー' 'ラ')
>>> MoraStr('シミュレーション')
MoraStr('シ' 'ミュ' 'レ' 'ー' 'ショ' 'ン')
```

リストやタプルと同じように添え字アクセスできます。

```Python
# 添え字アクセス
>>> MoraStr('アーティキュレーション')[3]   # 0-indexed で3モーラ目
'キュ'
>>> MoraStr('アーティキュレーション')[-2]  # 後ろから2モーラ目
'ショ'

# スライス
>>> MoraStr('ジェットエンジン')[:3]       # 最初の3モーラを抽出
MoraStr('ジェ' 'ッ' 'ト')
```

`MoraStr` オブジェクトには 2つの属性があります。`.length` 属性でモーラ数が取得でき、`.string` 属性で分割されていない文字列表現が取得できます。`moras.length` と `len(moras)` は同じ値を返します。好きな方を使ってください。

```Python
# .length 属性
>>> MoraStr('クッション').length          # モーラ数を取得
4
>>> len(MoraStr('クッション'))            # 上と同じ
4

# .string 属性
>>> moras = MoraStr('クッション')
>>> moras
MoraStr('ク' 'ッ' 'ショ' 'ン')
>>> moras.string                         # 分割前の文字列表現を取得
'クッション'
```

### 部分モーラ列に対応する文字列の取り出し

`MoraStr` クラスを使えばこう書けます

```Python
moras = MoraStr('ガッキューシンブン')
# MoraStr('ガ' 'ッ' 'キュ' 'ー' 'シ' 'ン' 'ブ' 'ン')
text = moras[:4].string                                # 4モーラ目までの文字列
# 'ガッキュー'
```

`.string` 属性で文字列を取り出しています。`''.join()` する必要はありません。

### 部分モーラ列の判定

```Python
moras = MoraStr('ガッキューシンブン')
# MoraStr('ガ' 'ッ' 'キュ' 'ー' 'シ' 'ン' 'ブ' 'ン')
print('キュ' in moras)
# True
print('ギュ' in moras)
# False
print('キュー' in moras)
# True
print(MoraStr('キュー') in moras)
# True
print('ギュー' in moras)
# False
```

部分モーラ列が 1モーラであってもそうでなくても、期待した通りの結果が得られます。
以下のどちらの書き方でも OK です。

> `<文字列> in <MoraStr オブジェクト>`
>
> `<MoraStr オブジェクト> in <MoraStr オブジェクト>`

```Python
moras = MoraStr('マッチャパフェ')
# MoraStr('マ' 'ッ' 'チャ' 'パ' 'フェ')
submoras = MoraStr('マッチ')
# MoraStr('マ' 'ッ' 'チ')

print('マッチ' in moras)
# False
print(submoras in moras)
# False
```

上の例のような場合にも、「マッチャパフェ」の部分モーラ列では**ない**「マッチ」に対して、`True` が返されることはありません。

### バリデーション

`MoraStr` クラスのコンストラクタにひらがなや半角カタカナを渡すと、自動的に全角カタカナに変換されます。

```Python
moras = MoraStr('ひらがな')
# MoraStr('ヒ' 'ラ' 'ガ' 'ナ')
```

```Python
moras = MoraStr('ﾊﾝｶｸﾓｼﾞ')
# MoraStr('ハ' 'ン' 'カ' 'ク' 'モ' 'ジ')
```

それ以外の文字列を含んでいるとエラーとなり、インスタンスは作成されません。

```Python
moras = MoraStr('漢字')
# ValueError
```

```Python
moras = MoraStr('latin alphabet')
# ValueError
```

また、`MoraStr` オブジェクトはイミュータブルであり、インスタンス作成後の値の変更は不可です。

```Python
moras = MoraStr('カタカナ')
moras[3] = 'タ'
# TypeError
```

ですので、以下のコードで *`new_moras`* 内部の文字列表現にカタカナ以外の文字列が混入してしまうことはありません。

```Python
moras = MoraStr('チャ' 'ッ' 'ト')
new_moras = moras + suffix                # 文字列としてカタカナのみを含むことが保証される
```

これにより、気付かずにモーラを数え間違ってしまうことを未然に防げます[^4]。

## 導入方法

`MoraStr` クラスは、morastrja モジュールから利用できます。pip を通じてインストール可能です。

```sh
pip install morastrja
```

ビルドにはCコンパイラ（C99以降）が必要です。

## モジュールの特徴

* `pip install` だけで導入でき、他のサードパーティーライブラリを必要としません。
* MIT ライセンスです。
* 型ヒントのスタブを同梱しています。
* コア部分は C で実装されているため、同じ内容の処理を Python で書くよりも高速です。
* `MoraStr` オブジェクトは、イミュータブルでハッシュ可能です。辞書や集合のキーとして安全に使えます。
* `MoraStr.find()` `MoraStr.count()` など、文字列型と共通のメソッドを多く備えています。
* モーラ文字列の前処理に便利なユーティリティー関数が利用できます：

  + *`utils.vowel_to_choon()`*
  カタカナ文字列中の同一母音の連続を長音記号（ー）に変換します。
  + *`utils.choon_to_vowel()`*
  カタカナ文字列中の長音記号（ー）を対応する母音に変換します。

詳しくは、[ドキュメント](https://hizuru3.github.io/morastrja/)をご覧ください。

## 今後検討してみたいこと（やるとは言ってない）

+ C/C++ から直接呼び出せる API の提供

+ SIMD の使用

+ utils の追加

+ このライブラリを活用した、より高次なライブラリの作成

## C 言語で Python モジュールを作ることについて（ライブラリ開発者向け）

この項目は、主にこれから Python の C/C++ 拡張を作ろうと思っている人が対象です。

私自身、C 言語は初心者同然ですが、勉強もかねて Python C/API を使ってライブラリのコア部分を実装してみました。正直、Python クラスを C 言語で書くのはあまりおススメできません。細かい罠が多く、ネット上に公開されているコード片が正しく動かないこともままあります。あと、単純に実装量が多くなります。 他でも書かれていますが、やっぱ拡張モジュールの作成は Cython や pybind11 を使うのが楽なんじゃないかと思います。私は詳しくないですが、Rust 向けの Python バインディング (PyO3) もあるので、特に Rust に慣れている人はそちらを試してみるのもいいと思います。

拡張モジュールを作るときの注意点としては、やはり参照カウントの管理でしょうか。API を使うときに、それが new reference を返すのか borrowed reference を返すのか、しっかり確認する必要があります。私が書いたものは、色々とお行儀の悪いコードになっていますが、そこに関しては気を付けて実装したつもりです。C++ を使う場合、std::vector に Python オブジェクト（ポインタ）を格納するのはメモリ関係のバグの元になりやすいですし、避けた方がいいです。STL は CPython にないデータ構造を扱いたい場合には便利ですが、動的配列なら Cpython にもありますし、std::vector 自体は別に速いわけでもないので、普通に PyListObject を使いましょう。循環参照も GC が解放してくれます。

### 参考にしたページ（CPython 拡張関連）
* [Python インタプリタの拡張と埋め込み](https://docs.python.org/ja/3/extending/index.html)
* [Python/C API リファレンスマニュアル](https://docs.python.org/ja/3/c-api/index.html)
公式です。質・量ともに充実しています。CPython 拡張を作るなら、まずざっと目を通しておくといいと思います。

* [年末が近づくと Python/C API を無駄に使いたくなるので準備](https://qiita.com/fgshun/items/63a70c74c9b67e969311)
C 拡張が動くようにするために必要な手続きや注意すべき点についてまとめられています。

* [【Python/C API】PythonとC言語の関数とで様々なデータをやりとりする（リスト・辞書・キーワード引数など）](https://daeudaeu.com/c-python-various-data/)
参照カウントの管理について、例と共に詳しく書かれていて良いです。ただ、例外処理についてももう少し丁寧にやってくれていいかなと感じました。

## おわりに

ライブラリも記事も結構ニッチな感じが否めないのですが、刺さる人に刺さればいいなと思い、公開しました。気になったら使ってみてください！

## 外部リンク

* Github
https://github.com/Hizuru3/morastrja

* ドキュメント
https://hizuru3.github.io/morastrja/

[^1]: MoraStr クラスでは、モーラ列が長い場合に、より効率的なアルゴリズムを用いて探索を行います。
[^2]: モーラは特定の文字では始まらないことを前提に、正規表現により検索を行う方法も考えられます。
[^3]: 「マッチ」は「マッチャパフェ」の「部分文字列」であっても、「部分モーラ列」ではないと言えます。
[^4]: ただ、いずれにせよエラー時の処理は必要ですが。