⚙️

nanochatを理解するためのRust入門

に公開

はじめに

これまでCUDAプログラミングの解説記事を連投してきましたが、本記事では少し趣向を変えて、nanochatの理解に向けたRustプログラミングの解説を行いたいと思います。

nanochat(githubリポジトリ)は、2025年10月にAndrej Karpathyが公開した、ChatGPTライクなLLMを、モデルの訓練含めゼロから構築できるミニマルなプロジェクトです。LLMのチャットモデルについて、データ準備からチャットUI提供までの一連のパイプラインを、ミニマムながらもちゃんと動作するコードとして提供してくれています。

Karpathyは最近話題となった「AGI is still a decade away」というインタビューのなかで、このプロジェクトについて以下のように述べています(Andrej Karpathy 00:28:10の部分参照):

  • 全体で8,000行のコードである
  • 写経しながら理解を進めるべし
  • コピペ禁止!

筆者は、GPUプログラミングの理解深化の目的に照らすと、このnanochatが実験等を行うアプリケーションとして最適だと考えています。そのため、一旦CUDAプログラミングから離れて、nanochatの理解を進めているところです。

ただ、nanochatの理解を進めようとすると、全体のパイプラインの入り口となるトークナイザの構築で、rustbpeというRustによりコーディングされたトークナイザ訓練エンジンが登場します。Pythonは書けるものの、Rustには触れたことがないという方は多いと思われ、そのような人はここで挫折してしまうかもしれません。ここで躓いてしまうのはもったいないです。

そこで本記事では、前編・後編の2部構成でrustbpeの理解を深めていきます。前編となる本記事では、nanochatとrustbpeの概要、およびrustbpeを読解するために必要なRustの基礎知識を解説します。後編では、rustbpeのコード詳細を徹底的に解説する予定です。一緒に理解を深められたら嬉しいです。

本記事の流れ

本記事は以下の流れで解説します:

  1. nanochat、rustbpeとは?
  2. rustbpe読解のためのRust基礎知識
  3. rustbpeに現れる文法

1. nanochat、rustbpeとは?

nanochatのパイプラインを整理のうえ、rustbpeがどのような位置付けなのかを解説します。

1.1 nanochatの特徴

nanochatは以下の特徴があります。

  • トークナイザーの学習から、モデルの事前学習、ファインチューニング、強化学習、そしてUI提供までの全プロセスをカバーしています
  • プロジェクト全体が約8,000行のコードで構成されています。このミニマムな内容で実際に動作するチャットボットが作成できます。
  • 依存関係を最小限に抑えて、すべてのコードを読んで理解できる設計になっています。一つのスクリプト(speedrun.sh)を実行するだけで、全プロセスが実行される構成となっています

1.2 nanochatの構築パイプライン

ミニマムな学習を実行するspeedrun.shを実行すると、以下のフローにてモデルの構築が進められます:

  1. トークナイザー学習: FineWeb-EDUデータセットの一部(約2Bキャラクタ)を用いてrustbpeでBPEトークナイザーを学習
  2. 事前学習(Pre-training): 約11.2Bトークンで基礎的な言語モデルを学習
  3. 中間学習(Mid-training): 会話形式データ(SmolTalk)や多肢選択問題(MMLU)、ツール使用データ(GSM8K)で追加学習
  4. 教師あり微調整(SFT): 対話品質を向上させる
  5. 強化学習(RL): オプションでGSM8Kに対してGRPOを実施
  6. 評価と配信: 各種ベンチマークで評価し、CLIまたはWeb UIで提供

この一連のプロセスにより、約560M〜1.9Bパラメータの小規模ながら、動作するChatGPTライクなモデルが構築できます。

1.3 rustbpeとは?

rustbpeは、nanochatプロジェクトのトークナイザー部分を担うRust実装のBPE(Byte Pair Encoding)というアルゴリズムを用いたトークナイザーです。

1.3.1 rustbpeの概要

Karpathy自身が、rustbpeのREADMEで以下のように説明しています:

トークナイザーのライブラリには既存のものがいくつかあります。tiktokenは推論には優れていますが、学習機能がありません。一方、Hugging Faceのtokenizersライブラリは学習ができますが、肥大化しており、歴史的な経緯から複雑になっています。最近、私はminbpeというライブラリを書きましたが、これは学習と推論の両方ができるものの、Pythonで書かれており効率が悪いです。私が本当に欲しかったのは、シンプルで効率的な学習コード(minbpeより効率的で、tokenizersよりシンプル)と、tiktokenでの推論のために学習済みボキャブラリをエクスポートする機能です。

つまり、学習可能な極力シンプルな構成としつつ、ボトルネックとならない程度には効率的であることを目指したトークナイザです。

1.3.2 BPE(Byte Pair Encoding)とは?

BPEは、元々データ圧縮のために開発されたアルゴリズムです。データ圧縮としての基本的な仕組みは次のとおりです:

  1. データの中で最も頻繁に現れるバイト(8bit)のペア(2つ並び)を見つけ、そのペアを1つの新しい記号に置き換える
  2. 置き換え後のデータで、再び最も頻繁に現れるペアを探す

元々はデータ圧縮のために開発されましたが、バイトを文字として読み替えれば、そのままテキストを頻出するトークンに分割するアルゴリズムとして利用できます。実際GPT-2、GPT-3、GPT-4、Llama、Mistralなど、現代のほぼすべての大規模言語モデルで使用されています。

上記のBPEのデータ圧縮としての仕組みを、トークナイザの役割として読み替えると以下のとおりとなります。

  1. テキストをUTF-8バイト列に変換し、各バイト(0-255)を個別のトークンとして扱う
  2. 最も頻繁に出現する連続バイトペアを見つけ、それらを新しいトークンとしてマージする
  3. 指定したボキャブラリサイズに達するまで、ステップ2を繰り返す

例えば、"aaabdaaabac"というテキストに対して3回のマージを行うと:

  • 初期状態: [a, a, a, b, d, a, a, a, b, a, c]
  • 1回目: a,aが最頻出なのでa,a → P1とマージ → [P1, a, b, d, P1, a, b, a, c]
  • 2回目: a,bが最頻出なのでa,b → P2とマージ → [P1, P2, d, P1, P2, a, c]
  • 3回目: P1,P2が最頻出なのでP1,P2 → P3とマージ → [P3, d, P3, a, c]

その他、詳細が気になる場合は以下の記事等を参照ください。

2. rustbpe読解のためのRust基礎知識

Rustは2006年に生まれ、2010年にリリースされた比較的新しい言語です。CやC++を置き換えるシステムプログラミング言語となることを目指したものとなっており、以下の特徴があります

  • C(C++)と同等の速度がでる
  • メモリ安全性および並列処理の安全性をコンパイラが保証する

Rustの評価の一例を挙げると、Googleの実験的なプロジェクトCarbon-lang(C++の後継言語)のドキュメントにおいて、

If you can use Rust, ignore Carbon
(もしあなたがRustを使えるのならば、Carbonは無視してかまわない)

と示されています。パフォーマンスが優先され、かつ既存のコードベース等の依存がない場合には第一に検討すべき選択肢と考えています。nanochatのrustbpeは既存のコードベースがなくゼロベースで作成され、またパフォーマンスが優先されることから、Rustでの処理にうってつけです。

ただし、一点意識してもらいたいのは、Rustは上記のような高速性・安全性等のメリットがあることの裏返しで、どうしてもコードの記載量はPythonと比較すると増えてしまいます。これは、パフォーマンスや安全性のために払うべきコストであると考えるべきで、メリットがコストに見合うかどうかに応じ、PythonとRustをうまく組み合わせるべきと筆者は考えます。

以下では、rustbpeを読み解くために必要なRustの知識を駆け足で紹介します。これは非常に野心的な取り組みであるため、理解しづらい部分もあるはずです。そのようなものについては順次改善していきたいと思いますので、ぜひコメント頂けますと幸いです。

2.1 Rustの環境準備

Rustの環境構築、プロジェクト作成、コンパイルおよび実行を手元のPCで行いたい場合は、こちらのページ等を参照してください。
Rust開発環境をさっと構築して小さなアプリを書きましょう!

また、上記の環境構築が面倒な場合は、以下のプレイグラウンドでも以下のコードスニペットを実行できますので、活用してください。
play.rust-lang

2.2 RustでHello, world

Hello, world!をPythonとRustで比較してみましょう。まずはPythonです。

print("Hello, world!")

上記と同様な処理をRustで行うには、以下のように記載します。

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

上記のスニペットの範囲であれば、そこまでとっつきにくさはないかなと思います。ですが、一応丁寧に解説を進めます。

まず、上記のとおり関数の定義はキーワードfnを用いて

fn functionName(引数) {
	(処理内容)
}

という形で行います。
また、Pythonでは処理内容の記載範囲をインデントで示すのに対し、Rustでは波括弧{ }にて挟み込んで関数の処理内容の記載範囲を指定します。
加えて、Rustではコードをコンパイルし実行するさい、main()という名前で定義された関数を実行します。そのため、上記のスニペットをコンパイルし実行すると、何も指定しなくてもmain()に記載された処理内容が実行されます。

次に、以下のmain()の処理内容を解説します。

println!("Hello, world!");

まずprintln!()です。これは関数に見えますが、名前の最後に!がついていることに注目してください。これはマクロと呼ばれ、ひとまず関数みたいなものと思っておいて差し支えありません。マクロについては後述します。
println!()マクロは、引数に与えた文字列をコンソールに出力後、行を改行します。ちなみに改行したくない場合はprint!()マクロを用います。
引数に与えている"Hello, world!"文字列リテラルという小難しい名前がついた値となります。Pythonでは文字列の定義のさい、シングルクオート'(文字列)'およびダブルクオート"(文字列)"の両方が使えますが、Rustでは区別が必要なので注意しましょう。シングルクオートは文字型(char)の定義に用いるので、1文字しか含めることができません。具体的には、1文字の場合でも文字列と扱って良いことに留意すると、以下はOKです。

  • 'a'
  • "b"
  • "abc"
    しかし、以下はコンパイルエラーとなります。
  • 'abc'

2.3 Rustの型

Rustは静的型付け言語です。Pythonのような動的型付け言語とは異なり、すべての変数の型がコンパイル時に決定されます。これにより、パフォーマンスの向上やバグ防止のメリットが受けられます。例えばPythonのPandasの型エラーに苦しんだことがある人は、きっとこの価値を感じられると思います。

2.3.1 変数の宣言

Pythonでは以下のように型を明示せずに変数を定義でき、インタープリタがよしなに処理を行なってくれます。

x = 42
text = "hello"
numbers = [1, 2, 3]

一方、Rustでは変数を定義する際、基本的には以下のように型を明示します。(以下のi32, &strという指定のこと。これらの型については後ほど解説します)

let x: i32 = 42;
let text: &str = "hello";

ただし、Rustは型推論を備えており、多くの場合は型を明示しなくてもよしなに型を特定してくれます。以下のように型の指定を省略しても問題ないです。

let x = 42;  // コンパイラがi32と推論
let text = "hello";  // コンパイラが&strと推論

なお、型推論はコンパイル時に実施し、処理実行時には型は特定されている留意してください。これが、Rustが高速に処理を実行できる一要因となっています。

ここで示したletによる変数の宣言は、デフォルトで不変(immutable)となっており、そのままでは値を変更することができません。

let x = 5;
x = 10;  // エラー!不変な変数は変更できない

値の変更・追加・削除等を行う場合は以下のとおりmutキーワードを追加して可変(mutable)な変数として宣言します。

let mut x = 5;  // mutを付けて可変宣言
x = 10;  // OK
println!("{}", x);  // 10

以降では、rustbpeを読解するうえで必要となる型を順に解説していきます。使用しない浮動小数点型等は省略しておりますのであしからず。

2.3.2 整数型

pythonでは整数方はint一択な一方、Rustでは整数は符号の有無、および使用するビット数(=表現できる値の範囲)で複数のオプションがあります。色々種類がありますが、表現する値によって使い分けることを認識しておけば十分です。この識別も面倒と思うかもしれませんが、パフォーマンス向上のために必要なコストです。

符号なし整数(unsigned integer)

  • u8: 0 から 255 まで(8ビット=1バイト)
  • u32: 0 から 4,294,967,295 まで(32ビット=4バイト)
  • u64: 0 から 18,446,744,073,709,551,615 まで(64ビット=8バイト)
  • usize: アーキテクチャ依存
    • 32ビットシステムならu32想定、64ビットシステムならu64相当
    • なお、現代のPCでは64bitシステムばかりなので、u64相当と思っておいて問題ないです

符号付き整数(signed integer)

  • i32: -2,147,483,648 から 2,147,483,647 まで(4バイト)
  • i64: -9,223,372,036,854,775,808 から 9,223,372,036,854,775,807 まで(8バイト)

2.3.3 文字列型

Rustの文字列型は、基本的なものは可変長のString型と、固定長の&strとなります。これらの変数については、後述しますが文字列の値自体はヒープというメモリ領域に格納されることを認識しておいてください。

String: 可変長の文字列型

  • サイズを動的に変更可能

&str: 固定長の文字列

  • 文字列リテラル("hello")の型
  • サイズは不変
  • &は「レフ」と読み、これが意味するところは後述します。

文字列の処理の例です

let text1: &str = "hello";  // 文字列リテラル(不変)
let mut text2: String = String::from(text1);  // 可変な文字列
text.push_str(" world!");  // 文字列の追加

2.3.4 タプル型

タプルは、複数の値をひとまとめにしたものです。まとめる値は型が異なっていても問題ありません。Pythonのタプルと似ています。
rustbpeでは、バイトペアを表すためにタプルが用いられています。

let byte_pair = (65, 66);  // 'A'と'B'のペア

2.3.5 コレクション型

コレクション型は、複数の値をまとめて扱うためのデータ構造です。

Vec<T> - ベクタ型

Vec<T>は、Pythonのリストに相当する、可変長の配列です。変数の宣言時にTの箇所に型を記載することで、要素の型を指定できます(下記ではTに符号なし整数u32を指定)。

# Python
numbers = [1, 2, 3, 4, 5]
numbers.append(6)
// Rust
let mut numbers: Vec<u32> = vec![1, 2, 3, 4, 5]; //ベクタを宣言するためのマクロ
numbers.push(6);

HashMap<K, V> - ハッシュマップ

HashMap<K, V>は、Pythonの辞書(dict)に相当する、キーと値のペアを保存するデータ構造です。値の取り出し方もPythonと同様で、変数名の後ろの[]の中にkeyの値を入れるだけです。

use std::collections::HashMap; // pythonのimport文に相当

fn main() {
    let mut age: HashMap<&str, u32> = HashMap::new();
    age.insert("一郎", 22); // 22歳の一郎さん
    age.insert("二郎", 20); // 20歳の二郎さん

    // keyに対するvalueの取り出し方はpythonと同様
    println!("二郎さんの年齢は{}歳", age["二郎"]) // pythonの文字列の.format()メソッド相当
}

基本的な型の解説は以上です。

2.4 スタックとヒープ

この次の節で、Rust学習の大きなハードルとなる所有権システムを解説します。Rustの所有権システムを理解するために、まず先にメモリがどのように管理されているかの概要を掴んでおくことを強くお勧めします。プログラムが使用するメモリは、主にスタック(stack)とヒープ(heap)という2つの領域に分けられます。

補足

余談になりますが、初学者向けのRustの所有権システムの解説において、このメモリ管理の仕組みを省略する場合が大半だと思います。ただ、私自身はこのスタックとヒープの使い分けを学んだことで、ようやく所有権システムを理解した感覚が得られました。なので、皆様にもぜひこの基礎から理解を進めていただきたいです。

2.4.1 スタックとヒープの基本

スタックは、サイズが固定された値を高速に管理するメモリ領域です。rustbpeで使われている以下のような基本的な型を単独で定義した場合、すべてスタックに配置されます。

  • 整数型(u8, u32, usizeなど)
  • タプル((u8, u8)のようなバイトペア)
  • 参照(&str, &[u8]などのポインタ自体)

ヒープは、サイズが可変な値や、大きなデータを管理するメモリ領域です。StringVec<T>などの可変長であったりデータ量が大きくなりうるデータ構造はヒープに配置されます。

let x: i32 = 42;  // スタックに配置
let text: String = String::from("hello");  // データ本体はヒープに配置
let numbers: Vec<i32> = vec![1, 2, 3, 4, 5];  // データ本体はヒープに配置

2.4.2 ヒープの実際の格納方法

ヒープについて補足します。上記でString等の型はヒープに保存されると書きましたが、これは本当は正確ではありません。正確にはヒープに保存される値のポインタ等のメタデータはスタックに保存され、そのポインタが示す値はヒープに格納されます。

訳がわからないと思うので、String型の例で示します。以下のようにtextというString型を定義し、そこに"hello"という文字列を格納したとします。

let text: String = String::from("hello");

String型は、以下のメタデータをもちます

  • ポインタ(ptr、文字列の格納先の1文字目のアドレス)
  • 確保済みの容量(capacity)
  • データ長さ(len)
    上記のメタデータはスタックに配置され、"hello"という文字列自体はヒープに格納されます。図で示すと以下のとおりです。データの値にアクセスするさいは、まずptrで先頭アドレスを確認したあと、その先頭からlen分の長さのデータを取得することで、値全体が取得できます。

特にこのヒープに格納されるデータを適切に参照し、不要なタイミングで即座に解放できるようにすることを念頭に、次で解説する所有権のシステムが構築されていると筆者は理解しています。

2.5 所有権とライフタイム

Rustの最も特徴的な機能が所有権システム(ownership system)です。この所有権システムにより、高速性を維持しながらメモリ安全性を確保します。Python等のモダンな言語のほとんどは、ガベージコレクションという仕組みによりメモリ管理を行いますが、安全性は確保される一方、メモリ解放の遅れやオーバヘッド等の観点でパフォーマンスが犠牲になります。Rustでは所有権のルールに基づいてコンパイラが分析を行い、メモリ安全性をチェックしながら不要なメモリを即座に解放するようコードをコンパイルします。

所有権の基本的な考え方は、以下の3点だと筆者は考えています。

  • 不要になった値は即座に解放する
  • 解放した値が使われないことをコンパイラが保証する
  • 値の変更が同時に複数行われないことをコンパイラが保証する

上記を意識しながら、所有権の解説を読み進めてください。

2.5.1 所有権の基本ルール

Rustの所有権では値の所有者(owner)という概念があります。所有者は以下の基本ルールに従います:

  • 各値には必ずただ一つの所有者(owner)がおり、所有者が破棄されると値も同時に破棄される

Pythonとの違いを見てみましょう:

# Python
text = "hello"
text2 = text  # text2とtextは同じオブジェクトを参照
print(text2)
print(text)   # OK

上記のPythonコードは問題なく動作しますが、Rustで同様なことを行うとエラーが出ます。

let text = String::from("hello");
let text2 = text;  // 所有権がtext2に移動(move)
println!("{}", text2);  // OK
println!("{}", text);  // エラー!textはもう使えない

Rustでは、text2 = textの時点で所有権がtextからtext2に移動(move)します。その後、textは使用できなくなります。これは「所有者は常に1つだけ」というルールに従っています。
所有権の移動は変数の定義時だけでなく、関数に値を渡す場合も起きます。

fn take_ownership(s: String) {
    println!("{}", s);
}  // ここでsが破棄される

let text = String::from("hello");
take_ownership(text);  // textの所有権が関数に移動
println!("{}", text);  // エラー!textはもう使えない

2.5.2 基本ルールの例外

ここで上記の基本ルールについて、重要な例外を解説します。上記の所有権の移動はString型で起こりましたが、整数型などの、値がスタックに保持されるプリミティブ型では所有権の移動が起こりません。

let x: i32 = 42;
let y = x;  // 所有権は移動せず、値がコピーされる
println!("{}", x);  // OK!xはまだ使える
println!("{}", y);  // OK

これは、i32などのプリミティブ型がCopyトレイトを実装しているためです。Copyトレイトを持つ型は、代入時に所有権が移動するのではなく、値が自動的にコピーされます。

Copyトレイトを持つ主な型:

  • 整数型(i32, u8, usizeなど)
  • 浮動小数点型(f32, f64
  • 真偽値型(bool
  • 文字型(char
  • 上記の型のみで構成されたタプル(例:(i32, i32)
  • 参照型(&T

一方、StringVec<T>などヒープにデータを持つ型はCopyトレイトを実装していないため、代入時に所有権が移動します。

この違いは、スタックとヒープの仕組みと関連しています。スタック内にメタデータおよび値全てが格納されるプリミティブ型はコピーのコストが非常に小さいため、値を複製してしまう方が合理的です。一方、ヒープにデータを持つ型はコピーのコストが大きくなりうるため、データの値そのものは触らずにメタデータの所有権を移動させることで、大きな値のコピーを行わずに値を代入しています。

最初は「なぜ型によって挙動が違うのか」と混乱するかもしれませんが、「スタックに完結する軽量な型は自動コピー、ヒープを使う重い型は明示的な操作が必要」と覚えておくと整理しやすいです。

2.5.3 参照(Reference)と借用(Borrowing)

上記で所有者は常に1つだけ、というルールを解説しました。ただしこれだとプログラミングが窮屈になります。値の所有権を移動させずに、一時的に値を使いたい場合のために、借用(borrowing)という操作が用意されています。ある値を借用した変数のことを参照(reference)と呼びます。

参照は、その借用元の値を変更できない不変参照と、借用元の値を変更できる可変参照があります。

不変参照(&T

不変参照は、所有権を移動せずに値を読み取ることができます。値の変更はできません。不変参照の型は、その借用元の変数の型をTとしたら、&Tと表記します(&は"レフ"と読みます)。

fn print_length(text: &String) {
    println!("Length: {}", text.len());
}

let text = String::from("hello");
print_length(&text);  // textの参照を渡す
println!("{}", text);  // textは引き続き使える

&texttextの不変参照を作成します。print_length関数はtextを借用するだけなので、関数呼び出し後もtextを使い続けることができます。

複数の不変参照を同時に作ることも可能です:

let text = String::from("hello");
let ref1 = &text;
let ref2 = &text;
println!("{} {}", ref1, ref2);  // OK

可変参照(&mut T

値を変更したい場合は、可変参照を使います。

// Rust
fn add_suffix(text: &mut String) {
    text.push_str(" world");
}

let mut text = String::from("hello");
add_suffix(&mut text);
println!("{}", text);  // "hello world"

ただし、可変参照には重要な制約が2つあります。
まず、可変参照は同時に1つだけしか存在できません。1つ目の可変参照が有効なうちに2つめの可変参照を宣言しようとするとエラーとなります。

let mut text = String::from("hello");
let ref1 = &mut text;
let ref2 = &mut text;  // エラー!可変参照は1つだけ
ref1.push_str(" world");

これにより、意図しない複数の変更を行なってしまうことをコンパイラが防いでくれます。
続けて2つめの制約ですが、可変参照と不変参照は同時に存在することはできません。

let mut text = String::from("hello");
let ref1 = &text;  // 不変参照
let ref2 = &mut text;  // エラー!不変参照が存在する間は可変参照を作れない
println!("{}", ref1);

これらの制約により、Rustはコンパイル時にデータ競合を防ぐことができます。

なお、可変参照について「所有者でないのに値を変更できる」というのは、字面だけを捉えると奇異に感じるかもしれません。実際、これが所有権システムのわかりにくさの一因になっていると思います。所有者は値をいつ破棄すべきかを管理するための概念であり、値の変更可否とは直接関係ないことを意識するようにしてください。

2.5.4 参照の値にアクセスする(参照外し)

参照を通じて元の値にアクセスするには、*演算子を使って参照外し(dereference)を行います。

let x = 42;
let r = &x;      // xへの参照
println!("{}", *r);  // *で参照外し、値42にアクセス

let mut y = 10;
let mr = &mut y;
*mr += 5;        // 参照外しして値を変更
println!("{}", y);  // 15

多くの場合、Rustは自動的に参照外しを行うため(自動参照外し機能)、明示的に*を書く必要はありません。例えばメソッド呼び出しや比較演算では自動的に処理されます。

let text = String::from("hello");
let r = &text;
println!("{}", r.len());  // 自動参照外し、*r.len()と書く必要なし

結局、参照の値にアクセスする場合、まずは素直にコードを記載し、コンパイラに指摘されたタイミングで参照外しを記載するので問題ありません。

2.5.5 変数のスコープとライフタイム

まず、変数のスコープについて確認しましょう。
Rustでは、変数は波括弧{}で囲まれたスコープ内でのみ有効で、スコープを抜けると自動的に破棄されます:

{
    let x = String::from("hello");
    println!("{}", x);  // OK
}  // ここでxは破棄される
println!("{}", x);  // エラー!xはもう存在しない

最初は窮屈に感じるかもしれませんが、不要な変数は即座に削除するという考え方をコンパイラが徹底してくれることの現れなので、うまく付き合いましょう。

参照は、その参照元の変数が存在する間だけ有効です:

let r;
{
    let x = String::from("hello");
    r = &x;  // エラー!xはスコープを抜けて破棄されるため
}
println!("{}", r);  // rは無効な参照を持つことになる

これにより、解放した値を参照することをコンパイラが防いでくれます。なお、解放した値を参照するとなぜ危険なのかについては、「ダングリングポインタ」というキーワードを調べてください。

次に、ライフタイムの解説に移ります。ライフタイムは参照がどのくらいの期間有効かを示す概念です。多くの場合、コンパイラが自動的に推論してくれるためライフタイムのラベル付を明示する必要はありません。ただし、関数が参照を返すなど、明示的に指定する必要がある場合があります。

ライフタイムは'a(アポストロフィ + 名前)というラベルで表現します。ちなみにaはただ名前をつけているだけなので、bhoge等他の名前でも問題はありません。

以下は、二つの文字列x, yを受け取って、その文字数が長い方を返すという関数です。この場合、関数から返ってくる値がxなのかyなのか、コンパイル時にはわからないため、コンパイラが困ってしまいます。そのため、x, yおよびこの関数の返り値resultが同じライフタイムを持つということをライフタイムのラベルにより宣言します。

// 最も長い文字列を返す関数
fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
    if x.len() > y.len() {
        x
    } else {
        y
    }
}

let string1 = String::from("long string");
let string2 = String::from("short");
let result = longest(&string1, &string2);
println!("Longest: {}", result);

この<'a>は、「引数xy、そして戻り値の参照はすべて同じライフタイム'aを持つ」ことを示しています。つまり、戻り値の参照は、xyが有効な間だけ有効であることを保証します。

rustbpeでは、構造体にライフタイム指定が登場します:

struct TextProcessor<'a> {
    text: &'a str,  // 参照を保持
}

impl<'a> TextProcessor<'a> {
    fn new(text: &'a str) -> Self {
        TextProcessor { text }
    }
}

この場合、TextProcessor構造体が参照textを持つため、その参照がどのくらい有効かをライフタイム'aで表現しています。

所有権とライフタイムについて、rustbpeの読み解きに最低限必要な範囲を解説しましたが、この点についてはコンパイラに何度も怒られながら身につけていくのが最も近道だと考えています。写経をすすめながらこの節を振り返ったり、他の解説記事等を参照するようにしてください。

3. rustbpeに現れる文法

2.で基礎知識を解説しました。そちらをふまえつつ、rustbpeに現れる文法を解説します。

3.1 Option, Result, エラーハンドリング

Rustには例外処理に関連する型として、Option型とResult型があります。Pythonでは例外処理の実装は必要な場合に行えば良いですが、RustのOption型やResult型は例外処理を明示的に記述しないと使用できません。

これも最初は窮屈に感じるかもしれませんが、例外処理の漏れをコンパイラが防いでくれるということで、将来のエラーを仕組みで防ぐものとして理解してください。コードは書く時間よりもエラー対応の時間の方が長いものです。

3.1.1 Option型

Option<T>は、コンパイル時には存在するかどうかわからない値を表現する型です。Option型はその使い方とセットで理解する必要があります。まず、基本的なmatch構文を用いた値の取り出し方を解説します。
Option<T>は以下の2つの値を取ります

  • Some(value): 値が存在する場合
  • None: 値が存在しない場合
    Noneはそのままなのでわかりやすいと思います。一方、Some(value)ってなんやねんと思うと思いますが、これは以下の使い方とセットで理解する必要があります。
use std::collections::HashMap;

fn find_value(dictionary: &HashMap<&str, i32>, key: &str) -> Option<i32> {
    dictionary.get(key).copied()
}

let mut map = HashMap::new();
map.insert("a", 1);
map.insert("b", 2);

let result = find_value(&map, "a");
match result {
    Some(value) => println!("Found: {}", value),
    None => println!("Not found"),
}

上記のコードのなかで

Some(value) => println!("Found: {}", value)

について
「値が存在する場合は、その値をvalueに格納し、=>以降の処理実行する」
と読んでください。慣れていないと奇妙に見えるかもしれませんが、パターンマッチングと呼ばれ、Java等他の言語でも同様な文法が採用されています。

ただし、上記のmatch構文を明示的に書くのが煩わしいこともあると思います。たとえば整数型の変数について、値がない場合はデフォルトの0を返すという場合があると思います。そのような場合にはunwrap_or()メソッドが使用できます。

let some_value: Option<i32> = Some(5);
let none_value: Option<i32> = None;

// unwrap_or: Noneの場合にデフォルト値を返す
println!("{}", some_value.unwrap_or(0));  // 5
println!("{}", none_value.unwrap_or(0));  // 0

上記のmatch構文もしくはunwrap_or()メソッドにより、値が存在しない場合の処理を明示的示すことが強制され、Noneのチェックおよびその場合の処理の実装漏れによるバグを防ぐことができます。

これもめんどくさそうに思うかもしれませんが、例えばPythonのインデックスエラー"list index out of range"は実行時にはじめてわかることが多々あると思います。このようなエラーをコンパイル時に防いでくれるものとなっているのは、筆者は良い仕組みだと考えています。

3.1.2 Result型

Result<T, E>は、成功(Ok(value))またはエラー(Err(error))を表現する型です。例えばファイルを開いて中身のテキストを取り出すとき、そのフォーマットが適切かはコンパイル時には保証できません。そのようなときは、ファイルを開いて適切に中身が取り出せたら成功、フォーマットが不適切な場合はエラーメッセージを出力し適切なファイル選定を促すべきです。このような、うまくいくかどうかわからない処理を扱うさいには、値はResult型で返されます。
以下は0での割算をエラーとして返す例です。

fn divide(a: i32, b: i32) -> Result<i32, String> {
    if b == 0 {
        Err(String::from("Division by zero"))
    } else {
        Ok(a / b)
    }
}

match divide(10, 2) {
    Ok(result) => println!("Result: {}", result),
    Err(error) => println!("Error: {}", error),
}

match文は、先ほどのOptionの場合をふまえると理解は難しくないと思います。まずOk(result) => ・・・については、処理が成功裏に実行された場合にはその返り値をresultに格納し、=>以降の処理実行します。また、Err(error) => ・・・については、処理でエラーが発生した場合はそのエラー内容をerrorに格納し、=>以降の処理実行します。

上記のmatch文の記載は、関数が別の関数を呼び出し、その関数がさらに別の関数を呼び出して、という場合にそれぞれの呼び出し元にエラーメッセージを明示的に渡さないといけないので面倒です。。これを避けるために、Rustでは?演算子というものが用意されており、これを使ってエラーを呼び出し元に簡潔に伝播させることができます:

fn read_and_parse() -> Result<i32, String> {
    let text = read_file()?;  // read_fileがErrを返したらここで関数を抜ける
    let number = parse_number(&text)?;  // parse_numberがErrを返したらここで関数を抜ける
    Ok(number)
}

?演算子は、ResultErrの場合にそのエラーを呼び出し元に返し、Okの場合は中身の値を取り出します。これにより、Errの場合の処理をいちいち記載せずに、呼び出し元の関数にエラー内容を伝搬できます。

3.2 イテレータとクロージャ

Pythonを書いてきた方であれば、リスト内包表記やmap/filterを使った処理に馴染みがあるかと思います。Rustにも同様の仕組みがあり、それがイテレータとクロージャです。ただし、Rustのそれらは単なる便利機能にとどまらず、パフォーマンスを最大限引き出すため遅延評価(lazy evaluation)という設計思想に基づいています。

3.2.1 イテレータ

イテレータは、コレクションの要素を順番に処理するための仕組みです。Pythonのイテレータやジェネレータと似ていますが、先述のとおりRustのイテレータには遅延評価という重要な特徴があります。これは、最終的に値が必要になるまで、実際の処理を行わないということです。
遅延評価により、不要な中間出力をメモリに保存する必要がなくなり、大量のデータを効率的に処理できるようになります。巨大なテキストデータを扱うrustbpeにとって、これは非常に重要な特性です。

以下は配列を偶数の値のみフィルタリングし、2倍にして返す処理です。

let numbers = vec![1, 2, 3, 4, 5];
let even_doubled: Vec<i32> = numbers
    .iter() //iteratorに変換。各要素のポインタを順次返す
    .filter(|x| *x % 2 == 0) // 要素を偶数のみにフィルタリング。参照外し(*)を行なっていることに注意
    .map(|x| x * 2) // 全要素を2倍するmap処理
    .collect(); // 処理したiteratorを再度vec型に戻す
println!("{:?}", even_doubled);  // [4, 8]

このコードだけでは見えませんが、実際の処理は.collect()が呼ばれたさいに全ての処理が一気に行われます。

3.2.2 クロージャ

クロージャは、Pythonのラムダ式や無名関数に相当します。変数を関数の引数として明示的に渡さずに使用(キャプチャ)することができる匿名関数です。
上記のコードに|x| x * 2という見慣れない記法が出てきました。これがクロージャ(closure)で、Pythonのラムダ式(lambda x: x * 2)に相当するものです。

クロージャの基本的な記法は|引数| 式です。処理が複数行にわたる場合は|引数| { 処理 }のように波括弧で囲みます。

let double = |x| x * 2;
println!("{}", double(5));  // 10

// 複数行の処理
let complex_calc = |x, y| {
    let sum = x + y;
    let product = x * y;
    sum + product // ←セミコロン(;)がないのは誤記ではなく、当該行が返り値であることを示す
};

クロージャがラムダ式と異なる重要な点は、変数を関数の引数として明示的に渡さずに使用(キャプチャ)することができることです。これが「クロージャ」(閉包)という名前の由来でもあります。

let multiplier = 2;
let numbers = vec![1, 2, 3, 4, 5];
let doubled: Vec<i32> = numbers
    .iter()
    .map(|x| x * multiplier)  // 外部の変数multiplierを使用
    .collect();

このとき、multipliermapに引数として渡していないにもかかわらず、クロージャ内で使用できています。これは、クロージャが自分の定義された環境にある変数を「記憶」しているからです。

3.2.3 イテレータの連鎖(チェーン)

イテレータとクロージャの真価は、メソッドを連鎖させた時に発揮されます。Rustでは処理を「パイプライン」のように繋げて書けます。

let words = vec!["hello", "world", "rust", "programming"];
let result: Vec<String> = words
    .iter()
    .filter(|w| w.len() > 4)       // 5文字以上の単語だけを選択
    .map(|w| w.to_uppercase())     // 大文字に変換
    .collect();
// ["HELLO", "WORLD", "PROGRAMMING"]

このチェーンの記載を見ると「何をしているのか」が上から下へと自然に読めると思います。

3.3 構造体とメソッド

Rustの構造体(struct)は、関連するデータをまとめ、またそれらデータに適用できるメソッドをまとめるためのデータ構造です。Pythonのクラスに似ていますが、データの定義とメソッドの実装が分離している点が異なります。

3.3.1 構造体の定義とメソッド

構造体の定義方法およびメソッドの定義、利用方法はPythonと似たものとなっています。ただし所有権システムがあるため、selfの利用の仕方について注意が必要です。

// 長方形の構造体
struct Rectangle {
    width: u32,
    height: u32,
}

// implブロックでメソッドを実装
impl Rectangle {
    // 長方形の幅(width)と高さ(height)を受け取り、
    // 新規のインスタンスを生成するコンストラクタ
    // 大文字のSelfはこの構造体の型(今回の場合は上で定義したRectangle)を示す
    fn new(width: u32, height: u32) -> Self {
        Rectangle { width, height }
    }
    
    // メソッド:&selfのwidthとheightから面積を計算
    // このメソッド使用後も残っていてほしいため、参照(&self)で使用
    fn area(&self) -> u32 {
        self.width * self.height
    }
}

// 使用方法
let rect = Rectangle::new(10, 20);
println!("Area: {}", rect.area());  // 200

メソッドの第一引数は&self(不変借用)、&mut self(可変借用)、またはself(所有権の移動)のいずれかです。メソッド使用後もインスタンスが残っていて欲しい場合は&selfもしくは&mut self、メソッド使用後に不要(削除したい)ならselfを示します。
一方、頭が大文字のSelfは、実装対象の型自身を指します。Selfselfは紛らわしいので留意してください。

3.4 マクロとアトリビュート

2.2節ではprintln!()がマクロであり、関数みたいなものであるということに触れました。、また、rustbpeのコードを読んでいくと#[derive(Debug)]というような記法に出会いますが、これはアトリビュートと呼ばれるものです。マクロとアトリビュートは、コンパイル時には適切なコードに展開されることが共通しています。以下、それぞれを解説します。

3.4.1 マクロ

マクロはコードを生成するコードで、名前に!が付きます。関数のように見えますが、実際はその箇所がコードに展開される点が異なります。コンパイル時に展開される点が異なります

println!("Value: {}", x);  // フォーマット出力
vec![1, 2, 3];             // ベクタ作成

「関数でいいのでは?」と思われるかもしれませんが、マクロでなければ実現できないことがあります。例えばprintln!は、渡されたフォーマット文字列に応じて必要な引数の数や型が変わります。Rustの関数は引数の数と型が固定されているため、このような柔軟な処理は関数では実現できません。

3.4.2 アトリビュート

アトリビュートは#[...]という形式で書かれ、その直後に書かれた要素(構造体、関数など)に追加の情報や振る舞いを付与します。アトリビュートも、マクロと同様にコンパイル時に適切なコードが展開されます。例えば以下のコードでは、二次元空間上の点を表現するPoint構造に対し、値のコピーを得る.clone()メソッドを追加できます。

#[derive(Clone)]     // トレイトの自動実装
struct Point { x: i32, y: i32 }

コードを明示的に書かなくても、構造体や関数に便利な機能を追加できるものとして認識しておいて貰えば良いです。また、どのようなアトリビュートを追加すべきかについては、コンパイラが提示してくれることが多いです。

3.5 トレイトとderive

トレイト(trait)は、型に特定の機能を付与する仕組みです。rustbpeでは標準ライブラリのトレイトを用いて、コード内で定義したPair(バイトのペアの構造体)に対して、比較やソートなどの機能を実装しています。

3.5.1 deriveによるトレイトの自動実装

#[derive(...)]アトリビュートを使うと、トレイトの実装を自動生成できます。
ここでは以下のトレイトを、二次元空間上の点を表現するPoint構造体に適用している例を示します。

  • Clone: 値を複製するclone()メソッドを使えるようにする
  • PartialEq: 値が等しいか否かを調べる==を使えるようにする
#[derive(Clone, PartialEq)]
struct Point {
    x: i32,
    y: i32,
}

let p1 = Point { x: 1, y: 2 };
let p2 = p1.clone();       // Clone: 値の複製
assert_eq!(p1, p2);        // PartialEq: 等価性比較

上記のPartialEqやについては次節でも補足します。

3.5.2 構造体をソート可能にする

rustbpeでは、バイトペアの出現頻度でソートを行う場面があります。以下のPairCountという構造体は、そのペアの出現頻度およびペアのID2つのタプルで構成されていますが、この構造体を出現頻度の大小でソートできるようにする必要があります。そのためには、比較に関するトレイトを実装する必要があります。

#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
struct PairCount {
    count: u64,    // ソートの優先度1:出現回数
    pair: (u8, u8), // ソートの優先度2:ペア自体
}

let mut counts = vec![
    PairCount { count: 5, pair: (65, 66) },
    PairCount { count: 10, pair: (67, 68) },
    PairCount { count: 5, pair: (69, 70) },
];

counts.sort();  // countで昇順、同じならpairで昇順
// 結果: [(5, (65,66)), (5, (69,70)), (10, (67,68))]

#[derive(PartialOrd, Ord)]を使うと、構造体のフィールドを上から順に比較する辞書順(lexicographic order)でソートされます。そのため、ソートの優先度が高いフィールドを上に配置します。上記の例では、まずcountで比較し、countが同じならpairの辞書順で比較します。

4. まとめ

本記事では、rustbpeを読み解くために必要なRustの基礎知識を解説しました。次記事ではrustbpeのコードを丁寧に解説する予定です。実際のコード解説と本記事を併せて確認してもらい、コード理解に役立ててもらえたら幸いです。

Discussion