Страницы

Поиск по вопросам

четверг, 28 ноября 2019 г.

Передача по ссылке с последующим копированием vs передача по значению

#c++


Есть некий тяжёлый для копирования тип, например:

struct S 
{ 
    int a[100]; 
};


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


S test(const S& s)
{
    S news = s;           // делаем копию
    news.a[42] = 100500;  // изменяем 
    return news;          // возвращаем
}

S test(S s)               // делаем копию
{
    s.a[42] = 100500;     // изменяем
    return s;             // возвращаем
}



Вариант 2 выглядит более коротким с точки зрения кода, однако, как показывает сборка,
передача по ссылке даёт более короткий ассемблерный код.

Почему так происходит и какие есть ещё плюсы и минусы этих двух подходов, чтобы понимать,
какой из них предпочесть? Может есть ещё какие-то варианты?
    


Ответы

Ответ 1



В современном С++ более предпочтительным считается именно второй вариант. Т.е. если вы знаете, что вам в любом случае понадобится копия, то лучше чтобы эту копию для вас делал компилятор, а не вы сами. Однако традиционное обоснование, приводящееся для этого утверждения, рассчитывает на типы, которые "тяжелы" для копирования не потому, что велики по размеру сами по себе, а потому, что требуют глубокого (deep) копирования. Т.е. речь идет о типах, которые являются компактными на мелком (shallow) уровне, но владеют дополнительными ресурсами через указатели/хендлы. Вся идея тут в том, что компилятор будет в состоянии заменить копирование на перемещение в ситуациях, когда исходное значение является временным/переместимым. Например, если в вашем случае заменить S на std::string, то при вызове test("abc") второй вариант при подготовке аргументов обойдется без глубокого копирования вообще, в то время как в первом варианте вы сами безусловно выполните глубокое копирование. (Еще более эффективным может быть вариант с двумя отдельными функциями - для параметра const std::string & и для параметра std::string &&, но если вы не пытаетесь выжать последние такты процессора, то одна функция с параметром std::string часто выглядит привлекательнее.) В случае же, когда "тяжесть" объекта встроена непосредственно в сам объект, как в вашем примере, сэкономить на копировании не удастся. Я бы ожидал одинаковой производительности от обоих вариантов. Некоторым нюансом является то, что, согласно абстрактной семантике языка, создание копии во втором варианте делается в контексте вызывающего кода. Некоторые реализации следуют этой семантике буквально - они выполняют создание копии и резервирование памяти для нее в контексте вызывающего кода. При этом память может резервироваться заранее, независимо от того, будет ли фактически вызываться функция в процессе выполнения. Т.е. написав, скажем, вот такую рекурсивную функцию void recursive(unsigned n, const S &s) { if (n > 0) recursive(n - 1, s); else test(s); } вы можете с удивлением обнаружить, что при использовании второго варианта функции test память в стеке для копии s выделяется на каждом уровне рекурсии, в то время как фактически эта память нужна только на самом дне рекурсии. Первый вариант test будет свободен от этого недостатка. Другие реализации могут поступать более экономно: даже при использовании второго варианта test выполнять резервирование памяти только если функция действительно вызывается.

Ответ 2



Процитирую "Совершенный код" Макконела, глава 7.5: Не используйте параметры метода в качестве рабочих переменных Использовать передаваемые в метод параметры как рабочие переменные опасно. Создайте для этой цели локальные переменные. Так, в следующем фрагменте Java кода переменная inputVal некорректно служит для хранения промежуточных результатов вычислений: int Sample(int inputVal) { inputVal = inputVal * CurrentMultiplier( inputVal ); inputVal = inputVal + CurrentAdder( inputVal ); //... //Переменная inputVal уже не содержит входного значения. return inputVal; } В этом фрагменте переменная inputVal вводит в заблуждение, потому что при завершении метода она больше не содержит входного значения; она содержит результат вычисления, частично основанного на входном значении, и поэтому ее имя неудачно. Если позднее вам придется задействовать первоначальное входное значение в другом месте метода, вы, вероятно, задействуйте переменную inputVal, пред полагая, что она содержит первоначальное значение, но это предположение будет ошибочным. ИМХО: Это почва для ошибок. Хотя сам лично писал: template QVector reversed(QVector vector){ std::reverse(vector.begin(), end.begin()); return vector; } ... и никакой вины не чувствую :) Исходя из рассуждений Макконела разумнее передавать параметры по ссылке и делать локальную копию.

Ответ 3



передача по ссылке даёт более короткий ассемблерный код В ассемблерном коде x86-64 clang, на который вы дали ссылку, разница между первым и вторым вариантом в следующем: В первом варианте срабатывает оптимизация и структура копируется из переданного аргументом адреса сразу на место возвращаемого значения. При возврате не требуется дополнительных действий. Во втором варианте такая оптимизация не применяется, и копирование структуры происходит два раза. Первый раз вызывающий код создает копию для передачи в функцию (за скобками данного кода), а второй раз - при возврате из функции. Второй вариант длиннее на одну строку из-за оператора lea rsi, [rsp + 16], который как раз и вычисляет аргумент source для вызова memcpy (в первом случае это не нужно, так как он явно передан вызывающей функцией). Таким образом, сферически в вакууме, второй вариант не эффективен, из-за двойного копирования структуры и лишнего вычисления аргумента для memcpy. какие есть ещё плюсы и минусы этих двух подходов, чтобы понимать, какой из них предпочесть? Я полагаю, в реальных программах об этом не надо заботиться. Оптимизирующий компилятор будет рассматривать не отдельные функции, а программу в целом, и выберет лучший вариант. Хотя второй вариант теоретически "хуже", в реальности компилятор обе функции просто превратит в inline (если на них не берутся указатели), и разницы никакой не будет.

Ответ 4



Первый вариант, очевидно, лучше, потому что подходит под понятие хорошего C++-кода, который пропагандируется на протяжении многих лет. Такой код не вызывает вопросов, а вот структура, переданная не по ссылке, вызывает. И чем больше размер структуры, тем больше вопросов такое решение будет вызывать. А раз вызывает вопросы, значит требует комментария. Кроме того, как Вы сами указываете в своём вопросе, эффективность двух методов Вы не измерили (размер листинга ассемблера вообще ни о чём не говорит), поэтому и ссылаться не на что. Этот вопрос, на мой взгляд, типичный пример попытки преждевременной оптимизации, которая, в целом, может вылиться в пессимизацию с отрицательным эффектом касательно читабельности кода. Написал довольно объёмный текст по данному вопросу. С ним можно ознакомиться по этой ссылке: Передача по ссылке или по значению?

Ответ 5



Как вариант подойдёт S* test(const S& s) { S *news = new S; // создаём новый элемент *news = s; // делаем копию news->a[42] = 100500; // изменяем return news; // возвращаем }

Комментариев нет:

Отправить комментарий