POSTD PRODUCED BY NIJIBOX

POSTD PRODUCED BY NIJIBOX

ニジボックスが運営する
エンジニアに向けた
キュレーションメディア

POSTD PRODUCED BY NIJIBOX

POSTD PRODUCED BY NIJIBOX

ニジボックスが運営する
エンジニアに向けた
キュレーションメディア

FeedlyRSSTwitterFacebook
Nerijus Arlauskas

本記事は、原著者の許諾のもとに翻訳・掲載しております。

今日、ソーシャルサイト「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 ライブラリには、どんな lvalue rvalue に変えられる std::move という関数があります。

そのため、 std::move val をラッピングすることで不要なコピーを避け、既存の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_ptr Box など)や shared_ptr Arc など)にラッピングするのが有効です。こうすれば、ヒープ領域で値のシングルインスタンスを保持します。このような場合、 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++では、 Person const となり得る場所で、 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_ptr shared_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のコンパイラはライフタイムの省略を理解できます。つまり、毎回ライフタイムへの参照に注釈をつけなくても、参照する場所が分かるのです。

例えば、 Person impl が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++では本当に必要不可欠なのです。

監修者
監修者_古川陽介
古川陽介
株式会社リクルート プロダクト統括本部 プロダクト開発統括室 グループマネジャー 株式会社ニジボックス デベロップメント室 室長 Node.js 日本ユーザーグループ代表
複合機メーカー、ゲーム会社を経て、2016年に株式会社リクルートテクノロジーズ(現リクルート)入社。 現在はAPソリューショングループのマネジャーとしてアプリ基盤の改善や運用、各種開発支援ツールの開発、またテックリードとしてエンジニアチームの支援や育成までを担う。 2019年より株式会社ニジボックスを兼務し、室長としてエンジニア育成基盤の設計、技術指南も遂行。 Node.js 日本ユーザーグループの代表を務め、Node学園祭などを主宰。