Следует ли перемещать нетривиальные условные операторы в раздел инициализации циклов?

21

Я получил эту идею из этого вопроса на stackoverflow.com

Следующий шаблон является распространенным:

final x = 10;//whatever constant value
for(int i = 0; i < Math.floor(Math.sqrt(x)) + 1; i++) {
  //...do something
}

Суть, которую я пытаюсь сделать, заключается в том, что условное утверждение является чем-то сложным и не меняется.

Это лучше объявить в разделе инициализации цикла, как таковой?

final x = 10;//whatever constant value
for(int i = 0, j = Math.floor(Math.sqrt(x)) + 1; i < j; i++) {
  //...do something
}

Это более понятно?

Что делать, если условное выражение простое, такое как

final x = 10;//whatever constant value
for(int i = 0, j = n*n; i > j; j++) {
  //...do something
}
Celeritas
источник
47
Почему бы просто не переместить его в строку перед циклом, тогда вы также можете дать ему разумное имя.
Джоншарп
2
@ Mehrdad: они не эквивалентны. Если xвелико по величине, Math.floor(Math.sqrt(x))+1равно Math.floor(Math.sqrt(x)). :-)
R ..
5
@jonrsharpe Потому что это расширило бы область действия переменной. Я не обязательно говорю, что это хорошая причина не делать этого, но это причина, по которой некоторые люди этого не делают.
Кевин Крумвиде
3
@KevinKrumwiede Если область действия представляет собой проблему, ограничьте ее, поместив код в свой собственный блок, например, { x=whatever; for (...) {...} }или, еще лучше, подумайте, достаточно ли происходит, что это должна быть отдельная функция.
Blrfl
2
@jonrsharpe Вы можете дать ему разумное имя, объявив его также в разделе инициализации. Не говоря, что я бы положил это туда; это все еще легче читать, если это отдельно.
JollyJoker

Ответы:

62

Я бы сделал что-то вроде этого:

void doSomeThings() {
    final x = 10;//whatever constant value
    final limit = Math.floor(Math.sqrt(x)) + 1;
    for(int i = 0; i < limit; i++) {
         //...do something
    }
}

Честно говоря, единственная веская причина, чтобы втиснуть инициализацию j(сейчас limit) в заголовок цикла, состоит в том, чтобы держать его правильно. Все, что нужно для того, чтобы не создавать проблем, - это хороший ограниченный объем.

Я могу ценить желание быть быстрым, но не жертвую удобочитаемостью без реальной уважительной причины.

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

candied_orange
источник
Хорошая точка зрения. Я бы предположил, что не стоит беспокоиться о другой переменной, если выражение является чем-то простым, например, for(int i = 0; i < n*n; i++){...}вы бы не присвоили n*nпеременную, не так ли?
Celeritas
1
Я бы и имел. Но не для скорости. Для удобства чтения. Читаемый код имеет тенденцию быть быстрым сам по себе.
candied_orange
1
Даже проблема с областью видимости исчезнет, ​​если вы отметите как константу ( final). Кого волнует, доступна ли константа с принудительным применением компилятора, предотвращающим ее изменение, позже в функции?
jpmc26
Я думаю, что большая вещь - это то, что вы ожидаете. Я знаю, что в примере используется sqrt, но что, если это была другая функция? Функция чистая? Вы всегда ожидаете одинаковых значений? Есть ли побочные эффекты? Являются ли побочные эффекты чем-то, что вы собираетесь делать на каждой итерации?
Питер Б
38

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

Тем не мение...

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

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

Карл Билефельдт
источник
5
Как только вы начинаете включать вызовы функций, компилятору становится намного сложнее оптимизировать. Компилятор должен иметь специальные знания о том, что Math.sqrt не имеет побочных эффектов.
Питер Грин
@PeterGreen Если это Java, JVM может понять это, но это может занять некоторое время.
Хрилис - на забастовке -
Вопрос помечен как C ++, так и Java. Я не знаю, насколько продвинута Java JVM, и когда она может и не может понять это, но я знаю, что компиляторы C ++ вообще не могут понять это, хотя функция чиста, если у нее нет нестандартной аннотации, сообщающей компилятору так, или тело функции является видимым, и все косвенно вызванные функции могут быть обнаружены как чистые по тем же критериям. Примечание: без побочных эффектов недостаточно, чтобы вывести его из состояния петли. Функции, которые зависят от глобального состояния, также нельзя вывести из цикла, если тело цикла может изменить это состояние.
HVd
Это становится интересным после замены Math.sqrt (x) на Mymodule.SomeNonPureMethodWithSideEffects (x).
Питер Б
9

Как сказал @Karl Bielefeldt в своем ответе, обычно это не проблема.

Однако когда-то это было распространенной проблемой в C и C ++, и уловка была в том, чтобы обойти эту проблему без снижения читабельности кода - повторять в обратном направлении, вплоть до0 .

final x = 10;//whatever constant value
for(int i = Math.floor(Math.sqrt(x)); i >= 0; i--) {
  //...do something
}

Теперь условием каждой итерации является то, >= 0что каждый компилятор будет компилироваться в 1 или 2 инструкции по сборке. Каждый процессор, созданный за последние несколько десятилетий, должен иметь такие базовые проверки; делая быструю проверку на моей машине x64, я вижу, что это предсказуемо превращается вcmpl $0x0, -0x14(%rbp) (значение long-int-сравнения 0 против смещения регистра rbp -14) и jl 0x100000f59(переход к инструкции, следующей за циклом, если предыдущее сравнение было верным для «2nd-arg» <1-й аргумент ”) .

Обратите внимание, что я удалил + 1из Math.floor(Math.sqrt(x)) + 1; чтобы математика сработала, начальное значение должно бытьint i = «iterationCount» - 1 . Также стоит отметить, что ваш итератор должен быть подписан; unsigned intне будет работать и, скорее всего, предупредит компилятор.

После программирования на языках C в течение ~ 20 лет я теперь пишу только циклы с итерацией обратного индекса, если нет особой причины для итерации с обратным индексом. В дополнение к более простым проверкам в условных выражениях, обратная итерация часто также обходит стороной то, что иначе было бы проблематично с мутациями массива во время итерации.

Слипп Д. Томпсон
источник
1
Все что ты пишешь технически правильно. Однако комментарий к инструкциям может вводить в заблуждение, поскольку все современные процессоры также были разработаны для эффективной работы с циклами прямой итерации, независимо от того, есть ли у них специальные инструкции для них. В любом случае, большую часть времени обычно проводят внутри цикла, не выполняя итерацию.
Йорген Фог
4
Следует отметить, что современные оптимизации компилятора разработаны с учетом «нормального» кода. В большинстве случаев компилятор генерирует такой же быстрый код, независимо от того, используете ли вы «хитрости» оптимизации или нет. Но некоторые уловки могут фактически препятствовать оптимизации в зависимости от того, насколько они сложны. Если использование определенных приемов делает ваш код более читабельным или помогает обнаруживать распространенные ошибки, это замечательно, но не заставляйте себя думать: «Если я напишу код таким образом, это будет быстрее». Напишите код, как обычно, а затем профилируйте, чтобы найти места, где вам нужно оптимизировать.
0x5453
Также обратите внимание, что unsignedсчетчики будут работать здесь, если вы измените проверку (самый простой способ - добавить одинаковое значение в обе стороны); например, для любого декремента Decпроверка (i + Dec) >= Decвсегда должна иметь тот же результат, что и signedпроверка i >= 0, с обоими signedи unsignedсчетчиками, если в языке есть четко определенные правила обтекания unsignedпеременных (в частности, -n + n == 0это должно быть верно для обоих signedиunsigned ). Обратите внимание, однако, что это может быть менее эффективно, чем подписанная >=0проверка, если у компилятора нет оптимизации для него.
Джастин Тайм - Восстановить Монику
1
@JustinTime Да, подписанное требование - самая трудная часть; добавление Decконстанты как к начальному, так и к конечному значению работает, но делает ее гораздо менее интуитивно понятной, и если вы используете ее iв качестве индекса массива, вам также потребуется сделать unsigned int arrayI = i - Dec;в теле цикла. Я просто использую прямую итерацию, когда застрял с неподписанным итератором; часто с i <= count - 1условием держать логику параллельной циклам обратной итерации.
Слипп Д. Томпсон
1
@ SlippD. Thompson Я не имел в виду добавление Decначальных и конечных значений, а сдвиг проверки состояния с Decобеих сторон. for (unsigned i = N - 1; i + 1 >= 1; i--) /*...*/ Это позволяет вам iнормально использовать в цикле, в то же время гарантируя, что самое низкое возможное значение в левой части условия 0(чтобы не допустить мешающего переноса). Это , безусловно , намного проще использовать прямую итерацию при работе с неподписанными счетчиками, хотя.
Джастин Тайм - Восстановить Монику
3

Это становится интересным после замены Math.sqrt (x) на Mymodule.SomeNonPureMethodWithSideEffects (x).

По сути, мой образ действия: если ожидается, что что-то всегда будет давать одно и то же значение, оцените его только один раз. Например, List.Count, если список не должен изменяться во время работы цикла, то получить значение за пределами цикла в другую переменную.

Некоторые из этих «подсчетов» могут быть на удивление дорогими, особенно когда вы имеете дело с базами данных. Даже если вы работаете с набором данных, который не должен изменяться во время итерации списка.

Питер Б
источник
Когда подсчет дорогой, вы не должны его использовать вообще. Вместо этого вы должны делать эквивалентfor( auto it = begin(dataset); !at_end(it); ++it )
Бен Voigt
@BenVoigt Использование итератора, безусловно, лучший способ для этих операций. Я просто упомянул это, чтобы проиллюстрировать мою точку зрения об использовании не чистых методов с побочными эффектами.
Питер Б
0

На мой взгляд, это сильно зависит от языка. Например, если использовать C ++ 11, я бы заподозрил, что если проверка условия былаconstexpr функцией, компилятор с большой вероятностью оптимизировал бы несколько выполнений, поскольку он знал, что каждый раз будет давать одно и то же значение.

Однако, если вызов функции является библиотечной функцией, которая не constexpr является компилятором, он почти наверняка выполнит ее на каждой итерации, поскольку не может вывести это (если только он не является встроенным и, следовательно, может быть выведен как чистый).

Я меньше знаю о Java, но, учитывая, что JIT скомпилирован, я бы предположил, что компилятор имеет достаточно информации во время выполнения, чтобы, вероятно, встроить и оптимизировать условие. Но это будет зависеть от хорошего дизайна компилятора, и компилятор, решивший этот цикл, был приоритетом оптимизации, о котором мы можем только догадываться.

Лично я чувствую, что немного более элегантно поместить условие в цикл for, если вы можете, но если оно будет сложным, я запишу его в функцию constexprили inline, или ваши языки будут равносильны намеку на то, что функция чистая и оптимизируемая. Это делает намерение очевидным и поддерживает идиоматический стиль цикла без создания огромной нечитаемой строки. Это также дает проверке состояния имя, если это его собственная функция, поэтому читатели могут сразу увидеть логически, для чего проверка, не считывая ее, если она сложная.

Vality
источник