По-прежнему лучше предпочитать предварительный прирост по сравнению с последующим увеличением?

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

Похоже, что это уже не вызывает серьезной озабоченности (до тех пор, пока накладывается 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. Я считаю, что дополнительный код был добавлен, чтобы разобраться с тем, что деструктор выбрасывает исключение. Удаление мертвого кода частично очистило его, поскольку тело деструктора было пустым, но результат не был идентичным коду для версии с предварительным приращением.