вторник, 13 сентября 2011 г.

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

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

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

clip_image002

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

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

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

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

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

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

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

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

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

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

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

25 комментариев:

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

    ОтветитьУдалить
  2. @Sergo: спасибо за спасибо:) Приходите еще;)

    ОтветитьУдалить
  3. Как знать null значение или ошибка.
    Просто !

    Использовать монаду Maybe или любой аналог :)

    ОтветитьУдалить
  4. Спасибо, про самурая - порадовало:). Однако, есть моменты которые как мне кажется остались "за кадром".
    1) public void Foo(correct parameters)
    {
    try
    {
    }
    catch(InternalException ex)
    {
    return new OutgoingException(ex)
    }
    }
    2)
    public void Foo(correct parameters)
    {
    try
    {
    }
    catch(InternalException ex)
    {
    throw;
    }
    }
    3)
    public void Foo(correct parameters)
    {
    try
    {
    }
    catch(InternalException ex)
    {
    //nothing bad happened (EOF for example).
    }
    }
    Все три варианта - рабочие. Т.е вполне корректные. И тут уже "максимализм" по сносу головы не проходит. Надо разбираться в каждом конкретном случае. В общем, не все так просто... :)

    ОтветитьУдалить
  5. @NN: а как монада Maybe может понять, почему у нас нет результата? Из-за ошибки (и из-за какой) или это нормальный результат.

    @eugene:
    1) и 2) описаны. Я сказал, что нужно либо пробрасывать исключение, либо оборачивать его в другое и бросать новое.

    3) тоже описан. Было сказано, что код "либо сам обрабатывае исключение, либо падает".

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

    Так что все именно так, как описано. Ты либо делаешь свою работу (а вот это уже методу решать, что значит сделать свою работу) либо упасть.

    ОтветитьУдалить
  6. Возвращение null object-а
    Возвращение nullа.
    Null Object это нечто другое: http://en.wikipedia.org/wiki/Null_Object_pattern

    ОтветитьУдалить
  7. @Oleg: да, конечно, null и null-object - это разные вещи.

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

    В этом они "схожи".

    ОтветитьУдалить
  8. Для етого использую Microsoft.Contracts:

    void SomeMethod(SomeType p1, SomeType p2, SomeListType l1)
    {
    Contract.Requires(p1 != null);
    Contract.Requires(l1.Count > 0,
    "List should not be empty.");

    Contract.Requires(Contract.ForAll(l1, e => e != null),
    "List item should not be null");

    Contract.Ensures(l1.Sum(s => s.Amount)) == p2);
    }

    .............
    method body
    .............
    }

    ОтветитьУдалить
  9. @snn: да по сути, на протяжении всей заметки сквозит контрактами, предусловиями и постусловиями. И даже в конце дал ссылку на свои статьи по теме Design by Contract.

    ОтветитьУдалить
  10. Минимализм красив, но не практичен.

    ОтветитьУдалить
  11. @Никита: я бы сказал, что зачастую, минимализм весьма даже практичен, поскольку он идет рука об руку с простотой (читай, отсутствием излишней сложности), что обычно является большим залогом расширяемости нежели большинство других хитроумных решений.

    ОтветитьУдалить
  12. Согласен с вашим высказыванием, но исключительно в одном случае. Это случай когда разработкой приходится заниматься одному. Просто по мне, минимализм не всегда вызывает решение каких то задач, скорее упрощение. А так, я полностью разделяю вашу идею, так как по мне, проще изобрести велосипед и держать его под контролем. Нежели привинчивать пару-тройку библиотек сторонних. Или наоборот, добавлять библиотеки, но не изгаляться контролем версий. Или и то и другое, но без кроссплатформенности. В общем критериев куча. А самая коварная тема на мой взгляд - это система отлова ошибок. В этой области полнейший бардак как для кроссплатформенности, так и для реализации с выдержкой чистоты С++ и ООП. Здесь, минимализм может стать еще одной проблемой конструирования системы. Помимо сложностей, надо еще следовать правилам отказа от избыточности, стилевого оформления кода и мн.др.

    ОтветитьУдалить
  13. Сергей, а что вы думаете относительно исключений в методах веб-служб?
    Нужно ли их оборачивать в специальные объекты и возвращать как результат работы или выкидывать исключение как есть?

    ОтветитьУдалить
  14. @Алексей: в WCF есть понятие Fault-ов, которые, по сути, являются частью контракта (они несколько похожи на спецификацию исключений в C++/Java).

    Вроде бы веб службы тоже поддерживают fault-ы, поскольку они специфицированы в протоколе SOAP (гуглить на предмет SOAP Faults).

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

    ОтветитьУдалить
  15. @Алексей: вот толковое обсуждение этого вопроса на StackOverflow: SOAP faults or results object?

    ОтветитьУдалить
  16. Здравствуйте!
    А можно ли при ловле исключительных ситуаций в блоке catch исправлять ,если можно, значения ?
    Например,у меня была ситуация, когда при добавлении новой записи валилось исключение,потому что у записи несколько полей было NULL и в базу оно не могло добавиться.

    тогда я просто изменил NULL поля на "" .
    Верно ли это с точки зрения функции-самурая?

    ОтветитьУдалить
  17. Здравствуйте!
    А можно ли при ловле исключительных ситуаций в блоке catch исправлять ,если можно, значения ?
    Например,у меня была ситуация, когда при добавлении новой записи валилось исключение,потому что у записи несколько полей было NULL и в базу оно не могло добавиться.

    тогда я просто изменил NULL поля на "" .
    Верно ли это с точки зрения функции-самурая?

    ОтветитьУдалить
  18. @Konstantin: вообще говоря, этот подход не нарушает принцип самурая, но и назвать его подходящим мне тоже сложно.
    Чего-то мне кажется, что в этом случае используются исключения для управления потоком исполнения, что должно выполняться другими средствами.

    Я бы, например, предложил сделать метод Validate, который бы проверял, что с данными что-то не так и только после валидации уже вызывал бы метод сохранения дата объекта.

    А так это напоминает такой код:

    try {
    foo(arg);
    }
    catch(ArgumentNullException)
    {
    foo(defaultArg);
    }

    ОтветитьУдалить
  19. Да, примерно так у меня в коде и есть:)

    ОтветитьУдалить
  20. А чем плох возврат из функции экземпляра следующего класса:
    class Result{
    public T ActualResult{get;private set;}
    public int? Code {get;private set;}
    public string Description {get; private set;}
    public int? ErrorSource {get; private set;}

    //Несколько удобных перегруженных конструкторов
    }
    Это возвращает всё, что может быть нужно из 99% функций. И нет выброса исключения, которое работает как GoTo, только ещё ко всему прочему и без метки (по выражению Липперта, кстати).

    ОтветитьУдалить
    Ответы
    1. Илья, подобный код возврата плох тем, что никто не может гарантировать того, что я не обращусь к ActualResult, когда его на самом деле не будет:)
      В функциональных языках используется именно этот подход, путем использования discriminated union, которые умеют представлять два взаимоисключающих результата: результат ИЛИ данные об ошибке.

      Без подобных средств всегда остается вариант неверного использования.

      З.Ы. А как пользоваться этим классом в случае конструкторов?

      З.Ы.Ы. Я не помню, чтобы Липперт был против исключений. Вроде как еще камрад Страуструп положил конец этим обсуждениям даже в С++, а в таких языках как Java или C#, именно исключения - это стандарт де факто и де юре в обработке ошибок.

      Удалить
    2. Хорошее замечание про discriminated unions, спасибо.

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

      Кстати, он у нас структурой сделан. Не класс. Чтобы не было соблазна null вернуть.
      Если вы обращаетесь к ActualResult а там null, то обрабатываете как и обрабатывали бы раньше возврат при отсутсвии класса Result. Это предмет соглашения в команде. Если возврат не устраивает, то смотрим код и обрабатываем как надо.
      Я полагаю, что коды возвратов отнюдь не мертвы. Если пользоваться только исключениями, то можно встретить ту ситуацию, когда будет генерироваться большое количество исключений в цикле, редко, но метко.

      2. В каком смысле "как пользоваться этим классом в случае конструкторов"? Если рухнул конструктор, или вы что-то другое имели ввиду?

      3. Липперт не против) Он за разумное использование. Видимо использование "разума" это главное отличие хороших программистов от "тупо следователей всем прописаным канонам" :)

      Я сам люблю исключения, но считаю, что весь код, который может напороться на исключения должен быть покрыт тестами (или контрактами, которые вы любите). В противном случае, приложение с гарантией будет падать.

      Ссылка на Липперта. Посмотрите комментарии в принятом ответе.
      http://codereview.stackexchange.com/questions/2429/is-catching-expected-exceptions-that-bad (прошу прощения, я далёк от вэба и от ведения блогов, поэтому не знаю как ссылку нормально проставить :) )

      Exceptions are slow. Exceptions are noisy when you're debugging. Many high-reliability products are configured to log all exceptions and treat them as bugs. (I once added a by-design exception on a common control path in Active Server Pages and boy, did I hear about that the day afterwards, when suddenly their error logs had several hundred thousand new entries.) Exceptions are intended to represent exceptional situations, not common situations.

      Even if exceptions were cheap and so on, they'd still be a lousy control flow best left to exceptional circumstances. An exception is a goto without even a label. Their power comes at an extremely high cost; it becomes impossible to understand the flow of the program using only local analysis; the whole program must be understood. This is especially true when you mix exceptions with more exotic control flows like event-driven or asynchronous programming. Avoid, avoid, avoid; use exceptions only in the most exceptional circumstances, where the benefits outweigh the costs.

      4. Кстати, Сергей, а когда вы вызываете File.Open какие типы исключений вы ловите?

      Удалить
    3. > 4. Кстати, Сергей, а когда вы вызываете File.Open какие типы исключений вы ловите?
      Очень зависит от того, в каком контексте я вызываю File.Open.
      Очевидно, что это лишь один из шагов для решения более высокоуровневой задачи, например, конфигурирования приложения, открытие файла для импорта, обработки данных из файла и т.п. При этом, работа с файлом может идти в интерактивном режиме (из приложения с пользовательским интерфейсом) или из серверного кода.

      При реализации этой "более высокоуровневой" функциональности можно прикинуть, какие ожидаемые отказы могут быть при работе с файлами. Двумя самыми простыми кажутся 1) отсутствие файла и 2) отсутствие прав доступа. Если это так, то в месте работы с файлом можно отловить эти исключения и бросить какие-то специфические более высокоуровневые исключения.

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

      Удалить
    4. Проблема заключается также в том (и я лично на это напарывался много раз), что мы не видим все типы исключений, которые могут быть выброшены, скажем, из того же File.Open. Ну, нет их в xml-комментариях. Пример из жизни. Открываю в desktop UI-app обыкновенный файл, лежащий на диске. Сначала в продакшн вышла версия с катчем FileNotFound. Приложения слегло с каким-то другим эксепшном. Затем я добавил (вроде это был InvalidOperationException) катч InvalidOperationException. В третий раз приложение слегло с новым типом эксепшна. Я добавил третье. Во всех вышеприведённых случаях приложений не должно было падать. Ничего критичного для работы всего приложения не случалось. Но оно падало, потому что не были отловлены нужные типы исключений, про возможность которых программист и не догадывался. Это не к тому, что эксепшн - зло, это к тому, что как минимум все методы public API обязаны уведомлять ОБО ВСЕХ типах исключений, которые могут быть выкинуть изнутри. Иначе мы сталкиваемся с невозможность написания устойчивых приложений. Как выход мне нравится перехват всех исключений, фильтрование внутри катча на типы самых злостных исключений и падёж в случае, если мы столкнулись со злостным (WeDontHaveMuchMemoryException lol), проброс дальше по стеку в противном. Однако эта практика считается мелкософтом не тру!))) Почему пока не разобрался)

      Удалить
    5. Илья, описанная проблема не является проблемой.
      Смотрите, вы пишите, что все открытые методы обязаны уведомлять ОБО ВСЕХ типах исключений. В большинстве случаев это все делается, но есть одно "но": вы не знаете, какие типы используются внутри в закрытых частях класса.
      Решением проблемы может быть использование спецификации исключений как в Java, но ведь и там уже давно отказались от их использования и используют непроверяемые исключения.

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

      Удалить