C ++では、関数からベクトルを返すことはまだ悪い習慣ですか?


102

短いバージョン:多くのプログラミング言語では、ベクトルや配列などの大きなオブジェクトを返すのが一般的です。クラスにmoveコンストラクターがある場合、このスタイルはC ++ 0xで受け入れられるのですか、それともC ++プログラマーは奇妙/醜い/退屈であると考えていますか?

長いバージョン: C ++ 0xでは、これはまだ悪い形式と見なされていますか?

std::vector<std::string> BuildLargeVector();
...
std::vector<std::string> v = BuildLargeVector();

従来のバージョンは次のようになります。

void BuildLargeVector(std::vector<std::string>& result);
...
std::vector<std::string> v;
BuildLargeVector(v);

新しいバージョンでBuildLargeVectorは、から返される値は右辺値であるためstd::vector、(N)RVOが発生しないと仮定すると、vはの移動コンストラクタを使用して構築されます。

C ++ 0xの前でさえ、(N)RVOのために最初の形式はしばしば「効率的」でした。ただし、(N)RVOはコンパイラーの裁量に任されています。これで右辺値参照ができたので、ディープコピーが行われないことが保証されます。

編集:質問は本当に最適化についてではありません。示されている両方の形式は、実際のプログラムでほぼ同一のパフォーマンスを持っています。一方、最初の形式では、パフォーマンスが大幅に低下する可能性がありました。その結果、最初の形式は、長い間C ++プログラミングの主要なコードのにおいでした。もういいんじゃないの?


18
そもそもそれが悪い形だと誰が言ったのですか?
エドワードストレンジ

7
それは確かに、「昔」のコードの悪臭でした。:-)
ネイト

1
私はそう確信している!値渡しの人気が高まることを期待しています。:)
20分03秒にsellibitze

回答:


73

Dave Abrahamsは、値を渡す/返す速度のかなり包括的な分析を行っています

短い答えです。値を返す必要がある場合は、値を返します。コンパイラはとにかくそれを行うので、出力参照を使用しないでください。もちろん警告がありますので、その記事を読んでください。


24
「コンパイラはとにかくそれを行う」:コンパイラはそれを行う必要はありません==不確実性==悪い考え(100%の確実性が必要です)。「包括的な分析」その分析には大きな問題があります-不明なコンパイラのドキュメント化されていない/非標準の言語機能に依存しています(「標準ではコピーの省略は必要ありません」)。したがって、動作しても、使用することはお勧めできません。意図したとおりに動作するという保証はなく、すべてのコンパイラが常にこのように動作するという保証はありません。このドキュメントに依存することは悪いコーディング慣行です、IMO。パフォーマンスが低下しても。
SigTerm

5
@SigTerm:それは素晴らしいコメントです!!! 参照されている記事のほとんどは曖昧すぎて、本番環境での使用を検討することすらできません。人々は、Red In-Depthの本を書いた著者は福音であり、これ以上考えたり分析したりせずに忠実であるべきだと考えています。ATMには、Abrahamsが記事で使用している例ほど多様なコピーエリソンを提供するコンパイラーが市場にありません。
Hippicoder

13
@SigTerm、コンパイラーが実行する必要のないことはたくさんありますが、とにかくそうすることを想定しています。コンパイラは変更に「必要」されていないx / 2x >> 1ためintの、あなたはそれがすると仮定します。この標準では、参照を実装するためにコンパイラーがどのように必要とされるかについては何も述べられていませんが、ポインターを使用して効率的に処理されると想定しています。この規格ではvテーブルについても何も述べられていないため、仮想関数呼び出しが効率的であるかどうかもわかりません。基本的に、コンパイラーに時々信仰を置く必要があります。
Peter Alexander

16
@Sig:プログラムの実際の出力を除いて、実際にはほとんど保証されていません。何が起こるかを100%確実にしたい場合は、完全に別の言語に切り替える方がよいでしょう。
Dennis Zickefoose 2010年

6
@SigTerm:私は「実際のケースのシナリオ」に取り組んでいます。私はコンパイラが何をするかをテストし、それで動作します。「遅くなるかもしれません」はありません。コンパイラーがRVOを実装しているため、標準で必要とされているかどうかに関係なく、単に遅くなることはありません。ifs、buts、または多分ありません、それは単なる単純な事実です。
Peter Alexander

37

少なくともIMO、それは通常悪い考えですが、効率上の理由ではありません。問題の関数は通常、イテレーターを介して出力を生成する汎用アルゴリズムとして作成する必要があるため、これはお勧めできません。イテレータを操作する代わりにコンテナを受け入れたり返したりするほとんどすべてのコードは疑わしいと見なされるべきです。

誤解しないでください。コレクションのようなオブジェクト(文字列など)を渡すことが理にかなっている場合がありますが、引用した例では、ベクトルを渡すか返すことをお勧めしません。


6
イテレータアプローチの問題は、コレクション要素のタイプがわかっている場合でも、関数とメソッドをテンプレート化する必要があることです。これは苛立たしく、問題の方法が仮想的なものである場合は不可能です。注:私はあなたの答え自体に反対しているわけではありませんが、実際にはC ++では少し面倒になります。
jon-hanson

22
私は反対しなければなりません。出力にイテレータを使用するのが適切な場合もありますが、一般的なアルゴリズムを記述していない場合、一般的なソリューションでは、正当化するのが難しい避けられないオーバーヘッドが発生することがよくあります。コードの複雑さと実際のパフォーマンスの両方の観点から。
Dennis Zickefoose 2010年

1
@Dennis:私の経験はまったく逆だったと言わざるを得ません。事前に関係する型がわかっている場合でも、かなりの数をテンプレートとして記述します。そうすることで、操作が簡単になり、パフォーマンスが向上します。
Jerry Coffin、2010年

9
個人的にコンテナを返却します。意図は明らかで、コードは簡単です。コードを書くときのパフォーマンスはあまり気にしません(私は初期の悲観化を避けているだけです)。大規模なプロジェクトでは依存関係によって開発が中止されるため、出力イテレータを使用することで意図が明確になるかどうかはわかりません。また、できるだけ非テンプレートコードが必要です。
Matthieu M.10年

1
@Dennis:概念的には、「範囲に書き込むのではなく、コンテナを構築する」べきではないと思います。コンテナーはまさにそれです-コンテナー。懸念事項(およびコードの懸念事項)は、コンテナではなくコンテンツにある必要があります。
ジェリーコフィン

18

要点は次のとおりです。

エリシオンとRVOをコピーすると、「怖いコピー」を回避できます(これらの最適化を実装するためにコンパイラーは必要ではなく、状況によっては適用できません)。

C ++ 0x RValue参照、それを保証する文字列/ベクトル実装を許可します。

古いコンパイラ/ STL実装を放棄できる場合は、ベクトルを自由に返します(独自のオブジェクトがそれをサポートしていることも確認してください)。コードベースが「より少ない」コンパイラをサポートする必要がある場合は、古いスタイルを使用してください。

残念ながら、それはインターフェースに大きな影響を与えます。C ++ 0xがオプションではなく、保証が必要な場合は、一部のシナリオで、代わりに参照カウントオブジェクトまたはコピーオンライトオブジェクトを使用できます。ただし、マルチスレッドには欠点があります。

(私はC ++での1つの答えが単純で簡単で、条件がないことを望みます)。


11

実際、C ++ 11以降、コピーのコストはstd::vectorほとんどの場合なくなっています。

ただし、新しいベクトルを作成する(その後、ベクトルを破棄する)コストは依然として存在することに注意してください。ベクトルの容量を再利用したい場合は、値で返すのではなく、出力パラメーターを使用すると便利です。これは、C ++コアガイドラインのF.20に例外として記載されています。

比較してみましょう:

std::vector<int> BuildLargeVector1(size_t vecSize) {
    return std::vector<int>(vecSize, 1);
}

と:

void BuildLargeVector2(/*out*/ std::vector<int>& v, size_t vecSize) {
    v.assign(vecSize, 1);
}

ここで、これらのメソッドをnumIterタイトなループで何度も呼び出し、何らかのアクションを実行する必要があるとします。たとえば、すべての要素の合計を計算してみましょう。

を使用するとBuildLargeVector1、次のようになります。

size_t sum1 = 0;
for (int i = 0; i < numIter; ++i) {
    std::vector<int> v = BuildLargeVector1(vecSize);
    sum1 = std::accumulate(v.begin(), v.end(), sum1);
}

を使用するとBuildLargeVector2、次のようになります。

size_t sum2 = 0;
std::vector<int> v;
for (int i = 0; i < numIter; ++i) {
    BuildLargeVector2(/*out*/ v, vecSize);
    sum2 = std::accumulate(v.begin(), v.end(), sum2);
}

最初の例では、多くの不要な動的割り当て/割り当て解除が発生しています。これは、2番目の例では古い方法で出力パラメーターを使用し、すでに割り当てられているメモリを再利用することで防止されます。この最適化を行う価値があるかどうかは、値の計算/変更のコストと比較した割り当て/割り当て解除の相対的なコストに依存します。

基準

値で遊ぼうvecSizenumIter。vecSize * numIterを一定に保つため、「理論上」同じ時間がかかり(=割り当てと追加の数は同じで、値はまったく同じです)、時間の差は、割り当て、割り当て解除、およびキャッシュのより適切な使用。

具体的には、vecSize * numIter = 2 ^ 31 = 2147483648を使用してみましょう。これは、16 GBのRAMがあり、この数値により8 GBが割り当てられないため(sizeof(int)= 4)、ディスクにスワップしないことを保証します(他のすべてのプログラムは閉じられており、テストの実行時に約15GBが利用可能でした)。

これがコードです:

#include <chrono>
#include <iomanip>
#include <iostream>
#include <numeric>
#include <vector>

class Timer {
    using clock = std::chrono::steady_clock;
    using seconds = std::chrono::duration<double>;
    clock::time_point t_;

public:
    void tic() { t_ = clock::now(); }
    double toc() const { return seconds(clock::now() - t_).count(); }
};

std::vector<int> BuildLargeVector1(size_t vecSize) {
    return std::vector<int>(vecSize, 1);
}

void BuildLargeVector2(/*out*/ std::vector<int>& v, size_t vecSize) {
    v.assign(vecSize, 1);
}

int main() {
    Timer t;

    size_t vecSize = size_t(1) << 31;
    size_t numIter = 1;

    std::cout << std::setw(10) << "vecSize" << ", "
              << std::setw(10) << "numIter" << ", "
              << std::setw(10) << "time1" << ", "
              << std::setw(10) << "time2" << ", "
              << std::setw(10) << "sum1" << ", "
              << std::setw(10) << "sum2" << "\n";

    while (vecSize > 0) {

        t.tic();
        size_t sum1 = 0;
        {
            for (int i = 0; i < numIter; ++i) {
                std::vector<int> v = BuildLargeVector1(vecSize);
                sum1 = std::accumulate(v.begin(), v.end(), sum1);
            }
        }
        double time1 = t.toc();

        t.tic();
        size_t sum2 = 0;
        {
            std::vector<int> v;
            for (int i = 0; i < numIter; ++i) {
                BuildLargeVector2(/*out*/ v, vecSize);
                sum2 = std::accumulate(v.begin(), v.end(), sum2);
            }
        } // deallocate v
        double time2 = t.toc();

        std::cout << std::setw(10) << vecSize << ", "
                  << std::setw(10) << numIter << ", "
                  << std::setw(10) << std::fixed << time1 << ", "
                  << std::setw(10) << std::fixed << time2 << ", "
                  << std::setw(10) << sum1 << ", "
                  << std::setw(10) << sum2 << "\n";

        vecSize /= 2;
        numIter *= 2;
    }

    return 0;
}

そしてここに結果があります:

$ g++ -std=c++11 -O3 main.cpp && ./a.out
   vecSize,    numIter,      time1,      time2,       sum1,       sum2
2147483648,          1,   2.360384,   2.356355, 2147483648, 2147483648
1073741824,          2,   2.365807,   1.732609, 2147483648, 2147483648
 536870912,          4,   2.373231,   1.420104, 2147483648, 2147483648
 268435456,          8,   2.383480,   1.261789, 2147483648, 2147483648
 134217728,         16,   2.395904,   1.179340, 2147483648, 2147483648
  67108864,         32,   2.408513,   1.131662, 2147483648, 2147483648
  33554432,         64,   2.416114,   1.097719, 2147483648, 2147483648
  16777216,        128,   2.431061,   1.060238, 2147483648, 2147483648
   8388608,        256,   2.448200,   0.998743, 2147483648, 2147483648
   4194304,        512,   0.884540,   0.875196, 2147483648, 2147483648
   2097152,       1024,   0.712911,   0.716124, 2147483648, 2147483648
   1048576,       2048,   0.552157,   0.603028, 2147483648, 2147483648
    524288,       4096,   0.549749,   0.602881, 2147483648, 2147483648
    262144,       8192,   0.547767,   0.604248, 2147483648, 2147483648
    131072,      16384,   0.537548,   0.603802, 2147483648, 2147483648
     65536,      32768,   0.524037,   0.600768, 2147483648, 2147483648
     32768,      65536,   0.526727,   0.598521, 2147483648, 2147483648
     16384,     131072,   0.515227,   0.599254, 2147483648, 2147483648
      8192,     262144,   0.540541,   0.600642, 2147483648, 2147483648
      4096,     524288,   0.495638,   0.603396, 2147483648, 2147483648
      2048,    1048576,   0.512905,   0.609594, 2147483648, 2147483648
      1024,    2097152,   0.548257,   0.622393, 2147483648, 2147483648
       512,    4194304,   0.616906,   0.647442, 2147483648, 2147483648
       256,    8388608,   0.571628,   0.629563, 2147483648, 2147483648
       128,   16777216,   0.846666,   0.657051, 2147483648, 2147483648
        64,   33554432,   0.853286,   0.724897, 2147483648, 2147483648
        32,   67108864,   1.232520,   0.851337, 2147483648, 2147483648
        16,  134217728,   1.982755,   1.079628, 2147483648, 2147483648
         8,  268435456,   3.483588,   1.673199, 2147483648, 2147483648
         4,  536870912,   5.724022,   2.150334, 2147483648, 2147483648
         2, 1073741824,  10.285453,   3.583777, 2147483648, 2147483648
         1, 2147483648,  20.552860,   6.214054, 2147483648, 2147483648

ベンチマーク結果

(Intel i7-7700K @ 4.20GHz; 16GB DDR4 2400Mhz; Kubuntu 18.04)

表記:mem(v)= v.size()* sizeof(int)= v.size()* 4私のプラットフォームでは。

当然のことながら、numIter = 1(つまり、mem(v)= 8GB)場合、時間は完全に同一です。実際、どちらの場合でも、メモリに8 GBの巨大なベクトルを1回だけ割り当てます。これは、BuildLargeVector1()を使用してもコピーが行われなかったことも証明します。コピーを実行するのに十分なRAMがありません!

の場合numIter = 2、2番目のベクトルを再割り当てする代わりにベクトル容量を再利用すると、1.37倍速くなります。

の場合numIter = 256、ベクトル容量の再利用(ベクトルの割り当て/割り当て解除を256回繰り返す代わりに...)は2.45倍高速です:)

time1はnumIter = 1to からまでほぼ一定numIter = 256であることがわかります。つまり、8 GBの1つの巨大なベクトルを割り当てると、32 MBの256ベクトルを割り当てる場合と同じくらいのコストがかかります。ただし、8 GBの1つの巨大なベクトルを割り当てると、32 MBの1つのベクトルを割り当てるよりも明らかにコストがかかるため、ベクトルの容量を再利用するとパフォーマンスが向上します。

numIter = 512(MEM(V)= 16メガバイト)までnumIter = 8M(MEM(V)= 1kBの)スイートスポットです:両方の方法は、早く正確で、より速くnumIterとvecSizeのすべての他の組み合わせよりも。これはおそらく、私のプロセッサのL3キャッシュサイズが8MBであるため、ベクトルがほぼ完全にキャッシュに収まるためです。time1mem(v)= 8MB の突然のジャンプがなぜmem(v)= 16MBであるのかは、実際には説明しません。驚くことに、このスイートスポットでは、再利用しない方が実際にはわずかに高速です。私は本当にこれを説明しません。

numIter > 8M物事が醜くなり始めるとき。どちらの方法も遅くなりますが、値によるベクトルの返却はさらに遅くなります。最悪の場合、単一のを1つだけ含むベクトルでintは、値で返す代わりに容量を再利用すると、3.3倍速くなります。おそらく、これは、支配し始めるmalloc()の固定コストが原因です。

time2の曲線がtime1の曲線よりもどのように滑らかであるかに注意してください。ベクトル容量の再利用は一般的に高速であるだけでなく、おそらくより重要なことに、より予測可能です。

また、スイートスポットでは、64ビット整数の20億回の追加を約0.5秒で実行できたことに注意してください。これは、4.2Ghz 64ビットプロセッサで非常に最適です。8つのコアすべてを使用するために計算を並列化することで、より良い結果が得られます(上記のテストでは、一度に1つのコアのみを使用します。これは、CPU使用率を監視しながらテストを再実行して確認しました)。最高のパフォーマンスは、mem(v)= 16kBのときに達成されます。これは、L1キャッシュの桁数です(i7-7700KのL1データキャッシュは4x32kBです)。

もちろん、実際にデータに対して実行する必要がある計算が多ければ多いほど、違いはますます少なくなります。以下は、次のように置き換えsum = std::accumulate(v.begin(), v.end(), sum);た場合の結果ですfor (int k : v) sum += std::sqrt(2.0*k);

ベンチマーク2

結論

  1. 値で返す代わりに出力パラメーターを使用すると、容量を再利用することでパフォーマンスが向上する場合があります。
  2. 最近のデスクトップコンピューターでは、これは大きなベクター(> 16MB)と小さなベクター(<1kB)にのみ適用できるようです。
  3. 数百万/数十億の小さなベクトル(<1kB)を割り当てることは避けてください。可能であれば、キャパシティを再利用するか、さらに良い方法として、アーキテクチャを別の方法で設計します。

他のプラットフォームでは結果が異なる場合があります。通常どおり、パフォーマンスが重要な場合は、特定のユースケースのベンチマークを記述します。


6

私はまだそれは悪い習慣だと思いますが、私のチームはMSVC 2008とGCC 4.1を使用しているため、最新のコンパイラーは使用していません。

以前は、MSVC 2008のvtuneに表示されるホットスポットの多くは文字列のコピーに分類されていました。次のようなコードがありました。

String Something::id() const
{
    return valid() ? m_id: "";
}

...独自のString型を使用していることに注意してください(プラグイン作成者が異なるコンパイラを使用しているために、std :: string / std :: wstringの互換性のない異なる実装を使用できるソフトウェア開発キットを提供しているため、これが必要でした)。

String :: String(const String&)がかなりの時間を費やしていることを示すコールグラフサンプリングプロファイリングセッションに応答して、簡単な変更を加えました。上記の例のようなメソッドが最大の貢献者でした(実際、プロファイリングセッションでは、メモリの割り当てと割り当て解除が最大のホットスポットの1つであり、Stringコピーコンストラクターが割り当ての主な貢献者でした)。

私が行った変更は簡単でした:

static String null_string;
const String& Something::id() const
{
    return valid() ? m_id: null_string;
}

しかし、これは違いの世界を作りました!ホットスポットは後続のプロファイラーセッションでなくなり、これに加えて、アプリケーションのパフォーマンスを追跡するために、徹底的なユニットテストを数多く行っています。これらの単純な変更の後に、あらゆる種類のパフォーマンステスト時間が大幅に減少しました。

結論:絶対に最新のコンパイラを使用していませんが、値によって確実に返されるようにコピーを最適化するコンパイラに依存しているようには見えません(少なくともすべての場合では)。MSVC 2010などの新しいコンパイラーを使用している場合は、そうではない可能性があります。C++ 0xを使用して右辺値参照を使用できるようになるのを楽しみにしています。複雑なコードを返すことでコードを悲観化することを心配する必要はありません。値によるクラス。

[編集] Nateが指摘したように、RVOは関数内で作成された一時的な戻りに適用されます。私の場合、そのような一時的なものはなく(空の文字列を作成する無効なブランチを除く)、RVOは適用されませんでした。


3
RVOはコンパイラに依存しますが、C ++ 0xコンパイラは、 RVOを使用しないことを決定した場合(移動コンストラクタがあると想定)、移動セマンティクスを使用する必要があります。3文字表記演算子を使用すると、RVOが無効になります。Peterが言及したcpp-next.com/archive/2009/09/move-it-with-rvalue-referencesを参照してください。ただし、一時的なものを返さないため、この例は移動セマンティクスの対象にはなりません。
2010年

@ Stinky472:値でメンバーを返すと、常に参照よりも遅くなります。右辺値参照は、元のメンバーへの参照を返すよりも低速です(呼び出し元がコピーを必要とせずに参照を取得できる場合)。さらに、コンテキストがあるので、右辺値参照よりも多くの場合、保存できます。たとえば、String newstringを実行できます。newstring.resize(string1.size()+ string2.size()+ ...); newstring + = string1; newstring + = string2; 等これはまだ右辺値よりもかなりの節約です。
パピー

@DeadMGは、RVOを実装するC ++ 0xコンパイラを使用しても、バイナリ演算子+を大幅に節約できますか?もしそうなら、それは残念です。+ =は直接newstringに連結できるのに対し、連結された文字列を計算するための一時変数を作成する必要があるため、このmakseの意味も同じです。
stinky472

次のような場合はどうでしょう:string newstr = str1 + str2; moveセマンティクスを実装するコンパイラでは、それは次のように高速であるか、さらに高速である必要があります。string newstr; newstr + = str1; newstr + = str2; 予備はありません。いわば(サイズ変更ではなく予備を意味していると思います)。
stinky472

5
@Nate:私はあなたが混乱していると思うトリグラフなどの<::??!との条件演算子 ?:(とも呼ばれる三項演算子を)。
fredoverflow 2010年

3

少しひねりを加えるだけです。多くのプログラミング言語では、関数から配列を返すことは一般的ではありません。それらのほとんどでは、配列への参照が返されます。C ++では、最も近い類推は次のようになりますboost::shared_array


4
@Billy:std :: vectorは、コピーセマンティクスを持つ値型です。現在のC ++標準では、(N)RVOが適用されるという保証はありません。実際には、適用されない場合の実際のシナリオが数多くあります。
Nemanja Trifunovic 2010年

3
@Billy:ここでも、いくつかの非常に現実的なシナリオでも、最新のコンパイラがNRVOを適用していないところがあります。efnetcpp.org/wiki/Return_value_optimization#Named_RVO
ネマニャTrifunovic

3
@Billy ONeal:99%では不十分で、100%必要です。マーフィーの法則-「問題が発生する可能性がある場合、それは発生します」。ある種のファジーロジックを処理している場合は、不確実性は問題ありませんが、従来のソフトウェアを作成することはお勧めできません。コードがあなたの考えた通りに機能しない可能性が1%さえあれば、このコードはあなたを解雇させる重大なバグを引き起こすと期待するべきです。さらに、それは標準機能ではありません。ドキュメントに記載されていない機能を使用することは悪い考えです。1年以内にコンパイラが機能を削除する場合(標準では必要ありません)、問題が発生します。
SigTerm 2010年

4
@SigTerm:行動の正しさについて話していたら、私はあなたに同意します。ただし、ここではパフォーマンスの最適化について話しています。このようなことは、100%未満の確実性で問題ありません。
Billy ONeal

2
@ネマニャ:何に「依存」されているのか、ここではわかりません。RVOとNRVOのどちらを使用しても、アプリは同じように実行されます。ただし、これらを使用すると、実行速度が速くなります。特定のプラットフォームでアプリが遅すぎて戻り値のコピーまでたどった場合は、必ず変更してください。ただし、それでも、戻り値を使用することがベストプラクティスであるという事実は変わりません。コピーが発生しないことを確認する必要がある場合は、ベクターをa shared_ptrで囲み、1日と呼びます。
Billy ONeal

2

パフォーマンスが実際の問題である場合は、移動のセマンティクスが常にコピーよりも速いとは限らないことを理解する必要があります。たとえば、小さな文字列の最適化を使用する文字列がある場合、小さな文字列の場合、移動コンストラクターは通常のコピーコンストラクターとまったく同じ量の作業を行う必要があります。


1
moveコンストラクターが追加されたからといって、NRVOがなくなるわけではありません。
Billy ONeal

1
@ビリー、真実だが無関係である質問は、C ++ 0xがベストプラクティスを変更したか、C ++ 0xが原因でNRVOが変更されなかったか
Motti
弊社のサイトを使用することにより、あなたは弊社のクッキーポリシーおよびプライバシーポリシーを読み、理解したものとみなされます。
Licensed under cc by-sa 3.0 with attribution required.