13
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Rustの好きなところ1 --型システム--

Last updated at Posted at 2024-07-21

はじめに

私が今一番好きな言語は Rust です。
Rust は書いていて楽しいですし、学びも多い言語だと感じます。

そこで今回は私が特に好きな Rust の特徴を紹介しつつ、Rust の良さを伝えていきたいと思います。
一気に全部書こうとしたのですが、長くなりすぎるので、複数回に分けて書いていきます。

今回の記事は私個人の意見であり、他の言語と比べた Rust の優位性を述べたいわけではありません。
あくまで好きなところ、書いていて楽しいところです。

第一回目は型システムについてです。

1 型システムが好き

Rust の型システムは非常に強力です。型システムが強力なのでコンパイルさえ通せば動く可能性が高いと、自信を持ってコードを書くことができます。

また、Rust には多くの機能が備わっているため、堅牢な型やメソッドなどの定義を少ない記述で一気に記述することができます。
そのため書いていたり、良い記法について考えている時がいつも楽しいです。

今回は特に私が好きな以下の特徴について説明します。

1. 型の制約が厳しいこと
2. 標準で用意されている型が豊富で便利であること
3. トレイトが強力かつ便利であること

それぞれの機能は相互に関連していることもあり、若干かぶる部分もあるかもしれませんが、ご了承ください。

1-1 型の制約が厳しいこと

Rust は静的型付けの中でも型の制約が厳しい言語です。
厳しさを利用することで、安全性を高めることができます。

以下は型制約の中でも私が好んでいる具体的な例です。

1. 公称型を採用しており、強力な型情報を持っていること
2. new type pattern が手軽に使え、型の区別がしやすいこと
3. 可変かどうか型レベルで区別できること

1-1-1 公称型を採用しており、強力な型情報を持っていること

公称型とは、型の名前に基づいて型の等価性を判断する方式のことです。
対して構造的部分型は、型の構造に基づいて型の等価性を判断する方式です。

上の例ではわかりにくいかもしれませんので、以下にコードの例を載せます。
公称型の例は Rust,構造的部分型の例は Go です。

// 公称型の説明

trait Machine {
    fn run(&self);
}

// Machineトレイトを実装したT型が必要
fn machine_loop<T:Machine>(machine: T) {
    loop {
        println!("Start machine");
        machine.run();
        println!("Done");
    }
}

// Machineトレイトと同じ名前のメソッドを持っているが、別の型情報(トレイト)
trait Animal {
    fn run(&self);
}

fn main() {
    struct MyMachine;
    impl Machine for MyMachine {
        fn run(&self) {
            println!("MyMachine is running");
        }
    }

    let my_machine = MyMachine;
    machine_loop(my_machine);

    struct MyAnimal;
    impl Animal for MyAnimal {
        fn run(&self) {
            println!("MyAnimal is running");
        }
    }

    // Rustが公称型を採用しているためコンパイルエラー
    // メソッド名が同じでもトレイトが異なるので、コンパイルできない
    machine_loop(MyAnimal);
}
// 構造的部分型の説明

package main

import "fmt"

type Machine interface {
	run()
}

func machineLoop(machine Machine) {
	for {
		fmt.Println("Start machine")
		machine.run()
		fmt.Println("Done")
	}
}

type Animal interface {
	run()
}

type MyMachine struct{}

func (m MyMachine) run() {
	fmt.Println("MyMachine is running")
}

type MyAnimal struct{}

func (a MyAnimal) run() {
	fmt.Println("MyAnimal is running")
}

func main() {
	myMachine := MyMachine{}
	machineLoop(myMachine)

	myAnimal := MyAnimal{}
    // 構造的部分型を採用しているため、コンパイルエラーにならない
    // runメソッドがあればMachineとしてもAnimalとしても扱える
	machineLoop(myAnimal)
}

上の例のように公称型はメソッド名が同じものを実装していても、型情報(トレイト)が異なる場合はコンパイルエラーになります。

対して、構造的部分型はメソッド名が同じであれば、型情報が異なってもコンパイルエラーになりません。

どちらもにメリットデメリットがありますが、安全性は公称型の方が高いと思います。

理由は上の例のように全くコンテキストが異なる型も入り得てしまうことがあるためです。

ただ、上の例は相当極端な例ですし、構造的部分型は柔軟性が高く、依存注入がしやすいなどのメリットもあります。
そのため、どちらが良いかは状況によると思います。あくまで私の好みです。

加えて公称型の特性を活かしたマーカートレイトによる型の区別も私の推しポイントです。
マーカートレイトとは、メソッドを持たないトレイトのことで、型を区別するためだけに使われます。

trait Marker {}

例えば、振る舞いとしては特に必要としないが、複数の型を同一に扱いたい場合に使われます。
とってもお手軽です。

1-1-2 new type pattern が手軽に使え、型の区別がしやすいこと

Rust は new type pattern が使えるため、型の区別がしやすいです。

new type pattern とは、新しい型を作る際に既存の型をラップすることです。

例えば、Id や Name などの型を作る場合、プリミティブ型をそのまま使うと、誤った型を代入してしまう可能性があります。

プリミティブ型ではなく、新しい型を作ることで誤った型を代入することを防ぐことができます。
また、プリミティブ型をラップすることで、ドキュメント性が高くなり、保守性や可読性が向上します。

Rust では new type pattern を使うことで上記の要件を達成でき、また new type pattern は以下のように簡潔に記述することができます。

struct UserId(u32);
struct UserName(String);

fn get_user(id: UserId) -> UserName {
    ...
}

fn main() {
    let user_id = UserId(1);
    // OK
    let user_name = get_user(user_id);
    // NG
    let user_name = get_user(1);
}

型の名前と元になるプリミティブ型をラップするだけで良いので、簡潔に記述することができ、ハードルも低いです。

地味な機能かもしれませんし、他の言語でも同じようなことができるかもしれませんが、私の推しポイントの一つです。

1-1-3 可変かどうか型レベルで区別できること

Rust は可変かどうかすら型レベルで区別できるます。

Rust には所有権や参照、可変参照などの特殊なルールがあり、データが可変かどうかは重要な情報です。ただ、この辺りの説明はまた今度の記事で書こうかと思います。

今回は難しい話は置いておいて、可変かどうか型レベルで区別できることを紹介します。

例えば、以下のようなコードを考えます。

fn push_twice(v:&Vec<i32>,data:i32) {
    v.push(data);
    v.push(data);
}

上のコードは Vec(可変配列)に対して 2 回要素を追加する関数です。
ただし、上のコードはコンパイルエラーになります。
なぜなら、v&Vec<i32>であり、可変なデータではないからです。

上のコードを修正するためには以下のように書く必要があります。

fn push_twice(v:&mut Vec<i32>,data:i32) {
    v.push(data);
    v.push(data);
}

vの型にmutというキーワードが追加されています。これはvが可変なデータであることを示しています。
このように、Rust では可変かどうか型レベルで区別できるため、可変なデータを扱う際に安全にコードを記述することができます。

この嬉しさは型情報を見るだけで、データが変更されるかどうかがわかるという点にあります。

他の言語だと知らない間にデータが変更されていてバグを引き起こすことがあると思います。
もちろん、良い命名やその言語への理解があれば問題ないかもしれませんが、それを開発者全員が意識できるかと言われると難しいと思います。

対して Rust は型定義を見れば良いのです。型定義を見れば、データが変更されるかどうかがわかります。

私ごとですが、他の言語ではデータが変更されるのかがシグネチャを見ただけでわからず、Rust のありがたさを感じるケースが少なくないです。

一方で、この可変かどうかが型レベルで区別できるということは、呼び出す側が可変かどうかを意識しなければならないということでもあります。
私的には抽象化の漏れではなく、ちょうど良い抽象化だと考えていますが、どう感じるかは人それぞれだと思います。

また、一つの関数を可変に変えたことで、その関数を利用する全てのコードを可変に変更しなければならないこともありますので、この辺りも好みが分かれるところかと思います。

ただ、どちらにせよ可読性は高まると思いますし、何度も言いますが私はこの機能が好きです。

1-1-4 型推論が強力であること

これまでは型制約の厳しさについて説明してきましたが、型制約が強すぎるので型を明示的に書くのが面倒だと思われるかもしれません。

ご安心ください。
Rust は型推論が強力であるため、型を明示的に書かなくてもコンパイラが型を推論してくれます。

ほとんどのケースでは型を明示的に書かないです。
型を書くのはあくまで構造体や関数の定義を記述する時と、取りうる型が複数ある場合です。

この「取りうる型が複数ある場合」でよくあるケースがIteratorの collect メソッドです。

collect メソッドは要素を集めて新しいコレクションを作成します。
ただ、コレクションの候補としてVecHashMapなどがあり、collect メソッドはそのコレクションの型を推論することができないケースがあります。

// 何のコレクションになるかわからないため型を明示する必要がある
// 以下の例はVecになる
let tmp = iterator.collect::<Vec<_>>();
println!("{:?}", tmp);

ただ、collect を利用するときでも型推論が効く時があります。
それは、collect から生成される値を利用する処理の型が決まっている場合です。

上の例では、println!が任意の型を受け取ることができるため、型を書く必要がありますが、以下のように生成される値を利用する処理の型が決まっている場合は型推論が働き、型を明示する必要はありません。

fn twice_extend(mut v:Vec<i32>) ->Vec<i32>{
    let cloned = v.clone();
    v.extend(cloned);
    v
}

// twice_extend は Vec<i32> を引数に取る関数なので、型推論が効く
let tmp = iterator.collect();
let result = twice_extend(tmp);

このように型推論が強力であるため、Rust の特性を活かして型を厳密に書いても楽に開発を進めることができると思います。

一旦ここまでが、型の制約が厳しいことについての説明です。

次は標準で用意されている型が豊富で便利であることについて説明します。

1-2 標準で用意されている型が豊富で便利であること

Rust は標準で用意されている型が豊富で便利です。

私のお気に入りは以下の型です。

  • Option<T>
  • Result<T, E>
  • Iteratorを実装する型
  • etc...

それぞれ説明します。

1-2-1 Option<T>Result<T, E>

まずOption<T>Result<T, E>ですが、必ずと言っていよいほど使う機会が多く、めちゃめちゃ便利です。

Option<T>は値がないかもしれないことを表現する型で、Result<T, E>はエラーが発生するかもしれないことを表現する型です。

外部 API とやりとりする時なんかは、レスポンスが null かもしれないし、ネットワークエラーや HTTP レベルのエラーなど様々な事象が発生すると思います。
その際に、Option<T>Result<T, E> を使うことで、安全かつ漏れなく処理を記述することができます。

例えば、以下のようなコードを書くことができます。

// レスポンスのjsonを表現
// 住所はnullの可能性がある
struct ExampleResponse {
    id: u32,
    name: String,
    address: Option<String>,
}

struct ExampleError {
    code: u32,
    message: String,
}

// レスポンスのjsonを取得する関数
// 説明のためにあえて型を明示しているが、実際には型推論があるので、基本は型を明示する必要はない
let response:Result<ExampleResponse,ExampleError> = dummy_code::http_request("https://example.com");

// match式で安全かつ漏れなく処理を記述することができる
// パターンに漏れがあるとコンパイルエラーになる
match response {
    // レスポンスが正常な場合
    Ok(response) => {
        println!("Success: {}", response.name);
        match response.address {
            // addressが存在した場合
            Some(address) => println!("Address: {}", address),
            // addressが存在しなかった場合
            None => println!("Address: null"),
        }
    },
    // レスポンスがエラーの場合
    Err(error) => {
        println!("Code: {} --- Error: {}",error.code, error.message);
    }
}

Option<T>Result<T, E>T は実際には何かしらの型ですが、これを利用するには上の例のように、 match 式などで中身を取り出す必要があります。

つまり、response.addressのようなコードは書けません。
まずはresponseが成功しているかどうかを確認し、その後、address が存在するかどうかを確認する処理を書く必要があります。
これによりバグを大きく減らすことができると思います。

また、ドキュメント性も高まると感じます。
型の情報を見て Option 型であることがわかれば、一目で値が存在しない可能性があることがわかりますし、Result 型であることがわかれば一目でエラーが起きる可能性があることがわかります。
型を見ればわかるということは、ドキュメントとしても非常に優れていると言っていいと思います。

OptionResult には mapor_else などの安全かつ便利に中身を取り出すためのメソッドが多数用意されているのもめちゃめちゃありがたいです。
上の例は以下のようにメソッドチェーンを使って書くこともできます。

dummy_code::http_request("https://example.com")
    .map(|response| response.address.unwrap_or_else(|| "null".to_string()))
    .map(|address| println!("Address: {}", address))
    .unwrap_or_else(|error| {
        println!("Code: {} --- Error: {}", error.code, error.message);
    });

1-2-2 Iteratorを実装する型

次にIteratorを実装する型です。

Iteratorは要素を順番に取り出すことができる型を表現するためのトレイトです。
なぜこれが便利かというと、Iteratorにはコレクション型に対して実施したい操作が多数用意されているからです。

for 文1や if 文を使って地道に処理を書くこともできるのですが、Iteratorに実装されているメソッドを使うことで、簡潔に処理を記述することができます。

例えば、あるメトリクスの合計値を計算する処理を考えます。ただし、メトリクスの値は null の可能性や外れ値の可能性があるとします。
for 文で書くと以下のようになります。

let mut sum = 0;

for metric in data {
    if metric.is_none() {
        continue;
    }
    if !is_valid_metric(metric.unwrap()) {
        continue;
    }
    sum += metric.unwrap();
}

これをIteratorのメソッドを使って書くと以下のようになります。

let sum: i32 = data
    .into_iter()
    .filter_map(|metric| metric)
    .filter(|metric| is_valid_metric(*metric))
    .sum();

上の例ではIteratorのメソッドを使うことで、以下のようなメリットがあるかと思います。

  • for 文や if 文を使うよりも宣言的で簡潔な記述ができている
  • ミュータブルな変数を使わずに処理を記述できている

Iteratorは上の例以外にも好きな要素分まで取り出すtakeやスキップするskip、要素を任意の型に畳み込む fold などまだまだ多数のメソッドが用意されています。

個人的には for で書かずにIteratorのメソッドを使い、メソッドチェーンを繋ぎることが楽しくて好きです。

一方で、for 文はループの途中で break や continue などを使いやすく、ループを最適化することができるというメリットがあります。
また、for 文の方が慣れている人も多いかと思いますので、チームでどのように利用するかは検討が必要だと思います。

1-3 トレイトが強力かつ便利であること

最後にトレイトが強力かつ便利であることについてです。

すでに何回か出てきているトレイトですが、ここで改めて説明します。

トレイトとは、型に対して振る舞いを提供するための仕組みです。
他言語であればインターフェースに近いものだと思います。

ただし、個人的にはトレイトはインターフェイスにはない強力な機能があると感じています。2

1-3-1 トレイト境界

トレイト境界とは、ジェネリクスに対して実装しているべきトレイトを指定することです。
ジェネリクスとは、型をパラメータ化するもので、複数の型に対して同じ処理を行うことができる機能です。

例えば、単純にデータを出力する関数を考えます。
ジェネリクスを利用しない場合、以下のように実装したい全ての型に対して関数を実装する必要があります。

fn print_i32(value: i32) {
    println!("value is {}", value);
}
fn print_f64(value: f64) {
    println!("value is {}", value);
}
fn print_string(value: String) {
    println!("value is {}", value);
}
...

型ごとに全く異なる実装をする必要があれば上のように、一つ一つの型に独自の実装が必要ですが、型によらず同じ処理を行う場合(上だと、データがなんであれ"value is DATA"で出力したい)はジェネリクスを使うことで以下のように実装することができます。

fn print<T>(value: T) {
    println!("value is {}", value);
}

これにより複数の型に対して同じような関数を記述する必要がなくなり簡潔になります。

ただし、上のコードはコンパイルエラーになります。

なぜなら、T がどの型でも良い設定になっているからです。
Rust の println!でフォーマットを利用する場合は、std::fmt::Display トレイトを実装している型でないとコンパイルエラーになります。

そこで登場するのがトレイト境界です。
先ほども説明しましたが、トレイト境界はジェネリクスに対してトレイトを指定することです。
つまり以下のように書くことで、Tstd::fmt::Display トレイトを実装している型であることを指定することができます。

fn print<T: std::fmt::Display>(value: T) {
    println!("value is {}", value);
}

このようにジェネリクスによって簡潔に処理を記述でき、またトレイト境界により型の制約をかけることができるので、安全に処理を記述することができます。

トレイト境界は複数のトレイトを指定することもでき、型の制約をより厳密にすることができます。

fn print_original_and_reverse<T>(value: T)
    // 制約が多い場合は where キーワードを使い、より読みやすく記述することもできる
    // std::fmt::Display と dummy_code::ReverseDisplay トレイトを実装している型であることを指定
    where T: std::fmt::Display + dummy_code::ReverseDisplay
{
    println!("value is {}", value);
    println!("{} si eulav", value.reverse_display());
}

一方でジェネリクスに対してインターフェイスを指定し、同じような効果を得る言語は多いため、トレイト境界自体で達成できることは目新しく感じないかもしれません。
Go 言語でもジェネリクスが採用され、以下のようにインターフェイスを指定することで同じような効果を得ることができます。

func Print[T ToString](value T) {
    fmt.Printf("value is %s", value.String())
}

今回はトレイト境界はトレイトを利用した機能の基盤となりえるため紹介しました。

1-3-2 ジェネリクスに対してトレイトを実装できること

この機能が私的にはえぐいと思っている機能です。

通常、インターフェイスを実装できるのはインターフェイスではなく、クラスなどの具体型だと思います。3

Rust はジェネリクスに対してトレイトを実装できるため、幅広い型に対して一気にトレイトを実装することができます。
しかもトレイト境界を組み合わせることで、型の制約をかけることもできます。

例えば、以下の関数はPrintable トレイトを実装している型を引数に取り処理を行います。

fn print_with_message<T:Printable>(value: T) {
    println!("Call print_with_message");
    println!("{}", value.print());
    println!("End print_with_message");
}

ジェネリクスに対して trait を実装しない場合は、以下のように具体型に対して Printable を愚直に実装していく必要があります。

impl Printable for i32 {
    fn print(&self) -> String {
        self.to_string()
    }
}
impl Printable for f64 {
    fn print(&self) -> String {
        self.to_string()
    }
}
impl Printable for String {
    fn print(&self) -> String {
        self.clone()
    }
}
...

上のコードの問題点は

  • 同じような実装を何度も書く必要がある
  • String になりうる型を書き漏らしてしまう可能性がある
  • 新しい型が追加された場合、その型に対しても実装を書く必要がある

などがあるかと思います。

しかし、Rust はジェネリクスに対してトレイトを実装できるため、以下のように書くことができます。

impl<T:ToString> Printable for T {
    fn print(&self) -> String {
        self.to_string()
    }
}

なんとこれだけで、ToString トレイトを実装している型に全てに対して Printable トレイトを実装することができます。
つまり、i32f64String も、ToString トレイトを標準で実装しているので、Printable トレイトを実装していることになります。

また、自分で以下のような新しい型を作ったとします。

struct MyData {
    data: String,
}

こいつに対してもToString トレイトを実装してあげれば、自動的にPrintable トレイトを実装してくれ、print_with_message 関数に渡すことができます。

impl ToString for MyData {
    fn to_string(&self) -> String {
        self.data.clone()
    }
}
print_with_message(MyData{data: "Hello".to_string()});

この機能のおかげで漏れなく簡潔にトレイトを実装することができます。

私の経験上の話ですが、このような機能を提供してくれる言語には出会ったことがないので、利用した時はとても感動しました。

1-3-3 3rd パーティの型にトレイトを実装できる、3rd パーティのトレイトを自分の型に実装できる

Rust は 3rd パーティの型に対してトレイトを実装できることができます。
また、3rd パーティのトレイトを自分の型に実装することもできます。

一つ前の例で出てきたMyData 構造体は自分で定義した型ですが、ToString という標準ライブラリ4のトレイトを実装しています。
つまり、3rd パーティ(ここでは自分で実装していないことを指す)のトレイトを自分の型に実装しているわけです。

また、Printable は自分で実装したトレイトですが、ToString を実装している i32f64 などの標準ライブラリの型に対して実装できています。
つまり、3rd パーティの型に対して自分で実装したトレイトを実装しているわけです。

この機能により、外部ライブラリの trait を簡単に実装できることや、自分の trait を外部ライブラリの型に対して実装できることが可能になります。

1-3-4 デフォルトメソッドによる簡潔な記述

トレイトはデフォルトメソッドという、トレイトの定義にメソッドの実装を含めることができる機能があります。

この機能によりメソッドの実装を省略することができ、少ない記述でトレイトを実装することができます。

例えば以下のようなトレイトを考えます。

trait Executor {
    fn setup(&mut self);
    fn finalize(&mut self);
    fn core(&mut self);
    fn execute(&mut self) {
        println!("Start execute");
        self.setup();
        println!("Start core logic");
        self.core();
        println!("End core logic");
        self.finalize();
        println!("End execute");
    }
}

Executor トレイトは setupfinalizecoreexecute メソッドを持っています。

setupfinalizecore は型によって独自の実装が必須ですが、それを決まった順番で実行したり、決まったデバッグログを出力するのであれば、execute メソッドはデフォルトメソッドとして実装しておくことができます。
これにより、execute メソッドを実装する必要がなくなり、setupfinalizecore だけを実装すれば良くなるため簡潔な記述が可能になります。

もちろん名前の通りデフォルトであるだけなので、実装を上書きすることもできます。

実は多くの標準ライブラリのトレイトはデフォルトメソッドを持っているため、標準ライブラリを自分のコードに取り込む際は少ないメソッドの実装で済むことが多いです。

前に紹介したIterator トレイトは next メソッドを実装する必要がありますが、それ以外のメソッドはデフォルトメソッドとして実装されています。

struct RandomIterator {
    rng: rand::rngs::ThreadRng,
}
impl Iterator for RandomIterator {
    type Item = i32;
    fn next(&mut self) -> Option<Self::Item> {
        Some(self.rng.gen_range(0..1000).into())
    }
}

// nextの実装だけでIteratorを実装したことになるので様々なメソッドを使える
let rand_iter = RandomIterator{rng: rand::thread_rng()};
let concat_result = rand_iter.map(|i|i.to_string()).take(10).reduce(|a,b|format!("{}+{}",a,b)).unwrap();

let rand_iter = RandomIterator{rng: rand::thread_rng()};
rand_iter.take_while(|i|i!=100).for_each(|x| println!("{}", x));

上の例のように少しの記述で trait の恩恵をフルに受けることができ、とても便利です。

他にも関連型やトレイトオブジェクトなど、トレイトには様々な機能がありますが、特に好きな機能を説明できたため一旦ここまでにしておきます。

終わりに

今回は Rust の好きなところでも型システムについて触れました。
少し冗長になってしまったかもしれませんが、その分好きさが伝われば幸いです(笑)

もし間違いや、誤解を招くような記述があれば指摘していただけると幸いです。

また私自身他言語の経験も少ないことから、他の言語の良いところなども教えていただけると嬉しいです。
ぜひ皆様も Rust を触ってみてください!

ここまで読んでいただきありがとうございました!
第 2 段以降も書けるように頑張ります!

  1. 実は for 文もIteratorを使って値を取り出しています。ただ、その後の処理はIteratorのメソッドを使うことがないため、for 文との比較を記載しました。

  2. とは言ったものの、全ての言語を知っているわけではないので、こんな言語のこんな機能もあるよ!という情報があれば教えていただけると嬉しいです。

  3. 上と同じく、他の言語も教えてください!

  4. Rust ではライブラリをクレートと呼びますが、ここでは一般的な用語であるライブラリと記述しています。

13
3
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
13
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?