きっかけ
ローマ字かな変換サンプルコード
という記事が先日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
を参照した。
完成品
git clone
してcargo build
すれば普通にコンパイルできるはずだ。
C++と違ってこの辺が楽で本当にいい。つーかいつになったらC++にmoduleが来るんだ・・・。
元記事に合わせて実行時引数になんか文字列を渡すだけでよい。入力がローマ字かひらがなかは勝手に判定してくれる。不正な入力に対してはinvalid input
と言いながらpanicする。
紹介
変換テーブル
まず真っ先にJIS X 4063:2000に基づく変換テーブルを作った。
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を作ったりする関数を定義している。他にもいくつかある。
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するのをどうしたらいいのかわからなかった。というのは
TABLE1.iter().map(|(s1, s2)| (s2, s1)).cloned().collect::<Vec<(&str, &str)>>()
とかしようとして苦戦していた。
結局わからずteratailに質問を投げた。
Rust - [(&str, &str); 6]な配列の全要素のタプルについて1番目と2番目を入れ替えvectorに変換するには|teratail
@yohhoy さんの回答をそのまま採用したのだが、その後で @tesaguri さんから詳しい解説がついた。上のコードがなんでだめか秒殺で答えられない読者の方はぜひリンクを踏んで解説を読みに行ってほしい。
・・・RustってC++と違って参照がなんかめっちゃ重なっていくから感覚に反して怖い。
解説読んでも未だに.cloned()
がいつ必要なのかよくわからない。他のところでも書いててエラーが出たらつけたり外したりという雑な対応をしていた。RustのコンパイルエラーはC++と違って読みやすくていいですね。C++20でようやくConceptが来るのでちょっとはわかりやすくなるんでしょうか。
is_consonant
元記事から唯一受け継いだ関数名だ。元記事では
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なら
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
で紹介されていた。
|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
型が導入される程度でUnicode間の変換もかつてC++11で導入されたものの脆弱性が見つかってまるごとC++17/20でdeprecatedになってしまった。
Rustにはunicode_normalization
というまんまなcrateがあるのでこれを使えばいい。
ローマ字に変換するときにcodepointでいうと2文字分見る必要がある。きゃ
とかを変換するために。なので
let mut prev_c = '\0';
のような変数をループ外に持つことになった。
またっ
(促音)を正しく扱うのに
let mut prev_sokuonn_count = 0;
のような変数をループ外に持つことになった。これは
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: ローマ字->ひらがな
これが実に面倒くさい。
とにかく基本方針としてまず母音を含めつつ母音の直後で分割してそれぞれについてさらに条件分岐して変換することにした。
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
で知ったのでちょっといじってこんな感じに。
&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++にも一度そういう提案が出たがスルーされている。
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: 変換
入力がローマ字のみか否かを調べてどっちの変換を使うか分岐させている。
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
というやつがあったらしい。