logo
Ещё

Инкремент и декремент в Java

Обычно первая эйфория от простоты изучения Java проходит в тот момент, когда в жизнь ученика приходит постфиксный/префиксный инкремент или декремент. Если вы не знакомы с C++ (начинали сразу с программирования Java), работа этого арифметического оператора может вас запутать, особенно если вам дали разобрать сложную арифметическую операцию вроде x = --a + a++ / b-- * ++b. Ниже – о формате префиксных и постфиксных инкрементов и декрементов, о приоритетах этих операторов, о for и «наследии» C++. Кроме того, мы посмотрим, как реализовали префиксную и постфиксную форму этих команд в других языках, и разберем пару сложных примеров самостоятельно.


Что делают инкремент и декремент

В общем-то, все довольно просто: инкремент увеличивает значение переменной, декремент его уменьшает. В обоих случаях изменения равны 1, то есть инкремент – это +1 к переменной, декремент – это -1 к переменной. Работают они с тремя типами переменных: целочисленные, числа с плавающей точкой и char. С целочисленными и числами с плавающей точкой все просто и понятно: значение увеличивается или уменьшается на 1. С типом char все немного сложнее. Предположим, что мы запустили следующий код:

public class HelloWorld{

public static void main(String []args){

char i = 'c';

System.out.println(++i);

}

}

Что уйдет в поток вывода? Символ ‘d’. Дело в том, что языком Java от C и C++ унаследовано много странностей. Если не вдаваться глубоко в историю, то изначально char был целочисленным типом, который имел размер 8 бит (1 байт) и предназначался для численного выражения символов из таблицы ASCII. Когда появились Unicode/UTF, char стал во многом бесполезен, но Java успела унаследовать его старую суть: char является одновременно символом и числом. Компиляторы Java при инкременте char сначала переводят символ в число, затем увеличивают число на 1, после чего снова переводят число в символ. Учитывая, что поведение char – это неочевидное легаси, мы рекомендуем настоятельно избегать инкрементирования и декрементирования char там, где это возможно.

У инкремента и декремента есть постфиксные и префиксные формы: ++x, x++, --x, x--. От формы зависит, когда +1 или -1 произойдет – до вычисления или после. Например, у вас есть такая строка:

int x = 5;

int y = x++ + 1;

System.out.println("y = " + y);

System.out.println("x = " + x);

И x, и y в данном случае равны 6, потому что:

  1. Переменной x присваиваем 5.
  2. Входим в выражение y = x++ + 1.
  3. Видим, что ++ стоит после переменной – откладываем его в сторону.
  4. Вычисляем y = x + 1, получаем 6.
  5. Берем отложенное x++, вычисляем, тоже получаем 6.

Если же мы будем использовать префиксную форму:

int x = 5;

int y = ++x + 1;

System.out.println("y = " + y);

System.out.println("x = " + x);

То x будет равен 6, а y – 7:

  1. X присваиваем 5, входим в выражение.
  2. Видим, что ++ стоит перед x, поэтому сразу вычисляем. X теперь равен 6.
  3. Вычисляем y = 6 + 1, получаем 7.

Конструкции типа ++x++ запрещены, расстановка скобок вроде ++(x++) тоже не проходит через компилятор.

И вообще, инкремент и декремент можно использовать только с переменными, если попытаетесь поставить его перед или после выражения в скобках – компилятор будет ругаться.

Какой у них приоритет

Практически самый высокий – инкремент и декремент выполняются сразу после скобок. Но есть нюанс: операторы выполняются слева направо, и каждое выполнение меняет переменную. Например, у вас есть строка: y = x++ + --x - x--. Сначала будут выполнены все операции инкрементирования и декрементирования, после чего уже обычные + и -. Если x = 5, то сначала на место x++ подставляется 5 и x увеличивается на 1, потом обрабатывается --x (уменьшение на 1 и подстановка 5), после чего обрабатывается x-- (подстановка 5 и уменьшение на 1). По итогу y = 5 + 5 – 5 = 5, а x = 4.

Разборы на практике

  • a = 3; b = a++ * 3. Все крайне просто – сначала умножаем 3 на 3, кладем это в b, после чего увеличиваем a. a = 4, b = 9.
  • a = 5; b = 3; c = --a + b++ + a-- + ++b. Вычисляем слева направо: --a = 4; b++ = 3 с увеличением b до 4; a-- = 4 с уменьшением a до 3; ++b = 5. c = 4 + 3 + 4 + 5 = 16. a = 3, b = 5.
  • a = 3; b = 5; c = a++ / ++b + --a.a++ = 3 с увеличением a до 4, ++b = 6, --a = 3. c = 3 / 6 – 3 = 3, a = 3, b = 6.

Как реализовано в других языках

Короткая операция увеличения и уменьшения реализована в тех языках, которые наследуют C и C++: Java, JavaScript, PHP и так далее. Во многом они наследуют эту операцию вместе с циклом for: у многих языков параметры цикла выглядят как (int i = 0; i < 10; i++), здесь инкремент очень удобен для увеличения значения на единицу. У Python, например, инкремента и декремента нет вообще, потому что циклы в нем задаются через iterable-объекты (чаще всего их получают функцией range()), i++ здесь не нужен, а добавлять его «для удобства» – плохая идея, о чем мы расскажем ниже.

Интересно, что одним из основных «наследников» C++, языком программирования Golang, была унаследована только постфиксная форма инкремента/декремента – писать --i в этом языке нельзя. 

Почему инкремент и декремент – это не очень хорошо

А теперь поговорим о проблемах, которые вызывают операции инкремента и декремента. Этих проблем – ровно 2:

  1. Усложняют код.
  2. Могут приводить к неуловимым ошибкам.

Усложнение кода объяснить очень просто: если вы искали ошибку в своем коде и пришли к строке:

int x = a++ + (b-- * f-- - --g) + g++ * d--
, то удали вам понять, что здесь пошло не так. Да и если ошибки нет, но вы просто пытаетесь разобраться в чьем-то чужом модуле, и вдруг встречаете такую строчку, то вырванные на голове и не только волосы вам обеспечены.
А теперь поговорим о более коварной проблеме.


Давайте посмотрим на безобидный пример:

int a = 2;

int b = a++ + (--a * ++a);

Чему будет равно b? Давайте посчитаем (пока не компилируйте эти строки, если компилятор у вас под рукой):

  1. В документации написано, что скобки имеют более высокий приоритет, значит сначала исполняем то, что в скобках. –a * ++a = 1 * 2 = 2.
  2. Получаем b = a++ + 2, a = 2. b = 2 + 2 = 4.
  3. Чтобы удостовериться в правильности, запускаем этот пример в компиляторе. Смотрим на ответ… 8.


Оооооокееееей. Но и это еще не все – чудеса не закончились. Все вы знаете про математическое правило коммутативности, которое в школе формулировали как «от перемены мест слагаемых сумма не меняется». Давайте проверим его – поменяем код на

int b = (--a * ++a) + a++;
В этот раз компилятор выдаст нам 4 – то есть инкременты и декременты буквально сломали математику.

Возможно, вы рано или поздно догадаетесь сначала произвести операцию a++, после чего уже совершать все остальные действия. В этом случае получится b = 2 + (--a * ++a) при a = 3; b = 2 + (2 * 3) = 8. Теперь все сходится, кроме одного – в документации Java явно указано, что скобки имеют более высокий приоритет, чем инкремент и декремент. Документация врет?

И да, и нет. Дело в том, что инкремент и декремент обладают побочными эффектами – эффектами, изменяющими какие-то внешние состояния.

Побочные эффекты мало того что приводят к трудноуловимым багам (пример которого вы можете видеть выше), так еще и не учитываются компилятором. Компилятор – это специальная программа, которая переводит ваш код в машинные инструкции (в случае с Java он переводит ваш код в более простую форму, а потом интерпретатор уже переводит ее на машинный язык). И у компилятора есть ряд оптимизаций – специальных действий, которые призваны уменьшить нагрузку вашей программы на систему. Одна из таких оптимизаций – ленивые вычисления, которые работают в основном для логического оператора И/ИЛИ. Суть: вычисления происходят слева направо, если ответ уже очевиден – дальше можно не вычислять. В выражении 0 И (1 ИЛИ 0) после первого 0 вычисления можно не производить – сразу понятно, что результат всего И будет 0. Так вот, из-за реализации ленивых вычислений компилятор пытается считать слева направо везде, где только можно. В нашем примере компилятор видит часть выражения b = a++ + что-то там, справедливо решает, что ему ничего не мешает пока что вычислить a++ и делает это. Из-за побочного эффекта инкремента a увеличивается, и результат всего вычисления становится неправильным, потому что сначала нужно было совершить действия в скобках.

Итак, мы выяснили, что 8 – неправильный ответ. То есть правильный – 4? Нет. Еще одно откровение: правильного ответа нет вообще, поскольку результат не определен. «Результат не определен» – это когда мы не можем с уверенностью сказать, что произойдет, если мы выполним ту или иную строку. Почему нет уверенности? Потому что ленивые вычисления, про которые мы говорили выше, можно отключить в настройках компилятора. Выключили – получаем ответ 4, включили – получаем ответ 8. Когда ответ зависит от какого-то внешнего фактора (настроек компилятора), мы не можем на него полагаться, отсюда и неопределенность.

Итак, надеемся, что вы поняли, почему инкремент и декремент – плохая практика, которую стоит избегать.

Есть 2 варианта, в которых эти операторы уместны:

  1. В условиях цикла for.
  2. Когда они занимают целую строку.

То есть i++; писать можно без проблем, побочного эффекта здесь нет – просто увеличили переменную на 1. Использовать инкремент и декремент в составных выражениях не рекомендуется.

Что можно почитать

Вывод

Тезисно:

  • Инкремент и декремент – это оператор, увеличивающий или уменьшающий число на 1.
  • Работает с целочисленными типами данных, числами с плавающей точкой и типом char.
  • Бывает префиксным и постфиксным. Префиксный тип сначала вычисляет инкремент/декремент, затем возвращает его в выражение. Постфиксный тип сначала возвращает исходное значение, после чего увеличивает/уменьшает его.
  • Инкремент и декремент могут вести к неопределенному поведению, поэтому стоит избегать их употребления. Единственный безопасный вариант – когда инкремент/декремент не вызывает побочных эффектов (занимает всю строку).
Часто ищут