コンテンツにスキップ

C#開発者のためのRust

ふだんC#を書いているなら、うれしいお知らせがあります。Rustの多くの部分は、きっとなじみやすく感じるはずです。Rustには、制約付きのジェネリクス、async/await、強力なパターンマッチング、LINQによく似たイテレータのメソッドチェーンがあります。さらに、dotnet と同じ仕事をこなす優れたコマンドラインツール(Cargo)もあります。型、インターフェース、ラムダで考えることには、もう慣れていますよね。その知識はそのまま活かせます。

大きく変わるのは、メモリとエラーの扱いです。Rustにはガベージコレクタがありません。その代わりに、すべての値には所有者がひとりだけいて、コンパイラがプログラムの実行前に所有権(ownership)と借用(borrowing)をチェックします。また、Rustには null がなく(Optionを使います)、例外もありません(Resultを使います)。学習時間のほとんどは、この3つの違いに使うことになるでしょう。このページでは、C#と.NETで知っていることをRustに対応づけていきます。そうすれば、本当に新しい部分に時間をかけられます。

C++、Go、Pythonとの全般的な比較が知りたい場合は、Rust vs C++・Go・Pythonを見てください。

この表は、旅行の会話帳のように使ってください。いちばん右の列は、Rust側を説明しているこのサイトのページへのリンクです。

C# Rust 詳しくは
フィールドとメソッドを持つ class データ用の struct + メソッド用の impl ブロック 構造体
interface trait トレイト
抽象クラス、継承 継承はない。コンポジション(構造体の中に構造体を入れる)+ デフォルトメソッド付きのトレイトを使う トレイト、デザインパターン
異なるクラスを入れた List<IShape> Vec<Box<dyn Shape>>(トレイトオブジェクト) トレイトオブジェクト
拡張メソッド 自分で定義したトレイトを、自分が書いていない型(String など)に実装する トレイト
where T : IComparable<T> 付きのジェネリクス トレイト境界付きのジェネリクス:T: Ord または where T: Ord ジェネリクス
null、Nullable<int>、null許容参照型 Option<T>:Some(value) か None のどちらか Option
?? と ?. .unwrap_or(...)、.map(...)、.and_then(...)、そして ? Option
例外、throw、try/catch Result<T, E> + ? 演算子 Result、独自のエラー型
本物のバグを表す例外(InvalidOperationException など) panic!(スレッドを止める) panic
IDisposable + using Drop。所有者がスコープを抜けると自動で実行される(RAII) スコープ、スマートポインタ
ガベージコレクタ 所有権と借用。コンパイル時にチェックされる 所有権、借用
record #[derive(Debug, Clone, PartialEq)] を付けた struct 構造体、よく使うトレイト
recordの with 式 構造体更新記法:Order { total: 5, ..old } 構造体
enum(名前付きの整数だけ) 各バリアントが独自のデータを持てる enum 列挙型
switch 式、is パターン match(すべてのケースを網羅したかコンパイラがチェックする) match、パターンマッチング
タプル (int, string) タプル (i32, String) タプル
ref / out パラメータ &mut T、またはタプルや Option を返す 借用
ラムダ、Func<T>、Action<T>、デリゲート クロージャ。Fn、FnMut、FnOnce というトレイトで表される クロージャ、Fn・FnMut・FnOnce
event チャネル、またはボックス化したクロージャ(コールバック)のリスト チャネル、Fn・FnMut・FnOnce
LINQ:Where、Select、Sum、OrderBy、ToList イテレータアダプタ:filter、map、sum、sort_by(Vec に対して)、collect イテレータ、並べ替えと検索
IEnumerable<T> と yield return impl Iterator<Item = T>、または Iterator を実装した独自の型 独自のイテレータ
async Task<T> / await async fn は Future を返す。実行にはtokioのようなランタイムが必要 async/await
Task.Run、Thread、lock std::thread::spawn または tokio::spawn。Mutex<T> は守るデータを包み込む 並行処理、共有状態
プロパティ(get; set;) プロパティはない。pub フィールドか、ゲッター・セッターのメソッドを使う 構造体
string と StringBuilder &str(テキストを借用したビュー)と String(所有していて、伸ばせる) 文字列
文字列補間 $"Hi {name}" format!("Hi {name}") と println!("Hi {name}") 出力
var let(デフォルトで不変。変更したいときは let mut と書く) 変数
const、readonly const、そして普通の let(最初から読み取り専用) 変数
static readonly フィールド static、または起動時に作る値なら LazyLock 変数
namespace と using ディレクティブ モジュール(mod)と use モジュール
アセンブリ / プロジェクト クレート(ライブラリまたはバイナリ) Cargoとクレート
[Serializable] のような属性 #[derive(Serialize)] のような属性と、deriveマクロ マクロ、SerdeでJSONを扱う
リフレクション 実行時のものはほぼない。代わりにマクロと derive がコンパイル時にコードを生成する マクロ
unsafe とポインタ unsafe ブロックと生ポインタ unsafe Rust
Span<T>、ReadOnlySpan<char> スライス:&[T] と &str スライス
ToString() のオーバーライド、IFormattable Display を実装する(すると .to_string() もタダで使える) よく使うトレイト
IEquatable<T>、IComparable<T>、GetHashCode PartialEq/Eq、PartialOrd/Ord、Hash(たいていderiveで済む) よく使うトレイト
implicit / explicit 変換演算子 From/Into(失敗する可能性があるなら TryFrom) 型変換(From・Into)
演算子オーバーロード(operator +) Add など、std::ops のトレイトを実装する 高度なトレイト
checked / unchecked の算術 checked_add、wrapping_add、saturating_add 整数オーバーフロー
decimal 組み込みの10進数型はない。rust_decimal クレートを使うか、金額を最小単位(セントなど)の整数で持つ データ型

System.Collections.Generic にあるものは、ほとんど標準ライブラリでまかなえます。名前は変わっても、考え方は同じです。

C# Rust 詳しくは
List<T> Vec<T> ベクタ
T[](長さ固定) 長さがコンパイル時にわかるなら [T; N]、そうでなければ Vec<T> 配列
Dictionary<K, V> HashMap<K, V> HashMap
SortedDictionary<K, V> BTreeMap<K, V>(キーが並んだまま保たれる) HashMap
HashSet<T> HashSet<T> HashSetとBTreeMap
SortedSet<T> BTreeSet<T> HashSetとBTreeMap
Queue<T> VecDeque<T>(push_back / pop_front) VecDequeとBinaryHeap
Stack<T> push / pop で使う Vec<T> ベクタ
PriorityQueue<TElement, TPriority> BinaryHeap<T>(大きいものから順に出る。小さいものから出したいなら Reverse で包む) VecDequeとBinaryHeap
LinkedList<T> LinkedList<T> もあるが、必要になることはまれ。たいていは VecDeque のほうが速い VecDequeとBinaryHeap
ConcurrentDictionary<K, V> Mutex<HashMap<K, V>>、または dashmap クレート 共有状態

ひとつ注意があります。Dictionary と同じく、Rustの HashMap にも決まった順序はありません。中身を表示するときは、先に並べ替えるか、BTreeMap を使いましょう。

ここは、ほとんどのC#開発者がすぐに気に入る部分です。Cargoは、dotnet CLI、MSBuild、NuGetの仕事をひとつのツールでこなします。

.NET Rust メモ
dotnet CLI cargo ビルド、実行、テスト、パッケージの追加、公開。Cargoとクレートを参照
.csproj Cargo.toml 短くて、手で編集しやすい
packages.lock.json Cargo.lock 自動で作られる
複数のプロジェクトを含む .sln Cargoワークスペース 最上位の Cargo.toml ひとつにメンバーのクレートを並べる
NuGet / nuget.org crates.io 公開されたすべてのクレートのAPIドキュメントが docs.rs にある
dotnet add package Serilog cargo add tracing
dotnet new console cargo new my_app(ライブラリなら cargo new --lib)
dotnet run / build / test cargo run / cargo build / cargo test
-c Release --release デバッグビルドは実行がかなり遅い。ベンチマークはリリースビルドで
dotnet watch cargo watch または bacon(cargo install でインストール)
dotnet tool install -g cargo install
global.json(SDKのバージョン) rustup と rust-toolchain.toml ファイル
Roslynアナライザー cargo clippy 役に立つリントが何百もある公式ツール
dotnet format cargo fmt 全員にひとつの標準スタイル
Visual Studio / Rider VS Code + rust-analyzer、またはJetBrains RustRover rust-analyzerで補完、定義へのジャンプ、その場でのエラー表示が使える
XMLドキュメントコメント /// Markdownで書く /// ドキュメントコメント + cargo doc ドキュメント内のコード例はテストとして実行される。Rustのドキュメントを参照
xUnit / NUnit / MSTest 組み込みの #[test] と cargo test(より速いランナーとして cargo nextest が人気) Rustのテストを参照
Moq mockall、またはトレイトを実装した手書きのフェイク
Coverlet(コードカバレッジ) cargo llvm-cov または cargo tarpaulin
BenchmarkDotNet criterion パフォーマンスを参照
dotnet-trace、プロファイラ cargo flamegraph、perf、または任意のネイティブプロファイラ

.csproj と比べられるように、小さな Cargo.toml を載せておきます。

[package]
name = "orders-api"
version = "0.1.0"
edition = "2021"
[dependencies]
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tokio = { version = "1", features = ["full"] }

ライブラリとフレームワーク:「代わりに何を使えばいいの?」

Section titled “ライブラリとフレームワーク:「代わりに何を使えばいいの?」”

これは、どの.NETチームも最初にする質問です。「うちはEntity Frameworkを使っているけど、Rustには何があるの?」というものですね。ここでは、いちばん近いものを紹介します。.NETにはMicrosoftがいますが、Rustには「標準の構成」を決める1つの会社がありません。そのため、良いコミュニティ製のクレート2〜3個の中から選ぶことがよくあります。

.NET Rust メモ
Entity Framework Core Diesel:ORM兼クエリビルダー。クエリはコンパイル時に型チェックされる。マイグレーションは diesel_cli で 成熟している(2.x)。標準では同期(diesel-async もある)
Entity Framework Core(非同期、エンティティ中心) SeaORM:エンティティ、リレーション、マイグレーション(sea-orm-cli)を備えた非同期ORM EFにいちばん近い感覚。sqlxの上に作られている
Dapper sqlx:普通のSQLを書くと、query! マクロがコンパイル時に実際のデータベースと照らし合わせてチェックする。マイグレーションは sqlx-cli で 非同期で、とても人気。ORMではない
ADO.NET sqlx、tokio-postgres、rusqlite(SQLite) より低レベルのドライバ
StackExchange.Redis redis

EFが気に入っていたなら、まずSeaORMを試しましょう。Dapperで自分でSQLを書くのが好きだったなら、sqlxを。コンパイル時の保証をいちばん強くしたいなら、Dieselを試してください。Rustのデータベースでは、3つとも動くコード付きで紹介しています。

.NET Rust メモ
ASP.NET Core Web API axum(プロジェクト: AxumでWeb APIで実際に作ります)、actix-web、Rocket axumとactix-webは本番環境で広く使われている
Minimal APIs axum:ルートは普通の async fn ハンドラ スタイルがとても近い
ミドルウェアのパイプライン tower のレイヤーと tower-http クレート(CORS、圧縮、トレース、タイムアウト) axumはtowerを直接使っている
組み込みの依存性注入(DI) axumの State、普通のコンストラクタ、トレイト 下のDIの行も参照
Swashbuckle / OpenAPI utoipa
SignalR axumのWebSocket(ws フィーチャー)、または tokio-tungstenite SignalRのようなプロトコル層は組み込まれていない
gRPC(Grpc.AspNetCore) tonic(protobufには prost)
HttpClient reqwest 標準で非同期。ブロッキング版のクライアントもオプションである
JWTベアラー認証 jsonwebtoken
.NET Rust メモ
System.Text.Json / Newtonsoft.Json serde + serde_json 定番。TOML、YAMLなどにも対応。SerdeでJSONを扱うを参照
ILogger / Serilog tracing(+ tracing-subscriber)、または log + env_logger スパン付きの構造化ログ。ログ出力を参照
IConfiguration / appsettings.json config または figment ファイルと環境変数を重ねて、serdeでデシリアライズする
DIコンテナ(IServiceCollection) たいていは何も使わない。依存するものはコンストラクタに渡し、テストで差し替えたいものにはトレイトを使う shaku もあるが、コンテナを使うプロジェクトは少ない
AutoMapper From / Into の実装を書く 明示的で、コンパイラがチェックしてくれる。型変換(From・Into)を参照
FluentValidation / DataAnnotations validator または garde(deriveを使ったバリデーション)
Polly(リトライ) backon、またはtowerのリトライ用ミドルウェア
MediatR 直接対応するものはない 普通のトレイト、またはタスク間のチャネル
Hangfire / バックグラウンドジョブ プロセス内のタスクなら tokio::spawn、永続的なジョブキューなら apalis
CancellationToken Futureをドロップする、または tokio-util の CancellationToken を使う

デスクトップ、ゲーム、その他

Section titled “デスクトップ、ゲーム、その他”
.NET Rust メモ
Blazor WebAssembly Leptos、Yew、Dioxus どれもバージョン1.0前で、APIはまだ変わる。RustとWebAssemblyを参照
WPF / WinForms / MAUI Tauri(Web UI + Rustのバックエンド)、Slint、egui、Iced いちばん安定しているのはTauri 2とSlint 1.x。公式のGUIツールキットはない
Unity Bevy、または godot クレートを使ってGodotの中でRustを使う Bevyは1.0前で、リリースのたびにAPIが変わる
ML.NET candle、burn、linfa PythonのMLツールに比べるとずっと若い
Azure SDK for .NET 公式のAzure SDK for Rust:azure_core、azure_identity、azure_storage_blob、… .NET SDKよりも対応しているサービスが少ない

並べて比較:クラスは struct + impl になる

Section titled “並べて比較:クラスは struct + impl になる”

実際のコードを見てみましょう。C#で書いた小さな銀行口座です。

var account = new BankAccount("Ada", 100);
account.Deposit(50);
Console.WriteLine($"{account.Owner}の残高は{account.Balance}です");
public class BankAccount
{
public string Owner { get; }
private decimal balance;
public BankAccount(string owner, decimal balance)
{
Owner = owner;
this.balance = balance;
}
public void Deposit(decimal amount) => balance += amount;
public decimal Balance => balance;
}

Rustではこうなります。

struct BankAccount {
owner: String,
balance: i64,
}
impl BankAccount {
// `new` はただの慣習で、キーワードではありません。
fn new(owner: &str, balance: i64) -> Self {
BankAccount { owner: owner.to_string(), balance }
}
// `&mut self`:このメソッドは口座を変更します。
fn deposit(&mut self, amount: i64) {
self.balance += amount;
}
// `&self`:このメソッドは読むだけです。
fn balance(&self) -> i64 {
self.balance
}
}
fn main() {
let mut account = BankAccount::new("Ada", 100);
account.deposit(50);
println!("{}の残高は{}です", account.owner, account.balance());
}

実行結果:

Adaの残高は150です
▶ 実際に試してみよう

データと振る舞いは、2つの別々のブロックに分かれています。コンストラクタはなく、あるのは Self を返す関数だけです。どのメソッドも、値を読むだけ(&self)なのか変更する(&mut self)のかを最初に宣言します。そして deposit は口座を変更するので、変数は let mut でなければなりません。C#ではどのメソッドでもどのフィールドでも変更できますが、Rustでは変更することがシグネチャに表れるのです。

並べて比較:インターフェースはトレイトになる

Section titled “並べて比較:インターフェースはトレイトになる”

C#なら、IShape インターフェースと2つのクラス、そして List<IShape> を使うかもしれません。

var shapes = new List<IShape> { new Circle { Radius = 1 }, new Rectangle { Width = 2, Height = 3 } };
foreach (var s in shapes) Console.WriteLine($"{s.Area():F2}");
public interface IShape { double Area(); }
public class Circle : IShape
{
public double Radius { get; init; }
public double Area() => Math.PI * Radius * Radius;
}
public class Rectangle : IShape
{
public double Width { get; init; }
public double Height { get; init; }
public double Area() => Width * Height;
}

Rustでは、インターフェースはトレイトになり、それぞれの構造体が自分の impl ブロックでトレイトを実装します。

trait Shape {
fn area(&self) -> f64;
}
struct Circle {
radius: f64,
}
struct Rectangle {
width: f64,
height: f64,
}
impl Shape for Circle {
fn area(&self) -> f64 {
std::f64::consts::PI * self.radius * self.radius
}
}
impl Shape for Rectangle {
fn area(&self) -> f64 {
self.width * self.height
}
}
fn main() {
// Box<dyn Shape> が「何らかの IShape への参照」の役割をします。
let shapes: Vec<Box<dyn Shape>> = vec![
Box::new(Circle { radius: 1.0 }),
Box::new(Rectangle { width: 2.0, height: 3.0 }),
];
for shape in &shapes {
println!("{:.2}", shape.area());
}
}

実行結果:

3.14
6.00
▶ 実際に試してみよう

C#では、インターフェースの呼び出しはすべて仮想呼び出しになります。Rustでは自分で選べます。Box<dyn Shape> は仮想呼び出しを使います(トレイトオブジェクトを参照)。一方、fn print_area<T: Shape>(s: &T) のようなジェネリック関数は型ごとに別々にコンパイルされるので、仮想呼び出しはまったく起きません。うれしいおまけもあります。自分で定義したトレイトを、自分が書いていない型、たとえば String や i32 にも実装できるのです。C#の拡張メソッドにあたることを、Rustはこうやって実現しています。

並べて比較:null は Option になる

Section titled “並べて比較:null は Option になる”

C#のnull許容参照型はnullについて警告してくれますが、実行時には null はまだ存在します。

string? FindEmail(int userId) => userId == 1 ? "[email protected]" : null;
var email = FindEmail(2);
if (email is not null) Console.WriteLine(email.ToUpper());
else Console.WriteLine("メールアドレスが登録されていません");
Console.WriteLine(FindEmail(2) ?? "不明");

Rustでは、値がないかもしれないものは Option<T> 型になります。None の場合を処理するまでは、普通の値として使うことができません。

fn find_email(user_id: u32) -> Option<String> {
if user_id == 1 {
Some("[email protected]".to_string())
} else {
None
}
}
fn main() {
match find_email(1) {
Some(email) => println!("{}", email.to_uppercase()),
None => println!("メールアドレスが登録されていません"),
}
// ひとつのケースだけ気にするなら、`if let` が短く書けます。
if let Some(email) = find_email(2) {
println!("見つかりました: {email}");
} else {
println!("メールアドレスが登録されていません");
}
// unwrap_or はC#の ?? 演算子のように働きます。
let email = find_email(2).unwrap_or("不明".to_string());
println!("{email}");
}

実行結果:

メールアドレスが登録されていません
不明
▶ 実際に試してみよう

Rustには NullReferenceException がありません。参照をたどる先のnullが、そもそも存在しないからです。コンパイラは、Option<String> に対して .to_uppercase() を呼ぶことを許しません。便利なヘルパーメソッドはOptionのページで、短い書き方はif let / while letで学べます。

並べて比較:try/catch は Result と ? になる

Section titled “並べて比較:try/catch は Result と ? になる”

C#では、パース(文字列から数値への変換)は例外を投げることがあり、その例外は呼び出し元のどこかでキャッチします。

static int TotalSeats(string a, string b) => int.Parse(a) + int.Parse(b);
try
{
Console.WriteLine(TotalSeats("12", "30"));
Console.WriteLine(TotalSeats("12", "thirty"));
}
catch (FormatException e)
{
Console.WriteLine($"エラー: {e.Message}");
}

Rustでは、失敗する可能性のある関数は、そのことを戻り値の型で示します。? 演算子は「失敗していたら、今すぐそのエラーを呼び出し元に返す」という意味です。例外を上に伝えていくのと同じ役割ですが、それがコードの上で目に見えます。

use std::num::ParseIntError;
fn total_seats(a: &str, b: &str) -> Result<i32, ParseIntError> {
let first: i32 = a.parse()?;
let second: i32 = b.parse()?;
Ok(first + second)
}
fn main() {
for (a, b) in [("12", "30"), ("12", "thirty")] {
match total_seats(a, b) {
Ok(total) => println!("合計: {total}"),
Err(e) => println!("エラー: {e}"),
}
}
}

実行結果:

合計: 42
エラー: invalid digit found in string
▶ 実際に試してみよう

invalid digit found in string は標準ライブラリが返す英語のメッセージで、「文字列に数字として不正な文字が含まれています」という意味です。

大きな違いはここです。C#ではどのメソッドも例外を投げるかもしれず、シグネチャを見てもわかりません。Rustでは Result がシグネチャの一部なので、呼び出す側が処理を忘れることがありません。独自のエラー型については独自のエラー型を見てください。アプリケーションでは、anyhow クレートを使うと「どんな例外でもキャッチする」に近いことができます(anyhowとthiserrorを参照)。

並べて比較:LINQ はイテレータのチェーンになる

Section titled “並べて比較:LINQ はイテレータのチェーンになる”

よくあるLINQのクエリです。

var orders = new List<Order> { new("Ada", 120), new("Grace", 80), new("Linus", 200), new("Ken", 150) };
var bigSpenders = orders
.Where(o => o.Total > 100)
.OrderByDescending(o => o.Total)
.Select(o => o.Customer)
.ToList();
Console.WriteLine(string.Join(", ", bigSpenders));
Console.WriteLine($"売上: {orders.Sum(o => o.Total)}");
record Order(string Customer, int Total);

Rust版では、イテレータのアダプタとクロージャを使います。

struct Order {
customer: String,
total: u32,
}
fn main() {
let orders = vec![
Order { customer: "Ada".to_string(), total: 120 },
Order { customer: "Grace".to_string(), total: 80 },
Order { customer: "Linus".to_string(), total: 200 },
Order { customer: "Ken".to_string(), total: 150 },
];
// Where(...).ToList()
let mut big: Vec<&Order> = orders.iter().filter(|o| o.total > 100).collect();
// OrderByDescending(...):並べ替えはイテレータではなく Vec に対して行います。
big.sort_by(|a, b| b.total.cmp(&a.total));
// Select(...)
let names: Vec<&str> = big.iter().map(|o| o.customer.as_str()).collect();
println!("{}", names.join(", "));
// Sum(...)
let revenue: u32 = orders.iter().map(|o| o.total).sum();
println!("売上: {revenue}");
}

実行結果:

Linus, Ken, Ada
売上: 550
▶ 実際に試してみよう

Rustのイテレータは、LINQの遅延実行と同じく遅延評価(lazy)です。collect() や sum()、for ループのようなものを呼ぶまで、何も実行されません。collect() が ToList() にあたります。LINQと違って、イテレータのチェーンはたいてい手書きのループと同じ機械語にコンパイルされます。きれいな書き方をしても、速度の面で損をしないのです。並べ替えについては並べ替えと検索のページで説明しています。

並べて比較:using/IDisposable は Drop になる

Section titled “並べて比較:using/IDisposable は Drop になる”

C#では、リソースを適切なタイミングで解放するために using を忘れずに書く必要があります。

using (var conn = new Connection("orders-db"))
{
Console.WriteLine($"{conn.Name}に問い合わせ中");
}
Console.WriteLine("ブロックのあと");
class Connection : IDisposable
{
public string Name { get; }
public Connection(string name) => Name = name;
public void Dispose() => Console.WriteLine($"{Name}への接続を閉じます");
}

Rustでは、後片付けは自動です。値の所有者がスコープを抜けると、Rustがその値の Drop 実装を呼びます。忘れるものが何もありません。

struct Connection {
name: String,
}
impl Drop for Connection {
fn drop(&mut self) {
println!("{}への接続を閉じます", self.name);
}
}
fn main() {
{
let conn = Connection { name: "orders-db".to_string() };
println!("{}に問い合わせ中", conn.name);
} // ここで `conn` がスコープを抜けるので、drop() が実行されます。
println!("ブロックのあと");
}

実行結果:

orders-dbに問い合わせ中
orders-dbへの接続を閉じます
ブロックのあと
▶ 実際に試してみよう

これはRAIIと呼ばれる仕組みで、Rustはファイル、ソケット、ロック、そしてメモリそのものまで、すべてをこの方法で管理します。ファイルは File の値がドロップされたときに閉じられます。Mutex は、ガードがドロップされたときにロックが外れます。ファイナライザもGCによる停止もなく、後片付けは予測できるタイミングで行われます。

C#開発者がつまずきやすいところ

Section titled “C#開発者がつまずきやすいところ”

C#から来た人は、みんな同じところでつまずきます。前もって知っておくだけで、ずいぶん楽になります。

  • 代入は「ムーブ」で、参照のコピーではありません。 C#では、クラスに対して var b = a; と書くと、ひとつのオブジェクトを指す参照が2つになります。Rustでは、let b = a; は String や Vec の所有権を b にムーブし、a はもう使えなくなります。

    fn main() {
    let owner = String::from("Ada");
    let copy = owner;
    println!("{owner}と{copy}");
    }
    error[E0382]: borrow of moved value: `owner`
    --> src/main.rs:4:16
    |
    2 | let owner = String::from("Ada");
    | ----- move occurs because `owner` has type `String`, which does not implement the `Copy` trait
    3 | let copy = owner;
    | ----- value moved here
    4 | println!("{owner}と{copy}");
    | ^^^^^ value borrowed here after move

    このエラーは「ムーブ済みの値 owner を借用しようとしている」という意味です。直すには、借用する(let copy = &owner;)か、意図してクローンします(owner.clone())。まず所有権を読んでください。このルールを詳しく説明しています。

  • 借用チェッカー。 読み取り用の参照(&T)はいくつでも持てますが、書き込み用の参照(&mut T)を持てるのはひとつだけです。両方を同時に持つことはできません。親を指し返すオブジェクトや、ひとつの共有オブジェクトを多くのオブジェクトが変更する、といったC#ではよくあるパターンには、スマートポインタか別の設計が必要になります。最初の数週間はイライラするかもしれませんが、そのあとはずっと楽になります。

  • String と &str。 C#の文字列型は string ひとつですが、Rustには2つあります。String はテキストを所有し、&str はテキストを借用します。関数はたいてい &str を受け取り、構造体はたいてい String を持ちます。Rustの文字列を見てください。

  • 継承がありません。 共通のフィールドを持つ基底クラスを作って、メソッドをオーバーライドすることはできません。共通の振る舞いには(デフォルトメソッド付きの)トレイトを使い、共通のデータは、ほかの構造体に含める構造体にまとめます。

  • 例外がありません。 エラーは値です。? をたくさん書くことになりますし、main も Result を返せます。学習中は .unwrap() を使ってもかまいませんが、これは「例外を無視してクラッシュさせる」のRust版だと覚えておきましょう。

  • null がありません。 最初は、あちこちに Option があるのが手間に感じます。でもしばらくすると、null参照のバグに出会わなくなったことに気づくはずです。

  • クローンは明示的に。 Rustは、ヒープ上の値を勝手にコピーしません。.clone() が書いてあれば、そこで本当にコピーが作られます。コストが目に見えるのは、むしろ長所です。

  • コンパイル時間。 多くのクレートを使うプロジェクトのクリーンビルドは、.NETのビルドより時間がかかります。インクリメンタルビルドと cargo check(コード生成を省略する)はずっと速いので、編集中は cargo check を使いましょう。

  • asyncにはランタイムが必要です。 .NETでは、ランタイムとスレッドプールがいつも用意されています。Rustでは、async fn はエグゼキュータが実行するまで何もしません。そのため、tokioを追加して main に #[tokio::main] を付けます。async/awaitを見てください。

このサイトを順番どおりに読む必要はありません。C#開発者なら、次のルートで進むと、すでに知っていることは飛ばして、Rustならではの部分に時間をかけられます。

  1. Rustを始める:Rustをインストールして cargo new を実行します。
  2. Rustの変数とRustのデータ型:ざっと目を通しましょう。主に let mut と数値の型を覚えるためです。
  3. 所有権、次に借用、そしてスライス:あなたにとっていちばん大事なページです。じっくり時間をかけてください。
  4. Rustの文字列:String と &str の違い。
  5. 構造体と列挙型、次にmatchとパターンマッチング。
  6. OptionとResult、次に独自のエラー型。
  7. トレイト、ジェネリクス、よく使うトレイト、トレイトオブジェクト。
  8. クロージャとイテレータ:これがあなたのLINQです。
  9. ベクタ、HashMap、VecDequeとBinaryHeap。
  10. モジュール、Cargoとクレート、テスト。
  11. ライフタイムとスマートポインタ。
  12. 並行処理、共有状態、async/await。
  13. SerdeでJSONを扱うとログ出力。そのあとWeb APIのプロジェクトを作ってみましょう。

どちらの問題も、C#のコード片をRustに移植(ポーティング)します。

1. recordとLINQのクエリを移植する

Section titled “1. recordとLINQのクエリを移植する”

このC#のコードを移植してください。在庫があって、価格が20未満の商品の名前を表示します。ヒント: #[derive(Debug, Clone, PartialEq)] を付けた struct を作り、filter、map を使って Vec<&str> に collect します。Vec<&str> には .join(", ") メソッドがあります。

var products = new List<Product>
{
new("Notebook", 5, true),
new("Headphones", 45, true),
new("Pen", 2, false),
new("Mug", 12, true),
};
var cheap = products.Where(p => p.InStock && p.Price < 20).Select(p => p.Name);
Console.WriteLine(string.Join(", ", cheap));
record Product(string Name, int Price, bool InStock);
解答を見る
#[derive(Debug, Clone, PartialEq)]
struct Product {
name: String,
price: u32,
in_stock: bool,
}
fn product(name: &str, price: u32, in_stock: bool) -> Product {
Product { name: name.to_string(), price, in_stock }
}
fn main() {
let products = vec![
product("Notebook", 5, true),
product("Headphones", 45, true),
product("Pen", 2, false),
product("Mug", 12, true),
];
let cheap: Vec<&str> = products
.iter()
.filter(|p| p.in_stock && p.price < 20)
.map(|p| p.name.as_str())
.collect();
println!("{}", cheap.join(", "));
}

実行結果:

Notebook, Mug
▶ 実際に試してみよう

小さなヘルパー関数が、recordの位置指定コンストラクタの役割をしています。deriveを付けると、C#のrecordが自動で用意してくれるものが手に入ります。{:?} での表示、.clone() でのコピー、== での比較です。

2. 例外を使うDictionaryを移植する

Section titled “2. 例外を使うDictionaryを移植する”

このC#のコードは、在庫の辞書から商品を1つ取り出し、問題があれば例外を投げます。take が例外を投げる代わりに Result<u32, String> を返すように移植してください。ヒント: stock.get_mut(item) は Option<&mut u32> を返します。.ok_or(...) で Result に変えてから、? を使いましょう。

var stock = new Dictionary<string, int> { ["りんご"] = 3, ["なし"] = 0 };
int Take(string item)
{
if (!stock.TryGetValue(item, out var count))
throw new KeyNotFoundException($"{item}という商品はありません");
if (count == 0)
throw new InvalidOperationException($"{item}は在庫切れです");
stock[item] = count - 1;
return count - 1;
}
foreach (var item in new[] { "りんご", "なし", "キウイ" })
{
try { Console.WriteLine($"{item}を1つ取りました。残りは{Take(item)}個です"); }
catch (Exception e) { Console.WriteLine($"エラー: {e.Message}"); }
}
解答を見る
use std::collections::HashMap;
fn take(stock: &mut HashMap<String, u32>, item: &str) -> Result<u32, String> {
let count = stock
.get_mut(item)
.ok_or(format!("{item}という商品はありません"))?;
if *count == 0 {
return Err(format!("{item}は在庫切れです"));
}
*count -= 1;
Ok(*count)
}
fn main() {
let mut stock = HashMap::new();
stock.insert("りんご".to_string(), 3);
stock.insert("なし".to_string(), 0);
for item in ["りんご", "なし", "キウイ"] {
match take(&mut stock, item) {
Ok(left) => println!("{item}を1つ取りました。残りは{left}個です"),
Err(e) => println!("エラー: {e}"),
}
}
}

実行結果:

りんごを1つ取りました。残りは2個です
エラー: なしは在庫切れです
エラー: キウイという商品はありません
▶ 実際に試してみよう

out パラメータを使う TryGetValue は、Option を返す get_mut になります。2つの throw は Err の値になり、try/catch は match になります。呼び出し側の &mut stock を見れば、take が辞書を変更することがはっきりわかります。

  • C#の多くはそのまま活かせます。ジェネリクス、async/await、ラムダ、パターンマッチング、LINQ風のイテレータには、どれもRustによく似たものがあります。
  • 大きな違いは、ガベージコレクタの代わりに所有権、nullの代わりに Option、例外の代わりに Result + ? を使うことです。
  • クラスは struct + impl に、インターフェースはトレイトに、継承はコンポジションとトレイトになります。using/IDisposable は自動で動く Drop になります。
  • Cargoが dotnet CLI、MSBuild、NuGetの代わりになり、clippyとrustfmtがアナライザーと dotnet format の代わりになります。テストは最初から組み込まれています。
  • EF CoreならSeaORMかDiesel、Dapperならsqlx、ASP.NET Coreならaxumかactix-webを見てみましょう。GUI、ゲーム、MLの選択肢はまだ若い分野です。

理解度チェック

1. Rustでは null の代わりに何を使いますか?

2. あなたのチームはDapperを使っていて、SQLを手で書くのが好きです。いちばん近いRustのクレートはどれですか?

3. C#の IDisposable を使った using ブロックに、いちばん近いRustの仕組みはどれですか?

4. C#では、クラスに対する var b = a; は参照をコピーします。Rustで String に対して let b = a; と書くと、どうなりますか?

スコア: 0 / 4