Немного о «мертвом коде»

5f110c083a503ba2f92b03c4b1f628b8.jpg

А вдоль дороги мертвые с косами стоят

Термин «мертвый код» — это, скорее, жаргонное, чем научное название участков программы, на которые не может попасть управление и, таким образом, они никогда не выполняются. Разумеется, в нормальных программах таких участков быть не должно. Но поскольку языки программирования становятся все сложнее и сложнее (а программисты все тупее и тупее, шутка!) в кодах программ может быть все, что угодно.

Поэтому в компиляторах одна из обычных задач на этапе оптимизации — это выявление и выбрасывание невыполняемых участков программы.

Кстати, мне известен пример «мертвого кода», выполнявшего полезную функцию. В ядре древней MS DOS среди данных было вставлено несколько команд от еще более древней версии этой ОС. Управление на них, естественно, никогда не попадало, но по коду этих команд (т.е. по их сигнатуре) совсем уж древние резидентные программы вроде редактора SIdekick искали адрес флага занятости MS DOS. Поэтому такой «мертвый код», оставленный для совместимости, выбрасывать было нельзя. Но это все-таки исключительный случай, обычно все «мертвые коды» компилятору нужно найти и уничтожить.

В своей работе я использую очень маленький компилятор, который сам же и сопровождаю, и, по мере сил, совершенствую. Оптимизатор в этом компиляторе работает на самом низком уровне — практически на уровне команд x86–64. У такой локальной или, как я ее называю, «тактической» оптимизации возможности скромнее, чем, например, у оптимизаторов кода LLVM, но зато и некоторые локальные задачи оптимизации, в том числе выбрасывание «мертвого кода», становятся тривиальными.

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

Поэтому такие заготовки команд при оптимизации легко переставлять/исключать/заменять. И после этапа генерации двоичного кода эти команды все еще можно исключать, даже не выбрасывая из связанного списка, а просто обнуляя поле длины команды (т.е. делая их опять «пустыми»).

Помнится, как-то задавали вопрос: сколько команд процессора используют компиляторы? В моем случае (не считая команд FPU) — 90 штук, и не все они на самом деле являются командами x86. Например, в это число входят и команды «метка», которые, конечно, никакого кода не имеют, но имеют адрес в коде, как и обычные команды x86. Меток-«команд» есть две разновидности: метка компилятора, которую тот ставит, например, при генерации кодов условного оператора, и метка программиста, которую программист имеет право сам поставить почти в любом месте исходного текста. Разумеется, имена описываемых в исходном тексте подпрограмм и функций — это тоже команды-«метки».

Так вот, еще на одном из первых этапов компиляции — этапе распределения регистров, просматривается связанный список будущих команд x86. Если в этом списке попадается команда возврата или команда безусловного перехода, то следующая за ней команда обязательно должна быть упомянутая команда-«метка», иначе получается как раз тот самый недостижимый «мертвый код», на который без метки никак не попасть и который можно смело удалять до следующей команды-«метки», даже и не начиная генерировать двоичный код для этих удаляемых команд.

Таким образом, одна-единственная проверка в компиляторе, выполняемая к тому же попутно при подготовке к формированию кода x86, позволяет без громоздкого анализа исходного текста программы выявить и сразу удалить недостижимые участки кода.

Правда, дело на этом не заканчивается. Компилятору теперь еще требуется определить, а кто эту недостижимость создал, он сам или программист в исходном тексте?

Если это сам компилятор так доигрался, то нужно просто выбросить недостижимые команды и помалкивать. Но если это следует из исходного текста — обязательно надо выдать предупреждение программисту, поскольку часто это следствие каких-либо ошибок в программе.

Простейший пример. Вся программа состоит из одного «бесконечного» цикла чтения и обработки файла. Перед циклом записан обработчик конца файла.

test:proc main;
dcl f file; 
on endfile(f) stop;
do repeat;
// здесь читаем и обрабатываем содержимое файла
end repeat;
end test;

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

Но если поместить какой-либо непомеченный оператор перед строкой «end test;» — этот оператор будет удален уже с предупреждением.

Правда, если разместить там помеченный оператор, но на эту метку нигде не переходить в тексте программы, что должен делать компилятор? Удалять или не удалять?

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

А в некоторых случаях «мертвый код» может даже принести пользу. В приведенном выше примере неявный return в конце подпрограммы не мог привести к ошибке, независимо от того, выкинул его компилятор или нет. Но, например, в языке PL/1 (компилятор с которого я и описываю) есть потенциальная опасность неприятных ошибок, связанных с описанием функций.

В обычной подпрограмме (т.е. в процедуре в терминах PL/1 или в функции, возвращающей void, в терминах Си) выход происходит или по явному return или по достижению конца «тела» подпрограммы, куда компилятором всегда подставляется неявный return. А вот для функции обязательно нужен явный оператор return со значением. И в PL/1 описание процедур и функций отличается друг от друга только заголовком и видом операторов return, которые можно размещать где угодно и как угодно, причем в исходном тексте процедур return может не быть вообще, а в тексте функций обязательно они должны быть. Вот тут-то и появляется опасность ошибки.

Конечно, компилятор проверяет, что в исходном тексте функции есть хотя бы один return, но ошибка может быть тоньше.

Например, в случае, если текст какой-нибудь функции f оканчивается выражением вроде:

… if x>0 then return(1); else return(-1); end f;

то все в порядке, всегда будет выход со значением функции.

Но, если я, сморозив глупость, написал что-нибудь вроде:

… if x>0 then return(1); if x<0 then return(-1); end f;

то в случае x=0 становится возможным достижение конца исходного текста функции без вычисления какого-либо ее значения, хотя return и имеются. И независимо от того, стоит ли в конце еще и неявный return или нет, ничего хорошего из этого не выйдет.

Для обнаружения таких неприятных ошибок без громоздкого анализа исходного текста компилятор и использует «мертвый код». А именно, в конце каждой процедуры-функции сначала обязательно ставится псевдокоманда func, которая тоже входит в пресловутые 90 команд. Она имеет двоичный код останова по контрольной точке (байт 0CCH) и на следующих этапах компиляции должна быть именно как «мертвый код» и выброшена. Если же она сохранилась в связанном списке будущих команд x86, значит этот код не «мертвый» и потенциально возможно попадание управления в эту точку. Следовательно, можно выдавать предупреждение, что из такой-то функции возможен выход без значения. А ошибку в этом примере можно было бы исправить как-нибудь так:

… if x>0 then return(1); if x<0 then return(-1); return(0); end f;

и тогда и предупреждение и код 0CCH исчезают.

Если же не обращать внимания на предупреждение, и все-таки запускать программу с такой потенциальной ошибкой, то в случае, если она действительно произойдет, из-за оставшегося кода 0CCH программа вылетит по исключению «контрольная точка» (или выйдет в интерактивный отладчик), что гораздо лучше молчаливой поломки в непредсказуемом месте.

Таким образом, «мертвый код» — это суровые реалии программирования. Он может возникнуть как в результате работы компилятора (особенно при оптимизации), так и в результате ошибок в программе. В некоторых случаях невыполняемый участок программы может быть даже сделан намеренно, например, при отладке в исходный текст может быть специально вставлен оператор перехода/возврата, чтобы пока не выполнялась бы какая-то часть программы.

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

© Habrahabr.ru