プログラミング言語RustについてC#と比較しながら紹介してみる

はじめに

皆さんはじめまして。 サービスシステム部でエンジニアをやっている難波です。 カカクコムに入社してまだ1年未満ですが、Webサービス開発にも慣れてきて、自分の携わったサービスが多くの方々に使ってもらえるということにやりがいを感じてきています。

価格.comは20年以上前から続いているサービスであるため、巷では.NET 8やC# 11の話題で盛り上がっている中でも現場では.NET Framework系やClassic ASPなどのレガシーな言語での業務が多く、世間の華々しい話題も取り上げてみたい意欲が溜まってきています。

そこで今回は個人的な気分転換も兼ねて、新進気鋭のプログラミング言語RustについてC#との比較を交えながら紹介してみたいと思います。

ただ、私もまだまだRust学習の道半ばですので、温かい目で見守って頂けると幸いです。

Rustとは

Owned by Rust Foundation, under Creative Commons license.

Rustは2010年頃に登場し、2015年にバージョン1.0がリリースされた比較的新しいプログラミング言語です。

公式サイトを見てみると

効率的で信頼できるソフトウェアを誰もがつくれる言語

という文が書かれています。

系統としてはC言語 の進化形で、C言語と同様に静的型付けのコンパイラ言語です。C++の兄弟のような立ち位置かなと思います。

Hello Worldのプログラムを見てもC言語っぽさを感じられることでしょう。

fn main() {
    println!("Hello, world!");
}

ただRustはさまざまな言語の影響を受けているため、C言語に似ているのは見た目くらいで、機能面はかなり異なっています。

そんなRustですが、公式サイトを見ると3つの特徴が挙げられています。

  • パフォーマンス
  • 信頼性
  • 生産性

本記事ではこれらの観点でRustについて紹介していきます。

パフォーマンス

Rustはコンパイラ言語であり、そのコンパイル結果はネイティブコード(機械語)になります。そのネイティブコードの性能は非常に高く、メモリ効率と高速性はC言語のそれと同等と言われています。

なぜそのようなネイティブコードが生成できるのかというと、RustがコンパイラバックエンドとしてLLVMを採用していることにあると思っています。

LLVMは非常に優秀なコンパイラ基盤であり、LLVM IRを効率的なネイティブコードにコンパイルしてくれます。LLVM IRとはLLVM用の中間言語であり、.NETのILのようなものだと考えています。

そのためRustコンパイラの開発チームはLLVMフロントエンドとしてのコンパイラの開発に注力でき、結果高い性能のネイティブコードを生成できるのではないでしょうか。

ちなみにLLVMの公式ロゴはドラゴンです。かっこいいですよね?はい、かっこいいです。

また、RustがC#のようにGCガベージコレクション)を必要としない点もパフォーマンス向上に寄与しています。GCが無いので余分にメモリが食われることもなく、更にGC動作時にすべてのアプリケーションスレッドが停止する事象(いわゆる「ストップ ザ ワールド」)も当然発生しません。

GCが無いと聞いて「またC言語のような手動でのメモリ管理が必要になるのか・・・」という懸念を抱く方もいるかも知れませんが、安心して下さい。Rustにはライフタイム(生存期間)という概念が導入されており、変数のライフタイムが尽きた時点で自動的にリソースを開放してくれます。

この項での話題をC#とRustとで比較すると以下のようになります。

C# Rust
コンパイラバックエンド 無し(自前) LLVM
コンパイル結果 .NETのIL ネイティブコード
ランタイム .NETランタイム 無し(ネイティブ)
GC 有り 無し

まとめ: RustはLLVMとそのフロントエンドコンパイラによって効率的なネイティブコードを生成し、そのコードはランタイムもGCも必要としないため高いパフォーマンスが期待できます。

信頼性

Rustの信頼性は主にライフタイム所有権リッチな型システム変数の不変性スレッドセーフによって担保されています。

リッチな型システム

リッチな型システムについてはGenericsやTrait(C#のinterfaceのようなもの)、代数的データ型等、主に現代的なプログラミング言語関数型言語の影響を色濃く受けています。最近は静的型付けの重要性が見直されているので、静的型付けとリッチな型システムがプログラムの信頼性に直結することは皆さんも認識しているかと思います。

変数の不変性

Rustでは変数はデフォルトで不変となります。

let a = 1;     // 変数はデフォルトで不変
a = 2;         // ! 不変な変数に代入しようとするとコンパイルエラー

let mut b = 1; // mut キーワードで修飾すると変数は可変
b = 2;         // 問題なく代入できる

不変な変数のメリットは、プログラムの複雑性を低減する効果が期待できることです。プログラムが可変な変数でまみれていると、「この変数の値はこの長い処理の中で変更されているかもしれない」ということを考慮する必要が出てきて、それはデバッグの効率を低下させます。 また、不変な変数が増えることによってコンパイラの最適化が効きやすくなります。

ライフタイム

Rustはライフタイムという概念を導入することで信頼性を高めています。

Rustでは全ての変数がライフタイムを持ちます。ライフタイムは静的に定まり、Rustコンパイラはそれを管理します。Rustコンパイラは変数のライフタイムが終わる(寿命を迎える)時点に、その変数が持っている値のリソースを開放するコードを自動的に挿入します。これによりRustは開発者に自前でメモリ管理を行わせること無く、同時にGCのような機能も不要にすることを達成しているのです。

所有権

所有権についてですが、これはライフタイムと密接な関係を持っています。まずは以下のコードをコメントに注目して読んでください。

fn f() {
    let foo = Foo::new(); // Foo::new() の呼出し結果の値を変数 foo に束縛 (foo が右辺の値の所有権を持つ)
    g(foo);               // foo の値の所有権が関数 g に移動 (foo は無効になる)
    g(foo);               // ! foo は無効なのでコンパイルエラー
}

fn g(x: Foo /* x は実引数として渡ってきた値の所有権を持つ */) {
    // do something
} // 関数終了時点で x のライフタイムが尽き、x が所有している値のリソースが開放される

Rustは所有権の移動を静的に追跡し、値の所有者が1人だけになっていない場合はコンパイルエラーが発生します。故にコンパイルが通った後は値の所有者が必ず1人だけになっていることが保証され、その所有者のライフタイムが尽きる際に安全にリソースを開放できます。これによりメモリリークダブルフリーといった問題を解決しています。

しかし関数の引数に指定したい場合でも同様に値の所有権が移動してしまい、元の変数が無効になってしまうのでは非常に使い勝手が悪いです。

そのような場合は参照を使います。上記のコードも、参照を使えばコンパイルが通るようにできます。

fn f() {
    let foo = Foo::new();  // Foo::new() の呼出し結果の値を変数 foo に束縛 (foo が右辺の値の所有権を持つ)
    g(&foo);               // foo への参照を関数 g に渡す (値の所有者は foo のまま)
    g(&foo);               // foo はまだ有効なので再度 foo への参照を関数 g に渡してもコンパイルエラーにならない
} // 関数の終了時点で foo のライフタイムが尽き、その値のリソースが開放される

fn g(x: &Foo /* x は Foo 型の値への参照 */) {
    // do something
} // 関数終了時点で x のライフタイムが尽きるが、x は参照先の所有権を持っている訳ではないので参照先のリソースが開放されることはない

変数の参照を取ることは借用と呼ばれます。Rustコンパイラは変数の借用状況を静的に追跡しており、参照先のライフタイムが尽きた時点で参照も無効となるため、それを使おうとするとコンパイルエラーとなります。つまり、ダングリングポインタ問題も防ぐことができるのです。

例えば以下のコードはコンパイルエラーになります。

fn f() {
    let bar;

    { // ブロックスコープに入る
        let foo = Foo::new();
        bar = &foo; // bar に対して foo への参照を束縛
    } // ブロックスコープを抜けた時点で foo のライフタイムが尽きる

    g(bar); // ! bar は foo への参照を持っているが、foo は既に無効になっているためコンパイルエラー
}

fn g(x: &Foo) {
    // do something
}

参照には参照先を書き換え不可能な不変参照と参照先を書き換え可能な可変参照があるのですが、これらには以下のようなルールがあります。

  • 不変参照と可変参照の両方を同時に取ることはできない
  • 不変参照は同時に何個でも取ることができる
  • 可変参照は同時に2個以上取ることができない

参照は実体としてはただのポインタなのですが、これらのルールが存在することでプログラムの安全性を高めるとともに、プログラムの複雑性を低減させています。

なおライフタイムや所有権の概念は(ほぼ)Rust特有の概念であるとともに比較的難しい概念であるため、Rust初学者の挫折点になりやすいとよく耳にしますので、学習する際には気を付けたいポイントです。

スレッドセーフ

Rustは強いスレッド安全性を持ち、特にデータレースについての安全性が高いと考えています。主なデータレースとして、マルチスレッドで変数を読み書きすると値がおかしくなることがよく取り上げられます。例えば以下のようなC#コードを書くとデータレースが発生します。

var x = 0;

var tasks = new List<Task>();
for(var i=0; i<100; i++) {
    tasks.Append(Task.Run(() => {
        for(var i=0; i<100; i++) {
            x += 1;
        }
    }));
}

foreach(var task in tasks) {
    task.Wait();
}

Console.WriteLine($"{x}"); // 10000 が出力されるのを期待するが、実際は異なる数値が表示される

同様のプログラムをRustで書くとコンパイルエラーになります。

let mut x = 0;

let mut threads = Vec::new();
for _ in 0..100 {
    threads.push(std::thread::spawn(|| { // ! コンパイルエラー
        for _ in 0..100 {
            x += 1;
        }
    }));
}

for thread in threads {
    thread.join();
}

println!("{x}");

Rustコンパイラがこのコードをコンパイルエラーにするのは以下のような理由からです。

  • 変数xのライフタイムが他のスレッド自体のライフタイムよりも長いことを保証できない (=他のスレッドがxを触っている最中にx自体のライフタイムが尽きる可能性をコンパイラが否定できない)
  • そもそもxの可変参照は同時に1つまでしか取れない

データレースをコンパイル時に検知しプログラムの安全性を高められるのは利点なのですが、とは言っても複数スレッドから同じ変数を扱えないのは不都合です。

この場合、Rust標準ライブラリに用意されているArcMutexを使うことで解決することができます。 以下がその実装例になります。

use std::sync::{Arc, Mutex};

let x = Arc::new(Mutex::new(0)); // スマートポインタのようなものを作成

let mut threads = Vec::new();
for _ in 0..100 {
    let y = x.clone(); // x をクローン (中身の参照先は元の x と同じ)
    threads.push(std::thread::spawn(move || {
        for _ in 0..100 {
            y.lock().map(|mut inner| {
                *inner += 1;
            });
        }
    }));
}

for thread in threads {
    thread.join();
}

x.lock().map(|inner| {
    println!("{inner}"); // 100000 と表示される
});

Arcはスレッドセーフなリファレンスカウント方式のスマートポインタのようなものと考えるとわかりやすく、これを使うことで複数スレッドから同じ値を安全に参照(共有)することができます。 Mutexは実行時の内部可変性を提供するものです。仮に変数や参照が不変であっても、実行時にlockすることで内部の値を安全に(排他的に)書き換えることができるようになります。

このようにRustは安全性のために開発者に対して厳しい制限を与えつつも、その制限を安全に回避できる策を用意してくれています。

ただし、Rustはデータレースを防いでくれますがデッドロックまで防いでくれるわけではないので、マルチスレッドのプログラムを書く際はそのことには気をつける必要があります。(もし、デッドロックコンパイル時に防いでくれるプログラミング言語が存在するなら知りたい限りです。)

この項での話題をC#とRustとで比較すると以下のようになります。

C# Rust
リッチな型システム
デフォルトの変数可変性 可変 不変
自動メモリ管理 動的(GC) 静的(ライフタイムと所有権)
データレース安全性 △ (mutexやスレッドセーフなクラスを使えば安全)
デッドロック安全性
学習難度 比較的易しい 難しい

まとめ: Rustのリッチな型システムはプログラムの安全性を向上させ、変数の不変性はプログラムの複雑性を低減させます。Rustは高いスレッド安全性を持ちデータレースをコンパイル時に防いでくれますが、デッドロックまでは防いでくれません。ライフタイムと所有権はプログラムの安全性を高める非常に強力な仕組みですが、Rust特有の概念であるため学習の際の1つのポイントです。

生産性

Rustには標準でさまざまなツールが付属しており、これらが生産性の向上に大きく寄与してくれています。

Rust開発で最も頻繁に使うツールはCargoになると思います。Cargoはコマンドラインツールであり、Rustの パッケージマネージャ 兼 ビルドツール 兼 自動テストツール 兼...です。コンパイル自体はrustcコマンドが担当するのですが、直接触る機会はほぼ無いので詳しく知らなくても問題になり難いです。

Cargoはたくさんある機能をサブコマンドで呼び分けるスタイルで、これは最近の.NETのdotnetコマンドと似ています。

以下がhelpですが、多くの機能がCargoのサブコマンドに集約されていることがわかります。

> cargo help

Rust's package manager

Usage: cargo [+toolchain] [OPTIONS] [COMMAND]
       cargo [+toolchain] [OPTIONS] -Zscript <MANIFEST_RS> [ARGS]...

Options:
  -h, --help                Print help
  -V, --version             Print version info and exit
      --list                List installed commands
      --explain <CODE>      Run `rustc --explain CODE`
  -v, --verbose...          Use verbose output (-vv very verbose/build.rs output)
  -q, --quiet               Do not print cargo log messages
      --color <WHEN>        Coloring: auto, always, never
  -C <DIRECTORY>            Change to DIRECTORY before doing anything (nightly-only)
      --frozen              Require Cargo.lock and cache are up to date
      --locked              Require Cargo.lock is up to date
      --offline             Run without accessing the network
      --config <KEY=VALUE>  Override a configuration value
  -Z <FLAG>                 Unstable (nightly-only) flags to Cargo, see 'cargo -Z help' for details

Some common cargo commands are (see all commands with --list):
    build, b    Compile the current package
    check, c    Analyze the current package and report errors, but don't build object files
    clean       Remove the target directory
    doc, d      Build this package's and its dependencies' documentation
    new         Create a new cargo package
    init        Create a new cargo package in an existing directory
    add         Add dependencies to a manifest file
    remove      Remove dependencies from a manifest file
    run, r      Run a binary or example of the local package
    test, t     Run the tests
    bench       Run the benchmarks
    update      Update dependencies listed in Cargo.lock
    search      Search registry for crates
    publish     Package and upload this package to the registry
    install     Install a Rust binary. Default location is $HOME/.cargo/bin
    uninstall   Uninstall a Rust binary

See 'cargo help <command>' for more information on a specific command.

さらに、RustにはRust Analyzerという公式のコーディング支援ツールがあります。これを使うことでコンパイラのメッセージ可視化やコード定義ジャンプ、コードテンプレート、リファクタリングといったIDEのような機能が使えるようになります。

Rust開発でよく使われるIDEやエディターはおそらくVisual Studio Code+Rust Analyzer、またはCLion+IntelliJ Rustかと思います。

また、Rustは強力な型推論機能を持っており、文脈から型を推論してくれるため多くの場面で型を省略することができます。

以下が型推論の例になります。 コメントは実行時の動作ではなく、コンパイル時の解析の話であることに注意してください。

// 変数の型をフル記述し、右辺の Vec の型引数も記述した場合
let mut v: Vec<i32> = Vec::<i32>::new(); // v と右辺はともに型が明記されているので型推論は不要
v.push(1);

// 右辺の Vec の型引数を省略した場合
let mut v: Vec<i32> = Vec::new(); // ここで v の型は Vec<i32> であるから、右辺の型は Vec<i32> に推論される
v.push(1);

// 変数の型を省略した場合
let mut v = Vec::<i32>::new(); // ここで右辺の型は Vec<i32> であるから、v の型は Vec<i32> に推論される
v.push(1);

// 変数の型と右辺の Vec の型引数を省略した場合
let mut v = Vec::new(); // ここで右辺は Vec<{unknown}> に推論され、v もまた Vec<{unknown}> に推論される
v.push(1);              // ここで v の型は Vec<i32> に推論され、元の右辺 Vec::new() の型も Vec<i32> に推論される

型推論を用いる場合に、以下のように静的に型を決定できない場合はコンパイルエラーになります。

let v = Vec::new();      // 文脈から型を推論・決定できないのでコンパイルエラー
println!("{}", v.len()); // この v の使い方からは v の型を推論・決定することができない

なおRust Anazlyerを使用すると推論された型を表示できるため、型の記述を省略しながらも安全・快適にコーディングできます。

この項での話題をC#とRustとで比較すると以下のようになります。

C# Rust
パッケージ管理 dotnet (NuGet) Cargo
ビルド dotnet Cargo
自動テスト dotnet Cargo
コード解析・支援 Roslyn Rust Analyzer
型推論 値の型から推論(右辺値や関数の実引数) 文脈から推論

まとめ: Rustにはパッケージマネージャやビルドツール等の便利なツールが同梱されています。Rust Analyzerを使うことでIDEによくあるような機能を使用できるようになり、生産性の向上に繋がります。Rustの型推論は強力で多くの場面で型の記述を省略できるため、スムーズなコーディングが可能です。

おわりに

本記事ではプログラミング言語RustについてC#との比較を交えつつ簡単に紹介しました。

Rustには他にもさまざまな機能・特徴がありますが、本記事ではこれで区切りとさせていただきます。本記事では紹介しなかったキーワードを並べておくと、null安全変数のシャドーイング代数的データ型、強力なパターンマッチングトレイトバウンドAssociated Types健全なマクロasync/awaitNewTypeイディオム組み込みシステムWebAssembly等でしょうか。

新興技術・言語が過去の技術・言語から何を学び、何を取り入れ、何を捨てたのか。それらを学ぶことで現在の業務で使用している技術・言語でも役立つ「心得」を学ぶことができると思っています。本記事でRustに興味を持たれた方はぜひRustを書いてみたり、詳しく調べたりしてみてください!

これはRust非公式マスコットのFerris the crab

カカクコムでは、ともにサービスをつくる仲間を募集しています!

カカクコムのエンジニアリングにご興味のある方は、ぜひこちらをご覧ください!

カカクコム採用サイト