フロントエンドエンジニアが Rust で LeetCode を1ヶ月がんばってみた

公開:2021/02/16
更新:2021/02/19
7 min読了の目安(約6300字TECH技術記事 2

2021年は Rust 頑張りたいという気持ちで LeetCode に課金して January LeetCoding Challenge 2021 をコンプリートしたので今のところの所感をまとめてみる( 成果物のまとめ

タイミング逃して 3回ほど Time Travel チケットを使ったものの 36問は一通り完了。まったくわからなくてネットで解説探したのが 2つほど、解き方は思いついたものの詰めきれずに仕上げ部分だけネットで調べたのが 3〜4つくらい、という戦績

自分のスキル的にはガチのフロントエンドで JavaScript & TypeScript を主戦場に Kotlin, Java, Swift は業務で使えるレベル。 Ruby は必要に迫られて雰囲気で使っています

よかったこと

Rust が手に馴染んできた感じはある

最初のうちはそれこそ 1行ごとにドキュメントを検索して、、、みたいな感じだったのだけれど、 Iteratorfor 文の使い方とか、 String, Char, &str の使い分けみたいなのとかはざっくり把握できていて、 vec.iter().map().collect() みたいなのとかも意識しなくても大体書ける様になったし、ポインタ周りも(効率よい書き方ができている自信はないけれど)使うだけならまぁ問題なし、という感じ

所有権のある世界での map の操作のお作法( map.entry(v).and_modify(|c| *c += 1).or_insert(1); とか)みたいなのは、やっぱり繰り返し触って手に馴染ませないと、ドキュメントだけで使いこなせるようにはならないですね

これまで C とか C# とかほとんど使ってこなかった上に Rust はさらに文法的なクセが強い言語なので、急にコード読めとか言われるとうーん、、、という感じで読める気がしなかったけれど、今ならコードみたらフワッと内容が頭に入ってきそうなくらいの自信はつきました

ドキュメント周りの雰囲気が掴めた

「すべてがオブジェクトです」で済ませられる ECMAScript の世界と違い、 Rust は標準ライブラリで提供されているモジュールや構造体がしっかり細分化されていてそれぞれで使えるメソッドも分かれている。新しい構造体とかを使う時は調べながら戦うわけですが、なんとなくどこをみるとどんなことが書かれているかわかる様になってきた

Rust は公式のドキュメントが充実していて、さらに似た様なことを繰り返しやらされると同じドキュメントを何度も読む羽目になるので、大体の文章構造やらどこに何が書いてあるとかは把握できた感じ

特に良いな、と思ったのがドキュメントにソースへのリンクがあること。 Nightly の機能とかでも実装みるとなんとなくモジュールや構造体が提供している機能的なのが掴めるのがよかったです

コンピュータサイエンスへのコンプレックスが少し解消された

ぼくはコーダーとかマークアップエンジニアと呼ばれていた方面からキャリアを積んでいて、いわゆる CS の教育を受けたことがなくてそれがコンプレックスだったのだけれど、 Rust で LeetCode というのはかなり自分ができていないと思っていたことにチャレンジしている雰囲気があってやりがいあり

スポイラー見てしまった問題でも少なくとも解決の方向性的には間違っていなかったケースがほとんどだったので、ロジカルに考えればちゃんと正しい答えにたどり着けるんだなぁ、という謎の自信はつきました

普段は触らない量の入力値を処理させられるので、試行錯誤しながら計算量とかの肌感も得られたかな。 Rust だから参照なのか実体なのかとかも意識して実装できるのでこの辺りはメリットですね

とりあえず BTreeMap とか BinaryHeap とかフロントエンド開発だけしていると中々使うことのないデータ構造を触ってみるのは純粋に知的好奇心的に楽しいです(とはいえ、急に「ヒント:ダイクストラ法」とか言われても実装に 3時間くらいかかってしまうのでその辺りは辛い)

つらいこと

テストデータを作るのが辛い

Rust には型と所有権というきびしめの制約があるので、引数が node:Some<Rc<RefCell<TreeNode>>> みたいなのになりがち。コードは主に Rust Playground で書いているのだけれど、そうするとテストケースによっては以下みたいな検証データを書かないといけない。流石に辛い

let input = Some(Rc::new(RefCell::new({TreeNode {
    val: 3,
    left: Some(Rc::new(RefCell::new({TreeNode {
        val: 2,
        left: Some(Rc::new(RefCell::new({TreeNode {
            val: 1,
            left: None,
            right: None,
        }}))),
        right: None,
    }}))),
    right: Some(Rc::new(RefCell::new({TreeNode {
        val: 4,
        left: None,
        right: None,
    }}))),
}})));
assert_eq!(
    Some(Rc::new(RefCell::new({TreeNode {
        val: 7,
        left: Some(Rc::new(RefCell::new({TreeNode {
            val: 9,
            left: Some(Rc::new(RefCell::new({TreeNode {
                val: 10,
                left: None,
                right: None,
            }}))),
            right: None,
        }}))),
        right: Some(Rc::new(RefCell::new({TreeNode {
            val: 4,
            left: None,
            right: None,
        }}))),
    }}))),
    leet_challenge(input)
);

usize と i32 の変換が辛い

i32 はいわゆる Int 型、 usize はポインター用の整数型

LeetCode では配列の操作が頻発するわけですが、その場合に使うのは主に usize

そして、引数や返り値として利用するのは主に i32

さらにいうと usize はマイナスの値にならないので、メトリクスで経路探索するときとかに引き算したりするとポインターエラーが頻発する

まぁ、 as usize とか as i32 って書けばいいだけなのだけれどめんどい

// NG
let next_pointer: usize = pointer - 1; // current が usize で 0 だと実行時エラー
if next_pointer < 0 { return result } // そもそも意味がない
if vec[next_pointer] != true {
  result += 1;
  pointer = next_pointer;  // 型が一致しないのでコンパイルエラー
}

// OK
let next_pointer: i32 = (pointer as i32) - 1;
if next_pointer < 0 { return result }
if vec[next_pointer as usize] != true {
  result += 1;
  pointer = next_pointer as usize;
}

// also OK
let check: i32 = (pointer as i32) - 1
if check < 0 { return result }
let next_pointer: i32 = check as usize;
if vec[next_pointer] != true {
  result += 1;
  pointer = next_pointer;
}

break とか使えないので関数型あんまり使えない

これは Rust に限らないかもしれないけれど、パフォーマンスを考えて配列を走査して条件を満たしたらその時点で答えを返す、みたいなやつをやろうとすると関数型はあんまり使えない

まぁ、用途が違うといえばそうなのだけれど、もっと綺麗に書けないかなぁみたいな気持ちにはなる

// NG
vec.into_iter().for_each(|v| {
  if v == x { return true } // for_each のクロージャ関数の return になってしまう
});
return false

// OK
for v in vec.into_iter() {
  if v == x { return true }
}
return false

mutable 使いまくる

上の関数型と同じで、なるべくミュータブルにしようとすると効率が悪かったりするし、所有権の問題があるのでそもそもそのままだと実装できなかったりする

LeetCode の趣旨的にまぁ仕方ないところはあるのだけれど、もし実務で使う、となったらミュータブルにしていくと思うのでその辺りに手を慣らせられないのがちょっと残念。所有権がなければあまり気にしないのだけれど、所有権を意識しつつのミュータブルなコーディングはちょっとまだ想像つかない

逆にちゃんとメモリ空間意識して組むのはそれはそれで楽しいので切り替えてはいます(実務でどれほど使うか、、、みたいなのはありつつ)

trait 使えない

なぜか標準ライブラリしか使えない。 Python は使えるみたいなのに、、、(ひどぃ)

上のミュータブルにできないにも関わっていて、自分で実装するのはしんどいけどこの trait 使えるならここはミュータブルにするのになぁ、、、という時に諦めることが結構あった

今は慣れちゃったけど、 LeetCode のフォーラムでもリクエストあるから対応して欲しい気持ちはある

Rust がない問題がある

いざ解こうとした時に違和感を感じてよく見ると C# ってなってることがたまにある。新しい問題で Rust の実装が間に合っていないとか?

でもそういう時は TypeScript で書くので全能感が得られて悪くはない

unwrap の誘惑に負けてしまう

if !vec.is_empty() {
  let next = vec.iter().next().unwrap(); // vec は空じゃないので問題ない
}

よくないのはわかっているんだけれど、直前に値のチェックしていたり、入力値に条件がきちんと決まっているので、あまり考えずに unwrap() つかっちゃう。まじでこれはよくないのだけれど、 Optional のチェックとかし始めると場合によっては簡単にコード量が 1.5〜2倍くらいになっちゃうので・・・(この例は if let 使えば問題ない)

所有権はまだまだわからないけれどわかりそうな雰囲気はある

正直 Rc はまだわかんないです

// NG
if let Some(next_node) = node.borrow().next {
  if next_node.val == 1 {
    node.next = None;
  }
}

// OK
let val = if let Some(next_node) = node.borrow().next { next_node.val } else { 0 }
if val == 1 {
  node.next = None;
}

なんとか答えにたどり着けたら、他の人の解放をみて「ふむふむ、なるほどー」ってのもできるんだけれど、「ロジック完成 → コンパイルエラーの解消 → ロジックがそもそも間違っていた → 最初に戻る」みたいなのを何度か繰り返すと流石に諦めて Time Travel チケット使おうかな、、、という気になる

ただ、まぁこれは手数なのでもうしばらくやればもう少し理解できそうかも。もう一度チュートリアルやるのもアリかもしれない

新しい言語を学ぶためのプラットフォームとしての LeetCode

アルゴリズムとか設計は別に言語変わってもたいして違わないし、コンセプチュアルなので解説読めばだいたい掴めるのですよね

でも、言語仕様やら標準ライブラリの使い方とかって最終的には考えずに手が動くみないなところまで到達しないと仕事だと使いものにならなくて、そうすると沢山手を動かすのが大事で、業務でできればいいですけどそれが難しい時に LeetCode 使って手に馴染ませる、というのは結構悪くないんじゃないかなって思いました

最後に

Rust はやっぱり良い言語だし、楽しいのでこれからも頑張っていきたい。もう少し手慣れてきたら、ずっとやりたかった Real World HTTP を Rust で頑張るをやりたいです

この記事に贈られたバッジ