AlexZhang

AlexZhang

安全でないから安全へ | 安全でないRustの包括的理解

はじめに#

よく知られているように、Rust 言語は主に 2 つの部分から構成されています:Safe Rust と Unsafe Rust です。Unsafe Rust は Safe Rust のスーパーセットです。これは、Safe Rust で書かれたすべてのコードが Unsafe Rust でも正常に動作することを意味しますが、Unsafe Rust は Safe Rust では直接使用できない追加の機能や操作を提供します。

しかし、コミュニティで最も頻繁に尋ねられる質問の 1 つは、**「標準ライブラリで大量の Unsafe Rust が使用されているのに、なぜ Rust は安全な言語と見なされるのか?」** です。

さらに、コミュニティの中には Unsafe Rust に対して PTSD を発展させ、Unsafe に関わるものはすべて危険だと見なす人々もいます。確かに、Unsafe の使用を最小限に抑えるべきですが、Unsafe が不可欠な状況に直面したときには、Unsafe を安全に使用する方法を知り、Unsafe コードをレビューする方法を理解し、したがって Unsafe を見るときにそれほど恐れたり抵抗したりしないようにする必要があります。

したがって、この記事の目的は、読者が Unsafe Rust を体系的に理解し、Unsafe Rust の巧妙な使用法を真に把握できるように導くことです。

目次

  • なぜ Unsafe Rust が必要なのか
  • Unsafe Rust ができること
  • Unsafe Rust の安全哲学
    • 安全な抽象化:安全な不変条件と有効性の不変条件を維持する
    • 標準ライブラリからのケーススタディ
    • FFI からの例
  • Unsafe Rust プログラミングのガイドライン
  • ホットディスカッション:Unsafe Rust vs Zig
    • Zig 言語の紹介
    • Zig は本当に Unsafe Rust より安全なのか?
  • まとめ

なぜ Unsafe Rust が必要なのか#

一般的に、Safe Rust は Unsafe Rust から抽象化されています。世界自体は Unsafe であるため、Unsafe が先にあり、その後に Safe があります。これが Rust 言語の基本的な世界観です。

この世界観は実際には客観的な世界と非常に一致しており、人々にとって理解しやすいものです。宇宙は Unsafe であるため、私たちはそれを探求するために安全な宇宙船や宇宙服を必要とします。地球は Unsafe であるため、私たち人間は科学と文明を使って安全な家を継続的に作り出します。

言語設計の観点から、Unsafe Rust が必要な理由は以下の通りです:

  • 高レベルの抽象化に対する低レベルのサポートを提供する:Rust は参照カウント、スマートポインタ、同期プリミティブなどの高レベルの抽象化を提供します。これらの抽象化は、低レベルでの Unsafe 操作、つまり生ポインタ操作やメモリ管理を必要とします。Unsafe Rust を通じて、ライブラリ開発者はこれらの低レベル操作を実装し、安全なインターフェースにカプセル化できます。
  • 高性能の最適化:メモリの安全性を確保するために、Rust コンパイラは自動的に配列の境界チェックなどのいくつかのランタイムチェックを挿入します。パフォーマンスが重要なシナリオでは、これらのチェックがパフォーマンスのボトルネックになることがあります。Unsafe Rust は、開発者がこれらのチェックを回避しつつ安全性を確保し、より高いパフォーマンスを達成できるようにします。
  • システムレベルのプログラミング:システムプログラミング言語として、Rust はオペレーティングシステムや組み込みシステム、ドライバ開発などの低レベル操作を扱う必要があります。Unsafe Rust はこの柔軟性を提供し、Rust をこれらのタスクに適したものにします。
  • 他の言語との相互運用性:実際のプロジェクトでは、Rust コードが他の言語(C/C++ など)で書かれたコードと相互作用する必要があります。これらの言語は異なるメモリ管理や型システムを使用する可能性があるため、Rust はこの外部コードと安全に通信する方法を提供する必要があります。Unsafe Rust はそのようなメカニズムを提供し、生ポインタや型変換を Rust で扱うことを可能にし、他の言語との相互運用性を実現します。
  • 言語の拡張性:Unsafe Rust は将来の言語機能やライブラリの可能性を提供します。低レベルの操作を許可することで、Unsafe Rust は Rust コミュニティが新しいアイデアを探求し、それを言語に統合し続けることを可能にします。

要約すると、Rust 言語設計の観点から、Unsafe Rust は意図的な設計妥協です。低レベルの操作、パフォーマンスの最適化、相互運用性のニーズを満たしながら、厳格な制限とカプセル化を通じて全体の安全性を確保します。

Unsafe Rust ができること#

前述のように、Unsafe Rust は Safe Rust のスーパーセットです。したがって、Unsafe Rust は Safe Rust のすべてのコンパイラ安全チェックも含まれています。しかし、Unsafe Rust には Safe Rust には存在しない操作も含まれています。つまり、Unsafe Rust でのみ実行できる操作です:

  1. 生ポインタ操作:生ポインタ(*const Tおよび*mut T)を作成、逆参照、および操作できます。これにより、メモリアドレスに直接アクセスし、メモリの割り当て、解放、変更などを行うことができます。
  2. Unsafe 関数の呼び出し:Unsafe Rust はunsafeとしてマークされた関数を呼び出すことができます。これらの関数は未定義の動作を引き起こす可能性があるため、unsafeコードブロック内で呼び出す必要があります。これらの関数は通常、メモリ管理やハードウェアアクセスなどの低レベルの操作を実装するために使用されます。
  3. Unsafe トレイトの実装:unsafeとしてマークされたトレイトを実装できます。これらのトレイトには潜在的に危険な操作が含まれている可能性があり、実装時には明示的にunsafeとしてマークする必要があります。
  4. 可変静的変数へのアクセスと変更:Unsafe Rust では、グローバルライフタイムを持つ可変静的変数にアクセスし、変更できます。これらの変数はプログラムの実行全体にわたってアクティブであり、潜在的なデータ競合の問題を引き起こす可能性があります。
  5. Union型との作業。複数のフィールドが同じメモリ位置を共有するため、unionを使用することには一定のリスクがあります。unionフィールドにアクセスする際、コンパイラは現在格納されている値がどのフィールドに属するかを判断できないため、型安全性を保証できません。unionフィールドへの安全なアクセスを確保するためには、unsafeコードブロック内で操作を行う必要があります。
  6. ランタイム境界チェックの無効化:Unsafe Rust では、配列の境界チェックを回避できます。get_uncheckedおよびget_unchecked_mutメソッドを使用することで、境界チェックを行わずに配列やスライスの要素にアクセスでき、パフォーマンスが向上します。
  7. インラインアセンブリ:Unsafe Rust では、インラインアセンブリ(asm!マクロ)を使用してプロセッサ命令を直接記述できます。これにより、プラットフォーム固有の最適化や操作を実装できます。
  8. 外部関数インターフェース(FFI):Unsafe Rust では、他のプログラミング言語(C/C++ など)で書かれたコードと相互作用できます。これには通常、ネイティブポインタ操作、型変換、および Unsafe 関数の呼び出しが含まれます。

Unsafe Rust を使用する際には注意が必要です。可能な限り Safe Rust を使用してコードを書くことを優先してください。強力な機能を提供しますが、未定義の動作やメモリ安全性の問題を引き起こす可能性もあります。したがって、Rust の公式標準ライブラリのソースコード実装と公式のUnsafe Code Guidelineは、Unsafe Rust の安全哲学を含み、その安全性を維持しています。

Unsafe Rust の安全哲学#

Unsafe Rust の安全哲学は、開発者が制限された条件下で低レベルの操作やパフォーマンス最適化を行うことを許可しつつ、全体のコードが安全であることを保証することです。

安全な抽象化:安全な不変条件と有効性の不変条件を維持する#

全体のコードが安全であるとはどういうことか?Unsafe Rust には、これを定義するための安全な不変条件という用語があります。

Safe Rust にはメモリ安全性と同時実行安全性を確保するためのコンパイラ安全チェックがありますが、Unsafe Rust の特殊な操作シナリオでは、Rust コンパイラは助けることができないため、開発者自身がコードのメモリ安全性と同時実行安全性を確保する必要があります。Unsafe Rust は、Rust コンパイラに対する開発者の安全なコミットメントです:「安全性は私が守る!」

Unsafe Rust コードを書く際にこの安全なコミットメントを守るために、開発者は常に安全な不変条件を維持する必要があります。安全な不変条件とは、メモリ安全性を確保するためにプログラム実行全体を通じて維持しなければならない条件を指します。これらの条件には通常、ポインタの有効性、データ構造の整合性、データアクセスの同期などが含まれます。安全な不変条件は主にプログラムの正しい実行と未定義の動作の回避に焦点を当てています。Unsafe Rust を使用する際、開発者はこれらの安全な不変条件が満たされるように責任を持つ必要があります。

安全な不変条件を維持する過程で、理解すべきもう 1 つの概念は有効性の不変条件です。有効性の不変条件とは、特定のデータ型や構造がそのライフタイム中に満たさなければならない条件を指します。有効性の不変条件は主にデータ型や構造の正確性に焦点を当てています。たとえば、参照型の場合、有効性の不変条件にはヌルでないポインタと有効なメモリが含まれます。Unsafe Rust コードを書く際、開発者はこれらの有効性の不変条件が維持されるようにする必要があります。

安全な不変条件と有効性の不変条件には一定の関連性があります。なぜなら、両者ともコードの正確性と安全性に焦点を当てているからです。有効性の不変条件を維持することは、しばしば安全な不変条件が満たされるのを助けます。たとえば、参照型ポインタの有効性を確保する(有効性の不変条件)は、ヌルポインタの逆参照を防ぐ(安全な不変条件)ことができます。関連性はありますが、安全な不変条件と有効性の不変条件の焦点は異なります。安全な不変条件は主にプログラム全体のメモリ安全性と未定義の動作の回避に焦点を当てているのに対し、有効性の不変条件は特定のデータ型や構造の正確性に焦点を当てています。

両者の関係は以下の側面で要約できます:

  1. 目的:安全な不変条件は主にメモリ安全性とデータ整合性に焦点を当て、未定義の動作を防ぎます。有効性の不変条件は、型インスタンスがそのライフタイム中に正しく使用されるために満たさなければならない条件に焦点を当てています。
  2. 範囲:安全な不変条件は通常、プログラム全体またはモジュールのメモリ安全性に関与しますが、有効性の不変条件は型制約に特有のものです。ある程度、安全な不変条件はグローバルな制約と見なすことができ、有効性の不変条件はローカルな制約です。
  3. 階層:安全な不変条件と有効性の不変条件は異なるレベルで相互作用することがあります。一般的に、有効性の不変条件を維持することは、安全な不変条件を実装するための基盤です。言い換えれば、型の有効性の不変条件は、より高レベルの安全な不変条件を実装するために必要とされることがよくあります。
  4. 依存関係:安全な不変条件は有効性の不変条件に依存します。型の有効性の不変条件が満たされると、安全な不変条件を実装するために必要な条件が確保されます。たとえば、Rust では、安全な参照アクセスは、基になる型の有効性の不変条件が満たされることに依存します。

したがって、Unsafe Rust コードが安全な不変条件や有効性の不変条件に違反するとき、そのコードは不健全であると言います。不健全なコードは未定義の動作、メモリリーク、データ競合などの問題を引き起こす可能性があります。Rust は、コンパイラや型システムを通じてほとんどのコードのメモリ安全性を確保し、特に未定義の動作を避けることで、不健全な状況を回避しようとしています。

詳細を読む:2 種類の不変条件:安全性と有効性

未定義の動作とは、プログラムの実行結果が予測できない状況を指します。これは、境界外メモリアクセス、ヌルポインタの逆参照、データ競合などのエラーが原因で発生する可能性があります。未定義の動作に遭遇すると、プログラムがクラッシュしたり、不正確な結果を生成したり、その他の予期しない動作を示したりすることがあります。

ただし、一部の人々は、Zig や Rust のような新しい言語で「未定義の動作」という用語を使用することは正確ではないかもしれないと主張しています。

Rust では、未定義の動作を引き起こす可能性のある状況に特に注意を払う必要があります:

  1. データ競合:データ競合は、複数のスレッドが同じメモリ位置に同時にアクセスし、少なくとも 1 つのスレッドが書き込み操作を行うときに発生します。Rust の所有権システムと借用チェッカーは、コンパイル時にほとんどのデータ競合を防ぎますが、Unsafe Rust では、開発者はデータ競合を避けるために特に注意する必要があります。
  2. 無効なポインタの逆参照:無効なポインタ(たとえば、ヌルポインタや解放されたメモリへのポインタ)を逆参照すると、未定義の動作が発生します。
  3. 整数オーバーフロー:場合によっては、整数オーバーフロー(たとえば、整数の加算、減算、乗算など)が予期しないプログラムの状況を引き起こす可能性があります。Rust はデバッグモードで整数オーバーフローのチェックをデフォルトで有効にしていますが、リリースモードでは整数オーバーフローの動作が定義されています。詳細はRFC 0560を参照してください。
  4. 初期化されていないメモリへのアクセス:初期化されていないメモリへのアクセスは未定義の動作を引き起こします。これには、初期化されていないメモリの読み取りや書き込み、初期化されていないメモリを外部関数に渡すことが含まれます。
  5. 不正な型キャスト:ある型のポインタを別の型のポインタに強制的に変換し、それを逆参照すると未定義の動作が発生する可能性があります。これは通常、Unsafe Rust コードで発生し、型変換が安全であることを確認するために特別な注意が必要です。

要約すると、Unsafe Rust を使用する際、開発者は安全な不変条件と有効性の不変条件を維持することに特に注意を払う必要があります。不健全で未定義の動作を避けるためです。これらの原則を厳密に守る Unsafe Rust コードをUnsafe 安全な抽象化と呼びます。これにより、低レベルの操作やパフォーマンス最適化を行いながら、全体の安全性を維持することができます。

標準ライブラリからの例#

Vec<T>#

標準ライブラリのVec<T>型のpushメソッドは、Unsafe 安全な抽象化の典型的な例です。以下のコードは、このメソッドの簡略化された実装です:

pub struct Vec<T> {
    ptr: *mut T,
    len: usize,
    cap: usize,
}

impl<T> Vec<T> {
    pub fn push(&mut self, value: T) {
        if self.len == self.cap {
            // メモリを再割り当てする(詳細な実装はここでは省略)
            self.reallocate();
        }

        // ここで安全な不変条件と有効性の不変条件が満たされていることを確認する
        unsafe {
            let end = self.ptr.add(self.len); // endはlen < capなので有効なポインタです
            std::ptr::write(end, value);      // endが有効であると仮定して、endが指すメモリに値を書き込みます
            self.len += 1;                    // 長さを更新します
        }
    }
}

このVec<T>の簡略化された実装では、pushメソッドが Unsafe Rust を使用しています。では、安全な不変条件と有効性の不変条件がどのように満たされているかを分析しましょう:

  1. 安全な不変条件:
    • メモリの割り当てと解放:pushメソッドは、長さが容量と等しい場合にreallocateメソッドを呼び出し、十分なメモリが割り当てられることを保証します。これにより、境界外メモリアクセスが発生しないことが保証されます。
    • データアクセスの同期:この例では、pushメソッドは可変参照(&mut self)であり、呼び出し時に他の可変または不変の参照がないことを保証します。これにより、データ競合が発生しないことが保証されます。
  2. 有効性の不変条件:
    • ポインタの有効性:endポインタはself.ptr.add(self.len)を使用して計算されます。self.len < self.capreallocateの後)であるため、endが指すメモリアドレスが有効であることが保証されます。
    • データ型の正確性:std::ptr::write(end, value)は、endが指すメモリにvalueを書き込みます。すでにendの有効性を確認しているため、この操作は安全であり、データ型の正確性を保証します。

安全な不変条件と有効性の不変条件を維持することで、Rust 標準ライブラリのVec<T>型はメモリ安全性を確保しながら高性能な操作を提供できます。このpushメソッドの簡略化されたバージョンでは、Unsafe Rust を使用して低レベルのメモリ操作を行っていますが、プロセス全体を通じて開発者は安全な不変条件と有効性の不変条件が満たされるようにしています。

結論として、Rust 標準ライブラリは低レベルの操作と最適化のために Unsafe Rust を使用しながら、安全な不変条件と有効性の不変条件が満たされることを保証し、安全で高性能な抽象化を実現しています。このアプローチは、StringHashMapなどの他の多くの標準ライブラリの型やメソッドにも適用されており、開発者が安全で高レベルのコードを書く際に低レベルの操作やパフォーマンスの最適化を活用できるようにしています。

String#

標準ライブラリのString型には、from_utf8from_utf8_uncheckedという一対の類似したメソッドがあります。違いは、前者が Safe 関数であり、後者が Unsafe 関数であることです。

pub fn from_utf8(vec: Vec<u8>) -> Result<String, FromUtf8Error> {
    // これは標準ライブラリのソースコードではなく、デモです
    let vec = match run_utf8_validation(v) {
        Ok(_) => {
            // 安全性:検証に成功しました。
            Ok(unsafe { from_utf8_unchecked(v) })
        }
        Err(err) => Err(err),
    };
    Ok(String { vec })
}

from_utf8コードでは、入力バイトシーケンスが UTF8 エンコーディングの検証を受けており、vec 引数のデータの有効性が保証されています。したがって、任意のバイトシーケンスを処理する際に、全体の関数が不健全な動作を示すことはありません。この時点で、安全な不変条件が維持されていると考えることができます。したがって、この関数は安全です。

from_utf8_uncheckedのソースコード例:

/// # 安全性
///
/// この関数は、渡されたバイトが有効なUTF-8であることを確認しません。この制約が違反されると、`String`の将来のユーザーに対してメモリの安全性の問題を引き起こす可能性があります。
pub unsafe fn from_utf8_unchecked(bytes: Vec<u8>) -> String {
    String { vec: bytes }
}

この関数では、入力バイトシーケンスが UTF8 エンコーディングの検証を受けずに直接 String として構築されるため、リスクがあります。bytes引数のデータの有効性が保証されないため、全体の関数はunsafeである必要があり、Safetyのドキュメンテーションコメントも追加して、この関数を使用する際に安全である条件を開発者に通知する必要があります。つまり、呼び出し元が安全な不変条件を維持する必要があることを知らせるのです。

一部の読者は、** なぜfrom_utf8_unchecked関数が必要なのか、すでにfrom_utf8関数があるのに?** と疑問に思うかもしれません。

これは Unsafe Rust の実践における慣習であり、パフォーマンスの出口としてunsafe関数に_uncheckedという名前のサフィックスを付けることです。たとえば、ある環境では、from_utf8_unchecked関数のbytes引数が外部で UTF8 エンコーディングの検証を受けている(たとえば、C インターフェースで)ため、ここで二重に検証する必要がありません。パフォーマンスの理由から、_uncheckedメソッドを使用できます。

FFI からの例#

Unsafe Rust の一般的なシナリオの 1 つは、C-ABI を介してさまざまな他の言語と相互作用すること、つまり FFI(Foreign Function Interface)シナリオです。この場合、Unsafe 安全な抽象化を実現するために多くの安全要因を考慮する必要があります。FFI での安全な抽象化を実現する方法を例で示しましょう。

C 言語ライブラリ(my_c_lib.c)があるとします:

#include <stdint.h>

int32_t add(int32_t a, int32_t b) {
    return a + b;
}

このadd関数を呼び出す Rust プログラムを書く必要があります。まず、Rust で FFI バインディングを作成する必要があります:

// my_c_lib.rs
extern "C" {
    fn add(a: i32, b: i32) -> i32;
}

次に、この Unsafe FFI バインディングをラップする安全な Rust 関数を作成できます:

// my_c_lib.rs
pub fn safe_add(a: i32, b: i32) -> i32 {
    // 開発者は安全な不変条件と有効性の不変条件を維持する責任があります
    // aまたはbが無効なi32である場合、0を返します(特定のビジネス選択に応じて)
    if a >= i32::MAX || b >= i32::MAX || (i32::MAX - a - b) < 0 {return 0}
    unsafe {
        add(a, b);
    }
}

この例では、Unsafe FFI バインディングadd関数を安全なsafe_add関数にラップしました。これにより、他の Rust コードがsafe_addを呼び出すときに、潜在的な安全性の問題(たとえば、整数オーバーフロー)を心配する必要がなくなります。Unsafe Rust コードはsafe_add関数の内部に制限され、開発者は安全な不変条件と有効性の不変条件が満たされることを確認する責任があります。

これで、Rust でsafe_add関数を安全に使用できます:

fn main() {
    let result = my_c_lib::safe_add(5, 7);
    println!("5 + 7 = {}", result);
}

この FFI シナリオでは、Unsafe Rust を使用して C で実装されたadd関数を呼び出します。Unsafe コードを安全な API にカプセル化することで、この API を呼び出す際に Rust コードの安全性を確保します。実際には、開発者は Unsafe Rust をカプセル化する際に潜在的な安全な不変条件や有効性の不変条件の問題に注意を払い、それらが満たされるようにする必要があります。

Unsafe Rust プログラミングのガイドライン#

Unsafe Rust の実践において、これらのガイドラインに従うことで、Unsafe 安全な抽象化を効果的に実現できます:

  1. Unsafe コードを最小限に抑える:Unsafe Rust の使用は制限され、必要な場合にのみ使用されるべきです。ほとんどの機能は Safe Rust を使用して実装し、メモリの安全性を確保し、未定義の動作を避けるべきです。可能な限り、Unsafe Rust コードは安全な API にカプセル化し、ユーザーに安全なインターフェースを提供するべきです。
  2. 明示的に unsafe とする:Unsafe Rust は明示的にunsafeとしてマークされる必要があります。これにより、開発者は潜在的な安全リスクを明確に特定できます。unsafeキーワードに遭遇した場合、開発者は特に注意を払い、コードを慎重にレビューして、メモリ安全性の問題や未定義の動作を適切に処理する必要があります。関数やトレイトがunsafeとしてマークされるべきかどうかを慎重に考慮し、意図的にunsafeマークを削除したり、使用しなかったりしてはいけません。
  3. レビューとテスト:Unsafe Rust コードは、その正確性を確保するために厳格なレビューとテストが必要です。これには、低レベルの操作、メモリの割り当てと解放、同時実行の動作をテストすることが含まれます。Unsafe Rust コードの正確性を確保することは重要であり、エラーは深刻なメモリ安全性の問題や未定義の動作を引き起こす可能性があります。
  4. ドキュメンテーションとコメント:Unsafe Rust コードは十分に文書化され、コメントが付けられるべきです。これにより、他の開発者がその目的、操作、および潜在的なリスクを理解できるようになります。これにより、メンテナや他の貢献者がコードを修正する際に正しい安全なプラクティスを守るのに役立ちます。
  5. 複雑さを減らす:Unsafe Rust コードはできるだけシンプルで明確であるべきです。これにより、理解と保守が容易になります。複雑な Unsafe Rust コードは、見つけにくいエラーを引き起こす可能性があり、潜在的な安全リスクが増加します。

これらの原則に従うことで、Unsafe Rust の安全哲学は、低レベルの操作やパフォーマンスの最適化の必要性と、全体のコードが安全であることを保証することを目指しています。Unsafe Rust の使用は潜在的な安全性の問題を引き起こす可能性がありますが、Rust 言語は明示的なマーク付け、使用範囲の制限、厳格なレビュー、徹底した文書化を通じてこれらのリスクを最小限に抑えようとしています。

ホットスポットディスカッション:Unsafe Rust vs Zig#

最後に、Unsafe コードを書く際にどちらの言語が安全か、Unsafe Rust と Zig について議論したいと思います。

このトピックの起源は、最近 Reddit の Rust チャンネルで熱く議論された投稿です:Zig が Rust よりも安全で速いとき。タイトルにはRustとありますが、実際にはUnsafe Rustと比較しています。

Zig 言語の紹介#

Zig は、C のいくつかの複雑さを簡素化し、より大きな安全性と使いやすさを提供することを目的とした、現代的で高性能なシステムプログラミング言語です。Zig は 2016 年に Andrew Kelley によって始められ、活発なオープンソースコミュニティからのサポートを受けています。Zig は、オペレーティングシステム開発、組み込みシステム、ゲーム開発、高性能コンピューティングなど、さまざまなシナリオに適しています。Zig 言語は比較的新しいですが、多くの開発者の注目を集め、実際のプロジェクトに適用されています。コミュニティとエコシステムの発展に伴い、Zig はシステムプログラミングの分野で重要な選択肢となることが期待されています。

特徴ZigRust
設計目標シンプルさ、高性能、使いやすさ、C との互換性安全性、同時実行性、高性能、メモリ安全性
構文C 言語に近く、シンプルML 系列の言語に近く、表現力豊かな型システム
メモリ安全性コンパイル時のチェック、借用チェッカーと所有権システムなし借用チェッカーと所有権システム、コンパイル時に保証されたメモリ安全性
パフォーマンス高性能、C 言語に近い高性能、C++ に匹敵
行単位コンパイル行単位コンパイルをサポートし、コードのコンパイル時実行を可能にするconst ジェネリクスをサポートし、限られたコンパイル時実行能力
エラーハンドリングエラー戻り値とエラー共用体型、例外なしResult 型と Option 型、例外なし
FFI優れた C 言語互換性、既存の C コードとの相互運用が容易良好な FFI サポート、追加のバインディング作成が必要
パッケージ管理組み込みのパッケージマネージャーCargo パッケージマネージャー
ランタイムランタイムオーバーヘッドなし最小限のランタイム、std なしで実行可能
コミュニティとエコシステム比較的新しく、コミュニティが発展中成熟したコミュニティと豊富なエコシステム

この表は主な違いを要約していますが、各言語には実際に独自の特徴があるため、選択を行う前に各言語の特性をより深く掘り下げる必要があります。

Zig 言語はメモリ安全性に重点を置いて設計されていますが、Rust とは異なり、所有権システムや借用チェッカーはありません。それでも、Zig はコンパイル時のチェックや言語機能を通じてメモリ安全性を向上させています。Zig 言語がメモリ安全性を実現する方法は以下の通りです:

  1. コンパイル時のチェック:Zig コンパイラは、配列の境界外アクセスやヌルポインタの逆参照など、潜在的なメモリエラーをキャッチするために多くのチェックを行います。Zig コンパイラがこれらのエラーを検出すると、コンパイルを停止し、エラーを報告します。
  2. エラーハンドリング:Zig は明示的なエラーハンドリングを通じてコードの堅牢性を向上させます。Zig には例外がなく、代わりにエラー戻り値とエラー共用体型を使用してエラーを処理します。これにより、開発者は潜在的なエラーを明示的に処理する必要があり、未処理のエラーによるメモリ安全性の問題を減らすのに役立ちます。
  3. オプショナル型:Zig はオプショナル型(Optionals)を提供し、ヌルの可能性がある値を表現します。オプショナル型を使用することで、ヌル値のケースを明示的に処理でき、ヌルポインタの逆参照のリスクを減らします。
  4. 定義された動作:Zig は多くのメモリ関連操作に対して定義された動作を設計し、未定義の動作によるセキュリティリスクを回避します。たとえば、ヌルポインタを逆参照する際、Zig は未定義の動作を引き起こすのではなく、明確に定義されたエラーが発生することを保証します。
  5. メモリ管理:Zig は手動メモリ管理、組み込みアロケータ、ユーザー定義アロケータの使用など、柔軟なメモリ管理オプションを提供します。明示的なメモリ管理を通じて、開発者はメモリ使用をより良く制御でき、メモリリークやメモリエラーのリスクを減らします。

要約すると、Zig 言語は C のようにメモリ管理を人間に委ねており、人間の開発に完全に信頼を置いています。Zig はメモリ安全性を確保するためにいくつかのメモリ安全性チェックを提供していますが、Rust の所有権システムや借用チェッカーの厳格なコンパイル時保証は欠けています。したがって、Zig コードを書く際には、開発者は潜在的なメモリ安全性の問題により注意を払い、エラーや例外的な状況を正しく処理する必要があります。

Zig は本当に Unsafe Rust より安全なのか?#

Safe Rust と比較すると、Zig 言語は開発者により多くの自由を与えますが、Safe Rust よりも安全性は低くなります。しかし、Zig は Unsafe Rust よりも安全なのでしょうか?

Reddit の記事Zig が Rust よりも安全で速いときに戻りましょう。

1. 著者は、Unsafe Rust の使用が難しく、完全に Miri のチェックに依存していると言っています。 この発言は正しいように思えますが、完全には正しくありません。

Miri は多くの機能を持つ MIR インタープリタです。その 1 つは、Unsafe Rust における UB チェックです。

まず、Unsafe Rust は確かに難しいです。前述の Unsafe Rust の安全な抽象化に関する内容を理解した後、この難しさは Rust 開発者にとって半分に減少すべきです。少なくとも、Unsafe Rust の使用はそれほど混乱するものではなく、正しい方向性があります。

UB の問題は Zig 言語にも存在し、Zig も Unsafe コードの問題に直面します。Zig で Unsafe コードを書く際、メモリ安全性の保証は主に開発者の経験とコーディングプラクティスに依存します。Zig コンパイラはコンパイル時のチェックを提供しますが、Unsafe コードでは、これらのチェックがすべての潜在的なメモリエラーをキャッチするには不十分な場合があります。Unsafe コードを書く際にメモリ安全性を確保するために、開発者は以下のプラクティスに従うことができます:

  1. Unsafe コードの使用を減らす:パフォーマンスや機能を損なうことなく、Unsafe コードの使用を最小限に抑えるように努めます。Unsafe コードは可能な限り小さな範囲に制限し、レビューや保守を容易にします。
  2. 型システムを使用する:Zig の型システムを最大限に活用して、さまざまなデータ型や制約を表現します。型システムは、開発者がコンパイル時に潜在的なエラーをキャッチするのに役立ち、メモリエラーのリスクを減らします。
  3. 明示的なエラーハンドリング:Unsafe コード内で潜在的なエラーが明示的に処理されるようにし、エラー戻り値やエラー共用体型を使用して可能なエラー状況を表現します。これにより、コードの堅牢性が向上し、未処理のエラーによるメモリ安全性の問題が減少します。
  4. 適切なカプセル化と抽象化:Unsafe コードを使用する必要がある部分は、安全な抽象化にカプセル化することを検討し、Unsafe コードを隔離します。これにより、他の部分のコードが呼び出されたときに潜在的なメモリエラーに触れないようにします。
  5. コードレビュー:Unsafe コードに関与する部分について詳細なコードレビューを行い、開発者が潜在的なメモリリスクを理解し、エラーを防ぐための適切な対策を講じることを確認します。
  6. テスト:Unsafe コードのテストケースを作成し、さまざまなシナリオで正しく動作することを確認します。テストは、潜在的なメモリエラーを発見し、修正の効果を検証するのに役立ちます。

Zig で Unsafe コードを書く際にも、Unsafe Rust と同様に安全な抽象化を実施し、安全な不変条件と有効性の不変条件を維持することに注意を払う必要があります。

2. 著者は Unsafe Rust の使用の難しさを示すために 2 つの例を提供しています

まず、*mut T*const Tを使用すると、コンパイラの安全性の保証が失われ、C でもポインタを使用する際には開発者が安全性を確保する必要があります。Zig では、ポインタの使用は C と似ているため、この点で Zig に特別な安全性の利点はありません。

著者はまた、Unsafe Rust でポインタを使用すると、(*ptr).fieldのようにコードが散発的になると述べています。ポインタはメソッドを呼び出すことができないためです。しかし、Unsafe Rust にはより良く、安全な解決策があります:

impl Foo {
	/// # 安全性
	/// このメソッドを呼び出すときは、ポインタがヌルであるか、次のすべてが真であることを確認する必要があります:
	/// -  ポインタは適切に整列されている必要があります。
	/// -   ポインタは初期化されたインスタンスの`T`を指している必要があります。
	unsafe fn as_ref<'a>(ptr: *const Foo) -> &'a Foo {
		unsafe {
		    if let Some(foo) = ptr.as_ref() {
		        println!("値を取得しました: {foo}!");
		        foo
		    }
		}
	}
	/// # 安全性
	/// このメソッドを呼び出すときは、ポインタがヌルであるか、次のすべてが真であることを確認する必要があります:
	/// -  ポインタは適切に整列されている必要があります。
	/// -   ポインタは初期化されたインスタンスの`T`を指している必要があります。
	unsafe fn as_mut<'a>(ptr: *mut Foo) -> &'a mut Foo {
		unsafe {
		    if let Some(foo) = ptr.as_mut() {
		        println!("値を取得しました: {foo}!");
		        foo
		    }
		}
	}
}

標準ライブラリは、生ポインタのためにas_refおよびas_mutメソッドを提供しており、これらを使用して不変参照と可変参照に変換できます。これらの 2 つのメソッドをFooに実装することで、安全性の条件を完全に考慮し、(*ptr).fieldのようなコードを使用せずにFooのインスタンスのメソッドを呼び出すことが便利になります。

著者が配列について指摘した点については、彼らの例の改善版を提供しました:

#[derive(Debug)]
struct Value(i32);

unsafe fn do_stuff_with_array(values: *mut Value, len: usize) {
    let values: &mut [Value] = std::slice::from_raw_parts_mut(values, len);
    // イテレータのエルゴノミクスを使用できます!
    for val in values.iter_mut() {
        // ...
        // 各`val`(型は&mut Value)の操作を行います
        val.0 += 1;
    }
}

fn main() {
    // `do_stuff_with_array`の使用例
    let mut values = vec![Value(1), Value(2), Value(3)];
    unsafe { do_stuff_with_array(values.as_mut_ptr(), values.len()) };
    println!("{values:?}");
}

このコードに問題はないと思います。著者のポイントを見逃したかもしれません。少なくとも、彼らの提案である参照の使用を避けることがこの問題の解決策であるとは同意できません。

著者はこれらの問題を Rust の「ダークアーツ」と呼んでいますが、Unsafe Rust に関する Rust の安全哲学を完全に理解していない可能性があると考えています。

3. 著者は Zig の組み込み安全戦略を好む

著者が好む Zig の組み込み安全戦略には以下が含まれます:

  • 明示的な割り当てポリシーとメモリエラーを検出する特別なアロケータ。
  • ポインタはデフォルトでヌルでなく、ヌル可能性は?*Valueで表現できます。
  • ドット演算子はポインタの逆参照と単一および配列値ポインタの区別に使用されます。

この観点から見ると、Zig のポインタに対する安全対策は Rust の参照に似ています。Zig は、より多くの安全要因を考慮する必要がない C のように、より柔軟に使用できるかもしれません。Zig は Unsafe Rust よりも安全であるように見えます。

Unsafe Rust のポインタは C のポインタと何ら変わりませんが、誤った使用は C と同様に安全性の問題を引き起こす可能性があります。しかし、Unsafe Rust の安全哲学は、開発者が生ポインタに関連する安全性の問題を完全に考慮できるようにします。標準ライブラリは、開発者がポインタをより安全に使用するのを助けるためのいくつかのメソッドも提供しています。開発者はポインタを完全に参照に変換するか、NoNull<T>を使用してポインタをヌルでなくすることができます。さらに、Miri やkaniのような安全性チェックツールが、ポインタ関連の問題に対する安全性チェックを実行します。

Unsafe Rust は開発者に対してより高い要件を課し、その安全性はより良いかもしれません。Unsafe Rust のunsafeキーワードは伝播する可能性があります。開発者やレビュアーにとって、Unsafe Rust から安全性を抽象化するにはより多くの努力が必要です。しかし、これらの努力は価値があります。

Zig の安全戦略は 100%安全ではなく、開発者が安全性の要因を考慮する必要があります。たとえば、明示的なメモリ管理は、開発者がメモリのライフサイクルをより明確に制御できるようにしますが、同時にメモリエラーが発生しないようにするための責任を開発者に負わせることを意味します。

したがって、どちらがより安全であるかということはありません。ただし、著者のベンチマークテストによれば、Zig のパフォーマンスは Unsafe Rust よりも優れています。

結論#

この記事は、読者が Unsafe Rust の安全哲学を理解するのを助け、最近 Reddit で議論された Zig と Unsafe Rust の安全性の比較についてコメントを提供することを試みています。しかし、1 つの記事では Unsafe Rust の安全な抽象化のさまざまな詳細をすべてカバーすることはできません。読者は Unsafe Rust シリーズの今後の記事にも注意を払うべきです。

最後に、この記事の作成には GPT4 の助けもありました。

お読みいただきありがとうございます。

読み込み中...
文章は、創作者によって署名され、ブロックチェーンに安全に保存されています。