Rambler's Top100 Service калинин.ru / программирование / c и c++ /  << 14.04.01 >>

Религия и goto

Начнем несколько издалека. В программировании существует тенденция к алгоритмизации самого процесса программирования. То есть, выведение некоторых универсальных правил, использование которых в реальной практике предопределило бы успех всего проекта. Например, объектно-ориентированный подход к программированию в настоящее время преподносится как панацея от всех бед в создании программного обеспечения. Модные слова употребляются настолько часто, что уже просто некультурно не придерживаться общего умопомешательства, которое охватило окружающих. Я не собираюсь утверждать, что объектно-ориентированное программирование вместе с анализом и проектированием не подходят для решения реальных задач. Конечно же, подходят, во всяком случае для тех, кто умеет их использовать. Просто, выражаясь словами Ф.Брукса, "серебряной пули нет", т.е. не существует таких методологий, которые бы давали возможность создавать любое программное обеспечение на качественно ином уровне, чем раньше. А следовательно, постоянное употребление одних и тех же правил в любых ситуациях не всегда даст желаемый результат.

Например, одно из самых больших преимуществ ОО-подхода к созданию программных систем заключается в том, что созданные программные компоненты, в принципе, можно использовать повторно в других проектах или в других местах того же проекта (software reuse). Это достигается за счет сокрытия реализации от пользователя и предоставление в его распоряжение только лишь внешний интерфейс. Предполагается, что пользователь будет только смотреть на интерфейс и действовать исходя из того, что компонент является своебразным "черным ящиком", выполняющим атомарные операции. С одной стороны, конечно же, это хорошо и правильно: такой подход дает возможность нескольким программистам работать над проектом так, как будто они "одни", рассчитывая только на узкие интерфейсы взаимодействия. Но только... у любой медали есть две стороны. В данном случае, оборотная сторона медали заключается в том, что программист-пользователь совершенно не знает как устроен компонент, который использует. Только не удивляйтесь: недавно я это преподносил как преимущество такого подхода, а теперь наоборот. Но вдумайтесь сами: когда программист передает управление какому-то методу известного ему интерфейса он совершенно не представляет, что же случится на самом деле. Сколько времени это займет, памяти и, самое главное, он не знает, что делать в том случае, если предоставляемые ему методы интерфейса не подходят к его задаче.

И это не простые претензии. В случае с C++, конечно же, самый "очевидный" камень попадает в огород переопределения операций. Программист никогда не может заранее сказать, что же конкретно произойдет при выполнении такого кусочка кода:

a = b;

И хотя редко когда в operator=() вставляют форматирование жесткого диска, непонимание того, что произойдет при подобном присваивании, может привести к нежелательным эффектам. Например, при этом данные из объекта b скопируются в объект a, или будет применена технология разделения данных до первой записи? Для того, чтобы точно узнать ответ на подобный вопрос, необходимо иметь доступ к исходным текстам и знать реализацию интерфейса. Заметьте: реализацию! Это значит, что программист должен все-таки знать о том, как устроен компонент, который он использует, и изменения во внутренней реализации все-таки будут сказываться на пользовательском коде.

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

Тем не менее, стоит вернуться к C++. Язык, прямо скажем, спорный. Сам по себе он является нарушением большого количества правил и технологий программирования: не так реализованы объекты и классы, чтобы он являлся в полной мере Объектно-Ориентированным языком программирования с большой буквы; goto, опять же, оставлен, что тоже несколько не подходит некоторым религиозным деятелям и т.д. Количество разнообразнейших директив, придуманых для "правильного" программирования на C или C++ просто впечатляет. Одна лишь венгерская нотация чего стоит: это когда имя переменной предваряет сокращение от ее типа. Наверняка, ее создатель, Чарльз Саймони, знает преимущества своей нотации, но большая часть программистов, которые ее используют вслед за примерами из MSDN, не смогут внятно объяснить зачем она им нужна в языке программирования со строгой типизацией. Если вы считаете, что я не прав, вы можете поправить меня по электронной почте.

Догмы, которые сковывают движения программиста, вводятся ему при обучении в институте или при получении первых навыков программирования на работе. Следование им носит несколько религиозный характер: человек не может объяснить, почему его догма лучше другой...

Рассуждения в этой статье, конечно же, в большей части субъективны, спорны, но они не призваны быть еще одной догмой.

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

Когда программы писались на языках программирования значительно более близких к компьютеру, чем сегодняшние, использование 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;
}

У этого текста есть недостаток. Он, возможно, тоже несколько догматичен, но зачастую удобнее, когда функция возвращает значение в одном месте, а не в нескольких. Это связано с удобством отлаживания, удобством изменения кода, который должен выполниться перед возвратом из функции, например, освобождение занятых ресурсов. Кстати, освобождение ресурсов, конечно же, можно "повесить" на деструкторы классов-оберток для этих ресурсов, но обычно это становится настолько громоздко, что проще воспользоваться 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 = x; \
     goto finish; \
   } while(0)

   int res;
   
   // ...

   if(a < b) RETURN(10);

   // ...

   if(a > b) RETURN(20);

   // ...

finish:
   return res;

#undef RETURN
}

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

Еще пример: выход из "многократного" switch или нескольких вложенных циклов. Как бы плохи не были бы эти конструкции сами по себе с точки зрения их понятности досужему читателю исходных текстов, их все равно приходится использовать. Сторонники догм в таких ситуациях используют различные флаги, признаки окончания работы. Это ужасно:

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() завершила свою работу.\n");
   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
    {
       // обработка таймаута
    }

  // ...
}

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

Резюме

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

BYTE/Россия

Текст выше является черновым вариантом моей статьи, опубликованной в журнале BYTE/Россия за апрель 2001 года. В журнал вошла статья, совсем иная по духу и, в общем-то, немного о другом (просто об использовании goto). Несмотря на то, что окончательный вариант этой статьи мне нравится несколько больше (хотя бы тем, что затрагивает только одну проблему, а не несколько), мне кажется что этот вариант будет более по духу моей домашней странички.

Конечно же, сам опус кореллирует с другим опусом в этом разделе (тоже про goto), будем считать его развитием темы.


Версия для печати


  Ссылки по теме:
http://www.acm.org/classics/oc
   Edsger W. Dijkstra, Go To Statement Considered Harmful.
  Рядом в разделе:
C или C++? (09.07.01)
   Существуют два диаметрально противоположенных, но одинаково распространенных мнения, которые можно выразить как "C++ это C с классами" и "C++ и C...   >>>>
ploticus (16.10.00)
   Есть такая программа, предназначенная для создания графиков различных видов из командной строки, называется ploticus. Программа сама по себе достаточно удобная ---...   >>>>
  Рядом по дате:
TCP/IP Illustrated, volume I. The Protocols (22.04.01)
   И опять, книга, о которой мне хочется рассказать, насколько мне известно, отсутствует в русском переводе. Тем не менее, в разделе сетевого...   >>>>
НТВ (06.04.01)
   Надоевшая тема, но все-таки. Хочу сразу же оговориться: я не считаю себя носителем объективного мнения и не претендую на него. Мало...   >>>>
  Содержание:
Заглавная страница
Мой блог
Мое резюме
Дайджест
Программирование
   C&C++
Сети
Unix
Алгоритмы
Оптимизация
Соревнования
Отвлеченно
XML
TeX
Просто так
Студенческое
Туризм
  Байки
Фотографии
Комментарии
   Книги
Web-ресурсы
Фильмы
Интернет
Программное обеспечение
Жизнь
Благодарности
Форум
Хронология
 
  В этом разделе:
Простой, но полезный аллокатор памяти (18.02.03)
   Эта заметка --- продолжение "Postfix изнутри" в том смысле, что в качестве примера опять берется postfix. Но если в прошлый раз...   >>>>
C или C++? (09.07.01)
   Существуют два диаметрально противоположенных, но одинаково распространенных мнения, которые можно выразить как "C++ это C с классами" и "C++ и C...   >>>>
Религия и goto (14.04.01)
   Начнем несколько издалека. В программировании существует тенденция к алгоритмизации самого процесса программирования. То есть, выведение некоторых универсальных правил, использование которых в...   >>>>
ploticus (16.10.00)
   Есть такая программа, предназначенная для создания графиков различных видов из командной строки, называется ploticus. Программа сама по себе достаточно удобная ---...   >>>>
Шаманство, или ошибки работы с памятью (25.09.00)
   Когда программа становится внушительной по своему содержанию (то есть, не по количеству строчек, а по непонятности внутренних связей), то ее поведение...   >>>>
Библиотека консорциума W3, libwww (20.09.00)
   Популярный нынче термин "веб-программирование" обычно подразумевает под собой программирование, в лучшем случае, на perl, в худшем --- на PHP, в совсем...   >>>>
Инварианты внутри программы (18.09.00)
   Вы когда-нибудь задумывались, над тем, как вы пишите программы? Если нет, то, я думаю, сегодняшняя заметка будет вам полезна. Итак, как...   >>>>
Содержание раздела полностью...
   Примерно в тоже время
TCP/IP Illustrated, volume I. The Protocols (22.04.01)
   И опять, книга, о которой мне хочется рассказать, насколько мне известно, отсутствует в русском переводе. Тем не менее, в разделе сетевого...   >>>>
НТВ (06.04.01)
   Надоевшая тема, но все-таки. Хочу сразу же оговориться: я не считаю себя носителем объективного мнения и не претендую на него. Мало...   >>>>
Хронология полностью...
   Содержание
Заглавная страница
Мой блог
Мое резюме
Дайджест
Программирование
  C&C++
Сети
Unix
Алгоритмы
Оптимизация
Соревнования
Отвлеченно
XML
TeX
Туризм
  Байки
Фотографии
Комментарии
  Книги
Web-ресурсы
Фильмы
Интернет
Программное обеспечение
Жизнь
Студенческое
Просто так
Благодарности
Форум
Хронология
© 2000-2008, Andrey L. Kalinin
mailto:andrey@kalinin.ru
Rambler's Top100