Byte/RE ИТ-издание

Разумный GOTO

Андрей Калинин
andrey@kalinin.ru, http://www.kalinin.ru

Cпоров, которые можно отнести к раздряду религиозных, очень много. Но у программистов есть один, который преследует их уже давно и всегда вызывает острые дискуссии: это проблема использования или, точнее, неиспользования оператора GOTO.

С появлением языков высокого уровня, в которые были введены конструкции наподобие for или if, а также блоки операторов, острая необходимость в повсеместном использовании GOTO и подобных ему операторов отпала (потому что соответствующие команды процессора использовались обычно именно для организации ветвлений или циклов). Но было бы неверно считать, что операторы ветвления полностью заменяют GOTO.

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


i = 5;
GOTO label;

// ...

for(i = 1; i < 10; i++)
{

// ...

label:

// ...
}

Почему-то именно данный пример обычно приводят в оправдание отрицательного отношения к GOTO. Скажу честно: в общем случае этот пример мне тоже кажется отвратительным. Но я могу представить себе ситуацию, когда подобная конструкция окажется попросту более эффективной, чем любая другая, а в некоторых случаях это перевешивает любые «религиозные» рассуждения. Зачем нам чистота программного кода, если она достигнута за счет превращения получившейся программы в бесполезного, с трудом ворочающегося мастодонта?

Ричард Стивенс, автор известных книг «TCP/IP Illustrated» и «Unix Network Programming», как-то раз заметил, что противникам использования GOTO стоит попробовать реализовать функцию tcp_input() из 4.4BSD реализации TCP/IP без GOTO так, чтобы она была хотя бы столь же эффективна, как исходная. Мораль: всему свое место. Конечно же, такие трюки скорее всего не добавят читабельности исходному тексту, но иногда этим тоже можно пожертвовать. В конечном счете, никому доподлинно неизвестно, что лучше: писать программы, которые новички в программировании смогут читать как художественную прозу (не получая при этом никакого опыта в программировании), либо вообще не принимать во внимание то, что даже опытный программист не сразу поймет, зачем нужна та или иная строчка. В итоге большая часть правил имеет перед собой цель усреднить программистов: сгладить отсутствие знаний у новичков за счет вдалбливания в них жестких законов программирования и… заставить опытных программистов не смущать новичков «неправильными» приемами. После такого усреднения программистов будет легче заменять в работе. Возможно, что само по себе это полезно для управления группой программистов, но в конечном счете усреднение, как правило, отрицательно сказывается на общем результате, если тот должен содержать в себе какие-то элементы новизны.

Следующий недостаток GOTO, который раньше ему приписывался, заключался в том, что в старых версиях C++ при его помощи можно было миновать («перепрыгнуть») инициализацию переменных, то есть вызов конструктора. Хочется особо отметить, что с тех пор кое-что изменилось, и сейчас запрещение таких ситуаций — это как раз одно из требований стандарта.

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

Замечу, что есть вполне оправданные применения GOTO, которые, на мой взгляд, достаточно логичны и понятны, чтобы противостоять любым догмам. Во-первых, это возврат значения из функции, например:


int f()
{
// ...

if(a < b) return 10;

// ...

if(a > b) return 20;

// ...

return res;
}

У этого текста есть недостаток. Он, возможно, тоже несколько догматичен, но зачастую бывает удобнее, когда функция возвращает значение в одном месте, а не в нескольких (здесь можно вспомнить IDEF0). Это связано с удобством отладки, изменения кода, который должен выполняться перед возвратом из функции (например, освобождение занятых ресурсов). Кстати, освобождение ресурсов, конечно же, можно «повесить» на деструкторы классов-оберток для этих ресурсов, но тогда конструкция может оказаться столь громоздкой, что в некоторых случаях проще воспользоваться GOTO:


int f()
{
int res;

// ...

if(a < b) { res = 10; GOTO finish; }

// ...

if(a > b) { res = 20; GOTO finish; }

// ...

finish:
return res;
}

Вообще вместо «явного» GOTO можно воспользоваться… средствами макропроцессора. Прошу читателей не обижаться, но подобный код действительно хорошо читается (если функция не слишком большая):


int f()
{
#define RETURN(x)
do {
res = 10;
GOTO finish;
} while(0)

int res;

// ...

if(a < b) RETURN(10);

// ...

if(a > b) RETURN(20);

// ...

finish:
return res;

#undef RETURN
}

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

Еще пример: выход из «многократного» switch или нескольких вложенных циклов. Как бы ни были плохи эти конструкции сами по себе (с точки зрения их понятности для досужего читателя исходных текстов), их все равно приходится использовать. Если не брать в расчет GOTO, то в таких ситуациях обычно используют различные флаги, признаки окончания работы. Мне кажется, это ужасно:


for(int i = 0; i < 10; i++)
for(int j = 0; j < 10; j++)
for(int k = 0; k < 10; k++)
for(int l = 0; l < 10; l++)

К примеру, из внутреннего цикла надо закончить все остальные. Заметим, что break и continue обычно не считаются отступлением от веры в «светлое будущее без GOTO», поэтому для завершения одного цикла в чрезвычайном случае обычно используется что-то в духе:

if( … ) break;

Так как break, по сути, ничем не отличается от GOTO, то было бы логично использовать такую же конструкцию с явным использованием оператора безусловного перехода для завершения всех четырех циклов:

if( … ) GOTO finish;

Но вместо этого чаще всего встречается один из следующих вариантов:

if( … ) { finish = true; break; }

и в каждом из циклов на каждой итерации присутствует проверка флага finish:

if(finish) break;

либо, что то же самое, изменение условий цикла:

for(int i = 0; i < 10 && !finish; i++)

Кстати сказать, в таком случае вместо флага finish, у которого условие нормального выполнения циклов — false, лучше использовать флаг run, который принимает значение false в том случае, если надо прекращать работу; таким образом экономится лишнее отрицание.

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

Одно из ограничений GOTO — локальность его использования. Например, при помощи GOTO нельзя передать управление другой функции, и понятно почему: передать управление можно только той функции, которая вызвала данную (прямо или косвенно), но этот вопрос можно решить только во время выполнения программы, а не при компиляции. Поэтому в языке C (и в стандарте POSIX) существуют макросы нелокальных переходов, позволяющие выполнять подобные трюки. Не собираюсь дублировать здесь описание стандартной библиотеки языка C, но на всякий случай приведу пример использования этих макросов:


#include <setjmp.h>

jmp_buf jbuf;

void foo( /* .. */ )
{
if(exit_condition)
longjmp(jbuf, 1);
else
{
// ...

foo( /* ... */ );
}
}

int main()
{
if(setjmp(jbuf))
printf("foo() завершила свою работу.
");
else
foo( /* ... */ );
}

В этом примере происходит следующее: через вызов setjmp() инициализируется специальный буфер, в котором содержится информация о месте, куда надо будет перейти, и различные параметры программного окружения. Этот макрос возвращает 0 после своего непосредственного вызова и ненулевое значение, если был вызван longjmp() (longjmp() — это тоже макрос, осуществляющий переход в то место программы, где был инициализирован буфер).

В примере выше longjmp() служит для быстрого возврата из рекурсивной функции, минуя последовательный возврат управления. В принципе, это уже пример достаточно оправданного применения пары setjmp()/longjmp(), но мне хочется привести еще один вполне реальный пример, в котором без нелокальных переходов обойтись сложно.

Существует библиотека BerkeleyDB, первая версия которой обычно входит в библиотеку языка C libc.a в клонах Unix обеспечивает реализацию долгосрочных Б-деревьев, хеш-таблиц и списков при помощи простого набора функций. У нее есть некоторые недостатки, один из которых — отсутствие средств для разграничения доступа (в следующих версиях, кстати, они появились). Этот недостаток традиционно компенсируется за счет набора флагов режима доступа к файлу, содержащему данные таблиц, например таким образом:


DB* db =
dbopen("test.db", O_RDWR | O_EXLOCK | O_CREAT,
0644, DB_BTREE, NULL);

Разграничение доступа гарантируется тем, что затребован эксклюзивный режим доступа к файлу (определяемый флагом O_EXLOCK), и в результате в каждый момент времени только один пользователь может иметь доступ к таблице. С другой стороны, если файл уже заблокирован, то dbopen() (а точнее, open()) вернет управление вызывающей стороне только тогда, когда блокировка будет снята (такой подход к вызовам называется полным делегированием). Но зачастую это неприемлемо: во-первых, время ожидания может быть ограничено, а во-вторых, никто не гарантирует, что ожидание не продлится вечно (например, в результате того, что разные процессы взаимно блокируют друг друга). Один из способов установки времени ожидания (может быть, не самый корректный) заключается в использовании обработчика сигнала SIGALRM и longjmp().


jmp_buf timebuf;

void sig_timeout(int sig)
{
longjmp(timebuf, 1);
}

int main()
{
// ...

if(setjmp(timebuf) == 0)
{
signal(SIGALRM, sig_timeout);
alarm(TIMEOUT);

// работа с таблицами
}
else
{
// обработка таймаута
}

// ...
}

Такой вариант использования нелокальных переходов все же вполне разумен. Кстати сказать, для достижения того же эффекта можно было бы воспользоваться, к примеру, библиотекой pthreads, но это будет несколько менее эффективно.

Конечно, использование longjmp() не лишено побочных эффектов. Например, при переходе будет пропущен вызов деструкторов для объектов, размещенных в стеке. Собственно, именно в этом и состоит основное отличие при использовании исключений (exceptions) в C++: они гарантируют, что для объектов будут вызваны деструкторы.

В завершение этой темы хочется еще раз обратить внимание на то, что однозначных решений и правил не существует: использование или неиспользование GOTO и подобных средств тому пример. Можно, конечно, с легкостью довести ситуацию до абсурда; мало того, я видел исходные тексты, в которых безусловные переходы используются далеко не лучшим образом. Но в этом виновата не языковая конструкция, а программист, который ее так использовал. Более того, складывается ощущение, что даже жесткий запрет на использование GOTO не улучшил бы качество подобных исходных текстов.

Вам также могут понравиться