
皆様こんにちは。現在スパイダープラスはフロントエンド・バックエンドにTypescriptを用いて、型安全なWebアプリケーション開発を行っています。
TypeScriptを用いることで、型の安全性を確保しつつ、高い生産性で開発を進められることが実感できています。
一方で、安全性やパフォーマンスを重視するシステム開発にRustを採用する試みを進めています。
Rustは、TypeScriptとは異なるアプローチでメモリ管理と安全性を実現しています。
この記事では、TypeScript/JavaScriptを基準にしながら、Rustが保証する「メモリ安全性」とは何なのかを探ります。メモリ管理の仕組みなど「安全性」に対する考え方の違いを解説します。
TypeScriptのメモリ管理の仕組み
まず、TypeScript/JavaScriptでコーディングする時どのような仕組みとなっているかを解説します。
TypeScript/JavaScriptでコーディングするとき、ガベージコレクタ(GC)が定期的にゴミをメモリから掃除してくれるので、「このオブジェクトが使われなくなったら、メモリを解放しよう…」と考えずに開発を進められます。
では、GCは具体的にどうやって「ゴミ」を見つけ出すのでしょうか?
これはマーク&スイープ(Mark and Sweep)というアルゴリズムによって実現されています。
マーク&スイープとは簡単に説明すると、まずプログラムのルート(グローバル変数や関数の引数など)から辿れるオブジェクトを「マーク」し、次にマークされなかったオブジェクトを「スイープ(解放)」するという仕組みです。以下、ステップで解説します。
ステップ1: マークフェーズ
- 下図で説明します。プログラムの「ルート」から開始し、参照をたどります。下図では
globalがルートに相当し、グローバル変数、現在実行中の関数のローカル変数、関数の引数など、確実にアクセス可能な場所を表します。 - ルートから辿れるすべてのオブジェクト(図中の
node1~node9)を再帰的に探索し、「到達可能なもの」にマークをつけます。これらのノード間には相互参照や循環参照があっても、ルートから辿れる限りすべてマークされます。
ステップ2: スイープフェーズ
- 次に、メモリ上のすべてのオブジェクトをスキャンし、マークがついていないオブジェクト(=どこからも参照されていないゴミ)を見つけます。図中では「削除対象 (unreachables)」のサブグラフ内にある
unr1、unr2、unr3がこれに該当します。これらのノードは互いに参照し合っていても、globalから到達できないため削除対象となります。 - それらのメモリ領域を解放し、再利用可能にするという仕組みとなります。

この仕組みによって、開発者が明示的に deleteや freeのような命令を書かなくても、使われなくなったメモリは自動的に掃除されます。
これにより、開発者はメモリ管理を意識することなく開発を進めることができ、アプリケーションのビジネスロジックに集中することができます。これはアプリケーション開発においては大きなメリットとなり、高い生産性をもたらすことが期待されます。
Rust - メモリ領域「スタック」「ヒープ」「静的領域」
TypeScriptと違い、RustはGCを持ちません。その代わり、プログラムが利用する主要なメモリ領域、「スタック」、「ヒープ」、「静的領域」を意識して開発を行う必要があります。
なぜメモリ領域を使い分ける必要があるのか?
なぜわざわざメモリ領域を「スタック」「ヒープ」「静的領域」と分けて使う必要があるのでしょうか?
それは、Rustがコンパイル時にメモリ配置を確定させる必要があるからです。
TypeScript/JavaScriptではGCが実行時に動的にメモリを管理してくれますが、Rustには実行時のGCがありません。その代わり、コンパイル時に「どのデータをどこに配置し、いつ解放するか」を決定します。これにより、実行時のオーバーヘッドなしで安全なメモリ管理を実現しているのです。
そのためRustでは、すべての型のサイズがコンパイル時に確定している必要があります。コンパイラが「この変数にはN バイト必要だ」と事前に知ることができれば、スタックに効率的に配置できます。逆に、実行時にしかサイズが分からないデータ(可変長の文字列や配列など)は、柔軟に伸縮できるヒープ領域に配置する必要があるのです。
一見面倒に思えるこの制約がRustの安全性や高速性を実現するための重要な要素となります。以下、それぞれのメモリ領域について詳しく見ていきましょう。
スタック (Stack)
スタックは高速にアクセスできるメモリ領域です。関数が呼び出されるとデータが積まれ(プッシュ)、関数が終了すると自動的にデータが降ろされます(ポップ)。i32のような数値型、bool、固定長の配列など、コンパイル時にサイズが確定しているデータが置かれます。
ヒープ (Heap)
ヒープはスタックと比べると低速ですが、柔軟に利用できるメモリ領域です。プログラムの実行中、任意のタイミングでメモリ領域を確保し、不要になったら解放します。Stringや Vec<T>(可変長配列)など、実行時にサイズが変わる可能性のあるデータが置かれます。
静的領域 (Static Storage)
静的領域はプログラムの実行期間中ずっと存在するメモリ領域です。文字列リテラル(例: "Hello, world!")やグローバル変数など、プログラム全体で共有されるデータが置かれます。static変数もここに配置されます。
TypeScript/JavaScriptではGCがこれらのメモリ管理を自動的に行いますが、Rustでは開発者がこれらを意識して使い分ける必要があるのです。
実例で見るメモリ配置
このスタックとヒープの使い分けが、TypeScriptとRustでどう異なるのか、具体的な例で見ていきましょう。
TypeScriptの場合
まず、TypeScriptでコードを書く際のメモリ管理を考えてみましょう。
// 文字列 const message = "Hello, world!"; let greeting = "Hello"; greeting += ", world!"; // 文字列の連結 // 配列 const numbers = [1, 2, 3]; numbers.push(4); // 要素の追加
TypeScript/JavaScriptでは、このコードを書くとき、文字列がどのメモリ領域に保存されるかを意識することはありません。JavaScriptエンジンが自動的に最適な場所にメモリを割り振ってくれます。
Rustの場合 - 明示的なメモリ管理による安全性
一方、Rustの場合を確認してみましょう。Rustでは、文字列と配列に対して2つの異なる型が用意されており、それぞれが異なるメモリ領域に保存されます。
1. 文字列: &str と String
TypeScriptでは文字列は string型で扱いますが、Rustには主に2種類の文字列型があります。
&str (文字列スライス / 文字列リテラル):
let s1: &str = "Hello, world!";
&strは参照型(データそのものではなく、「データのありかを示す情報(アドレス)」を持つ型のこと)なので、「スタック」上に変数 s1が格納され、s1が「文字列データへのポインタ」と「長さ」という2つの情報を持ちます。実際の文字列データ("Hello, world!")自体は「静的領域」に保存されます。s1はそれを指し示しているだけです。代入時には、このポインタと長さの情報がコピーされます(参照のコピー)。本体が読み取り専用なので、&strの中身を後から変更することはできません。

String (文字列型)
一方で、String型は後から文字を追加したり変更したりできる動的な文字列です。
let mut s2: String = String::from("Hello");
この文字列の実際のデータ ("Hello") は「ヒープ領域」に確保されます。そして s2という変数は、そのヒープデータへのポインタや現在の長さ、確保済みの容量といった管理情報を「スタック」上に保持します。ヒープにあるからこそ、s2.push_str(", world!");のように後からデータを追加できるのです。

&strとの違いは、実際の文字列データが「静的領域」ではなく「ヒープ領域」に配置される点です。ヒープは動的に確保・解放できるため、文字列の長さを後から変更できます。
2. 配列: [T; N] vs Vec<T>
配列も同様です。
- 配列
[i32; 3]:
let mut a: [i32; 3] = [1, 2, 3];
a[0] = 10; // OK! 配列の個々の要素は変更できる
// a.push(4); // コンパイルエラー! 要素の追加はできない
[i32; 3]は「i32型の要素が3つ」という固定長の配列です。サイズがコンパイル時に確定しているため、このデータは丸ごとスタック上に確保されます。
重要なポイント:
- スタックに保存されているため、配列のサイズ変更(要素の追加・削除)はできません
- しかし、配列の各要素の値は変更できます(
mutが必要) - これは、スタック上の固定サイズ領域内で値を書き換えるだけなので、メモリレイアウトに影響しないためです
- ベクタ
Vec<i32>:
let mut v: Vec<i32> = vec![1, 2, 3];
v[0] = 10; // OK! 要素の値を変更
v.push(4); // OK! 要素の追加
v.pop(); // OK! 要素の削除
Vec<T>は可変長の配列で、TypeScriptの Arrayに近いです。実際のデータはヒープ領域に確保され、vは Stringと同様に管理情報をスタックに持ちます。
重要なポイント:
- ヒープに保存されているため、配列のサイズ変更(要素の追加・削除)が可能
- もちろん、各要素の値も変更できます
- ヒープ領域は動的に伸縮できるため、容量が足りなくなれば、より大きなメモリ領域を確保し直してデータを再配置します
ここまで見てきたように、TypeScriptでは string 1つ、Array<T> 1つで済むのに対し、Rustでは &str と String、[T; N] と Vec<T> という2つの型を使い分ける必要があります。
なぜRustはスタックとヒープを使い分けるのか?
ここで自然な疑問が浮かびます。「そもそも、全部ヒープに保存すれば、型を2つに分ける必要はないのでは?」
実際、TypeScript/JavaScriptはまさにそのアプローチを取っており、GCのある言語では基本的にほとんどのデータをヒープに置いて管理しています。しかし、Rustがスタックとヒープを使い分けるのには、明確な理由があります。
1. パフォーマンス - スタックは高速
スタックとヒープでは、メモリの確保・解放にかかる時間に大きな違いがあります。
スタックは高速にアクセスできるメモリ領域です。メモリの確保と解放はポインタの移動だけで完了するため、数ナノ秒で済みます。
一方、ヒープはメモリアロケータと呼ばれるヒープメモリを管理するシステムが、メモリの中から適切な空き領域を探索し、確保するのに時間がかかります。
ヒープ領域の容量は、ヒープ上に「とりあえずこれだけ確保しておいた」メモリ全体のサイズです。上の String型の図では容量を5バイト確保しています。例えば、pushなどでそれに対して文字を追加すると確保した容量5バイトでは足りなくなるケースがあります。
この場合、もっと大きなメモリをヒープに確保する必要があり、この作業を再確保(リアロケーション)と呼びます。リアロケーションは新しいメモリ領域を確保し、既存のデータをコピーし、古いメモリを解放するという一連の操作が必要になるため、非常にコストが高くなり、その分動作が遅くなるのです。
2. メモリ効率 - 管理コストが無駄
ヒープにデータを配置する場合、実際のデータサイズに加えて、メモリ管理のためのオーバーヘッドが必要になります。
例えば、4バイトの整数をヒープに配置すると、データ本体の4バイトに加えて、メモリアロケータが管理情報として16〜32バイトのオーバーヘッドを必要とします。
一方、スタックに配置する場合、このような管理オーバーヘッドは一切不要です。4バイトの整数は、そのまま4バイトのメモリ領域だけで済み、メモリ効率は100%です。
全てをヒープに配置すると、本来必要のない管理コストで大量のメモリを浪費することになります。
3. コンパイル時最適化 - コンパイラが賢く最適化できる
スタック上のデータは、コンパイラが最適化を行います。
例えば、コンパイルする時に常に同じ結果になることがわかれば、その計算を定数に置き換えることができます。 以下のような、スタック上の変数だけを使う単純な関数を考えます。
fn add_on_stack() -> i32 { let a: i32 = 2; let b: i32 = 3; a + b }
この関数は、常に 5 を返すことがコンパイル時にわかるため、コンパイラはこの関数を単に 5 を返すコードに置き換えることができます。これにより、わざわざ実行時に関数を呼び出して、スタックに aと bのメモリを確保して計算する必要がなくなります。
一方、ヒープへのメモリ確保は「副作用」と見なされるため、コンパイラは簡単に省略できません。たとえ計算結果が常に同じであっても、メモリ確保の操作は保持される必要があります。このため、ヒープを使う場合は最適化の余地が制限され、実行時のパフォーマンスを阻害する可能性があるのです。
スタックを活用することで、コンパイラはより積極的な最適化を行い、実行時のコードサイズと実行速度の両方を改善できるのです。
4. データサイズによる使い分け - 巨大なデータはヒープが有利
ここまでスタックの利点を説明してきましたが、常にスタックが最適というわけではありません。
データサイズが大きい場合、スタックに配置するとコピーコストが問題になります。例えば、数メガバイトの画像データや大きな構造体を関数間で受け渡す場合、スタック上でそのまま扱うと、関数呼び出しのたびにデータ全体がコピーされてしまいまい、パフォーマンスが低下する可能性があります。
// 巨大な構造体の例 struct LargeData { buffer: [u8; 1_000_000], // 1MBの配列 } fn process_data(data: LargeData) { // data全体(1MB)がコピーされる }
一方、ヒープにデータを置き、スタックにはそのポインタだけを持つようにすれば、関数呼び出し時にコピーされるのはポインタだけで済みます。
fn process_data(data: Box<LargeData>) { // ポインタ(8バイト)だけがコピーされる }
このように、Rustでは小さなデータは高速なスタックに、動的なサイズや巨大なデータはヒープにという使い分けが重要になります。
まとめ
ここまで見てきたように、TypeScriptとRustのメモリ管理には大きな違いがあります。
TypeScriptの特徴:
- ガベージコレクタが自動的にメモリの配置と解放を行う
- 開発者はメモリを意識せずにコーディングでき、アプリケーションロジックに集中できる
- しかし、GCの動作タイミングは予測不可能で、パフォーマンスへの影響や意図しないメモリリークの可能性がある
- メモリの仕組みを理解していないと、クロージャによる意図しない参照保持や、大きなオブジェクトの不要なコピーなど、気づかないうちにパフォーマンス上の問題を引き起こす可能性がある
Rustの特徴:
- 型システムを通じて、データをどこに配置するかを明示的に制御する
&strは静的領域、Stringはヒープ、[T; N]はスタック、Vec<T>はヒープ、というように配置場所を明確に意識して使い分ける- コンパイル時にメモリ配置が確定し、メモリ解放のタイミングも確定的
TypeScriptはメモリを意識せず開発ができる反面、不要になった値がいつ解放されるか分からず、メモリリークを引き起こす可能性は否定できません。
一方でRustはメモリ管理を明示的に行う必要がありますが、その分パフォーマンスや安全性を高いレベルで保証できます。
どちらが優れているという話ではありませんが、Rustの仕組みを学ぶことで、TypeScriptでは意識しなかったメモリや、型システムの本質に気づくことができます。
このブログを通して、Rustのメモリ管理の仕組みを理解することで、普段は意識していなかったメモリの扱い方や、GCに任せることのトレードオフに気づき、さらなる技術的洞察を得られることを願っています。
皆様もこの機会に、ぜひRustの世界にも触れてみてはいかがでしょうか?
スパイダープラスでは、エンジニアを募集しています。興味のある方はぜひ、カジュアル面談からでもお気軽にご応募ください!
TypeScriptだけでなくRustの経験者の方も大歓迎です。






