Обработка исключений в функциональном стиле


24

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

def fn(*args):
    try:
        ... do something
    except SomeException:
        return None

Это нарушает чистоту? И если так, значит ли это, что невозможно обработать ошибки исключительно в Python?

Обновить

В своем комментарии Эрик Липперт напомнил мне еще один способ обработки исключений в FP. Хотя я никогда не видел такого на практике в Python, я играл с ним еще тогда, когда изучал FP год назад. Здесь любая optional-decorated функция возвращает Optionalзначения, которые могут быть пустыми, для обычных выходных данных, а также для указанного списка исключений (неопределенные исключения все еще могут прервать выполнение). Carryсоздает отложенную оценку, в которой каждый шаг (отложенный вызов функции) либо получает непустой Optionalвывод с предыдущего шага и просто передает его, либо иным образом оценивает себя, передавая новый Optional. В конце концов, окончательное значение либо нормальное, либо Empty. Здесь try/exceptблок скрыт за декоратором, поэтому указанные исключения могут рассматриваться как часть сигнатуры возвращаемого типа.

class Empty:
    def __repr__(self):
        return "Empty"


class Optional:
    def __init__(self, value=Empty):
        self._value = value

    @property
    def value(self):
        return Empty if self.isempty else self._value

    @property
    def isempty(self):
        return isinstance(self._value, BaseException) or self._value is Empty

    def __bool__(self):
        raise TypeError("Optional has no boolean value")


def optional(*exception_types):
    def build_wrapper(func):
        def wrapper(*args, **kwargs):
            try:
                return Optional(func(*args, **kwargs))
            except exception_types as e:
                return Optional(e)
        wrapper.__isoptional__ = True
        return wrapper
    return build_wrapper


class Carry:
    """
    >>> from functools import partial
    >>> @optional(ArithmeticError)
    ... def rdiv(a, b):
    ...     return b // a
    >>> (Carry() >> (rdiv, 0) >> (rdiv, 0) >> partial(rdiv, 1))(1)
    1
    >>> (Carry() >> (rdiv, 0) >> (rdiv, 1))(1)
    1
    >>> (Carry() >> rdiv >> rdiv)(0, 1) is Empty
    True
    """
    def __init__(self, steps=None):
        self._steps = tuple(steps) if steps is not None else ()

    def _add_step(self, step):
        fn, *step_args = step if isinstance(step, Sequence) else (step, )
        return type(self)(steps=self._steps + ((fn, step_args), ))

    def __rshift__(self, step) -> "Carry":
        return self._add_step(step)

    def _evaluate(self, *args) -> Optional:
        def caller(carried: Optional, step):
            fn, step_args = step
            return fn(*(*step_args, *args)) if carried.isempty else carried
        return reduce(caller, self._steps, Optional())

    def __call__(self, *args):
        return self._evaluate(*args).value

1
На ваш вопрос уже дан ответ, так что просто комментарий: вы понимаете, почему в функциональном программировании ваша функция вызывает исключение? Это не произвольный каприз :)
Андрес Ф.

6
Существует другая альтернатива возвращению значения, указывающего на ошибку. Помните, что обработка исключений - это поток управления, и у нас есть функциональные механизмы для уточнения потоков управления. Вы можете эмулировать обработку исключений в функциональных языках, написав метод взять две функции: продолжение успеха и продолжение ошибки . Последнее, что делает ваша функция, это вызывает или продолжение успеха, передавая «результат», или продолжение ошибки, передавая «исключение». Недостатком является то, что вы должны писать свою программу в наизусть.
Эрик Липперт

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

3
3 - когда вы работаете с функциями итога, вы можете составлять их с другими функциями и использовать их в функциях более высокого порядка, не беспокоясь о результатах ошибок, «не закодированных в типе», т.е. об исключениях.
Андрес Ф.

1
@EliKorvigo Установка переменной вводит состояние, по определению, полную остановку. Вся цель переменных в том, что они содержат состояние. Это не имеет ничего общего с исключениями. Наблюдение за возвращаемым значением функции и установка значения переменной на основе наблюдения вводит состояние в оценку, не так ли?
user253751

Ответы:


20

Прежде всего, давайте выясним некоторые заблуждения. Здесь нет «нижнего значения». Нижний тип определяется как тип, который является подтипом любого другого типа в языке. Отсюда можно доказать (по крайней мере, в любой интересной системе типов), что нижний тип не имеет значений - он пустой . Так что нет такой вещи, как нижнее значение.

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

def do_thing(a: int) -> Bottom: ...

мы знаем, что do_thingникогда не сможем вернуть, так как он должен будет вернуть значение типа Bottom. Таким образом, есть только две возможности:

  1. do_thing не останавливается
  2. do_thing выдает исключение (в языках с механизмом исключения)

Обратите внимание, что я создал тип, Bottomкоторый на самом деле не существует в языке Python. Noneявляется неправильным; на самом деле это значение единицы , единственное значение типа единицы , которое вызывается NoneTypeв Python ( type(None)для подтверждения сделай это сам).

Теперь еще одно заблуждение заключается в том, что функциональные языки не имеют исключений. Это тоже не правда. SML, например, имеет очень хороший механизм исключений. Однако исключения используются в SML гораздо реже, чем, например, в Python. Как вы уже сказали, общий способ указать на какую-то ошибку в функциональных языках - это вернуть Optionтип. Например, мы бы создали функцию безопасного деления следующим образом:

def safe_div(num: int, den: int) -> Option[int]:
  return Some(num/den) if den != 0 else None

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

Поэтому я бы посоветовал придерживаться языковых соглашений в этом случае. Python идиоматически использует исключения для управления потоком управления (что является плохим дизайном, IMO, но, тем не менее, стандартным), поэтому, если вы не работаете только с написанным вами кодом, я бы рекомендовал следовать стандартной практике. Является ли это «чистым» или нет, не имеет значения.


Под «нижним значением» я имел в виду «нижний тип», поэтому я написал, что Noneон не соответствует определению. В любом случае, спасибо, что поправили меня. Не думаете ли вы, что использование исключения только для полной остановки выполнения или для возврата необязательного значения - это нормально с принципами Python? Я имею в виду, почему это плохо, чтобы не использовать исключения для сложного управления?
Эли Корвиго,

@EliKorvigo Вот что я сказал более или менее, верно? Исключения составляют идиоматические Python.
садовник

1
Например, я не рекомендую своим студентам старшекурсников использовать try/except/finallyкак альтернативу if/else, то есть try: var = expession1; except ...: var = expression 2; except ...: var = expression 3..., хотя это обычное дело на любом императивном языке (кстати, я настоятельно не рекомендую использовать if/elseблоки и для этого). Ты имеешь в виду, что я безрассуден и должен допускать такие паттерны, потому что "это Python"?
Эли Корвиго,

@EliKorvigo Я согласен с вами в целом (кстати, вы профессор?). try... catch...должен не быть использован для управления потоком. По какой-то причине, именно так сообщество Python и решило что-то сделать. Например, safe_divфункция, которую я написал выше, обычно пишется try: result = num / div: except ArithmeticError: result = None. Так что, если вы учите их общим принципам разработки программного обеспечения, вам определенно следует препятствовать этому. if ... else...это также запах кода, но это слишком долго, чтобы вдаваться в подробности.
садовник

2
Существует «нижнее значение» (оно используется, например, при обсуждении семантики языка Haskell), и оно не имеет ничего общего с типом дна. Так что это на самом деле не является заблуждением ОП, просто говорить о другом.
Бен

11

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

Чистая функция:

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

  • Не вызывает побочных эффектов; он не меняет входы, выходы или что-либо еще во внешней среде. Это только производит возвращаемое значение.

Так что спросите себя. Ваша функция делает что-нибудь, кроме принятия ввода и возврата вывода?


3
Итак, не имеет значения, насколько уродливо это написано внутри, пока оно ведет себя функционально?
Эли Корвиго

8
Правильно, если вы просто заинтересованы в чистоте. Конечно, могут быть и другие вещи.
Роберт Харви

3
Чтобы сыграть в защиту дьяволов, я бы сказал, что бросание исключения - это просто форма вывода, а функции, которые бросают, чистые. Это проблема?
Берги

1
@ Берджи Я не знаю, что вы играете адвоката дьявола, поскольку именно этот ответ подразумевает :) Проблема в том, что помимо чистоты есть и другие соображения. Если вы разрешите непроверенные исключения (которые по определению не являются частью сигнатуры функции), тогда тип возвращаемого значения каждой функции фактически становится T + { Exception }(где Tявно объявленный тип возвращаемого значения), что является проблематичным. Вы не можете знать, вызовет ли функция исключение, не глядя на свой исходный код, что также затрудняет написание функций более высокого порядка.
Андрес Ф.

2
@Begri, хотя бросание может быть чисто, IMO с использованием исключений не только усложняет тип возвращаемого значения каждой функции. Это повреждает сочетаемость функций. Рассмотрим реализацию ошибки map : (A->B)->List A ->List Bгде A->Bможно. Если мы позволим f сгенерировать исключение, map f L он вернет что-то типа Exception + List<B>. Если вместо этого мы позволим ему возвращать optionalтип стиля, map f Lто вместо этого вернем List <Optional <B >> `. Этот второй вариант кажется мне более функциональным.
Майкл Андерсон

7

Семантика Haskell использует «нижнее значение» для анализа значения кода на Haskell. Это не то, что вы действительно используете непосредственно в программировании на Haskell, и возвращение None- это совсем не то же самое.

Нижнее значение - это значение, приписываемое семантикой Haskell для любого вычисления, которое не может быть нормально оценено как значение. Одним из таких способов вычисления на Haskell может быть исключение! Так что, если вы пытались использовать этот стиль в Python, вы должны просто генерировать исключения как обычно.

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

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

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

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

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

Так что, если вы хотели выбросить исключение и хотите «вернуть дно», чтобы подражать подходу Хаскелла, вы просто ничего не делаете. Пусть исключение распространяется. Это именно то, что имеют в виду программисты на Haskell, когда говорят о функции, возвращающей нижнее значение.

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

Причина, по которой нам нравятся тотальные функции, заключается в том, что их гораздо проще рассматривать как «черные ящики», когда мы их комбинируем и манипулируем. Если у меня есть функция total, возвращающая что-то типа A, и функция total, которая принимает что-то типа A, тогда я могу вызвать секунду на выходе первого, ничего не зная о реализации того или другого; Я знаю, что получу правильный результат, независимо от того, как будет обновлен код любой функции в будущем (при условии, что их совокупность сохраняется, и до тех пор, пока они сохраняют одну и ту же сигнатуру типа). Такое разделение интересов может быть чрезвычайно мощным средством рефакторинга.

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

Поэтому функциональный программист, который советует вам избегать исключений, предпочел бы, чтобы вы вместо этого возвращали значение, которое кодирует либо ошибку, либо действительное значение, и требует, чтобы для его использования вы были готовы использовать обе возможности. Такие вещи , как Eitherтипы или Maybe/ Optionтипов являются одними из самых простых подходов , чтобы сделать это в более сильно типизированных языках (обычно используется со специальным синтаксисом или функций высшего порядка , чтобы помочь склеить вещи , которые нуждаются в Aс вещами , которые производят Maybe<A>).

Функция, которая либо возвращает None(если произошла ошибка), либо какое-либо значение (если ошибки не было) не следует ни одной из вышеуказанных стратегий.

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


1
+1 Отличный ответ! И очень всеобъемлющий.
Андрес Ф.

6

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

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

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

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


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

1
@gardenhead Вы правы, я думал о типе устройства. Исправлена.
8bittree

В случае вашего первого примера, не является ли файловая система и какие файлы в ней существуют просто одним из входов в функцию?
Vality

2
@Vaility В действительности у вас, вероятно, есть дескриптор или путь к файлу. Если функция fчистая, вы f("/path/to/file")всегда будете возвращать одно и то же значение. Что произойдет, если фактический файл будет удален или изменен между двумя вызовами f?
Андрес Ф.

2
@Vaility Функция может быть чистой, если вместо дескриптора файла вы передаете ей фактическое содержимое (т. Е. Точный моментальный снимок байтов) файла, и в этом случае вы можете гарантировать, что, если в него входит то же содержимое, то же выводится выходит Но доступ к файлу не такой: :)
Andres F.
Используя наш сайт, вы подтверждаете, что прочитали и поняли нашу Политику в отношении файлов cookie и Политику конфиденциальности.
Licensed under cc by-sa 3.0 with attribution required.