現在、C言語で開発されているプロジェクトが、Rustに移行するケースが増えています。Rustは、C言語に比べて安全性が高く、メモリ管理が自動化されているため、開発者にとって魅力的な選択肢となっています。とは言え、C言語で簡単に書ける処理がRustでとても複雑になるという場面もあります。今回は、その代表例として、単方向リストの実装をC言語とRustで比較してみましょう。

  • 単方向リストを作ってC言語とRustを比較してみよう

    単方向リストを作ってC言語とRustを比較してみよう

Rustの安全性は所有権システムにある

Rustの安全性は、所有権システムに基づいています。所有権システムは、メモリ管理を自動化し、データ競合やメモリリークなどの問題を防止します。C言語では、開発者が手動でメモリ管理を行う必要があり、これがメモリリークなどバグの原因となることがあります。

さらにRustの安全性を際立たせているのが、ポインタの使用を制限している点です。Rustでは、ポインタの代わりに参照やスマートポインタを使用します。これにより、データの所有権とライフタイムが明確になり、安全なコードを書くことができます。

とは言え、ポインタを使えば簡単に書ける処理が、Rustでは複雑になることもあります。例えば、単方向リストの実装は、C言語では非常にシンプルですが、Rustでは所有権と借用のルールを考慮する必要があります。そこで、今回は、C言語とRustを比較してみます。

単方向リストとは

単方向リストとは、各ノードが次のノードへのポインタを持つデータ構造です。リストの最後のノードは、次のノードへのポインタがNULLになります。単方向リストは、動的なデータ構造であり、要素の追加や削除が容易に行えるため、様々な場面で使用されます。次のような構造になっています。

  • 単方向リストの仕組み

    単方向リストの仕組み

単方向リストを表現する構造体の定義

C言語で単方向リストを実装する場合、次のような構造体を定義しておいて、この構造体を複数生成し、ポインタのnextから次のノードを指すようにします。

typedef struct Node {
    char *value;
    struct Node *next;
} Node;

Rustでも同様の構造体を定義することができますが、所有権と借用のルールを考慮する必要があります。Rustでは、次のように定義します。

#[derive(Debug)]
struct Node {
    value: String,
    next: Option<Box<Node>>,
}

C言語と比べると、Rustの方が複雑に見えます。Optionとは、値が存在するかどうかを表す列挙型で、Boxはヒープ上にデータを格納するためのスマートポインタです。Rustでは、所有権と借用のルールを考慮する必要があるため、単方向リストの実装はC言語よりも複雑になります。

と言うのも、Rustのプログラミングでは、メモリの配置場所であるスタックとヒープの違いを、より意識する必要があります。そして、Rustのプログラムをコンパイルする時に、型のサイズが確定している必要があります。単方向リストのような再帰的なデータ構造は、型のサイズを計算するときに、無限になってしまうのです。それで、間接参照(Boxなど)を使ってサイズを固定化する必要があります。

例えば、Rustで次のような構造体を定義すると、コンパイルエラーになります。

struct Node {
    value: String,
    next: Option<Node>, // ← これはコンパイルエラーになる
}

どのような機能を実装するのか考えよう

最初にどのような機能を実装するのか考えましょう。ここでは、最も簡単な部分として、リストに要素を追加して、最後にリストの一覧を表示するという機能を実装します。

C言語で以下のようなメイン関数を実装するものとします。それで、実装するのは、データを追加するpush_data関数と、リストの一覧を表示するprint_list関数、そして最後にメモリを解放するfree_list関数です。

int main(void) {
    Node *head = NULL; // 最初のNode要素を指すポインタ
    // データの追加
    push_data(&head, "りんご");
    push_data(&head, "みかん");
    push_data(&head, "バナナ");
    // リストの一覧を表示
    print_list(head);
    // メモリの解放
    free_list(&head);
    return 0;
}

C言語で単方向リストを実装してみよう

最初に、C言語で単方向リストを実装すると次のようになります。以下は、リストを追加するpush_data関数の実装例です。push_data関数を実装するために、文字列をコピーするclone_str関数、ノードを作成するcreate_node関数、そしてリストの末尾に文字列を追加するpush_data関数を実装します。

// 文字列をコピーする関数 --- (*2)
char *clone_str(const char *src) {
    size_t len = strlen(src) + 1;
    char *dest = (char *)malloc(len);
    if (dest == NULL) {
        fprintf(stderr, "メモリ確保に失敗しました。\n");
        exit(EXIT_FAILURE);
    }
    memcpy(dest, src, len);
    return dest;
}
// ノードを作成する関数 --- (*3)
Node *create_node(const char *value) {
    Node *node = (Node *)malloc(sizeof(Node));
    if (node == NULL) {
        fprintf(stderr, "メモリ確保に失敗しました。\n");
        exit(EXIT_FAILURE);
    }
    node->value = clone_str(value);
    node->next = NULL;
    return node;
}
// リストの末尾に文字列を追加する関数 --- (*4)
void push_data(Node **head, const char *value) {
    Node *node = create_node(value);
    if (*head == NULL) {
        *head = node;
        return;
    }
    // リストの末尾のノードを探す
    Node *current = *head;
    while (current->next != NULL) {
        current = current->next;
    }
    // リストにノードをつなげる
    current->next = node;
}

プログラムを確認してみましょう。(*2)では、文字列をコピーするための関数を実装しています。C言語では、malloc関数を使用してメモリを確保し、memcpy関数を使用して文字列をコピーします。

(*3)では、ノードを作成するための関数を実装しています。構造体のNodeを作成する場合も、malloc関数を使用してメモリを確保します。この時、ノードの値を記録するvalueフィールドに、clone_str関数を使用して文字列をコピーした値を設定します。

(*4)では、リストの末尾に文字列を追加するための関数を実装しています。単方向リストでは、次を表すnextフィールドを順にたどって、リストの末尾を見つけます。そして、末尾のノードのnextフィールドに、新しいノードをつなげます。

そして、リストの一覧を表示するprint_list関数と、メモリを解放するfree_list関数は、次のように実装できます。

// リストの一覧を表示する関数 --- (*5)
void print_list(const Node *head) {
    const Node *current = head;
    while (current != NULL) {
        printf("- %s\n", current->value);
        current = current->next;
    }
}
// リストのメモリを解放する関数 --- (*6)
void free_list(Node **head) {
    Node *current = *head;
    while (current != NULL) {
        Node *next = current->next;
        free(current->value);
        free(current);
        current = next;
    }
    *head = NULL;
}

プログラムを確認しましょう。(*5)では、リストの一覧を表示するための関数を実装しています。一覧を表示するには、先頭からリストの末尾に向かって、次を表すnextフィールドを順にたどっていくことで、リストの全てのノードを表示できます。値を追加する時に、末尾のノードを探すのと同じ要領です。

(*6)では、リストのメモリを解放するための関数を実装しています。C言語では、確保したメモリは自身で解放する必要があります。今回は文字列を内包するノードをmalloc関数で確保しているので、free関数で最初にノードのvalueフィールドを解放し、その後にノード自体を解放します。

このプログラムの全体は、こちらにアップロードしました。ファイルを「list.c」という名前で保存したら、次のようにしてコンパイルして実行できます。以下は、GCCを使用してコンパイルして実行する例です。

$ gcc list.c -o list
./list
- りんご
- みかん
- バナナ

Rustで単方向リストを実装してみよう

続いて、Rustで単方向リストを実装してみましょう。C言語と同様に、リストに要素を追加して、最後にリストの一覧を表示するという機能を実装します。Rustでは、次のように実装できます。最初に、リストを追加するpush_data関数の実装例を示します。プログラム中に入れた(*番号)のような番号は、C言語の実装と対応させています。

// 単方向リストのノード構造体 --- (*1)
#[derive(Debug)]
struct Node {
    value: String,
    next: Option<Box<Node>>,
}
impl Node {
    // ノードを作成する関数 --- (*3)
    fn new(value: &str) -> Self {
        Node {
            value: value.to_string(), // --- (*2)
            next: None,
        }
    }
}
// リストの末尾に文字列を追加する関数 --- (*4)
fn push_data(head: &mut Option<Box<Node>>, value: &str) {
    if head.is_none() {
        *head = Some(Box::new(Node::new(value)));
    } else {
        // リストの末尾のノードを探す
        let mut current = head.as_mut().unwrap();
        loop {
            if current.next.is_none() {
                current.next = Some(Box::new(Node::new(value)));
                break;
            } else {
                current = current.next.as_mut().unwrap();
            }
        }
    }
}

確認してみましょう。(*1)では、単方向リストのノード構造体を定義しています。C言語と同様に、valueフィールドには文字列を格納し、nextフィールドには次のノードへの参照を格納します。Rustでは、所有権と借用のルールを考慮する必要があるため、nextフィールドはOption>型で定義されます。

(*2)では、文字列をコピーするための関数を実装しています。Rustでは、to_stringメソッドを使用して文字列をコピーします。

(*3)では、ノードを作成するための関数を実装しています。Node構造体のnew関数は、引数として文字列を受け取り、新しいノードを作成して返します。

(*4)では、リストの末尾に文字列を追加するための関数を実装しています。Rustでは、Option型のis_noneメソッドを使用してリストが空かどうかを確認し、空の場合は新しいノードを作成してheadに設定します。リストが空でない場合は、nextフィールドを順にたどってリストの末尾を見つけ、新しいノードを追加します。

そして、loopを使用してリストの末尾を探す部分は、C言語の実装と同様のロジックですが、RustではOption型のas_mutメソッドを使用して可変参照を取得しています。そして、nextフィールドがNoneであるかどうかを確認し、Noneであれば新しいノードを追加し、そうでなければ次のノードに移動します。

続いて、リストの一覧を表示するprint_list関数とメイン関数は、次のように実装できます。

// リストの一覧を表示する関数 --- (*5)
fn print_list(head: &Option<Box<Node>>) {
    let mut current = head.as_ref();
    while let Some(node) = current {
        println!("- {}", node.value);
        current = node.next.as_ref();
    }
}
// メイン関数 --- (*7)
fn main() {
    let mut head: Option<Box<Node>> = None;
    // データの追加
    push_data(&mut head, "りんご");
    push_data(&mut head, "みかん");
    push_data(&mut head, "バナナ");
    // リストの一覧を表示
    print_list(&head);
    // Rustでは自動的にメモリが解放される(Drop traitにより)
}

プログラムを確認しましょう。(*5)では、リストの一覧を表示するための関数を実装しています。Rustでは、while let構文を使用してOption型の値を処理します。current変数は、リストの現在のノードへの参照を保持します。currentがSome(node)である限り、ノードの値を表示し、次のノードに移動します。

(*7)では、メイン関数を実装しています。C言語と同様に、最初にhead変数をNoneで初期化し、push_data関数を使用してリストに要素を追加します。そして、print_list関数を呼び出してリストの一覧を表示します。

なお、メモリを解放する(*6)の処理が省略されていますが、Rustでは、所有権と借用のルールにより、メモリ管理が自動化されているため、明示的にメモリを解放する必要はありません。

上記のプログラムをこちらにアップロードしました。

最初に、Cargoを使用して新しいプロジェクトを作成します。

$ mkdir rust_list
$ cd rust_list
$ cargo init

そして、上記のプログラムを「/rust_list/src/main.rs」に記述します。そして、次のようにコンパイルして実行できます。

$ cargo run
- りんご
- みかん
- バナナ

RustらしくVecを使って文字列リストを利用しよう

なお、Rustでは、単方向リストのようなデータ構造を自分で実装することはあまり一般的ではありません。Rustの標準ライブラリには、Vecという動的配列が用意されており、これを使用することで、より簡単に文字列のリストを管理できるからです。

Vecは、要素の追加や削除が容易であり、メモリ管理も自動化されているため、Rustらしいコードを書くことができます。上記の単方向リストの例をVecを使用して書き換えると、次のようになります。

fn main() {
    // リストを作成
    let mut list: Vec<String> = Vec::new();
    // データの追加
    list.push("りんご".to_string());
    list.push("みかん".to_string());
    list.push("バナナ".to_string());
    // リストの一覧を表示
    for item in &list {
        println!("- {}", item);
    }
}

このように、標準ライブラリのVecを使用することで、単方向リストのようなデータ構造を自分で実装する必要がなくなります。特に、Vecを利用すると、連続するメモリに要素が格納されるため、キャッシュ効率が向上し、パフォーマンスも向上します。

つまり、C言語のコードをRustに移行する際には、単純にC言語のコードをRustに書き換えるのではなく、Rustの特徴を活かしたコードを書くことが重要です。Rustの所有権システムや標準ライブラリを活用することで、より安全で効率的なコードを書くことができます。

まとめ

以上、C言語とRustを比較して、単方向リストの実装を通じて、Rustの所有権システムや標準ライブラリの活用方法について説明しました。Rustは、C言語に比べて安全性が高く、メモリ管理が自動化されているため、開発者にとって魅力的な選択肢となっています。

しかし、今回見たようにC言語の処理をそのままRustに1:1で翻訳するように実装するなら、逆に複雑になってしまう場面もあります。Rustの特徴を理解し、適切なデータ構造や機能を選択することで、より安全で効率的なコードを書くことができます。Rustは、C言語からの移行において、単純なコードの書き換えではなく、Rustの特徴を活かしたコードを書くことで、より良い結果を得ることができるでしょう。

自由型プログラマー。くじらはんどにて、プログラミングの楽しさを伝える活動をしている。代表作に、日本語プログラミング言語「なでしこ」 、テキスト音楽「サクラ」など。2001年オンラインソフト大賞入賞、2004年度未踏ユース スーパークリエータ認定、2010年 OSS貢献者章受賞。これまで50冊以上の技術書を執筆した。直近では、「大規模言語モデルを使いこなすためのプロンプトエンジニアリングの教科書(マイナビ出版)」「Pythonでつくるデスクトップアプリ(ソシム)」「実践力を身につける Pythonの教科書 第2版」「シゴトがはかどる Python自動処理の教科書(マイナビ出版)」など。