Вернуть магическое значение, выбросить исключение или вернуть ложь при неудаче?

83

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

Я думаю, что есть три возможных решения для такой относительно неисключительной ситуации, чтобы указать сбой в C # 4:

  • вернуть магическое значение, которое иначе не имеет значения (например, nullи -1);
  • бросить исключение (например KeyNotFoundException);
  • вернуть falseи предоставить фактическое возвращаемое значение в outпараметре (например, Dictionary<,>.TryGetValue).

Итак, вопросы: в какой неисключительной ситуации я должен выбросить исключение? И если я не должен бросать: когда возвращается волшебное значение, предложенное выше, реализующее Try*метод с outпараметром ? (Мне outпараметр кажется грязным, и это больше работы, чтобы использовать его правильно.)

Я ищу фактические ответы, такие как ответы, включающие рекомендации по проектированию (я не знаю ничего о Try*методах), удобство использования (как я спрашиваю об этом для библиотеки классов), согласованность с BCL и удобочитаемость.


В библиотеке базовых классов .NET Framework используются все три метода:

Обратите внимание, что, как Hashtableбыло создано в то время, когда в C # не было обобщений, он использует objectи поэтому может возвращаться nullкак магическое значение. Но с дженериками, исключения используются в Dictionary<,>, и первоначально это не имело TryGetValue. Видимо, понимание меняется.

Очевидно, что Item- TryGetValueи Parse- TryParseдвойственность есть причина, поэтому я полагаю , что бросать исключения для неисключительных отказов в C # 4 не сделали . Однако Try*методы не всегда существуют, даже когда они Dictionary<,>.Itemсуществуют.

Даниэль А.А. Пельсмакер
источник
6
«исключения означают ошибки». запрос словаря для несуществующего элемента - ошибка; Запрос потока для чтения данных, когда это EOF, происходит каждый раз, когда вы используете поток. (это обобщение длинного красиво отформатированного ответа, который я не получил возможности отправить :))
KutuluMike
6
Дело не в том, что я думаю, что ваш вопрос слишком широкий, а в том, что это не конструктивный вопрос. Там нет канонического ответа, так как ответы уже показывают.
Джордж Стокер
2
@ GeorgeStocker Если бы ответ был прямым и очевидным, я бы не стал задавать вопрос. Тот факт, что люди могут спорить, почему данный выбор предпочтителен с любой точки зрения (например, производительность, удобочитаемость, удобство использования, удобство обслуживания, рекомендации по проектированию или согласованность), делает его по своей сути неканоническим, но в то же время отвечает за мое удовлетворение. На все вопросы можно ответить несколько субъективно. Очевидно, вы ожидаете, что вопрос будет иметь в основном аналогичные ответы, чтобы он был хорошим вопросом.
Даниэль А.А. Пельсмакер
3
@Virtlink: Джордж - модератор, выбранный сообществом, который посвятил большую часть своего времени поддержанию переполнения стека. Он заявил, почему он закрыл вопрос, и FAQ поддерживает его.
Эрик Дж.
15
Вопрос здесь не потому, что он субъективен, а потому, что он концептуален. Основное правило: если вы сидите перед кодированием IDE, спросите об этом в переполнении стека. Если вы стоите перед мозговой атакой на доске, спросите об этом здесь, на Программистах.
Роберт Харви

Ответы:

56

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

  1. Магическое значение является хорошим вариантом, когда есть условие «до», например, StreamReader.Readили когда есть простое в использовании значение, которое никогда не будет действительным ответом (-1 для IndexOf).
  2. Бросьте исключение, когда семантика функции заключается в том, что вызывающая сторона уверена, что она будет работать. В этом случае несуществующий ключ или плохой двойной формат действительно исключительны.
  3. Используйте параметр out и возвращайте bool, если семантика должна проверять, возможна или нет операция.

Приведенные вами примеры совершенно понятны для случаев 2 и 3. Что касается магических значений, можно утверждать, является ли это хорошим решением для проектирования или не во всех случаях.

NaNВозвращаемый Math.Sqrtявляется частным случаем - он соответствует стандарту с плавающей запятой.

Андерс Абель
источник
10
Не соглашайтесь с номером 1. Магические значения никогда не являются хорошим вариантом, так как они требуют, чтобы следующий кодер знал значение магического значения. Они также ухудшают читабельность. Я не могу вспомнить ни одного случая, в котором использование магического значения лучше, чем образец Try.
0b101010
1
Но как насчет Either<TLeft, TRight>монады?
Сара
2
@ 0b101010: просто потратил некоторое время на поиски того, как streamreader.read может безопасно вернуть -1 ...
jmoreno
33

Вы пытаетесь сообщить пользователю API, что они должны делать. Если вы выбрасываете исключение, ничто не заставляет их ловить его, и только чтение документации даст им понять, каковы все возможности. Лично я нахожу медленным и утомительным копаться в документации, чтобы найти все исключения, которые может выдать определенный метод (даже если это в intellisense, мне все равно придется копировать их вручную).

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

Вот почему, хотя outпараметры иногда не одобряются, я предпочитаю этот метод с Try...синтаксисом. Это канонический синтаксис .NET и C #. Вы сообщаете пользователю API, что он должен проверить возвращаемое значение, прежде чем использовать результат. Вы также можете включить второй outпараметр с полезным сообщением об ошибке, которое снова помогает при отладке. Вот почему я голосую за решение Try...с outпараметром.

Другой вариант - вернуть специальный «результат» объекта, хотя я нахожу это гораздо более утомительным:

interface IMyResult
{
    bool Success { get; }
    // Only access this if Success is true
    MyOtherClass Result { get; }
    // Only access this if Success is false
    string ErrorMessage { get; }
}

Тогда ваша функция выглядит правильно, потому что она имеет только входные параметры и возвращает только одну вещь. Просто то, что он возвращает, это своего рода кортеж.

На самом деле, если вам Tuple<>нравятся такие вещи, вы можете использовать новые классы, представленные в .NET 4. Лично мне не нравится тот факт, что значение каждого поля менее явное, потому что я не могу дать Item1и Item2полезные имена.

Скотт Уитлок
источник
3
Лично я часто использую контейнер результатов, как ваша IMyResultпричина, можно передать более сложный результат, чем просто trueили falseили значение результата. Try*()полезно только для простых вещей, таких как преобразование строки в int.
this.myself
1
Хороший пост. Для меня я предпочитаю идиому структуры Result, которую вы описали выше, а не для разделения между «возвращаемыми» значениями и «выходными» параметрами. Держит это в чистоте и порядке.
Ocean Airdrop
2
Проблема со вторым выходным параметром состоит в том, что если вы работаете в сложной программе на 50 функций, как вы можете затем передать это сообщение об ошибке пользователю? Бросить исключение намного проще, чем иметь уровни проверки ошибок. Когда вы получаете один, просто бросьте его, и не важно, насколько вы глубоки.
катится
@rolls - Когда мы используем выходные объекты, мы предполагаем, что непосредственный вызывающий объект будет делать с выходными все, что он хочет: обрабатывать его локально, игнорировать или всплывать, выбрасывая его в исключение. Это лучшее из обоих слов - вызывающая сторона может ясно видеть все возможные выходные данные (с перечислениями и т. Д.), Может решить, что делать с ошибкой, и не нужно пытаться перехватить каждый вызов. Если вы в основном ожидаете немедленной обработки результата или его игнорирования, вернуть объекты проще. Если вы хотите выбросить все ошибки на верхние уровни, исключения проще.
Дризин
2
Это просто изобретает проверенные исключения в C # гораздо более утомительным способом, чем проверенные исключения в Java.
Зима
17

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

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

Краткий ответ

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

Длинный ответ

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

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

  • Как еще одно практическое правило, выбрасывание исключения часто позволяет получить более чистый код по сравнению с самыми грубыми магическими значениями, поскольку вы не полагаетесь на то, что программист переводит код ошибки в другой код ошибки, поскольку он проходит через несколько уровней клиентского кода в направлении точка, в которой достаточно контекста для последовательной и адекватной обработки. (Особый случай: здесь, nullкак правило, лучше, чем у других магических ценностей, из-за его склонности к автоматическому переводу себя NullReferenceExceptionв случае некоторых, но не всех типов дефектов; обычно, но не всегда, довольно близко к источнику дефекта. )

Так какой урок?

Для функции, которая вызывается всего несколько раз в течение жизненного цикла приложения (например, инициализация приложения), используйте все, что дает вам более понятный и понятный код. Производительность не может быть проблемой.

Для одноразовой функции используйте все, что дает более чистый код. Затем выполните некоторое профилирование (при необходимости вообще) и измените исключения на коды возврата, если они входят в число предполагаемых верхних узких мест на основе измерений или общей структуры программы.

Для дорогой многоразовой функции используйте все, что дает вам более чистый код. Если вам, по сути, всегда приходится проходить в обе стороны по сети или анализировать XML-файл на диске, затраты на создание исключения, вероятно, незначительны. Гораздо важнее не потерять детали любого сбоя, даже не случайно, чем быстро вернуться из «неустранимого отказа».

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

Вы перечисляете отличный пример того Dictionary<,>.Item, что, собственно говоря, изменилось от возврата nullзначений к выбрасыванию KeyNotFoundExceptionмежду .NET 1.1 и .NET 2.0 (только если вы хотите считать Hashtable.Itemего практическим неуниверсальным предвестником). Причина этого «изменения» здесь не без интереса. Оптимизация производительности типов значений (не больше бокса) сделало оригинальное магическое значение ( null) недоступным; outпараметры просто вернут небольшую часть затрат на производительность. Это последнее соображение производительности совершенно незначительно по сравнению с накладными расходами на бросание a KeyNotFoundException, но дизайн исключений здесь все же лучше. Почему?

  • Параметры ref / out несут свои затраты каждый раз, а не только в случае «отказа»
  • Любой, кому небезразлично, может позвонить Containsперед любым обращением к индексатору, и этот шаблон читается совершенно естественно. Если разработчик хочет, но забывает звонить Contains, никакие проблемы с производительностью не могут закрасться; KeyNotFoundExceptionдостаточно громко, чтобы быть замеченным и исправленным.
Джирка ханика
источник
Я думаю, что 200x настроен оптимистично в отношении исключений ... см. Blogs.msdn.com/b/cbrumme/archive/2003/10/01/51524.aspx "Производительность и тенденции" непосредственно перед комментариями.
gbjbaanb
@gbjbaanb - Ну, может быть. В этой статье используется процент отказов в 1% для обсуждения темы, которая не является совершенно другой. Мои собственные мысли и смутно запомненные измерения были бы взяты из контекста реализованного в таблице C ++ (см. Раздел 5.4.1.2 этого отчета , где одна проблема заключается в том, что первое исключение такого рода, вероятно, начнется с ошибки страницы (резкой и переменной). но амортизируется один раз наверх). Но я сделаю и опубликую эксперимент с .NET 4 и, возможно, настрою это приблизительное значение. Я уже подчеркиваю разницу.
Джирка Ханика
Так ли высока стоимость параметров ref / out ? Как так? И вызов Containsперед вызовом индексатора может вызвать состояние гонки, которое не обязательно должно присутствовать TryGetValue.
Даниэль А.А. Пельсмакер
@gbjbaanb - Эксперимент закончен. Я был ленив и использовал моно на Linux. Исключения дали мне ~ 563000 бросков за 3 секунды. Возвраты дали мне ~ 10900000 возвратов за 3 секунды. Это 1:20, даже не 1: 200. Я все еще рекомендую думать 1: 100+ для любого более реалистичного кода. (Вариант без параметров, как я и предсказывал, имел незначительные затраты - на самом деле я подозреваю, что джиттер, возможно, полностью оптимизировал вызов в моем минималистическом примере, если не было выброшено исключение, независимо от подписи.)
Jirka Hanika
@Virtlink - Согласовано с безопасностью потоков в целом, но, учитывая, что вы ссылались на .NET 4, в частности, используйте ConcurrentDictionaryдля многопоточного доступа и Dictionaryоднопоточного доступа для оптимальной производительности. То есть неиспользование `` Contains` НЕ делает поток кода безопасным с этим конкретным Dictionaryклассом.
Джирка Ханика
10

Что лучше всего делать в такой относительно неисключительной ситуации, чтобы указать на неудачу, и почему?

Вы не должны допустить провала.

Я знаю, это волнующе и идеалистично, но выслушай меня. При разработке дизайна есть ряд случаев, когда у вас есть возможность выбрать версию, в которой нет режимов отказа. Вместо того, чтобы иметь «FindAll», который терпит неудачу, LINQ использует предложение where, которое просто возвращает пустое перечисляемое значение. Вместо того, чтобы иметь объект, который необходимо инициализировать перед использованием, позвольте конструктору инициализировать объект (или инициализировать при обнаружении неинициализированного). Ключ удаляет ветку сбоев в коде потребителя. Это проблема, поэтому сосредоточьтесь на ней.

Другой стратегией для этого является KeyNotFoundсценарий. Почти в каждой кодовой базе, над которой я работал с 3.0, существует что-то вроде этого метода расширения:

public static class DictionaryExtensions {
    public static V GetValue<K, V>(this IDictionary<K, V> arg, K key, Func<K,V> ifNotFound) {
        if (!arg.ContainsKey(key)) {
            return ifNotFound(key);
        }

        return arg[key];
    }
}

Для этого нет режима реального сбоя. ConcurrentDictionaryимеет аналогичный GetOrAddвстроенный.

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

Второй вариант достаточно хорош, когда ваш пользователь делает что-то глупое. Дает вам строку с неверным форматом, пытается установить дату 42 декабря ... что-то, что вы хотите, чтобы вещи быстро и эффектно взрывались во время тестирования, чтобы выявлять и исправлять плохой код.

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

Если возвращаемое значение и выходной параметр нетривиальны, то предположение Скотта Уитлока о результирующем объекте является предпочтительным (как Matchкласс Regex ).

Telastyn
источник
7
Пиво здесь: outпараметры полностью ортогональны к вопросу о побочных эффектах. Изменение refпараметра является побочным эффектом, а изменение состояния объекта, которое вы передаете через входной параметр, является побочным эффектом, но outпараметр - это просто неуклюжий способ заставить функцию возвращать более одного значения. Там нет никакого побочного эффекта, просто несколько возвращаемых значений.
Скотт Уитлок
Я сказал, как правило , из-за того, как люди их используют. Как вы говорите, они просто несколько возвращаемых значений.
Теластин
Но если вам не нравятся параметры и вы используете исключения, чтобы эффектно взорвать ситуацию, когда формат неправильный ... как вы тогда справляетесь со случаем, когда ваш формат - пользовательский ввод? Тогда либо пользователь может взорвать ситуацию, либо он понесет снижение производительности, вызвав его, а затем поймав исключение. Правильно?
Даниэль А.А. Пельсмакер
@virtlink, имея особый метод проверки. В любом случае это необходимо для предоставления надлежащих сообщений в интерфейс перед их отправкой.
Теластин
1
Существует допустимый шаблон для параметров out, и это функция с перегрузками, которые возвращают различные типы. Разрешение перегрузки не будет работать для возвращаемых типов, но оно будет для выходных параметров.
Роберт Харви
2

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

Обратите внимание , что Parseи TryParseна самом деле не то же самое , за исключением режимов отказа. Факт, что это TryParseможет также возвратить значение, действительно несколько ортогонально. Рассмотрим ситуацию, когда, например, вы проверяете некоторые входные данные. На самом деле вам все равно, что это за значение, если оно действительно. И нет ничего плохого в том, чтобы предлагать какую-то IsValidFor(arguments)функцию. Но это никогда не может быть основным режимом работы.

DeadMG
источник
4
Если вы имеете дело с большим количеством вызовов метода, исключения могут оказать огромное негативное влияние на производительность. Исключения должны быть зарезервированы для исключительных условий и будут вполне приемлемы для проверки ввода формы, но не для разбора чисел из больших файлов.
Роберт Харви
1
Это более специфическая потребность, а не общий случай.
DeadMG
2
Итак, ты говоришь. Но вы использовали слово «всегда». :)
Роберт Харви
@DeadMG, согласился с RobertHarvey, хотя я думаю, что ответ был чрезмерно отвергнут, если он был изменен, чтобы отражать «большую часть времени», а затем указать на исключения (без каламбура) для общего случая, когда речь идет о часто используемой высокой производительности призывает рассмотреть другие варианты.
Джеральд Дэвис,
Исключения не дорогие. Поймать глубоко выброшенные исключения может быть дорого, так как система должна разматывать стек до ближайшей критической точки. Но это «дорогое удовольствие» относительно крошечно, и его не стоит бояться даже в тесных вложенных циклах.
Мэтью Уайтед
2

Как уже отмечали другие, магическое значение (включая логическое возвращаемое значение) не так уж и велико, кроме как маркер конца диапазона. Причина: семантика не является явной, даже если вы исследуете методы объекта. Вы должны прочитать всю документацию по всему объекту вплоть до «о, да, если он возвращает -42, это означает, что бла бла бла».

Это решение может использоваться по историческим причинам или по причинам производительности, но в противном случае его следует избегать.

Это оставляет два общих случая: исследование или исключения.

Здесь эмпирическое правило заключается в том, что программа не должна реагировать на исключения, кроме как для обработки, когда программа / непреднамеренно / нарушает какое-либо условие. Для того, чтобы этого не произошло, следует использовать зондирование. Следовательно, исключение либо означает, что соответствующее исследование не было выполнено заранее, либо произошло нечто совершенно неожиданное.

Пример:

Вы хотите создать файл по заданному пути.

Вам следует использовать объект File, чтобы заранее оценить, является ли этот путь допустимым для создания или записи файла.

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

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

Андерс Йохансен
источник
0

Я думаю, что Tryшаблон - лучший выбор, когда код просто указывает на то, что произошло. Я ненавижу param и люблю обнуляемый объект. Я создал следующий класс

public sealed class Bag<TValue>
{
    public Bag(TValue value, bool hasValue = true)
    {
        HasValue = hasValue;
        Value = value;
    }

    public static Bag<TValue> Empty
    {
        get { return new Bag<TValue>(default(TValue), false); }
    }

    public bool HasValue { get; private set; }
    public TValue Value { get; private set; }
}

так что я могу написать следующий код

    public static Bag<XElement> GetXElement(this XElement element, string elementName)
    {
        try
        {
            XElement result = element.Element(elementName);
            return result == null
                       ? Bag<XElement>.Empty
                       : new Bag<XElement>(result);
        }
        catch (Exception)
        {
            return Bag<XElement>.Empty;
        }
    }

Выглядит как nullable, но не только для типа значения

Другой пример

    public static Bag<string> TryParseString(this XElement element, string attributeName)
    {
        Bag<string> attributeResult = GetString(element, attributeName);
        if (attributeResult.HasValue)
        {
            return new Bag<string>(attributeResult.Value);
        }
        return Bag<string>.Empty;
    }

    private static Bag<string> GetString(XElement element, string attributeName)
    {
        try
        {
            string result = element.GetAttribute(attributeName).Value;
            return new Bag<string>(result);
        }
        catch (Exception)
        {
            return Bag<string>.Empty;
        }
    }
GSerjo
источник
3
try catchнанесет ущерб вашей производительности, если вы звоните GetXElement()и терпите неудачу много раз.
Роберт Харви
иногда это не имеет значения. Посмотрите на вызов Bag. Спасибо за ваше наблюдение
ваш класс Bag <T> почти идентичен System.Nullable <T> aka «обнуляемый объект»
аэрозон
да, почти public struct Nullable<T> where T : structглавное отличие в ограничении. Кстати, последняя версия находится здесь github.com/Nelibur/Nelibur/blob/master/Source/Nelibur.Sword/…
GSerjo
0

Если вас интересует маршрут «магическая ценность», еще один способ решить эту проблему - перегрузить цель класса Lazy. Несмотря на то, что Lazy предназначен для отсрочки создания экземпляров, ничто не мешает вам использовать как вариант Maybe или Option. Например:

    public static Lazy<TValue> GetValue<TValue, TKey>(
        this IDictionary<TKey, TValue> dictionary,
        TKey key)
    {
        TValue retVal;
        if (dictionary.TryGetValue(key, out retVal))
        {
            var retValRef = retVal;
            var lazy = new Lazy<TValue>(() => retValRef);
            retVal = lazy.Value;
            return lazy;
        }

        return new Lazy<TValue>(() => default(TValue));
    }
zumalifeguard
источник