完全転送 | Programming Place Plus 新C++編

トップページ新C++編

先頭へ戻る

このページの概要 🔗

このページでは、完全転送について学びます。完全転送とは、関数テンプレートが受け取った引数を他の関数に引き渡すときに、元のオブジェクトの性質(lvalue か rvalue か、const か否か)をすべて保持したまま渡すことです。この実現に必要な forwardingリファレンスと std::forward関数を解説しているほか、型特性や直接配置についても触れています。

このページの解説は C++14 をベースとしています

以下は目次です。要点だけをさっと確認したい方は、「まとめ」をご覧ください。



転送と完全転送 🔗

転送 (forwarding) とは、関数が受け取った引数を、次に呼び出すほかの関数からも利用できるように引き渡すことを指します。

void f2(int& v);

void f1(int& v)
{
     f2(v);  // 引数v を f2 に転送
}

転送には参照型の引数を使います。もしオブジェクトをコピーとして渡していたら、それは異なるオブジェクトだということになります。これでは、呼び出された先の関数で変更されたとしても、元のオブジェクトは変化しないことになるため、転送していることになりません。

しかし、単なる転送の考え方では、オブジェクトの性質に関する情報が失われます。次のサンプルプログラムのように、仮引数を const参照にすれば色々なパターンの実引数を渡せますが、実引数が lvalue だったのか rvalue だったのかとか(「オブジェクトのムーブ」のページを参照)、本来は const が付いていたといった情報が失われてしまいます。

#include <iostream>

void f2(const int& v)
{
     std::cout << v << "\n";
}

void f1(const int& v)
{
     f2(v);  // 引数v を f2 に転送
}

int main()
{
     int x1 {10};
     const int x2 {20};

     f1(x1);  // x1 は constでない lvalue
     f1(x2);  // x2 は const の lvalue
     f1(30);  // 30 は rvalue
}

実行結果:

10
20
30

3通りの実引数を渡しており、どの場合も値は正しく渡せています。しかし、仮引数は const int& ですから、どのような実引数を渡そうとも、const参照として扱うことなりますし、仮引数である v はいつも lvalue です。これは、実引数がムーブできるものだとしても、ムーブされないことを意味します。仮引数vを int&& にすれば、rvalue をムーブできますが、今度は lvalue を渡せなくなります。名前を持つ変数である仮引数v はつねに lvalue として扱われるので、関数に引き渡すたびに std::move関数を使って rvalue にキャストし直す必要があります。

そこで、元のオブジェクトが持っていた情報も含めてすべてを転送する方法が必要になります。そのような転送を、完全転送 (perfect forwarding) といいます。情報が失われないので、完全転送が実現されていれば、最初の関数を呼び出すことと、同じ実引数を指定して完全転送先の関数を呼び出すことはまったく同じ結果を生むことになります。

完全転送を実現するためには、forwardingリファレンスと std::forward関数という2つの要素が必要です。

forwardingリファレンス 🔗

次のコード例において T&&forwardingリファレンス(転送参照) (forwarding reference) と呼ばれます。

template <typename T>
void f(T&& v)  // v は forwardingリファレンス
{
}

一見すると rvalueリファレンスのようですが、T&& のテンプレート仮引数 T の部分が型推論によって決まることがポイントになります。このほかに、auto に対する auto&& も forwardingリファレンスですが、こちらもやはり型推論が関与していますauto&& については改めて取り上げますただし、const T&& のように、const 修飾子が付いている場合は forwardingリファレンスではなく、rvalueリファレンスです。

転送参照の呼び名が正式なものになったのは C++17 からで、それ以前には俗称としてユニヴァーサル参照 (universal reference) と呼ばれることがありました。

そのリファレンスの型を決定するために型推論が関与しているかどうかがポイントになるので、T&& というソースコード上の見た目だけで判断しないように注意してください。たとえば、次のコードに現れる T&& は forwardingリファレンスではなく、rvalueリファレンスです。

template <typename T>
class C {
public:
    void f(T&& v)  // v は rvalueリファレンス
    {
    }
};

なぜなら、C<int> c {}; のようにして、クラステンプレートがインスタンス化されるときに T の型は決定されているのであって、その時点で fメンバ関数の仮引数の型は決定済みだからです(void f(int&& v) となるので、これは rvalueリファレンスである)。

一方で、f がメンバ関数テンプレートであり、そのテンプレート仮引数を使っているのなら、forwardingリファレンスになります。

template <typename T>
class C {
public:
    template <typename U>
    void f(U&& v)  // v は forwardingリファレンス
    {
    }
};

forwardingリファレンスで、テンプレート仮引数や auto の箇所を型推論するとき、渡された値が lvalue であれば元の型の lvalueリファレンスに、rvalue であれば非参照型に推論されます。

#include <utility>

template <typename T>
void f(T&& v)  // v は forwardingリファレンス
{
}

struct C {};

int main()
{
    C c {};

    f(c);            // lvalue を渡しているので、T は C&(lvalueリファレンス)
    f(std::move(c)); // rvalue を渡しているので、T は C(非参照型)
}

関数テンプレート f の仮引数は、lvalueリファレンスに型推論された場合には C& && ということになり、非参照型に型推論された場合には C&& ということになります。後者は単に rvalueリファレンスですが、前者は rvalueリファレンスへの lvalueリファレンスという、通常ではありえない形になってしまいます。

ここで C& && というあり得ない状態に対して、コンパイラは参照の圧縮(参照崩壊) (reference collapsing) と呼ばれる処置を施します。

参照の圧縮は、&&& が2つ重なって登場したときに1つにまとめるルールです。具体的には、2つともが rvalueリファレンスの場合にのみ rvalueリファレンスにまとめ、それ以外はつねに lvalueリファレンスにまとめます。したがって、さきほどの C& &&C& にまとめられ、lvalueリファレンスとなります。

こうした動作を経ることで、forwardingリファレンスは、参照先が lvalue であれば lvalueリファレンスとして、rvalue であれば rvalueリファレンスとして動作することになります。

#include <utility>

template <typename T>
void f(T&& v)  // v は forwardingリファレンス
{
}

struct C {};

int main()
{
    C c {};

    f(c);            // lvalue を渡しているので、T は C& であり、仮引数は C& 
    f(std::move(c)); // rvalue を渡しているので、T は C であり、仮引数は C&&
}

std::forward関数 🔗

完全転送を実現するためにもう1つ必要な要素が、<utility> で宣言されている std::forward関数[1]です(実際には関数テンプレート)。

#include <utility>

struct C {};

void f2(C& v);
void f2(C&& v);

template <typename T>
void f1(T&& v)
{
    f2(std::forward<T>(v));  // v を完全転送
}

仮引数 v は forwardingリファレンスなので、参照先は lvalue でも rvalue でもありえます。しかし、v そのものはつねに lvalue です。そのため単に f2(v); のように呼び出すと、必ず lvalue を受け付ける関数が呼び出される結果となり、完全転送にはなりません。そこで、std::forward関数を使います。

std::forward関数は、そのテンプレート仮引数(T)に基づいて、static_cast<T&&> を行った結果を返します。std::move関数がムーブを行っていないのと同じで(「オブジェクトのムーブ」のページを参照)、std::forward関数もこれ自体が完全転送を行うわけではありません

std::forward関数の実装は次のようになっています(完全に規格のとおりではありません)。

template <typename T>
constexpr T&& forward(std::remove_reference_t<T>& v) noexcept
{
    return static_cast<T&&>(v);
}

std::forward関数にはこのほかに、rvalueリファレンスを受け取るオーバーロードがあります[1]。このオーバーロードがあることによって、ある関数が返した rvalue を転送することが可能になります。また、テンプレート仮引数 T が lvalueリファレンスでありながら、誤って rvalue を渡してしまったときなどに、関数内の static_assert で検知することで間違った使用ができないように対策されています。

std::remove_reference_t は型名から参照をあらわす &&& を取り除くテンプレートです。この手のテンプレートはまだ解説していませんが、コンパイル時点で型を操作するために用いられるものです。詳細はあとで取り上げるとして、ともかく、std::remove_reference_t<T> によって、T の部分に含まれている参照が取り除かれた型名が得られます。

もし、f1関数に渡されたものが lvalue だったなら、f1関数の T は C& です。std::forward<T>(v) のように呼び出すので、std::forward関数の T も C& ということになります。T だった部分を C& に置き換えると次のようになります。

constexpr C& && forward(std::remove_reference_t<C&>& v) noexcept
{
    return static_cast<C& &&>(v);
}

C& && が登場するので、参照の圧縮によって以下のようにまとめられます。

constexpr C& forward(std::remove_reference_t<C&>& v) noexcept
{
    return static_cast<C&>(v);
}

また、std::remove_reference_t<C&> の結果は、C& から参照が取り除かれるので C になります。ここに元から指定されている & が加わって、仮引数 v の型は C& となります。

constexpr C& forward(C& v) noexcept
{
    return static_cast<C&>(v);
}

したがって、渡された C&C& にキャストして返すコードになり、実質的に何も行われないことが分かります。これはつまり、f1関数に渡されたときに lvalue だったものは、lvalueリファレンスにより f2関数へと転送されることを意味します。

一方、f1関数に渡されたものが rvalue だったなら、f1関数の T は C です。std::forward<T>(v) のように呼び出すので、std::forward関数の T も C ということになります。したがって、次のようにインスタンス化されます。

constexpr C&& forward(std::remove_reference_t<C>& v) noexcept
{
    return static_cast<C&&>(v);
}

参照の圧縮が必要になる箇所はありません。std::remove_reference_t<C> の結果は C のままであり、仮引数 v の型は C& となります。

constexpr C&& forward(C& v) noexcept
{
    return static_cast<C&&>(v);
}

したがって、渡された C&C&& にキャストして返すコードになり、rvalueリファレンスへキャストして返すという動作になります。これはつまり、f1関数に渡されたときに rvalue だったのに、仮引数を経由することで必ず lvalue となってしまっていたものが、rvalue として f2関数へと転送できるようになったことを意味します。

こうして、std::forward関数の助けにより、lvalue / rvalue のどちらであったのかが維持されたまま、ほかの関数へと転送する完全転送が実現されます。

auto&& 🔗

forwardingリファレンスには auto&& で表現するものもあります。これは auto なので型推論によって型が決定されるのと同時に、forwardingリファレンスなので lvalue / rvalue の両方に対応できるというものです。

int x {10};
auto&& r1 {x};  // x は lvalue なので、r1 は int&(lvalueリファレンス)
auto&& r2 {20}; // 20 は rvalue なので、r2 は int&&(rvalueリファレンス)

auto&& を使用する代表的な場面の1つに、ジェネリックラムダ(「関数テンプレート」のページを参照)があります。仮引数を auto&& にすることで、ラムダ内で完全転送を行うことができます。このようなラムダ式の手法を、完全転送ラムダ (perfect forwarding lambda) と呼ぶことがあります。

auto lambda = [](auto&& v) {
    f(std::forward<decltype(v)>(v));  // v を関数f へ完全転送
};

関数テンプレートの中で完全転送を行う場合と違って、ラムダ式のテンプレート仮引数に直接アクセスする(使う)方法がないため、std::forward関数のテンプレート実引数の指定には decltype を使う必要があります(「スコープと名前空間」のページを参照)。

【C++20】ジェネリックラムダに、関数テンプレートに似た記法が追加され、[]<typename T>(T&& v){} のような記述が可能になったため[2]、decltype に頼らず、std::forward<T>(v) のように書けます。

型特性とメタ関数 🔗

さきほど登場した std::remove_reference_t [3]ですが、これは <type_traits> で定義されているテンプレートです。<type_traits> には、コンパイル時点で、型の特性を判定したり操作したりする型特性 (type traits) と呼ばれる仕組みを実現するためのテンプレートが多数定義されています。

std::remove_reference_t は、型からリファレンスを取り除く操作を行います。宣言は次のようになっています。

namespace std {
    template <typename T>
    struct remove_reference {
        using type = /* */;
    };

    // C++14 以降
    template <typename T>
    using remove_reference_t = typename remove_reference<T>::type;
}

std::remove_reference のように、クラス(構造体)でありながら、コンパイル時に型を導き出したり、値を計算するように設計されたクラス(構造体)のことを、メタ関数 (meta function) と呼びます。

C++11 の時点では std::remove_reference_t が定義されておらず、std::remove_reference<T>::type のように記述する必要がありました。また、曖昧になる場面では typename を付加する必要もありました(「クラステンプレート」のページを参照)。

C++14 からは末尾に _t を付加した名前が定義され、std::remove_reference_t<T> のように書けるようになっており、今ではこちらの書き方を使えばいいです。

#include <type_traits>

int main()
{
    using T1 = std::remove_reference_t<int&>;     // T1 は int
    using T2 = std::remove_reference_t<int&&>;    // T2 は int
    using T3 = std::remove_reference_t<int>;      // T3 は int
    using T4 = std::remove_reference<int&>::type; // T4 は int(かつての方法)
}

想定どおりの結果になっていることを調べるために、やはり <type_traits> に定義されている std::is_same[4] を使ってみます。これは2つの型が同じであるかを判定するメタ関数です。コンパイル時点で処理が行われるので、static_assert と組み合わせて、想定どおりの結果でなければコンパイルが失敗するようにして確認してみます。

#include <type_traits>

int main()
{
    static_assert(std::is_same<std::remove_reference_t<int&>, int>::value, "Error");
    static_assert(std::is_same<std::remove_reference_t<int&&>, int>::value, "Error");
    static_assert(std::is_same<std::remove_reference_t<int>, int>::value, "Error");
    static_assert(std::is_same<std::remove_reference<int&>::type, int>::value, "Error");
}

std::is_same は、std::is_same<型1, 型2>::value のように記述して使います。型1 と型2 が同じであれば true になり、異なっていれば false になります。

【C++17】std::remove_reference の ::type を std::remove_reference_t を使って簡潔にできたのと違って、std::is_same はやや面倒な記述になっていますが、C++17 からは std::is_same_v が定義されて、std::is_same_v<型1, 型2> のように書けるようになりました。

直接配置(emplacement) 🔗

完全転送を利用する場面の1つに、オブジェクトの直接配置 (emplacement) があります。直接配置とは、コンテナなどの要素となるオブジェクトを生成するときに、コンテナの外でオブジェクトを生成してからコピーやムーブでコンテナへ引き渡す手順を踏むのを避け、コンテナが使用しているメモリ領域に直接的に生成してしまおうという方法です。

たとえば、std::vector の push_backメンバ関数では、呼び出し側でオブジェクトを生成してから渡すことになります。

#include <string>
#include <vector>

class MyData {
public:
    MyData(int a, const std::string& b) {
        // ...
    }
};

int main()
{
    std::vector<MyData> vec {};
    vec.push_back({10, "xyz"});  // オブジェクトを生成してから渡す
}

しかし、求めていることが、vec の内部に新しい要素を作り出すことであるのなら、外で作ってから渡すよりも、中で作らせた方が効率的です。これが直接配置の考え方です。

直接配置をするためには、コンテナの中でオブジェクトを生成するときに呼び出すコンストラクタの実引数を渡さなければなりませんが、ここで完全転送を活用します。

標準コンテナの場合、要素を追加する各種メンバ関数に対応する直接配置のためのメンバ関数が用意されています。たとえば、push_backメンバ関数に対しては emplace_backメンバ関数が、insertメンバ関数に対しては emplaceメンバ関数です。

emplace_backメンバ関数の実引数には、要素を生成するためのコンストラクタに渡す引数を順番に渡します。emplaceメンバ関数の場合は、第1引数に挿入位置を指定するイテレータを渡し、第2引数からは要素を生成するためのコンストラクタに渡す引数を順番に渡します。

#include <iostream>
#include <iterator>
#include <string>
#include <vector>

class MyData {
public:
    MyData(int a, const std::string& b)
        : m_a {a}, m_b {b}
    {
    }

    void print() const
    {
        std::cout << "a: " << m_a << ", b: " << m_b << "\n";
    }

private:
    int m_a;
    std::string m_b;
};

int main()
{
    std::vector<MyData> vec {};
    vec.emplace_back(10, "xyz");
    vec.emplace(std::cbegin(vec), 20, "abc");

    for (const auto& item : vec) {
        item.print();
    }
}

実行結果:

a: 20, b: abc
a: 10, b: xyz

直接配置を自力で実装するにはどうすればいいでしょうか。最大の問題は、要素の型はいつも同じではないため、コンストラクタの仮引数の型も個数も異なるということです。型の違いだけならば、これまでのページで学んだテンプレートだけで対応できますが、個数の違いは問題になります。どうやっているのか、std::vector の emplace_backメンバ関数、emplaceメンバ関数の宣言をみてみましょう。

template <typename... Args>
void emplace_back(Args&&... args);

template <typename... Args>
iterator emplace(const_iterator position, Args&&... args);

このように、... を使った見慣れない表記が登場します。詳しい説明は次のページに回しますが、... を使うと、そこに0個以上の引数があることを表現できます。この機能を使い、std::forward関数を使って完全転送を行うことにより、直接配置を実現できます。

まとめ 🔗


新C++編の【本編】の各ページには、末尾に練習問題があります。ページ内で学んだ知識を確認する簡単な問題から、これまでに学んだ知識を組み合わせなければならない問題、あるいは更なる自力での調査や模索が必要になるような高難易度な問題をいくつか掲載しています。


参考リンク 🔗


練習問題 🔗

問題の難易度について。

★は、すべての方が取り組める入門レベルの問題です。
★★は、自力でプログラミングができるようなるために、入門者の方であっても取り組んでほしい問題です。
★★★は、本格的にプログラマーを目指す人のための問題です。

問題1 (確認★)

次のコード内にある T&&U&& はそれぞれ rvalueリファレンス、forwardingリファレンスのどちらですか?

template <typename T>
void func1(T&& v) {
}

template <typename T>
class MyClass {
public:
    void func2(T&& v) {
    }

    template <typename U>
    void func3(U&& v) {
    }
};

解答・解説

問題2 (確認★)

型推論により T が以下のように推論された場合、T&& は最終的にどのような型になりますか?

  1. T が int& に推論された場合
  2. T が int に推論された場合
  3. T が const int& に推論された場合

解答・解説

問題3 (基本★)

次のプログラムの wrapper関数内に、完全転送のコードを実装してください。

#include <iostream>
#include <utility>

void process(int& v) {
    std::cout << "lvalue\n";
}

void process(int&& v) {
    std::cout << "rvalue\n";
}

template <typename T>
void wrapper(T&& v) {
    // ここに完全転送の実装を追加
}

int main()
{
    int x = 10;
    wrapper(x);          // "lvalue" と出力
    wrapper(20);         // "rvalue" と出力
}

解答・解説

問題4 (調査★★)

直接配置による方法と、push_backメンバ関数などを使ってオブジェクトを作って渡す方法の実行速度の違いを計測してください。

解答・解説


解答・解説ページの先頭



更新履歴 🔗




はてなブックマーク に保存 Facebook でシェア X で ポストフォロー
LINE で送る noteで書く rss1.0 取得ボタン RSS
管理者情報 プライバシーポリシー
先頭へ戻る