Transcript

Andy Brinkmeyer: 私の名前はAndyです。ここ数年、私は自律システムの分野で働いてきました。今日のトークでも、いくつかの例をその分野から紹介します。これはRustについてのトークです。どんなカンファレンスでも、Rustについてのトークが必ずあるものですよね。これは皆さんへの必須のRustトークです。まずは私個人の経験から始めたいと思います。私は多くの人にRustについて話してきました。そこで気づいたのは、人々はRustを全く好きではないか、非常に好きかのどちらかだということです。Rustを全く好きではない人たちは、たいていあまりRustを使ったことがありません。おそらく一度試してみて、すぐに理解できなかったためにやめてしまったのでしょう。一方、Rustを使い続けたり、実際にプロジェクトで使ってみたりした人たちは、結局Rustを愛するようになりました。2年半ほど前に、私たちは大きな新しいプロジェクトを始めました。さまざまな言語から来た人々がチームに加わりました。そのプロジェクトはすべてRustで書かれていました。私の同僚の一人は、C++から来たばかりで、最初の2週間は毎日私に不満を言っていました。

しかし、3〜4週間ほど経つと状況が変わり始めました。彼はRustを本当に好きになり始め、今では二度と他の言語に戻りたくないと言っています。Stack Overflowの調査で、人々がRustを最も愛されている言語と答えるのには、何か理由があるはずです。その理由は何でしょうか?それはメモリ安全性でしょうか。メモリ安全性は素晴らしいものです。私自身が関わるプロジェクトでも大いに恩恵を受けています。しかし、それだけではないと思います。もし、Rustがより正しいソフトウェアを最初から書きやすくし、簡単なミスや開発者のミスを犯しにくくし、全体としてより耐障害性の高いソフトウェアを作れるようにするのだとしたらどうでしょうか。それが今日のトークのテーマであり、Rustにおけるメモリ安全性以外に何があるのかを詳しくお話ししたいと思います。

Rust自体は、その核心においてかなりシンプルな言語だと私は考えています。Rustを使ったことがなく、聞いたことがあるだけのほとんどの人は、Rustについて2つのことを知っています。メモリ安全であること、そして学習曲線が非常に急で超難しいということです。それにはある程度真実があります。認めざるを得ません。Rustは簡単に入門できる言語ではありません。しかし、その核心を見れば、すべてはデータと関数についてです。データとは、ある型が持つデータのことです。関数は型や値、データに対して何かを行います。他の言語で見られるような複雑さを増す概念の多くは、Rustには存在しません。たとえば、ガベージコレクション、クラス、継承、伝統的なオブジェクト指向プログラミング、ヌルポインタ、関数オーバーロード、型強制などはありません。核心において、Cに少し似ている、または最近ではZigに似ていると表現する人もいます。それがあなたにとってどう感じるかは、あなた次第です。

列挙型: 単なる整数以上のもので

Rust特有の概念に入る前に、Rustの列挙型から始めたいと思います。なぜなら、Rustの列挙型は単なる整数以上のものであり、列挙型のバリアントはデータを保持できるからです。つまり、列挙型の各バリアントは、特定のデータを保持できます。データを持たない場合もあれば、他のバリアントとは異なるデータを持つ場合もあります。これは、TypeScriptのタグ付きユニオンに少し似ているかもしれません。Rustでは、これもファーストクラスの概念です。このデータをアクセスするには、列挙型をマッチさせる必要があります。マッチとは、他の言語のswitch文に似ていて、複数のアームやケースを持ち、列挙型の実際の値が何であるかをチェックします。Rustのmatchアームに入ると、そこで何らかのロジックを実行できます。

ロジックを実行できるだけでなく、このデータを実際にアクセスできるようになります。正しいmatchアームにいる場合にのみ、コンパイラがそのデータへのアクセスを許可します。マッチは網羅的でなければならず、すべてのバリアントを処理することが強制されます。これは、すべてのバリアントを明示的に処理することを意味するわけではなく、キャッチオールを使うこともできますが、すべてを処理する必要があり、何かを忘れることを防ぎます。もちろん、特殊なケースでより簡単で人間工学的な書き方ができる追加の構文もあります。

実際に何ができるか見てみましょう。オプションのデータという概念は、どこにでも存在します。しばしば、ヌルポインタ、ヌル値、nil値、またはこの概念に関するユーティリティを提供する特定の標準ライブラリ型としてモデル化されます。Rustでは、これを単にOptionとしてモデル化します。これが標準ライブラリのやり方です。コンパイラに組み込まれているわけではなく、単なるOptionです。1分もかからずに自分で書くことができます。RustのOptionは、2つのバリアントを持つ列挙型として定義されます。データに関連付けられていないNoneバリアントと、データTを保持できるSomeバリアントです。Tは単なる標準的なもので、ジェネリックです。後で説明します。Tとは、コンパイル時に任意の型に置き換えられるという意味です。これらのデータアクセスルールにより、オプションデータは偶発的なアクセスから保護され、マッチさせる必要があります。

たとえば、私が働いている分野では、ロボットがあり、ロボットはアクティブなジョブを持つことができます。何かを実行しているか、何もしていないかのどちらかです。これをOptionでモデル化できます。アクティブなジョブに対して何かを行うには、マッチさせる必要があります。Noneの場合、たとえばこの例では単にreturnし、何もしません。アクティブなジョブがある場合、ここでジョブにアクセスでき、外部システムに更新を公開するなどの処理ができます。

さらに進みましょう。状態はどこにでも存在します。Rustで状態をモデル化する直感的な方法の1つは、列挙型を使うことです。なぜなら、基本的に欲しいのは「今どの状態か」を示すマーカーであり、その特定の状態にのみ適用可能な特定のデータを持ちたいからです。Rustでは、これを1つの大きな列挙型としてモデル化し、異なるバリアントを持ち、各バリアントが単一の状態であり、何らかのデータをラップします。たとえば、ロボット工学の例として、ロボットの状態があり、未初期化(uninitialized)である可能性があります。単に存在していることを知っています。初期化済み(initialized)である可能性もあり、その時点で位置を受け取り、空間内のどこにいるかを知っています。また、ジョブを実行中(executing a job)である可能性もあり、それを別のバリアントとしてモデル化し、中にジョブを持ちます。データは、誤った状態で誤ってアクセスされることから保護されるだけでなく、逆もまた然りです。新しい状態に遷移したい場合、データを指定する必要があります。そうでなければ、そのバリアントを構築できません。また、有効な状態のみを最初から構築することが保証されます。他のすべてはコンパイラによって防がれます。以上が列挙型についてです。

所有権: 1つしか存在できない

次に、最初の大きな概念に移ります。所有権はRustの核心的な概念であり、他の言語ではほとんど見られません。所有権は実際には非常にシンプルです。つまり、Rustの各値には所有者が存在するということです。所有者が必要です。所有者がいないことは許されません。また、一度に1人の所有者しか持てません。このルールはコンパイラによって強制され、コンパイル時に静的にチェックされます。この所有権の概念により、値の所有者が誰であるかがわかり、所有者がいつ作成され、いつスコープ外に出るかを簡単に追跡できるため、値のライフサイクルを追跡できます。変数を考えてみてください。変数がいつスコープ外に出るかを把握するのは難しくありません。これにより、値のライフサイクルを追跡できます。なぜなら、所有者がスコープ外に出ると、値は所有者を失うため、ドロップされるからです。

ドロップとは、メモリが解放されたり、他のリソースが解放されたりするなど、何らかの方法で破棄されることを意味します。所有権は移動することもできます。ここから少し興味深いことが始まります。この例のように、代入を行うたびに、それは自動的に所有権の移転を意味します。データはコピーされず、所有権が実際に移動します。データが移動すると言うこともできますが、データ自体が別のメモリ位置に移動するという意味ではありません。これは、関数呼び出しなどで起こる可能性があります。通常は概念的に、所有権をある所有者から別の所有者に移動することを意味し、古い所有者は所有権を放棄し、その値は古い所有者を通じてアクセスできなくなります。これを使ってすでにいくつかの興味深いことを行えます。これで防げるのは、私が「二重使用」と呼ぶものです。

二重使用とは、あるエンティティが一度しか存在できないという概念です。シングルトンの意味ではなく、値の一意なインスタンス化という意味です。たとえば、ロボットがジョブを実行するシステムでのジョブのように、複数のジョブを持つことはできますが、各ジョブは一度しか実行できません。なぜなら、実行された後は終了し、それ以上作業できなくなるからです。単一のロボットは、単一のジョブでしか作業できません。また、単一のジョブは、一度に単一のロボットによってのみ実行できます。

これをRustでどのようにモデル化できるでしょうか。まず、関数シグネチャを見てみましょう。このトークでは、関数シグネチャをたくさん見ることになります。ロボットのassign関数についてです。selfの最初の部分は無視してください。これは、呼び出しているロボットを指すだけです。次にjobを見てください。パラメータjobを定義し、Job型の値を取ります。その前に参照記号がないため、このjobを値で取ることを意味します。所有権のルールを思い出してください。これは、このパラメータjobがJob値の所有権を取ることを意味します。外部から渡す場合、所有権は関数に移動し、より詳しく言うと、パラメータjobに移動します。これが実際に何を意味するでしょうか。ジョブを割り当てたい場合、ジョブが格納されているキューがあると仮定して、キューの先頭からポップして新しいジョブを取得します。

ここで、所有権が実際に働いているのがわかります。キューは以前、job値の所有者でした。今、キューの先頭からポップして新しいjob変数に代入しました。この時点で、キューはその所有権を放棄しました。もちろん、内部的にも、内部バッファから移動されたことを意味します。ポインタが再配置されたり、メモリ割り当てが変わったりするかもしれません。所有権の観点からは、ジョブキューはもはやジョブを所有していません。これで、先ほど議論した関数を使ってロボットに割り当てることができます。同じジョブを別のロボットに再度割り当てようとすると、コンパイル時エラーが発生します。なぜなら、移動された値を使おうとしているからです。最初のロボットへの割り当てでは、実際にはjobの所有権を新しいjob変数からrobot_1関数に移動したからです。これが、Rustで二重使用を効果的に防ぐ方法です。

また、値のライフサイクルについても話しました。ライフサイクルはメモリ管理だけではありません。各値のライフサイクルがわかっているため、いつ作成され、いつドロップされるかがわかっているため、リソース管理をこのライフサイクルに関連付けることができます。Rustでは、値のドロップにフックできます。ドロップとは、値がスコープ外に出るイベント、つまり所有者がスコープ外に出て値が所有者を失うときに発生します。drop関数を通じてこれにフックでき、特別な動作を定義できます。ロボット工学の分野では、2D空間に領域があり、たとえば角の周りの狭いスペースで、一度に1台のロボットしか入れないという指定があることがよくあります。実世界のリソース、たとえばこの例では2D物理空間を管理し、この保証が実際にコードで守られていることを確認したいとします。

Rustでこれをモデル化する方法は、アクセスを管理するレジストリを用意することです。ゾーンIDを指定してアクセスを要求します。返されるのはZoneAccessです。ZoneAccessは私が実装したカスタム型で、トークンのように機能します。このトークンを持っている場合、ゾーンに入ることが許可されます。ZoneAccessはコピーやクローンができず、移動することしかできないため、一度に1つのZoneAccessしか存在できません。たとえば、ロボット間で受け渡しすることはできますが、誤って2台のロボットに同じZoneAccessを与えることはできません。さらに進みます。ゾーンを解放しなければならないケースは多くあります。たとえば、ロボットが切断された場合です。その場合、コードで明示的に処理する必要があります。つまり、切断された場合は解放する必要がある、状態が変わった場合は解放する必要がある、などです。コード内のすべての個別の経路を管理する代わりに、ZoneAccess型のライフサイクルにフックして、「ドロップされたときにリソースを解放する」と言うことができます。このコードの下部を見ると、drop関数でzone_registryへの参照を持ち、ドロップされたときにfreeを呼び出すとしています。zone_registryは、他のシステムへの通信や内部状態での解放のマーク付けなどを処理します。つまり、ロボットがZoneAccessの所有権を取っている場合、ロボットがドロップされると、ロボットがZoneAccessの所有者であり、所有者自体がスコープ外に出るため、ZoneAccess自体もスコープ外に出て、コンパイラが関連するすべてのリソースの解放を処理してくれます。これにより、メモリ以外の実世界のリソースを扱い、コードのこの1つの経路を見落としてリークする可能性のあるすべてのリーク問題を防ぐことがはるかに簡単になります。

借用: データを安全に参照する

多くの場合、データを所有するだけでなく、データを参照したいだけの場合があります。データを所有するだけでは、言語がかなり制限されてしまうからです。そこで借用が出てきます。借用は、データを安全に参照する方法ですが、いくつかのルールがあります。このルールは、ある値に対して同時に存在できるのは、1つの可変参照か、任意の数の不変参照のどちらかであるということです。これはもちろんメモリ安全性にとって重要です。なぜなら、可変参照が一度に1つしか存在しない場合、複数の場所から同じ変数にアクセスしようとするデータ競合が実質的に発生しないからです。これがメモリ安全性以外にも使えることを見ていきます。名前が示すように、可変借用では、基になるデータを変更できます。不変借用では、単に見るだけです。また、借用は所有権を移動しません。元の所有者は変わりません。代わりに、値を参照するだけです。

ライフタイム: まだ安全に使えるか?

これは、ライフタイムという次の概念と密接に関連しています。ライフタイムは、「このものはまだ安全に使えるか?この借用はまだ有効で、この特定の時点で安全に使えるか?」を教えてくれます。つまり、借用のライフタイム、つまり参照のライフタイムは、貸し手のライフタイムより長く生きることはできません。もちろん、これはメモリ安全性のためです。解放された後の値を参照することはできません。しかし、それだけではありません。すぐにそれを見ていきます。値のライフタイム、つまりライフサイクルを思い出してください。変数のライフタイムは、作成されたときに始まり、破棄されたときに終わります。この情報は、もちろんライフタイムのチェックに使われます。また、ほとんどの場合、ライフタイムを明示的に参照する必要はありません。コンパイラが処理してくれます。ただし、時々必要になる場合があり、それがtickとその後に文字や単語を付ける表記です。次のスライドのために覚えておいてください。

プロトコルを型に埋め込む

所有権、借用、ライフタイムを一緒に使うことで、私は非常に興味深いことができると思います。ランタイムプロトコルをコンパイル時に型に埋め込むことができます。ここで、Rustの主要なシリアライズライブラリからの魅力的な例、または非常に興味深い例を見てみましょう。具体的には、シリアライザ、つまり単一の値をシリアライズするのを容易にする型を見てみます。少し簡略化していますので、実際のライブラリ(Serdeと呼ばれます)を見ると少し複雑ですが、これは核心的な動作を捉えています。シリアライザから始めます。何かをシリアライズしたいとき、他の何かがシリアライザを渡してくれ、シリアライザは整数32、浮動小数点64、列挙型、構造体など、あらゆる高レベルまたは一般的な値のための複数のメソッドを実装しています。構造体のシリアライズがどのように機能するかをを見てみましょう。serialize_struct関数があり、selfを値で取ります。

呼び出しているシリアライザインスタンスを参照する代わりに、シリアライザインスタンス自体を消費します。つまり、この関数を呼び出した後、同じシリアライザに再度アクセスできなくなります。戻り値の型を見てください。SerializeStructを返します。これを変換と見なすことができます。元のより汎用的なシリアライザを、構造体のシリアライズに特化したより特化したものに変換します。この構造体シリアライザは、自身で2つのメソッドを実装しています。1つはserialize_fieldで、単一のフィールドをシリアライズするためのものです。シグネチャを少し簡略化しましたが、その核心は、selfへの不変参照を取るため、インスタンスを消費せず、何度も呼び出せるということです。複数のフィールドを1つずつシリアライズしたいため、何度も呼び出せます。

次に、end関数を見てください。end関数は、このシリアライズステップの終了または最終化をマークします。これは、シリアライズしたい実際のプロトコルに多くの意味を持つ可能性があります。JSONで閉じ括弧を最終的に置き換えることを意味するかもしれません。構造体の総サイズをバイト単位で含むファイルヘッダを計算して書き込むことを意味するかもしれません。end関数を呼び出すことに重みがあります。シリアライズライブラリでは、しばしばdocstringで「endを呼び出した後に他のシリアライズデータを呼び出さないでください」と書かれています。クラッシュする可能性もありますし、ライブラリがより良く作られていれば、ランタイムエラーが発生します。Rustでは、これは必要ありません。なぜなら、end関数がselfを消費すると言うことができるからです。SerializeStructインスタンスを消費します。所有権の観点からは、構造体シリアライザを保持する値が構造体シリアライザの所有権を放棄し、それをend関数に移動することを意味します。end関数では、基本的に終了処理を行い、その後値はスコープ外に出てドロップされ、再び使用できなくなります。

これが実際にどのように見えるか、実践で見てみましょう。通常のシリアライザがあり、どこかから取得し、serialize_structを呼び出してstruct_serializerを構築します。次のステップで、フィールドのシリアライズを何回か行います。完了し、終了したいので、endを呼び出します。これは、struct_serializer変数から所有権をendメソッドに移動することを思い出してください。しかし、どこにも返されません。struct_serializerのライフタイムはここで終了し、ドロップされます。再度呼び出そうとすると、コンパイル時エラーが発生し、「借用された移動値: struct_serializer」と表示されます。これは非常に興味深いと思います。なぜなら、開発者のバグの全クラスをコンパイル時に完全に排除できるからです。そうでなければ行わなければならなかった多くのエラーハンドリングを節約できます。これらすべてが、ランタイムプロトコルを型システムに埋め込むことによって実現されます。Rustの型システムがこれを可能にします。

Mutexによる真に保護されたアクセス

これに関連するもう1つの例は、Mutexを通じて共有データへのある種のアクセスをモデル化することです。しばしば、これは2つの別々のものとしてモデル化されます。1つは保護したいデータ、もう1つはMutexで、開発者との契約として、この値にアクセスするにはまずMutexをロックする必要があるとします。ロックした場合にのみ、たとえばポインタをデリファレンスすることが許可されます。Rustでは、これは少し異なり、少し堅牢に動作します。まず、newメソッドを見てみましょう。これは新しいMutexを作成します。シグネチャに参照がないことがわかります。つまり、保護したいデータはMutexに移動されるということです。所有権を放棄し、Mutexの外でデータを参照したり使用したりできなくなります。なぜなら、所有権を放棄してMutex自体に移動したからです。

次に、Mutexをロックし、ロックが成功した場合、MutexGuardが返されます。ここで初めて明示的なライフタイム、このtick aを見ます。このtick aは、MutexGuardのライフタイムがMutex自体のライフタイムに束縛されることを意味します。これを少し分解すると、MutexGuardはMutex自体のライフタイムより長く生きることは許されないということです。Mutexが実際に生きている間より長くMutexGuardを保持することはできません。さらに興味深いことがあります。MutexGuardを通じてデータをアクセスでき、guardをデリファレンスするだけでアクセスできます。また、データへの参照だけが得られます。ライフタイムルールにより、基になるデータのこの参照も、MutexGuardのライフタイムに束縛されます。もちろん、MutexGuardのライフタイムはMutex自体のライフタイムに束縛されているため、この借用のライフタイム、つまり参照のライフタイムもMutex自体のライフタイムより長く生きることはできません。

これはメモリ安全性です。さらに興味深いことがあります。なぜなら、MutexGuardはスコープ外に出たとき、つまりドロップされたときにMutexのロックを解除するからです。これらすべてをまとめましょう。MutexGuardを通じてのみ取得できる基になるデータへの参照は、MutexGuard自体のライフタイムより長く生きることはできないことがわかります。MutexGuardがドロップされた後に基になるデータへの参照にアクセスしようとすると、コンパイル時エラーが発生します。MutexGuard自体がスコープ外に出たときにMutexのロックを解除するため、ロックを所有していないときに保護されたデータへの参照を取得したり使用したりすることは不可能です。これは非常に興味深いことです。なぜなら、私は重いマルチスレッドのコードに携わってきましたが、人々が誤って参照を保持し、受け渡し、忘れてからMutexのロックを解除し、突然同時実行の問題が発生するケースが非常に多かったからです。

ジェネリクス: 強力な代役

今日の最後の概念は、ジェネリクスの概念です。ジェネリクスは、Option型などで既に見てきました。ジェネリクスはRustの代役です。異なる具体的な型で再利用できる定義です。たとえば、Optionでは置き換え可能で、もう1つの例はベクタです。コレクションでよく使われます。なぜなら、通常は型を一度実装し、格納するさまざまな型で再利用したいからです。ジェネリクスは常にコンパイル時に具体的な型に置き換えられ、モノモーフィゼーションと呼ばれるプロセスを通じて行われます。つまり、すべての特殊化に対して特定のコードが生成されるため、ランタイムオーバーヘッドが排除されます。具体的な型で作業するのと同じくらい高速です。ジェネリクスでさらに多くのことができます。トレイトとライフタイムで境界を指定できます。ライフタイムで境界を指定することは、MutexGuardで既に見たものです。また、トレイトで境界を指定することもできます。Rustのトレイトは、型がサポートしなければならない動作の種類を指定する概念に過ぎません。今日はそれほど興味深いものではありません。

ジェネリクスとTypestateによるステートマシン

ステートマシンを再訪しましょう。状態はRustで列挙型を使って簡単にモデル化できると言いました。これは真実であり、多くの場合非常に実用的です。しかし、一部の状況では、別のパターンの方が役立つことがあります。それがtypestateパターンです。Typestateとは、状態情報をコンパイル時に型システムにエンコードするという、私たちがすでに訪れた概念を意味します。詳しく見てみましょう。Uninit型、Init型、ExecutingJob型の3つの新しい型を定義します。これらは、列挙型の元のステートマシンにあったのと同じ状態ですが、バリアントとしてエンコードされています。Uninit型は、データが関連付けられていないことを思い出してください。ここでは単なる空の構造体です。ゼロサイズの型です。スペースを占有しません。コンパイラでマーカーとして使用されるものと見なすことができます。特定の型であることを示すマーカーです。

次に、Init型があり、位置などのデータを持つことができます。もう1つの型はExecutingJob型で、位置に加えて現在実行中のジョブも持ちます。これらをそのまま使用し、これらの型間の状態遷移を定義することもできます。これは完全に問題ありません。多くの場合、モデル化しようとしているものの状態に関連付けられた追加のデータもあり、それをどこかに格納したいとします。たとえば、ロボットには名前があり、その名前を参照してどこかで使いたいとします。これらの型を単独で使用する代わりに、より高レベルの型に埋め込みます。この場合、単にRobotとしましょう。Robotは状態Sに対してジェネリックです。これらが山括弧とSです。これで、Sを型代役として、たとえばフィールドで使用できます。つまり、このRobotには状態があり、その状態はS型であるとします。

この時点では、状態はまだわかりません。コンパイル時に具体的な状態が指定される何らかの状態であることだけがわかります。この実装ブロックでこのRobotの動作を実装できます。Sを指定していないことに注意してください。これは、この機能が任意の状態のすべてのRobotで機能することを意味します。これは、ロボットの名前は常に利用可能であり、状態に依存しないはずだと言ったからです。これがモデル化していることです。

次に、Uninit状態にあると仮定しましょう。別の実装ブロックを作成します。ここで、ジェネリックをUninit型に指定します。もはや型のコレクション、つまり型のセットについて話しているのではなく、特定の型、Robotについて話しています。これを今、Robot型と呼びましょう。これに対して、init関数を実装します。これはselfの所有権を取り、Init型の新しいRobotを返し、位置も取ります。新しいRobot型を構築するだけです。もちろん、裏で実行される追加の機能や動作がある場合もあります。単純化のため、これは単なるコンストラクタのようなものです。Init状態のRobotを見ることもできます。そこで同じことを行います。状態遷移は、この具体的な型、Robotのメソッド実装になります。そこで再びselfの所有権を取ります。

必要なデータを取ります。または、このデータは関数内で他の場所から計算されるかもしれません。次に、新しい型、ExecutingJob状態のRobotを返します。また、ここでも位置が利用可能であると言います。これを外部の人々が利用できるようにするために、パブリックメソッドpositionを実装し、位置を返します。このpositionメソッドを汎用的なRobotに置く代わりに、オプションデータとして返す必要があり、呼び出し側は常に「これは存在するか、存在しないか」をチェックする必要があり、または「存在するはずだが正しい型が得られないので、正しい型に強制する必要がある」というエラーハンドリングを行う必要がありました。これにより、Init状態では位置が存在するはずであることがわかっているため、はるかに簡単になります。

これを実際に見てみましょう。Robotを取得します。理論上のキューからポップします。Uninit状態なので、明示的にRobotとしてマークして、この型が何であるかのヒントを与えます。nameメソッドは問題なく呼び出せます。positionメソッドを呼び出すと、コンパイル時エラーが発生し、「現在のスコープで構造体Robotにpositionという名前のメソッドが見つかりませんでした」と表示されます。このエラーメッセージにはさらに多くの情報があり、「このメソッドはExecutingJob状態のRobotまたはRobotで利用可能です」と提案するものもあります。コンパイラはここで非常に役立ちます。最後の例です。これは少し高度です。ビルダーに対してまったく同じ種類のパターンを活用しています。ビルダーパターンは、かなり複雑な型や複雑な作成フローを必要とする型がある場合のパターンです。

たとえば、HTTPリクエストで、ヘッダーを設定し、ボディを設定し、何らかの方法で終了し、正しいかどうかの検証を行う必要があるとします。通常、2つのステップで行います。ビルダー型を作成し、このビルダー型が構築プロセスを容易にします。次に、finishメソッドを呼び出してビルダーを終了し、最終的な型を返します。

Rustでこれをどのようにモデル化できるかを見てみましょう。ロボットシミュレーションの構築の例を使います。単一のロボットのシミュレーションです。2種類の情報を提供する必要があるとしましょう。初期位置と、使用するマップの種類、つまり運転するマップです。RobotSimulationBuilder型を作成します。ここでジェネリクスを使い、位置のためのPジェネリックと、マップのためのMジェネリックを使います。もう1ステップ戻りましょう。4つの構造体を定義し、すべてゼロサイズなのでデータを持ちません。コンパイラのためのマーカーのようなものです。NoPosition、PositionSet、NoMap、MapSetです。常にNoPositionとNoMapの特定の状態から始めます。NoPositionとNoMapに置き換えられたこれら2つの指定された古いジェネリックを持つRobotSimulationBuilderは、具体的な型になりました。この型では、位置を設定したり、マップを設定したりできます。

関数シグネチャを見てみましょう。おそらく繰り返しになると思いますが、selfを消費して以前の値を無効化します。位置を設定する場合、たとえばNoPosition型をPositionSet型に変更し、NoMapはそのままにします。この具体的な型、RobotSimulationBuilder PositionSet but NoMapに対して何を実装するかを見てみましょう。位置はすでに設定されているため、set_mapメソッドのみを実装します。再度必要ないため、set_mapと言うだけです。これは、同じ値をリセットすることが、たとえばバックグラウンドで高価な計算を行うため、または外部リクエストを行うため、高価である場合に非常に興味深いです。または、契約自体から、同じ値を複数回提供することが無効である場合です。これをコンパイル時に防ぐことができます。この特定の型特殊化では、SetMapメソッドのみが利用可能です。PositionSetとMapSetを持つRobotSimulationBuilderを返します。これで完了です。なぜなら、ここで必要なのはbuildを呼び出すだけで、buildは最終的な構築ステップを実行するからです。必要なものをすべて提供したことがわかっているため、エラーが返される理由はありません。ストレートにロボットシミュレーションを返します。

堅牢性は難しくある必要はない

今日お伝えしたかったのは、堅牢性は難しくある必要はないということです。難しくなることはあり、ソフトウェアに対する非常に高いレベルの信頼性を達成することは、依然として厳格なプロセスと高価なツールを必要とする非常に困難な問題です。しかし、実用的な目的では、私たちのほとんどは安全クリティカルなアプリケーションを構築しているわけではありません。人々が依存する信頼性の高いアプリケーションを構築しているだけです。Rustは、型システムを活用することで、他の言語では達成が難しい堅牢性のレベルを達成するのに大いに役立ちます。

質問と回答

参加者1: これは2つのジェネリック要素がある場合にうまく機能します。1つのPと1つのNoMapを持つことが可能であることを願っています。たとえば、SetMapをすべての順列で実装する必要がないように。

Andy Brinkmeyer: はい、わかっています。もちろん、設定したいものが10種類ある場合、手ですべての順列を書き出すのは非現実的になります。Rustには非常に強力なマクロシステムがあります。Rustのマクロは単なるテキストテンプレートではありません。Rustでは、コンパイル時に新しいコードを生成するコードを実際に書くことができます。実際に、このメソッド生成をすべて行うマクロを実装できます。

参加者1: RobotSimulationBuilder P and NoMapのように書くことはできませんでしたよね。つまり、一部をジェネリックにすることはできません。

Andy Brinkmeyer: はい、それはできません。指定する必要があります。何かあれば、Pと書いて、Mを型に特殊化されていないジェネリックにすることもできます。それがどこで役立つのかわかりません。

参加者2: 所有権と借用モデルについて言及されました。これは完全にコンパイル時に行われ、余分なコードは追加されないと仮定します。

Andy Brinkmeyer: はい。

参加者2: また、もちろん、ランタイム中に誰が何かの所有者であるかを反映することは不可能です。

Andy Brinkmeyer: いいえ、所有者のランタイムリフレクションはありません。なぜなら、それは本当にコンパイル時の概念であり、コンパイル時に完全にチェックされるからです。ランタイムでは、他の変数がこの値を所有しているかを調べる方法はありません。それは不可能です。

参加者3: ジェネリクスをモノモーフィック型に変換するためのモノモーフィゼーションについて言及されました。コンパイラにとってどれくらい複雑または難しいですか?遅くしますか?コンパイル時に行われるこれらすべての素晴らしいチェックは、コンパイラにどれくらいの負荷がかかりますか?

Andy Brinkmeyer: あなたは、Rustコンパイラチームがまだ取り組んでいる核心的な改善点の1つに触れています。それはコンパイル時間についてです。もちろん、このような複雑なコンパイラでは、他の言語よりもコンパイルに時間がかかります。それは事実です。多くのチェックが行われており、所有権を把握するための非常に複雑なアルゴリズムがあります。Rustは開発速度に多大な投資をしており、インクリメンタルコンパイルにより、すべてを最初からコンパイルし直す必要がありません。ほとんどの場合、大規模なプロジェクトであっても、コンパイル時間は通常数秒です。もちろん、本番ビルドを作成する場合、これは時間がかかる可能性があります。多くの場合、これは継続的インテグレーションのワーカーに任せて処理させます。また、ウェブアプリケーションのTypeScriptプロジェクトで、ビルド時間が私たちの大規模なRustアプリケーションよりも長かったのを見たことがあります。

参加者4: 以前のスライドで列挙型について話していましたが、異なる状態を持つことができ、異なるデータを持つことができ、それらの種類のものです。C++の背景から来て、メモリ局所性やそのようなことを心配する必要がありますか?多くの異なるメモリ場所に存在する可能性のあるデータコレクションを持っていますか?この列挙型がデータを収集し、それを別の列挙型に変更し、それが状態の追加データを持っている場合、アプリケーションのメモリプールの多くの異なる部分から実際にデータを引き出しているのでしょうか?

Andy Brinkmeyer: データの局所性とデータの再配置について質問していますか?

参加者4: はい。

Andy Brinkmeyer: いいえ、列挙型は一度割り当てられます。スタック上またはヒープ上、割り当て場所に応じてです。列挙型のサイズは、最大のバリアントのサイズです。Cの共用体に似ていて、コンパイラがアライメントとサイズの要件などを把握し、適切に割り当てます。列挙型を別のバリアントに実際に変更する場合、実際には再割り当てしたり、異なる場所からデータを引き出したりしません。可能な限り小さくするための最適化もあります。たとえば、Rustにはヌル値がないため、Rustには「ヌルになり得ない型」とマークする概念があり、これは列挙型で使用され、保持する値がヌルになり得ないことがわかっている場合に、一部のバリアントをマークすることができます。これにより、メモリをさらに節約するために、すべてのメモリがヌル値に置き換えられたバリアントを持つこともできます。

参加者5: Rustにはジェネリクスの部分特殊化のためのナイトリサポートがあると理解しました。

Andy Brinkmeyer: 部分特殊化とはどういう意味ですか?

参加者5: 最初の質問、一部のジェネリックテンプレートのみを特殊化する実装を持つことです。

Andy Brinkmeyer: そう言えば、もちろん、はい、それは現在ナイトリ機能でしょう。バリアディックジェネリクスのようなものだけが、パイプラインに長い間あり、さまざまな数のジェネリックを指定し、まだわからないと言うことができます。もちろん、ジェネリックシステムにもまだ多くの作業があります。

より多くのトランスクリプト付きのプレゼンテーションを見る