よみとばしポイント
どうも限界派遣SESのnikawamikanです。
ひとりアドカレ17日目の今日もQiitan獲得のために頑張ります。
Rust100-exercisesをやった感想とかを書いていきます。
Rust100-exercisesとは?
Rustを学習するためのチュートリアル的なもので、100個の課題が与えられ一つずつ答えていくというものです。
私はハンズオンで覚えてから気になった所を掘り進めていくタイプなので、この手のエクササイズである程度俯瞰的に網羅できて良さそうだなぁーとおもってやってみました。
私はこのエクササイズを通してそれなりにRustのことを理解出来たので、けっこうオススメできます。
どんな感じで勧めていくん?
主にRustの基本的な構文から始まり、最終的に非同期ランタイムを使ってチケットシステムを構築していこうといった感じになっています。
概要として以下のようなイメージです。
- Rustの基本的な構文
- トレイトの実装
- Enumとエラーハンドリングとか
- 配列などのコレクション系の話
- スレッドでの非同期プログラミング
- 非同期ランタイムでの非同期プログラミング
ちなみに私は非同期あたりで心が折れそうでした。
今回は長くなりそうだったので、1と2の基本構文とトレイトの話をしていきます。
おそらく続きも書くと思います。
基本構文のはなし
基本構文でもRustでは他の言語と違う特徴がいくつもあります。
その中で印象的だったものをいくつか紹介します。
変数の再代入ができない
Rustでは基本的に変数はimmutable(不変)で定義されます。
例えば以下のようなコードです。
fn main() {
let x = 5;
x = 6; // 再代入しようとするとエラー
}
このように再代入しようとするとエラーが出ます。
これは有名だと思います。
気軽に多重ループを書こうとすると、このimmutableな変数の性質が邪魔をします。
fn main() {
let list_a = vec![1, 2, 3];
let list_b = vec![4, 5, 6];
for a in list_a {
for b in list_b { // list_bの所有権を失っているため、エラーとなる
println!("{} {}", a, b);
}
}
}
このコードはコンパイルエラーになります。
fn main() {
let list_a = vec![1, 2, 3];
let list_b = vec![4, 5, 6];
for a in list_a {
for b in &list_b { // list_bを借用する
println!("{} {}", a, b);
}
}
}
このようにlist_bを借用することでコンパイルエラーを回避できます。
このように、所有権と借用を意識しながらプログラムを書くことがRustの特徴です。
何度でもletで同じ変数名を定義できる
Rustでは同じ変数名を何度でも定義することができます。
fn main() {
let x = 5;
let x = x + 1;
let x = x * 2;
println!("{}", x); // 12
}
このように、let
で変数を再定義することができます。
不思議に思うかもしれませんが、所有権の移動があるため、let x = 5
で代入したx
はその後のlet x = x + 1
で使われる時に寿命が終わっているため、問題なく再定義できます。
厳密な比較を要求される
例えばi32
とi64
では型が違うため、比較することができません。
fn main() {
let a: i32 = 1;
let b: i64 = 1;
if a == b { // 型が違うためエラー
println!("same");
}
}
殆どの言語では暗黙的に型変換を行ってくれると思いますが、Rustでは厳密な比較を要求されます。
個人的には何でも比較できてしまうとJavaScrip
の==
のような挙動をするので、この厳密な比較は好きです。
&strとStringの使い分け
Rustでは&str
とString
があります。
&str
は文字列スライスで、String
はヒープ上に確保された文字列です。
&str
は文字列リテラルを指すことができます。
fn main() {
let s: &str = "hello";
println!("{}", s);
}
String
はString::from
を使って生成することができます。
fn main() {
let s: String = String::from("hello");
println!("{}", s);
}
String
はヒープ上に確保されるため、&str
よりも柔軟に扱うことができます。
ここらへんがは高級言語出身者にはややこしいですが、C++
でいうstd::string
とchar*
の関係に似ていると思います。
基本的には&str
を使っていけば問題ないと思いますが、文字列を追加したりすることを前提とした場合はString
を使うと良いです。
また、文字列は[]で範囲指定することでスライスを取得することができます。
fn main() {
let s = "hello";
let slice = &s[0..2];
println!("{}", slice); // he
}
これで、文字列のコピーを作らずにスライスを取得することができるので、メモリ効率が良くなります。
関数にreturnを書かない
Rustでは関数の最後にreturnを書かないことができます。
fn add(a: i32, b: i32) -> i32 {
a + b // returnを書かなくても最後の式が返り値になる
}
この時、;
をつけると意味が変わってしまい。コンパイルエラーになります。
fn add(a: i32, b: i32) -> i32 {
a + b; // コンパイルエラー
}
このように、Rustは最後の式が返り値になるという特徴があります。
個人的にはreturn
で終わるという前提をもっているので少し違和感があります。
以下のようなコードも許容されます。
fn max(a: i32, b: i32) -> i32 {
if a > b {
a
} else {
b
}
}
どちらも最後の式が返り値になるため、コンパイルエラーになりません。
panic!する
Rustでは継続不可能なエラーが発生した際にpanic!
マクロを使ってプログラムを終了させることができます。
fn main() {
panic!("ぱにっく!");
}
かわいいですね。
個人的にはGo言語にもpanic
があるので、なんとなく親近感を覚えて好きです。
マクロの扱いが特殊
Rustでは標準的に用意されているマクロがいくつかあり、知らず知らずに使っていることが多いです。
例えば、println!
やformat!
などがあります。
fn main() {
let x = 5;
println!("x is {}", x);
}
始めてみると、「なんで!
ついてるんやろなぁ。」と不思議な感覚になりますが、Rustの関数では可変長な引数を取ることができないため、マクロを使って可変長引数を取ることができるようになっています。
例えば、vec!
マクロは可変長な引数を取ってVec
を生成することができます。
fn main() {
let list = vec![1, 2, 3]; // 型推論が効くので型を指定しなくても良い
println!("{:?}", list);
}
他の言語でよくある以下のような関数がマクロで実装されていると考えればわかりやすいかもしれません。
def func(*args):
print(args)
トレイトのはなし
RustのRustたる所以とも言えるトレイトについても触れていきます。
Rustはトレイトを使って構造体などに振る舞いを追加する。ということを一貫して行っている印象です。
トレイトとは他の言語でいうInterfaceのようなものですが、Rustではトレイトでの実装を徹底して行っているという印象を受けました。
これは純粋関数に近い形でプログラムを書くということを意識するならばある意味当然のことかもしれません。
例えばソートする関数ではOrd
トレイトを実装していればソートできるようになるなど、トレイトを実装しているからこの関数で利用できるという形で使われています。
トレイトの実装
以下はPoint
構造体にディープコピーを実装する例です。
// Point構造体にコピーを自動実装する
#[derive(Copy, Clone)]
struct Point {
x: i32,
y: i32,
}
#[derive(Copy, Clone)]
と書くことで、Copy
とClone
トレイトを自動実装することができます。
Copy
トレイトはコピーを実装するトレイトで、Clone
トレイトはクローンを実装するトレイトです。
これにより、Point
構造体はディープコピーができるようになります。
この自動実装というものはマクロを使って実装が行われており、開発者が手動で実装することはありません。
手動で実装する際は以下のようになります。
struct Point {
x: i32,
y: i32,
}
impl Copy for Point {}
impl Clone for Point {
fn clone(&self) -> Self {
*self
}
}
このように、impl
ブロックを使ってトレイトを実装することができます。
あとからメソッドを追加する
Rustではトレイトを使ってあとからメソッドを追加することができます。
これはC#
の拡張メソッド
のようなものです。
trait Print {
fn print(&self);
}
impl Print for Point {
fn print(&self) {
println!("x: {}, y: {}", self.x, self.y);
}
}
このように、Print
トレイトを定義して、Point
構造体にprint
メソッドを追加しています。
Orphan Rule
ただし、トレイトを実装する際はOrphan Ruleというものがあり、以下のような制約があります。
- トレイトの実装は、そのトレイトまたは構造体のどちらかが自分のクレートで定義されている場合にのみ可能
- 他のクレートで定義されたトレイトを、他のクレートで定義された構造体に対して実装することはできない
この制約は、トレイトの実装がどこで行われているかがわかりやすくなるためにあると思います。
例えば以下のようなコードはエラーになります。
// クレートAで定義されたトレイト
pub trait MyTrait {
fn my_method(&self);
}
// クレートBで定義された構造体
pub struct MyStruct;
// クレートCでの実装(エラーになる)
impl MyTrait for MyStruct {
fn my_method(&self) {
// 実装
}
}
最低でも、トレイトと構造体のどちらかが同じクレートで定義されている必要があります。
ジェネリクスのトレイト
トレイトにジェネリクスを使うことができます。
trait Print<T> {
fn print(&self, t: T);
}
impl Print<i32> for Point {
fn print(&self, t: i32) {
println!("x: {}, y: {}, t: {}", self.x, self.y, t);
}
}
このように、Print
トレイトにジェネリクスを使ってprint
メソッドに引数を追加しています。
また、基本的にはオーバーロードが許容されていませんが、ジェネリクスを使うことでオーバーロードのようなことができるようになります。
trait Print<T> {
fn print(&self, t: T);
}
impl Print<i32> for Point {
fn print(&self, t: i32) {
println!("x: {}, y: {}, t: {}", self.x, self.y, t);
}
}
impl Print<&str> for Point {
fn print(&self, t: &str) {
println!("x: {}, y: {}, t: {}", self.x, self.y, t);
}
}
そのほかにもたくさんのトレイトがある
Rustには標準で多くのトレイトが用意されています。
-
Deref
: ポインタ型を参照型に変換する -
Drop
: スコープを抜けた際に実行される -
From
: 型変換を行う -
Into
: 型変換を行う -
Iterator
: イテレータを実装する
などなど、一般的なプログラミングで使うトレイトが多く用意されています。
正直、これ以上あげてしまうと記事がドキュメントの書き写しみたいになってしまいそうなので、ここらへんでやめておきます。
ただ、トレイトを実装することで振る舞いを実装していくというスタイルであることを念頭においておけば、必要な処理があった時にどんなトレイトを探せばいいか?ということがわかると思うので概念的な部分で覚えておきましょう。
まとめ
今回はRust100-exercisesをやってみて、基本構文とトレイトについて触れてみました。
Rustは正直一筋縄ではいかない言語だと思いますが、その分学びが多い言語だと思います。
ちなみに私は以下の記事を見てこのRust100-exercisesのことを知りました。
Rust100-exercisesをやってから少し時間が経って記憶が曖昧な部分もあり不正確な情報になっていないか少し心配です。
何か間違った点などあればご指摘いただけると幸いです。
次回は続きを書きます。それでは。