Tech Racho エンジニアの「?」を「!」に。
  • Ruby / Rails以外の開発一般

Rustはいいぞと言い続けるだけの記事

こんにちは、Rustはいいぞ

こんにちは。BPSでは主にC++を書いているyoshiです。今回はRustの話をさせてもらおうかと思います。

マスコットキャラクターがいいぞ

非公式(※)マスコットの Ferrisちゃんです。カワイイ!!

※非公式ですが公式のドキュメントの中にもいたりします。立ち位置としては半公式くらい?

何でカニ?

英語で甲殻類を crustacean と言うからです。ちなみに C++er とか PHPer とか Rubiest 的な やつの Rust 版は Ruster とか Rustist ではなく Rustacean と言います。

ところで

実のところ私はこの子はカニじゃなくてヤドカリの仲間の可能性があると疑っています。なぜかと言うと脚が10本書かれているのを見たことがないからです。カニもヤドカリも十脚類ですが、ヤドカリは第5脚が小さく目立たないので8本しかないように見えるんですね

カニの仲間

ヤドカリの仲間

Ferrisちゃん

完全にヤドカリと化したFerrisちゃん( Rust secure code のロゴ)

まあヤドカリでもカニでもどっちでもいいですよね、カワイイので。

……!?

ウニにもなるんか君……。

可変性の扱い方がいいぞ

Ferris ちゃんの可愛さについて語るだけでは流石に Rust について語ったことにはならないので言語の話に入ります。

Rustは純粋関数型言語ではないので、一度変数に束縛した値を更新することができます。
ただし、値を更新したことによる事故を防ぐために、プログラマが可変性を明示しないと扱えない仕組みになっています。例えば、

let a = 0;

という変数は不変なので、値を更新することはできません。

let mut a = 0;

とすると値の更新が可能になります。JavaやC++だと不変であることを明示するために finalconst を書くことになるので、その逆ですね。Rustでは原則として不変であることがデフォルトです。
私は常々C++でも可変でない限り const を書いたほうがいいと思っているのですが、しかしC++の場合常に const を書くというのは面倒なので疎かにしがちです。 const をつけまくったC++のコードと、 mut をつけないRustのコードは可変性という意味合いにおいては全く同じはずなのですが、人間はデフォルトがどちらになるかに大きく影響されます。ScalaやKotlinのように valvar といった別のキーワードを用意する言語と比べるとそこまで優位性のある話でもないですが、個人的には可変にするのはちょっと面倒なくらいで丁度いいと思っているので、可変変数の宣言が少し長くなるのは問題ないと思っています。

ライフタイムがいいぞ

ライフタイムは Rust の特徴的な機能の一つです。 Rust は安全性に重点を置いた言語ですが、その一環にライフタイムがあります。

一般に、どのプログラミング言語でも値には何らかの寿命があります。リソースは有限なので、不要になった値は廃棄してメモリ領域は使い回さなければいけません。
しかし、この使い回しが原因で、既に廃棄した領域から不定値を読み込んでしまったり、同じ領域に新しく確保された値を間違って書き換えたりするのは典型的なバグの一種です。

どこからも参照されなくなるまでメモリ回収を遅らせることでこのようなバグを防ぐ GC を持つ言語も少なくありません。 Java, C#, Go, Python, Ruby, PHP, JavaScript など、現在主要な言語のほとんどが何らかの GC を持っています。寿命が切れた値へアクセスすることに起因するバグを防げる恩恵は大きいのですが、一方でヒープからのメモリ確保が必要になるためオーバーヘッドが生まれたり、 GC のタイミングによっては負荷がかかる瞬間があったり、ファイルハンドルなどの外部リソースと結びついた値は破棄のタイミング(=外部リソース解放のタイミング)が掴めなくなるため特別な注意が必要になるなど、別種の問題が発生することもあります。 C# の構造体など GC にマネージされない型を用意する仕組みや、 Java の try-with-resources 構文、 Python の with 構文などによる部分的な RAII の導入などはこれらの問題の解決策として用意されたものになります。

一方で、古来から GC を持たない言語として設計され、現在も第一線で利用されている C 言語や C++ では、パターンによってはコンパイラが警告やエラーを出してくれることもありますが、基本的にメモリ管理はプログラマーの責務です。 C や C++ では安全性を犠牲にして直接メモリを扱うことで、よりオーバーヘッドを減らしているとも言えるでしょう( C 言語に関して、設計当初からそういう思想があったのかどうかは知りませんが)。

Rust は(多数の言語に影響を受けているとは言え)系譜としては C++ に近い言語です。 C++ と直接の互換性はないため C++ のコードをそのまま Rust のソースコードに流用することはできませんが、 C++er から見て C++ で不満に思っていたところを改善したのが Rust であると見ることもできます。そんな訳で C++er は割と Rust に好意的な人が多いです。

Rust のライフタイムは、そんな C++ からの改善点の一つと言えます。元々、概念としてのライフタイム、値の寿命は C++ などでも存在していました。 C++ でスタック上に確保された自動変数は、確保されたブロックスコープの終わりで破棄されます。ですから当然 C++ プログラマーは自動変数の寿命を意識しなければなりません。うっかり間違えて寿命の切れた変数のあったメモリ領域に踏み込もうものなら、その瞬間に未定義動作、鼻から悪魔が踊りだしコンパイラはタイムトラベルを始めます。

Rust のライフタイムが画期的な点は、概念として存在したそれを型システムの一部にしたことです。これによって寿命はコンパイル時に可視化され、未定義動作を踏むようなうっかりを行った場合、コンパイラがエラーを出すことが可能になりました。

let a: &Foo;
{
    let b = Foo::new(); // ----> b の寿命ここから
    a = &b; // error[E0597]: `b` does not live long enough
} // <-------------------------- b の寿命ここまで
a.foo();

上の例では、 ba.foo() まで生きていないことがコンパイル時に検出できるため、 a =&b で代入を行おうとした時点でエラーを発生させています。同じようなことを C++ で書くと、

Foo *a;
{
    Foo b{}; // ----> b の寿命ここから
    a = &b;
} // <------------- b の寿命ここまで
a->foo(); // !?!?!?!?

となりますが、こちらはコンパイルが通ってしまい、実行時に未定義動作でおかしなことになる可能性があります。

参照がいいぞ

上述のライフタイムは、主に参照を扱う時に必要になる概念です。参照とは何かと言うと、所有権を持たない値を扱うために必要になる仕組みです。参照が必要になる典型例は

  1. 値をコピーせずにその内容を読む
  2. 既に作られた値を関数などから更新する

というパターンでしょう。1のケースで使われるものを(不変)参照と言い、2のケースは中身を書き換える必要があるため可変参照と言います。C++で言うと1が Foo const& のようなもので、2が Foo& のようなものです。もちろん文法的にも意味的にも相違点はあります。
Rustの参照はC++と同じく & を使って表すのですが、 &Foo のように & を前置します。可変参照なら更に mut キーワードを加えて &mut Foo とします。こちらも不変がデフォルトで、可変にする場合は mut をつけるという一貫性があります。

とは言えこれだけならC++の参照などと大差はないのですが、Rustで特徴的なのは、不変参照と可変参照を同時に扱うことはできず、また更に可変参照は2個以上作ることができないという制約があることです。
というのも、もしこの制約がなければ、不変参照の参照先の値が可変参照を通して変更されてしまうといったケースが発生します。これは事故の元になるので、組み込みの参照型ではそれができないようになっています。
Rustでは参照を作ることを借用(Borrow)と言うのですが、参照が制約を満たしているかを確かめる型システムの仕組みはボローチェッカーと呼ばれています。初心者のうちはボローチェッカーがコンパイルエラーを出すことも多く、また他の言語では見慣れないエラーなので戸惑う人もいるようですが、慣れてくれば安全性のために必要な機能だと分かってくると思います。

ちなみに、実際のプログラムでは不変参照と可変参照を同時に持っておきたいというユースケースも無い訳ではありません。それが実際に必要な場合は、標準ライブラリの RefCell 型などを使うことができます。ただ、処理を見直せば実は必要ないことが分かったりするため、実際にそれが必要かどうかは使う前に考えると良いでしょう。

トレイトがいいぞ

Rustにはジェネリクスが存在します。C++のテンプレートに似て、型や関数が複数の型に対応することができる機能です。
ジェネリクスで複数の型を扱うには、扱う対象の型に共通のインターフェイスが実装されている必要があります。
例えば、値を文字列にフォーマットして出力するには、なんらかの形で文字列化するインターフェイスが必要である、といった具合にです。
C++の古典的なテンプレートでは、単純に、いかなる型が渡されるにせよ、特定のインターフェイスを実装されていることを仮定したコードを書いて、もし実際に渡された型がそのインターフェイスを満たしていなかったらエラーにするという仕組みでした。例えば、 std::ostream を使って出力できることを仮定したコードを書くと、

template<typename T>
void show(T const& v) {
    std::cout << v << std::endl;
}

といったコードになります。
これの問題点は、実際に型を置き換えてみないとエラーになるかどうかが分からないことで、そのため、C++のコンパイルエラーは実際にエラーになった箇所に加えて、その関数を呼び出している箇所が大量に出てきます。どの段階で型を間違えたのかが分からないせいです。
(最近ではC++にもコンセプトという機能が追加され、型がどのようなインターフェイスを満たしているかを記述できるようになりました。ただ、それでも古典的なテンプレートがなくなった訳ではなく、不明瞭なエラーが出る問題は消えてはいません)

もう一つ、テンプレート関数の中ではどのような型が来るか分からない状態で書く必要があるため、コード補完などのエディタの支援機能が使いづらいという問題もあります。

Rustでは、型が満たすべきインターフェイスをトレイトを使って記述することができ、ジェネリクスの内部で何らかの操作を行いたい場合はトレイトが満たされていることを要求する必要があります。これをトレイト境界と言います。

先程のC++の例をRustでトレイト境界を使って記述すると、

fn show<T: std::fmt::Display>(v: &T) {
    println!("{}", v);
}

となります。

トレイト境界が記述されたジェネリクスを呼び出す場合、呼び出し側でもトレイトが満たされていることが保証されている必要があります。
そのため、エラーは必ずトレイトが満たされていない箇所に発生します。対処方法としてはトレイト境界を追加で記述するか、具体的な型にトレイトを実装するかということになり、エラー箇所をたどってどこで間違ったかを考える必要はありません。
また、トレイト境界が記述されていればジェネリクスの内部でもトレイトに書かれた関数などは呼び出せることが保証されているため、コード補完の恩恵を受けることができます。

Rustはいいぞ

Rustの良い点は他にも色々ありますが(マクロ、エコシステム全般、実行速度などなど)、とりあえずここまでにしておきます。最近はRustもいろんな所で名前を聞くようになってきましたし、皆さんも例えば小さなコマンドラインツールなどを作る時にでも採用してみてはどうでしょうか。

Ferrisちゃんの可愛さをもう一度眺めてこの記事を終わりたいと思います。

カワイイ!!!!



CONTACT

TechRachoでは、パートナーシップをご検討いただける方からの
ご連絡をお待ちしております。ぜひお気軽にご意見・ご相談ください。