По-прежнему лучше предпочитать предварительный прирост по сравнению с последующим увеличением?
Раньше было предпочтительным предварительное приращение, потому что перегруженный пост-инкремент в классе требовал возврата временной копии, которая представляла состояние объекта до приращения.
Похоже, что это уже не вызывает серьезной озабоченности (до тех пор, пока накладывается inlining), поскольку мой старый компилятор С++ (GCC 4.4.7), похоже, оптимизирует следующие две функции в идентичном коде:
class Int {
//...
public:
Int (int x = 0);
Int & operator ++ ();
Int operator ++ (int) {
Int x(*this);
++*this;
return x;
}
};
Int & test_pre (Int &a) {
++a;
return a;
}
Int & test_post (Int &a) {
a++;
return a;
}
Результирующая сборка для обеих функций:
.cfi_startproc
.cfi_personality 0x3,__gxx_personality_v0
pushq %rbx
.cfi_def_cfa_offset 16
.cfi_offset 3, -16
movq %rdi, %rbx
call _ZN3IntppEv
movq %rbx, %rax
popq %rbx
.cfi_def_cfa_offset 8
ret
.cfi_endproc
Однако, если ничего не встроено, кажется, все еще есть преимущество в предпочтении предварительного приращения для пост-приращения, поскольку test_post
вынужден вызывать в operator++(int)
.
Предположим, что operator++(int)
встроен как конструктор идиоматической копии, вызывает предварительный приращение и возврат копии, как показано выше. Если конструктор копирования является inlined или реализацией конструктора по умолчанию, является ли достаточная информация для компилятора для оптимизации пост-инкремента, чтобы test_pre
и test_post
стали идентичными функциями? Если нет, то какая другая информация требуется?
Ответы
Ответ 1
Да. Это не должно иметь значения для встроенных типов. Для таких типов компилятор может легко анализировать семантику и оптимизировать их; если это не изменит поведение.
Однако для класса-типа он может (если это не так) иметь значение, поскольку в этом случае семантика может быть более сложной.
class X { /* code */ };
X x;
++x;
x++;
Последние два вызова могут быть совершенно разными и могут выполнять разные вещи, как и эти вызовы:
x.decrement(); //may be same as ++x (cheating is legal in C++ world!)
x.increment(); //may be same as x++
Так что не позволяйте себе захватить синтаксический сахар.
Ответ 2
Обычно оператор post-increment в пользовательских типах включал создание копии, которая медленнее и дороже, чем типичный оператор pre-increment.
Поэтому оператор pre-increment должен использоваться в предпочтении для пользовательских типов.
Также хороший стиль должен быть последовательным, и поэтому предварительный приращение также должен быть предпочтительным со встроенными типами.
Пример:
struct test
{
// faster pre-increment
test& operator++() // pre-increment
{
// update internal state
return *this; // return this
}
// slower post-increment
test operator++(int)
{
test c = (*this); // make a copy
++(*this); // pre-increment this object
return c; // return the un-incremented copy
}
};
Компилятор не может оптимизировать пост-инкремент для пользовательских типов, поскольку их реализация - это соглашение, а не то, что может сделать компилятор.
Ответ 3
Кроме того, что потенциально более эффективно, основная причина, по которой вы (обычно) предпочитаете предварительный прирост по сравнению с последующим приращением, заключается в том, что первое - это то, что вы на самом деле имели в виду в первую очередь.
Когда вы пишете заголовок цикла, например
for ( std::size_t i = 0; i < numElements; i++ )
вы не имеете в виду "pls добавить один к значению i, а затем дать мне его старое значение". Вы не заботитесь о возвращаемом значении выражения я ++ вообще! Итак, зачем заставить компилятор прыгать через обручи и дать одно возвращаемое значение, которое требует наибольшей работы?
Я понимаю, что компилятор обычно оптимизирует ненужную дополнительную работу в любом случае, но почему бы просто не сказать, что вы имеете в виду, вместо того чтобы надеяться, что компилятор выяснит, что вы имеете в виду?
Ответ 4
Оптимизация компиляторов делает всевозможные замечательные и волшебные вещи, особенно если вы не используете отладочную сборку, но не вдаваясь во внутренние детали, оператор pre-increment, применяемый к пользовательскому типу, все еще будет как быстро, так и быстрее, не прилагая больше усилий, чтобы писать или поддерживать.
Это похоже на то, что вы можете использовать код типа a>b ? a:b
вместо использования функции max, а оптимизация компиляторов обычно приводит к выпуску нераспределенного кода в этих случаях. Но с какой целью это работает, когда мы можем так же легко и, возможно, с большей ясностью, напишем max(a, b)
?
Когда вы можете достичь чего-то такого же быстрого или быстрого без каких-либо дополнительных усилий или затрат на ремонтопригодность, чем, в худшем случае, небольшое изменение в старых стилистических привычках, когда я думаю, что мы должны перестать искать оптимизатора для ответов. Оптимизатор должен быть там, чтобы сделать вещи, которые на самом деле изначально приносили больше усилий и имели более высокую стоимость обслуживания дешевле.
Ответ 5
Я выбрал ответ Nawaz как лучший. Я, как правило, согласен с большинством комментариев и другими ответами о том, что предварительный приращение все равно будет предпочтительным. Тем не менее, я хотел понять, как компилятор может определить, что можно рассматривать семантически то же, что и другой. Конечно, можно было просто сказать: "Неважно, как, вы не должны использовать пост-приращение". Но этот ответ действительно не удовлетворяет мое интеллектуальное любопытство.
Похоже, что у компилятора достаточно информации для обработки класса, такого как встроенный, если конструктор и деструктор копирования (подразумевающие, что любые содержащиеся в нем объекты также имеют тривиальные деструкторы) являются тривиальными, а пост-приращение является идиоматическим.
Идиоматический встроенный пост-инкремент и тривиальный конструктор копии недостаточно для того, чтобы компилятор мог вывести, что две функции test_pre
и test_post
могут быть реализованы одинаково. Если деструктор нетривиален, код отличается. Даже с пустым корпусом деструктора блок пост-инкремента слегка меняется для рассматриваемого компилятора, GCC 4.4.7:
.cfi_startproc
.cfi_personality 0x3,__gxx_personality_v0
.cfi_lsda 0x3,.LLSDA1106
pushq %rbx
.cfi_def_cfa_offset 16
.cfi_offset 3, -16
movq %rdi, %rbx
.LEHB0:
call _ZN3IntppEv
.LEHE0:
movq %rbx, %rax
popq %rbx
.cfi_remember_state
.cfi_def_cfa_offset 8
ret
.L12:
.cfi_restore_state
.L9:
movq %rax, %rdi
.LEHB1:
call _Unwind_Resume
.LEHE1:
.cfi_endproc
Обратите внимание, что путь выполнения в основном одинаков, за исключением некоторых дополнительных .cfi_*
операторов, которые не отображаются в пред-инкрементной версии, а также неохваченный вызов _Unwind_Resume
. Я считаю, что дополнительный код был добавлен, чтобы разобраться с тем, что деструктор выбрасывает исключение. Удаление мертвого кода частично очистило его, поскольку тело деструктора было пустым, но результат не был идентичным коду для версии с предварительным приращением.