ローマ字かな変換をRustで

# きっかけ

[ローマ字かな変換サンプルコード](https://qiita.com/fygar256/items/0824efc9b1c197c1dcef)
という記事が先日Qiitaに公開された。C言語で書かれている。

自分はC++erなので最初はC++で書けばもっと書きやすくなるんじゃないかと思ったが、ここはずっと入門します詐欺をして触れていなかったRustで書こうと思い立った。

C++20でRangeが来ることでかなりRustっぽく扱えるようになるはずだが、まだC++20は発行もされてないしまともに実装もされていない。

Rust入門としてちょうどいい難易度なのでやってみた。ついでにRustとC++を雑に比較する。

# 方針

## 元記事のコードをどこまで参照するか

元記事はC言語なのでDictionaryなんてない世界線の書き方をしている。

Rustにはせっかく`HashMap`があるのだからこれを使って検索を速くしたい。

## ローマ字

まずそもそもローマ字というのが実に曖昧な定義で、どうしてくれようかという気持ちになる。

かつて JIS X 4063:2000という規格があったのだが、2010年1月20日に廃止されている。

しかしその後なにかまともな規格は出ていないので、これに従うことにする。

といっても規格書は高いのでそれを引っ張っているWikipedia
[ローマ字入力 - Wikipedia](https://ja.wikipedia.org/wiki/%E3%83%AD%E3%83%BC%E3%83%9E%E5%AD%97%E5%85%A5%E5%8A%9B)
を参照した。

# 完成品

https://github.com/yumetodo/romaji_kana_cvt_rust

`git clone` して`cargo build`すれば普通にコンパイルできるはずだ。

C++と違ってこの辺が楽で本当にいい。つーかいつになったらC++にmoduleが来るんだ・・・。

元記事に合わせて実行時引数になんか文字列を渡すだけでよい。入力がローマ字かひらがなかは勝手に判定してくれる。不正な入力に対しては`invalid input`と言いながらpanicする。

# 紹介

## 変換テーブル

まず真っ先にJIS X 4063:2000に基づく変換テーブルを作った。

https://github.com/yumetodo/romaji_kana_cvt_rust/blob/v1.2.1/src/cvt/jis_x_4063_2000.rs

```rust
use std::collections::HashMap;

// We will follow the definition of JIS X 4063:2000 even though that was already obsolete.
const TABLE1: [(&str,  &str); 6] = [
//from JIS X 4063:2000: must
    ("a", "あ"), ("i", "い"), ("u", "う"), ("e", "え"), ("o", "お"),
    ("n", "ん")
];
const TABLE2: [(&str,  &str); 204] = [
//from JIS X 4063:2000: must
    ("ka", "か"), ("ki", "き"), ("ku", "く"), ("ke", "け"), ("ko", "こ"),
    //中略
    ("xtsu", "っ"),
    //("^", "ー"),
];
```

こんな感じでtupleのarrayだ。

そしてこんな感じでDictionayを作ったりする関数を定義している。他にもいくつかある。

```rust
pub fn make_from_romaji_table() -> [HashMap<&'static str, &'static str>; 2]{
    [
        TABLE1.iter().cloned().collect(),
        TABLE2.iter().cloned().collect()
    ]
}

pub fn make_to_romaji_table() -> HashMap<&'static str, &'static str> {
    let mut t: Vec<_> = [
        TABLE2.iter().map(|(s1, s2)| (*s2, *s1)).collect::<Vec<_>>(),
        TABLE1.iter().map(|(s1, s2)| (*s2, *s1)).collect::<Vec<_>>(),
    ].concat();
    t.sort_by(|a, b| a.0.cmp(b.0));
    t.dedup_by(|a, b| a.0.eq(b.0));
    t.iter().cloned().collect()
}
```

tupleの要素をswapするのをどうしたらいいのかわからなかった。というのは

```rust
TABLE1.iter().map(|(s1, s2)| (s2, s1)).cloned().collect::<Vec<(&str,  &str)>>()
```

とかしようとして苦戦していた。

結局わからずteratailに質問を投げた。
[Rust - [(&str, &str); 6]な配列の全要素のタプルについて1番目と2番目を入れ替えvectorに変換するには｜teratail](https://teratail.com/questions/171988#reply-256175)

@yohhoy さんの回答をそのまま採用したのだが、その後で @tesaguri さんから詳しい解説がついた。上のコードがなんでだめか秒殺で答えられない読者の方はぜひリンクを踏んで解説を読みに行ってほしい。

・・・RustってC++と違って参照がなんかめっちゃ重なっていくから感覚に反して怖い。

解説読んでも未だに`.cloned()`がいつ必要なのかよくわからない。他のところでも書いててエラーが出たらつけたり外したりという雑な対応をしていた。RustのコンパイルエラーはC++と違って読みやすくていいですね。C++20でようやくConceptが来るのでちょっとはわかりやすくなるんでしょうか。

## is_consonant

https://github.com/yumetodo/romaji_kana_cvt_rust/blob/v1.2.1/src/cvt.rs

元記事から唯一受け継いだ関数名だ。元記事では

```c

bool  isconsonant(char c)
{
static  char  ctab[]="kstnhmyrwzjpbcgf";
  int i;
  for(i=0;ctab[i]!='\0';i++) {
    if (c==ctab[i])
      return(true);
    }
  return(false);
}
```

のように定義されているが、Rustなら

```Rust
    fn is_consonant(&self, c: char) -> bool {
        self.ctab.contains(&[c][..])
}
```

のようにスッキリ書ける。ちなみに`self.ctab`は変換テーブルから無駄に生成させていて、`bcdfghjkmnpqrstvwxyz`と等価になる。元記事よりちょっと多い。

まあC++でも`std::all_of`があるのでそこまでRustと変わらないか。

~~ところで`&[c][..]`という変態的な文法はどうにかならないのか・・・。
[Split string on multiple delimiters : rust](https://www.reddit.com/r/rust/comments/30608a/split_string_on_multiple_delimiters/cppsuqb/)
で紹介されていた。
`|c2| c == c2`と多分等価だと思うが・・・。~~

単に`c`で良かったようだ、コメントで指摘頂いた。

## to_romaji: ひらがな->ローマ字

まず真っ先にやらないといけないのがUnicode NFC正規化だ。なぜか？

`で(U+3066, U+3099)`という2つのcodepointで表されるものを`で(U+3067)`に変換するためだ。さもないとせっかくの変換テーブルに引っかからなくなる。

漢字が絡んでくると単なるNFC正規化じゃなくてちょっと省いたやつにしないとまずかったりするがひらがなだけなので問題ない。これによってglyphまで考えなくてもcodepointで考えれば良くなった。Rustはcodepoint単位で扱うのが極めて楽な言語なので素晴らしい。

一方のC++はC++20でようやく[`char8_t`型が導入される](https://qiita.com/yumetodo/items/54e1a8230dbf513ea85b)程度でUnicode間の変換もかつてC++11で導入されたものの脆弱性が見つかってまるごとC++17/20でdeprecatedになってしまった。

Rustには`unicode_normalization`というまんまなcrateがあるのでこれを使えばいい。

https://github.com/unicode-rs/unicode-normalization

ローマ字に変換するときにcodepointでいうと2文字分見る必要がある。`きゃ`とかを変換するために。なので

```rust
let mut prev_c = '\0';
```

のような変数をループ外に持つことになった。

また`っ`(促音)を正しく扱うのに

```rust
let mut prev_sokuonn_count = 0;
```

のような変数をループ外に持つことになった。これは

```rust
            if 0 != prev_sokuonn_count {
                let sokuonn = iter::repeat(append.chars().next()?).take(prev_sokuonn_count).collect::<String>();
                re += &sokuonn;
                prev_sokuonn_count = 0;
            }
```

のように使っている。

~~ところで任意の一文字をn個連ねた`String`を作るもっといい方法はないんだろうか？~~

~~今は`[char; 1]`なarrayを作って`.iter().cycle().take(N).collect::<String>()`とかしているが、もうちょっとまともな方法がほしい。~~

C++なら`std::string(N, c)`で済む。
https://cpprefjp.github.io/reference/string/basic_string/op_constructor.html

`std::iter::repeat`で解決しました、コメントありがとうございます。

## from_romaji: ローマ字->ひらがな

これが実に面倒くさい。

とにかく基本方針としてまず母音を含めつつ母音の直後で分割してそれぞれについてさらに条件分岐して変換することにした。

```rust
    pub fn from_romaji(&self, input: String) -> Option<String> {
        let mut re = String::with_capacity(input.len() * 2);
        let mut prev_i = 0;
        for (index, _) in input.match_indices(|c| Self::VOWEL.contains(&[c][..])) {
            let s = self.from_romaji_impl(&input[prev_i..=index])?;
            re += &s;
            prev_i = index + 1;
        }
        if prev_i != input.len() {
            let s = self.from_romaji_impl(&input[prev_i..])?;
            re += &s;
        }
        Some(re)
}
```

`match_indices`というのがあるらしいと言うのを
[rust - Split a string keeping the separators - Stack Overflow](https://stackoverflow.com/questions/32257273/split-a-string-keeping-the-separators)
で知ったのでちょっといじってこんな感じに。

`&input[prev_i..=index]`とかやっているがこれはcodepoint単位じゃなくてUTF-8のbyte単位らしいのであんまよくないが、Unicodeなのでまあダメ文字問題みたいなのには出くわさないはずなのでとりあえず妥協。`&input.chars().skip(prev_i).take(index + 1 - prev_i).collect::<String>()`とかしたほうがいいのかもしれないが長すぎる。つーかRustには`substr`的なやつはないんだろうか。
https://cpprefjp.github.io/reference/string_view/basic_string_view/substr.html

### from_romaji_impl

でこうして母音ごとに分割された切片を処理するのが`from_romaji_impl`だ。

切片の長さで分岐をしている。長さ0は明示的に分岐していないがどうせ`chars().next()?`あたりで勝手に`None`が帰っていくはずなので気にしない。

長さが1か2のときは`っ`(促音)や`ん`(撥音)を気にしなくていいので単純にdictonaryを見に行っている。
長さが3か4のときはとりあえずdictonaryを見に行ってなければ`っ`(促音)や`ん`(撥音)の処理に飛ばしている。
それ以上の長さのときはdictionaryを引く必要がないので単に`っ`(促音)や`ん`(撥音)の処理に飛ばしている。

#### convert_sokuonn_and_the_sound_of_the_kana_n

促音/撥音という概念が英語圏にないらしく関数名が意味不明になっている。識別名に絵文字とか使えるようにしようぜっていう提案がRustではあったはずなんだがまだ通ってないらしい。ちなみにC++にも一度そういう提案が出たがスルーされている。

```rust
    fn convert_sokuonn_and_the_sound_of_the_kana_n(&self, s: &str) -> Option<(String, usize)> {
        let mut it = s.chars();
        let c1 = it.next()?;
        let cnt = it.take_while(|c| c1 == *c).count();
        if 'n' == c1 {
            let kana_cnt = (cnt + 1) / 2;
            if 0 == kana_cnt {
                if '\'' == s.chars().nth(1)? {
                    Some(('ん'.to_string(), 2))
                } else if self.is_consonant(s.chars().nth(1)?) {
                    Some(('ん'.to_string(), 1))
                } else {
                    None
                }
            } else {
                Some((iter::repeat('ん').take(kana_cnt).collect::<String>(), kana_cnt * 2))
            }
        } else if 0 == cnt {
            None
        } else if self.is_consonant(c1) {
            Some((iter::repeat('っ').take(cnt).collect::<String>(), cnt))
        } else {
            None
        }
    }
```

相互再帰したくなかったのでtupleのOptionを返すことになった。

一文字目だけ自力で取ってあとは`take_while`と`count`の合わせ技で連続数を数えている。

`ん`(撥音)は`n`, `nn`, `n'`があるが、`n`はな行とかぶるので無効とする。で`n'`のためにコードが長くなった。
https://github.com/yumetodo/romaji_kana_cvt_rust/commit/e4832541714d5a0e0ec2134b5557987d521da2c5
`n'`の連続については一回ずつ再帰することにした。

あと`nya`みたいに`n`+子音字の組み合わせを当初考慮し忘れていて修正した
https://github.com/yumetodo/romaji_kana_cvt_rust/issues/1

`nn`の連続については連続数を2で割って切り捨てた回数分`ん`があることを意味するのでそのように変換している。

それ以外の子音字の連続は1引いた上で、単に`っ`に置き換えたのと同じように変換している。

## convert: 変換

https://github.com/yumetodo/romaji_kana_cvt_rust/blob/v1.2.1/src/main.rs

入力がローマ字のみか否かを調べてどっちの変換を使うか分岐させている。

```rust
mod cvt;
use cvt::RomajiCvt;

fn convert(input: String) -> Option<String> {
    let converter = RomajiCvt::new();
    if input.chars().all(|c| c.is_ascii_alphabetic() || c == '\'') {
        converter.from_romaji(input)
    } else {
        converter.to_romaji(input)
    }
}
```

当初`filter`して`count`して`len`と比較とかいうだるいことをしていたが`all`というやつがあったらしい。


# License

[CC BY 4.0](https://creativecommons.org/licenses/by/4.0/deed.ja)

<a href="https://commons.wikimedia.org/wiki/File:CC-BY_icon.svg#/media/File:CC-BY_icon.svg"><img src="https://upload.wikimedia.org/wikipedia/commons/thumb/1/16/CC-BY_icon.svg/1200px-CC-BY_icon.svg.png" alt="CC-BY icon.svg" width="88px"></a>