Pull to refresh

Запретный плод GOTO сладок (версия для микроконтроллеров)!

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

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

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

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


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

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



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

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

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

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

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



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

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

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

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

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


Апофеозом таких вычислительных устройств стали микро-, просто- и супер-компьютеры. Все они в основе имеют язык кодов, достаточно легко преобразуемый в Ассемблер с приблизительно совпадающим набором команд. Рассмотрим некий усредненный ассемблер микроконтроллеров (я знаком с ассемблером для ATmeg-и, PIC и AT90). Как у него построена работа переходов?

Переходы бывают безусловные (просто переход на следующий адрес, переход в подпрограмму, выход из нее) и условные (в зависимости от состояния флагов).

Со всей ответственностью заявляю – без операций перехода в ассемблере обойтись невозможно! Любая программа на ассемблере просто таки пестрит ими! Впрочем, тут со мной никто спорить, я думаю, не будет.

Итог


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

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

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




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

Здесь 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!

Еще мне предлагали этот алгоритм реализовать приблизительно так:


if a
A
C

while b or not c
if not b
D
B
C

E



Вроде бы красиво, да? Почему бы не сделать copy-past, почему бы не накрутить дополнительные проверки, почему бы… сделать все что угодно, кроме goto!!!

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

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



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

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

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


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

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

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


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

Насчет нечитабельности кода и плохой оптимизируемости – еще раз взгляните на листинги выше.
Насчет вероятности появления ошибок – согласен, такой код воспринимается несколько сложнее из-за того, что мы привыкли читать листинг сверху вниз. Но и все! А что, другие средства С безопасные и не могут создать ошибок в коде? Да взять хотя бы преобразования типов, работа с указателями. Там напортачить — за нечего делать!

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

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

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

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

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


Возьмем обработку входных пакетов с некоего внешнего устройства:

pack = receive_byte ();

switch (pack)
{
case ‘A’:
	for (f = 0; f < 10; ++f)
	{
		…
		if (timeout)
			goto Leave;
		…
	}
	break;

case ‘B’:
	…
}
Leave:
	…



Мы получили заголовок пакета. Проанализировали. Ага, пакет 'A' — значит, нам надо 10 раз сделать чего-то. Мы не забываем контролировать время работы этого участка — а вдруг вторая сторона зависла? Ага, таки зависла — сработало условие timeout — тогда выходим наружу — из цикла, из switch.

Тоже самое можно сделать и с помощью всяческих флагов — но работать это будет медленней, плюс ко всему займет такую дефицитную ячейку памяти. Оно того стоит?

Это случай простой. А ведь receive_byte () тоже может быть макрофункцией с обработкой таймаутов. И там тоже будут такие вот резкие выходы.

Это как раз тот самый случай, где я активно использую 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 …}. С этим я сталкиваюсь часто при обработке входящих пакетов неодинаковой структуры.

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


Знакомы ли Вы с автоматным программированием? Или любым другим автоматизированным созданием кода? Скажем, создатели лексических обработчиков (без использования громоздкого boost::spirit). Все эти программы создают код, который можно использовать как «черный ящик» — Вам не важно, что там внутри; Вам важно, что он делает. А внутри там 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:
  • непривычность кода
  • нарушение хода следования чтения листинга сверху вниз и стандартизированного обхода блоков в коде (в смысле, что возможен переход в центр блока, а также выход из его)
  • усложнение компилятору (а иногда и невозможность) процесса оптимизации кода
  • повышение вероятности создания трудноуловимых ошибок в коде


Кто еще подскажет плюсы/минусы? Впишу, если они будут оправданы.

Еще раз обращаю внимание: я не призываю тулить goto везде! НО в некоторых случаях он позволяет реализовать алгоритм куда эффективней всех остальных средств.
Tags:
Hubs:
+17
Comments 70
Comments Comments 70

Articles