Принцип самурая

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

    Еще одной метафорой, или скорее принципом разработки, является «принцип самурая», призванный описать «контракт» между функцией и вызывающим ее кодом и заключается в следующем. Любая функция, реализующая некоторую единицу работы должна следовать тому же кодексу чести «бусидо», по которому живет любой самурай. Так, самурай не будет выполнять никаких заданий, противоречащих его «кодексу чести» и если к нему подойти с «непристойным» предложением, то он снесет вам башку раньше, чем вы успеете глазом моргнуть. Но если уж самурай возьмется за дело, то можно быть уверенным в том, что он доведет его до конца (**).




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

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

    Если функции переданы неверные входные параметры или она вызывается в некорректном состоянии – генерируйте исключение; если при выполнении своей работы внутренняя функция генерирует исключение, то это означает, что и ваша функция свою работу выполнить не сможет. В этом случае нужно либо пробрасывать исключение, либо завернуть его в другое исключение.

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

    Возвращение null object-а в случае возникновения исключений также является опасной практикой, поскольку вызывающий код просто не сможет определить, является ли пустой объект корректным значением, или же при его получении произошла ошибка:

    public SomeEntry ReadEntryById(int id)
    {
      try
      {
        // Читаем SomeEntry из базы данных
      }
      catch (Exception)
      {
        // Ядрёна кочарыжка! Как же вызывающему коду узнать,
        // была ли ошибка, или записи с таким id нет в базе?
        return null;
      }
    }


    * This source code was highlighted with Source Code Highlighter.


    Бывают случаи, когда функция может перехватывать исключение и не пробрасывать его вызывающему коду; это может быть функция самого высокого уровня и вызывающего кода может не быть. В остальных же случаях, функция может самостоятельно попытаться восстановиться после возникновения ошибки, но, в конечном счете, если эта попытка не увенчается успехом, то у нее не останется другого выхода, кроме как последовать принципу самурая – «сделать свою работу или умереть».

    --------------

    (*) За подробностями о том, что это за метафоры, обращайтесь к соответствующим заметкам: «Технический долг» и «Эффект второй системы», соответственно.

    (**) В большинстве статей о «принципе самурая» говорится только о второй части соглашения, что, дескать, любой метод, как и истинный самурай, должны либо выполнить свою работу, либо умереть. Но вызывающий код не является начальником самурая или его императором, которого самурай должен слушаться беспрекословно. Самурай, как и функция, не должны выполнять задания, противоречащие их «кодексу чести»; в отношениях между функцией и вызывающим кодом важно, чтобы обе стороны выполняли свои соглашения.

    (***) Принцип самурая не является революцией в разработке ПО; этому принципы следуют уже давно, причем некоторые делают это достаточно формальным образом с помощью проектирования по контракту. Если вы хотите познакомиться с понятиями предусловия и постусловия, то можно начать со статьи «Как не надо писать код», или же обратиться к целой серии статей, посвященных теме проектирования по контракту.
    Поделиться публикацией

    Комментарии 40

      +1
      по-моему круто. Кодекс чести функции :)
      А вообще сильно облегчает размышления по поводу «что делать с ошибками и исключениями». Для меня это пока мутная тема, привык к С-подобной обработке, когда возвращается код ошибки или null какой-нибудь.
        +2
        По-моему, это называется контрактным программированием — проверкой предусловий и иногда постусловий. В Qt я проверяю входные значения static assert'ами; в библиотеке boost, вроде, есть собственная реализация ассертов, а в некоторых языках даже нативная поддержка контрактов.
          +2
          Кстати, по моему, это называется точно так же. Я ж в сносках черным по русскому об этом и пишу:) И там даже ссылка на цикл статей о проектировании по контракту и даже с примерами нативной поддержки;)
            0
            Я раньше тоже пользовался ассертами, но после пары случаев, когда в дебаге всё работало, а в релизе у клиента программа падала из-за того, что передавались какие-то не такие данные (а ассерты из релиза выбрасываются) — перешел на проверку условий без ассертов.
              +3
              Контракты != Ассерты. Их можно рассматривать как ассерты на стероидах. Контракты предусматривают, что во время компиляции можно указать, будут ли предусловия преобразовываться в ассерты, в генерацию исключения или они вообще будут выкинуты из кода.
                +1
                Это лишь значит, что вы не учли всех возможных неправильных данных. Впрочем, их там много может быть, да… Вообще, asserts — это лишь поручень для подстраховки, когда идешь по узкому карнизу. Без поручня оно как-то боязно, из-за чего и падаешь, а с ним — хоть сто метров пройти можно. Для ранней разработки asserts незаменимы, поскольку локализуют ошибки с точностью до функции, а иногда даже — до ошибки. Но куда важнее продумать и реализовать архитектуру, настолько хорошую, насколько это возможно: значительная часть ошибок будет отсеяна автоматически.
                0
                Для таких случаев часто применяются guard-классы шаблона GuardClause для пре/пост валидации. Для C# есть целые библиотеки напр. CuttingEdge.Conditions
              0
              Получается этот принцип отличается от Контрактного Программирования тем, что нужно обязательно «умирать» при невозможности выполнения.
              P.S. Вы же писали на хабре про технический долг и эффект второй системы, давали бы уже ссылки на хабрастатьи :) В одном месте читать удобнее.
                0
                На самом деле, в контрактном проеграммировании помимо понятия предусловия, есть и постусловие. Просто контракты умеют динамически включать и отключать эти проверки. Сам же смысл остается неизменным.

                По поводу P.S.: ок, подумаю.
                +1
                В этом случае нужно либо пробрасывать исключение, либо завернуть его в другое исключение.


                Если пробрасывать не заворачивая, то код более высокого уровня будет получать низкоуровневые ошибки о которых он знать не должен по определению.
                  0
                  Заворачивая исключение в другое мы теряем информацию, но не очевидно, что приобретаем. В чем опасность того, что летят низкоуровневые исключения? (Если вы про Java, то я говорю только про unchecked exception)
                    0
                    Сори, я чего-то недопонял. Если мы «заворачиваем» исключение, то во вложенном исключении находится исходное исключение. Где мы чего теряем, я не знаю. Потенциально же, мы можем потерять информацию если пробросим *другое исключение* и не оставим информации об исходном. Иногда лучше делать так, иногда — эдак. В каждом конкретном случае нужно решать отдельно.
                      0
                      Ага, нужно, но, мне кажется, что по умолчанию заворачивать более неудобно и опасно.

                      1. Неудобно.
                      Если исключение, которое ты ожидаешь поймать в catch завернуто, то нужно ловить все, которые могут его завернуть, разворачивать их (а тут может быть не один уровень вложенности) и проверять is'ом.

                      2. Опасно.
                      a) когда я передаю делегат, который может кинуть мое исключение, в библиотеку, по умолчанию, я не предполагаю, что библиотека может завернуть его, следовательно, мой catch не сработает и программа будет работать некорректно.

                      b) нужно перехватывать *все* исключения, которые могут завернуть мое, и знать как они заворачивают, а на этапе компиляции этой информации запросто может не быть.
                      0
                      Смысл заворачивать исключение в том, что обработчик исключения может ловить определенные exceptions, зная точно его тип (ему нет надобности знать другие типы). А данные исходного исключения потом использовать при необходимости, напр. для информирования, что именно произошло на низком уровне.
                      Такой подход в DevExpress мы применяли для планировщика при парсинге из iCalendar-файлов (пример).
                      Свойство OriginalException там как раз исходное исключение в более высокоуровневом iCalendarParseException.
                    +1
                    Использую такую систему в одном web-проекте. Любая ошибка (не найден шаблон, mysql не отвечает и т.п.) считается фатальной: функция с нехитрым названием ke_bugCheck заваливает программу и выводит* сообщение c backtrace, многим похожее на BSOD:


                    * — на продакшене подробные сообщения сохраняются как html в каталоге, недоступном извне, а пользователю выводится сообщение без подробностей.
                      +1
                      А как сопоставляете html файлы с пользователями?
                        0
                        Файлу дается имя в таком виде: timestamp-IP.html, например 4e6ee387-127.0.0.1.html.
                        +5
                        мрачновато у вас на проекте
                        +2
                        Этот «путь» далеко не все возможные ситуации. Банальный пример: какая-нибудь функция parseDocs(), которая обрабатывает «пачку» данных, например, парсит N документов. Если один документ имеет неправильный формат, валить всю функцию? Глупо. Сообщить вызывающему коду об ошибке, конечно, надо, но не эксепшеном, который просто сообщит «что-то где-то пошло не так». В строготипизированных языках вроде Haskell есть специальные «обёртки», которые позволяют вернуть либо «упакованное» значение, либо метку «Ничего»:

                          +1
                          (ох уж эти мне быстрые клавиши)

                          [...] либо «упакованное» значение, либо метку «Ничего»:

                          Maybe = Just x | Nothing

                          Причём управляющий код будет обязан проверить возвращаемое значение и в случае чего соответствующим образом обработать его. При таком варианте parseDocs() может спокойно вернуть коллекцию таких «обёрнутых» объектов и предоставить (и заставить) управляющий код решать, что с ними делать.
                            +1
                            Да, функциональные шняжки, в частности более строгая типизация null-значений могут здорово помочь при обработке ошибок. В большинстве же ОО языков (насколько я знаю, такая поддержка есть только в Eiffel), возврат null-ов — путь к беде.

                            В целом согласен. Но для решения этой проблемы в ОО языке достаточно вашу функцию разбить на 2: одна разбирает один объект/файл и бросает исключение, а вторая функция юзает первую и сама решает прерываться ей, если при обработке i-го файла произошла ошибка, или продолжать выполнение.
                              0
                              В общем-то, это как раз и был пример функции, которая не должна валиться если какая-то её часть (вызов другой функции или просто код, в данном случае неважно) бросила исключение. Эта функция может не быть функцией верхнего уровня и не уметь восстанавливаться (полностью), но при этом и пробрасывать исключение дальше она не имеет права. Она должна сама тем или иным образом позаботиться о сохранении управляющего класса в состоянии инварианта.
                            +2
                            Есть еще одна стратегия, которая называется «Заменить неудачу списком успехов». В этом случае функция возвращает список хороших исходов, и если он пуст, значит, произошла какая-то ошибка.
                              0
                              чем это отличается от возвращения null?
                                0
                                Особо ничем, кроме реализации. Но зато не нужно держать специальную константу (null), обозначающую fail. В языках вроде Haskell это становится удобным, поскольку можно использовать pattern matching — сопоставление с шаблоном. И, кроме того, пустой список — замечательная (и часто единственная) база для рекурсии.
                              +2
                              Кстати, другой интересной реализацией системы обработки ошибок является система кондишенов в Common Lisp. Кондишены похожи на эксепшены, но они не раскручивают стек, позволяя управляющему коду решить, что делать с ошибкой, не выходя из текущей функции.
                              0
                              Данный принцип добавляет очень важное свойство функции — однозначность.
                              Если результат получен, значит он валидный.
                              Если не получен — «надо что-то менять».
                                0
                                Кстати, менять что-то совсем не обязательно. Главное, сказать вызывающему коду, что вы свою работу не смогли сделать, а не утаивать это:)

                                Тут, кстати, можно интересную параллель провести между функциями и работниками. Ни те, ни другие не любят говорить другим (и, в частности, руководству) о своих неудачах.
                                  0
                                  Согласен, в данном случае я использовал «надо что-то менять» как цитату, а не руководство к действиям.

                                  Насчёт работников тоже соглашусь, так как постоянно сталкиваюсь с ситуацией, когда работника приходится «пинать», чтобы добиться от него ответа — так и функции есть, которые приходится ещё по несколько раз «пинать», чтобы понять, в чём же проблема.
                                +1
                                >«если при выполнении своей работы внутренняя функция генерирует исключение, то это означает, что и ваша функция свою работу выполнить не сможет»

                                Нифига такого это не значит. Я вполне могу обработать это исключение и попытаться найти другой путь решения задачи. Например, я запускаю какой-то видеоконвертор для перекодирования видео, он кидает исключение (не понимает входной формат), я запускаю другой — и он срабатывает.
                                  –1
                                  /**
                                  * @return null, если что-то не так или нет в базе, хотите подробностей пишите сами, благо не final
                                  */
                                  public SomeEntry ReadEntryById(int id){
                                  }
                                    +3
                                    ИМХО, фиговый контракт. А как узнать, что не так? Как по мне, самый простой способ — это использовать самый простой способ:)) Можешь получить данные — верни их, а если произошла какая-то хрень, то, не стесняйся и скажи об этом.
                                      0
                                      Я к тому что бывает преждевременная оптимизация, а бывает преждевременная валидация. Сейчас всем расскажете про контракты, а мне потом все методы в try catch оборачивать, пишите лучше комментарии к функциям.
                                        +1
                                        Значит фигово я про контракты рассказываю:)) В контрактах в .net-е при их нарушении специально бросаются «внутренние» исключения, которые невозможно оборачивать. Нарушение контракта — это баг, который можно исправить только путем исправления кода. Блоки же try/catch (кроме блоков самого высокого уровня) предусматривают возможность восстановления состояния класса/системы, что сделать, в случае нарушения контракт *невозможно*.
                                    0
                                    Изложенный принцип является частным случаем следования одной из гарантий безопасности исключений Абрахамса (базовой, строгой или отсутствия) а также призывает (неясно в каких случаях) обеспечивать нейтральность по отношению к исключениям.

                                    Обеспечение одной из гарантий является обязательным требованием для любого метода при написании хорошего кода, а нейтральность к исключениям требуется в основном в библиотеках. На практике, если функция обладает достаточными знаниями чтобы обработать исключение то она должна это сделать, иначе — преобразовать или прокинуть исключение наружу.
                                      +1
                                      На всяк случай напомню, что о существовании приведенной ссылки я знаю, ибо я и есть автор этой статьи:))

                                      И, да, о с последним абзацем согласен, ибо об этом же явно в текущей статье написано.

                                      Главное, речь в том, что пустой блок catch, в большинстве случаев нифига не хороший способ «обработать исключение»:

                                      try
                                      {
                                      // Чего-то делаем
                                      }
                                      catch {}


                                      А им, к сожалению, очень часто пользуются.
                                        0
                                        >На всяк случай напомню, что о существовании приведенной ссылки я знаю, ибо я и есть автор этой статьи:))

                                        Точно! У меня как раз эта статья в голове вертелась при прочтении топика :)
                                      0
                                      «Так, самурай не будет выполнять никаких заданий, противоречащих его «кодексу чести»»

                                      Насколько я читал, самурай выполняет все приказы своего сюзерена, а уже потом, если что не так, делает сеппуку.
                                        0
                                        Да, но внешний (читай чужой) код не является хозяином для функции. Именно поэтому я и сделал оговорку, по поводу того, что считать внешний код — хозяином самурая или его императором — не честно.

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

                                      Самое читаемое