Skip to content

第6回 Rust言語2、計算モデル

本日の講義内容

  • 借用・ライフタイムとは
  • Rustの基本の続き
  • 計算モデル

借用・ライフタイムとは

前回の講義では、Rustの基本的な文法と機能を見てきました。また、最後に所有権について簡単に解説しました。今回はまず、所有権についておさらいしつつ、借用ライフタイムといった概念を見ていきましょう。

借用

さて、前回見たように、Rustでは所有権というものが存在し、以下のようなルールが存在するのでした。

  • すべての値には所有者がいる
  • 所有者は常に1人だけである
  • 所有者がスコープを抜けると、その値は破棄 (drop) される
1
2
3
4
5
6
fn main() {
    let s1 = String::from("hello");
    let s2 = s1;
    println!("s1 = {}", s1);
    println!("s2 = {}", s2);
}
1
2
3
4
5
6
fn main() {
    let x1: i32 = 10;
    let x2 = x1;
    println!("x1 = {}", x1);
    println!("x2 = {}", x2);
}

たとえば、ヒープ上に領域を確保するString型の変数s1s2に代入されたとき、s1の所有権はs2に移動し、s1は以降使えなくなります。こうした挙動をムーブとも呼びます。

一方、前回説明したようなシンプルな型 (整数型、浮動小数点数型、論理値型、文字型、固定長の配列型) は、ヒープ上にデータを持つことはなく、他の何かに対して責任を持つことはありません。こうした型では、値はシンプルにコピーされます。たとえば、整数型の変数x1x2に代入されたとき、x1の所有権はx2に移動しません。x1の値がコピーされ、コピーされたそれぞれの値をそれぞれ独立して使うことができます。

所有権

なお、このような型による挙動の違いは、後ほど説明するトレイトによって実現されています。

しかし、変数の所有権を移すことなく、データを参照したい場面は多々ありそうです。そこで、借用という概念が導入されます。&を用いて以下のように参照することで、所有権をムーブせずに値を利用することができます。ここで、&StringStringの参照型を表しています。この辺りの表記や使い方は、C言語のポインタと似ています。*を用いて参照先の値へアクセスすることができます。

1
2
3
4
5
6
7
fn main() {
    let s1 = String::from("hello");
    let s2: &String = &s1;  // 参照を作る(借用)

    println!("s1 = {}", s1);
    println!("s2 = {}", *s2);
}

演習

  • 上記のプログラムを写経し、コンパイル・実行してみましょう。
  • また、*s2の値を変更しようとすると何が起こるでしょう?
  • 続いて、*s2s2と書き換えて、コンパイル・実行してみましょう。

このような&を用いた参照は、不変参照と呼ばれます。不変参照は、参照先の値を変更することはできません。値を変更したい場合は、可変参照を用います。可変参照には、&mutを用います。注意点として、変数はデフォルトで不変なので、書き換えのためにはまず変数宣言時にmutを付ける必要があります。

演習

上記のプログラムを修正し、&mutを用いて可変参照を作成することで、*s2経由でs1の値を別の文字列に変更し、表示してみましょう。

こたえ
fn main() {
    let mut s1 = String::from("hello");
    let s2: &mut String = &mut s1;  // 型推論しない場合はこのように記述する必要がある
    *s2 = String::from("world");

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

参照には、以下のような制約があります。ある値について同時に存在できるのは、

  • 複数の不変参照
  • 単一の可変参照

のみです。不変参照と可変参照の両方を持つことはできません。こうした制約によって、複数参照間のデータ競合を防ぐことができます。読むだけなら複数あっても問題ないので不変参照は複数同時に存在できますが、書き込みするものは1つまでしか存在できません。

不変参照

可変参照

関数の引数についても、参照を受け取ることができます。この場合も、所有権は関数に移動しません。

1
2
3
4
5
6
7
8
fn print_str(s: &String) {
    println!("{}", *s);
}

fn main() {
    let s = String::from("hello");
    print_str(&s);
}

演習

上記のプログラムを写経し、コンパイル・実行してみましょう。

なお、構造体についても、参照を介してデータを操作することができます。そうした演習問題に取り組んでみましょう。

演習

マンハッタン距離を求める関数を作成してみましょう。2次元平面上の点を表す次のPoint構造体が与えられるとします。

struct Point {
    x: i32,
    y: i32,
}
  • 原点からのマンハッタン距離を求める関数manhattan_from_origin()を実装してみましょう。
  • 2点間のマンハッタン距離を求める関数manhattan_between_points()を実装してみましょう。
  • translate()関数を実装してみましょう。この関数は、点とdx: i32, dy: i32を引数に取り、指定された距離だけ点を移動する関数です。
こたえ
main.rs
struct Point {
    x: i32,
    y: i32,
}

fn manhattan_from_origin(p: &Point) -> i32 {
    let mut dx = p.x;
    let mut dy = p.y;

    if dx < 0 {
        dx = -dx;
    }
    if dy < 0 {
        dy = -dy;
    }

    dx + dy
}

fn manhattan_between(p1: &Point, p2: &Point) -> i32 {
    let mut dx = p1.x - p2.x;
    let mut dy = p1.y - p2.y;

    if dx < 0 {
        dx = -dx;
    }
    if dy < 0 {
        dy = -dy;
    }

    dx + dy
}

fn translate(p: &mut Point, dx: i32, dy: i32) {
    p.x += dx;
    p.y += dy;
}

fn main() {
    let mut a = Point { x: 1, y: 2 };
    let b = Point { x: 4, y: -1 };

    let d0 = manhattan_from_origin(&a);
    println!("a from origin: {}", d0);

    let d0 = manhattan_from_origin(&b);
    println!("b from origin: {}", d0);

    let d1 = manhattan_between(&a, &b);
    println!("before move: {}", d1);

    translate(&mut a, 3, -2);

    let d2 = manhattan_between(&a, &b);
    println!("after move: {}", d2);

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

ライフタイム

さて、ここまでで所有権と借用について説明してきました。これにより、use-after-freeのようなメモリ安全性の問題を防ぎつつ、データを複数の場所で安全に利用することができるようになりました。果たしてこれで十分でしょうか?

たとえば、前回示したuse-after-scopeのような例についてはどうでしょう。

use-after-scope

1
2
3
4
5
6
7
8
9
fn func() -> &i32 {
    let x = 42;
    &x  // スタック領域の変数のアドレスを返してしまっている
} 

fn main() {
    let p = func();  // ダングリング (宙ぶらりんな) 参照?
    println!("{}", *p);  // NG: use-after-scope?
}

演習

上記のプログラムを写経し、コンパイルしてみましょう。

Rustでは、参照先の変数や参照の生存期間についても考慮されています。これをライフタイムと呼びます。これらを考慮することによって、参照先の寿命が尽きたあとに参照を利用しようとしている上記のようなケースをコンパイルエラーとして検出することができます。所有権によって、解放の責任やタイミングを定め、借用の仕組みによって競合を防ぎつつ同時に値を使えるようにし、ライフタイムによって時間的な制約も守ることができます。

なお、Rustでは、こうしたライフタイムを非レキシカル (non-lexical)に求めています。どういうことかと言うと、単なるスコープだけでなく、どこまで参照が利用されているかということをしっかりと考慮して、参照の生存期間を正確に判定しています。難易度は高いですが、詳細についてはこちらにまとまっています。

コラム: Rustの安全性の証明

ここまで、Rustの実現するメモリ安全性について、いくつかのケースのみを挙げて説明してきました。しかし、こうした安全性は本当にあらゆるケースで成り立っているのでしょうか?

Rustの安全性について形式的な証明を与える試みとして、RustBeltのようなプロジェクトが存在します。たとえばプロジェクトと同名の論文では、Rustの中心的な仕組みが本当にメモリ安全であることの証明に取り組んでいます。興味の湧いた方は、論文や、プロジェクトページを眺めてみるとよいでしょう。また、証明の基盤となっているRocqIrisについても調べてみると面白いかもしれません。

Rustの基本

以降は前回に引き続いて、Rustの基本的な文法や機能について、残りを見ていきます。

メソッド

メソッドは、関数と似ていますが、構造体や列挙型と一緒に定義されるものです。Pythonのクラスなどとは異なり、Rustでは対応する構造体の定義とは離れたところで、implブロックを用いて定義されます。

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

impl Point {
    fn distance_from_origin(&self) -> f64 {
        ((self.x.pow(2) + self.y.pow(2)) as f64).sqrt()
    }
}

fn main() {
    let p = Point { x: 3, y: 4 };
    println!("distance from origin: {}", p.distance_from_origin());
}

ここで、第一引数の&selfは、インスタンスの参照を意味します。可変参照を取る場合は、&mut selfと書きます。selfとすることもあります。

メソッドは.を使って呼び出します。インスタンスに対して操作を加えるものや、インスタンスを主語として考えられるような振る舞いについては、メソッドとして定義すると見通しが良いと思います。

上記では、pow()sqrt()を使っています。これらは、i32f64型に付属しているメソッドで便利に使うことができます。それぞれで利用可能なメソッドについては、i32のドキュメントや、f64のドキュメントを参照してみるとよいでしょう。

演習

  • 上記のプログラムを写経し、コンパイルしてみましょう。
  • dx: i32, dy: i32を引数に取り、指定された距離だけ座標を移動するメソッドshift()を追加し、移動後の座標を表示してみましょう。
こたえ
struct Point {
    x: i32,
    y: i32,
}

impl Point {
    fn distance_from_origin(&self) -> f64 {
        ((self.x.pow(2) + self.y.pow(2)) as f64).sqrt()
    }
    fn shift(&mut self, dx: i32, dy: i32) {
        self.x += dx;
        self.y += dy;
    }
}

fn main() {
    let mut p = Point { x: 3, y: 4 };
    println!("distance from origin: {}", p.distance_from_origin());
    p.shift(1, 2);
    println!("distance from origin: {}", p.distance_from_origin());
}

また、引数にself&self&mut selfを取らない関連関数もimplブロック内で定義できます。コンストラクタのような初期化関数などに使われます。

impl Point {
    fn new(x: i32, y: i32) -> Point {
        Point { x, y }
    }
}

このように定義した関連関数は、Point::new()のように呼び出すことができます。

1
2
3
4
fn main() {
    let p = Point::new(3, 4);
    println!("distance from origin: {}", p.distance_from_origin());
}

演習

  • 先ほどのコードを修正し、Point::new()を使ってインスタンスを作成するようにしてみましょう
こたえ
struct Point {
    x: i32,
    y: i32,
}

impl Point {
    fn new(x: i32, y: i32) -> Point {
        Point { x, y }
    }
    fn distance_from_origin(&self) -> f64 {
        ((self.x.pow(2) + self.y.pow(2)) as f64).sqrt()
    }
    fn shift(&mut self, dx: i32, dy: i32) {
        self.x += dx;
        self.y += dy;
    }
}

fn main() {
    let mut p = Point::new(3, 4);
    println!("distance from origin: {}", p.distance_from_origin());
    p.shift(1, 2);
    println!("distance from origin: {}", p.distance_from_origin());
}

トレイト

トレイトは、型が持つべき共通のメソッドを定義する仕組みです。ソフトウェアを作っていると、異なる型に対して共通の振る舞いを持たせたくなることがあると思います。トレイトでは複数の型に対して共通のメソッドを提供することができます。他の言語で継承と呼ばれるような仕組みを使ったことのある方は、違いについて考察してみるとよいと思います。

たとえば、次のように、Shapeトレイトを定義します。

trait Shape {
    fn area(&self) -> f64;
}

ここで、area()は、形状の面積を返すメソッドです。このように、Shapeトレイトを実装した型は、area()メソッドを持つことになります。

それぞれの形状に対して、Shapeトレイトを実装し、area()メソッドを実装します。たとえば長方形に対しては、次のように実装します。

struct Rectangle {
    width: f64,
    height: f64,
}

impl Shape for Rectangle {
    fn area(&self) -> f64 {
        self.width * self.height
    }
}

このように、impl トレイト名 for 型名のように記述し、その内部でトレイトに定義されたメソッドを実装します。

演習

  • 上記のコードに面積を表示するmain関数を追記し、コンパイルしてみましょう。
  • 三角形や円形に対してもShapeトレイトを実装し、area()メソッドを実装してみましょう。
    • ヒント: 円周率はstd::f64::consts::PIで取得できます。
struct Triangle {
    base: f64,
    height: f64,
}
struct Circle {
    radius: f64,
}
こたえ
main.rs
// トレイト
trait Shape {
    fn area(&self) -> f64;
}

// 長方形
struct Rectangle {
    width: f64,
    height: f64,
}

impl Shape for Rectangle {
    fn area(&self) -> f64 {
        self.width * self.height
    }
}

// 三角形
struct Triangle {
    base: f64,
    height: f64,
}

impl Shape for Triangle {
    fn area(&self) -> f64 {
        self.base * self.height / 2.0
    }
}

// 円形
struct Circle {
    radius: f64,
}

impl Shape for Circle {
    fn area(&self) -> f64 {
        std::f64::consts::PI * self.radius * self.radius
    }
}

fn main() {
    // 各図形を作る
    let rectangle = Rectangle {
        width: 4.0,
        height: 3.0,
    };

    let triangle = Triangle {
        base: 5.0,
        height: 2.0,
    };

    let circle = Circle {
        radius: 1.5,
    };

    // 面積を表示
    println!("Rectangle area = {}", rectangle.area());
    println!("Triangle area  = {}", triangle.area());
    println!("Circle area    = {}", circle.area());
}

よく使われるトレイトについては、deriveを使って自動で実装することができます。たとえば構造体の中身を表示したい場合、Debugトレイトを自動で実装し、println!("{:?}", self)とすることで、構造体の中身を表示することができます。

#[derive(Debug)]
struct Rectangle {
    width: f64,
    height: f64,
}

fn main() {
    let rectangle = Rectangle {
        width: 4.0,
        height: 3.0,
    };
    println!("{:?}", rectangle);
}

ジェネリクス

ジェネリクスは、型を抽象化するため仕組みです。ソフトウェアを作っていると、処理の中身は同じで型が違う、というものを複数定義したい場面も出てくると思います。ジェネリクスによってそうしたものをまとめて安全に扱えるようにできます。

たとえば、構造体Rectangleが整数型の幅と高さを持つ場合と、浮動小数点数型の幅と高さを持つ場合を考えてみましょう。

struct Rectangle<T> {
    width: T,
    height: T,
}

このように、<T>を使ってジェネリックな型を定義することで、型を抽象化することができます。

struct Rectangle<T> {
    width: T,
    height: T,
}

fn main() {
    let int_rect = Rectangle {
        width: 5,
        height: 10,
    };

    let float_rect = Rectangle {
        width: 1.5,
        height: 4.0,
    };
}

メソッドについてもたとえば、area()で、整数も浮動小数点数も扱う方法を考えてみましょう。

impl<T: Mul<Output = T> + Copy> Rectangle<T> {
    fn area(&self) -> T {
        self.width * self.height
    }
}

ここで、T: Mul<Output = T> + Copyは、TMulトレイトとCopyトレイトを実装していることを意味します。すなわち、掛け算できてコピーできる型でなければなりません。このように、特定のトレイトを実装していることを制約として表すことができます。トレイト制約 (trait bounds)と呼ばれます。

演習

  • ジェネリック型のRectangleに対して面積を計算するメソッドを付け、コンパイル・実行してみましょう。
    • 整数型の幅と高さを持つ長方形と、浮動小数点数型の幅と高さを持つ長方形の面積を計算してみましょう。
    • ヒント: Mulトレイトを用いるには、冒頭で、use std::ops::Mulとしてインポートする必要があります。
こたえ
main.rs
use std::ops::Mul;

// ジェネリックなRectangle
struct Rectangle<T> {
    width: T,
    height: T,
}

// Tが掛け算できて、結果がT、かつCopy可能なときだけarea()を定義
impl<T: Mul<Output = T> + Copy> Rectangle<T> {
    fn area(&self) -> T {
        self.width * self.height
    }
}

fn main() {
    // 整数型のRectangle
    let int_rect = Rectangle {
        width: 5,
        height: 10,
    };

    // 浮動小数点数型のRectangle
    let float_rect = Rectangle {
        width: 1.5,
        height: 4.0,
    };

    // 面積を計算して表示
    println!("int rectangle area   = {}", int_rect.area());
    println!("float rectangle area = {}", float_rect.area());
}

Option

標準ライブラリでジェネリクスを利用した型として、Optionを紹介しておきます。Optionは、値が存在するかどうかを表す型です。SomeNoneの2つの値を取ります。値があるかもしれない関数の戻り値などによく利用されます。

enum Option<T> {
    Some(T),
    None,
}
// 配列の中から最初の3の倍数を探す関数
fn find_num(nums: &[i32]) -> Option<i32> {
    for n in nums {
        if n % 3 == 0 {
            return Some(*n);
        }
    }
    None
}

fn main() {
    let nums = [1, 4, 5, 7, 9];

    match find_num(&nums) {
        Some(n) => println!("found: {}", n),
        None => println!("not found"),
    }
}

演習

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

Result

同様に、Resultという型もあります。これは、成功か失敗かを表す型です。失敗する可能性がある関数の戻り値に利用されます。

enum Result<T, E> {
    Ok(T),
    Err(E),
}
// 配列の中から最初の3の倍数を探す関数
fn find_num(nums: &[i32]) -> Result<i32, &'static str> {
    for n in nums {
        if n % 3 == 0 {
            return Ok(*n);
        }
    }
    Err("not found")
}

fn main() {
    let nums = [1, 4, 5, 7, 9];

    match find_num(&nums) {
        Ok(n) => println!("found: {}", n),
        Err(e) => println!("error: {}", e),
    }
}

これによってエラーメッセージを表示できます。

演習

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

マクロ

マクロは、コードを生成するためのメタプログラミングの機能です。Rustのマクロは非常に強力で様々な機能を実現できますが、ひとまずここではよく利用される標準ライブラリのマクロをいくつかごく簡単に紹介しておきます。

println!

println!は、これまで何度も使ってきた標準出力のためのマクロで、

println!("Hello, world!");
のように使うことができます。

print!

print!は、println!と似ていますが、改行を付けずに出力します。

print!("Hello, world!");

vec!

vec!は、Vec<T>型のベクタを作成するためのマクロで、vec![1, 2, 3]のように使うことができます。

let v = vec![1, 2, 3];

Vec<T>型は、実行時に長さを変更できる可変長配列を表す型であり、 要素はヒープ領域に格納されます。

assert!

assert!は、条件式がfalseの場合にプログラムを強制的に終了させるマクロで、assert!(条件式)のように使います。

assert!(condition == true);

プログラムが想定通りに動作しているか検査するためによく利用されます。

panic!

panic!は、プログラムを強制的に終了させるマクロで、panic!()のように使います。正常でない状態になった場合に明示的に異常終了させるため利用されます。

その他のマクロや、マクロの詳細については、The Rust Programming LanguageThe Rust Referenceなどを見てみるとよいと思います。

コレクション

Rustでは、標準ライブラリに基本的なデータ構造がコレクションとしてまとめられています。

ベクタ

Vec<T>は、実行時に長さを変更できる可変長配列です。前述したvec!マクロを用いて簡単に作成することができます。コレクションの中でも、使用頻度の高いものになると思います。

fn max_value(v: &Vec<i32>) -> Option<i32> {
    if v.is_empty() {
        return None;
    }

    let mut max = v[0];
    for &x in v.iter() {  // イテレータを使って要素を順に取り出す
        if x > max {
            max = x;
        }
    }
    Some(max)
}

fn main() {
    let nums = vec![3, 1, 5, 2];

    match max_value(&nums) {
        Some(m) => println!("max value = {}", m),
        None => println!("vector is empty"),
    }
}

演習

  • 上記のコードを写経し、コンパイル・実行してみましょう。空のベクタの場合はどうなるでしょうか。

線形リスト

LinkedList<T>は、線形リストを表すコレクションです。以前の講義でも触れましたが、線形リストは「データ」と「参照」をセットにした要素によって構成されるデータ構造となっています。リストの分割や結合などの操作を容易におこなうことができます。

たとえば、以前の講義で示したサンプルコードに近いものとしては以下のようになります。

use std::collections::LinkedList;  // これが必要
use std::io::{self, Read};

fn main() {
    let mut input = String::new();
    io::stdin().read_to_string(&mut input).unwrap();

    let mut list: LinkedList<String> = LinkedList::new();

    // push_front に対応
    for line in input.lines() {
        list.push_front(line.to_string());
    }

    // 先頭から順に出力
    for s in &list {
        println!("{}", s);
    }
}

演習

上記のソースコードmain.rsを以下のように、コンパイルして実行してみましょう。temp.txtには数行のテキストを書いておきましょう。

$ cargo run < temp.txt

イテレータ

すでに何度か登場しましたが、イテレータは、コレクションの要素を効率的に処理するためのものです。

let v = vec![1, 2, 3];
for n in v.iter() {
    println!("n = {}", n);
}

イテレータはIteratorトレイトとして実現されており、様々なメソッドが用意されています。たとえば、mapメソッドを使うことで、要素を変換することができます。filterメソッドでは、要素をフィルタリングすることができます。また、collectメソッドを使うことで、イテレータの結果をコレクションに収集します。以下のようなイメージで複数要素への操作を記述することができます。

let v = vec![500, 250, 300, 400];
let v2 = v.iter()  
    .map(|n| n * 3)  // 3倍にする
    .filter(|n| n >= 1000)  // 1000以上のみにする
    .collect::<Vec<i32>>();  // 結果をベクタに収集

イテレータで可能な操作は、Iteratorにまとまっています。

なお、コレクションにはその他にも、VecDequeHashMapBTreeMapHashSetBTreeSetBinaryHeapといった便利なデータ構造が用意されています。

コラム: Rustはどこから来たのか

Rustの初期の開発にはMozillaが中心的に関わっており、2015年にRust 1.0のリリースがおこなわれました。現在広く用いられているプログラミング言語の中では、RustはC/C++と似ているほか、OCamlやHaskellといった関数型言語からも取り入れている要素があります。また、初期の開発者によれば、NIL, Hermes, Erlang, Sather, Newsqueak, Alef, Limbo, Napier88など、他にも様々な言語から影響を受けて設計されているとのことです。プログラミング言語は無数に存在し影響を与え合っているので、それぞれの設計について調べてみると発見があると思います。

学術的な文献としては、Ownership typeや、安全なシステムプログラミング言語を目指したCycloneVaultといったものが先行する例として存在します。また、Rustの所有権モデルは、線形型システムやアフィン型システムと呼ばれる型システムと密接な関係があります。こうした型システムの理論的な側面について知りたい方には、Types and Programming Languagesや、Advanced Topics in Types and Programming Languagesといったテキストが参考になります。後者には、線形型システムに関する詳細な議論も掲載されています。

クレート・モジュール

crate

Rustには、クレートという概念がありました。クレートは、コンパイルの単位になります。そのクレートの中に、1つ以上のモジュールが存在します。デフォルトでmain.rsという名前になるようなルートモジュールからなるバイナリクレートが、これまで皆さんが作ってきたものになります。一方、cargo new --libとすることで、ライブラリクレートを作成することができます。この場合は、lib.rsという名前のルートモジュールからなるライブラリクレートが作成されます。

クレートは複数のモジュールからなります。基本的にソースファイル単位がモジュールになります。たとえば、

  • main.rs
  • mod_a.rs
  • mod_b.rs

src以下の同一階層に存在するとき、図のような階層構造のモジュールに分割されます。ここで、main.rsに、

mod mod_a;
mod mod_b;

のような記述をするとそれぞれが見えるようになり、mod_a.rsmod_b.rsでは、外部に公開する要素をpubキーワードで指定します。

pub fn func() {}

すると、main.rsからmod_a::func()mod_b::func()を呼び出すことができます。pubキーワードを付けない関数や構造体は、デフォルトでprivateとなり、そのモジュール内でしか使えないものになります。

これまで何度か、useキーワードを使ってきました。これは外部のクレートやモジュールの要素を呼び出しやすくするためのものでした。基本的な型やトレイトはstd::preludeというデフォルトで読み込まれるものに含まれているため、useを書かなくても利用できていたのでした。

たとえば、同一クレート内では、main.rs

mod mod_a;
use crate::mod_a::func;

のような記述をすると、mod_aモジュール内のfunc関数をそのままの名前で呼び出すことができます。

より詳細な仕様については、こちらなどが参考になると思います。

crate.io

Rustではcrate.ioという公式のサイトでライブラリクレートを公開・共有することができます。また、Cargoを利用してここに公開されているライブラリクレートを簡単に利用することが可能です。

前回の講義でも少し触れましたが、Cargo.tomlに依存関係を記述することで利用することができます。試しにclapserdeというクレートを利用してみましょう。

  • clap
    • コマンドライン引数のパースを行うためのクレート
  • serde
    • YAMLのようなファイルのシリアライズ/デシリアライズをおこなうためのクレート

YAMLは、構造化されたデータを表現できるフォーマットのひとつで、設定ファイルなどによく使われます。clapを使ってこれをコマンドラインからそれっぽく読み込み、serdeを使ってそうしたデータを構造体に読み込むことができます。

まず、Cargo.tomlに依存関係を記述します。

[dependencies]
clap = { version = "4.5.54", features = ["derive"] }
serde = { version = "1.0.228", features = ["derive"] }
serde_yaml = "0.9.34"

演習

YAML設定ファイルを読み込んでみましょう。下のようなconfig.yamlmain.rsを記述し、コンパイル・実行してみましょう。 実行コマンドは、以下のようにしてみてください。

$ cargo run -- --help
$ cargo run -- --config config.yaml
config.yaml
1
2
3
4
title: App
window_width: 800
window_height: 600
fullscreen: false
main.rs
use clap::Parser;
use serde::Deserialize;
use std::fs;


// コマンドライン引数
#[derive(Parser, Debug)]
#[command(author, version)]
struct Args {
    /// 設定ファイル(YAML)
    #[arg(short, long, value_name = "FILE")]
    config: String,

    /// フルスクリーン表示にする(設定を上書き)
    #[arg(short, long)]
    fullscreen: bool,
}

// 設定ファイル用構造体
#[derive(Debug, Deserialize)]
struct Config {
    title: String,
    window_width: u32,
    window_height: u32,
    fullscreen: bool,
}

fn main() {
    // CLI 引数を読む
    let args = Args::parse();

    // 設定ファイルを読み込む
    let content = fs::read_to_string(&args.config)
        .expect("failed to read config file");

    let mut config: Config = serde_yaml::from_str(&content)
        .expect("failed to parse yaml");

    if args.fullscreen {
        config.fullscreen = true;
    }

    // 読み込んだ設定を表示
    println!("=== Config loaded ===");
    println!("title          : {}", config.title);
    println!("window_width   : {}", config.window_width);
    println!("window_height  : {}", config.window_height);
    println!("fullscreen     : {}", config.fullscreen);
}

また、GUIを実現するクレートとして、egui (eframe)といったものがあります。WSL2など、ローカル環境で試している方は、こうしたものを利用して、先ほどの設定ファイルを元にGUIアプリを起動できるかもしれません。

その場合、Cargo.tomlに再度依存関係を記述します。追加でeframeを利用します。

[dependencies]
clap = { version = "4.5.54", features = ["derive"] }
serde = { version = "1.0.228", features = ["derive"] }
serde_yaml = "0.9.34"
eframe = "0.33.3"
参考

Cargoを用いて、eguiを利用してGUIアプリを起動してみましょう。下のようなコードをmain.rsに記述し、実行コマンドは以下のようにしてみてください。

# 環境構築
$ sudo apt-get install libxkbcommon-x11-0 xkb-data mesa-utils libegl1
$ sudo apt-get install libxcb-render0-dev libxcb-shape0-dev libxcb-xfixes0-dev libxkbcommon-dev libssl-dev
# 実行
$ cargo run -- --config config.yaml
main.rs
use clap::Parser;
use serde::Deserialize;
use std::fs;
use eframe::egui;

// コマンドライン引数
#[derive(Parser, Debug)]
#[command(author, version)]
struct Args {
    /// 設定ファイル(YAML)
    #[arg(short, long, value_name = "FILE")]
    config: String,

    /// フルスクリーン表示にする(設定を上書き)
    #[arg(short, long)]
    fullscreen: bool,
}

// 設定ファイル用構造体
#[derive(Debug, Deserialize)]
struct Config {
    title: String,
    window_width: u32,
    window_height: u32,
    fullscreen: bool,
}

struct MyApp {
    config: Config,
}

impl MyApp {
    fn new(config: Config) -> Self {
        Self { config }
    }
}

impl eframe::App for MyApp {
    fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
        egui::CentralPanel::default().show(ctx, |ui| {
            ui.heading(&self.config.title);
            ui.separator();

            ui.label(format!(
                "Window size: {} x {}",
                self.config.window_width,
                self.config.window_height
            ));
            ui.label(format!(
                "Fullscreen: {}",
                self.config.fullscreen
            ));
        });
    }
}

fn main() -> eframe::Result<()> {
    // CLI 引数を読む
    let args = Args::parse();

    // 設定ファイルを読み込む
    let content = fs::read_to_string(&args.config)
        .expect("failed to read config file");

    let mut config: Config = serde_yaml::from_str(&content)
        .expect("failed to parse yaml");

    if args.fullscreen {
        config.fullscreen = true;
    }

    // 読み込んだ設定を表示
    println!("=== Config loaded ===");
    println!("title          : {}", config.title);
    println!("window_width   : {}", config.window_width);
    println!("window_height  : {}", config.window_height);
    println!("fullscreen     : {}", config.fullscreen);

    // egui の設定
    let mut options = eframe::NativeOptions::default();
    options.viewport.inner_size = Some(egui::vec2(
        config.window_width as f32,
        config.window_height as f32,
    ));
    options.viewport.fullscreen = Some(config.fullscreen);

    let title = config.title.clone();

    // GUI 起動
    eframe::run_native(
        &title,
        options,
        Box::new(|_cc| Ok(Box::new(MyApp::new(config)))),
    )

}

リンタ・フォーマッタ

モダンなソフトウェア開発では、リンタフォーマッタといったツールを利用します。リンタは、ソースコードを解析し、問題となりそうな点を検出してくれます。フォーマッタは、ソースコードを統一された書式に整形してくれるツールです。こちらは基本的に見た目を改善してくれます。こうしたツールを利用することで、コードの品質を向上させることができます。Rustでは、こうしたツールをCargoを介して簡単に利用することができます。

clippyは、コードを静的に解析するためのリンタで、以下のように実行します。

$ cargo clippy

rustfmtは、コードを整形するためのフォーマッタで、以下のように実行します。

$ cargo fmt

参考資料

前回と今回の講義で、Rustの文法のうち基本的なものを見てきました。C言語と比べると、残念ながら触れられなかった話題もそれなりに残っていると思います。もう少し深堀りしたい方向けの包括的なガイドとして、以下のようなものがWeb上に公開されています。

The Rust Programming Language

Comprehensive Rust

また、手を動かしながら学べる教材として、以下のようなものがあります。

計算モデル

ここからは、少し計算モデルというものについて見ていきます。これは計算や計算機の動作を抽象的に捉えたものです。これまでこの講義では、ソフトウェアの設計や、アルゴリズムについて学んできました。ここで、計算や計算機といったものについて改めて考えてみましょう。

有限オートマトン

有限オートマトン

上のような状態遷移図で表現したものを有限オートマトン (Finite Automaton)と呼びます。ディジタル回路/論理回路に関連する講義では、こうしたものを論理回路設計のための抽象化の技法として学ぶと思います。

チューリングマシン

もう少し実際的な計算モデルとして、チューリングマシン (Turing Machine)があります。これは、無限長のテープと、ヘッドというポインタを持つ計算機です。ヘッドはテープを読み書きすることができ、テープ上のデータを読み取ったり、書き込んだりすることができます。ヘッドはテープ上を移動することができ、テープ上のデータを読み取ったり、書き込んだりすることができます。なんとなく我々の知っている計算機ハードウェアに似ています。

こうしたチューリングマシンで実行可能な手続きをアルゴリズムと呼ぶのでした。これまで、この講義ではアルゴリズムをプログラミング言語を用いてプログラムにし、計算機に実行させてきました。そのため、アルゴリズムとは計算機で実行可能な手続きのことである、と言われてみてもほとんど違和感を感じないかもしれませんが、こうしたことは考えてみるとちょっと不思議です。

model

有限オートマトンをチューリングマシンと似たかたちで表現してみると、有限長のテープを一方向に動いていく、また、読み取りだけで書き込みはしないようなものになりそうです。ここから予想出来る通り、チューリングマシンは有限オートマトンよりも高い計算能力を持ったモデルになっています。実際、チューリングマシンは物理的に実現可能な計算モデルの中で最も強力なものであると考えられています。

ラムダ計算

参考として、ラムダ計算についても述べておきます。関数の抽象と適用からなる式を、簡約規則によって書き換えていくもので、これも同様に計算モデルです。なんとなく我々の知っているプログラミング言語に似ています。たとえば、今回の講義では取り上げませんでしたが、Rustのクロージャ (無名関数) はラムダ計算と少し似ています。

ラムダ計算の計算能力はチューリングマシンと同等であることが知られています。見た目は全然違いますが、ラムダ計算で記述できる計算はすべてチューリングマシンで実行可能であり、逆にチューリングマシンで実行できる計算はすべてラムダ計算で表現できることを意味します。こうした計算能力をチューリング完全 (Turing-complete) と呼ぶのでした。

現実の計算機

現実の計算機

計算モデルは以上のようにシンプルに定義されます。一方、現実の計算機には人間の認知や物理由来の様々な制約が存在し、その結果として非常に複雑なものになっています。たとえば、ソフトウェアの設計にはこれまでの講義で見てきたようなプログラミング言語が用いられています。人間が把握しやすく安全に設計できるよう抽象化がなされたものになっています。ハードウェアについても、非常に複雑な状態機械と階層化されたメモリやストレージを組合せたものが用いられます。現実の演算装置や記憶装置の動作速度、容量は有限であることなどが理由です。そうした前提の下で所望の計算を効率よく実現するための良い全体設計を目指していくことになります。

今後は様々な情報処理の講義やアルゴリズムの講義で、どのような計算をおこなっていくか学ぶことになるでしょう。また、適切なソフトウェアの在り方については、ソフトウェア工学や、オペレーティングシステムについての講義で触れていくことになるでしょう。ハードウェアについては、計算機アーキテクチャやVLSI、電子デバイスについての講義で学んでいくことになると思います。他方、理想的な計算モデルについては、プログラミング言語や計算理論を扱う講義でより詳細に学ぶことになると思います。現実の計算機の話題は、次回の講義でも取り上げます。

コラム: 量子計算機

昨今、量子計算機が注目を集めています。量子計算機は量子論に基づいており、古典論に基づく計算モデルや計算機とは異なる方法で計算をおこないます。計算可能な問題は変わらないものの、従来の計算モデルや計算機では効率的に解くことができない一方で、量子計算機であれば効率的に解ける、という種類の問題が存在すると信じられています。また、そのような問題に向けた量子計算機向けアルゴリズムを、量子アルゴリズムと呼びます。Shorのアルゴリズムや計算複雑性 (計算量)、量子誤り訂正符号といったキーワードについて調べてみるとよいでしょう。

ライフゲーム

最後に、大きな規模のシミュレーションプログラムの実装に取り組んでみましょう。扱う題材はライフゲームというものです。イギリスの数学者John Conwayが提案したもので、生命の誕生・衰亡・変化のシミュレーションと見ることのできるものです。非常に単純なルールですが、多様な変化を観察することができ、人工生命の研究などにも利用されています。

  • ライフゲームでは、2次元グリッド上のセルの状態が、周囲のセルの数に応じて世代ごとに更新されます。

  • 各セルは、次のいずれかの状態を持ちます。

    • 生きている
    • 生きていない
  • 各セルは、自身を中心とした 周囲8近傍(上下・左右・斜め)を持ちます。

  • 次の世代の状態は、現在の状態と周囲の生きているセルの数によって決まります。

    • 生きているセルが有る場所
      • 周囲に生きているセルが2個または3個存在するならばそのまま
      • そうでなければセルは消滅
    • 生きているセルが無い場所
      • 周囲に生きているセルがちょうど3個存在するならばセルが誕生
      • そうでなければそのまま

上記の時間発展をプログラムでシミュレーションしていきます。

このように、格子状に並んだセルの状態変化による計算モデルは、セルオートマトン (Cellular Automaton) と呼ばれます。ライフゲームはセルオートマトンのうち、2次元グリッド上にセルが配置され上述したルールによって記述されるもの、ということになります。ライフゲームもチューリング完全であると知られています。

実装例

サンプルプログラムを以下からダウンロードできます。

eeic-software2/lifegame

$ git clone https://github.com/eeic-software2/lifegame.git

eeic-software2/lifegame_rust

$ git clone https://github.com/eeic-software2/lifegame_rust.git

演習

サンプルプログラムを以下のようにコンパイルしてみましょう。

$ cd lifegame
# キャリブレーション用のプログラムのコンパイル
$ gcc -o calibration calibration.c 
# ライフゲーム用のプログラムのコンパイル
$ gcc -o mylifegame -Wall mylife.c
コンパイルが成功したら、以下のように実行してみましょう。まず表示領域を調整するために先ほどコンパイルしたcalibrationというプログラムを実行します。

$ ./calibration
ターミナルのサイズをマウスで調整(主に縦に大きく)して、グリッド全体が表示されるように調整してください。

次に、先ほどコンパイルしたmylifegameというプログラムを実行します。

# 引数なしで実行するとあらかじめ決められた初期配置になる
$ ./mylifegame
# 引数をあたえると、そのファイルから配置情報を読み取る
$ ./mylifegame gosperglidergun.lif

Rustの場合は、

$ cd lifegame_rust
$ cargo run
コンパイルが成功したら、以下のように実行してみましょう。calibration相当のものはないので、適切にターミナルサイズを広げてから実行してください。
# 引数なしで実行するとあらかじめ決められた初期配置になる
$ cargo run
# 引数をあたえると、そのファイルから配置情報を読み取る
$ cargo run -- gosperglidergun.lif

実行を停止する場合はCtrl-cを押してください。 引数で与えるファイルはLife1.06と呼ばれる形式です。こちらに情報があります。ただし簡易実装のため、用意した盤面外の座標が入力にあるとバグる可能性が高いです。

レポート

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

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

week6_1

  • 独自のライフゲームを実装してみましょう。C言語でもRustでもokです。以下のa, b, cすべてに取り組んでください。
    1. 引数がない場合の初期配置がランダムになり、実行のたび異なるよう修正してください。
    2. 100世代ごとの盤面の状態をgen0100.lif(100世代目の状態)、gen0200.lif(200世代目の状態)... のようにLife1.06形式でファイルに出力するようにプログラムを修正してください。
      • 出力ファイル名はgen%04d.lif のように4桁で左0埋めとしてください。10000世代以上ではファイル出力しないようにしてください。
      • 以下の独自の工夫の結果、Life1.06形式とは異なる形式になってしまうのはokです。何らかの形式でファイルに出力するようにはしてください。
    3. 独自の工夫を加えてプログラムを発展させてください。以下は参考例です。
      • ルールの変更: 生死判定変更、生物種を増やす、地形効果の導入等、急激な環境変化、定期的にボンバーマンが出現するなど
      • 機能の追加: 盤面状態を画像や動画などで保存する、GUIで操作できるようにするなど
        • 主要開発言語がC言語やRustであれば、ターミナル以外のGUIを用いても構いません。
  • また、開発内容をまとめたREADME.mdを作成してください。
    • 開発内容と、独自の工夫についてまとめてください。
      • どのようなルール変更、機能追加を行い、その結果どうなったかを簡単に説明してください。
    • Githubリポジトリ上で綺麗に表示されるよう作成してください。