Rust開発者のためのC++入門書:所有権と借用について

今日、ソーシャルサイト「reddit」を見ていたら、“Rustの基礎を学んでからC++を始める場合、何を勉強すればいいか”と問う投稿があり、私は自分のブログを復活させ、その中で質問への答えを書いたら面白いのではと考えました。

私にはRustを学んだ後にC++を扱う仕事に就いた経験があるため、Rustの経験を持つ人がC++に移行していく様子をまとめてみたいと思ったのです。

本稿はC++の構文と特徴を既に知っていて、RustからC++の世界に移行する方法に興味を持っている読者を対象とします。

しかし、私は全てに精通しているわけではないので、本稿では所有権(ownership)、借用(borrowing)、ライフタイム(lifetime)に焦点を当てて説明していきます。

所有権と移動

Rustの一番大きな特徴は所有権です。所有権は、プリミティブ型ではない値に対するデフォルトの動作として、コピーではなく移動することを示します。

例えば、RustでStringを生成して別の関数に渡した場合、文字列はその関数に移動し、破壊されるでしょう。

fn foo(val: String) {
    // val destroyed here
}

fn main() {
    let val = String::from("Hello");
    foo(val);
    // accessing val here is compile-time error
}

C++のコードも見てみましょう。

#include <string>

using std::string;

void foo(string val) {
    // val is destroyed here
}

int main() {
    string val("Hello");
    foo(val);
    // accessing val here is fine, because we passed a copy to function
    // original val is destroyed here
}

C++でもコピーは減らしたいはずです。

C++にはlvalues、そして対となるrvaluesという概念があります。

C++では、実際に型に移動命令を実装した場合、rvaluesが移動できるのに対し、lvaluesはコピーされます(色々ある細かい点は、割愛します)。

C++のstdライブラリには、どんなlvaluervalueに変えられるstd::moveという関数があります。

そのため、std::movevalをラッピングすることで不要なコピーを避け、既存のC++プログラムをRustと同じような挙動に変更することができます。

#include <string>

using std::string;

void foo(string val) {
    // val is destroyed here
}

int main() {
    string val("Hello");
    foo(std::move(val));
    // warning: accessing val here is NOT fine!
    // original val is also destroyed here, but contains no value so it's fine
}

std::moveは実際に何かを動かす関数ではない、ということを覚えておいてください。これは、ある特定の場所において、コンパイラが値を扱う方法を変更するだけです。今回はstd::stringが移動命令を実装しているため、移動したのです。

C++では、移動した値を間違って使うことがあります。そのため、通常の移動命令では元のコンテナサイズをゼロに設定します。

以上のことから、不要な値のディープコピーをすることになったとしても、移動した値を間違って使わないために移動を避けるというのはC++では有効な方法です。

値のコピーが実際に危険で、それをコピーすべきでない場合は、unique_ptrBoxなど)やshared_ptrArcなど)にラッピングするのが有効です。こうすれば、ヒープ領域で値のシングルインスタンスを保持します。このような場合、moveへの依存は非常に弱く、正しいプログラムを維持するためのコストが発生します。

関数とメソッド

const参照

Rustでは、値を借用するために変更不可能な関数を作れます。

fn foo(value: &String) {
    println!("value: {}", value);
}

Rustのコンパイラは、String上で、そのStringの内容を変更するメソッドや操作を呼び出すことを許可しません。またRustでは、変更可能な文字列の借用や、文字列の所有権を必要するメソッドの呼び出しを許可しません。

C++で同じことができます。

#include <string>
#include <iostream>

using std::string;
using std::cout;
using std::endl;

void foo(const string& value) {
    cout << "value: " << value << endl;
}

const T&というコードはRustの&Tと似ています。C++のコンパイラは、const T&オブジェクトの内容の変更を許可しません。またC++では、非constな文字列上でのメソッドの呼び出しを許可しません。

constメソッド

Rustに構造体Personがあって、関数print_full_nameのパラメータとして使うとしましょう。

struct Person {
    first_name: String,
    last_name: String,
}

fn print_full_name(person: &Person) {
    println!("{} {}", person.first_name, person.last_name);
}

この関数はPerson上のメソッドとなり得ます。

struct Person {
    first_name: String,
    last_name: String,
}

impl Person {
    pub fn print_full_name(&self) {
        println!("{} {}", self.first_name, self.last_name);
    }
}

print_full_nameは不変なアクセスでしか&self参照にアクセスできないことを覚えておいてください。

C++では、メソッド上のconst修飾子を使えば同じことができます。

#include <string>
#include <iostream>

class Person {
private:
    std::string first_name;
    std::string last_name;
public:
    void print_full_name() const {
        std::cout << first_name << " " << last_name << std::endl;
    }
};

Rustでは、Personの不変借用な場所で、print_full_nameメソッドを使えます。

fn foo(person: &Person) {
    person.print_full_name();
}

C++では、Personconstとなり得る場所で、print_full_nameメソッドを使えます。

void foo(const Person& person) {
    person.print_full_name();
}

C++で可変借用するメソッド

Rustでは、参照を変更するメソッドは、必ず&mut参照を使います。例としてPersonに実装されたメソッドを見てみましょう。

struct Person {
    first_name: String,
    last_name: String,
}

impl Person {
    pub fn clear_name(&mut self) {
        self.first_name.clear();
        self.last_name.clear();
    }
}

または、スタンドアローンなメソッドは次のようになります。

fn foo(person: &mut Person) {
    person.clear_name(); // "clear_name" mutably re-borrows Person
}

C++では、単純に、const修飾子を持たないメソッドの場合は、全て以下のようになります。

#include <string>

class Person {
private:
    std::string first_name;
    std::string last_name;
public:
    void clear_name() {
        first_name.clear();
        last_name.clear();
    }
};

非const参照を持つメソッドの場合は、全て以下のようになります。

void foo(Person& person) {
    person.clear_name();
}

C++で所有権を保持するメソッド

前述のとおり、C++で所有権を保持することは可能ですが、よくないものとされており、所有権の移動はコンパイラに委ねるべきです。

しかし、手動のstd::moveが問題ない場合も多少あります。その1つがsetter関数です。
nameを変更するRustのメソッドを考えてみましょう。

struct Person {
    name: String,
}

impl Person {
    pub fn set_name(&mut self, name: String) {
        self.name = name;
    }
}

これをnameの所有権を持つ関数fooの中に呼び出すことができます。

fn foo(person: &mut Person, name: String) {
    person.set_name(name); // requires explicit clone
}

Rustでは、set_nameがnameの所有権を保持するのがデフォルトです。しかしC++では、nameはデフォルトでコピーされます。
以下にC++のメソッドを示します。

#include <string>

class Person {
private:
    std::string name;
public:
    void set_name(std::string name) {
        this->name = std::move(name); // we can safely move
    }
};

既にコピーされたパラメータがあるので、セッタの中を安全に移動できます。しかし、呼び出す場所でコピーを避けることはしませんでした。

void foo(Person& person, std::string name) {
    person.set_name(name); // copy
}

ここでstd::moveを使えます。

void foo(Person& person, std::string name) {
    person.set_name(std::move(name)); // move
}

しかし、fooの呼び出し元は移動を確実にするために同じことをしなければならず、このサイクルが続いていきます。

std::moveを使う時に探すのは、可変参照です。では関数fooの中に可変参照があるとして、値を移動してみましょう。

void foo(Person& person, std::string& name) {
    person.set_name(std::move(name)); // move clears the original name
}

そうすると、fooの呼び出し元は突然名前が消えてしまったことを知ります。

この特殊なケースでは、const T&の参照をセッタまでずっと使う方がいいでしょう。これで最小限のコストでセッタ内に名前のコピーを作成するのです。

しかし、nameがとても大きな文字列の場合はどうでしょう。例えばファイルのコンテンツなどで、さらにパフォーマンスの理由からコピーをしてはいけないという場合などには、unique_ptrshared_ptrが役立ちます。

#include <string>
#include <memory>

class Person {
private:
    std::shared_ptr<std::string> personal_page;
public:
    void set_personal_page(const std::shared_ptr<std::string>& personal_page) {
        this->personal_page = personal_page; // note that we copy here
    }
};

コピーは残ることをお忘れなく。しかしコピーするのは同じメモリのコンテンツを参照するArcポインタだけです。

ライフタイム

Rustを書く人々がよくやるのが、値のコンテンツを露出して外部を変化させることです。Rustの全てのイテレータは、多くの標準ライブラリ関数と同様にこのコンセプトのもとに構築されています。

例えば、誰かが名字や名前を変更できるようにするPersonのメソッドを追加すると、以下のようになります。

#[derive(Debug)]
struct Person {
    first_name: String,
    last_name: String,
}

impl Person {
    pub fn get_first_name_mut(&mut self) -> &mut String {
        &mut self.first_name
    }

    pub fn get_last_name_mut(&mut self) -> &mut String {
        &mut self.last_name
    }
}

これで文字列の参照に“foo”を追加する関数を持つことができます。

fn append_foo(value: &mut String) {
    value.push_str(" foo");
}

それから、外部関数がPerson内のStringの内容を変更できるようにするコードを書くことができます。

fn main() {
    let mut p = Person {
        first_name: String::from("John"),
        last_name: String::from("Smith"),
    };

    append_foo(p.get_first_name_mut());
    append_foo(p.get_last_name_mut());

    println!("{:?}", p);

    // output:
    // Person { first_name: "John foo", last_name: "Smith foo" }
}

ご存知かもしれませんが、Rustのコンパイラはライフタイムの省略を理解できます。つまり、毎回ライフタイムへの参照に注釈をつけなくても、参照する場所が分かるのです。

例えば、Personimplが3つのライフタイムの注釈を持っているとしましょう。

impl Person {
    pub fn get_first_name_mut(&'a mut self) -> &'a mut String {
        &mut self.first_name
    }
}

参照は基本的にポインタと同じです。ライフタイムの構文&'a mutは、返される値が関数の引数として'aと同じ、もくしくはより狭い記憶場所を参照しなければならないとコンパイラに伝えます。

'aの外にある値に参照を返そうとすると、以下のようにコンパイラが文句を言います。

impl Person {
    pub fn get_first_name_mut(&'a mut self) -> &'a mut String {
        &mut String::from("Other") // error: borrowed value does not live long enough
        //   ^^^^^^^^^^^^^^^^^^^^^ temporary value created here
    }
}

というわけで、呼び出しをする場所では、コンパイラはappend_fooの呼び出しの時は常にPersonが借用されることを知っており、おかしなことができないようになっています。以下がその例です。

fn main() {
    let mut p = Person {
        first_name: String::from("John"),
        last_name: String::from("Smith"),
    };

    {
        let name: &mut String = p.get_first_name_mut();
        p.first_name = String::from("Crash");
        // error: cannot assign to `p.first_name` because it is borrowed
        append_foo(name);
    }
}

一方C++は、ポインタや参照が指し示す場所を機械的に理解していませんし、助けてくれません。しかし、C++でも同じことを実装することは可能です。

まず、Personでは以下のようになります。

class Person {
public:
    std::string first_name;
    std::string last_name;

    Person(std::string first_name, std::string last_name)
    : first_name(std::move(first_name))
    , last_name(std::move(last_name))
    {}

    std::string& get_first_name_mut() {
        return this->first_name;
    }

    std::string& get_last_name_mut() {
        return this->last_name;
    }
};

セッタと同じように、コピーを回避するためコンストラクタでstd::moveのトリックを使います。
これはC++では常に使われる実用例です。
次にappend_fooを作りますが、これは驚くようなものではありません。

void append_foo(std::string& value) {
    value += " foo";
}

そして最後に、main関数です。

int main() {
    Person p("John", "Smith");

    append_foo(p.get_first_name_mut());
    append_foo(p.get_last_name_mut());

    std::cout << "first name: " << p.first_name << std::endl;
    std::cout << "last name: " << p.last_name << std::endl;

    // output:
    // first name: John foo
    // last name: Smith foo
}

ただし、C++のコンパイラはライフタイムを追跡してメモリの安全を保証することはできません。

コンパイラがこうした検証をしてくれることに慣れている人にとっては、これは問題ですね。ここまで書いてきたオブジェクトがもっと複雑になるかもしれませんし、Personにあれこれ加えた変更を追跡するのはさらに大変になるでしょう。

int main() {
    Person p("John", "Smith");

    std::string& name = p.get_first_name_mut();
    p = Person("Crash", "Bob");
    append_foo(name);

    // Output:
    // first name: Crash foo
    // last name: Bob
}

Personの記憶場所を上書きしてしまっていましたが、大丈夫でした。これは本当にずっとうまくいくかもしれません。しかしリリースビルドの中でダメになる可能性もあります。もしくは、他の開発者がPersonをshared_ptrの中にラッピングした時にダメになるかもしれません。

int main() {
    auto p = std::make_shared<Person>("John", "Smith");

    std::string& name = p->get_first_name_mut();
    p = std::make_shared<Person>("Crash", "Bob");
    append_foo(name);

    std::cout << "first name: " << p->first_name << std::endl;
    std::cout << "last name: " << p->last_name << std::endl;

    // Output:
    // first name: Crash
    // last name: Bob
}

これで、解放済みメモリを修正しました。これはうまくいきましたが、もし前の記憶場所に何か別のことが書いてあったら、機能していないかもしれません。

C++では、可変な参照を返すメソッドを回避した方がよいでしょう。代替案としては、フィールドに直接アクセスすることができます(その代わりプライバシーが侵されます)。

int main() {
  Person p("John", "Smith");

  append_foo(p.first_name);
  append_foo(p.last_name);
}

もしくは別のコピーを作成します。これは難しいものではりません。

std::string append_foo(const std::string& value) {
    // set capacity and avoid multiple allocations
    std::string ret;
    ret.reserve(value.size() + 4);
    ret += value;
    ret += " foo";
    return ret;
}

int main() {
    Person p("John", "Smith");

    p.first_name = append_foo(p.first_name);
    p.last_name = append_foo(p.last_name);
}

結論

RustからC++に戻る時の大きなハードルは、デフォルトで所有権を移動する機能がなくなることです。ということは、C++の世界で使われる他の慣用的なパターンを学ばなければならず、場合によっては、全てのコードが効率性と保守性を両立できている必要はないと認めなければならないということです。

多くの場合、保守性が優先されます。そして、「早まった最適化」を避けることが、C++では本当に必要不可欠なのです。