Чи корисна практика замінити ділення на множення, коли це можливо?


73

Щоразу, коли мені потрібно поділ, наприклад, перевірка стану, я б хотів змінити вираз поділу на множення, наприклад:

Оригінальна версія:

if(newValue / oldValue >= SOME_CONSTANT)

Нова версія:

if(newValue >= oldValue * SOME_CONSTANT)

Тому що я думаю, що це може уникнути:

  1. Ділення на нуль

  2. Переповнення, коли oldValueдуже мало

Це так? Чи є проблема за цією звичкою?


41
Будьте уважні, що за від’ємних цифр дві версії перевіряють абсолютно різні речі. Ви впевнені в цьому oldValue >= 0?
user2313067

37
Залежно від мови (але особливо це стосується мови C), якою б оптимізацією ви не придумали, компілятор, як правило, може зробити це краще, -OR- , має достатньо сенсу, щоб не робити цього взагалі.
Марк Беннінгфілд

63
Ніколи не є "хорошою практикою" завжди замінювати код X кодом Y, коли X і Y не є семантично еквівалентними. Але завжди корисно подивитися на X і Y, увімкнути мозок, подумати, які вимоги є , а потім прийняти рішення, яка з двох альтернатив є більш правильною. Після цього слід також подумати про те, які тести потрібно перевірити, чи правильно ви отримали семантичні відмінності.
Док Браун

12
@MarkBenningfield: Не важливо, компілятор не може оптимізувати ділення на нуль. "Оптимізація", про яку ви думаєте, - "оптимізація швидкості". ОП думає про інший вид оптимізації - уникнення помилок.
slebetman

25
Точка 2 є хибною. Оригінальна версія може переповнюватись для малих значень, але нова версія може переповнюватись для великих значень, тому ні в одному випадку це не безпечніше.
ЖакБ

Відповіді:


74

Два розповсюджені випадки:

Ціла арифметика

Очевидно, що якщо ви використовуєте цілу арифметику (яка скорочується), ви отримаєте інший результат. Ось невеликий приклад у C #:

public static void TestIntegerArithmetic()
{
    int newValue = 101;
    int oldValue = 10;
    int SOME_CONSTANT = 10;

    if(newValue / oldValue > SOME_CONSTANT)
    {
        Console.WriteLine("First comparison says it's bigger.");
    }
    else
    {
        Console.WriteLine("First comparison says it's not bigger.");
    }

    if(newValue > oldValue * SOME_CONSTANT)
    {
        Console.WriteLine("Second comparison says it's bigger.");
    }
    else
    {
        Console.WriteLine("Second comparison says it's not bigger.");
    }
}

Вихід:

First comparison says it's not bigger.
Second comparison says it's bigger.

Арифметика з плаваючою комою

Окрім того, що поділ може дати інший результат, коли він ділиться на нуль (він створює виняток, тоді як множення не робить), це також може призвести до дещо різних помилок округлення та іншого результату. Простий приклад в C #:

public static void TestFloatingPoint()
{
    double newValue = 1;
    double oldValue = 3;
    double SOME_CONSTANT = 0.33333333333333335;

    if(newValue / oldValue >= SOME_CONSTANT)
    {
        Console.WriteLine("First comparison says it's bigger.");
    }
    else
    {
        Console.WriteLine("First comparison says it's not bigger.");
    }

    if(newValue >= oldValue * SOME_CONSTANT)
    {
        Console.WriteLine("Second comparison says it's bigger.");
    }
    else
    {
        Console.WriteLine("Second comparison says it's not bigger.");
    }
}

Вихід:

First comparison says it's not bigger.
Second comparison says it's bigger.

Якщо ви мені не вірите, ось ось Загадка, яку ви можете виконати і побачити самі.

Інші мови можуть бути різними; майте на увазі, однак, що C #, як і багато мов, реалізує бібліотеку з плаваючою точкою стандарту IEEE (IEEE 754) , тому ви повинні отримати ті ж результати в інших стандартизованих часах виконання.

Висновок

Якщо ви працюєте на « зеленому полі» , ви, ймовірно, добре.

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

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

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


41
По-друге, фінансовий біт. Такий перемикач просить бухгалтерів переслідувати вас вилами. Я пам’ятаю 5000 рядків, де мені доводилося докладати більше зусиль, щоб утримати вили в страху, ніж знаходити «правильну» відповідь - що насправді було дещо неправильним. Відключення на .01% не мало значення, абсолютно послідовні відповіді були обов'язковими. Таким чином, я був змушений зробити обчислення таким чином, що спричинило систематичну помилку округлення.
Лорен Печтел

8
Подумайте про придбання цукерок 5 цент (не те, що вже існує.) Купіть 20 штук, "правильною" відповіддю було не податок, оскільки не було податку на 20 покупок однієї штуки.
Лорен Печтел

24
@LorenPechtel, це тому, що більшість податкових систем включає правило (з очевидних причин), що податок стягується за транзакцію, а податок сплачується з кроком, не меншим за найменшу монету царства, а дробові суми округляються на користь платника податків. Ці правила є "правильними", оскільки вони є законними та послідовними. Бухгалтери із вилами, напевно, знають, які правила є насправді таким чином, що комп'ютерні програмісти не будуть (якщо вони також не є досвідченими бухгалтерами). Похибка 0,01%, ймовірно, призведе до помилки врівноваження, і помилка балансування є незаконною.
Стів

9
Оскільки я ніколи раніше не чув термін « зелене поле» , я подивився його. У Вікіпедії сказано, що це "проект, у якого відсутні будь-які обмеження, накладені попередньою роботою".
Генрік Ріпа

9
@Steve: Нещодавно мій бос протиставляв "greenfield" до "brownfield". Я зауважив, що певні проекти більше схожі на "чорне поле" ... :-D
DevSolar

25

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

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

Ви праві, що нова версія уникає поділу на нуль. Безумовно, вам не потрібно додавати варту (уздовж рядків if (oldValue != 0)). Але чи має це сенс? Ваша стара версія відображає співвідношення між двома числами. Якщо дільник дорівнює нулю, то коефіцієнт не визначений. Це може бути більш значущим у вашій ситуації, тобто. у цьому випадку ви не повинні давати результату.

Захист від переливу є дискусійним. Якщо ви знаєте, що newValueце завжди більше, ніж oldValue, то, можливо, ви могли б зробити цей аргумент. Однак можуть бути випадки, коли (oldValue * SOME_CONSTANT)також буде переповнено. Тому я не бачу великого прибутку тут.

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

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


16
Так, довільне множення, більш ефективне, ніж довільне ділення, насправді не залежить від процесорів для машин реального світу.
Дедупликатор

1
Існує також питання арифметики з цілим чи плаваючою комою. Якщо коефіцієнт дробовий, поділ потрібно виконати з плаваючою точкою, вимагаючи відтворення. Відсутність акторів призведе до ненавмисної помилки. Якщо частка має відношення між двома малими цілими числами, то їх перестановка дозволяє порівняння проводити в цілій арифметиці. (У цей момент ваші аргументи будуть застосовані.)
rwong

@rwong Не завжди. Декілька мов там роблять цілочисельний поділ, випадаючи десяткової частини, тому жодне введення не потрібно.
Т. Сар

@ T.Sar Техніка, яку ви описуєте, та семантика, описана у відповіді, різні. Семантика - чи планує програміст відповідь плаваючою чи дробовою величиною; описана вами методика - це ділення на зворотне множення, що іноді є ідеальним наближенням (підстановкою) для цілого ділення. Остання методика зазвичай застосовується тоді, коли дільник відомий заздалегідь, оскільки виведення цілочисельного зворотного (зміщеного на 2 ** 32) може бути здійснено під час компіляції. Робити це під час виконання не було б вигідно, оскільки це дорожче процесора.
rwong

22

Немає.

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

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

І, в деяких випадках, навіть вигідно «деоптимізувати» заради читабельності, ясності чи явності. У більшості випадків ваші користувачі не помітять, що ви зберегли кілька рядків циклу коду чи процесора, щоб уникнути обробки кращих регістрів чи обробки винятків. Незручно або мовчки відсутність коду, з іншого боку, буде впливати на людей - ваші колега по крайней мере. (А також, отже, витрати на створення та підтримку програмного забезпечення.)

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

Також зверніть увагу: Компілятори часто оптимізують поділ для вас у будь-якому випадку - коли це безпечно зробити.


11
-1 Ця відповідь насправді не відповідає питанню, що стосується потенційних підводних каменів поділу - нічого спільного з оптимізацією
Бен Коттрелл

13
@BenCottrell Це ідеально підходить. Підводний камінь полягає у наданні цінності в безглуздих оптимізаціях продуктивності за рахунок ремонту. З питання "чи є така проблема за цією звичкою?" - так. Це швидко призведе до написання абсолютного гнучкості.
Майкл

9
@Michael питання також не запитує жодної з цих речей - це конкретно запитання про правильність двох різних виразів, кожен з яких має різну семантику та поведінку, але обидва вони повинні відповідати одній і тій же вимозі.
Бен Коттрелл

5
@BenCottrell Можливо, ви могли б мені вказати, де у питанні є якась згадка про правильність?
Майкл

5
@BenCottrell Ви повинні були щойно сказати "Я не можу" :)
Майкл

13

Використовуйте той, хто є менш глючним і має більше логічного сенсу.

Зазвичай поділ на змінну так чи інакше є поганою ідеєю, оскільки зазвичай дільник може дорівнювати нулю.
Поділ на постійну зазвичай просто залежить від того, який логічний сенс.

Ось кілька прикладів, щоб показати, що це залежить від ситуації:

Відділ хороший:

if ((ptr2 - ptr1) >= n / 3)  // good: check if length of subarray is at least n/3
    ...

Помноження погано:

if ((ptr2 - ptr1) * 3 >= n)  // bad: confusing!! what is the intention of this code?
    ...

Помноження добре:

if (j - i >= 2 * min_length)  // good: obviously checking for a minimum length
    ...

Відділ поганий:

if ((j - i) / 2 >= min_length)  // bad: confusing!! what is the intention of this code?
    ...

Помноження добре:

if (new_length >= old_length * 1.5)  // good: is the new size at least 50% bigger?
    ...

Відділ поганий:

if (new_length / old_length >= 2)  // bad: BUGGY!! will fail if old_length = 0!
    ...

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

6
@Michael: Гм ... ви (ptr2 - ptr1) * 3 >= nзрозуміли, що це так само просто, як і вираз ptr2 - ptr1 >= n / 3? Це не змушує мозку відключитися і повернутися назад, намагаючись розшифрувати значення подвоєння різниці між двома вказівниками? Якщо це дійсно очевидно для вас і вашої команди, то більше сили для вас я здогадуюсь; Я просто повинен бути в повільній меншості.
Мехрдад

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

1
Ці приклади насправді не бідні. Однозначно не є "надзвичайно бідними" - навіть якщо ви підміняєте "розумні імена", вони все одно мають менший сенс, коли ви замінюєте їх на погані випадки. Якби я був новим у проекті, я б швидше побачив «добрі» випадки, перелічені у цій відповіді, коли я пішов виправити деякий виробничий код.
Джон-М

3

Робити що- небудь "коли це можливо", дуже рідко є доброю ідеєю.

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

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


1

Щодо читабельності коду, я думаю, що множення насправді є більш читабельним у деяких випадках. Наприклад, якщо є щось, що ви повинні перевірити, чи newValueзбільшився на 5 відсотків і вище вище oldValue, то 1.05 * oldValueце поріг, на який потрібно перевірити newValue, і це природно написати

    if (newValue >= 1.05 * oldValue)

Але остерігайтеся від'ємних чисел, коли ви рефакторируєте речі таким чином (або замінюючи ділення множенням, або замінюючи множення діленням). Дві умови, які ви вважали, є рівнозначними, якщо oldValueгарантовано не є негативними; але припустимо, newValueце насправді -13,5 і oldValueстановить -10,1. Тоді

newValue/oldValue >= 1.05

оцінює до істинного , але

newValue >= 1.05 * oldValue

оцінює до хибного .


1

Зверніть увагу на відомий розділ паперу Invariant Integers, використовуючи множення .

Компілятор насправді робить множення, якщо ціле число є інваріантним! Не поділ. Це трапляється навіть при 2-х значеннях без потужності. Потужність 2-х підрозділів використовує очевидно бітові зсуви і, отже, ще швидше.

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

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

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

Крім того, для 32-розрядної архітектури 64-розрядний поділ не оптимізований, як я з'ясував .


1

Підбираючи свою точку 2, це дійсно запобіжить переповнення дуже маленького oldValue. Однак якщо SOME_CONSTANTце також дуже мало, то ваш альтернативний метод закінчиться підтопленням, де значення неможливо точно представити.

І навпаки, що станеться, якщо oldValueвона дуже велика? У вас такі самі проблеми, навпаки.

Якщо ви хочете уникнути (або мінімізувати) ризик переливу / переливу, найкращим способом є перевірка того, чи newValueє найближчим за величиною до oldValueабо до SOME_CONSTANT. Потім ви можете вибрати відповідну операцію поділу

    if(newValue / oldValue >= SOME_CONSTANT)

або

    if(newValue / SOME_CONSTANT >= oldValue)

і результат буде найточнішим.

Що стосується поділу на нуль, на моєму досвіді це майже ніколи не доцільно «вирішувати» в математиці. Якщо у вас під час постійних перевірок є ділення на нуль, то майже напевно у вас ситуація, яка вимагає певного аналізу, і будь-які розрахунки на основі цих даних безглузді. Явна перевірка поділу на нуль майже завжди є відповідним кроком. (Зверніть увагу, що я тут кажу "майже", тому що я не претендую на непогрішність. Я просто зазначу, що не пам'ятаю, щоб побачити вагому причину для цього за 20 років написання вбудованого програмного забезпечення, і продовжувати рухатися далі .)

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

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


0

Інкапсулюйте умовну арифметику за змістовними методами та властивостями. Мало того, що гарне іменування скаже вам, що означає "A / B" , перевірка параметрів та обробка помилок можуть також там акуратно ховатися.

Що важливо, оскільки ці методи складаються в більш складну логіку, зовнішня складність залишається дуже керованою.

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


0

Я думаю, що не може бути гарною ідеєю замінити множення на ділення, оскільки ALU CPU (Arithmetic-Logic Unit) виконує алгоритми, хоча вони реалізовані апаратно. Більш складні методи доступні в новіших процесорах. Як правило, процесори прагнуть паралельно виконувати операції пар біт, щоб мінімізувати необхідні тактові цикли. Алгоритми множення можна паралелізувати досить ефективно (хоча потрібно більше транзисторів). Алгоритми поділу не можна паралельно паралелізувати. Найефективніші алгоритми ділення досить складні. Як правило, їм потрібно більше тактових циклів на біт.

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