понедельник, 14 сентября 2026 г.

[prog.thoughts] Мои личные заморочки с ранними return-ами

Прежде чем перейти к основной теме поста два важных дисклеймера:

  • во-первых, это мое личное мнение. Делюсь им, но ни в коем случае не настаиваю на его непогрешимости и, уж тем более, не утверждаю, что оно единственно правильное. Может быть кто-то из прочитавших сочтет его заслуживающим внимания и постарается относиться к return-ам более ответственно, чтобы таким как я было проще читать код;
  • во-вторых, я программирую, в основном, на C++, поэтому ниже будет C++ная специфика. Возможно, сказанное ниже гораздо менее критично для других языков программирования;
  • в-третьих, любой анонимный эксперт c LOR, RSDN или Habr вам убедительно объяснит, что программировать я не умею, да и вообще редкостный дебил. Посему ошибки у меня сплошь и рядом, в том числе и с плюс-минус-единичкой.

Итак, когда я только начинал учиться программированию в 1990-ом году, то нам говорили, что умные люди масштаба Никлауса Вирта рекомендуют иметь единственный return в функции. Типа одна точка входа -- одна точка выхода.

Понятное дело, что своя логика в этой рекомендации есть. И на момент ее появления (а это, могу предположить, 1970-е, если не 1960-е годы) данная рекомендация была гораздо более актуальна, чем сейчас. Но таки у ранних return-ов есть свои преимущества и можно сходу вспомнить пару ситуаций, где ранний return упрощает код.

Очевидный пример -- это проверка валидности аргументов функции/метода:

int copy_file(const std::string & src, const std::string & dest)
{
  if(src.empty()) // Ошибка: нет имени источника.
    return errors::empty_src_name;

  if(dest.empty()) // Ошибка: нет имени результата.
    return errors::empty_dest_name;

  ... // Далее уже непосредственно действия по копированию.
}

Этот код, имхо, гораздо понятнее, чем вот такой:

int copy_file(const std::string & src, const std::string & dest)
{
  int result = errors::unexpected_error;

  if(!src.empty())
  {
    if(!dest.empty())
    {
      ... // Далее уже непосредственно действия по копированию.
    }
    else // Ошибка: нет имени результата.
      result = errors::empty_dest_name;
  }
  else // Ошибка: нет имени источника.
    result = errors::empty_src_name;

  return result;
}

Или вот такой:

int copy_file(const std::string & src, const std::string & dest)
{
  int result = errors::unexpected_error;

  if(src.empty()) // Ошибка: нет имени источника.
    result = errors::empty_src_name;
  else if(dest.empty()) // Ошибка: нет имени результата.
    result = errors::empty_dest_name;
  else
  {
    ... // Далее уже непосредственно действия по копированию.
  }

  return result;
}

Еще один простой пример: отказ от выполнения действий, если объект находится в неподходящем состоянии. Допустим, у нас есть класс "Файл" в виде обертки над простым хэндлом, который записывает значения строго в BigEndian представлении. Что-то вроде:

class file_with_big_endian_data
{
  // Хэндл открытого файла. Значение -1 указывает, что файл не был открыт.
  int m_handle;
  ...
public:
  // Методы для записи различных типов значений.
  void write(std::span<const std::uint16_t> what);
  void write(std::span<const std::uint32_t> what);
  void write(std::span<const std::uint64_t> what);
  ...
};

В таком случае каждый метод write может иметь в начале проверку m_handle с ранним return-ом для того, чтобы ничего не делать, если файл не был открыт:

void file_with_big_endian_data::write(
  std::span<const std::uint16_t> what)
{
  if(-1 == m_handle)
    return;

  ... // Запись с перестановкой байт, если это необходимо.
}

Еще один пример подсказали в комментариях на LinkedIn: ранние return-ы для ситуаций, когда компилятор может избавиться от хвостовой рекурсии. Сам пример достаточно игрушечный, но для иллюстрации вполне себе норм. Привожу его в оригинале на Python-е (спасибо пользователю Konstantin Z.):

def classify(n, steps=0):
  if n < 0:
    return "negative"
  if n == 0:
    return f"reached zero in {steps} steps"
  if n == 1:
    return f"almost there, {steps + 1} steps"
  return classify(n - 2, steps + 1)

Тем не менее, не смотря на то, что у ранних return-ов есть (на мой взгляд) неоспоримые преимущества в тех или иных ситуациях, лично у меня с подобными return-ами не все так однозначно. Хотя, наверное, было бы правильно говорить не о ранних return-ах вообще, а об их применении в чужих кодовых базах, куда время от времени по рабочей нужде приходиться заглядывать.

Прежде всего лично мне гораздо удобнее разбирать прикладную логику в виде лесенки if-ов. Т.е. вот это:

if(first-condition)
{
  if(second-condition)
  {
    if(third-condition)
    {
      ... основные действия, ради которых все условия проверялись.
    }
  }
}

изучать гораздо проще, чем вот это:

if(!first-condition)
  return;

if(!second-condition)
  return;

if(!third-condition)
{
  ... основные действия, ради которых все условия проверялись.
}

Видимо это какие-то особенности моего восприятия (или проявления старческого слабоумия). Но лесенка из if-ов как бы сама уводит взгляд к кульминации и сама говорит "если вот это, и вот это, и вот это, то..." Тогда как ранние return-ы эту прямую последовательность к кульминации прерывают.

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

А вот если уходящую вправо лесенку из if-ов переписать в виде нескольких if-ов с return-ами, то лично для меня логика уже меняется и воспринимать замысел автора становится сложнее.

У лесенки из if-ов есть еще одно преимущество: нормально такая лесенка воспринимается пока в ней не больше двух-трех уровней. Но как только она становится глубже, то сразу видно, что код превращается в известную субстанцию. Хочешь не хочешь, а задумываешься о том, как переписать. Переписываешь и получаешь более приемлемый результат.

Тогда как с ранними return-ами такого критерия нет. Может быть функция на 40 строк, в которой 10 ранних return-ов. По сути это уже говнокод, но без столь явного запаха, как у лесенки из if-ов в 5-6 уровней глубины.

Но хуже всего, когда встречаются объемные функции, в которых ранние return-ы расположены в if-ах на разной глубине. Что-то вроде:

int do_something_important(int a, int b)
{
  if(!is_first_arg_valid(a))
    return results::error_code_1;

  if(!is_second_arg_valid(b))
    return results::error_code_2;

  bool need_update_cache = false;
  if(is_specific_arg_combination(a, b))
  {
    const auto cache_id = make_cache_id(a, b);
    if(has_result_in_cache(cache_id))
    {
      take_value_from_cache(cache_id);
      return results::result_from_cache;
    }
    else
      need_update_cache = true;
  }

  int prepare_result = prepare_for_change(a, b);
  if(prepare_result != results::ok)
  {
    if(prepare_result == results::pool_overflow)
    {
      remove_oldest_from_pool();
      prepare_result = prepare_for_change(a, b);
    }
    if(prepare_result != results::ok)
      return prepare_result;
  }

  commit_change(a, b);
  if(need_update_cache)
  {
    const auto cache_id = make_cache_id(a, b);
    const auto cache_update = update_cache_for(cache_id);
    if(cache_update == results::no_resources)
      return results::ok;
    else
      return results::ok_with_warnings;
  }

  return results::ok;
}

Когда подобный код написан не тобой и ты видишь его впервые, то уследить за всей логикой с return-ами мне лично сложно.

Еще хуже, когда в центре такой объемной функции будет цикл, внутри которого будут сочетаться и return, и break, и continue. И если мне скажут, что это перебор и такого не бывает, то увы, такое бывает. Хотя да, это перебор.

Но помимо чтения подобного кода иногда у меня возникает необходимость временно добавить в подобную функцию трассировку. В простейшем случае это может быть печать "do_something_important started" при входе и "do_something_important completed" при входе.

Когда у нас есть единственный return в конце, то нет ничего проще -- одна печать на входе, вторая на выходе. Но когда return-ов много, то...

...то приходится прибегать к созданию в начале функции объекта, который в своем конструкторе будет печатать "do_something_important started", а в деструкторе -- "do_something_important completed". Для языков, в которых детерминированного вызова конструкторов-деструкторов нет, подойдет та или иная реализация Go-шного defer-а.

Но это не помогает, если строчка с "do_something_important completed" должна содержать еще и какое-то значение (например, возвращаемое значение). Вставлять такую печать перед каждым return-ом ну так себе занятие. Приходится что-то изобретать.

А вот для единственного return-а это не проблема от слова совсем. Тут вообще все тривиально и ничего не нужно изобретать.

Ну и как-то мне с необходимостью делать подобные трассировки регулярно везет. То нужно построить трассу взаимных вызовов. То засечь время работы. Или и то, и другое сразу. Благо в C++ легко делать объекты в RAII-стиле. В какой-нибудь Java сложностей было бы побольше.


Что хочется сказать в завершении.

ИМХО, в современном мире правило "один вход -- один выход" -- это уже какой-то экстремальный экстрим. Непонятно зачем отказываться от ранних return-ов, если они делают код и компактнее, и проще, и надежнее (как элемент defensive programming), и (временами) быстрее (раскрутка хвостовой рекурсии).

Но и другая крайность, когда return-ы лепятся не приходя в сознание, не менее экстремальный экстрим. Потому что ведет к спагетти коду, разбираться в котором и сопровождать который можно пожелать только врагу.

Посему могу только попросить программистов думать при написании return-ов, чтобы не плодить их сверх меры.

Комментариев нет: