👞

Rustでマルバツゲームを作った感想

2022/04/11に公開約10,100字1件のコメント

まえがき

当記事では、Rust初心者の私がチュートリアル[1]を読みつつ、マルバツゲームを作ってみた際の感想を綴ります。
Rustを綴る上での自己流のノウハウも記述していますが、まだ完全にチュートリアルを読み切りさえしていないので[2]、参考になるかも怪しい点にご留意ください。

目次

Rustの思想

まずもって、実際に書いて強く覚えたのはRustの思想の強さです。高度な機能を提供していながらも、それら全てが適切なコーディングへ向けられているのがよくわかりました。
このことへの所感について、以下で述べます。

新しい言語に見られる特徴

Rustについてよく聞いた噂といえば、難しい、玄人向け、といったものでしょう。少なくとも私の耳の届く範囲であればそうでした。
私としましても概ね同意しますが、この難しいという感想は絶対的に評価されたものではない、という点を付け加えます。
プロとして働くプログラマ、あるいはエンジニアと一口に言っても、様々な種類の人物が当てはまり、中にはコーディングそのものを得意としない方々も居られます。
これを考慮して先ほどの、難しい、という感想を今一度読み返しますと、Rustでは中々許されないようなコーディングをしていたのであろうという点が導かれるはずです。
ここで、Rustで許されない云々に当てはまる人種は二種ほどだと私は考えています。それは、動けばよいと考えている人種、そして絶対に動けばよいを許さない人種の二種です。
動けばよいと考えるというのは、個人的には決して悪いものでないと考えています。たとえばインディーズのゲームクリエイタなどは、メンテナンス性をあまり考慮せずともなんとかなる場合が多いですし、そもそもコードの綺麗さの担保は、その担保によって生まれるメリットに期待できなければ、現実的にはなんら意味をなしません。美しさの追求も勿論重要ですが、そこはコーディングのプロに任せるほうが上手く回るはずです。完成品のみを評価される立場であるならば、結局バランスの問題でしかないと私は考えます。
そして、プロジェクトを健全に継続可能な状態へと近づけるRustという言語は、先述の立場であるのなら非常にリスクの高い選択と言えます。betterのうち一つではあるかもしれませんが、bestではないということです。
そして、絶対に動けばよいを許さない人種にも現状のRustは向いていないのではないかと考えました。
私は彼らを、美しさと柔軟さ、そしてできうる限りの抽象的な実装を好む人種であると定義しています。Rustは組み込み系にも利用できる言語であるが故か、柔軟すぎる実装を拒みやすい傾向にあると感じました。可能ではありますが、それらを実現するための多量の型引数、ライフタイム、そしてBoxへ格納せねば渡すことのできないクロージャ等、プログラミング力というよりかはRust力を要求されました。プログラミングを数学と捉えるような状況でRust力を要求されるのは少々ずれているような気がします。
長くなりましたが、以上の点を考慮しますと、Rustを扱える人種は皆似た技術的傾向、スタックを持つのではないだろうか、といいますのが私の仮説です。
扱う相手を選び、それによって複数人での開発を推進する言語、これが私の持つRustへの印象です。そしてこれは、競合言語と見られることもあるGolangと近しい性質を持つとも言えます。
こういった様に、新しい言語にはただ言語として優秀、という要素以外に、チームマネジメントの負担の軽減を目指している部分があるように私は感じました。

Rustはポインタを扱うべきか

Rustは構造体とそれの拡張を行いやすい言語です。けれども、関数型言語の文脈でオブジェクト指向的に用いると途端に難解になるとも感じます。
特に頻出するのはmutableな借用とimmutableな借用が同時に発生する状況下でFnMutなクロージャを用いる場面でした。発生したコンパイルエラーが借用によるものなのか、クロージャのキャプチャを利用したからか判然とせず、解決にかなりの時間を要したのが印象的です。
これが通常の関数型ライクに記述していたときならよいのですが、オブジェクト指向要素を混ぜこんで構造体も利用しつつとなると、難易度は急上昇します。
具体的には、JSライクにメソッドチェーンが行えるよう独力で実装しようとすると、かなりの制限がありました。Rustの掲げる安全性を加味すると当然ではあるのですが、selfへの借用をキャプチャしたクロージャを作れないなど、パラメータのみをカリー化の具合で受け取り、返り値の構造体のメソッドに処理を移譲しようとした際の手間暇は中々骨が折れるものでした。
これを考えた上で構造体のメソッドの追加による拡張を考えますと、構造体へのメソッドは引数として構造体のインスタンスそのものを借用する場合が多く、借用によるポインタの存在を嫌でも意識せざるを得ません。
そして借用を扱うなら当然ライフタイムについての造詣も要求され、こうなってきますと、オブジェクト指向的な組み方はかなりの難度を持つと考えられます。
そうなってきますと、Rustでポインタを扱わないという手法は実質的にオブジェクト指向を捨て、手続き型言語として活用すると見ることができます。実際、手続き型として扱うならかなり手に馴染む言語で、Rustの厳格さもむしろ気が利くと感じられるほどでした。
ここで、Rustaceanとしてはこの難度の跳ね上がるオブジェクト指向活用派と、手続き型言語の延長とする派閥がどの程度の割合で存在するのか、興味深く考えています。ポインタを扱うべきか否か、という議題については是非ともご教授頂きたいものです。

アプリケーション層への優遇

上項目でも述べましたように、Rustはライブラリ層とアプリケーション層の記述で難易度が段違いに変化する言語であるという認識です。
ここで、Rustの背景を考えてみますと、バイナリとして実行される性質からC言語などの過去の資産を活用できるという点が挙げられます。
この点を加味して考えますと、Rustの持つ思想の一つにライブラリ層の拡充は含まれていなかったのではないでしょうか。少なくとも、初段階ではそうであるように感じます。
現段階ではWeb方面等ではGolangが優秀である空気感が私の周りにはあります。これもある種のRustが持つライブラリ層への触れづらい要素が関わってきているからではないのかなと考えました。

明示性

関数を定義する際の強制的な型注釈や、クロージャの暗黙的なキャプチャによる競合を解決するための明示的な借用など、Rustには明示的であればあるほどよいという思想があるように感じます。勿論型推論のシステムがあることからして、労力に見合った意味の持たない明示性については排除しているようですが、それでもTypeScriptの型システムのような柔軟さは持ち合わせていません。
これを加味したとき、上述のチームマネジメントへの考慮故である、とも考えられますが、続く候補としてはユーザビリティとLSPへの順応もあるのだろうか、というものがあります。
完全に型推論に頼り切ったLSPというのは、ある関数を定義した際の返り値さえLSPが遡って検出せねばならず、コーダーへのUIとしては強力ですが、その実比較的重くなってしまうという欠点もあります。
その点、関数への型注釈の強制をしてしまえば、スコープを超えた外部にある関数の型を読みに行く際も、ただコードから注釈を正規表現で抜き出せばよいだけなのです。これによりRustを記述する人間へのユーザビリティを向上させやすくなりますし、たとえば素vimなどのLSPを利用しないエディタであってもコードへの理解を示しやすくなります。
このように、Rustはユーザビリティを考慮した言語である要素が散見されますし、それはアプリケーション層の書きやすさから見ても肯定できます。

TS(JS)との対比

私は元々フロントエンドが専門で、それ故TypeScriptを用いることが多くありました。その際にいわゆる型パズル等も少々嗜んでいまして、その経験を元にTSとRustとを比較して以下に述べたいと思います。

ポインタの直接操作

TSにおいて、ポインタを直接操作することはあまりありません。しかしオブジェクトのプロパティやメソッドを絡めた場合はかなり頻出します。実際、破壊的なソート操作などはその代表格でしょう。
故にTSでミュータブルなデータを扱いたいときは、かならずオブジェクトに包んで扱うようにしていました。
しかし、Rustでは直接ポインタのアドレス先にあるデータを書き換えることができます。
これにより不必要なネストを回避することができますが、逆にイミュータブルな処理を考えづらくなります。そのためのlet mut操作なのでしょうが、構造体のプロパティが一つでもmutであれば上部へ遡って全てミュータブルになってしまいます。
一部分のみイミュータブルなデータを考えると、これはこれで考えるためにRust力が必要です。元々Rustは手続き型言語の文脈も汲んでいるということで、ミュータブルの操作のほうが早く得意なのだろうと考えられます。

トレイト境界

TSでは型を定義していると、その型の必要な条件を満たしていなくてもその型として認識する機能があります。その型はtype、またはinterfaceで定義する必要があります。
その機能はRustでは明示的に宣言する必要があり、これをトレイト境界と言うそうです。
このことはRustのチュートリアルページ[1:1]でも触れられています。

注釈: 違いはあるものの、トレイトは他の言語でよくインターフェイスと呼ばれる機能に類似しています。
出展:https://doc.rust-jp.rs/book-ja/ch10-02-traits.html

厳格なクロージャ

Rustのクロージャはかなり厳格であると言えます。その要素のうちのひとつとして、クロージャにはユニークな型が付与されるというものがあるでしょう。クロージャは型推論を強力に効かせられるからそうなったのか、あるいはクロージャはユニークな型で型注釈が難しいから型推論に任せているのかはわかりませんが、ともかくクロージャは特定の型で表すことができません。たとえばクロージャを引数に取ろうとしたときはジェネリクスの利用を強制されます。
また暗黙的なキャプチャにしても、そのアドレスへの操作権をユニークなものとして保持するので、外部からそのアドレスにアクセスすることが不可能になります。
TSでは簡単に型を書くことができるので、気軽にカリー化や高階関数を扱えますが、Rustでこれを行うのはかなり難度が高いと言えます。
またTSではランタイムの仕様としてオブジェクトは自動的にアロケートされ(ヒープかスタックかはわかりませんがおそらくヒープ)、このオブジェクトの定義は関数にも当てはまります。よって関数のやりとりは全て自動的にポインタのやりとりとなるのですが、Rustでは手でBox等のスマートポインタを利用する必要があります。
こういった状況から、クロージャを渡すのとクロージャを渡されるのでは扱う技術にかなり差があると言えます。これはアプリケーション層の優遇でも語りました。

型のパワー

Rustの型はかなり厳格です。けれども型の強力さというのは厳格さだけで決まるものでなく、むしろ緩いと言われがちなTSもかなり型のパワーを持つと私は考えています。
おそらくランタイムと型情報を分けることで実現可能とした、自動的なインターフェイスには目を見張るものがありますし、むしろランタイムに影響させられないことを割り切ってより柔軟な方向へ舵を切ることで、プリミティブな値を型として利用できます。
そういった文脈では、Rustの型システムはよくできてはいるものの、まだ柔軟性を伸ばす余地があると考えられます。ただ、先述の通り、そうして記述の幅を広げれば広げるほど書き手が足並みを揃えるのが難しくなります。この状態がよいと考えることも十分に可能ですので、Rustの開発チームの今後の舵取りに注目するのがよいのでしょう。

エラーハンドリング

RustのResult型はとてもよいものだと思います。主に非同期の文脈で、TSならPromiseといったラップオブジェクトが返されることがあり、これはエラーハンドリングの手段としても優れていました。周知の事実といってもよいかもしれません。そしてこれをもっと抽象的な部分に落とし込んだのが先述のResultであると私は捉えています。
そしてRustのパターンマッチングにより、数学ライクな関数型言語よりももっと柔軟な、プログラミング言語は結局のところ人工言語であるという特性を活かした記述を行うことができます。
Result型を明確に扱うのは初学者には難しいでしょうが、非同期通信における待ち合わせ処理の経験があるのなら、違和感なくスムーズに扱うことができるはずです。
またここで、エラーが発生したときもパターンマッチングによってエラーの分別を行うことが可能という面も素晴らしく思います。
しかし、以下に引用する公式のチュートリアルの一部は如何なものかと捉えました。

type Result<T> = std::result::Result<T, Box<dyn error::Error>>;

引用:https://doc.rust-jp.rs/rust-by-example-ja/error/multiple_error_types/reenter_question_mark.html

確かにこれは手軽で書きやすく、エラーを一々区別しない場合はよいでしょう。けれども、複数エラーの要因があり、それらを判別しつつ堅実に操作したいときにこれほどの悪手はありません。
結局のところ、値比較でしかErrorがどの種別なのか判断しかねるのです。しかもTSのように存在しないかもしれないプロパティにはアクセスできないと来ています。これでは簡単なプロパティの存在チェックなどが行いづらく、また出来たとしてもそれ専用のTypeGuardをするためにトレイトを実装しなければなりません。Rustの厳格でありながらも懐の深い点を殺してしまっていると言ってもいいでしょう。
このため、私はマルバツゲームを制作したとき、複数エラーが発生する場合にはenumを用いてエラークラスを定義し、それぞれパターンマッチングを利用できるようにしました。その際、こちらのページにも私は反旗を翻したいと思います。

https://doc.rust-jp.rs/rust-by-example-ja/error/multiple_error_types/wrap_error.html

このページでは型のキャストを行う方法でエラーをラップしており、結局のところ先に引用した例となんら変わりません。やはりここは、以下のようにenumによる親子関係の作成が最も丁寧な書き方であることでしょう。

#[derive(Debug)]
enum InputError {
  IO(std::io::Error),
  Parse(std::num::ParseIntError),
}

このエラーの記述方式であれば、IOないしParseのパターンマッチングでエラーの判別が用意に行えますし、ネストしていても問題ありません。

得られたノウハウ

以下では、マルバツゲームを作成した上で得られたテクニックを記載したいと思います。

ハンドラ

基本的に、相互に循環参照している二つの構造体を作ることはできません。
私の作成したマルバツゲー ムでは、座標であるPoint構造体は、盤面を表す二次元配列のインデックスを超えられないようにしています。よって盤面の一辺の長さがPointには付与されねばならず、よって盤面の構造体であるFieldのメソッドでPointをコンストラクト出来ます。
ここで、この記述を採用した場合、以下のようなコードが発生します。

// 初期化に関しては割愛
let mut field: Field;
let p = field.new_point(0, 0)?;
field.put(p)?;

ここで前提条件として、記述はResult型が返り値の関数内部のものであり、Ok(())の記述が割愛されているものとします。
そして、上記の操作方法でもよいのですが、あまり美しくないというのが正直なところです。TS出身者として、メソッドチェーンがしたくてたまりません。
具体的にはこうできたらよいな、というのが以下です。なお動きません。

field.new_point(0, 0)?.put();

これが動かないのは、Point構造体がメソッド内部で外部へそのPoint自体の所有権を外部に移さないといけないからです。
FieldのputメソッドをコールするだけならPointのコンストラクト時に可変参照をFieldから貰うだけで済みます。
けれどもその場合、可変参照を貰ったPointはそれのメソッドに対して、Point.put時に自分自身を渡さねばなりません。
これを単純化したコードが以下になります。

#[derive(Debug)]
struct Point<'a> {
  field: &'a mut Field,
}

impl<'a> Point<'a> {
  pub fn put(self) {
    // ここが動かない
    self.field.put(self);
  }
}

#[derive(Debug)]
struct Field {}

impl Field {
  pub fn put(&self, p: Point) {
    println!("{:?}", p);
  }
  pub fn new_point(&mut self) -> Point {
    Point { field: self }
  }
}

これは動かないのですが、じゃあRustでメソッドチェーンは無理なのかというと、そうではありません。
実際、Rust メソッドチェーンなどで検索を書けると必ずと言っていいほど、配列のメソッドチェーンの記事が上部へ挙がってきます。
ヒントは配列への操作をメソッドチェーンで行うiter()にあります。
つまり、Pointへの所有権を持ちつつ、fieldへの可変参照も持つ中間構造体を実装することでこれは実現できます。これが以下になります。

#[derive(Debug)]
struct Point {}

struct Handle<'a> {
  field: &'a mut Field,
  point: Point,
}

impl<'a> Handle<'a> {
  pub fn put(self) {
    // これで動く
    self.field.put(self.point);
  }
}

#[derive(Debug)]
struct Field {}

impl Field {
  pub fn put(&self, p: Point) {
    println!("{:?}", p);
  }
  pub fn new_point(&mut self) -> Handle {
    let p = Point {};
    Handle {
      field: self,
      point: Point {},
    }
  }
}

これでようやく、メソッドチェーンによってFieldからPointへ入力することができました。
なお私が作成したマルバツゲームでは、Fieldに直接Putしないのはそれはそれで気持ち悪いなと思い、Field等のコンテナ兼ラッパーのGame構造体にてハンドラを利用しています。

UIとロジックの分離

マルバツゲームはコマンドラインで実行するものですから、当然ユーザインターフェイスもRustでの実装となります。
ここで、エラーハンドリングを行う部分のラッパーからどうしても標準出力を抜き出すことができませんでした。具体的には座標の入力で、複数回入力と入力を促す出力が繰り返されるため、より上位でその部分のコールの前後に出力を書くという手法が使えなかったのです。
そこでもうこれはこういうものだと割り切ってReactの思想よろしく、ステートフルでロジックの潜在するコンポーネントがあってもよいといった具合にして、どうしても引き抜けないところはそのままにしてあります。
この部分の試行錯誤はReactの思想の構築をなぞるような体験で、かなりの勉強になりました。ロジックとUIを混同させる際のコンポーネント化という概念を、より実践的な環境で身につけられたと思います。

入力チェックの型への移譲

上述のPointに関してもそうでしたが、コンストラクタ時に値チェックを備えることで、入力確認を外部へと移譲するノウハウをRustで初めて知りました。実際のところ、大量の顔も知らないような人間がいる規模の開発に携わったことがなく、かつフロントエンドで、バリデートはバックエンドに移譲する部分もあったために、入力チェックは殆ど必要なかったというのもあります。
けれどもコマンドラインということで、またオブジェクト指向でコードを記述する際に当たって、初めて本格的なバリデートを行いました。これは型を利用したもので、型について煩くない言語では中々できないものですが、割合言語の壁を超えて利用できるノウハウである様に感じました。

あとがき

かなり長くなってしまいましたが、以上で私がマルバツゲームをRustで制作した際に起きた感想です。
具体的なコーディングの記述というよりは思想面にフォーカスした記事となりましたが、こういった視点からのRustへの意見もアリなのかなと思い、投稿してみました。
なお、マルバツゲームのリポジトリは以下になります。

https://github.com/NULL-header/ox_game
脚注
  1. https://doc.rust-jp.rs/book-ja/ ↩︎ ↩︎

  2. 具体的には15章の途中。Rustでモノを作ってみたいという欲求に負けた。 ↩︎

GitHubで編集を提案

Discussion

エラーハンドリングはthiserrorを使うと良いかもしれません。

ログインするとコメントできます