はじめに
C++といえば、メモリ管理に始まりメモリ管理に終わる――そんな言語です。私自身、他の言語も扱ってきたとはいえ、20年以上に渡ってC++と付き合ってきましたが、未だに
delete
忘れによるメモリリークや二重解放によるクラッシュに悩まされることがあります。
近年、Rustが注目されています。その中心にあるのが「所有権」という考え方です。C++にも
std::unique_ptr
などに似たような概念はありますが、Rustはそれを言語仕様のど真ん中に据えています。今回は、C++の歩みを振り返りつつ、C++とRustの違いを見てみたいと思います。
C++:生ポインタ時代の苦い思い出
昔々、C++を始めた頃のメモリ管理といえば、もちろん「生のポインタ」です。
Foo* p = new Foo();
Foo* q = p;
delete p;
p = nullptr; // 二重解放防止のnull代入
// ...ここで何か別の処理をやったあと...
delete q; // qにコピーしたポインタはそのままなのでクラッシュ
当時はVisual C++のデバッグヒープや、
Purify
といったツールを使いながら、どこかで
delete
を忘れていないか、あるいは二重解放していないかをチェックするのが日常でした。
「プログラマが気をつければ大丈夫」 という思想の時代です。
std::auto_ptr
:迷走の時代
C++98で導入された
std::auto_ptr
は、当時としては画期的でした。ポインタがオブジェクトの「所有権」を持ち、所有権を失ったら自動的に解放されるという発想です。
{
std::auto_ptr<Foo> p(new Foo());
std::auto_ptr<int> q = p; // 所有権がqに移動
} // qが消えると自動でオブジェクトが解放される
ところが、std::auto_ptrでは、所有権が移動すると、移動元のポインタはnullになるという、ややこしい仕様でした。
std::auto_ptr<Foo> p(new Foo());
std::auto_ptr<Foo> q = p; // ポインタのコピーのように見えるが、実際にはpはnullになる
q->DoSomething(); // これはOK
p->DoSomething(); // これはクラッシュ!
コピーするとnullになるのですから、コンテナに入れたら大惨事です。(C++ STLのコンテナは、内容物をコピーすることで移動やソートなどを行うので)結局、C++11で非推奨となり、
C++17では削除されてしまいました。
歴史的には「所有権」を導入しようとした最初の試みでしたが、このような致命的な問題があったために、短命に終わりました。
std::unique_ptr
:ようやく辿り着いた一つの答え
C++11で登場した
std::unique_ptr
は、「所有権を持つポインタは常に一つ」というルールを型で表現しました。
std::unique_ptr<Foo> p(new Foo());
// std::unique_ptr<Foo> q = p; // コピー不可(コンパイルエラー)
std::unique_ptr<Foo> q = std::move(p); // 明示的に移動
// コンテナに入れても安全
std::vector<std::unique_ptr<Foo>> v;
v.push_back(std::make_unique<Foo>());
「コピーできないけど、
std::move
で移動できる」という仕様は、C++にとっては大きな転換点でした。
私はこの頃、「ようやくC++もメモリ安全になってきた」と感じたものです。
std::shared_ptr
:所有権を「共有」する
現実のソフトウェアでは「リソースを複数の場所から参照したい」場面も少なくありません。そこで登場したのが
std::shared_ptr
でした。
void Func()
{
auto p = std::make_shared<Foo>(); // 最初は参照カウント1
std::shared_ptr<Foo> q = p; // 所有権を共有(参照カウント加算)
} // pとqの両方が消滅すると参照カウントが0になりオブジェクトが解放される
std::shared_ptr
は参照カウント方式でメモリを管理しており、すべての参照がなくなったときに解放されます。ただし、ガーベージコレクタと違って循環参照を自動的に解決することはできないため、
std::weak_ptr
を併用して慎重に管理する必要があります。
Rust:所有権が言語の中心にある世界
さて、Rustに出会って驚いたのは、
所有権の概念が、はじめから言語仕様に組み込まれていることです。
let s1 = String::from("hello");
let s2 = s1; // 所有権がs2に移動
// println!("{}", s1); // コンパイルエラー: s1は無効
println!("{}", s2);
C++の
std::unique_ptr
に似ていますが、Rustではすべての値がこのルールに従います。
ポインタを明示的に書かなくても、コンパイラが「誰が所有者か」を常に把握しているのです。
借用と「可変参照」
RustにはC++にはない「借用」の概念があります。
let s = String::from("hello");
let r1 = &s;
let r2 = &s; // 不変参照はいくつでも作れる
println!("{}, {}", r1, r2);
また「可変参照」という言葉も登場します。これは、
変数がデフォルトで不変(immutable)であるRustにおいて特に重要です。
- Rustの変数はデフォルトでは再代入も内容変更もできません。
- 変更可能にするには
mut
という特別な宣言を書く必要があります。
- 参照についても同様で、「不変参照 (
&T
)」は何個でも持てますが、「可変参照 (&mut T
)」は同時に一つだけ。
let mut s = String::from("hello");
let r1 = &mut s;
// let r2 = &mut s; // コンパイルエラー: 可変参照は1つしか作れない
r1.push_str(" world");
println!("{}", r1);
C++では「変数は変更可能、変更不可にしたいときだけconst宣言」でしたが、Rustでは「デフォルトでは不変、変更可能にしたいときだけmut宣言」という設計になっています。ここに、Rustの「安全第一」の思想がよく表れています。
Rustの「共有所有権」:Rc
と Arc
C++の
std::shared_ptr
に対応するのが、Rustの
Rc
と
Arc
です。
Rc<T>
:シングルスレッド用の参照カウント
use std::rc::Rc;
fn main() {
let a = Rc::new(String::from("hello"));
let b = Rc::clone(&a); // Rcは明示的にcloneする
println!("a = {}, b = {}", a, b);
println!("count = {}", Rc::strong_count(&a));
}
Rc
はスレッド間で安全に共有できませんが、シングルスレッドで「複数の所有者」を持ちたいときに使います。
Arc<T>
:マルチスレッド用の参照カウント
use std::sync::Arc;
use std::thread;
fn main() {
let data = Arc::new(42);
let d1 = Arc::clone(&data);
let handle = thread::spawn(move || {
println!("child: {}", d1);
});
println!("main: {}", data);
handle.join().unwrap();
}
Arc
は「Atomic Reference Count」の略で、スレッド間共有を安全に行えます。C++の
std::shared_ptr
に一番近い存在です。
Rustでは
Rc
も
Arc
も
不変参照しか提供しないため、可変にしたい場合は
RefCell
や
Mutex
などと組み合わせます。これは「可変性の制御」と「所有権の制御」をきっちり分離するためで、C++とは異なる発想です。
まとめ:C++の進化とRustの思想
-
C++
- 生ポインタ時代:完全に自己責任
std::auto_ptr
:所有権を導入したが失敗(C++17で削除)
std::unique_ptr
:所有権の明確化に成功
std::shared_ptr
:参照カウントによる共有所有権(ただし循環参照注意)
-
Rust
- 所有権・借用・ライフタイムを言語仕様で保証
- 「変数はデフォルト不変」「可変参照は一度に一つだけ」というルールで安全性を担保
- 「危険なコードはコンパイルできない」という思想
Rc
/ Arc
による共有所有権(可変性は別の仕組みで制御)
20年C++を書いてきた私にとって、Rustの所有権は「かつて夢見ていた理想の姿」に近いものです。もちろん学習コストはそれなりにありますが、あの頃必死でメモリリークと戦っていた自分に教えてやりたい。「未来の言語では、そんな心配をコンパイラが肩代わりしてくれるぞ」と。