🐡

Rustの基本概念だけを学習してみた

2024/06/16に公開

はじめに

業務で使用することはない Rust ですが、最近ホットだと言われているので重要な概念だけを学習してみて、メリットを調べました。

所有権の移動

所有権の移動の例

fn main() {
    let x = String::from("Hello");
    let y = x;
    println!("{}", x); // ここでエラーが発生します
    println!("{}", y);
}

エラーメッセージ

error[E0382]: borrow of moved value: `x`
 --> main.rs:4:20
  |
2 |     let x = String::from("Hello");
  |         - move occurs because `x` has type `String`, which does not implement the `Copy` trait
3 |     let y = x;
  |             - value moved here
4 |     println!("{}", x); // ここでエラーが発生します
  |                    ^ value borrowed here after move

このコードはコンパイルエラーになります。Rust には「所有権」という独自の概念があり、これが他の多くのプログラミング言語と異なる点です。所有権の移動とは、変数が所有するデータが他の変数に渡されたときに、元の変数はそのデータを「所有」しなくなることを指します。

上記の例では、let y = x; の行で、x が持っていたデータの所有権が y に移動します。そのため、x はもはやそのデータを参照することができません。これにより、println!("{}", x); でエラーが発生します。

所有権の移動のメリット

  1. メモリ管理の効率化: 所有権の概念により、Rust はコンパイル時にメモリの安全性を保証します。これにより、手動でメモリ管理を行う必要がなくなり、メモリリークや二重解放のバグを防ぐことができます。

    • 注意点: Rust のメモリ安全性はメモリリークを完全に防ぐことはできません。例えば、std::rc::Rc では循環参照が起こり、メモリリークになりますし、std::mem::forget を使うことでメモリリークを起こすことも可能です。Rust においてメモリリークもメモリ安全とされるのは、メモリリークが未定義動作を引き起こさないためです(参考: TRPL )。

    • メモリ安全性: メモリ安全性は「メモリアクセスが未定義動作とならない」ことを保証するものであり、メモリリークを防ぐためのものではありません。

  2. 競合状態の防止: 所有権に基づく借用規則により、データの同時アクセスが制限され、データ競合を防ぎます。これにより、スレッドセーフなコードが自然に書けるようになります。

    • 補足: Rust のスレッド安全性は主に Send トレイトと Sync トレイトによるものが大きいです。
  3. パフォーマンスの向上: ガベージコレクションが不要なため、実行時のオーバーヘッドが少なくなります。これにより、高パフォーマンスなプログラムを作成することが可能です。

借用

Rust には「借用」という概念があります。これは、変数の所有権を移動せずに、他の変数がその値を一時的に参照できる仕組みです。借用には「不変借用」と「可変借用」の 2 種類があります。

不変借用

fn main() {
    let s1 = String::from("Hello");
    let s2 = &s1; // 不変借用
    println!("{}", s1); // 問題なし
    println!("{}", s2); // 問題なし
}

このコードはコンパイルエラーにはなりません。let s2 = &s1; の行で、s1 のデータを不変で借用しています。この場合、s1 と s2 の両方が同じデータを参照することができます。ただし、不変借用中はその値を変更することはできません。

以下のコードでは、不変借用と可変借用を同時に行おうとすることでコンパイルエラーが発生します。

fn main() {
    let mut s1 = String::from("Hello");
    let s2 = &s1; // 不変借用
    let s3 = &mut s1; // 可変借用
    println!("{}", s1); // 問題なし
    println!("{}", s2); // 問題なし
    println!("{}", s3); // ここでエラーが発生します
}

エラーメッセージ

error[E0502]: cannot borrow `s1` as mutable because it is also borrowed as immutable
 --> main.rs:4:14
  |
3 |     let s2 = &s1; // 不変借用
  |              --- immutable borrow occurs here
4 |     let s3 = &mut s1; // 可変借用
  |              ^^^^^^^ mutable borrow occurs here
5 |     println!("{}", s1); // 問題なし
6 |     println!("{}", s2); // 問題なし
  |                    -- immutable borrow later used here

error[E0502]: cannot borrow `s1` as immutable because it is also borrowed as mutable
 --> main.rs:5:20
  |
4 |     let s3 = &mut s1; // 可変借用
  |              ------- mutable borrow occurs here
5 |     println!("{}", s1); // 問題なし
  |                    ^^ immutable borrow occurs here
6 |     println!("{}", s2); // 問題なし
7 |     println!("{}", s3); // ここでエラーが発生します
  |                    -- mutable borrow later used here

修正後のコード:

不変借用と可変借用を同時に行わないようにするためには、以下のように修正します。

fn main() {
    let mut s1 = String::from("Hello");
    {
        let s2 = &s1; // 不変借用
        println!("{}", s2); // 問題なし
    } // s2のスコープ終了
    let s3 = &mut s1; // 可変借用
    println!("{}", s3); // 問題なし
}

この修正版のコードでは、s2 の不変借用が終了した後に、s3 の可変借用を行っています。これにより、コンパイルエラーが発生しなくなります。

可変借用

fn main() {
    let mut s1 = String::from("Hello");
    let s2 = &mut s1; // 可変借用
    s2.push_str(", world!");
    println!("{}", s1); // 可変借用中はエラー
    println!("{}", s2); // 問題なし
    // println!("{}", s1); // 問題なし
}

このコードでは、let mut s1 = String::from("Hello"); として s1 を可変に宣言しています。そして、let s2 = &mut s1; の行で s1 を可変借用しています。可変借用中は、その値を変更することができますが、元の変数(ここでは s1)はその間アクセスできません。

エラーメッセージ

error[E0502]: cannot borrow `s1` as immutable because it is also borrowed as mutable
 --> src/main.rs:5:20
  |
4 |     let s2 = &mut s1; // 可変借用
  |              ------- mutable borrow occurs here
5 |     println!("{}", s1); // 可変借用中はエラー
  |                    ^^ immutable borrow occurs here
6 |     println!("{}", s2); // 問題なし
  |                      -- mutable borrow later used here

借用のメリット

  1. データ競合の防止: 借用規則により、同時に複数の可変参照が存在することが防がれ、データ競合を避けることができます。

  2. メモリ管理の簡素化: 借用を利用することで、所有権を移動せずにデータを共有できるため、メモリ管理が簡素化されます。

  3. コンパイル時の安全性: 借用規則がコンパイル時にチェックされるため、ランタイムエラーを減らし、安全なコードを書くことができます。

束縛

Rust では、変数に値を「束縛」することで、その変数が特定の値を保持するようになります。束縛は基本的に、変数に対して初期化を行う操作です。

変数の束縛

fn main() {
    let x = 5;
    println!("The value of x is: {}", x);
}

このコードでは、let x = 5; により、x に 5 という値を束縛しています。この束縛により、x は以降のコードで 5 という値を保持します。

パターンマッチングによる束縛

Rust では、パターンマッチングを使用して、複雑なデータ構造から値を取り出して変数に束縛することもできます。

fn main() {
    let (x, y) = (1, 2);
    println!("x: {}, y: {}", x, y);
}

この例では、タプル (1, 2) を let (x, y) に束縛しています。これにより、x に 1、y に 2 がそれぞれ束縛されます。

イミュータブルとミュータブルな束縛

Rust では、デフォルトで変数はイミュータブル(変更不可)です。ミュータブル(変更可能)な変数を作成するには、mut キーワードを使用します。

fn main() {
    let mut x = 5;
    println!("The value of x is: {}", x);
    x = 6;
    println!("The new value of x is: {}", x);
}

このコードでは、let mut x = 5; により、x をミュータブルな変数として束縛しています。その後、x の値を 6 に変更しています。

束縛のメリット

  1. 明確なデータ管理: 変数に値を束縛することで、その変数が何を保持しているかが明確になり、コードの可読性が向上します。

  2. 不変性の保証: デフォルトで変数はイミュータブルであるため、意図しない変更を防ぐことができます。これにより、予期せぬバグの発生を抑えることができます。

  3. 効率的なメモリ使用: 束縛は Rust の所有権システムと連携して動作し、効率的なメモリ使用と安全なメモリ管理を実現します。

シャドーイング

Rust では、変数の「シャドーイング」を利用して同じ名前の変数を再度宣言することができます。シャドーイングにより、変数の値を更新する際に新しいスコープを作成せずに、同じ変数名で再利用することが可能です。

fn main() {
    let x = 5;
    let x = x + 1;
    {
        let x = x * 2;
        println!("The value of x in the inner scope is: {}", x); //The value of x in the inner scope is: 12
    }
    println!("The value of x is: {}", x); //The value of x is: 6
}

このコードでは、let x = 5; により x に 5 が束縛され、その後 let x = x + 1; により x の値が 6 にシャドーイングされます。さらに内側のスコープでは、let x = x * 2; により x の値が 12 にシャドーイングされます。内側のスコープが終了すると、外側のスコープでの x の値は再び 6 に戻ります。

シャドーイングのメリット

  1. コードの明確化: 同じ変数名を再利用することで、変数の更新を明確に示すことができ、コードの読みやすさが向上します。

  2. 誤用の防止: 新しい変数を作成する際に同じ名前を使うことで、意図しない変数の再利用を防ぎ、誤用を減らします。

  3. スコープ管理の簡素化: シャドーイングにより、異なるスコープで同じ名前の変数を使用できるため、スコープ管理が簡素化されます。

ライフタイム

Rust では、ライフタイム(寿命)を使用して、参照が有効である期間を明確にすることができます。これにより、メモリの安全性が保証されます。ライフタイムの指定は、特に関数のパラメータや戻り値の参照で重要です。

以下の例では、ライフタイムパラメータを使用して、参照の寿命を指定しています。

fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
    if x.len() > y.len() {
        x
    } else {
        y
    }
}

fn main() {
    let string1 = String::from("long string is long");
    let result;
    {
        let string2 = String::from("xyz");
        result = longest(string1.as_str(), string2.as_str());
        println!("The longest string is {}", result); //The longest string is long string is long
    }
}

このコードでは、longest 関数にライフタイムパラメータ 'a を指定しています。これにより、x と y の参照の寿命が同じであることを保証しています。

ライフタイム 'a は、関数の引数 x と y の両方に適用され、返り値の参照も同じライフタイムを持ちます。

これにより、返り値の参照が引数のどちらかの参照よりも長生きすることがないようにします。

ライフタイムが異なる参照を返す場合

以下の例では、ライフタイムが異なる参照を返そうとしてコンパイルエラーが発生します。

fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
    if x.len() > y.len() {
        x
    } else {
        y
    }
}

fn main() {
    let string1 = String::from("long string is long");
    let result;
    {
        let string2 = String::from("xyz");
        result = longest(string1.as_str(), string2.as_str());
    } // string2がここでドロップされる
    println!("The longest string is {}", result); // ここでエラーが発生します
}

このコードでは、result が string2 の参照を持っている可能性があるため、string2 のスコープが終了した後に result を使用するとコンパイルエラーが発生します。

エラーメッセージ

error[E0597]: `string2` does not live long enough
  --> main.rs:14:44
   |
14 |         result = longest(string1.as_str(), string2.as_str());
   |                                            ^^^^^^^ borrowed value does not live long enough
15 |     } // string2がここでドロップされる
   |     - `string2` dropped here while still borrowed
16 |     println!("The longest string is {}", result); // ここでエラーが発生します
   |                                          ------ borrow later used here

このエラーメッセージは、string2 がスコープを抜けてドロップされるため、その参照を持つ result を使用しようとするとエラーが発生することを示しています。

ライフタイムのメリット

  1. メモリ安全性の保証: ライフタイムにより、参照が有効な期間を明確に指定でき、メモリ安全性が保証されます。

  2. バグの防止: 無効な参照によるバグをコンパイル時に検出できるため、実行時エラーを減らすことができます。

  3. コードの信頼性向上: ライフタイムを明確に指定することで、コードの信頼性と可読性が向上します。

トレイト

トレイトは、Rust のインターフェースのようなもので、特定の動作を定義します。トレイトを実装することで、構造体や列挙型に特定の機能を持たせることができます。

以下の例では、Summary トレイトを定義し、Tweet 構造体に実装しています。

pub trait Summary {
    fn summarize(&self) -> String;
}

pub struct Tweet {
    pub username: String,
    pub content: String,
    pub reply: bool,
    pub retweet: bool,
}

impl Summary for Tweet {
    fn summarize(&self) -> String {
        format!("{}: {}", self.username, self.content)
    }
}

fn main() {
    let tweet = Tweet {
        username: String::from("horse_ebooks"),
        content: String::from("of course, as you probably already know, people"),
        reply: false,
        retweet: false,
    };

    println!("1 new tweet: {}", tweet.summarize()); //1 new tweet: horse_ebooks: of course, as you probably already know, people
}

このコードでは、Summary トレイトを定義し、Tweet 構造体に対して summarize メソッドを実装しています。

トレイトのメリット

  1. 再利用性の向上: トレイトを使用することで、共通のインターフェースを持つ複数の型に対して同じ機能を実装できます。

  2. コードの整理: トレイトを使うことで、コードの構造を整理し、可読性を向上させることができます。

  3. 多態性の実現: トレイトを用いることで、異なる型のオブジェクトを同じように扱う多態性を実現できます。

スライス

Rust には「スライス」という概念があります。スライスは、コレクションの一部を参照するための型で、固定長の配列や動的なベクタの部分を扱うことができます。スライスは所有権を持たないため、元のコレクションの所有権を移動せずに使用することができます。

スライスの例

fn main() {
    let a = [1, 2, 3, 4, 5];
    let slice = &a[1..3];
    println!("{:?}", slice); // [2, 3]
}

参照としてスライスしない場合

fn main() {
    let a = [1, 2, 3, 4, 5];
    let slice = a[1..3]; //修正
    println!("{:?}", slice);
}

エラーメッセージ

error[E0277]: the size for values of type `[{integer}]` cannot be known at compilation time
 --> main.rs:3:9
  |
3 |     let slice = a[1..3];
  |         ^^^^^ doesn't have a size known at compile-time
  |
  = help: the trait `Sized` is not implemented for `[{integer}]`
  = note: all local variables must have a statically known size
  = help: unsized locals are gated as an unstable feature
help: consider borrowing here
  |
3 |     let slice = &a[1..3];
  |                 ^

error[E0277]: the size for values of type `[{integer}]` cannot be known at compilation time
   --> main.rs:4:5
    |
4   |     println!("{:?}", slice); // [2, 3]
    |     ^^^^^^^^^^^^^^^^^^^^^^^^ doesn't have a size known at compile-time
    |
    = help: the trait `Sized` is not implemented for `[{integer}]`
    = note: this error originates in a macro (in Nightly builds, run with -Z macro-backtrace for more info)

エラーの理由

エラーの理由
このエラーは、Rust のコンパイラが変数のサイズをコンパイル時に知る必要があることに起因します。a[1..3] というスライスを取得すると、その結果は動的なサイズの配列になります。この場合、slice 変数がコンパイル時にサイズを持っていないため、コンパイラはそれを処理できません。

Rust では、スライス(部分配列)を扱うために参照を使う必要があります。これにより、コンパイラはスライスのサイズを知ることができ、エラーを回避できます。

スライスのメリット

  1. データ共有の効率化: スライスを使用することで、コレクションの一部を効率的に共有でき、無駄なデータコピーを避けることができます。

  2. メモリ安全性: スライスは所有権を持たないため、所有権の移動によるエラーを防ぐことができます。

  3. 柔軟なデータ操作: スライスを利用することで、コレクションの部分を柔軟に操作できます。

パターンマッチング

Rust には「パターンマッチング」という強力な条件分岐の仕組みがあります。match 構文を使用することで、値の種類や内容に応じて異なる処理を行うことができます。

パターンマッチングの例

fn main() {
    let number = 6;
    match number {
        1 => println!("One"),
        2 | 3 | 5 | 7 => println!("Prime"),
        4..=10 => println!("Single digit number"),
        _ => println!("Other"),
    }
}

全パターンが網羅されていない場合

以下のコードは、全てのパターンが網羅されていないため、エラーが発生します。

fn main() {
    let number = 6;
    match number {
        1 => println!("One"),
        2 | 3 | 5 | 7 => println!("Prime"),
        4..=10 => println!("Single digit number"),
        // _ => println!("Other"),
    }
}

エラーメッセージ

error[E0004]: non-exhaustive patterns: `i32::MIN..=0_i32` and `11_i32..=i32::MAX` not covered
 --> Main.rs:3:11
  |
3 |     match number {
  |           ^^^^^^ patterns `i32::MIN..=0_i32` and `11_i32..=i32::MAX` not covered
  |
  = help: ensure that all possible cases are being handled, possibly by adding wildcards or more match arms
  = note: the matched value is of type `i32`

パターンマッチングのメリット

  1. コードの明確化: 複雑な条件分岐を簡潔に記述でき、コードの可読性が向上します。

  2. エラーの防止: コンパイラが全てのケースをチェックするため、抜け漏れのない安全なコードが書けます。

  3. 柔軟性: 複雑なデータ構造にも対応でき、柔軟な条件分岐が可能です。

イテレータ

Rust には「イテレータ」という概念があります。イテレータは、コレクションを順に処理するための抽象型で、next メソッドを持ち、次の要素を取得することができます。イテレータはチェイン操作や、map, filter などのメソッドを使用して強力なデータ処理が可能です。

fn main() {
    let v = vec![1, 2, 3];
    let iter = v.iter();
    for val in iter {
        println!("{}", val);
    }
}

イテレータのメリット

  1. 効率的なデータ処理: イテレータを使用することで、コレクション全体を効率的に処理できます。

  2. 高い抽象度: イテレータは抽象度が高く、複雑なデータ操作を簡潔に記述できます。

  3. 再利用性: イテレータは様々なデータ構造で使用でき、コードの再利用性が向上します。

クローン

Rust には「クローン」という概念があります。クローンは、所有権の移動ではなく、データの複製を行います。Clone トレイトを実装している型で使用できます。

fn main() {
    let s1 = String::from("Hello");
    let s2 = s1.clone();
    println!("s1 = {}, s2 = {}", s1, s2);
}

クローンのメリット

  1. データの安全な複製: クローンを使用することで、元のデータを安全に複製することができます。

  2. 独立したデータ操作: 複製されたデータは元のデータとは独立して操作できるため、データの整合性が保たれます。

  3. 所有権の柔軟性: クローンにより、所有権の移動を伴わずにデータを共有できるため、柔軟なデータ管理が可能です。

型推論

Rust には「型推論」という概念があります。Rust の強力な型推論機能により、明示的に型を指定しなくても、コンパイラが適切な型を推測してくれます。

fn main() {
    let x = 42; // i32 型
    let y = 3.14; // f64 型
}

型推論のメリット

  1. コードの簡潔化: 型を明示的に指定する必要がないため、コードが簡潔になります。

  2. 開発速度の向上: 型推論により、開発速度が向上し、プロトタイピングが迅速に行えます。

  3. 型安全性の確保: 型推論はコンパイル時に行われるため、型安全性が確保されます。

ジェネリクス

Rust には「ジェネリクス」という概念があります。ジェネリクスを使用することで、特定の型に依存しない柔軟な関数や構造体を定義できます。以下の例では、ジェネリクスを使って、リストの中から最大の要素を見つける関数を作成しています。

fn largest<T: PartialOrd>(list: &[T]) -> &T {
    let mut largest = &list[0];
    for item in list.iter() {
        if item > largest {
            largest = item;
        }
    }
    largest
}

fn main() {
    let number_list = vec![34, 50, 25, 100, 65];
    let result = largest(&number_list);
    println!("The largest number is {}", result);//The largest number is 100

    let char_list = vec!['y', 'm', 'a', 'q'];//The largest char is y
    let result = largest(&char_list);
    println!("The largest char is {}", result);
}

このコードでは、largest という関数を定義しています。この関数は、ジェネリック型 T を使用しており、PartialOrd トレイトを実装している型であれば、どのような型でも受け取ることができます。関数の引数には、スライスとしてリストを受け取り、リストの中から最大の要素への参照を返します。

main 関数では、整数のリストと文字のリストを作成し、それぞれに対して largest 関数を呼び出して、最大の要素を見つけています。

ジェネリクスのメリット

  1. 再利用性の向上: ジェネリクスを使用することで、複数の異なる型に対して同じ関数や構造体を再利用できます。

  2. 型安全性の確保: ジェネリクスにより、異なる型を安全に操作でき、型エラーを防止します。

  3. 柔軟な設計: ジェネリクスを利用することで、柔軟で拡張性の高いコード設計が可能です。

まとめ

変数の使い回しができなくなることで、値の追跡が容易になり、バグが減る点が良いと感じました。最近のプログラミングに関する書籍でも、変数の再代入を防ぐことが保守性を高める方法として紹介されています。それを工夫として実装するのではなく、言語仕様として取り入れたのは良い傾向だと思います。

Rust ならコンパイル通ってくれれば、その時点でかなりコード品質の安心感があるのはいいかなと思いました。やはり実行時にエラーが起こるよりもコンパイル時に起こってくれた方がうれしいですからね。

Discussion