検索
連載

なぜ「Rustは難しい言語」とされるのか――習得の難しさとその対策をWebエンジニアが考察WebエンジニアからみたRust(終)(1/2 ページ)

Web開発者としての興味、関心に基づきRustを端的に紹介し、その強みや弱みについて理解を深める本連載。第3回は、Rustの開発生産性を支える言語機能と難しさについて。

Share
Tweet
LINE
Hatena

 最終回となる今回は、Rustの開発生産性を支える言語機能および難しさにフォーカスを当てて簡潔に紹介します。

 開発生産性とはいうものの、この言葉は定義付けをすること自体が難しいです。下記のRust公式が提供するツール群は開発生産性を間違いなく向上させますが、実際に使ってみた方が理解がはかどるのでここでは紹介にとどめます。

  • Rustコンパイラによるコンパイルエラーメッセージの丁寧さ
  • Docs.rsのドキュメンテーション
  • Cargoによるパッケージ管理(≒Rubyのbundler、JavaScriptのnpm)
  • rust-analyzerによる強力な開発支援(≒インテリセンス)
  • 言語標準のユニットテスト

 端的にいえばモダン開発のプラクティスがRustのプロジェクトでもシームレスに利用でき、簡単に開発環境を整えることができます。

 下記はプロジェクトファイル(Cargo.toml)の例です。外部ライブラリなどを簡単に管理できます。

# Cargo.toml
# Javaのpom.xmlやC++のCMakeLists.txtを書くより圧倒的に簡単
[package]
authors = ["Naoki Fujita"]
edition = "2021"
license = "MIT"
name = "web_engineer_in_rust"
version = "0.1.0"
[dependencies]
# 利用するライブラリ(クレート)名とバージョン
# RubyのGemfileと考え方は似ている
anyhow = "1.0.52"
chrono = "0.4.19"
itertools = "0.10.3"
num-traits = "0.2.14"
[[bin]]
# エントリーポイントを作成
# cargo run --bin thread_safe_queueで実行可能
name = "thread_safe_queue"
path = "src/thread_safe_queue.rs"
[[bin]]
name = "moving_average_f64"
path = "src/moving_average_f64.rs"
[[bin]]
name = "moving_average_trait"
path = "src/moving_average_trait.rs"
[[bin]]
name = "use_trait_extension"
path = "src/use_trait_extension.rs"

Rustのエラーメッセージ1:所有権(借用規則)ルールに違反しても、コンパイラが分かりやすく指摘してくれる

 この記事では開発生産性に関連するRustの言語機能の紹介、抜粋と、Rustの難しさについての考察を主眼とします。この記事を作成するに当たり関連コードを収録したGitHubリポジトリも用意したので、自分でも検証したい方はぜひご利用ください。

スレッド安全性を型で表現する

 Web開発でよく用いられているRubyやPython(のC言語実装)には、グローバルインタープリタロック(GIL)という機構があり、スレッドを複数動作させていても同時に1つのスレッドのみがプログラムを実行できるという制約下にあります。

 同時に1つのスレッドしか動かないのであれば、これらの言語で書かれたプログラムは常にスレッドセーフであるといえればよいのですが、スレッドセーフといえるのは単純な処理のみです。複雑な(非アトミックな)処理については、ロックを取らないと正しく動作しません。例えば、あるスレッドでオブジェクトを変更し、別のスレッドでそのオブジェクトを参照すると、例外が起こり得ます。

 ドキュメントなどを見ていて「このコレクションはスレッドセーフである」や「この操作はスレッドセーフである」というような記述があるとロックを取らなくてよいと分かりますが、いつでもそのような記述があるとは限りません。スレッドセーフか否かを識別するのは大変です。

 Rustはスレッド安全性をドキュメントではなく型として表現します。それによりスレッド安全でないコードをコンパイルエラーとしてはじくことができます。

 この振る舞いを確認するために、少し複雑な課題を用意しました。2つのスレッド(record_thread1,2)が観測データを記録していき、一方で1つのスレッド(observe_thread)がキューを定期的に観測し、その最新値を取得するというものです。

 上記の要件を満たすようなサンプルコードを用意しました。

use chrono::NaiveDateTime; // タイムゾーンなしの日時
use std::sync::{Arc, Mutex}; // スレッドセーフな共有参照とMutexロックを使用
#[derive(Copy, Clone, Debug)]
// 観測データの構造体(データの組)を定義
struct Measurement {
    // 日時(タイムゾーン無し)
    time: NaiveDateTime,
    // 観測値
    value: f64,
    // データを観測したスレッドID
    thread_id: usize,
}
impl Measurement {
    // Measurement構造体の関連関数(メソッド)を定義
    fn new(value: f64, thread_id: usize) -> Self {
        Measurement {
            // 生成時の現在時刻を記録
            time: chrono::Utc::now().naive_utc(),
            // 観測値を登録, キーと変数名と同じなら省略記法が使える
            value,
            // データを生成したスレッドを記録
            thread_id,
        }
    }
}
fn main() -> anyhow::Result<()> {
    // キューの生成、キューの所有権はメインスレッドが持つ
    let queue = Vec::new();
    // (1):キューのロック付(Mutex)の共有参照(Arc)を定義する
    let arc_queue = Arc::new(Mutex::new(queue));
    // キューの共有参照をコピー(そうしないと複数のスレッドからアクセスできない)
    let arc_queue1 = arc_queue.clone();
    // データ記録スレッド1を作成
    // moveで共有参照を別スレッドに移動
    // 共有参照を移動=キューの参照権限を別スレッドにも渡したと考えると分かりやすい
    let record_thread1 = std::thread::spawn(move || {
        for i in 1..=10000 {
            let m = Measurement::new(i as f64, 1);
            // (2):キューのロックを取りキューに観測値を記録
            arc_queue1.lock().unwrap().push(m);
        }
    });
    let arc_queue2 = arc_queue.clone();
    // データ記録スレッド2を作成
    let record_thread2 = std::thread::spawn(move || {
        for i in 1..=10000 {
            let m = Measurement::new(i as f64, 2);
            arc_queue2.lock().unwrap().push(m);
        }
    });
    // データ観測スレッドを作成
    // 1ミリ秒のスリープを挟みつつ、キューの最新値を出力
    let observe_thread = std::thread::spawn(move || loop {
        // ここで{}と書いてスコープを作ることには意味がある
        {
            // ロックを取得
            let queue_lock = arc_queue.lock().unwrap();
            let latest = queue_lock.last();
            println!("{:?}", latest);
            // スコープによりここでqueue_lock変数が無効になる→ロックが解放される
        }
        std::thread::sleep(std::time::Duration::from_millis(1));
    });
    // 2つのデータ記録スレッドの終了を待つ
    for thread in [record_thread1, record_thread2] {
        let _ = thread.join();
    }
    Ok(())
}

 今回特に説明したいのは、サンプルコード内(1)と(2)で表現されている部分です。

 (1)はArcMutexパターンと呼ばれているもので、これによりスレッド安全性を手軽に実現できます。今回はVec(可変長配列)を用いましたが、さまざまなデータ構造を組み合わせられるので極めて汎用(はんよう)的です。また、(2)を見るとlock()というメソッド呼び出しがありますがこれはMutex型が持つメソッドであり、lock()を呼んでロックを取得しない限り内部のデータ構造にアクセスできないことを型としてうまく表現し、スレッド安全でないデータアクセスを排除します。非同期プログラムの難しさの一つを型システムが解決するというアイデアに引かれて筆者はRustに入門しました。

Rustのトレイト1(型要件の抽象化)

 Rustのトレイトはある要件を満たす(実装している)型の総称、集合と捉えることができます。トレイトにより高度な型の抽象化が実現できます。例えば、関数の引数にトレイトを指定することで、そのトレイトを実装している具体型とその集合を引数として捉えることができます。

 ここでは第2回のプログラムを簡略化し、トレイトを活用したプログラムに書き直してみようと思います。

Copyright © ITmedia, Inc. All Rights Reserved.

       | 次のページへ
ページトップに戻る