C++とRustのメモリ安全性に関する議論は、ソフトウェアエンジニアリングにおいて最も活発なトピックの一つとなっています。政府機関が見解を示し、カンファレンスでは専用の講演が行われ、双方に強い意見が存在します。

最初にはっきり言っておきます。Rustは素晴らしい言語です。 所有権モデルと借用チェッカーは真に革新的であり、バグの種類をまるごとコンパイル時に検出します。新しいプロジェクトを始めるにあたり、Rustがチームやエコシステムに適しているなら、それは素晴らしい選択です。

同時に、C++は世界で最もパフォーマンスが求められるソフトウェアの根幹であり続けています。オペレーティングシステムのカーネル、ゲームエンジン、ブラウザ、データベース、金融システムなどです。これは偶然ではなく、それらのチームがRustの存在を知らないからでもありません。

この記事で探求したいのは、議論の中でしばしば見落とされるポイントです。モダンC++(C++11以降)は、メモリ安全なコードを書くための堅牢なツールを提供しています。 スマートポインタ、RAII、境界チェック付きコンテナ、そして豊富な標準ライブラリは、Rustのコンパイラが強制するのと同じカテゴリのバグに対処します。両言語は同じ問題に対して異なる設計アプローチを取っているだけです。すなわち、コンパイラによる強制 vs. 開発者が選択するイディオムです。

抽象的に議論するのではなく、具体的な実行可能なコード例でお見せしましょう。

免責事項: この記事で述べられている意見は私個人のものであり、私の実務経験に基づいています。すべてのコード例は教育目的で意図的に簡略化しています。どの言語にもトレードオフがあります。この記事は思慮深い議論を奨励することを目的としており、いかなる言語やそのコミュニティを貶めるものではありません。結果は個人によって異なる場合があります。

例1:ダングリングポインタ(解放後使用)

これは最も一般的なメモリ安全性のバグの一つです。メモリが解放された後も、そのメモリを指すポインタが使われ続けます。

悪いC++(悪い慣習)

 1#include <iostream>
 2
 3int main() {
 4    int* ptr = new int(42);
 5    std::cout << "Value: " << *ptr << std::endl;
 6
 7    // ptr is now dangling (it still holds the address of freed memory)
 8    delete ptr;
 9
10    // Undefined behavior: reading freed memory
11    std::cout << "After delete: " << *ptr << std::endl;
12
13    return 0;
14}

何が問題だったのか: 開発者がnewで手動メモリ確保を行い、deleteで手動解放し、その後も生ポインタを使い続けました。これは教科書的な解放後使用のバグです。問題はC++がこれを「許した」ことではなく、モダンな代替手段が存在するにもかかわらず、開発者が1990年代のパターンを使ったことです。

良いC++(モダンな慣習)

 1#include <iostream>
 2#include <memory>
 3
 4int main() {
 5    auto ptr = std::make_unique<int>(42);
 6    std::cout << "Value: " << *ptr << std::endl;
 7
 8    // Explicitly releases the memory
 9    ptr.reset();
10
11    if (!ptr) {
12        std::cout << "Pointer is null, safe to handle." << std::endl;
13    }
14
15    return 0;
16}

何が変わったのか:

std::unique_ptrがリソースを所有します。スコープ外になるかリセットされると、メモリが解放され、かつポインタはnullチェック可能になります。ダングリングポインタは存在しません。未定義動作もありません。これは2011年からの標準C++です。

例2:バッファオーバーフロー

配列の境界を超えた読み書きは、脆弱性のもう一つの典型的な原因です。

悪いC++(悪い慣習)

 1#include <iostream>
 2#include <cstring>
 3
 4int main() {
 5    char buffer[10];
 6
 7    // Copying more data than the buffer can hold
 8    const char* source = "This string is way too long for the buffer";
 9    std::strcpy(buffer, source);
10
11    std::cout << "Buffer: " << buffer << std::endl;
12    return 0;
13}

何が問題だったのか:

開発者がC時代の関数(strcpy)を、固定サイズのC配列と境界チェックなしで使用しました。これはC++の評判を悪くするようなコードですが、モダン C++ではありません。C++コンパイラでコンパイルされたCコードです。

良いC++(モダンな慣習)

 1#include <iostream>
 2#include <string>
 3
 4int main() {
 5    std::string buffer;
 6
 7    // std::string manages its own memory and grows as needed
 8    buffer = "This string is way too long for a fixed buffer, but std::string handles it.";
 9
10    std::cout << "Buffer: " << buffer << std::endl;
11
12    // Need a substring? Bounds-checked:
13    try {
14        std::string sub = buffer.substr(0, 10);
15        std::cout << "Substring: " << sub << std::endl;
16
17        // This would throw std::out_of_range:
18        // std::string bad_sub = buffer.substr(9999, 10);
19    } catch (const std::out_of_range& e) {
20        std::cout << "Caught: " << e.what() << std::endl;
21    }
22
23    return 0;
24}

何が変わったのか:

std::stringは自身のメモリを管理します。オーバーフローすることはありません。.at()や無効なインデックスでの.substr()による範囲外操作を試みると、キャッチ可能な例外がスローされます。これはC++98から標準に含まれています。

例3:配列の範囲外アクセス

悪いC++(悪い慣習)

 1#include <iostream>
 2
 3int main() {
 4    int arr[5] = {10, 20, 30, 40, 50};
 5
 6    // Accessing index 10 (way out of bounds)
 7    std::cout << "arr[10] = " << arr[10] << std::endl;
 8
 9    return 0;
10}

何が問題だったのか:

生のC配列には境界チェックが一切ありません。開発者が無効なインデックスにアクセスし、ゴミデータ(あるいはさらに悪いことにセキュリティ脆弱性)を得ました。繰り返しますが、これは最も原始的なデータ構造を使うという開発者の選択です。

良いC++(モダンな慣習)

 1#include <iostream>
 2#include <array>
 3#include <stdexcept>
 4
 5int main() {
 6    std::array<int, 5> arr = {10, 20, 30, 40, 50};
 7
 8    try {
 9        // .at() performs bounds checking and throws on invalid access
10        std::cout << "arr.at(2) = " << arr.at(2) << std::endl;
11        std::cout << "arr.at(10) = " << arr.at(10) << std::endl;
12    } catch (const std::out_of_range& e) {
13        std::cout << "Caught out-of-range: " << e.what() << std::endl;
14    }
15
16    return 0;
17}

何が変わったのか:

std::arrayは自身のサイズを知っている固定サイズのコンテナです。.at()メソッドは実行時に境界チェックを行い、範囲を超えた場合は例外をスローします。C++11から利用可能です。

例4:二重解放

悪いC++(悪い慣習)

 1#include <iostream>
 2
 3int main() {
 4    int* ptr = new int(100);
 5    std::cout << "Value: " << *ptr << std::endl;
 6
 7    delete ptr;
 8    // Double free (undefined behavior, potential crash or exploit)
 9    delete ptr;
10
11    return 0;
12}

何が問題だったのか:

開発者が同じメモリを二度解放しました。これはヒープアロケータの内部状態を破壊する可能性があり、よく知られた攻撃ベクターです。これもまた手動メモリ管理のミスです。

良いC++(モダンな慣習)

 1#include <iostream>
 2#include <memory>
 3
 4int main() {
 5    auto ptr = std::make_unique<int>(100);
 6    std::cout << "Value: " << *ptr << std::endl;
 7
 8    // Memory is freed exactly once when ptr goes out of scope.
 9    // You literally cannot double-free with unique_ptr.
10    // Calling reset() is also safe:
11    ptr.reset();
12    // No-op. No crash. No UB.
13    ptr.reset();
14
15    std::cout << "Done safely." << std::endl;
16    return 0;
17}

何が変わったのか:

std::unique_ptrは単一所有権のセマンティクスを保証します。メモリはちょうど一度だけ解放されます。ポインタがスコープ外になった時か、reset()が呼ばれた時です。既にnullのunique_ptrに対してreset()を呼んでも、安全にノーオペレーションとなります。

例5:共有所有権の間違った方法と正しい方法

悪いC++(悪い慣習)

 1#include <iostream>
 2
 3void use(int* p) {
 4    std::cout << "Using: " << *p << std::endl;
 5}
 6
 7int main() {
 8    int* shared = new int(99);
 9
10    use(shared);
11
12    delete shared;
13
14    // Another function tries to use the same pointer (use-after-free)
15    use(shared);
16
17    return 0;
18}

何が問題だったのか:

コードの複数箇所が生ポインタを共有しており、明確な所有権がありません。一箇所がメモリを削除すると、他のすべての箇所がダングリングポインタを保持することになります。

良いC++(モダンな慣習)

 1#include <iostream>
 2#include <memory>
 3
 4void use(std::shared_ptr<int> p) {
 5    std::cout << "Using: " << *p
 6              << " (ref count: " << p.use_count() << ")" << std::endl;
 7}
 8
 9int main() {
10    auto shared = std::make_shared<int>(99);
11
12    use(shared);
13    use(shared);
14
15    // Memory freed automatically when the last shared_ptr goes out of scope.
16    std::cout << "Final ref count: " << shared.use_count() << std::endl;
17
18    return 0;
19}

何が変わったのか:

std::shared_ptrは参照カウントを使って共有所有権を管理します。メモリは、それを参照している最後のshared_ptrが破棄された時にのみ解放されます。ダングリングポインタは発生しません。手動のdeleteも不要です。C++11から利用可能です。

例6:RAII - モダンC++の安全性

RAII(Resource Acquisition Is Initialization:リソース獲得は初期化である)は、C++において最も重要なイディオムと言えるでしょう。考え方はシンプルです。リソースのライフタイムをオブジェクトのライフタイムに紐付けるのです。オブジェクトが生成されるとリソースを獲得し、オブジェクトが破棄される(スコープ外になる)とリソースを解放します。手動のクリーンアップは不要で、忘れる可能性もありません。

スマートポインタはRAIIの一つの応用に過ぎません。このパターンはあらゆるリソースに使えます。ファイル、ミューテックス、ネットワークソケット、データベース接続、GPUハンドルなど、何にでも適用できます。

悪いC++(手動リソース管理)

 1#include <iostream>
 2#include <fstream>
 3#include <stdexcept>
 4
 5void process_file(const std::string& filename) {
 6    FILE* f = fopen(filename.c_str(), "r");
 7    if (!f) {
 8        throw std::runtime_error("Cannot open file");
 9    }
10
11    char buf[256];
12    if (!fgets(buf, sizeof(buf), f)) {
13        // Oops - if we throw or return here, we leak the file handle
14        throw std::runtime_error("Cannot read file");
15    }
16
17    // If any exception is thrown above, fclose is never called
18    fclose(f);
19    std::cout << "Read: " << buf << std::endl;
20}
21
22int main() {
23    try {
24        process_file("example.txt");
25    } catch (const std::exception& e) {
26        std::cerr << "Error: " << e.what() << std::endl;
27    }
28    return 0;
29}

何が問題だったのか:

開発者がCスタイルのfopen/fcloseを使用しました。オープンとクローズの間に例外がスローされると、ファイルハンドルがリークします。リソースやエラーパスの数が増えるにつれ、すべてを確実にクローズすることはメンテナンスの悪夢になります。

良いC++(std::ifstreamによるRAII)

 1#include <iostream>
 2#include <fstream>
 3#include <string>
 4
 5void process_file(const std::string& filename) {
 6    std::ifstream file(filename);
 7    if (!file.is_open()) {
 8        throw std::runtime_error("Cannot open file");
 9    }
10
11    std::string line;
12    if (!std::getline(file, line)) {
13        throw std::runtime_error("Cannot read file");
14    }
15
16    // No need to close (std::ifstream closes automatically when it goes out of scope, even if an exception is thrown)
17    std::cout << "Read: " << line << std::endl;
18}
19
20int main() {
21    try {
22        process_file("example.txt");
23    } catch (const std::exception& e) {
24        std::cerr << "Error: " << e.what() << std::endl;
25    }
26    return 0;
27}

何が変わったのか:

std::ifstreamはファイルハンドルのRAIIラッパーです。ファイルはコンストラクタでオープンされ、デストラクタでクローズされます。これは関数がどのように終了しても(通常のreturn、例外、早期リターンでも)保証されています。設計上、リソースリークはゼロです。

カスタムリソースに対するRAII(ミューテックスロック)

RAIIは標準ライブラリに限定されません。スレッド同期での使い方を見てみましょう:

 1#include <iostream>
 2#include <mutex>
 3#include <thread>
 4#include <vector>
 5
 6std::mutex mtx;
 7int shared_counter = 0;
 8
 9void increment(int times) {
10    for (int i = 0; i < times; ++i) {
11        // std::lock_guard locks the mutex on construction,
12        // unlocks it on destruction (end of scope, even if an exception occurs)
13        std::lock_guard<std::mutex> lock(mtx);
14        ++shared_counter;
15    }
16}
17
18int main() {
19    std::vector<std::thread> threads;
20    for (int i = 0; i < 4; ++i) {
21        threads.emplace_back(increment, 10000);
22    }
23
24    for (auto& t : threads) {
25        t.join();
26    }
27
28    // Always prints 40000 (no data race, no forgotten unlock)
29    std::cout << "Counter: " << shared_counter << std::endl;
30
31    return 0;
32}

独自のRAIIラッパーの作成

RAIIはあらゆるリソースに適用できます。最小限の例を示します:

 1#include <iostream>
 2#include <stdexcept>
 3
 4// A generic RAII wrapper (acquires in constructor, releases in destructor)
 5class SocketHandle {
 6    int fd_;
 7public:
 8    explicit SocketHandle(int fd) : fd_(fd) {
 9        if (fd_ < 0) throw std::runtime_error("Invalid socket");
10        std::cout << "Socket " << fd_ << " acquired." << std::endl;
11    }
12
13    ~SocketHandle() {
14        if (fd_ >= 0) {
15            std::cout << "Socket " << fd_ << " released." << std::endl;
16            fd_ = -1;
17        }
18    }
19
20    // Prevent copying (single ownership, like unique_ptr)
21    SocketHandle(const SocketHandle&) = delete;
22    SocketHandle& operator=(const SocketHandle&) = delete;
23
24    // Allow moving
25    SocketHandle(SocketHandle&& other) noexcept : fd_(other.fd_) {
26        other.fd_ = -1;
27    }
28
29    int get() const { return fd_; }
30};
31
32void do_network_stuff() {
33    SocketHandle sock(42); // "Acquired"
34    std::cout << "Using socket " << sock.get() << std::endl;
35    // Even if an exception were thrown here, the socket is released.
36} // sock goes out of scope (destructor called, socket released)
37
38int main() {
39    do_network_stuff();
40    std::cout << "Back in main - no resource leak." << std::endl;
41    return 0;
42}

重要なポイント:

RAIIは回避策でも「ベストプラクティスのヒント」でもありません。一貫して適用すればリソースリークを構造的に不可能にする、C++の基本的な設計パターンです。Rustの所有権モデルよりも前に存在し、多くの面でそのモデルに影響を与えました。

Rustはどう比較されるのか?

Rustの借用チェッカーは、これらの問題の多くをコードが実行される前に、コンパイル時に検出します。これは真に重要な利点であり、正当に評価されるべきです。

トレードオフについて、正直にまとめるとこうなります:

  1. Rustはコンパイラレベルで安全性を強制し、C++は標準ライブラリとイディオムを通じて安全性を提供します。 どちらのアプローチも有効であり、異なる設計哲学を反映しています。

  2. モダンC++は、推奨されるツールを使用すれば同じ種類のバグを防ぎます。 スマートポインタ、RAII、std::arraystd::stringstd::spanstd::optional——これらはマイナーなライブラリ機能ではありません。10年以上にわたりC++の推奨される書き方です。

  3. どちらの言語も万能薬ではありません。 Rustのunsafeキーワードは理由があって存在します。FFI、パフォーマンス、低レベル操作のために安全ネットの外に出る必要がある場合があります。そして、ロジックバグ、(一部のパターンにおける)競合状態、設計上のエラーはどの言語にも存在します。

  4. 現実主義が重要です。 数十億行の実戦で鍛えられたC++コードが今日の本番環境で稼働しています。多くのチームにとって、モダンC++のプラクティス、より良いツール、静的解析の導入は、全面的な書き直しよりも現実的な道であり、実際の安全性向上をもたらします。

ツールはどうか?

モダンC++の開発者は、言語自体が強制する範囲を超えた問題も検出できる強力な安全性ツールにアクセスできます:

ツール用途
AddressSanitizer (ASan)解放後使用、バッファオーバーフロー、スタックオーバーフローの検出
MemorySanitizer (MSan)未初期化メモリの読み取り検出
UndefinedBehaviorSanitizer (UBSan)符号付きオーバーフロー、nullデリファレンスなどの検出
Valgrindメモリリーク検出とプロファイリング
Clang-Tidyモダン化提案を含む静的解析
C++ Core Guidelines安全でモダンなC++のための業界標準ガイドライン

この記事の「悪い」例を-fsanitize=address付きでコンパイルすれば、問題は実行時に即座にフラグされます。ツールは存在しています。問題は開発者がそれを使うかどうかです。

結論

C++とRustはメモリ安全性に異なる角度からアプローチしています:

  • Rustは安全性をデフォルトとし、unsafeで明示的にオプトアウトする必要があります。
  • **C++**は開発者に完全な制御を与え、標準ライブラリ、RAII、確立されたイディオムを通じて安全性を提供します。

C++コードベースにおけるメモリ安全性の問題のほとんどは、時代遅れのパターン——生のnew/delete、Cスタイルの配列、strcpyのような関数——を使用することに起因しており、言語が10年以上前から提供しているモダンなツールを使わないことが原因です。

良いニュースは、C++の前進の道筋が明確で十分に文書化されていることです:

  • 生のnew/deleteの代わりにスマートポインタ(std::unique_ptrstd::shared_ptr)を使用する
  • Cスタイルのバッファの代わりに標準コンテナ(std::stringstd::vectorstd::array)を使用する
  • すべてのリソース管理にRAIIを採用する
  • CIパイプラインでサニタイザー(ASan、MSan、UBSan)と静的解析を活用する
  • C++ Core Guidelines に従う

どちらの言語も強力なツールであり、どちらもモダンなソフトウェア開発において居場所があります。最適な選択はプロジェクト、チーム、制約条件によって決まるのであり、インターネット上の議論によって決まるものではありません。

この記事が有用、あるいは考えさせられるものだったなら、ぜひシェアしてください。異なる視点をお持ちでしたら、下のコメント欄でぜひお聞かせください。最良のエンジニアリング議論は、意見だけでなくコードで議論する時に生まれます。