Обычно первая эйфория от простоты изучения 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, потому что:
Если же мы будем использовать префиксную форму:
int x = 5;
int y = ++x + 1;
System.out.println("y = " + y);
System.out.println("x = " + x);
То x будет равен 6, а y – 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.
Короткая операция увеличения и уменьшения реализована в тех языках, которые наследуют C и C++: Java, JavaScript, PHP и так далее. Во многом они наследуют эту операцию вместе с циклом for: у многих языков параметры цикла выглядят как (int i = 0; i < 10; i++), здесь инкремент очень удобен для увеличения значения на единицу. У Python, например, инкремента и декремента нет вообще, потому что циклы в нем задаются через iterable-объекты (чаще всего их получают функцией range()), i++ здесь не нужен, а добавлять его «для удобства» – плохая идея, о чем мы расскажем ниже.
Интересно, что одним из основных «наследников» C++, языком программирования Golang, была унаследована только постфиксная форма инкремента/декремента – писать --i в этом языке нельзя.
А теперь поговорим о проблемах, которые вызывают операции инкремента и декремента. Этих проблем – ровно 2:
Усложнение кода объяснить очень просто: если вы искали ошибку в своем коде и пришли к строке:
int x = a++ + (b-- * f-- - --g) + g++ * d--
, то удали вам понять, что здесь пошло не так. Да и если ошибки нет, но вы просто пытаетесь разобраться в чьем-то чужом модуле, и вдруг встречаете такую строчку, то вырванные на голове и не только волосы вам обеспечены.А теперь поговорим о более коварной проблеме.
Давайте посмотрим на безобидный пример:
int a = 2;
int b = a++ + (--a * ++a);
Чему будет равно b? Давайте посчитаем (пока не компилируйте эти строки, если компилятор у вас под рукой):
Оооооокееееей. Но и это еще не все – чудеса не закончились. Все вы знаете про математическое правило коммутативности, которое в школе формулировали как «от перемены мест слагаемых сумма не меняется». Давайте проверим его – поменяем код на
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 варианта, в которых эти операторы уместны:
То есть i++; писать можно без проблем, побочного эффекта здесь нет – просто увеличили переменную на 1. Использовать инкремент и декремент в составных выражениях не рекомендуется.
Тезисно: