Я прочитал в этом вопросе, что функциональные программисты склонны использовать математические доказательства, чтобы гарантировать, что их программа работает правильно. Это звучит намного проще и быстрее, чем модульное тестирование, но, исходя из опыта OOP / Unit Testing, я никогда не видел, чтобы это было сделано.
Можете ли вы объяснить это мне и привести пример?
testing
functional-programming
leeand00
источник
источник
null
).Ответы:
В мире ООП доказательство намного сложнее из-за побочных эффектов, неограниченного наследования и
null
принадлежности к любому типу. Большинство доказательств основаны на принципе индукции, чтобы показать, что вы рассмотрели все возможности, и все три из этих вещей затрудняют доказательство.Допустим, мы реализуем двоичные деревья, которые содержат целочисленные значения (для упрощения синтаксиса я не буду вносить в это общее программирование, хотя это ничего не изменит.) В стандарте ML я определил бы это как это:
datatype tree = Empty | Node of (tree * int * tree)
Это вводит новый тип с именем
tree
, значения которого могут входить ровно в две разновидности (или классы, которые не следует путать с концепцией ООП класса) -Empty
значение, которое не несет никакой информации, иNode
значения, которые содержат 3-кортеж, первый и последний элементыtree
s и средний элемент которых являетсяint
. Ближайшее приближение к этому объявлению в ООП будет выглядеть так:С оговоркой, что переменные типа Tree никогда не могут быть
null
.Теперь давайте напишем функцию для вычисления высоты (или глубины) дерева и предположим, что у нас есть доступ к
max
функции, которая возвращает большее из двух чисел:Мы определили
height
функцию по кейсам - есть одно определение дляEmpty
деревьев и одно определение дляNode
деревьев. Компилятор знает, сколько классов деревьев существует, и выдает предупреждение, если вы не определили оба случая. ВыражениеNode (leftChild, value, rightChild)
в сигнатуре функции связывает значения 3-кортежа переменныхleftChild
,value
и ,rightChild
соответственно , таким образом , мы можем обратиться к ним в определении функции. Это похоже на объявление локальных переменных, таких как это на языке ООП:Как мы можем доказать, что мы реализовали
height
правильно? Мы можем использовать структурную индукцию , которая состоит из: 1. Доказать, чтоheight
это правильно в базовом случае (ах) нашегоtree
типа (Empty
) 2. Предполагая, что рекурсивные вызовыheight
верны, доказать, чтоheight
это верно для неосновного (и) случая (ов) ) (когда дерево на самом делеNode
).На шаге 1 мы видим, что функция всегда возвращает 0, когда аргумент является
Empty
деревом. Это правильно по определению высоты дерева.Для шага 2 функция возвращается
1 + max( height(leftChild), height(rightChild) )
. Предполагая, что рекурсивные вызовы действительно возвращают рост дочерних элементов, мы видим, что это также правильно.И это завершает доказательство. Комбинированные шаги 1 и 2 исчерпывают все возможности. Заметьте, однако, что у нас нет ни мутаций, ни нулей, и существует ровно две разновидности деревьев. Уберите эти три условия, и доказательство быстро станет более сложным, если не непрактичным.
РЕДАКТИРОВАТЬ: Так как этот ответ поднялся до вершины, я хотел бы добавить менее тривиальный пример доказательства и немного подробнее рассмотреть структурную индукцию. Выше мы доказали, что если
height
возвращает , его возвращаемое значение является правильным. Мы не доказали, что оно всегда возвращает значение. Мы также можем использовать структурную индукцию, чтобы доказать это (или любое другое свойство). Опять же, на шаге 2 мы можем предположить, что свойства являются рекурсивными вызовами, если все рекурсивные вызовы работают с прямым потомком объекта. дерево.Функция может не вернуть значение в двух ситуациях: если она выдает исключение, и если она зацикливается вечно. Сначала давайте докажем, что если не сгенерировано никаких исключений, функция завершается:
Докажите, что (если не выдается никаких исключений) функция завершается для базовых случаев (
Empty
). Поскольку мы безоговорочно возвращаем 0, он завершается.Докажите, что функция заканчивается в неосновных случаях (
Node
). Там три вызовов функций здесь:+
,max
иheight
. Мы знаем это+
иmax
заканчиваем, потому что они являются частью стандартной библиотеки языка, и они определены таким образом. Как упоминалось ранее, мы можем предположить, что свойство, которое мы пытаемся доказать, является истинным для рекурсивных вызовов, если они работают с непосредственными поддеревьями, поэтому вызовы также должныheight
завершаться.Это завершает доказательство. Обратите внимание, что вы не сможете доказать завершение модульным тестом. Теперь осталось только показать, что
height
не генерирует исключений.height
не генерирует исключения в базовом случае (Empty
). Возвращение 0 не может выдать исключение, поэтому мы закончили.height
не вызывает исключение в неосновном случае (Node
). Предположим еще раз, что мы знаем+
иmax
не бросаем исключения. И структурная индукция позволяет нам предполагать, что рекурсивные вызовы также не будут генерироваться (потому что работают с непосредственными дочерними элементами дерева.) Но подождите! Эта функция является рекурсивной, но не хвостовой . Мы могли бы взорвать стек! Наше попытанное доказательство обнаружило ошибку. Мы можем исправить это, изменивheight
хвостовую рекурсию .Я надеюсь, что это показывает, что доказательства не должны быть страшными или сложными. Фактически, всякий раз, когда вы пишете код, вы неофициально создаете доказательство в своей голове (в противном случае вы не будете уверены, что просто реализовали функцию.) Избегая нулевого значения, ненужных мутаций и неограниченного наследования, вы можете доказать, что ваша интуиция исправить довольно легко. Эти ограничения не так суровы, как вы думаете:
null
это языковой недостаток, и избавиться от него безусловно хорошо.источник
Гораздо проще рассуждать о коде, когда все неизменно . В результате циклы чаще записываются как рекурсия. В общем случае проще проверить правильность рекурсивного решения. Часто такое решение будет также очень похоже на математическое определение проблемы.
Тем не менее, в большинстве случаев нет достаточной мотивации для проведения фактического формального доказательства правильности. Доказательства сложны, занимают много (человека) времени и имеют низкую рентабельность инвестиций.
Некоторые функциональные языки (особенно из семейства ML) имеют чрезвычайно выразительные системы типов, которые могут дать гораздо более полные гарантии того, что система типов в стиле C (но некоторые идеи, такие как дженерики, также стали распространенными в основных языках). Когда программа проходит проверку типа, это своего рода автоматическое доказательство. В некоторых случаях это позволит обнаружить некоторые ошибки (например, забыть базовый случай в рекурсии или забыть обработать определенные случаи в сопоставлении с образцом).
С другой стороны, эти системы типов должны быть очень ограниченными, чтобы их можно было разрешить . Таким образом, в некотором смысле, мы получаем статические гарантии, отказываясь от гибкости - и эти ограничения являются причиной, по которой существуют сложные научные статьи в духе « монадического решения решаемой проблемы в Хаскеле ».
Мне нравятся как очень либеральные, так и очень ограниченные языки, и у обоих есть свои трудности. Но дело не в том, что кто-то будет «лучше», а просто удобнее для разных задач.
Затем следует указать, что доказательства и модульное тестирование не являются взаимозаменяемыми. Они оба позволяют нам оценить правильность программы:
Тестирование накладывает верхнюю границу на правильность: если тест не пройден, программа неверна, если тесты не пройдены, мы уверены, что программа обработает проверенные случаи, но все равно могут быть обнаруженные ошибки.
Доказательства устанавливают нижнюю границу правильности: может оказаться невозможным доказать определенные свойства. Например, может быть легко доказать, что функция всегда возвращает число (это то, что делают системы типов). Но может быть невозможно доказать, что число всегда будет
< 10
.источник
Слово предупреждения может быть в порядке здесь:
Хотя, как правило, верно то, что пишут здесь другие - короче говоря, усовершенствованные системы типов, неизменность и прозрачность ссылок вносят большой вклад в корректность, - это не тот случай, когда тестирование не проводится в функциональном мире. Наоборот !
Это потому, что у нас есть такие инструменты, как Quickcheck, которые генерируют тестовые случаи автоматически и случайным образом. Вы просто указываете законы, которым должна подчиняться функция, а затем quickcheck проверит эти законы для сотен случайных тестовых случаев.
Видите ли, это немного более высокий уровень, чем тривиальные проверки на равенство в нескольких тестовых случаях.
Вот пример из реализации дерева AVL:
Второй закон (или свойство) мы можем прочитать следующим образом: Для всех произвольных деревьев
t
справедливо следующее: еслиt
не пусто, то для всех ключейk
этого дерева он будет содержать тот взглядk
в дереве, который является результатом удаленияk
отt
, результат будетNothing
(что означает: не найден).Это проверяет надлежащую функциональность для удаления существующего ключа. Какие законы должны регулировать удаление несуществующего ключа? Мы, конечно, хотим, чтобы полученное дерево было таким же, как и то, из которого мы удалили. Мы можем выразить это легко:
Таким образом, тестирование действительно весело. И, кроме того, как только вы научитесь читать свойства quickcheck, они станут спецификацией для машинного тестирования .
источник
Я не совсем понимаю, что означает связанный ответ «достичь модульности с помощью математических законов», но я думаю, что у меня есть представление о том, что имеется в виду.
Проверьте Функтор :
Это не тестовые случаи, а пара законов, которые должны быть выполнены.
Теперь предположим, что вы реализуете
Functor
( источник ):Проблема состоит в том, чтобы убедиться, что ваша реализация удовлетворяет законам. Как ты это делаешь?
Один подход заключается в написании тестовых случаев. Основное ограничение этого подхода состоит в том, что вы проверяете поведение в конечном количестве случаев (удачи, исчерпывающе тестируете функцию с 8 параметрами!), И поэтому прохождение тестов не может гарантировать ничего, кроме того, что тесты пройдены.
Другой подход заключается в использовании математических рассуждений, то есть доказательств, основанных на фактическом определении (а не на поведении в ограниченном числе случаев). Идея в том, что математическое доказательство может быть более эффективным; однако это зависит от того, насколько ваша программа поддается математическому доказательству.
Я не могу провести вас через фактическое формальное доказательство того, что приведенный выше
Functor
пример удовлетворяет законам, но я постараюсь дать общее представление о том, как это доказательство может выглядеть:fmap id = id
Nothing
fmap id Nothing
=Nothing
по части 1 реализацииid Nothing
=Nothing
по определениюid
Just x
fmap id (Just x)
=Just (id x)
=Just x
по части 2 реализации, затем по определениюid
fmap (p . q) = (fmap p) . (fmap q)
Nothing
fmap (p . q) Nothing
=Nothing
по части 1(fmap p) . (fmap q) $ Nothing
=(fmap p) $ Nothing
=Nothing
двумя приложениями части 1Just x
fmap (p . q) (Just x)
=Just ((p . q) x)
=Just (p (q x))
по части 2, то по определению.
(fmap p) . (fmap q) $ (Just x)
=(fmap p) $ (Just (q x))
=Just (p (q x))
двумя приложениями части второйисточник
«Остерегайтесь ошибок в приведенном выше коде; я только доказал, что это правильно, а не пробовал». - Дональд Кнут
В идеальном мире программисты совершенны и не делают ошибок, поэтому ошибок нет.
В идеальном мире компьютерные ученые и математики также совершенны и тоже не делают ошибок.
Но мы не живем в идеальном мире. Поэтому мы не можем полагаться на программистов, чтобы они не делали ошибок. Но мы не можем предположить, что любой ученый, предоставивший математическое доказательство правильности программы, не допустил ошибок в этом доказательстве. Поэтому я бы не стал обращать внимания на тех, кто пытается доказать, что его код работает. Напишите юнит-тесты и покажите мне, что код ведет себя в соответствии со спецификациями. Все остальное не убедит меня ни в чем.
источник