Rust学習記録
Rustに入門してみる。x年ぶりy度目。
毎回途中で挫折している。
これに挑戦。
数あてゲーム
詰まったところ。
use rand::Rng;
use std::{cmp::Ordering, io};
fn main() {
println!("数当てゲーム");
let secret_number = rand::thread_rng().gen_range(1..101); // 1から100までの乱数を生成
let mut guess = String::new(); // この変数宣言をloopの外に出すとうまく動かない
loop {
println!("数を入れてね");
io::stdin()
.read_line(&mut guess)
.expect("読み込みに失敗しました");
println!("あなたの入力: {}", guess);
let guess: u32 = match guess.trim().parse() {
Ok(num) => num,
Err(_) => continue,
};
match guess.cmp(&secret_number) {
Ordering::Less => println!("それは小さすぎる"),
Ordering::Greater => println!("さすがに大き過ぎる"),
Ordering::Equal => {
println!("正解!");
break;
}
}
}
}
上のようなコードを書くと、以下のような結果になった。
数当てゲーム
数を入れてね
42
あなたの入力: 42
さすがに大き過ぎる
数を入れてね
2
あなたの入力: 42
2
数を入れてね
1
あなたの入力: 42
2
1
なんかユーザー入力の数字がどんどん連結されてる。
問題となる記述は
let mut guess = String::new(); // この変数宣言をloopの外に出すとうまく動かない
の部分で、変数宣言がloopの外側にあったため変数が使い回されてしまった。
どうやらread_lineは読み取った値を格納してくれるのではなく、どんどん書き足していく仕様らしい。
3章の3に翻訳ミス?を見つける
3章の3にこのような記述を見つけた。
fn main() {
print_labeled_measurement(5, 'h');
}
fn print_labeled_measurement(value: i32, unit_label: char) {
println!("The measurement is: {}{}", value, unit_label);
}
この例では、2引数の関数を生成しています。そして、引数はどちらもi32型です。それからこの関数は、 仮引数の値を両方出力します。関数引数は、全てが同じ型である必要はありません。今回は、 偶然同じになっただけです。
「引数はどちらもi32型」…というのはサンプルコードの記述と一致しない。
これはコントリビュートチャンス…と思ってリポジトリをフォークして記述を修正してみたが、自分より先に修正PRを出している人を見つけた。残念。
4章所有権を理解する
難。
これは難しい。
所有権自体の難しさ、スタックとヒープの違いを意識する煩わしさ、いちいち文字列リテラルとString型を区別することの煩わしさ、かなり混乱してくる。
代入するとムーブするのも厄介。全部cloneすればそういう悩みから開放されるかもしれないが、それだとRust使う意味がなくなる。
頭の中でコードをステップ実行して「はいこの変数死んだー」「はいここも死んだー」といちいちバッテンを付けていかないとコンパイル可能なコードが書けない。
これはもうそういう計算機を頭の中に構築するしか無いのかもしれない。
もしくはカーソルを動かすと使えない変数にバツが付くようなVisual Studio Code拡張があれば便利かもしれない。もうあるのかな。
5章 構造体
この辺はまぁgo言語っぽい。
let user2 = User {
email: String::from("another@example.com"),
username: String::from("anotherusername567"),
..user1
};
こういう感じで新しくオブジェクトを作る時に楽できる書き方って、Javascript由来かな。それともRustが先かな。
文法についてちょっとずつ分かってきたけど、まだ書ける気がしない。
ちょっと自分の色を出そうとお手本から逸脱すると、譲渡と借用をうまく使い分けられずすぐコンパイルエラーが出てしまう。
この辺は慣れていくしかないなぁ。
Enumとパターンマッチング
Enum => 「ふーんTypeScriptのUniton型みたいなのをRustではこう実現しているのか~」,
match => 「なるほどこれ便利だ」,
if let => 「なにこれ機能は分かるけど気持ち悪い。なんでこんな書き方なんだ???」,
という第一印象。
if letに面食らった。
if let Some(3) = some_u8_value {
println!("three");
}
「==」じゃなくて「=」なのが気持ち悪い…代入だからか…でも条件文だし…と最初は抵抗感が強かった。最初のサンプルコードが型チェックはしてるけど中身の値を使ってないのも「なんだこれ?」と思わせる要因になっていたかもしれない。
ググって辿り着いたこの記事を読んだらちょっと分かった気がしてきた。
いやでもまだなんかしっくりこない。
これはコンパイルが通る。
if let Foo::Hoge = hoge {
//
}
これはコンパイルが通らない。
let is_hoge = let Foo::Hoge = hoge;
if(is_hoge){
//
}
is_hogeにbool型が入ってほしかった。
でもnote: variable declaration using 'let' is a statement
と怒られてis_hogeに結果を入れられない。
letは式ではなく文だからそういう使い方をしちゃ駄目だと。
let文に普遍的な性質があり「if let」はその応用というよりは、if let文にだけ特殊な機能を持たせているように見える。
これもそういうものだと受け入れるしかないのか。
肥大化していくプロジェクトをパッケージ、クレート、モジュールを利用して管理する
パッケージの中にクレートがあり、クレートの中にモジュールがあるという関係か。
デフォルトで全部非公開という思想は潔い。
ディレクトリを作っても自動でモジュールの子階層を作ってくれるわけではなく、
pub mod module_name;
と宣言しないといけないのは若干面倒。勝手に認識してくれても良いような。
これは後々の章でバイナリに同梱しないファイルが配置される伏線かな。
testなんかをモジュールツリーから除外する為のルールかもしれない。
一般的なコレクション
ベクタで値のリストを保持する
これエラーになるの、理由を説明されれば「そうなんだ」と思うけど、いやでもキッツいなぁ…。
let mut v = vec![1, 2, 3, 4, 5];
let first = &v[0];
v.push(6);
println!("The first element is: {}", first);
じゃあどうすりゃいいの?と思いながら読み進めたが、この章では回避方法を説明されなかった。
文字列でUTF-8でエンコードされたテキストを保持する
1文字と一言で言っても「バイト」と「スカラー値」と「書記素クラスタ」がある。なるほど確かに。
バイトは"🤷🏽♀️".bytes()でアクセスする。なるほど。
スカラー値は"🤷🏽♀️".chars()でアクセスする。なるほど。
書記素クラスタは…標準ライブラリで用意されていない。え?
いやでもこの辺の処理はJavascriptでも面倒だったはず、と思って調べたら、最近のブラウザでは対応しているらしかった。
const text = "🤷🏽♀️";
const japaneseSegmenter = new Intl.Segmenter("ja-JP");
console.log([...japaneseSegmenter.segment(text)].length);
ちなみにこの機能はFirefoxでは使えない。
やはり書記素クラスタに標準で対応している言語のほうが少数派なのかもしれない。
キーとそれに紐づいた値/をハッシュマップに格納する
insertが値の上書きなのは、気が利いてる。
無ければ格納、あったら何もしないをentryというEnumで実現しているのは面白い。RustのEnumって偉大な発明だなぁ。
use std::collections::HashMap;
let mut scores = HashMap::new();
scores.insert(String::from("Blue"), 10);
scores.entry(String::from("Yellow")).or_insert(50);
scores.entry(String::from("Blue")).or_insert(50);
println!("{:?}", scores);
ピッグ・ラテンに挑戦
8章の最後にお題があったので、その中の1つピッグラテンに挑戦してみる。
ルール
- 各単語の最初の子音は、 単語の終端に移り、"ay"が足されます。従って、"first"は"irst-fay"になります。
- ただし、 母音で始まる単語には、お尻に"hay"が付け足されます("apple"は"apple-hay"になります)。
解答
かなり苦戦した…。とにかくコンパイルエラーになる。
Stringと&strの使い分けがまだ身体に染み付いていない。
chars()もちょっと癖がある。普通に添え字アクセスさせてくれない。
文字列のsliceが標準で用意されていないって本当だろうか。
今回のようなアルファベットに限定した文字列が渡ってくるケースでは&text[1..]
で部分文字列が取れるのは分かってる。しかしその方法ではマルチバイト文字列を渡された時にクラッシュする。そんなプログラムを書くのは日本人として負けた感じがする。
use std::io;
use std::ops::Range;
fn main() {
let mut word = String::new();
println!("英単語を入れてね");
io::stdin()
.read_line(&mut word)
.expect("読み込みに失敗しました");
let pig_word = pig(&word.trim());
println!("{}", pig_word);
}
fn pig(word: &str) -> String {
let aiueo = ['a', 'i', 'u', 'e', 'o'];
let top = word.chars().nth(0).unwrap();
if aiueo.contains(&top) {
word.to_owned() + "hay"
} else {
slice(word, 1..word.char_indices().count()) + &top.to_string() + "ay"
}
}
fn slice(s: &str, range: Range<usize>) -> String {
let mut result = String::new();
let mut index = 0;
for (_, c) in s.char_indices() {
if index >= range.start && index < range.end {
result.push_str(&c.to_string());
}
index = index + 1;
}
result
}
#[cfg(test)]
mod tests {
#[test]
fn pig() {
assert_eq!(super::pig("hello"), "ellohay");
assert_eq!(super::pig("apple"), "applehay");
assert_eq!(super::pig("はろー"), "ろーはay");
}
}
Range<usize>
を引数に取ればmy_function(0..100)
というように範囲を渡せるのは便利だと思った。
でもfn my_function(range: Range<usize>)
で定義するとmy_function(1..)
のように終了を省略するようなパターンに対応できない。1..
はRange型ではなく、RangeFrom型らしい。
Rustはオーバーロードが無いらしいので、標準ライブラリはジェネリクスを駆使しているのだろうか。今回は面倒なので呼び出し側で頑張ってもらった。
エラー処理
Go言語と同じで例外機構は無い感じ。
?
演算子は賢い。Go言語の面倒くさいところが改善されてる。この機能はGo言語にも欲しい…が、これはResult
という言語組み込みの型があるからできる芸当だよなぁ。Goは最後の戻り地がerrという慣例に依存しているからRustのように構文化は難しそう。後発言語の強みか。
ジェネリック型、トレイト、ライフタイム
ジェネリック型
まぁここは普通。
トレイト: 共通の振る舞いを定義する
Goと違ってダックタイピングじゃないんだ。
ジェネリックとトレイトを同じ章で扱ってるのが謎だったんだけど、なるほどこれらは同じようなものな訳か。Goがながらくジェネリックを不要と判断していたのも頷ける。
pub fn notify(item: &(impl Summary + Display)) {
トレイトの足し算ができるのも面白い。TypeScriptでこれできたかな。
impl<T: Display> ToString for T {
// --snip--
}
トレイトを実装している型にメソッドを生やせるのも面白い。
use std::fmt::Display;
trait BigToString {
fn big_to_string(&self) -> String;
}
impl<T: Display> BigToString for T {
fn big_to_string(&self) -> String {
format!("{}", self).to_ascii_uppercase()
}
}
fn main() {
let s = "hello world";
println!("{}", s.big_to_string());
}
こういうこともできるのか。
C#でも似たようなことできた気はするけど、自分は禁じ手として認識していた。
Rustでは積極的に外からメソッドを生やす文化なのかな。
トレイトの足し算ができるのも面白い。TypeScriptでこれできたかな。
今日偶然読んだ記事に例があった。
type Props = {
children: ReactNode;
} & ComponentProps<"button">;
ライフタイムで参照を検証する
また難しい話になってきた。
とても癖の強い構文なので「この機能はできるだけ使うな」ということかと思ったら、1.0未満の頃はライフタイム全指定が必須だったのか。うわぁ。
4章の所有権を読み直すことにする。
自動テストを書く
最近の言語だけあって、最初からテストツールがビルトインされているのは良い。
assertまでpanicで実装しているのは潔い。
assertがマクロで実装されているのは何故だろう。
マクロ化すれば実行時のコストをコンパイル時に支払えるのだろうけど、testなんてコンパイルと実行がほぼ1:1だろうし、コンパイル時間が増えるぶんプラマイゼロにならないのだろうか。ループの中でassertが1万回呼ばれるみたいなことも滅多にないだろうし。…でもそうなってるってことは、そっちのほうが効率いいのかな。
入出力プロジェクト: コマンドラインプログラムを構築する
この章はとても実践的で楽しい。
テスト駆動開発でライブラリの機能を開発する
pub fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
このメソッド定義、意味は分かるんだけどなぜこう定義しているのかよく分からない。
引数のcontentsは配列ではなく巨大なテキストなのだから、戻り値は新しい文字列になるわけで、戻り値のライフタイムを引数に合わせる必然性がわからない。Stringじゃだめなのだろうか。ここで返される戻り値はコマンドのわりと最後まで生存する必要がありそうだけど、巨大なcontentsなんてさっさと開放したいはず。
戻り値のVec<&'a str>はcontentsのsliceのVecという扱いなのかな。このあたりは最終版の実装まで追わないと分からないか。
An iterator over the lines of a string, as string slices.
Lines are ended with either a newline (\n) or a carriage return with a line feed (\r\n).
The final line ending is optional. A string that ends with a final line ending will return the same lines as an otherwise identical string without a final line ending.
lines関数の説明したら普通に書いてあった。slicesを返すのか。
contentsがメモリの中に用意されて、その中の一部を切り出すけれど、新しい文字列を生み出しているのではなく、contentsの一部への参照だけ保持しているのでそれ以上はメモリを使わないということか。
他の言語(LL系とか)を使ってるときはこんなのあんまり意識しないけど、性能を重視するならString乱用はNGかぁ。
まぁそもそも性能重視するなら巨大なcontentsを一度に展開するようなことはせず、Buffer的なのをgoのchみたいな機能でちょっとずつ処理していくんだろうけど、それは後の章でやるのかな。
環境変数を取り扱う
これ章の最後に「自身の別の鍛錬として、コマンドライン引数か、 環境変数で大文字小文字の区別を制御できるようにしてみてください。」とあるけど、goのflags
みたいなパッケージを使わずにパースするのは辛くなってきた。Rustにもないのかな、そういうの。
clapがよさそうということが分かった。
関数型言語の機能: イテレータとクロージャ
クロージャ: 環境をキャプチャできる匿名関数
シングルトン実装するためにわざわざCacher定義するのは、汎用性という観点からは分かるけど「クロージャは手軽で便利!」という文脈でそれをやるのはなんか本末転倒では…と思ったが、最後の方で別解を説明する流れになって納得した。
一連の要素をイテレータで処理する
え?sum読んだらもうそのイテレータ使えないのか…。
この縛りキツすぎないかな。
入出力プロジェクトを改善する
search関数は読みやすくなった気がするけど、コマンドライン引数の解析はなんか逆に読みづらくなった気がする。これ引数が10個あったら後半のほう今が何個目なのか分からなくなりそう。
パフォーマンス比較: ループVSイテレータ
イテレータのほうが速いのか。
以前Javascriptで似たようなコードを計測したときは単純なfor文の方がイテレータ的な書き方よりだいぶ速かった。一番速いのはforすら使わずに同じ処理をn回ベタ書きするのが最速だった。
Rustの場合はイテレータでもちゃんと最適化してくれるのか。
え?sum読んだらもうそのイテレータ使えないのか…。
この縛りキツすぎないかな。
使用済みになるのはイテレータだけで、元の配列の所有権まで奪うわけではなかった。そりゃそうか。
こういうのもできた。
fn main() {
let v1 = vec![1, 2, 3];
let total:i32 = v1.iter().sum();
let max:&i32 = v1.iter().max().unwrap();
println!("sum:{},max:{}",total,max)
}
そろそろ実践に入る
チュートリアルは途中だけど、そろそろ実践に入って現在の学習レベルを確認してみる
やること
以前Goで作ったダジャレを検出するコマンドの基本機能を移植してみる。
- まずは最低限動くところまで(Stringやcloneを使いまくって良い)
- 最低限動いたらRustらしく書き直す
- オプションなどの細かい実装はまたの機会に。
ライブラリ選定
Rustの形態素解析ライブラリはどれがメジャーなのかわからないけど、linderaというのを選んだ。
これを理由としては以下の通り。
- 検索したら最初に出てきた。
- Githubのスターもそれなりにある。
- 開発もかなり活発そう。最終更新日が昨日。
- コントリビューターも多い。Go言語の形態素解析ライブラリkagomeの作者のikawahaさんも一覧にいた。
linderaを1.18.0に上げた。
RustをVSCodeで使ってると古いライブラリの警告が出て良い。
lindetaをアップデートしたのは良いんだけど、使い方が変わってて戸惑った。
Readmeには
lindera = { version = "0.12.0", features = ["full"] }
とずいぶん古いバージョンが指定されており、これをそのまま1.18.0にあげるとエラーになる。
どうやらこのfeatures = ["full"]
というフィーチャーは0.17.0では使えるけど0.18.0では使えないらしい。
とりあえず今回はipadicをした。もうメンテされてない辞書らしいので、乗り換え先はのちのち検討する。
あと書き方もいろいろ変わってた。
単語の詳細を取得するのは0.17.0まではtokenizer.word_detail(ワードID)
で取得していたが、0.18.0からはtokenizer.tokenize_with_details(文字列)
でトークンを取得した上でtoken.details
で詳細を取得するようになった。オブジェクト指向っぽくなったのかな。
あと今まではTokenizer::new()
でTokenizerを雑に初期化できていたけど、今回からは辞書の指定が必須になった。
まだバージョン1.0.0に達してないし、いろいろ変わるのはまぁ仕方ないか。
linderaの使い方
こんな感じに書けば
use lindera::tokenizer::{DictionaryConfig, DictionaryKind, Tokenizer, TokenizerConfig};
use lindera::LinderaResult;
fn main() -> LinderaResult<()> {
// create tokenizer
let dictionary = DictionaryConfig {
kind: DictionaryKind::IPADIC,
path: None,
};
let config = TokenizerConfig {
mode: lindera::mode::Mode::Normal,
dictionary: dictionary,
user_dictionary: None,
};
let tokenizer = Tokenizer::with_config(config)?;
// tokenize the text
let tokens = tokenizer.tokenize("東京スカイツリーの最寄り駅はとうきょうスカイツリー駅です")?;
// output the tokens
for token in tokens {
println!("{},{:?}", token.text, tokenizer.word_detail(token.word_id));
}
Ok(())
}
こういう結果が得られる。
東京,Ok(["名詞", "固有名詞", "地域", "一般", "*", "*", "東京", "トウキョウ", "トーキョー"])
スカイ,Ok(["名詞", "一般", "*", "*", "*", "*", "スカイ", "スカイ", "スカイ"])
ツリー,Ok(["名詞", "一般", "*", "*", "*", "*", "ツリー", "ツリー", "ツリー"])
の,Ok(["助詞", "連体化", "*", "*", "*", "*", "の", "ノ", "ノ"])
最寄り駅,Ok(["名詞", "一般", "*", "*", "*", "*", "最寄り駅", "モヨリエキ", "モヨリエキ"])
は,Ok(["助詞", "係助詞", "*", "*", "*", "*", "は", "ハ", "ワ"])
とう,Ok(["副詞", "助詞類接続", "*", "*", "*", "*", "とう", "トウ", "トウ"])
きょう,Ok(["名詞", "副詞可能", "*", "*", "*", "*", "きょう", "キョウ", "キョー"])
スカイ,Ok(["名詞", "一般", "*", "*", "*", "*", "スカイ", "スカイ", "スカイ"])
ツリー,Ok(["名詞", "一般", "*", "*", "*", "*", "ツリー", "ツリー", "ツリー"])
駅,Ok(["名詞", "接尾", "地域", "*", "*", "*", "駅", "エキ", "エキ"])
です,Ok(["助動詞", "*", "*", "*", "特殊・デス", "基本形", "です", "デス", "デス"])
ReadMeに書いてるサンプルだけでは単語の分類まで取れなかったので最初「え、このライブラリ分解するだけなの?」と一瞬思ってしまった。ちゃんと取れる。
とりあえず最低限動いた
とりあえず最低限。パラメータは決め打ち。
変数の命名規則はガン無視。String使い放題、clone使い放題。例外でたらコケる。
キーワードを与えるとこんな感じで出力する。
「布団が吹っ飛んだ」はダジャレです。(フトン)
ベタ移植で3時間か…。結構かかった。
息をするようにRustが書けるようになるにはまだ遠い。
テスト追加
テストを追加した。
実装コードとテストコードを同じファイルにして良いのだろうか。
チュートリアルだからそういう説明なのか、それともこれがRustのベストプラクティスなのか、その辺まだわからない。
privateのメソッドまでテストしたい単体テスト
→同一ファイル内でテスト
publicなメソッドだけテストできればいい結合テスト
→別ファイルでテスト
のような位置づけかな。
CargoとCrates.ioについてより詳しく
dajarepの移植もボードゲームのWASM化も終わったので、勉強再開。