最近、趣味でRustの勉強を始めました。
RustにはPythonと異なる特徴が多く、学んでいくうちにPythonを改めて見つめ直す機会にもなっています。
Rustの特徴的な概念の1つに「所有権」があります。「これがあるからメモリが安全なんだよ!」という話らしいですが、Pythonを書いているときにはそもそもメモリのことを意識したことがありませんでした。
ということで、PythonとRustを比較しながらそれぞれどんな感じでメモリ管理をしているのかを緩くまとめました。
なんとなく「ふむふむ」と思ってもらえたら嬉しいです。
記事内容に間違いがありましたら、優しく指摘してほしいです。
メモリって何してるの?
プログラムが動く時に、変数に格納したりして私たちはいろいろな値を使っていますが、それをメモリに一時的に保存して、使い終わったら捨てて、プログラムは動き続けています。
使わない値は適宜削除してメモリを解放しないと、メモリがいっぱいになって処理ができなくなってしまいます。
なので、使わなくなったデータは消して、使うやつを残してということをする必要があります。
その時に発生する代表的な問題が二重解放やダングリングポインタです。
二重解放:すでにメモリから削除した値を再び削除しようとする
ダングリングポインタ:すでにメモリから削除した値を使おうとしてしまう
Pythonでコードを書いていても、うっかり削除した変数を使おうとするとクラッシュしますが、それと同じですね。
私は明示的に変数を削除することは少ないですが、後ろではPythonがよしなにやってくれています。
Pythonのメモリ管理担当:ガベージコレクション
Pythonを書いている時にメモリを意識することはほぼなかったですが、Pythonではガベージコレクションというものが、メモリをよしなに管理しています。
Pythonのガベージコレクションは、メモリ管理の負担を開発者から取り除いてくれる非常に便利な仕組みです。これを「部屋」と「椅子」に例えて説明してみます。
- メモリ = 部屋
- 値 = 椅子
値が作られると、その値を持った椅子が部屋に入ってくると考えてみてください。
a = "Hello world" # aが"Hello world"という椅子を持つ
b = a # "Hello world"という椅子をaとbの2つが共有する
del a # aは削除されたが、bがまだ"Hello world"を持っているので、椅子を片付ける(メモリを解放する)ことはできない
del b # bも削除され、誰も"Hello world"を持たなくなったので、椅子を片付ける
後ろ側ではこのようなことが行われています。
1つの値がどこからも参照されなくなったら、メモリが解放されます。
、Pythonではオブジェクトがどれだけ参照されているかを追跡するために参照カウント
が利用されています。先ほどの例で説明すると次のようになります。
a = "Hello world" # "Hello world"の参照カウントは1
b = a # "Hello world"の参照カウントは2
del a # "Hello world"の参照カウントは1
del b # "Hello world"の参照カウントは0になり、"Hello world"はメモリから解放される
これはかなり分かりやすいですが、循環参照が発生すると参照カウントが0にならないので、メモリを解放することができません。
循環参照が発生した場合は、Pythonのガベージコレクタ(GC)がそれを検知し、使われていないならばメモリを解放してくれます。
class Node:
def __init__(self):
self.ref = None
a = Node()
b = Node()
a.ref = b # aがbを参照
b.ref = a # bがaを参照(循環参照)
del a
del b
# 参照カウントだけでは解放されないが、GCが解放する
よしなにしてくれています。
このように、Pythonのガベージコレクションはバックグラウンドでうまくメモリを管理してくれます。また、よく使われるオブジェクトは頻繁にチェックされ、あまり使われないものは時々チェックするという「世代別ガベージコレクション」という最適化が行われています。これにより処理量を減らしつつ、メモリが圧迫されないように管理されています。
ただし、このメモリ管理は実行時に行われるため、オブジェクトを作成したり削除するたびにガベージコレクションが働きます。そのため、このガベージコレクション処理にはオーバーヘッド(追加の計算コストや処理負荷)があり、処理速度に悪影響を及ぼすこともあります。
Rustのメモリ管理
それでは、Rustのメモリ管理はどうなっているのでしょうか?Rustでは、Pythonとは異なるメモリ管理のアプローチを採用しています。それが**「所有権」**という仕組みです。
Rustの所有権システムは、次の3つのルールに基づいています:
-
値には所有者が1つだけ存在する:
- 各オブジェクトには、そのオブジェクトを管理する1つの「所有者」が存在します。
- 所有者はスコープ内にのみ存在し、スコープを抜けると自動的にメモリが解放されます。
-
所有権の移動(Move):
- 所有権は他の変数に「移動」することができます。これにより、オブジェクトの所有者は常に1つだけに保たれます。
- Pythonのような「複数の参照が可能」という仕組みではなく、オブジェクトの管理を非常に厳密に行います。
-
借用(Borrowing):
- 一時的に他の変数にアクセスするには「借用」を行います。借用には「不変の借用」と「可変の借用」があり、不変の借用は複数存在できますが、可変の借用は1つのみという制約があります。これにより、同時に複数のスレッドが同じデータを変更することを防ぎます。
所有権についてはRustのドキュメントの方が詳しく書かれています。
機になる方はそちらを参照してください。
Rustの所有権を部屋と椅子で説明
Rustでも「部屋」と「椅子」を使って所有権を説明してみましょう。
- メモリ(ヒープ) = 部屋
- 値 = 椅子
- 所有者 = 椅子を持っている人(変数)
Rustでは「誰がどの椅子を持っているのか」という所有権が厳密に管理されています。
基本的な所有権のルール
-
椅子(値)には必ず1人の所有者(変数)がいる:
- 1つの椅子を持てるのは1人だけです。他の人に渡す場合は、完全に譲る必要があります。(シェア・共有できない)
-
所有者が部屋から出ていったら(スコープから外れたら)、椅子は片付けられる:
- Rustではスコープを抜けた時に、その変数が管理しているメモリが自動的に解放されます。
例
fn main() {
let a = String::from("Hello world"); // aが"Hello world"という椅子を持つ
let b = a; // aからbに椅子を「移動」する
// println!("{}", a); // ここでaを使おうとするとコンパイルエラーになる!
println!("{}", b); // bは問題なく使える
}
解説:所有権の移動(Move)
-
let a = String::from("Hello world");
- 最初に、
a
という変数が"Hello world"という椅子を持ちます。つまり、a
が所有者です。
- 最初に、
-
let b = a;
-
a
からb
に所有権を「移動」しました。この時点で、a
はもはや所有者ではなくなり、椅子を持っていません。 - Rustでは、この「移動」により所有者が1人だけというルールを守ります。そのため、
a
を使おうとするとコンパイルエラーになります。
-
借用(Borrowing)の例
次に「借用」について説明します。「借用」は、椅子を一時的に他の人に使わせることを意味しますが、所有権は移動しません。
fn main() {
let a = String::from("Hello world"); // aが"Hello world"という椅子を持つ
let b = &a; // bはaから椅子を「借りる」(参照)
println!("{}", a); // aはまだ所有者なので使える
println!("{}", b); // bも参照として使える
}
解説:借用(Borrowing)
-
let a = String::from("Hello world");
-
a
が"Hello world"という椅子を所有しています。
-
-
let b = &a;
-
b
はa
から椅子を「借りる」形になります。この場合、所有権は依然としてa
にあります。 -
b
はただ一時的に椅子を使わせてもらっているだけなので、a
が部屋から出ていく(スコープを抜ける)まで、b
もその椅子を利用できます。
-
Rustの安全なメモリ管理
- 所有者が1つだけというルールを持つことで、Rustは二重解放やダングリングポインタ(解放されたメモリにアクセスしてしまうこと)を防いでいます。
- また、借用の仕組みを利用して、同時に複数の人が椅子を変更しようとする(データ競合)ことを防いでいます。
Rustのメモリ管理は、この「所有権」と「借用」によって、メモリの安全性をコンパイル時に保証しています。そのため、実行時にメモリ安全を保証するPythonのガベージコレクションのようなオーバーヘッドを回避しています。
PythonとRustのまとめ
- Pythonでは、部屋の中に椅子がいくつあるかをガベージコレクションが常に見ていて、不要になったら椅子を片付けてくれます。
- Rustでは、椅子の所有者は必ず1人で、所有者がいなくなったら自動的に片付けます。また、他の人が一時的に使う場合は「借りる」ことで、誰が何を持っているかを厳密に管理します。
Pythonのメモリ管理は実行時に行われるのでコーディング時には意識する必要がありません(意識している人いたらすみません)。そのため、学習コストや開発コストを下げることができます。ただ実行時にガベージコレクションが常に動いているので、処理速度においてはネガティブです。
それに対してRustは所有権という概念を用いることでコーディング時にメモリ安全を明確にしてコンパイル時にその安全性を保証しています。なので実行時は何も気にせず動いてもメモリ安全がすでに保証されているので、高速に動きます。ただ学習や開発のコストが上がるので、そこは大変だよということになります。
項目 | Python | Rust |
---|---|---|
メモリ管理の方法 | ガベージコレクションによる自動管理 | 所有権と借用のシステムに基づく静的管理 |
メモリ管理のタイミング | 実行時にガベージコレクションが動作 | コンパイル時に所有権に基づくメモリ管理 |
メモリ安全性の保証 | コーディング時には意識しなくてよい | 所有権によってコーディング時にメモリ安全を意識する |
学習コスト | 低い(メモリ管理を自動で行うため簡単) | 高い(所有権と借用ルールを理解する必要がある) |
開発コスト | 低い(メモリ管理の自動化により手軽) | 高い(メモリ管理を明示的に行う必要がある) |
実行時のパフォーマンス | ガベージコレクションのオーバーヘッドにより低下する可能性あり | コンパイル時にメモリ安全が保証され、実行時のオーバーヘッドが少なく高速 |
多分こういうことです。
私もRust勉強中なので、まだ他の話もありそうですが、大体はこんな感じの認識で大丈夫だと思います。
感想
比較対象ができることで、より自分がメインにしている言語のメリット・デメリットを理解することができています。
仕事で使おうと思うと気が重くなりますが、気楽に勉強する範囲ではいい経験になると思います。
特に独学で「とりあえずコードが動けばいいや」というテンションでやってきた私からすると「そんなこと考えたことなかったわ」ということだったので、今更基礎を理解している感じがします。