Pull to refresh

Запретный плод GOTO сладок!

Reading time 9 min
Views 107K
Доброго времени суток!

Какое Ваше отношение к оператору goto в языках С/С++? Скорее всего, когда Вы учились программировать, Вы его использовали. Потом Вы узнали, что это плохо, и Вы о нем позабыли. Хотя иногда при сложной обработке ошибок… нет-нет, там try … throw … catch. Или же для выхода из вложенных циклов … не-ет, там флаги и куча сложностей. Или когда вложенные switch … нет-нет-нет, там те же флаги.
И все-таки, иногда в ночной тиши Вы допускали в свое подсознание грешную мысль – «а почему бы не использовать вот тут goto? И программа вроде как стройней будет, и оптимально выходит. Да-а, было бы хорошо… Но нет – нельзя, забыли!».
А почему так оно?
Под катом – небольшое расследование и мое, основанное на многолетней практике и разных платформах, отношение к этому вопросу
UPD: тут статья рассматривает С и С++, программирование для PC и слегка для микроконтроллеров. Конкретно о микроконтроллерах есть другая статья.

Просьба к ярым противникам goto – не свергайте меня в геенну огненную минусовой кармы только из-за того, что я поднял эту тему и являюсь частичным сторонником goto!

Небольшой исторический экскурс


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

А все начиналось с комбинационных схем



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

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

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

Имея алгоритм, несложно построить комбинационную схему – схему, которая мгновенно (с точностью до срабатывания логических устройств и времени распространения сигналов) на выходе давала ответ.
Вопрос – тут нужны какие-нибудь переходы? Нет, их тут просто-напросто нет. Есть последовательное течение действий. Все эти действия можно реализовать в конечном счете за один такт (не спорю, это будет очень и очень громоздко, но задавшись разрядностью всех данных, такую схему Вам построит любой студент – и тем более синтезатор для VHDL или Verilog).

Но потом вмешались схемы с памятью



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

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

Вопрос – тут есть какие-нибудь переходы? Однозначно да! Более того, появляются переходы безусловные (адрес следующей команды не зависит от текущего состояния данных) и условные (адрес следующей команды зависит от состояния данных).

Можно ли без них обойтись? Да никак! Если не использовать переходы, то мы вернемся к комбинационной схеме без памяти.

В итоге мы пришли к ассемблеру


Апофеозом таких вычислительных устройств стали микро-, просто- и супер-компьютеры. Все они в основе имеют язык кодов, достаточно легко преобразуемый в Ассемблер с приблизительно совпадающим набором команд. Возьмем самый массовый персональный компьютер с Ассемблером от i386 процессора – благо, Windows XP написан для него. Как у него построена работа?

В данном процессоре память данных и команд общая, идентифицируется адресом. Команды не имеют фиксированную длину. Если не используется команда перехода, то следующей командой выполняется команда, расположенная сразу за текущей.

Есть также набор goto-команд. Они бывают безусловные (просто переход на следующий адрес – jmp, переход в подпрограмму – call, выход из нее – ret, вызов прерывания – int) и условные (в зависимости от состояния флагов – jnz, jne, jz, …). Есть также команда для создания циклов – loop. Она 1) делает -1 регистру CX/ECX и 2) переходит на указанный адрес, если этот регистр ненулевой.
В плане операций переходов другие массовые ассемблеры (те же микроконтроллеры) принципиально не отличаются – разве что нет возможности программно вызвать прерывание и не везде есть loop.
Со всей ответственностью заявляю – без операций перехода в ассемблере обойтись невозможно! Любая программа на ассемблере просто таки пестрит ими! Впрочем, тут со мной никто спорить, я думаю, не будет.

Итог


Какой итог можно подвести? На уровне микропроцессора операции перехода используются очень активно. Реальную программу, их не использующую, написать почти невозможно (может быть, ее можно сделать, но это будет супер-мега-извращение и точно уж не реальная программа!). С этим тоже спорить никто не будет.

Но почему же тогда в языках более высокого уровня – сконцентрируемся на С/С++ — оператор goto вдруг впал в немилость?..

Немного об алгоритмах




А теперь посмотрим на хитровывернутый алгоритм. Представление не имею что это за бред – но его надо реализовать.

UPD: здесь A, B, C, D, E — это некоторые операции, а не вызов функции! Вполне возможно, что они используют массу локальных переменных. И вполне возможно, что они меняют их состояние. Т. е. в данном случае речь не идет о вызове функций — некоторые действия, не будем детализировать.

Вот как это выглядит в С с goto:

if (a)
{
	A;
	goto L3;
}
L1:
if (b)
{
L2:
	B;
L3:
	C;
	goto L1;
}
else if (!c)
{
	D;
	goto L2;
}
E;
 


Очень лаконично и читабельно. Но — нельзя! Попробуем без goto:

char bf1, bf2, bf3;

if (a)
{
	A;
	bf1 = 1;
}
else
	bf1 = 0;

bf2 = 0;
do
{
	do
	{
		if (bf3 || b)
			bf3 = 1;
		else
			bf3 = 0;
		if (bf3 || bf2)
			B;
		if (bf3 || bf1 || bf2)
		{
			C;
			bf1 = 0;
			bf2 = 1;
		}
		if (!bf3)
		{
			if (!c)
			{
				D;
				bf3 = 1;
			}
			else
			{
				bf3 = 0;
				bf2 = 0;
			}
		}
	}
	while (bf3);
}
while (bf2);

E; 


Вы что-нибудь поняли из логики работы второго листинга?..
Сравним оба листинга:

  • На первый листинг я потратил раз в 5 меньше времени, чем на второй.
  • Листинг с goto короче как минимум в 2 раза.
  • Листинг с goto поймет любой человек с самой минимальной подготовкой в С. Второй же я постарался сделать максимально доступным и очевидным – и все равно, в него надо долго вникать.
  • Сколько времени уйдет на отладку первого варианта и сколько на отладку второго?
  • И вообще, если считать нарисованный алгоритм постановкой задачи, то первый листинг правильный на 100%. Про второй я до сих пор не очень уверен… хотя бы в очередности проверки условий и флагов.
  • Сравните получившийся ассемблерный код первого и второго листинга.


Но зато во втором листинге нет goto!

Ну да ладно, в жизни такие алгоритмы почти не встречаются. Лучше поговорим о жизни.

goto в реальных программах



Я за свой более чем 20-летний стаж прошел несколько аппаратных платформ и с десяток языков программирования, участвовал в написании крупного программного продукта ActiveHDL, делал коммерческую базу данных и много небольших программ для отладки оборудования, используемого в Олимпийских играх, а также делал устройства для этой самой Олимпиады (уже несколько Олимпиад, если быть точным). Короче, что-то я в программировании шарю. А, да, забыл – я закончил с почетным дипломом ХНУРЭ — то бишь, в теории я тоже секу.

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

Неявное использование goto


В языке С есть много операторов, которые на самом деле являются банальным goto – условным или безусловным. Это все виды циклов for (…), while (…) {…}, do {…} while (…). Это анализ числовых переменных switch (…) {case … case …}. Это те же операторы прерывания/перехода в циклах break и continue. В конце концов, Это вызовы функций funct() и выход из них return.

С++ добавляет массу невидимых переходов перегрузками, конструкторами и деструкторами. Из явных переходов – try {… throw …} catch (…) {…}.

Эти goto считаются «легальными» — чем же нелегален сам goto?

В чем обвиняют goto


Обвиняют его в том, что код становится нечитабельным, плохо оптимизируемым и могут появиться ошибки. Это про практические минусы. А теоретические – это просто плохо и неграмотно, и все тут!

Насчет нечитабельности кода и плохой оптимизируемости – еще раз взгляните на листинги выше.
Насчет вероятности появления ошибок – согласен, такой код воспринимается несколько сложнее из-за того, что мы привыкли читать листинг сверху вниз. Но и все! А что, другие средства С++ безопасные и не могут создать ошибок в коде? Приведу краткий список самых опасных вещей С++ («опасные» в том смысле, что с их помощью можно создать массу трудноуловимых ошибок): преобразования типов, перегрузка операторов, указатели и динамическое распределение памяти, наследование классов, шаблоны… Кажется, я перечислил почти все достоинства С++… А, ну да, полиморфизм не назвал – он у меня проходит под «перегрузкой операторов».

Вам не кажется, что нож – это очччень опасная вещь? Но почему-то на кухне мы им пользуемся. А 220 вольт – ужас как опасно! Но если пользоваться с умом – жить можно.

Тоже самое и goto. Пользоваться им надо с умом – и тогда код будет работать корректно.

А про теоретические доводы – это, уж простите меня, спор о вкусах. Вы пользуетесь Венгерской нотацией? Я – нет, терпеть ее не могу! Но я ж не говорю, что она плохая из-за этого! Лично я считаю, что переменная должна нести смысловую нагрузку – для чего она создана. Но я не буду запрещать пользоваться этим способом именования другим людям!

Или же есть эстеты, которые считают, что писать a = ++i неграмотно, надо писать i = i + 1; a = i. И что теперь, запретить и это тоже?

Впрочем, есть в C++ ньюанс, где goto приводит к проблемам:

goto Label;

for (;;)
{
	int p = 0;
Label:
	p += 10;
}


Чему будет равно p? А компилятор его знает! Впрочем, хороший компилятор такого не допустит – или допустит в том случае, если это p нигде не используется.

В то же время выход из цикла вызывает необходимые деструкторы – проверено в Visual Studio 2008.
Что на это скажешь? А то, что не-использование goto не застрахует Вас от неправильно написанной программы. Так же и применение его не гарантирует, что программа будет сыпаться. Нужно писать программы с умом и не делать таких элементарных глюков.

Обработка ошибок


В С++ по сравнению с С появилась замечательная возможность try … throw … catch. Она позволяет эффективно обрабатывать ошибки и прочие сложные ветвления. В С++ — да, но не в С. В С приходится в таком случае использовать goto. Разумеется, можно использовать всяческие флаги и прочее. Но этот вопрос мы уже обсудили – код раздувается, появляются источники новых ошибок, листинг становится нечитабельным.

Это как раз тот самый случай при программировании микроконтроллеров, где я активно использую goto. Это мне позволило не попадать в «зависания» в случае проблем с внешними устройствами, UART, USB и т. п.

Выход из вложенного цикла наружу


Посмотрите на программу ниже:

char a, b, c;

for (a = 0; a < 10; ++a)
{
	for (b = 0; b < a; ++b)
	{
		if (!c)
			goto Leave;
	}
	for (b = 10; b < 15; ++b)
	{
		d ();
	}
}

Leave:
e ();


Что происходит – понятно? Есть вложенный цикл. Если наступило какое-то условие – покидаем все последующие обработки.

Данный код с флагами выглядит иначе:

char a, b, c, f1;

f1 = 1;
for (a = 0; a < 10 && f1; ++a)
{
	for (b = 0; b < a && f1; ++b)
	{
		if (!c)
			f1 = 0;
	}
	if (f1)
	{
		for (b = 10; b < 15; ++b)
		{
			d ();
		}
	}
}

e ();


Что произошло в данном случае? На каждой итерации мы теперь проверяем флаг. Не забываем его проверять и дальше. Это мелочи, если итераций немного и речь идет о «безразмерной» памяти у PC. А когда программа написана для микроконтроллера – это все уже становится существенно.

Кстати, в связи с этим в некоторых языках (если не ошибаюсь, в Java) есть возможность выйти из цикла по метке вида break Leave. Тот же goto, между прочим!

Точно такой же пример я могу привести и с обработкой в switch (…) { case …}. С этим я сталкиваюсь часто при обработке входящих пакетов неодинаковой структуры.

UPD: как мне справедливо подсказали, в данном случае это проблема для С, а не С++. В С++ можно «выскользнуть» наружу с помощью throw… catch…. Но мы помним, что throw… catch… — тот же самый goto!

Автоматическое создание кода


Знакомы ли Вы с автоматным программированием? Или любым другим автоматизированным созданием кода? Скажем, создатели лексических обработчиков (без использования громоздкого boost::spirit). Все эти программы создают код, который можно использовать как «черный ящик» — Вам не важно, что там внутри; Вам важно, что он делает. А внутри там goto используется очень и очень часто…

Кстати, признаком хорошей программы на С++ является создание интерфейсов, которые работают в четко заданных условиях. И Вам знать не надо что там внутри. А, может, там тоже масса goto?.. Это уже проблема создателя такого класса – придумать, отладить и составить спецификацию. И в рамках этой спецификации заставить его работать стабильно. А внутренности – они не для посторонних.

Выход в одном месте


На С иногда приходится писать что-то вроде:

int f (…)
{
	…
	if (a)
	{
		c = 15;
		return 10;
	}
	…
	if (b)
	{
		c = 15;
		return 10;
	}
	…
	с = 10;
	return 5;
}


Этот код гораздо аккуратней будет выглядеть так:

int f (…)
{
	…
	if (a)
		goto Exit;
	…
	if (b)
		goto Exit;

	…
	с = 10;
	return 5;
Exit:
	c = 15;
	return 10;
}


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

Итог



Это моя точка зрения! И она справедлива для меня. Может – и для Вас, но я не буду Вас заставлять ей следовать!

Так вот, для меня очевидно, что goto помогает оптимальней и качественней решить некоторые проблемы.
А бывает и наоборот – goto может породить массу проблем.

UPD: Начитавшись гору комментариев, я для себя выделил положительные стороны использования goto и отрицательные.

Плюсы использования goto:
  • самый оптимальный (с т. зр. листинга и результирующего кода) выход из нескольких вложенных циклов и switch… case
  • Си: наиболее экономичный (по листингу и результирующему коду) способ обработки ошибок
  • в отдельно взятых случаях самое оптимальное построение алгоритма
  • экономит память и такты при аккуратном использовании, что иной раз бывает первостепенно важным

Минусы использования goto:
  • непривычность кода
  • нарушение хода следования чтения листинга сверху вниз и стандартизированного обхода блоков в коде (в смысле, что возможен переход в центр блока, а также выход из него)
  • усложнение компилятору (а иногда и невозможность) процесса оптимизации кода
  • повышение вероятности создания трудноуловимых ошибок в коде


Кто еще подскажет плюсы/минусы? Впишу, если они будут оправданы.
Tags:
Hubs:
+98
Comments 273
Comments Comments 273

Articles