image

Недавно мне попалась статья Against Best Practices, и в целом я согласен с посылом этого поста. Но у меня были и собственные мысли на эту тему, поэтому изложу их здесь.

Даже не особенно углубляясь в историю разработки ПО, вы легко найдёте манифест в жанре Considered Harmful («Считается вредным»), самый знаменитый из которых составил легендарный учёный-информатик Эдсгер Дейкстра. Другая распространённая аналогия таких документов в духе времени — это «наилучшие практики». Это не менее субъективный кодекс подобных законов, которым зачастую критически не хватает такой обоснованности, как у манифестов из первой категории. Притом, что, на мой взгляд, и первые, и вторые имеют право на существование, их важно понимать в контексте, так как без контекста их значение легко размывается.

Наилучшие практики


Начиная работать с любой новой технологией, легко допустить такие ошибки, которые покажутся грубыми любому эксперту. При обучении это естественно, в этом нет ничего плохого. Наилучшие практики призваны отвести вас от этих ошибок, которые обычно совершаются в самом начале. Мне нравится представлять, что программирование с использованием таких практик напоминает езду на четырёхколёсном велосипеде: на первых порах они незаменимы, но рано или поздно вы научитесь держать равновесие без них. Таким образом, наилучшие практики на самом деле не должны восприниматься как догма, а просто помогать при обучении. Рано или поздно вы должны эти практики перерасти.

Хочу пояснить: я не утверждаю, что, строго придерживаясь рекомендаций, вы напишете плохой код. Я считаю, что, опасаясь выйти за рамки рекомендованных практик, вы не сможете как следует понять те базовые причины, по которым они сформировались. Это, в свою очередь, мешает вам учиться. Хороший вывод, который можно было бы из этого сделать — можно изучить странности инструмента, стараясь понять, чем обоснованы «наилучшие практики» работы с ним.

Считается вредным


В некоторых манифестах утверждается, что конкретные языковые возможности сами по себе проблемные. Для этих манифестов характерна общая проблема: сначала они выдвигают тезис, а затем пытаются его обосновать. Поэтому их авторы тиражируют сенсационный тезис, и эти цитаты нарастают как снежный ком. Например, легко отказаться от использования goto, если у вас в голове зашито, что оператор «goto» считается вредным. Разумеется, пишутся такие эссе добросовестно и из лучших побуждений, поэтому выдвигаемый в них тезис защищают на языке разумных аргументов. Но такой подход мешает чётко связать следствие your (напр., «не используйте goto») с причиной (лишь при злоупотреблении goto получается плохой код).

Причём, я полностью купился на эту ловушку! В самом деле, я внимательно прочитал пресловутый текст Go To Considered Harmful, только когда готовил этот пост. Давайте подробно разберём аргументы Дейкстры, чтобы лучше понять, в каких случаях можно (и даже следует) использовать goto.

Разбор кейса: как обращаться с Goto


Базовые идеи Дейкстры – достойные и обоснованные. Если коротко, при использовании инструкций goto вы рискуете написать спагетти-код. Притом, что я не решусь спорить с Дейкстрой об этом, думаю, важно понимать, в каком контексте он высказал эти аргументы. Полагаю, Дейкстру волновала, прежде всего, неизбежная обфускация кода, возникающая при распространении таких неструктурированных переходов. Кстати, это одна из причин, по которым так сложно даётся программирование на ассемблере, если ты привык писать только на высокоуровневых языках. Выражаясь словами самого Дейкстры:

«Следует прилагать максимальные усилия, чтобы сократить концептуальный разрыв между статической программой и динамическим процессом».

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

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

Пример 1


Вот элементарный пример.

#include <stdio.h>
#include <string.h>

// В имени целенаправленно допущена обфускация!
int foo(const char *str, const char *sub) {
    // Если строка пустая, то считать ничего не требуется
    if (strlen(sub) == 0)
        goto done;

    // Инициализируем счётчик
    int count = 0;

next:
    // Ищем подстроку
    str = strstr(str, sub);
    // Если она больше не находится, значит, дело сделано
    if (str == NULL)
        goto done;
    // Увеличиваем значение счётчика
    count++;
    // Обработали это включение и идём дальше
    str += strlen(sub);
    // Продолжаем поиск
    goto next;

done:
    return count;
}

int main(void) {
    // К этому моменту вы, наверное, уже успели провести синтаксический разбор `foo()` и понять,
    // что она делает. В этой точке вызова функции можно спросить:
    //
    // Сколько раз последовательность "llo" содержится в предоставленной строке?
    printf("matches: %d\n", foo(
        "Hello, my fellow, why do you bellow?",
        "llo"
    ));
}

Вы заметили, в чём проблема? Верно! В строке 8 инициализация count пропускается, если эта строка пуста, но в строке 27 мы всё равно возвращаем return count. Именно в такую западню легко попасть при использовании goto: пропустить инициализацию переменной.

Уже представляю, как мне скажут: «именно для этого и нужны циклы», но суть не в этом! Подобные ситуации могут возникать в результате рефакторинга, которым в течение нескольких лет занимается сразу множество разработчиков. Базовая проблема в том, что, кто бы ни читал этот код, не следует заставлять его мысленно проследить каждую ветку, чтобы иметь представление, а разумно ли написан код. Напротив, такая разумность должна гарантироваться на уровне самой структуры кода. Должен быть правильно устроен сам поток управляющих конструкций в языке.

Возможно, именно поэтому Дейкстра заявляет, что «оператор go to нужно искоренить во всех «высокоуровневых» языках программирования? Нет, достаточно просто почитать чуть дальше, и он сразу подсказывает, в каком контексте понимать это утверждение — «во всех, кроме, пожалуй, чистого машинного кода». Ах! Даже Дейкстра признаёт, что в определённых контекстах (а именно, при программировании на ассемблере) goto-подобные структуры приемлемы и даже необходимы.

Пример 2


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

#include <stdio.h>
#include <stdlib.h>

int main(int argc, char *argv[]) {
    FILE *file = NULL;
    char buffer[4096];
    const char *error = NULL;

    // Убедимся, что был предоставлен файл с вводом
    if (argc == 1) {
        goto missing;
    }

    // Открыть файл
    file = fopen(argv[1], "r");
    if (file == NULL) {
        error = "could not open file";
        goto failure;
    }

    // Построчно считываем файл
    while (fgets(buffer, sizeof(buffer), file) != NULL) {
        // Выводим в консоль каждую строку отдельно
        printf("%s", buffer);
    }

    // Проверяем, нет ли ошибок считывания
    if (ferror(file)) {
        error = "could not read file";
        goto cleanup;
    }

cleanup:
    // Обрабатываем ошибки и при необходимости закрываем файл
    if (file != NULL) {
        if (fclose(file) != 0) { // fclose can fail
            error = "could not close file";
            goto failure;
        }
    }

    // Выходим в случае возникновения какой-либо критической ошибки
    if (error != NULL) {
        goto failure;
    }

    // В противном случае аккуратно возвращаемся
    return EXIT_SUCCESS;

missing:
    // Если отсутствует входной файл — обрабатываем этот случай
    error = "missing input file";

failure:
    fprintf(stderr, "error: %s\n", error);
    return EXIT_FAILURE;
}

Эта программа выводит в консоль предоставленный файл. Притом, что он совершенно прост, в нём есть несколько точек отказа, после попадания в которые приходится выполнять очистку и выходить с сообщением об ошибке. Здесь goto как нельзя кстати, поскольку препятствует повторению кода очистки, а позволяет непосредственно перейти на путь к ошибке, как только она возникнет. В отличие от бесцельных прыжков по спагетти-коду, в данном случае получается переход, благодаря которому гораздо проще судить о коде.

Здесь мы подходим к самому, на мой взгляд, значимому пункту этого эссе:

Оператор go to [sic] в чистом виде просто слишком примитивен.

Я уже отчасти намекал на это, когда разбирал пример 1 и писал, что это просто цикл. Учитывая, что циклы есть у нас в распоряжении просто как языковые конструкции, почему бы ими не пользоваться?! Если бы мы изменили код на цикл while (str != NULL) {… }, то будет совершенно не сложно понять все переходы в программе. Пример 2 можно аналогичным образом «исправить» при помощи оператора defer. Этот оператор гарантирует, что при развитии ситуации по неблагоприятному сценарию файл закрывается, и такая формулировка яснее указывает, что за ней последует goto cleanup.

В сущности, аргумент Дейкстры сводится к тому, что именно из-за отсутствия неявной семантики у goto этот оператор так сложно использовать. Я думаю, именно в этом заключается истинный смысл его знаковой статьи. Но из заголовка это понять сложно, поскольку движение «Considered Harmful», вдохновлённое этой статьёй, в основном направлено на искоренение оператора goto любыми средствами.

Заключение


Думаю, основная концепция, о которой нам удалось поговорить в этой статье — это идиоматичность. В конечном счёте, как рассуждения о «наилучших практиках», так и эссе в жанре «Considered Harmful» призваны помочь сообществу кодифицировать общие представления о том, как должен выглядеть идиоматичный код. Если сформулировать проблему таким образом, то она касается не столько строгих правил, сколько идеалов. Это хорошо, поскольку никто не требует строго стремиться к идеалу, и допускается иногда от них отказываться, если такой отказ хорошо обоснован.

В данном случае принципиально важно, что идиоматический код зависит от контекста! Например, в Rust применялись бы разные идиомы для обработки ошибок, в зависимости от того, где эти ошибки найдены — в приложении или в библиотечных пакетах. В приложениях вполне оправданно может быть применение panic!, unwrap или спонтанного возврата при помощи anyhow::Error, сопровождаемого простым сообщением. Но в библиотеках паники, как правило, недопустимы (если такое происходит — это, скорее всего, баг). Вместо этого библиотека должна возвращать ошибки, которые можно изучить, а затем обработать в коде приложения (например, в виде перечисления с вариантами, описывающими различные причины ошибок).

Учитывая всё вышесказанное, лучше не пропагандировать беспрекословное следование практикам или манифестам, а делать упор на понимании языковых идиом.

P.S. Обращаем ваше внимание на то, что у нас на сайте проходит распродажа.

Комментарии (2)


  1. JordanCpp
    22.11.2024 13:58

    Опять/снова goto...


  1. JordanCpp
    22.11.2024 13:58

    Примеры в статье это си код. Но в теге указан С++