⚖️

C++の完全転送とRustのimpl Trait

2022/08/22に公開2

C++の完全転送

まず、 C++ で直面する問題について説明します。

人の名簿をデータ構造化する典型的な例を考えます。
苗字と名前をコンストラクタに渡すようなインターフェースであれば、次のようなものを思いつきます。

struct Person {
	Person(const std::string& firstName, const std::string& lastName) :
	    firstName(firstName), lastName(lastName){}
	std::string firstName;
	std::string lastName;
};

パフォーマンスに敏感な C++ プログラマはすぐに文字列のコピーに気が付くでしょう。
次のようにこの構造体を使うと、まず "John""Smith" が文字列リテラルから std::string へと変換され、それがコンストラクタの引数へとコピーされます。

Person john = Person{"John", "Smith"};

文字列リテラルに対応するだけでよいのなら、次のように書くことで std::string の動的メモリを最小限に抑えられます。

	Person(const char* firstName, const char* lastName) :
	    firstName(firstName), lastName(lastName){}

しかし、次のように入力がすでに std::string であった場合は、 c_str() を使って一度 C 文字列へ変換し、さらにそれを std::string に変換するという無駄が生じます。

std::string firstName = readFromInput();
std::string lastName = readFromInput();

Person john = Person{firstName.c_str(), lastName.c_str()};

元々 std::string だったのだから、コピーではなくて値をそのままコピーすればいいではないか?と思いますね。

C++11 からはムーブセマンティクスが登場し、次のように書けるようになりました。

    Person(std::string&& firstName, std::string&& lastName) :
        firstName(std::move(firstName)), lastName(std::move(lastName)){}

    Person john = Person{std::move(firstName), std::move(lastName)};

しかし、こうなると元の文字列を取っておいてコピーをコンストラクタに渡したいと思ってむできなくなります。文字列リテラルも渡せなくなります。
明示的に std::string のコンストラクタを挟めば、 rvalue になるので動きますが、毎回この書き方をするのも煩わしいですね。
しかもムーブもノーコストなわけではないので、わざわざ文字列をコンストラクトしてからムーブするのも無駄な感じがします。

std::string firstName = readFromInput();
std::string lastName = readFromInput();

Person john = Person{firstName, lastName}; // エラー
Person john2 = Person{"John", "Smith"}; // エラー
Person john2 = Person{std::string("John"), std::string("Smith")}; // OKだが煩わしい

std::cout << firstName << " " << lastName << " has registered!\n";

じゃあ全てのパターンをオーバーロードすればいいのか?確かにそれもできます。

    Person(const char* firstName, const char* lastName) :
        firstName(firstName), lastName(lastName){}
    Person(const std::string& firstName, const std::string& lastName) :
        firstName(firstName), lastName(lastName){}
    Person(std::string&& firstName, std::string&& lastName) :
        firstName(std::move(firstName)), lastName(std::move(lastName)){}

しかし第1引数と第2引数に同じ型が渡されるという保証もありません。
次のようなケースも考えられます。

    std::vector<Person> people;
    std::string lastNames[] = {"Kono", "Aso"};
    for(auto lastName : lastNames){
        people.push_back(Person("Taro", std::move(lastName)));
    }

3種のコンストラクタでも多いのに、さらに組み合わせ数の爆発を招きます。引数2つぐらいならまだしも3つや4つに増えていったら手に負えなくなります。

そこで、 forwarding reference と完全転送の出番です。

    template<typename T, typename U>
    Person(T&& firstName, U&& lastName) :
        firstName(std::forward<T>(firstName)), lastName(std::forward<U>(lastName)){}

T&& および U&& は右辺値参照のように見えますが、実際には forwarding reference というものです。これは右辺値にも左辺値にもバインドされます。[1]
std::forward<T> はこの情報を T から読み取って、左辺値か右辺値か適切な方へキャストします。
これによって、実引数の型が何であろうとも、最も効率的な形で std::string のコンストラクタが呼ばれることになります。また、 firstNamelastName の型が同じでなくてもテンプレートがそれぞれの組み合わせのインスタンスを生成してくれます。

以上の内容は Effective Modern C++ により詳しく書いてあるのですが、正直言って複雑すぎないかという印象をほとんどの人が持つと思います。
静的文字列ならコピーを文字列にし、移動できるなら移動したいという、ただそれだけのことを実現するのにこれほどの知識が必要なのでしょうか。

Rust の解決法

Rust でも同様の問題はあります。
C++ と同様の議論から、次のような2通りのコンストラクタが考えられるでしょう(Rust では文字列リテラルは &str なので、 C++ のように3種類のコンストラクタは必要ありません。)

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

impl Person {
    fn new(first_name: String, last_name: String) -> Self {
        Self {
            first_name,
            last_name
        }
    }
    fn new_ref(first_name: &str, last_name: &str) -> Self {
        Self {
            first_name: first_name.to_string(),
            last_name: last_name.to_string(),
        }
    }
}

しかし2つでもできれば減らしたいですし、組み合わせの数の爆発の問題は依然としてあります。

Rust の解決法は、ジェネリックスと Into トレイトです。

impl Person {
    fn new(first_name: impl Into<String>, last_name: impl Into<String>) -> Self {
        Self {
            first_name: first_name.into(),
            last_name: last_name.into(),
        }
    }
}

引数の型が &str であった場合は、 <&str as Into<String>>::into() が呼び出され、 静的文字列から動的な String を生成します。
引数が String であった場合は、 <String as Into<String>>::into() が呼ばれますが、これは no-op になりますので、無駄なコピーは生じません。

異なる方を混ぜて使っても大丈夫ですし、テンプレート引数のようなものもいりません。

    let last_names = ["Kono".to_string(), "Aso".to_string()];
    let people = last_names.into_iter().map(|last_name| {
        Person::new("Taro", last_name)
    }).collect::<Vec<_>>();

Rust には関数のオーバーロードはありませんが、 Into トレイトが実質的にその役目を担っているといえると思います。

これが分かりやすいかというと、人によるかもしれませんが、少なくとも C++ ほどは複雑ではないと思います。

脚注
  1. Scott Meyers は universal reference という呼び方をしていましたが、 C++ 標準委員会では forwarding reference という呼称が一般的なようです。 ↩︎

Discussion

白山風露白山風露

C++の方ですが、例のようなパターンであれば

Person(std::string firstName, std::string lastName): firstName(std::move(firstName)), lastName(std::move(lastName)) {}

というコンストラクタで良いと考えています。コンストラクタが受け取る値が右辺値であればムーブコンストラクタが呼ばれますし、左辺値であればコピーコンストラクタが呼ばれます。

Rustの方も同じように String だけ受け取るようにしても良いのではないか、とも思います。好みの問題程度ですが、 .into() くらい呼び出し側で書いても良いのではないでしょうか。

msakutamsakuta

C++のほう、それだと文字列リテラルを渡したとき、呼び出し元で std::string をコンストラクト -> Person::Personの引数へコピーコンストラクト -> メンバーへムーブコンストラクト にならないでしょうか。

最適化でコピーコンストラクタは省略されますが、ムーブコンストラクタは残るようです:
https://godbolt.org/z/4aK5jEj1M

一方、完全転送版は最適化が効きすぎていてよくわかりませんが、 std::string のコンストラクタは呼び出されていないようです:
https://godbolt.org/z/PKG9nod8e

Rust のほうは、まあ好みの問題かもしれません。

ここまで書いておいてなんですが、完全転送の例として文字列の型変換はあまりよろしくない気がしてきました。完全転送が本領を発揮するのはコンテナの emplace 系 in-place コンストラクタで、文字列にここまで気を遣うことは現実にはほとんどないと思います。

いつか C++Con でこのような文字列の変換の例を見て、うろ覚えで書いたのですが、完全転送がこのためにあるという誤ったメッセージにならないかが少し心配です。記事を取り下げるほどではないとは思いますが……