Rustを触り始めたとき、「所有権」「ムーブ」「借用(borrow)」という言葉に、戸惑いました。
CやC++、Javaのポインタや参照に似ているのですが、どうもしっくりこない。
ここでは、C → C++ → Java → Rust という流れを、ポインタや参照をどう扱ってきたかという観点から振り返りつつ、なぜRustが「値の渡し方」をここまで厳密に分けたのかを、整理してみます。
1. オブジェクトをどう扱うか?
ポインタや参照、所有権の議論は、「データ・オブジェクト」の管理責任をどこに置くかという問題だと捉えられます。
プログラムを作っていると、いくつかの値をひとまとまりとして扱う必要が出てきます。
たとえば文字列やリスト構造、ファイルを扱うためのハンドルなどです。
これらは、「データ・オブジェクト」と呼び、メモリや外部資源を内部に持つため、生成・利用・解放のタイミングを管理する必要があります。
ここでは、ファイルの文字数を数えるプログラム wc (簡略化) を元に、C言語からRustにかけて、どのように「データ・オブジェクト」を扱ってきたのかを見てみましょう。
1.1. C:ポインタは自由だが自己責任
まずはCです。wc をCで書くと、カウント処理の中心はだいたいこんな関数になります。
static Counts count_file(FILE *fp, Flags flags); /* 関数定義 */
Counts cnt = count_file(fp, flags); /* 関数呼び出し */Code language: JavaScript (javascript)
FILE*は、FILE構造体のポインタです。
Cでは、ポインタは「アドレスを渡す」以上の意味を持ちません。
この形から分かることは少なくて、例えば次のような点はコードを読んでも分かりません。
fpは必ず有効なのかNULLが来る可能性はあるのか- いつまで使っていいのか
- 誰が
fcloseする責任を持つのか
wc のような小さなプログラムでも、stdin を渡すのか、fopen したものを渡すのかで注意点が変わります。
ただ、それらはすべて「人が覚えておくこと」でした。
/* minimal wc: -l -w -c, files or stdin, "-" means stdin */
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
typedef struct {
int l, w, c;
} Flags;
typedef struct {
unsigned long lines, words, bytes;
} Counts;
static int is_space(int ch) {
/* ASCII whitespace set used by this minimal implementation */
return ch==' ' || ch=='\t' || ch=='\n' || ch=='\r' || ch=='\v' || ch=='\f';
}
static Counts count_file(FILE *fp, Flags flags) {
Counts cnt;
int ch;
int in_word = 0;
cnt.lines = cnt.words = cnt.bytes = 0;
while ((ch = fgetc(fp)) != EOF) {
if (flags.c) cnt.bytes++;
if (flags.l && ch == '\n') cnt.lines++;
if (flags.w) {
if (is_space(ch)) {
in_word = 0;
} else if (!in_word) {
cnt.words++;
in_word = 1;
}
}
}
return cnt;
}
static void print_line(Counts cnt, Flags flags, const char *name) {
if (flags.l) printf("%lu ", cnt.lines);
if (flags.w) printf("%lu ", cnt.words);
if (flags.c) printf("%lu ", cnt.bytes);
if (name) printf("%s", name);
printf("\n");
}
static void usage(void) {
fprintf(stderr, "usage: wc [-l] [-w] [-c] [file...]\n");
}
int main(int argc, char **argv) {
Flags flags;
int saw_flag = 0;
int i;
int had_error = 0;
int nfiles = 0;
Counts total;
total.lines = total.words = total.bytes = 0;
flags.l = flags.w = flags.c = 0;
/* parse args (minimal) */
for (i = 1; i < argc; i++) {
if (strcmp(argv[i], "--") == 0) { i++; break; }
if (argv[i][0] == '-' && strcmp(argv[i], "-") != 0) {
int j;
for (j = 1; argv[i][j] != '\0'; j++) {
if (argv[i][j] == 'l') { flags.l = 1; saw_flag = 1; }
else if (argv[i][j] == 'w') { flags.w = 1; saw_flag = 1; }
else if (argv[i][j] == 'c') { flags.c = 1; saw_flag = 1; }
else {
fprintf(stderr, "wc: invalid option -- '%s'\n", argv[i]);
usage();
return 2;
}
}
} else {
break; /* first file operand */
}
}
if (!saw_flag) { flags.l = flags.w = flags.c = 1; }
/* count inputs */
if (i >= argc) {
Counts cnt = count_file(stdin, flags);
print_line(cnt, flags, NULL);
total.lines += cnt.lines; total.words += cnt.words; total.bytes += cnt.bytes;
} else {
for (; i < argc; i++) {
const char *name = argv[i];
FILE *fp;
nfiles++;
if (strcmp(name, "-") == 0) {
fp = stdin;
} else {
fp = fopen(name, "rb");
if (!fp) {
fprintf(stderr, "wc: %s: cannot open\n", name);
had_error = 1;
continue;
}
}
{
Counts cnt = count_file(fp, flags);
print_line(cnt, flags, name);
total.lines += cnt.lines; total.words += cnt.words; total.bytes += cnt.bytes;
}
if (fp != stdin) fclose(fp);
}
if (nfiles > 1) {
print_line(total, flags, "total");
}
}
return had_error ? 1 : 0;
}
Code language: PHP (php)
Cのポインタは、とても強力です。
その一方で、安全性や寿命管理について、言語はほとんど何も語ってくれない、という設計だったと思います。
1.2. C++:参照とconstで「意図」は書ける
次にC++です。
C++で count_stream関数を書くと、こうなります。
static Counts count_stream(std::istream& in, const Flags& flags); // 関数定義
Counts cnt = count_stream(f, flags); // 関数呼び出しCode language: PHP (php)
istream& というのは、入力ストリーム istream への参照です1。
Flags& というのは、Flagsオブジェクトへの参照です。
これは、ポインタに比べると、情報量が増えています。
istream&によって「NULLではない」ことが期待されるconst Flags&によって「この関数は設定を書き換えない」ことが伝わる
これはCから見ると大きな進歩で、特に const は、コードを読む側にとって安心材料になります。
ただし、ここで注意したいのは、保証されているわけではないという点です。
- 同じ
istreamを複数の関数が同時に触ってもコンパイルは通る - 排他的に使われているかどうかは、設計や規約次第
C++は「意図を書くための道具」を増やしてくれましたが、
最終的な安全性は、やはり人間の注意力に依存していると感じます。
1.3. Java:参照に統一しGCに任せた
Javaでは、話が少し変わります。wc の中心となる countStreamメソッドは、次のような形になります。
static Counts countStream(InputStream in, Flags flags) throws IOException;
Counts cnt = countStream(in, p.flags);Code language: JavaScript (javascript)
Javaでは、オブジェクト(非プリミティブ型)はすべて参照で扱われます(&を付けなくても)2。
ポインタ演算はできませんし、メモリ解放はGC(ガベージコレクション)が自動的に行います。
この設計のおかげで、
- ダングリングポインタ
- 二重解放
といった問題からは解放されました。
ただ、その代わりに、寿命やタイミングを制御できないという感覚が残ります。InputStream がいつ解放されるかは、プログラマには分かりません。
wc のような短命なコマンドでも、
「いつcloseされるのか」はコードの流れを追わないと見えてきません。
安全にはなりましたが、低レイヤ寄りの制御感は失われた、という印象を受けます。
1.4. 行き詰まり:安全か、制御か、性能か
ここまでを振り返ると、だいたいこんな整理になります。
/* C:自由で速いが、危険 */
static Counts count_file(FILE *fp, Flags flags; /* 関数定義 */
Counts cnt = count_file(fp, flags); /* 関数呼び出し */Code language: JavaScript (javascript)
// C++:意図は書けるが、保証は弱い
static Counts count_stream(std::istream& in, const Flags& flags); // 関数定義
Counts cnt = count_stream(f, flags); // 関数呼び出しCode language: PHP (php)
// Java:安全だが、メモリ管理を制御できない
static Counts countStream(InputStream in, Flags flags) throws IOException; // 関数定義
Counts cnt = countStream(in, p.flags); // 関数呼び出しCode language: JavaScript (javascript)
この三者を見ていると、
「安全で、制御できて、しかも速い」
という条件を同時に満たすのは、かなり難しいことが分かります。
ここで登場したのがRustでした。
// 関数定義
fn count_reader<R: Read>(mut r: R, flags: Flags) -> io::Result<Counts>;
// 関数呼び出し
count_reader(input, flags) {
Ok(cnt) => {
print_line(cnt, flags, Some(name));
total = add(total, cnt);
}
Err(e) => {
eprintln!("wc: {name}: {e}");
had_error = true;
}Code language: PHP (php)
2. Rust:ポインタの使い方を型にした
Rustは、ポインタを消したわけでも、GCに丸投げしたわけでもありません。
代わりにやったことは、とても割り切っています。
ポインタの使い方そのものを、型として区別する3
use std::env;
use std::fs::File;
use std::io::{self, Read};
use std::process;
#[derive(Clone, Copy)]
struct Flags {
l: bool, // lines
w: bool, // words
c: bool, // bytes
}
#[derive(Default, Clone, Copy)]
struct Counts {
lines: u64,
words: u64,
bytes: u64,
}
fn main() {
let (flags, files) = match parse_args(env::args().skip(1)) {
Ok(v) => v,
Err(msg) => {
eprintln!("{msg}");
eprintln!("usage: wc [-l] [-w] [-c] [file...]");
process::exit(2);
}
};
let mut had_error = false;
let mut total = Counts::default();
// 入力列挙(引数なしなら stdin)
if files.is_empty() {
match count_reader(io::stdin(), flags) {
Ok(cnt) => {
print_line(cnt, flags, None);
total = add(total, cnt);
}
Err(e) => {
eprintln!("wc: {e}");
had_error = true;
}
}
} else {
for name in &files {
match open_input(name) {
Ok(input) => match count_reader(input, flags) {
Ok(cnt) => {
print_line(cnt, flags, Some(name));
total = add(total, cnt);
}
Err(e) => {
eprintln!("wc: {name}: {e}");
had_error = true;
}
},
Err(e) => {
eprintln!("wc: {name}: {e}");
had_error = true;
}
}
}
if files.len() > 1 {
print_line(total, flags, Some("total"));
}
}
if had_error {
process::exit(1);
}
}
fn parse_args<I>(mut args: I) -> Result<(Flags, Vec<String>), String>
where
I: Iterator<Item = String>,
{
let mut flags = Flags { l: false, w: false, c: false };
let mut files: Vec<String> = Vec::new();
let mut saw_flag = false;
while let Some(arg) = args.next() {
if arg == "--" {
files.extend(args);
break;
}
if arg.starts_with('-') && arg != "-" {
// オプション解析(最小):-l -w -c の組み合わせだけ許可
for ch in arg.chars().skip(1) {
match ch {
'l' => { flags.l = true; saw_flag = true; }
'w' => { flags.w = true; saw_flag = true; }
'c' => { flags.c = true; saw_flag = true; }
_ => return Err(format!("wc: invalid option -- '{arg}'")),
}
}
} else {
files.push(arg);
}
}
// オプションが一つも無いなら全部表示
if !saw_flag {
flags = Flags { l: true, w: true, c: true };
}
Ok((flags, files))
}
fn open_input(name: &str) -> io::Result<Box<dyn Read>> {
if name == "-" {
Ok(Box::new(io::stdin()))
} else {
Ok(Box::new(File::open(name)?))
}
}
// Read を全部読むのではなく、チャンクで数える(ミニマルなストリーム処理)
fn count_reader<R: Read>(mut r: R, flags: Flags) -> io::Result<Counts> {
let mut buf = [0u8; 8192];
let mut cnt = Counts::default();
// word判定用の状態:直前が空白だったか
let mut in_word = false;
loop {
let n = r.read(&mut buf)?;
if n == 0 { break; }
if flags.c {
cnt.bytes += n as u64;
}
// -l/-w はバイト走査
if flags.l || flags.w {
for &b in &buf[..n] {
if flags.l && b == b'\n' {
cnt.lines += 1;
}
if flags.w {
let is_space = matches!(b, b' ' | b'\t' | b'\n' | b'\r' | 0x0b | 0x0c);
if is_space {
in_word = false;
} else if !in_word {
cnt.words += 1;
in_word = true;
}
}
}
}
}
Ok(cnt)
}
fn add(a: Counts, b: Counts) -> Counts {
Counts {
lines: a.lines + b.lines,
words: a.words + b.words,
bytes: a.bytes + b.bytes,
}
}
fn print_line(cnt: Counts, flags: Flags, name: Option<&str>) {
// POSIXの厳密な幅寄せは省略(ミニマル)
if flags.l { print!("{} ", cnt.lines); }
if flags.w { print!("{} ", cnt.words); }
if flags.c { print!("{} ", cnt.bytes); }
if let Some(n) = name { print!("{n}"); }
println!();
}
Code language: PHP (php)
2.1. 不変借用:読むだけで絶対に変えない
fn count<R: Read>(r: R, flags: &Flags) -> CountsCode language: HTML, XML (xml)
これは「不変借用(immutable borrow)」です。
C++の const& に近いですが、より強い意味を持ちます。
- この関数は
flagsの中身を変更しない - 変更しようとするとコンパイルエラー
wc の設定値のような「読むだけの情報」に、とても向いています。
2.2. 可変借用:今はこの関数だけが触る
fn count<R: Read + ?Sized>(r: &mut R, flags: &Flags) -> CountsCode language: HTML, XML (xml)
ここがRustらしいところです。&mut は「可変借用(mutable borrow)」で、排他的な利用を意味します4。
(Rustでは、従来の const &がデフォルトになったので、変更可能な参照渡しは &mut になったわけです)
ただし、この可変借用にも制限があり、この間、他の誰も r に触れない、同時に2つの &mut は存在できない、となっています。
wc で入力を読むと、読み取り位置が進みます。
つまり状態が変わります。
その事実を、型として正面から表現しているのが &mut だと思います5。
2.3. move:値を引き渡して、もう使わない
fn count<R: Read>(r: R) -> CountsCode language: HTML, XML (xml)
これは「所有権のムーブ(move)」と呼ばれます。
C風に言えば、「この関数が責任を持って使い切る」という意味です。
寿命や解放の責任は完全に関数側に移ります。
面白いのは、呼び出し元では、r はもう使えません。
所有権移動後の変数は無効化され、使用するとコンパイルエラーになります(挙動は未定義変数に少し似ています)。
Cで「この関数で fclose する」とコメントに書いていたことを、型で強制しているような感覚です。
3. 所有権:オブジェクトのメモリ解放はいつ?
「借用」や「ムーブ」を理解するには、Rustのオブジェクトは、1つの変数しか所有できない仕組みになっていることを知る必要があります。
すべての値には必ず1つの所有者となる変数がいて、変数がスコープを抜けると、値は自動的に解放されます。
let s = String::from("hello"); // s が所有者Code language: JavaScript (javascript)
値(ヒープ上のデータ)を解放する責任を持つ主体が、所有権(owenership)です。
たとえば、文字列のメモリの「所有権」は s にあり、そのスコープを抜けたタイミングで自動的に解放されます。
Rustでは、変数から変数への代入を書いても、両方が「同じ」になる、とは限りません6。
「ムーブ(move)」では、所有権が別の変数に移ります。
let s1 = String::from("hello");
let s2 = s1; // 所有権が s1 → s2 に移動Code language: JavaScript (javascript)
なので、元の変数の方は使えなくなります。
ムーブ(所有権移動)が自然なシチュエーションは、オブジェクトを生成する関数の返り値や、値をコレクションに入れる場合です。
fn make_string() -> String {
String::from("hello") // 移動
}
let s = make_string(); // 移動
let mut v = Vec::new();
v.push(s); // 移動Code language: JavaScript (javascript)
3.1. 借用(borrow)
関数で参照して読むだけや、大きなデータ構造を渡す場合には、「借用(borrow)」を使います。
「借用(borrow)は、所有権を移さずに、一時的に値を使えようにし、参照(& や &mut)によって行われます7。
つまり、値の解放は元の変数のスコープのままです。
シンプルな形では、不変参照になります。
つまり、読み取り専用です。
fn len(s: &str) -> usize {
s.len()
}
let s = String::from("hello");
let length = len(s);Code language: JavaScript (javascript)
関数で状態を更新する場合やコレクションの要素を直接操作する場合など、オブジェクトを変更する場合には、ひと手間必要です。
変数の定義、参照のときに mut というキーワードを付ける必要があるのです8。
let mut v = vec![1, 2, 3];
for x in &mut v {
*x *= 2; / v の中身を変更する
}
4. まとめ
ここまで見ると、Rustの用語が少し違って見えてきます。
CやC++の「参照」は、あくまで別名(エイリアス)です。
一方、Rustの参照は「期限付きで貸し出されるもの」です。
- いつまで借りていいか
- 誰が触っていいか
これらがすべて型で管理されます。
| 選択 | コード例 | シチュエーション |
|---|---|---|
| ムーブ | vec.push(s); | 生成・登録・委譲 |
| 不変借用 | fn length(s: &String) {} | 読み取り・共有 |
| 可変借用 | fn pop(vec: &mut Vector) {} | 更新・編集 |
だから「reference」ではなく「borrow」。
この言葉遣いは、かなり正直だと感じています9。
最初は、Rustの用語はとっつきにくいと思っていました10。
ただ、Cのポインタ問題から順に見ていくと、「こういう言葉で説明しないと、もう表現しきれなかったのでは、と感じるようになりました11。
- C++の参照は言語仕様上nullにならないことが保証されています。ただし、寿命が尽きたオブジェクトへの参照(dangling reference)を作ることは可能で、これは未定義動作を引き起こします。この点でRustの借用チェッカーはより厳格です。 – References and Borrowing – The Rust Programming Language
- Javaの非プリミティブ型(クラス、インターフェース、配列)が参照型として扱われます。プリミティブ型(int, double, booleanなど)は値型であり、参照ではありません。
- Rustでは、Copy トレイトを実装できる型には制約があります。すべてのフィールドがCopyを実装している必要があり、さらにDropトレイトを実装している型(独自のクリーンアップロジックを持つ型)はCopyを実装できません。String や Vec のようなヒープ割り当てを持つ型がCopyを実装できないのは、浅いコピーが二重解放(double free)を引き起こすためです。 – Copy in std::marker – Rust
- 正確には、&mut は「可変借用」というより、「排他的借用(exclusive borrow)」です。「書き込み可能」という点だけでなく、「この借用が存在する間、他のいかなる借用も存在できない」という排他性が重要です。この排他性により、Rustはデータ競合をコンパイル時に防ぎます。 – References and Borrowing – The Rust Programming Language
- Rustの借用規則は「任意の時点で、1つの可変参照、または任意の数の不変参照のいずれか一方のみ」と要約できます。これは「エイリアシング XOR 可変性」とも呼ばれ、データ競合を言語レベルで防ぐ基盤となっています。 – Borrow Checking – Comprehensive Rust
- Rustでは、ムーブとコピーの両方が実装レベルではメモリのビット単位のコピー(memcpy)として実行されることがあります。違いは、コンパイラが元の変数を無効化するかどうかです。Copy型の場合は元の変数も引き続き使用でき、非Copy型の場合は元の変数が無効化されます。 – What is Ownership? – The Rust Programming Language
- Rustの借用システムには「ライフタイム(lifetime)」という概念があります。ライフタイムは参照が有効である期間を表し、コンパイラはすべての参照がその参照先よりも長生きしないことを保証します。この記事では詳細を省略していますが、より複雑なコードではライフタイムパラメータを明示的に指定する必要があります。 – Validating References with Lifetimes – The Rust Programming Language
- Rust 2018 edition以降、Non-Lexical Lifetimes (NLL)という機能により、借用のスコープがより柔軟になりました。従来は借用がスコープ全体で有効でしたが、NLLでは借用が「最後に使用された時点」で終了します。これにより、多くの正当なコードがコンパイルエラーにならなくなりました。 – Non-Lexical Lifetimes – The Rust RFC Book
- Rustには「内部可変性(interior mutability)」というパターンもあります。RefCellやCellなどの型を使うことで、不変参照を通じて内部状態を変更できます。これは借用規則の「例外」ではなく、unsafe コードによって実装された安全な抽象化です。実行時に借用規則をチェックすることで、コンパイル時の制約を回避しています。 – RefCell and the Interior Mutability Pattern – The Rust Programming Language
- この記事で説明しているのは「Safe Rust」の範囲です。Rustには unsafe キーワードを使った「Unsafe Rust」もあり、生ポインタの操作やFFI(他言語との相互運用)など、コンパイラがチェックできない操作を行えます。ただし、unsafe ブロックは最小限に抑え、その周囲に安全な抽象化を構築することが推奨されています。 – Unsafe Rust – The Rust Programming Language
- Rustの所有権システムは「ゼロコスト抽象化(zero-cost abstraction)」として設計されています。すべての所有権と借用のチェックはコンパイル時に行われ、実行時のオーバーヘッドはありません。ガベージコレクションのような実行時のメモリ管理機構を持たないため、Rustは予測可能な性能を実現しています。 – Ownership – The Rust Programming Language