Прежде чем перейти к основной теме поста два важных дисклеймера:
- во-первых, это мое личное мнение. Делюсь им, но ни в коем случае не настаиваю на его непогрешимости и, уж тем более, не утверждаю, что оно единственно правильное. Может быть кто-то из прочитавших сочтет его заслуживающим внимания и постарается относиться к 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-ов, чтобы не плодить их сверх меры.