
Картинка из книги Thinking Forth
По мотивам «обобщенной» статьи про GOTO и статьи про GOTO в системном программировании:
Мотивы для использования GOTO и альтернативы ему принципиально отличаются для системного и прикладного программирования — это является и важной причиной холиваров. Для прояснения ситуации рассмотрим GOTO только в разрезе прикладного программирования.
Основной тезис: в прикладном программировании GOTO однозначно лучше обходить.
Как докажем:
- В прикладном программировании критически важен один параметр кода — сопровождаемость.
- Goto не ухудшает однозначно сопровождаемость только в небольшом проценте случаев, и даже в этих случаях принципиально от альтернатив не отличается
- Ради небольшого процента случаев его использовать вредно:
1) очень низкоуровневое, поэтому сильно развращает программиста (возникает соблазн использовать и в других местах) — большой вред из-за небольшого процента случаев, когда GOTO можно применить;
2) даже в таких случаях есть более красивые альтернативы.
GOTO — свойства и влияние на качество кода
Параметры качества кода
(считаем, что код написан правильно, все нештатные ситуации учтены):- Потребление ресурсов (памяти, процессорных тактов) — приоритет «от машины»
- Сопровождаемость (легкость сопровождения) кода — приоритет «от человека»:
1) читаемость кода — насколько легко понять написанный код (соответственно, насколько легко и написать, и отладить),
2) ошибкоустойчивость при изменениях — насколько сложно внести ошибку/насколько легко ее заметить при изменении кода,
3) легкость поддержки общего стандарта — насколько написание кода приучает программиста к отклонениям от неких общих стандартов
Общие свойства GOTO:
- неструктурировано: можно вставить в почти произвольное место, сложно понять, как мы туда попали, в отличие от остальных конструкций ветвления;
- закрепощает исходный код: если структурированные блоки можно менять по-разному, перестраивать их порядок, как в конструкторе, то GOTO — это гвоздь, соединяющий какие-то блоки конструктора — после его внедрения код изменить уже очень непросто.
Использование GOTO влияет на оба параметра качества кода и их составляющие. При этом понятно, что потребление ресурсов GOTO сокращает на небольшую константу, в который оно встроено, так как сложность алгоритма оно никогда не меняет.
Что не является GOTO:
- другие конcтрукции управления потоком выполнения — if,switch,while и т.п.: в них всех ветвления потока жестко заданы синтаксисом, находятся на границе того же блока — верхней или нижней (для return — граница функции), а GOTO можно размещать произвольно
- автоматически сгенерированный код — как и в сгенерированном ассемблере, в нем копаться и его поддерживать не приходится.
Особенности GOTO в прикладном программировании
Прикладное программирование здесь — программирование на языках высокого уровня, поддерживающих структурирование кода, в том числе структурный подход к обработке исключений: Java, C#, C++, интерпретируемые языки и т.п. — в общем, стандартный прикладной мэйнстрим. C не рассматриваю как низкоуровневый язык, используемый сейчас в основном для системного программирования.
Особенности прикладного программирования:
- нет необходимости в точечных оптимизациях — отдельных тактов или ячеек памяти, поэтому экономию ресурсов можно отбросить из рассмотрения GOTO — остается только сопровождаемость
- есть возможность как угодно структурировать логику — как угодно объединять в функции/методы, заводить сколько угодно переменных, классов и т.п. с любыми названиями, возможность бросать исключения
GOTO только ухудшает сопровождаемость кода
В системном программировании важна максимальная экономия ресурсов, поэтому там, возможно, применение GOTO для этой цели оправдано.
А в прикладном программировании параметр «потребление ресурсов» можно отбросить, остается только параметр сопровождаемости, который GOTO ухудшает.
GOTO — проблемы и варианты исправлений
Рассмотрим применение GOTO в различных вариантах перемещения по структуре кода и альтернативы ему:
1. Вход в блок извне:
1.1 Вход в «не цикл»:
легко и очевидно переписывается без GOTO: if(a)
GOTO InsideIf;
if(b) {
foo();
InsideIf:
bar();
}
=>
if(b) {
foo();
}
if(a || b) {
bar();
}
1.2 Вход в цикл:
нельзя: вообще непонятен поток выполнения: if(goIntoCycle)
GOTO MiddleOfCycle;
for(...) {
foo();
MiddleOfCycle:
bar();
}
2. Переход внутри одного блока:
нет необходимости, легко переписывается, обычно на if/else: if(bNotNeeded)
GOTO startFromC:
B();
startFromC:
C();
=>
if(bNeeded) {
B();
}
C();
3. Выход из блока наружу
Это основной случай возможного применения GOTO. Разобьем его на еще более мелкие и рассмотрим подробно на примерах.
Общий подход — максимально декомпозируем: разбиваем на методы по смыслу, логику фиксируем в флагах с говорящими названиями — получаем читабельный и самодокументированный код.
Важные правила:
- НЕ ИСПОЛЬЗУЕМ ИСКЛЮЧЕНИЯ:
1) исключения всегда используем для обработки ошибок и внештатных ситуаций, поэтому не используем их для чего-либо еще, чтобы не мозолить глаз;
2) можем случайно «проглотить» исключение с внутреннего уровня вложенности;
3) это дорогое удовольствие — в прикладном программировании крохоборствовать негоже, но и так разбрасываться ресурсами при наличии простых альтернатив нет смысла; - Избегаем множественных return, особенно с разных уровней вложенности: иначе мало отличий от GOTO — читабельность кода так же затруднена в большинстве случаев. (Возможно, это требует отдельного обоснования — тоже тот еще холивар).
3.1. Единственный выход из одного уровня вложенности:
тривиально заменяется if/break и т.п.3.2. Несколько выходов из одного уровня вложенности:
3.2.1 Обработка ошибок — только через исключения
(надеюсь, это очевидно; если нет — могу объяснить в отдельной статье)3.2.2 Перебор вариантов — на примере if:
class Tamagochi {
function recreate() {
if(wannaEat) {
eat();
GOTO Cleanup;
}
if (wannaDrink) {
drink();
GOTO Cleanup;
}
if(wannaDance) {
Dance();
}
return HAPPY; //true
Cleanup:
return washHands() && cleanKitchen();
}
}
Проблемы (кроме всегда присущей GOTO неочевидности потока выполнения):
захотели добавить поведение sleep в случаях wannaEat и wannaDance — все, обобшение для wannaEat и wannaDrink разрушено.
Как сделать красиво (сразу расширенный вариант):
function recreate() {
if(wannaEat) {
eat();
needCleanup = true;
needSleep = true;
}
if (wannaDrink) {
drink();
needCleanup = true;
}
if(wannaDance) {
Dance();
needSleep = true;
}
result = HAPPY; //true
if(needCleanup)
result = result && washHands() && cleanKitchen();
if(needSleep)
result = result && sleep();
return result;
}
3.3. Выход из нескольких уровней вложенности.
3.3.1 Если легко выделить разную логику (разные ответственности):
class BatchInputProcessor {
function processInput(inputRecords) {
for (inputRecord in inputRecords) {
for (validator in validators) {
if (!validator.check(inputRecord)
GOTO ValidationFailed;
}
item = new Item();
for (fieldValue in inputRecord.fieldValues) {
setFieldValue(fieldValue.field, fieldValue.value);
}
itemsToStore.add(item);
}
return store(itemsToStore);
ValidationFailed:
log(failedValidation);
tryToCorrect(inputRecords);
...
}
}
=>
function processInput(inputRecords) {
allRecordsAreValid = true;
for(inputRecord in inputRecords) {
recordIsValid = validateRecord(inputRecord, validators);
if(!recordIsValid) {
allRecordsAreValid = false;
break;
}
else {
itemToStore = createItemFromRecord(inputRecord);
itemsToStore.add(item);
}
}
if(allRecordsAreValid) {
result = store(itemsToStore);
}
else {
log(failedValidation);
tryToCorrect(inputRecords);
...
}
}
3.3.2 Сложнее выделить разную логику или при этом усложняется код.
Как правило, это может быть в случае однотипных вложенных циклов:
function findOrCreateTriadaOfEqual(firstArray, secondArray, thirdArray) {
result = null;
for(first in firstArray) {
for(second in secondArray) {
for(third in thirdArray) {
if(first == second && second == third) {
result = array(first, second, third);
GOTO Found:
}
}
}
}
equal = new Item();
result = array(equal, equal, equal);
Found:
log(result);
return result;
}
Вариантов все равно много:
- вынести в отдельную подфункцию с return из внутреннего цикла — самый простой
- обобщить — сделать рекурсивную функцию вида findEqualInArrays(arrayOfArrays, currentArrayIndex, currentFoundItemsArray);
- «if(result) break» — самый топорный:
result = null;
for(first in firstArray) {
for(second in secondArray) {
for(third in thirdArray) {
if(first == second && second == third) {
result = array(first, second, third);
break;
}
}
if(result)
break;
}
if(result)
break;
}
Это — единственный вариант, который смотрится хуже, чем GOTO, и GOTO даже понятнее. Но практически всегда есть и другие варианты.
Для оставшегося исчезающе малого процента случаев, когда других вариантов нет, нужно просто решить, что все равно можно сделать хотя бы флагами, зато гайдлайны будут проще — «Без GOTO!».
Резюме:
Важнее всего — сопровождаемость.
GOTO всегда ухудшает сопровождаемость, поэтому
нужно обходиться без GOTO — достаточно стандартных средств:
- обработка ошибок через исключения;
- декомпозиция — большой метод, решающий много задач, разбивается на маленькие, решающщие отдельные задачи;
- фиксация логики (вычисленных условий и выражений) в переменных с говорящими названиями.