Skip to content

第5回 Rust言語1

本日の講義内容

  • Rustの基本
  • 所有権とは

Rust事始め

なぜRustか

これまで皆さんが学んできたC言語は、メモリのアドレス・領域を意識したプログラミングができる言語でした。これまでのソフトウェア1、2の講義では、ポインタやメモリ領域の扱い方について実習をおこなってきました。実際に手を動かしながら、そのようなC言語の特徴について学ぶことができたと思います。

こうした特徴によって、C言語では、高速でハードウェアに近いプログラミングができます。その一方で、書いている最中に考えることが多く、また、その過程で厄介なバグに遭遇しがち、と感じた方も多いのではないでしょうか。たとえば、以下のようなバグに遭遇したこともあったのではないかと思います。

example0.c
#include <stdio.h>

int main() {
    char buf[4];

    buf[0] = 'A';
    buf[1] = 'B';
    buf[2] = 'C';
    buf[3] = 'D';
    buf[4] = 'E';  // NG: 範囲外書き込み、未定義動作、バッファオーバーフロー

    printf("buf[4] = %c\n", buf[4]);  // 範囲外読み出し、未定義動作
    return 0;
}

バッファオーバーフロー

example1.c
#include <stdio.h>
#include <stdlib.h>

int main() {
    int *p = malloc(sizeof(int));

    *p = 42;
    free(p);  // 解放

    printf("%d\n", *p);  // NG: use-after-free(未定義動作)

    return 0;
}

use-after-free

example2.c
#include <stdio.h>
#include <stdlib.h>

int *func() {
    int x = 42;
    return &x;  // スタック領域の変数のアドレスを返してしまっている
}

int main() {
    int *p = func();  // ダングリング (宙ぶらりんな) ポインタと呼ばれる
    printf("%d\n", *p);  // NG: use-after-scope(未定義動作)

    return 0;
}

use-after-scope

演習

それぞれをコンパイルして実行してみましょう。皆さんの環境ではどんな結果になるでしょうか。

これらはC言語における典型的なバグで、いずれもメモリに関連しています。単に見つけづらいバグならまだ良いのですが、これらは脆弱性、すなわちセキュリティ上の欠陥となる可能性もあります。予期しないメモリ領域へのアクセスが生じていると、攻撃者によってそれが悪用され、情報漏洩やデータの改ざん、任意コード実行といった深刻な被害につながる恐れがあります。

たとえば、Microsoftの発表によれば、過去の製品更新プログラムで修正された脆弱性の約70%はこうしたメモリ安全性に関するものであったとされています。

メモリ脆弱性

© Microsoft, Source: BlueHat IL 2019 presentation, Licensed under CC BY 4.0 https://creativecommons.org/licenses/by/4.0/

他方、たとえば、Pythonを使っているときには、このようなメモリ周りの面倒なことは意識せずにソフトウェア開発を進められていると思います。これは、Pythonの場合、ランタイムが自動的にメモリ管理をおこなってくれているためです。しかし、これには実行時の性能オーバーヘッドがともないます。高速性が求められる処理においては、このようなオーバーヘッドは許容できない場合があります。

今日学ぶRustは、高い性能とメモリ安全性の両立を目指した言語です。これらは従来、トレードオフの関係にあると考えられてきました。Rustは、所有権借用ライフタイムといった概念をはじめとした工夫によって、実行時のコストを増やすことなく、コンパイル時にメモリ安全性を検証するというアプローチを取っています。性能を維持しながら、安全で信頼性の高いソフトウェア開発の実現を目指しています。

もちろん、ほかにも学ぶべきプログラミング言語は世の中にたくさん存在します。その中でこの講義においてRustを学ぶ理由は、Rustが

  • C言語と似た特徴
    • ガベージコレクション無しでメモリを扱うため高性能
  • C言語と似た適用領域
    • システムソフトウェアや組み込みシステムなどで数多く利用

を持った言語であるからです。C言語の良さを保ちつつメモリ安全性やその他のバグを解決しようと考えたらどんな言語になるか、という視点で考えやすく、C言語の延長として学ぶのに適した言語だと思います。

Rustの文法や機能、周辺ツールを知ることには、以下のような一般的な嬉しさがあると思います。

  • 文法を知る
    • 今後、システムソフトウェアなど性能と安全性が要求される重要なソフトウェアの開発において、Rustの利用される場面が増えていくと思われる、そうしたコードを読めるようになる
  • 機能を知る
    • ソフトウェア開発における大きな課題であるメモリ安全性に対して、コンパイル時に安全性を保証する仕組みを理解できる
  • 周辺ツールを知る
    • モダンな言語であるRustには、ビルドツールやパッケージマネージャ、リンタ・フォーマッタ、ドキュメント生成ツールといった、便利な周辺ツールがデフォルトで用意されている。どんなものがあるのか、それぞれをどんな風に使うのか、がわかる

コラム: Rustが活用されているソフトウェア

近年は特に、システムソフトウェアの開発において、Rustの利用される場面が増えています(参考)。LinuxカーネルAndroidWindowsといった主要なシステムソフトウェアへとRustの導入が進んでいます。

その他、Rustが開発に活用されている著名なソフトウェアとして、以下のようなものがあります。「前からあった○○の高速・高機能版」を謳うものが多いです。また、性能や安全性が要求される中核、低レイヤ部分に新たにRustを導入し、高速化や堅牢化を図るソフトウェアも増えています。

  • uv: pipなどに似たPython向けパッケージマネージャ
  • Ruff: Flake8/PylintやBlackに似たPython向けリンタ・フォーマッタ
  • Polars: Pandasに似たデータ分析向けライブラリ
  • typst: LaTeXに似た組版システム
  • ripgrep: grepに似た高速なファイル検索ツール
  • fd: findに似たファイル検索ツール
  • eza: lsに似たファイル管理ツール
  • SWC: Next.jsやviteなどで利用されるTypeScript/JavaScript向けコンパイラ
  • ZLUDA: NVIDIA以外のGPU向けCUDAエミュレータ
  • Qiskit: 量子コンピュータ向けソフトウェア

環境構築

まずは環境構築を進めましょう。環境構築の下の方に手順をまとめています。こちらを見ながら環境構築に取り組んでみてください。

最初のプログラム

最初のプログラムとして、"hello, world"を表示してみましょう。

hello.rs
1
2
3
4
fn main() {
    // hello, world と表示、末尾に改行が入る。このようにコメントはC言語と同様に書ける。
    println!("hello, world");  
}

これを記述し、rustcを用いてコンパイルしてみましょう。Rustのソースファイルは、.rsという拡張子で保存するようにしましょう。

演習

上記のhello.rsを写経し、コンパイルして実行してみましょう。コンパイルコマンドと実行コマンドは下のようになります。コンパイルの結果生成される実行可能ファイルは、ソースファイルと同じ名前で拡張子の無いものになります。

$ rustc hello.rs
$ ./hello

さて、最初のプログラムは実行できたでしょうか。ところで、C言語で同じような内容を記述しようと思ったら、たとえば以下のように書いていたと思います。

hello.c
1
2
3
4
5
6
#include <stdio.h>

int main(void) {
    printf("hello, world\n");
    return 0;
}

C言語での記述とRustでの記述を比較すると、いくつかの違いに気が付くと思います。

  • ライブラリの読み込み

    Rustの記述には、C言語のstdio.h読み込みに相当するものがありません。Rustにも標準ライブラリに相当するものがありますが、このうちよく使う部分についてはデフォルトで読み込まれており、今回のような簡単なプログラムでは特に追加の記述は必要ありません。なお、Rustでは、ライブラリはクレートと呼ばれる形式で提供されます。これについては後ほど学びます。

  • 関数の定義

    Rustでは、fnが関数定義用のキーワードとなっています。また、main関数の戻り値については特に記述しなくてもokです。(この場合、main関数の戻り値は()、これは、ユニット型と呼ばれるもので、意味のある値を返す必要が無い時に使います)

  • 標準出力関数

    Rustでは、println!というものを使っています。これは実は関数ではなく、マクロと呼ばれるものです。とりあえずいまのところは、!がついているものは関数ではなくマクロで、関数と似ているが違うものらしいと思っておいてください。

  • return文

    Rustにも、returnはありますが、あまり使いません。通常は、関数内で最後に書いた式の値が戻り値として返されます。戻り値として使いたい場合は、;を付けません。このあたりについても、後ほど学びます。

Cargo

Rustをインストールすると、Cargoというツールが同時に手に入ります。これは、Rustのビルドツール兼パッケージマネージャで、makepipのような機能を併せ持ったとても便利なものです。ごくごく簡単なプログラム以外は、基本的にCargoを使ってパッケージを作成し、ビルドして実行する、という手順を踏んで開発を進めるのが良いと思います。

パッケージの作成

まず、Cargoを用いてパッケージを作成してみましょう。パッケージはCargoが管理する単位であり、その単位でビルドや実行、依存関係の管理がおこなわれます。

cargo newとすることで、整理されたディレクトリ構造を自動で生成してくれます。

演習

Cargoを用いて、helloという名前のパッケージを作成してみましょう。コマンドは下のようになります。

$ cargo new hello

helloという名前のディレクトリが作成されたと思います。このディレクトリがパッケージのルートディレクトリになります。実は、デフォルトでgitのリポジトリにもなっています。

hello
├── Cargo.toml  // パッケージの設定ファイル
└── src  // ソースコードを格納するディレクトリ
    └── main.rs

ここで、Cargo.tomlというファイルが作成されています。これは、プロジェクトの設定ファイルです。

Cargo.toml
1
2
3
4
5
6
[package]
name = "hello"
version = "0.1.0"
edition = "2024"

[dependencies]

[package]の下の行では、パッケージの名前、バージョン、利用するRustのエディションを設定しています。[dependencies]の下の行では、利用する外部のクレートを設定します。今回の講義では特に何も使わないので基本的には空です。

また、src/main.rsというファイルが作成されています。デフォルトで、以下のような内容が記述されていると思います。

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

このようなソースファイルについてはsrcディレクトリ以下に配置します。

さて、これをビルドして実行してみましょう。ビルド&実行コマンドはcargo runです。

演習

Cargoを用いて、パッケージをビルドし実行してみましょう。コマンドは下のようになります。

$ cd hello
$ cargo run

デフォルトでは、実行可能ファイルはtarget/debug/以下に生成されます。

まとめると、Cargoを活用した開発においては、以下のようなコマンドを使います。コマンドの実行場所は、パッケージのルートディレクトリ以下であればどこでも(src以下などのサブディレクトリでも)okです。

# パッケージを作成
$ cargo new <パッケージ名>

# パッケージをビルドして実行
$ cargo run

# パッケージをビルド (実行可能ファイルは生成せず、エラーがないかだけチェック)
$ cargo check

# パッケージをビルド
$ cargo build

# パッケージをリリース向けにビルド (時間をかけて最適化された実行可能ファイルを生成)
$ cargo build --release

# ビルドで生成されたファイルを削除
$ cargo clean

cargo checkcargo buildcargo runよりも高速に、エラーがないかどうかだけをチェックします。定期的にcargo checkを実行しながら、開発を進めるのが良いと思います。cargo build --releaseとすると、時間をかけて最適化された実行可能ファイルをtarget/release/以下に生成します。

Rustの基本

変数

Rustでは、変数の定義にはletというキーワードを使います。C言語では、変数の定義はint x = 10;のように型を指定して書いたと思います。一方、Rustには型推論という機能があり、型の指定を省略することができます。その場合、Rustのコンパイラが変数の型を推論してくれます。

1
2
3
4
fn main() {
    let x = 10;
    println!("x = {}", x);  // {}という記号を使って、xの値を出力
}

なお、明示的に型注釈を付けることもできます。

1
2
3
4
fn main() {
    let x: i32 = 10;  // i32は32ビットの整数型
    println!("x = {}", x);  // {}という記号を使って、xの値を出力
}

また、Rustでは、変数は、デフォルトで不変 (immutable)です。これは、すでに値が割り当てられた変数に対して再代入ができないことを意味します。どういうことでしょうか。まずは、下のようなコードを眺めて動かしてみましょう。

1
2
3
4
5
fn main() {
    let x = 10;
    x = 20;
    println!("x = {x}");  // {x}という書き方でもok
}

演習

上記のコードを写経して、コンパイルしてみましょう。Cargoを用いる場合は、以下のようにします。

$ cargo new <任意のパッケージ名>
$ cd <任意のパッケージ名>/src
# main.rsを編集
$ cargo check

さて、C言語を使って同じようなものを書いたら簡単に動いてくれそうなコードですが、コンパイルしてみるとエラーが発生したと思います。このように、Rustでは別の値を再代入することが通常はできません。

どうしても代入したい場合は、mutというキーワードを付けて定義します。たとえば、let x = 10;の代わりに、let mut x = 10;と書くと、再代入できるようになります。

演習

mutを使ってコードを書き直し、再度コンパイルして実行してみましょう。

このように、変数はデフォルトで不変で再代入はできないのですが、シャドーイングと呼ばれる仕組みで、新たに同じ名前の変数を定義することができます。この場合、以前の変数は覆い隠され、利用できなくなります。

ここでは、再代入ではなく、まっさらな新しい変数を都度定義しています。これによって値や型が段階的に変化していく処理を不変性を保ったまま記述することができます。

1
2
3
4
5
6
7
fn main() {
    let x = 10;
    let x = x + 1;
    let x = x * 2;

    println!("x = {}", x);
}

演習

上記のコードを写経して、コンパイルしてみましょう。

さて、どうしてこんな面倒なことをしているのか、と思った方もいるかもしれません。これはバグを未然に防ぐための仕様です。

これまで変数の値は自由に変更できることが当たり前だったので意識してこなかったかと思うのですが、ひとたび値の変更を許すと、

  • いまの値は何になっているのか
  • 値がいつ・どこで・誰によって変更されたのか

といったことをコード全体にわたって追っていく必要が生じます。これが限界を超えてしまったときに、バグが生じることになります。

変数が不変であることをデフォルトにすると、こうした追跡の必要はそもそもなくなり、意図せず値が変更されてしまっている場合にもそのことをコンパイル時にエラーとして検出することができます。また、どうしても変更したい変数がある場合にはそこにだけmutと付けさえすれば良いです。

とりあえずは、このような制約を、面倒な縛り、ではなく、プログラマを助けるための支援、だと捉えて進めてみてください。これに限らず今後も、「コンパイラがめちゃくちゃ怒ってくる」という印象を受ける場面があるかもしれませんが、「丁寧なご助言を賜っている」と寛大な心で向き合っていきましょう。「コンパイルさえ通れば、かなり安心して実行できる」というRustならではの利点を、次第に実感できるようになるかもしれません。

定数

定数は、グローバルなスコープでも定義できます。定数は必ず不変であり、mutは使えず再代入はできません。また、型注釈は必須です。

1
2
3
4
5
6
7
const X: i32 = 10;
const HEADER_SIZE: usize = 16;

fn main() {
    println!("X = {X}");
    println!("HEADER_SIZE = {HEADER_SIZE}");
}

基本的な型と演算子

Rustは静的型付け言語です。これは、変数の型がコンパイル時に決定されることを意味します。型推論が存在するため毎回型を明示する必要はありませんが、しっかりと型自体は存在し、厳格な型のチェックがおこなわれます。

整数型

整数型には、以下のようなものがあります。

サイズ 符号付き整数型 符号なし整数型
8-bit i8 u8
16-bit i16 u16
32-bit i32 u32
64-bit i64 u64
CPU依存 (32/64-bit) isize usize

Rustでは型の名前に明示的にサイズが入っています。この辺りはC言語と似ていて、i32などをよく使います。型注釈や文脈で判断できない場合は、i32に推論されます。

C言語では、異なる整数型の変数間で暗黙の型変換が実施され、たとえば以下のようなことができたと思います。

int x = -1;
unsigned int y = x; // 4294967295

Rustでは、そのようなことは通常できません。

1
2
3
4
5
6
7
fn main() {
    let x: i32 = 10;
    let y: u32 = 20;

    let z = x + y;
    println!("z = {}", z);
}

演習

上記のコードを写経して、コンパイルしてみましょう。またxyの型を一致させて再度コンパイル・実行してみましょう。

なお、asというキーワードを用いて、let y: u32 = x as u32;のように書くことで、C言語と似たような型の変換ができます。

演習

以下のコードを、コンパイルできるようにasを使って書き直し、実行してみましょう。実行結果はどうなるでしょうか。

1
2
3
4
5
6
fn main() {
    let x: u16 = 300;
    let y: u8 = x;

    println!("y = {}", y);
}
こたえ

実行結果はy = 44となります。これは、300を2進数で表すと100101100となり、これを8ビットに収めるために下位8ビットのみ00101100が取り出された結果です。

src/main.rs
1
2
3
4
5
6
fn main() {
    let x: u16 = 300;
    let y: u8 = x as u8;

    println!("y = {}", y);
}

浮動小数点数型

浮動小数点数型には、以下のようなものがあります。

サイズ 浮動小数点数型
32-bit f32
64-bit f64

こちらにも明示的にサイズが入っています。この辺りはC言語のfloatdoubleと似ています。型注釈や文脈で判断できない場合は、f64に推論されます。

浮動小数点数を表現したいときは、

let x = 1.0;
let y = 12.3456;

のように、小数点を使って書けば、意図通りの型が推論されます。

算術演算子

算術演算子としては、C言語と同様に以下のようなものがあります。

演算子 演算内容
+ 加算
- 減算
* 乗算
/ 除算
% 剰余

演習

次の4つの値の平均を計算してみましょう。合計は整数で計算し、平均は浮動小数点数で計算して、それぞれについてprintln!で表示してみてください。

  • 80
  • 90
  • 75
  • 88
こたえ
main.rs
fn main() {
    let a = 80;
    let b = 90;
    let c = 75;
    let d = 88;

    // 合計は整数で計算
    let sum = a + b + c + d;

    // 平均は f64 に変換して計算
    let average = sum as f64 / 4.0;

    println!("合計: {}", sum);
    println!("平均: {}", average);
}

ビット演算子

ビット演算子も、C言語ととても似ていますが、ビットごとの否定には~ではなく!を使います。

演算子 演算内容
& ビットごとの論理積
| ビットごとの論理和
^ ビットごとの排他的論理和
! ビットごとの否定
<< 左シフト
>> 右シフト
let x: u8 = 0b00111100;  // 60
let y: u8 = 0b00001101;  // 13

let and  = x & y;   // ビットごとのAND
let or   = x | y;   // ビットごとのOR
let xor  = x ^ y;   // ビットごとのXOR
let not  = !x;      // ビットごとのNOT
let shl  = x << 2;  // 左シフト
let shr  = x >> 2;  // 右シフト

演習

2進数0b101011000b10011010について、それぞれのビットごとの論理積、論理和、排他的論理和、否定を計算しprintln!で表示してみましょう。なおprintln!("{:b}", <変数>)のように書くと、変数の値を2進数で表示することができます。

こたえ
main.rs
fn main() {
    let a: u8 = 0b10101100;
    let b: u8 = 0b10011010;

    println!("AND : {:b}", a & b);
    println!("OR  : {:b}", a | b);
    println!("XOR : {:b}", a ^ b);
    println!("NOT a: {:b}", !a);
    println!("NOT b: {:b}", !b);
}

論理値型

論理値型には、以下のようなものがあります。

サイズ 取り得る値
8-bit bool true, false
let a: bool = true;
let b: bool = false;

これは、これまで学んできたC言語では見たことの無いものだと思います。if文の条件を記述する際のことを思い出すと、C言語では、0か、それ以外の整数値か、を使って、真か偽か、を表現していました。Rustには、論理値を表現する専用の型としてboolが用意されており、これは値として、truefalseのどちらかだけをとります。

コラム: C言語の論理値型

実は、C言語でもboolが使えます。まず、論理値を表現する専用の型として_Boolが用意されています。昔のC言語にはこういったものは存在しなかったのですが、C99からは_Boolが用意されています。なぜboolではなく_Boolなのかといえば、既存のコードで独自にboolという名前を使っているものが多かったため、衝突を避けようとしたものと思います。その後、C99では特定のヘッダファイルstdbool.hをインクルードすれば_Boolのマクロとしてのboolが使えるようになり、C23では特に何も読まなくてもboolが使えるようになっています。

関係演算子

関係演算子の記述方法は、C言語と同じです。

演算子 演算内容
< より小さい
<= 以下
> より大きい
>= 以上
== 等価
!= 不等価

注意点として、これらの結果はbool型の値として得られます。

論理演算子

基本的な論理演算子の記述方法も、やはり、C言語と同じです。

演算子 演算内容
&& 論理積(短絡評価)
|| 論理和(短絡評価)
^ 排他的論理和
! 否定
let a: bool = true;
let b: bool = false;

let and = a && b;  // 論理AND
let or  = a || b;  // 論理OR
let not = !a;      // 論理NOT

文字型

文字型として、charがあります。シングルクォーテーションで囲った文字が値となり、Unicodeスカラ値 (U+XXXXXと表現される値) が紐づきます。

let a: char = 'a';
let b: char = 'あ';
let c: char = '🎍';
let d: char = '\u{1F97A}';  // 🥺 スカラ値で直接指定する記法

配列型

Rustでは、配列は以下のように定義します。

let a: [i32; 3] = [1, 2, 3];  // 各要素はi32型、長さ3の配列
let b = [1, 2, 3];            // 型注釈なし(型推論ですべてi32になる)

配列の要素は、インデックスでアクセスできます。

let a = [1, 2, 3];
println!("a[0] = {}", a[0]);
println!("a[1] = {}", a[1]);
println!("a[2] = {}", a[2]);

2次元配列の場合は、以下のようになります。

let a: [[i32; 3]; 2] = [[1, 2, 3], [4, 5, 6]];
println!("a[0][0] = {}", a[0][0]);
println!("a[0][1] = {}", a[0][1]);
println!("a[0][2] = {}", a[0][2]);
println!("a[1][0] = {}", a[1][0]);
println!("a[1][1] = {}", a[1][1]);
println!("a[1][2] = {}", a[1][2]);

さて、冒頭で示したように、C言語では配列の範囲外アクセスが起こり得ました。Rustでは、どうなるでしょうか。

1
2
3
4
fn main() {
    let a = [10, 20, 30];
    println!("{}", a[3]);
}

演習

上記のコードを写経して、コンパイルしてみましょう。

また、以下のように配列のインデックスが実行時に与えられる場合には何が起こるでしょうか。そのような場合は、コンパイル時にエラーを検出することは難しそうです。

use std::io;  // 標準入出力を扱うためのクレート、後ほど説明

fn main() {
    let a = [10, 20, 30];

    let mut input = String::new();  // 空の文字列を作成、String型については後ほど説明
    println!("インデックスを入力してください:");

    io::stdin()  // 標準入力を扱う
        .read_line(&mut input)  // 入力を読み込む
        .expect("入力エラー");

    let index: usize = input.trim().parse().expect("数値を入力してください");  // 数値に変換

    println!("a[{index}] = {}", a[index]);  // 配列のインデックスでアクセス
}

演習

上記のコードを写経して、コンパイル・実行してみましょう。

タプル型

複数の異なる型の値をまとめて扱うための型として、タプル型があります。

let a: (i32, f64, char) = (1, 2.0, 'a');
let b = (1, 2.0, 'a');

タプルの要素は、<変数名>.0<変数名>.1<変数名>.2のようにインデックスでアクセスできます。

let a = (1, 2.0, 'a');
println!("a.0 = {}", a.0);
println!("a.1 = {}", a.1);
println!("a.2 = {}", a.2);

関数

Rustでは、関数は以下のように定義します。

1
2
3
4
5
6
7
8
9
// 引数の型は x: i32, y: i32、戻り値の型は i32
fn mul(x: i32, y: i32) -> i32 {
    x * y  // 戻り値は x * y、セミコロンを付けてはダメなので注意!
}

fn main() {
    let result = mul(1, 2);
    println!("{}", result);
}

C言語とは異なり、プロトタイプ宣言は不要です。また、関数同士の順番も関係ありません。戻り値は、最後の式の結果となります。これに対しては、セミコロンを付けてはダメなので注意しましょう。戻り値を返したくない場合は、main同様、何も書かないようにします。その場合は、()を返します。

演習

3つのi32の値を受け取り、f64に変換して平均値を返す関数 average()を書き、それをmainから呼んで値を表示してみましょう。

こたえ
main.rs
1
2
3
4
5
6
7
8
fn average(a: i32, b: i32, c: i32) -> f64 {
    (a + b + c) as f64 / 3.0
}

fn main() {
    let result = average(5, 8, 10);
    println!("{}", result);
}

制御構文

続いて制御構文について見ていきましょう。こちらも、基本的に見た目はC言語とよく似ていますが、少しずつ違ったところがあります。

if

ifは、以下のように記述します。

1
2
3
4
5
6
7
fn main() {
    let x = 10;

    if x > 0 {
        println!("x is positive");
    }
}

ifの条件式は、論理値型の値を返す式である必要があります。また、条件式に括弧は不要です。

elseと組み合わせる場合は、以下のようになります。

fn main() {
    let x = 10;

    if x > 0 {
        println!("x is positive");
    } else if x < 0 {
        println!("x is negative");
    } else {
        println!("x is zero");
    }
}

Rustのifは式として値を返すことができます。たとえば、以下のように書くことができます。三項演算子のように使えます。

let y = if condition { A } else { B };

演習

整数x: i32を受け取り、その絶対値を返す関数abs()を、ifを式として使って書いてみましょう。それをmainから呼んで値を表示してみましょう。

こたえ
main.rs
1
2
3
4
5
6
7
8
9
fn abs(x: i32) -> i32 {
    if x >= 0 { x } else { -x }
}

fn main() {
    let x = 10;
    let y = abs(x);
    println!("y = {}", y);
}

while

whileは、以下のように記述します。こちらもC言語と見た目はよく似ていて、条件式に括弧は不要です。

while x > 0 {
    x -= 1;
}

for

Rustのforは、以下のように使います。

for i in 0..n {
    println!("i = {}", i);  // 0, 1, 2, ..., n-1
}
for i in 0..=n {
    println!("i = {}", i);  // 0, 1, 2, ..., n
}

ここで、iはこのブロック内だけで使えるもので、別途変数を定義する必要はありません。

なお、逆順に繰り返したい場合は、たとえば、以下のように書くことで実現できます。

for i in (0..n).rev() {
    println!("i = {}", i);  // n-1, n-2, ..., 0
}

配列の要素を順番に取り出したい場合は、以下のように書きます。

let a = [1, 2, 3];
for i in a {
    println!("i = {}", i);  // 1, 2, 3
}

演習

次の配列について、偶数の要素だけをforifを使って表示してみましょう。

let a = [10, 21, 32, 43, 54];

こたえ
fn main() {
    let a = [10, 21, 32, 43, 54];

    for i in a {
        if i % 2 == 0 {
            println!("i = {}", i);
        }
    }
}

構造体

Rustでも構造体をよく使います。C言語の場合とよく似ていますが、微妙に記法が違うので注意しましょう。

struct Point {
    x: i32,
    y: i32,
}

構造体の各フィールドにアクセスするには、.を使います。

struct Point {
    x: i32,
    y: i32,
}

fn main() {
    let p = Point { x: 3, y: 4 };

    println!("x = {}", p.x);
    println!("y = {}", p.y);
}

各フィールドの値はデフォルトで不変です。値を変更するには、インスタンス全体をmutにします。

struct Point {
    x: i32,
    y: i32,
}

fn main() {
    let mut p = Point { x: 1, y: 2 };

    p.x = 10;   // OK
    p.y = 20;   // OK

    println!("x = {}, y = {}", p.x, p.y);
}

演習

上記のコードを写経して、コンパイルしてみましょう。また、mutを外してコンパイルしてみましょう。

列挙型

列挙型も、C言語のenumとよく似ています。バリアントと呼ばれる個別の値のいずれかを取ることができます。

enum Direction {
    North,
    South,
    East,
    West,
}

fn main() {
    let d = Direction::North;
}

列挙型については、matchを使ってパターンマッチングをおこない、それぞれのバリアントに応じた処理を行うことができます。

enum Direction {
    North,
    South,
    East,
    West,
}

fn main() {
    let d = Direction::East;

    match d {
        Direction::North => println!("北"),
        Direction::South => println!("南"),
        Direction::East  => println!("東"),
        Direction::West  => println!("西"),
    }
}

Rustの列挙型が特徴的なのは、各バリアントが値を持つことができることです。たとえば、以下のように書くことができます。

enum Message {
    Quit,
    Move {x: i32, y: i32},
    Write(String),  // String型、後ほど説明
}

matchを使うことで、各バリアントに応じた値を用いながらの処理も可能です。

enum Message {
    Quit,
    Move {x: i32, y: i32},
    Write(String),
}

fn main() {
    let msg = Message::Move{x: 10, y: 20};

    // 以下のような場合はカンマは省略されることが多い
    match msg {
        Message::Quit => {
            println!("終了");
        }
        Message::Move {x, y} => {
            println!("移動: x = {}, y = {}", x, y);
        }
        Message::Write(text) => {
            println!("メッセージ: {}", text);
        }
    }
}

なお、構造体と列挙型に関連して、メソッドという重要な概念があります。Pythonを学んでいる方にはお馴染みかもしれません。これについては次回触れることにします。

所有権とは

さて、ここまでの内容で、Rustの基礎的な文法を眺めることが出来ました。ところで講義の冒頭では、

  1. 配列の範囲外アクセスによるバッファオーバーフロー
  2. use-after-free, ヒープ由来のダングリングポインタ
  3. use-after-scope, スタック由来のダングリングポインタ

といったバグの例を示していました。

メモリ周りのバグ事例

まずバッファオーバーフローについては、配列の説明に関連して述べたように、Rustでは厳格な型と静的解析によって、静的に判定可能な範囲外アクセスはコンパイル時にエラーとして検出できます。また、それが難しいものについても実行時に検出しpanicを起こすことで、メモリ破壊や不正な読み出しが実際に起こることを防いでいます。

それでは、2, 3のダングリングポインタのような事例はどのようなとき発生する可能性があり、どのように防ぐことができているのでしょうか。ここで、冒頭に述べた所有権借用ライフタイムといった概念が登場します。

詳細な説明に先立ってまず、不思議な現象を体験してみましょう。以下の2つのコードをコンパイル・実行してみましょう。

1
2
3
4
5
6
fn main() {
    let x1: i32 = 10;
    let x2 = x1;
    println!("x1 = {}", x1);
    println!("x2 = {}", x2);
}
1
2
3
4
5
6
fn main() {
    let s1 = String::from("hello");
    let s2 = s1;
    println!("s1 = {}", s1);
    println!("s2 = {}", s2);
}

ここで、String型は何度か利用した文字列のための型です。可変長の文字列をサポートしています。

演習

それぞれをコンパイル・実行してみましょう。

一見、どう見ても問題なさそうなコードですが、片方では見慣れないエラーに遭遇したと思います。一体この2つでは何が違うのでしょうか?

実は、String型は、ヒープ領域にメモリを確保します。C言語では、ヒープ領域にメモリを確保するにはmallocを使いました。Rustでは通常そのような操作をプログラマはおこないません。その代わり、ヒープ領域にあった方がよいものについては勝手に判断されそちらに配置されます。String型は可変長文字列をサポートするため、実行時にサイズを変更できるヒープ領域に置かれます。

以下は以前の講義で示したものです。

メモリ領域

可変長のデータや、複数の関数を跨いでやり取りするデータ、大きなサイズのデータなどについては、スタック領域ではなくヒープ領域に配置することで実現されていたのでした。Stringは実際には以下の模式図のように配置されています。

Stringのメモリ配置

先ほどのx1の例も含め、今回の講義で扱ってきた変数の多くはC言語のローカル変数の場合と同様に、スタック領域に配置されていました。その結果、特に問題なく動作していました。しかし、ヒープ領域に配置された変数については、きちんと管理しなければ上で示したuse-after-freeののようなバグに遭遇してしまいます。

そこで、所有権が導入されます。これは、以下のようなルールを持っています。

  • すべての値には所有者がいる
  • 所有者は常に1人だけである
  • 所有者がスコープを抜けると、その値は破棄 (drop) される

あるヒープ領域に責任を持つ唯一の所有者を定め、所有者以外はその領域を自由に使えないことにします。また、所有者がスコープを抜けると同時に値を破棄し、その結果としてヒープ領域を解放します。さらに、その解放以降にその領域への参照が存在しないことをコンパイル時に保証することで、use-after-freeのようなバグを未然に防ぐことができそうです。

所有権移動

先ほどのエラーは、s1が所有権をすでに失っていたために起きたものでした。所有権はs2へと移っていたのでs1は使えなくなってしまったのでした。一方、これまで問題が起きなかったスタック領域のみに格納される変数については、代入時に所有権が移動するのではなく、値がコピーされます。その結果、両者はそれぞれ独立した値として別のスタック領域に配置され、以降は互いに影響せずに利用することができます。

ただし、こんな風に単一の所有者だけがヒープ領域上のデータを使えるような制約を設けてしてしまうと、そのデータを複数の場所で便利に利用することができなくなってしまい非常に不便そうです。そこで、借用ライフタイムが導入されます。

先取りして大雑把に説明するとすれば、Rustは所有権によって、use-after-freeのようなメモリ安全性の問題をコンパイル時に防ぎます。さらに、借用やライフタイムという仕組みを組み合わせることで、所有権を移動させることなく、ルールの範囲内で安全にデータを参照し、複数の場所で利用できるようにしています。

こうしたことのより具体的な説明は次回に譲ることとします。

コラム: Rustによる並行プログラミング

Rustを活用した高速なソフトウェアが開発されている理由の一つに、並行プログラミングを安全におこなうための仕組みがそろっていることが挙げられます。近年のCPUは、一般的なデスクトップPCであっても16コアなど、多数のコアを持つようになっています。複数の部分を並行に実行可能なプログラムを書き、それを複数のコアで同時並列に実行すれば、高速化を図ることができそうです。特に単一のプログラムは、複数の、スレッド、と呼ばれる単位に分割され並列に実行されます。ただし、このような並行プログラミングには、それぞれの部分間でのデータ競合など厄介な問題がつきまといます。

Rustにおける所有権などの仕組みは、このような場合にも役立ち、安全に並行プログラミングを実施しやすくなっています。また、並行・並列プログラミング用のライブラリも充実しています。この講義では残念ながら扱いきれませんが、興味のある方はこちらをまず見てみると良いと思います。

レポート

本日はここまでです。宿題として以下をお願いします。

  1. Slackで配付した宿題リンクから、レポートを提出してください。
    • リンクは公開しないでください
    • 締切は今回は次の授業前日の夜23:59までです(今回の場合、2026/1/14, 23:59)
    • リポジトリを更新するかたちで提出してください
      • リポジトリのトップ階層、またはsrcディレクトリ以下など、わかりやすい場所に作成したコードを配置してください
      • 自動採点的な機能はありません
    • レポートの内容は以下になります

week5_1

Rustでプログラムを実装してみましょう。ここではグラフ上の全点対最短経路問題を解くワーシャル–フロイド法(Warshall–Floyd algorithm)を実装してみましょう。

たとえば、あるアプリケーションには複数の画面が存在し、特定の画面間には直接遷移できるリンクがあるとします。

  • 各画面を 頂点
  • 画面間の遷移を
  • 遷移にかかるコストを 1

とみなすことで、この構造は有向グラフとして表現できます。

アプリ改善のため、すべての画面ペア(i, j)について、

  • 画面iから画面jへ、最短で何ステップで到達できるか

を求めたいときに、このような問題設定はグラフ上の全点対最短経路問題として表現できます。

以下のようなコードを埋めて、想定される出力結果を得られるものにしてみましょう。

main.rs
const N: usize = 4;
const INF: i32 = 1000000;

fn warshall_floyd(mut dist: [[i32; N]; N]) -> [[i32; N]; N] { 
    // ここに実装を書く
} 

fn main() { 
    // 初期の距離行列 
    // dist[i][j] = 0: 同じ画面同士
    // dist[i][j] = 1: 直接遷移できる
    // dist[i][j] = INF: 直接は遷移できない
    let dist: [[i32; N]; N] = [ 
        [0, 1, INF, INF], 
        [INF, 0, 1, INF], 
        [INF, INF, 0, 1], 
        [1, INF, INF, 0],
    ];

    let result = warshall_floyd(dist); 

    // 出力結果表示のための実装を書く
    // ヒント: println!()のほかに、print!()というものもあります。

}

想定される出力結果は以下のようになります。

0 1 2 3
3 0 1 2
2 3 0 1
1 2 3 0