Чи слід перемістити нетривіальні умовні оператори до розділу ініціалізації циклів?


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
}

47
Чому б просто не перемістити його до рядка перед циклом, тоді ви можете також дати йому розумне ім’я.
jonrsharpe

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 Ви можете дати йому розумне ім'я, оголосивши його в розділі init. Не кажучи, що я поставив би це там; все одно простіше читати, якщо це окремо.
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) у заголовку циклу є правильність її розміщення. Все, що потрібно для того, щоб невдача була приємною тісною сферою.

Я можу оцінити бажання бути швидким, але не жертвувати читабельністю без справжніх причин.

Звичайно, компілятор може оптимізувати, ініціалізація декількох vars може бути законною, але циклі досить важкі для налагодження, як це є. Будь ласка, будьте добрими до людей. Якщо це насправді сповільнює нас, приємно зрозуміти це досить, щоб виправити це.


Влучне зауваження. Я б припустив, що не турбуйтеся з іншою змінною, якщо вираз є чимось простим, наприклад, for(int i = 0; i < n*n; i++){...}ви б не призначили n*nцю змінну?
Селерітас

1
Я б і мав. Але не для швидкості. Для читабельності. Зчитуваний код, як правило, швидкий сам по собі.
candied_orange

1
Навіть питання щодо його масштабування зникає, якщо ви позначаєте, як константа ( final). Кого хвилює, якщо константа, у якій виконується виконання компілятора, що перешкоджає її зміні, буде доступна пізніше у функції?
jpmc26

Я думаю, що велика річ - це те, що ви очікуєте, що це станеться Я знаю, що в прикладі використовується sqrt, але що робити, якщо це була інша функція? Чи функція чиста? Ви завжди очікуєте однакових цінностей? Чи є побічні ефекти? Чи побічні ефекти є чимось, що ви маєте намір статися на кожній ітерації?
Пітер Б

38

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

Однак ...

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

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


5
Щойно ви починаєте включати виклики функцій, компілятору стає набагато складніше оптимізувати. Компілятор повинен мати спеціальні знання про те, що Math.sqrt не має побічних ефектів.
Пітер Грін

@PeterGreen Якщо це Java, JVM може це зрозуміти, але це може зайняти деякий час.
chrylis -на страйк-

Питання позначено як 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не працюватиме і, ймовірно, компілятор попереджає.

Після програмування на мовах на основі С протягом ~ 20 років я тепер записую лише цикли зворотного повторення індексу, якщо тільки немає конкретної причини для перенаправлення індексу-ітерації. На додаток до більш простих перевірок в умовному режимі, зворотна ітерація часто також є побічними кроками, що в іншому випадку було б проблемними мутаціями масиву під час ітерації.


1
Все, що ви пишете, технічно правильно. Коментар щодо інструкцій може бути оманливим, оскільки всі сучасні процесори також розроблені для того, щоб добре працювати з циклами переадресації вперед, незалежно від того, чи є в них спеціальна інструкція. У будь-якому випадку більшість часу зазвичай проводиться всередині циклу, не виконуючи ітерації.
Jørgen Fogh

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).

В основному мій modus operandi такий: якщо очікується, що щось завжди дасть однакове значення, то оцініть його лише один раз. Наприклад, List.Count, якщо список не повинен змінюватися під час роботи циклу, то отримайте відлік за межами циклу в іншу змінну.

Деякі з цих "рахунків" можуть бути напрочуд дорогими, особливо коли ви маєте справу з базами даних. Навіть якщо ви працюєте над набором даних, який не повинен змінюватися під час ітерації списку.


Коли підрахунок дорогий, його взагалі не слід використовувати. Натомість вам слід робити еквівалентfor( auto it = begin(dataset); !at_end(it); ++it )
Бен Войгт

@BenVoigt Використання ітератора, безумовно, найкращий спосіб для цих операцій. Я просто згадав це, щоб проілюструвати свою думку про використання нечистих методів із побічними ефектами.
Пітер Б

0

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

Однак якщо виклик функції - це функція бібліотеки, яка не constexprє компілятором, вона майже напевно буде виконувати її на кожній ітерації, оскільки вона не може цього зробити (якщо тільки вона не є вбудованою і тому може бути виведена як чиста).

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

Особисто я вважаю, що трохи елегантніше ставити умову всередині циклу for, якщо ви можете, але якщо він складний, я напишу це в constexprабо inlineфункцію, або ваші мови рівнозначні, щоб натякати, що функція чиста і оптимізована. Це робить наміри очевидними та підтримує ідіоматичний стиль циклу без створення величезної нечитабельної лінії. Він також дає назву перевірити умову, якщо це її власна функція, тому читачі можуть негайно логічно бачити, для чого перевірка, не читаючи її, якщо вона складна.

Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.