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