感想

Rust を書くことになるのでそのためのキャッチアップを行なっています。
それに以前からかなり Rust には興味があったのと、低レイヤーな言語も勉強してみたいと思っていたのでいい機会でした。

「Rust は早い!」という認識は以前からありましたがそれはふんわりした認識だったのを、この本を読んでより具体的に理解することができました。

本書

https://www.amazon.co.jp/gp/product/4798061700/ref=ppx_yo_dt_b_asin_title_o01_s00?ie=UTF8&psc=1

Rust はなぜ早いのか

Rust が速いことは誰もが知るところ、ただ「なぜ」速いのかは認識していなかった。
結論から言うと以下三つである。

  • Rust は機械語に直接コンパイルされる
  • ガベージコレクションを持たない
  • 「ゼロコスト抽象化」を追求している

Rust 機械語に直接コンパイルされる

Rust は機械語に直接コンパイルされるため、仮想マシンを介さずに実行できる。

Java や Python の場合

  • 独自の仮想マシンを持つ。メリットとして様々な環境下でプログラミング言語を実行しやすくなる点がある。
  • 一方で、インタプリタやコンパイルをカイすと仮想マシン用の言語を生成する。
  • 仮想マシンはソフトウェア上に実装されているため、速度面で少し不利になる。

C 言語や Rust の場合

  • コンパイル後の最終結果は機械語になる。
    • 仮想マシンを介さずに実行できるため、仮想マシンに起因する速度低下は起きない。
  • 様々な環境下で実行できるように、GNU Compiler Collection (GCC) や LLVM といったコンパイラが、環境に応じた機械語を生成するようになっている
  • Rust では機械語を生成する部分にこの LLVM を利用している。

ガベージコレクションを持たない

Rust はガベージコレクションを持たないため、メモリ管理に関するオーバーヘッドがない。

仮想マシンをもたない Go よりも速いのはなぜか

  • Go と Rust の大きな違いは、Go にはガベージコレクションがあり、Rust にはないこと
  • Java や Python も持っている

ガベージコレクションとは

プログラムが使用していたメモリを、プログラムが使用しなくなった時点で自動的に解放する仕組みのこと

  • プログラムは普段コンピューターのメモリと呼ばれる領域を逐一確保、解放しながら計算処理を行なっている。
  • プログラムが処理中に確保したメモリ領域のうち今後予定のない部分は、不要なメモリ領域と言える。
  • この不要なメモリ領域を長時間確保しておくと、プログラムが使えるもメリ領域が減ってしまう。
  • そのため、ガベージコレクションという処理を挟んで、不要なメモリ領域を解放することで、使えるメモリ領域を増やすことができる。

ガベージコレクションのデメリット

  1. 不要なメモリ領域がいつ解放されるかわからない
  2. 不要なメモリを解放する瞬間に、数ミリ秒 ~ 数秒間計算処理本体を止めてしまう可能性がある
    • この停止時間は、プログラムが行う処理全体からみると非常に大きい

ガベージコレクションを持たない言語では、どうやってメモリを解放するのか

プログラマが手動でメモリを確保し、解放するメモリ領域の管理を行う

  • 人の手でメモリ管理を行うことは時に困難を伴う。
  • メモリ領域を解放し忘れてしまったり、すでに解放している領域をもう一度解放してしまったりする。
    • こうした状況を、「メモリ安全ではない」と言ったりする。

Rust の新しいアプローチ

プログラミング言語側が、メモリの管理を行うようにした。

  • 一見すると、ガベージコレクションを持つ言語と同じように見える。
  • しかし、Rust ではガベージコレクションを持つ言語とは異なるアプローチを取っている。
    • 「所有権」
    • 「借用」
    • 「ライフタイム」

プログラマがメモリ管理を直接する必要はない一方で、ガベージコレクタにメモリ管理を任せる必要もない。
安全で、処理速度を低下させない道を採ることに成功したプログラミング言語と言える。

「ゼロコスト抽象化」を追求している

プログラム言語が持つ抽象化の機能を追加のコストを支払うことなく使用できること

  • ゼロコスト抽象化は、元々は C++の概念の一つ

  • 追加のコストとは、実行速度の低下やメモリ使用量の増加など

  • 抽象化するというのは、共通化などが一番の例

  • 似たような処理データをまとめあげ、大まかな動きさえ把握すれば損のプログラムを動かせるようにすることを「抽象化」と呼ぶ

  • しかし抽象化を行うということは、なんらかの犠牲を払う必要のある言語がほとんど

  • Rust では「抽象化」を「ゼロコスト」で行うことができるため、処理が早い

モダンな言語機能が一通り入っている

Rust は最近できた言語である。そのため、多くのプログラミング言語を参考にして設計されている。
つまり、RUst を学ぶことで他のプログラミング言語で良しとされてきた文法について学ぶことができると本書では書かれている。

私は TypeScript しかほとんど書いた事がない(ちょっとだけ Go と Dart に触れたことがあるくらい)ため、そことの違いについても触れていきたい。

不変・可変を明示的に制御できる

Rust では let を使って変数を割り当てることができる(これを束縛という)。
変数割り当ては、Rust では標準で「不変」となる。不変にはいくつか意味があるが、変数の文脈では、「値を一度変数に割り当てるとその値を変えることができない」という意味を持つ。
例えば、次のコードはコンパイルエラーとなる。

fn main(){
  // 変数xに対して5を割り当てる
  let x = 5;
  // println!は標準出力を意味する
  println!("number is: {}", x);
  // 6という値を再代入しようとする → コンパイルエラー
  x = 6;
  println!("number is: {}", x);
}

変数は標準で不変として宣言されるため、再代入できない。

ただずっと変数が不変なのかと言われるとそうではない。再代入を許可するための構文もある。
let の後ろに mut をつけると、可変な変数を扱うことができる

fn main(){
  // 変数xに対して5を束縛する
  let mut x = 5;
  // println!は標準出力を意味する
  println!("number is: {}", x);
  // 6という値を再代入
  x = 6;
  println!("number is: {}", x);
}

変数を標準で不変するするメリットとは

変数のスコープが長い状況で、変数の際代入を繰り返すとその時の値がそうなっているか把握するのが難しくなってしまう。
そこで、再代入を標準ではできないようにしておくことで、、予期しない再代入が発生しプログラムの結果がおかしくなるといったことを防ぐことができる。

filter, map などを使ってコレクションを操作できる

import java.util.List;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.stream.Collectors;

public class Main {
  public static void main(String[] args) throws Exception {
    // 1 ~ 5までの数値を持っているリストを用意する
    List<Integer> source = new ArrayList<>(Arrays.asList(1, 2, 3, 4, 5));
    List<String> result = source.stream()
      // 偶数か奇数か判定し、偶数なら残す
      .filter(n -> n % 2 == 0)
      // 数値を文字列型に変換する
      .map(n -> n.valueOf(i))
      // 結果をリストに詰める
      .collect(Collectors.toList());
  }
}

Rust では、イテレーターという機能を使って同じようなことができる。

// 1 ~ 5までの数値を持っているベクタを用意する
// vec![]とすると、Rustではベクタを作成できる
let source = vec![1, 2, 3, 4, 5];
let result = source
  .into_iter()
  // 偶数か奇数か判定し、偶数なら残す
  .filter(|n| n % 2 == 0)
  // 数値を文字列型に変換する
  .map(|n| n.to_string())
  // 結果をリストに詰める
  .collect::<Vec<String>>();

代数的データ型とパターンマッチング

代数的データ型

代数的データ型は、OCaml や HasKell などの関数型プログラミング言語から輸入されてきた概念。
Rust ではデータを設計する際にはなくてはならない存在になっている。

列挙型(enum)や構造体、タプルなど、多くの型を代数的データ型として扱うことができる。

パターンマッチング
C 言語や Java が持つような switch 文を、さらに強力にしたものというイメージ。
C 言語や Java などでは、switch 文を適用できる型には限りがあったが、Rust では様々な型をこのパターンマッチングに通すことができる。

列挙型とパターンマッチングの例

Rust では、列挙型は代数的データ型として扱うことができ、パターンマッチングできる。
値がないかもしれないということを示す Option 型や、エラーになっている可能性があることを示す Result 型などは、Rust にとっては代表的な列挙型と言える。

  • Rust の Option 型

    pub enum Option<T> {
      /// No value
      None,
      /// Some value `T`
      Some(T),
    }
    

値があることは SOme によって表現し、値がないことは None によって表現する。

  • 値があって、かつその中身が偶数だった場合は『偶数です: {値}』というメッセージを出力し、奇数の場合は『奇数です: {値}』というメッセージを出力する。値がない場合は『値がありません』というメッセージを出力するプログラム

    pub enum Option<T> {
      None,
      Some(T),
    }
    
    fn main(){
      // Some(1)を代入すると、値は1で奇数なので『奇数です: 1』と出力される
      // Some(2)を代入すると、値は2で偶数なので『偶数です: 2』と出力される
      // Noneを代入すると、値がないので『値がありません』と出力される
      // i32は符号付き整数型を示す
      let objective: Option<i32> = Some(1);
      match objective {
        Some(x) => if n % 2 == 0 => println!("偶数です: {}", x),
        Some(x) => println!("奇数です: {}", x),
        None => println!("値がありません"),
      }
    }
    

パターンマッチングを中心として処理を書いていくことで、より可読性の高いプログラムを書くことができる。

  • さっきの例の None の分岐を消すと、分岐を網羅できていないというエラーが出る

    pub enum Option<T> {
      None,
      Some(T),
    }
    
    match objective {
      Some(x) => if n % 2 == 0 => println!("偶数です: {}", x),
      Some(x) => println!("奇数です: {}", x),
      // None => println!("値がありません"),
    }
    

パターンマッチングは、処理していない箇所があった場合にコンパイラがコンパイルエラーを出す。
処理し忘れを防ぐことができ、結果的にプログラムのバグを軽減することができる。

強力な型推論

ここは意外すぎた。Go や Dart と同じで、型はほぼ全て明示的に書く必要があると思っていた。
やはり後発言語ということもあり、いい点を受け継いでくれている。型推論最高 🎉

ここは TypeScript で体に染み込んでいるので割愛。

トレイト

初見の言葉 👀
トレイトとは、Java におけるインターフェースのようなもの。共通の振る舞いを取り出して名前付けしたものを指すそう。

例)

ある動物の寿命と学術名を表示するプログラムを書いてみたいとする。
Animalに寿命を返すlifespan()関数と学術名を返すscientific_name()関数を定義し、2つの関数の情報を元に文字列を標準出力する処理を定義したい。

今回はDogとCatというデータに関して、それぞれ寿命と学術名を知りたいものとする。

通有情であれば、Dog と Cat それぞれの構造体に対して独自に関数を定義しておき、それぞれの構造体用に内容を出力する関数を用意する必要がある。
トレイトを使うことで、標準出猟苦する部分を共通化し、実装の手間を大幅に減らすことができる。

fn main(){
  let dog = Dog{};
  let cat = Cat{};

  show_animal_data(dog);
  show_animal_data(cat);
}

trait Animal {
  // 動物の寿命を返す
  fn lifespan(&self) -> u32;
  // 動物の学術名を返す
  fn scientific_name(&self) -> &str;
}

// 犬の構造体を用意する。
struct Dog;
wq//犬の構造体に対する `lifespan()`関数と`scientific_name()`関数を定義する
impl Animal for Dog {
  fn lifespan(&self) -> u32 {
    13
  }

  fn scientific_name(&self) -> &str {
    "Canis lupus familiaris".to_string()
  }
}

// 猫の構造体を用意する
struct Cat;

// 猫の構造体に対する`lifespan()`関数と`scientific_name()`関数を定義する
impl Animal for Cat {
  fn lifespan(&self) -> u32 {
    16
  }

  fn scientific_name(&self) -> &str {
    "Felis catus".to_string()
  }
}

// 動物の寿命と学術名を表示する関数
// "Animal"トレイトに定義されている"lifespan()"関数と"scientific_name()"関数を内部で呼び出す
// "T: Animal"というのは、"Animal"トレイトを境界として持つという意味
// これにより、"Animal"を実装している型のみこの"T"を型引数に入れることができる
fn show_animal_data<T: Animal>(animal: T) {
  println!("Lifespan: {} years", animal.lifespan());
  println!("Scientific name: {}", animal.scientific_name());
}

他の言語ではインターフェースとその実装先でオブジェクトの紐付けは実行時に行われる。
しかし、Rust ではコンパイル時に紐付けが行われるため、ゼロコスト抽象化を実現している