В C ++ все еще является плохой практикой возвращать вектор из функции?

103

Краткая версия: во многих языках программирования обычно возвращаются большие объекты, такие как векторы / массивы. Допустим ли этот стиль в 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является rvalue, поэтому v будет построен с использованием конструктора перемещения std::vector, если (N) RVO не имеет места.

Даже до C ++ 0x первая форма часто была «эффективной» из-за (N) RVO. Однако (N) RVO остается на усмотрение компилятора. Теперь, когда у нас есть ссылки на rvalue, гарантировано, что не будет глубокого копирования.

Изменить : вопрос действительно не об оптимизации. Обе представленные формы имеют почти одинаковую производительность в реальных программах. Тогда как в прошлом первая форма могла иметь на порядок худшие характеристики. В результате первая форма долгое время была основным запахом кода в программировании на C ++. Надеюсь, больше нет?

Нейт
источник
18
Кто сказал, что это дурной тон?
Эдвард Стрэндж
7
Это определенно был неприятный запах кода в «старину», откуда я родом. :-)
Nate
1
Я очень на это надеюсь! Я бы хотел, чтобы передача по ценности стала более популярной. :)
sellibitze

Ответы:

73

Дэйв Абрахамс провел довольно всесторонний анализ скорости передачи / возврата значений .

Короткий ответ: если вам нужно вернуть значение, верните значение. Не используйте выходные ссылки, потому что компилятор все равно это делает. Конечно, есть предостережения, поэтому вам следует прочитать эту статью.

Питер Александр
источник
24
"компилятор все равно сделает это": компилятор не обязан делать это == неопределенность == плохая идея (требуется 100% уверенность). «всесторонний анализ». С этим анализом возникает огромная проблема - он полагается на недокументированные / нестандартные языковые особенности в неизвестном компиляторе («Хотя исключение копирования никогда не требуется стандартом»). Поэтому даже если он работает, использовать его - не лучшая идея - нет абсолютно никакой гарантии, что он будет работать так, как задумано, и нет никакой гарантии, что каждый компилятор всегда будет работать таким образом. Полагаться на этот документ - плохая практика кодирования, ИМО. Даже если потеряешь производительность.
SigTerm
5
@SigTerm: Это отличный комментарий !!! большая часть упомянутой статьи слишком расплывчата, чтобы даже рассматривать ее для использования в производственной среде. Люди думают, что все, что автор написал книгу Red In-Depth, является Евангелием, и его следует придерживаться без каких-либо дальнейших размышлений или анализа. На рынке нет компилятора ATM, который бы обеспечивал такое разнообразие копирования, как примеры, которые Абрахамс использует в статье.
Hippicoder
13
@SigTerm, компилятор не обязан делать многое , но вы все равно предполагаете, что он это делает. Компиляторам не «требуется» переходить x / 2на x >> 1for ints, но вы предполагаете, что это произойдет. Стандарт также ничего не говорит о том, как компиляторы должны реализовывать ссылки, но вы предполагаете, что они эффективно обрабатываются с помощью указателей. В стандарте также ничего не говорится о v-таблицах, поэтому нельзя быть уверенным в эффективности вызовов виртуальных функций. По сути, вам нужно время от времени доверять компилятору.
Питер Александр
16
@Sig: На самом деле гарантируется очень мало, кроме фактического вывода вашей программы. Если вы хотите на 100% быть уверенным в том, что будет происходить в 100% случаев, вам лучше сразу перейти на другой язык.
Dennis Zickefoose
6
@SigTerm: Я работаю над «реальным сценарием». Я тестирую, что делает компилятор, и работаю с этим. Нет "может работать медленнее". Он просто не работает медленнее, потому что компилятор ДЕЙСТВИТЕЛЬНО реализует RVO, требует этого стандарт или нет. Нет никаких «если», «но» или «может быть», это просто факт.
Питер Александр
37

По крайней мере, IMO, это обычно плохая идея, но не из соображений эффективности. Это плохая идея, потому что рассматриваемая функция обычно должна быть написана как общий алгоритм, который производит свой вывод через итератор. Практически любой код, который принимает или возвращает контейнер вместо работы с итераторами, следует считать подозрительным.

Не поймите меня неправильно: бывают случаи, когда имеет смысл передавать объекты, подобные коллекциям (например, строки), но в приведенном примере я считаю передачу или возврат вектора плохой идеей.

Джерри Гроб
источник
6
Проблема с подходом итератора заключается в том, что он требует от вас создания шаблонов функций и методов, даже если известен тип элемента коллекции. Это раздражает, а когда рассматриваемый метод виртуальный, невозможно. Заметьте, я не возражаю против вашего ответа как такового, но на практике он становится немного громоздким в C ++.
jon-hanson
22
Я не согласен. Иногда уместно использовать итераторы для вывода, но если вы не пишете общий алгоритм, общие решения часто приводят к неизбежным накладным расходам, которые трудно оправдать. И с точки зрения сложности кода, и с точки зрения реальной производительности.
Dennis Zickefoose
1
@Dennis: Я должен сказать, что мой опыт был совершенно противоположным: я пишу изрядное количество вещей в виде шаблонов, даже если я заранее знаю, какие типы задействованы, потому что это проще и повышает производительность.
Джерри Коффин,
9
Я лично возвращаю контейнер. Смысл ясен, код проще, я не очень забочусь о производительности, когда пишу его (я просто избегаю ранней пессимизации). Я не уверен, что использование итератора вывода сделает мои намерения более ясными ... и мне как можно больше нужен не шаблонный код, потому что в большом проекте зависимости убивают развитие.
Matthieu M.
1
@Dennis: Я буду утверждать, что концептуально вы никогда не должны «создавать контейнер, а не писать в диапазон». Контейнер - это всего лишь контейнер. Ваша проблема (и проблема вашего кода) должна быть связана с содержимым, а не с контейнером.
Jerry Coffin,
18

Суть такова:

Copy Elision и RVO позволяют избежать «страшных копий» (компилятор не требуется для реализации этих оптимизаций, а в некоторых ситуациях его нельзя применить)

Ссылки C ++ 0x RValue допускают реализацию строк / векторов, которые это гарантируют .

Если вы можете отказаться от более старых реализаций компиляторов / STL, возвращайте векторы свободно (и убедитесь, что ваши собственные объекты тоже поддерживают это). Если ваша кодовая база должна поддерживать "меньшие" компиляторы, придерживайтесь старого стиля.

К сожалению, это сильно влияет на ваши интерфейсы. Если C ++ 0x не подходит, и вам нужны гарантии, в некоторых сценариях вы можете вместо этого использовать объекты с подсчетом ссылок или копированием при записи. Однако у них есть недостатки с многопоточностью.

(Я бы хотел, чтобы только один ответ на C ++ был простым, понятным и без условий).

Петерхен
источник
11

Действительно, так как C ++ 11, стоимость копированияstd::vector исчезает в большинстве случаев.

Однако следует иметь в виду, что затраты на создание нового вектора (а затем его разрушение ) все еще существуют, и использование выходных параметров вместо возврата по значению по-прежнему полезно, когда вы хотите повторно использовать емкость вектора. Это задокументировано как исключение в F.20 Руководящих принципов C ++ Core.

Сравним:

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);
}

В первом примере происходит много ненужных динамических распределений / высвобождений, которые во втором примере предотвращаются за счет использования выходного параметра старым способом, повторно используя уже выделенную память. Стоит ли делать эту оптимизацию или нет, зависит от относительной стоимости выделения / освобождения по сравнению со стоимостью вычисления / изменения значений.

Контрольный показатель

Давайте поиграем со значениями vecSizeи numIter. Мы будем поддерживать значение vecSize * numIter постоянным, так что «теоретически» это должно занять одинаковое время (= есть такое же количество присваиваний и добавлений, с точно такими же значениями), а разница во времени может возникать только из-за стоимости выделения, освобождения и лучшее использование кеша.

В частности, давайте использовать vecSize * numIter = 2 ^ 31 = 2147483648, потому что у меня 16 ГБ ОЗУ, и это число гарантирует, что выделено не более 8 ГБ (sizeof (int) = 4), гарантируя, что я не переключаюсь на диск ( все остальные программы были закрыты, у меня при запуске теста было доступно ~ 15Гб).

Вот код:

#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,20 ГГц; 16 ГБ DDR4 2400 МГц; Kubuntu 18.04)

Обозначение: mem (v) = v.size () * sizeof (int) = v.size () * 4 на моей платформе.

Неудивительно, что когда numIter = 1(например, mem (v) = 8 ГБ), времена абсолютно идентичны. Действительно, в обоих случаях мы выделяем в памяти только один раз огромный вектор размером 8 ГБ. Это также доказывает, что при использовании BuildLargeVector1 () не было копирования: у меня не хватило бы ОЗУ для копирования!

Когда numIter = 2повторное использование емкости вектора вместо перераспределения второго вектора происходит в 1,37 раза быстрее.

Когда numIter = 256повторное использование емкости вектора (вместо выделения / освобождения вектора снова и снова 256 раз ...) происходит в 2,45 раза быстрее :)

Мы можем заметить, что time1 в значительной степени постоянен от numIter = 1до numIter = 256, что означает, что выделение одного огромного вектора размером 8 ГБ примерно так же дорого, как выделение 256 векторов размером 32 МБ. Однако выделение одного огромного вектора размером 8 ГБ определенно дороже, чем выделение одного вектора размером 32 МБ, поэтому повторное использование емкости вектора обеспечивает повышение производительности.

От numIter = 512(mem (v) = 16MB) до numIter = 8M(mem (v) = 1kB) - это золотая середина: оба метода работают так же быстро и быстрее, чем все другие комбинации numIter и vecSize. Вероятно, это связано с тем, что размер кэша L3 моего процессора составляет 8 МБ, так что вектор в значительной степени полностью помещается в кеш. На самом деле я не объясняю, почему внезапный скачок time1для mem (v) = 16MB, более логично было бы произойти сразу после того, как mem (v) = 8MB. Обратите внимание, что, как ни странно, в этой золотой зоне отказ от повторного использования емкости на самом деле немного быстрее! Я действительно не объясняю этого.

Когда numIter > 8Mвсе становится некрасиво. Оба метода работают медленнее, но возврат вектора по значению становится еще медленнее. В худшем случае, когда вектор содержит только один единственный int, повторное использование емкости вместо возврата по значению происходит в 3,3 раза быстрее. Предположительно, это связано с фиксированными затратами на malloc (), которые начинают преобладать.

Обратите внимание на то, что кривая для time2 более гладкая, чем кривая для time1: не только повторное использование векторной емкости, как правило, быстрее, но, что, возможно, более важно, оно более предсказуемо .

Также обратите внимание, что в лучшем случае мы смогли выполнить 2 миллиарда сложений 64-битных целых чисел за ~ 0,5 с, что вполне оптимально для 64-битного процессора с тактовой частотой 4,2 ГГц. Мы могли бы добиться большего, распараллелив вычисления, чтобы использовать все 8 ядер (в приведенном выше тесте одновременно используется только одно ядро, что я проверил, повторно запустив тест при мониторинге использования ЦП). Наилучшая производительность достигается при mem (v) = 16 КБ, что соответствует порядку величины кеша L1 (кэш данных L1 для i7-7700K составляет 4x32 КБ).

Конечно, различия становятся все менее и менее значимыми, чем больше вычислений вам действительно нужно выполнить с данными. Ниже приведены результаты, если мы заменим его sum = std::accumulate(v.begin(), v.end(), sum);на for (int k : v) sum += std::sqrt(2.0*k);:

Контрольный показатель 2

Выводы

  1. Использование выходных параметров вместо возврата по значению может обеспечить повышение производительности за счет повторного использования емкости.
  2. На современном настольном компьютере это применимо только к большим векторам (> 16 МБ) и маленьким векторам (<1 КБ).
  3. Избегайте выделения миллионов / миллиардов небольших векторов (<1 КБ). Если возможно, повторно используйте емкость или, еще лучше, спроектируйте свою архитектуру по-другому.

Результаты могут отличаться на других платформах. Как обычно, если производительность имеет значение, напишите тесты для вашего конкретного варианта использования.

Борис Дальштейн
источник
6

Я по-прежнему считаю это плохой практикой, но стоит отметить, что моя команда использует MSVC 2008 и GCC 4.1, поэтому мы не используем последние компиляторы.

Ранее многие горячие точки, отображаемые в vtune с MSVC 2008, сводились к копированию строк. У нас был такой код:

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

... обратите внимание, что мы использовали наш собственный тип String (это было необходимо, потому что мы предоставляем комплект для разработки программного обеспечения, в котором авторы плагинов могут использовать разные компиляторы и, следовательно, разные несовместимые реализации std :: string / std :: wstring).

Я сделал простое изменение в ответ на сеанс профилирования выборки графа вызовов, показывающий, что String :: String (const String &) занимает значительное количество времени. Методы, подобные в приведенном выше примере, внесли наибольший вклад (на самом деле сеанс профилирования показал, что выделение и освобождение памяти является одной из самых больших горячих точек, причем конструктор копирования String является основным участником выделения).

Я сделал простое изменение:

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

И все же это имело огромное значение! Горячая точка исчезла в последующих сеансах профилировщика, и в дополнение к этому мы проводим много тщательного модульного тестирования, чтобы отслеживать производительность нашего приложения. После этих простых изменений время тестирования производительности всех видов значительно сократилось.

Заключение: мы не используем самые последние компиляторы, но мы все еще не можем зависеть от компилятора, оптимизирующего копирование для надежного возврата по значению (по крайней мере, не во всех случаях). Это может быть не так для тех, кто использует более новые компиляторы, такие как MSVC 2010. Я с нетерпением жду, когда мы сможем использовать C ++ 0x и просто использовать ссылки rvalue, и никогда не придется беспокоиться о том, что мы пессимизируем наш код, возвращая сложный классы по стоимости.

[Edit] Как указал Нейт, RVO применяется к возврату временных файлов, созданных внутри функции. В моем случае таких временных файлов не было (за исключением неверной ветки, в которой мы создаем пустую строку), и поэтому RVO не был бы применим.

вонючий472
источник
3
Вот в чем дело: RVO зависит от компилятора, но компилятор C ++ 0x должен использовать семантику перемещения, если он решает не использовать RVO (при условии, что есть конструктор перемещения). Использование оператора триграфа приводит к поражению RVO. См. Cpp-next.com/archive/2009/09/move-it-with-rvalue-references, на который ссылался Питер. Но ваш пример в любом случае не подходит для семантики перемещения, потому что вы не возвращаете временный.
Nate
@ Stinky472: возврат элемента по значению всегда будет медленнее, чем по ссылке. Ссылки Rvalue все равно будут медленнее, чем возврат ссылки на исходный член (если вызывающий может взять ссылку вместо необходимости в копии). Кроме того, есть еще много раз, когда вы можете сохранить ссылки на rvalue, потому что у вас есть контекст. Например, вы можете сделать String newstring; newstring.resize (string1.size () + string2.size () + ...); новая строка + = строка1; новая строка + = строка2; и т.д. Это по-прежнему существенная экономия по сравнению с rvalue.
Puppy
@DeadMG существенная экономия по сравнению с бинарным оператором + даже с компиляторами C ++ 0x, реализующими RVO? Если так, то это позор. Опять же, это имеет смысл, поскольку нам все еще приходится создавать временный объект для вычисления объединенной строки, тогда как + = может напрямую объединяться с новой строкой.
stinky472
Как насчет такого случая: string newstr = str1 + str2; В компиляторе, реализующем семантику перемещения, кажется, что это должно быть так же или даже быстрее, чем: string newstr; newstr + = str1; newstr + = str2; Без резерва, так сказать (я предполагаю, вы имели в виду резерв вместо изменения размера).
stinky472
5
@Nate: Я думаю, вы путаете триграфы вроде <::или ??!с условным оператором ?: (иногда называемым тернарным оператором ).
fredoverflow
3

Немного придираемся: во многих языках программирования не принято возвращать массивы из функций. В большинстве из них возвращается ссылка на массив. В C ++ наиболее близкой аналогией будет возвратboost::shared_array

Неманья Трифунович
источник
4
@Billy: std :: vector - это тип значения с семантикой копирования. Текущий стандарт C ++ не дает никаких гарантий, что (N) RVO когда-либо будет применен, и на практике существует множество реальных сценариев, когда это не так.
Nemanja Trifunovic
3
@Billy: Опять же, есть несколько вполне реальных сценариев, когда даже самые последние компиляторы не применяют NRVO: efnetcpp.org/wiki/Return_value_optimization#Named_RVO
Неманья Трифунович
3
@Billy ONeal: 99% недостаточно, нужно 100%. Закон Мерфи - «если что-то пойдет не так, то так и будет». Неопределенность - это нормально, если вы имеете дело с какой-то нечеткой логикой, но это плохая идея для написания традиционного программного обеспечения. Если есть хотя бы 1% вероятности того, что код не работает так, как вы думаете, тогда вы должны ожидать, что этот код внесет критическую ошибку, из-за которой вас уволят. Плюс это не стандартная функция. Использование недокументированных функций - плохая идея - если через год после того, как вы знаете, что компилятор откажется от функции (это не требуется по стандарту, верно?), У вас будут проблемы.
SigTerm
4
@SigTerm: Если бы мы говорили о корректности поведения, я бы с вами согласился. Однако речь идет об оптимизации производительности. Такие вещи хороши с менее чем 100% уверенностью.
Билли Онил
2
@Nemanja: Я не понимаю, на что здесь "полагаются". Ваше приложение работает одинаково независимо от того, используется ли RVO или NRVO. Если они используются, он будет работать быстрее. Если ваше приложение работает слишком медленно на определенной платформе, и вы проследили его до копирования возвращаемого значения, то непременно измените его, но это не меняет того факта, что лучше всего использовать возвращаемое значение. Если вам абсолютно необходимо убедиться, что копирование не происходит, оберните вектор в shared_ptrи закончите.
Билли Онил,
2

Если производительность является реальной проблемой, вы должны понимать, что семантика перемещения не всегда быстрее, чем копирование. Например, если у вас есть строка, в которой используется оптимизация небольшой строки, то для небольших строк конструктор перемещения должен выполнять точно такой же объем работы, что и обычный конструктор копирования.

Мотти
источник
1
NRVO не исчезает только потому, что были добавлены конструкторы перемещения.
Билли Онил
1
@Billy, правда, но неактуально, вопрос был в том, изменил ли C ++ 0x лучшие практики, а NRVO не изменился из-за C ++ 0x
Motti