Як у функціональному програмуванні можна досягти модульності за допомогою математичних законів?


11

Я читав у цьому запитанні, що функціональні програмісти, як правило, використовують математичні докази, щоб переконатися, що їх програма працює правильно. Це звучить набагато простіше і швидше, ніж тестування одиниць, але виходячи з фону OOP / Unit Testing, я ніколи не бачив цього робити.

Ви можете мені це пояснити і навести приклад?


7
"Це звучить набагато простіше та швидше, ніж тестування одиниці". Так, звуки. Насправді, для більшості програмного забезпечення це практично неможливо. І чому в заголовку згадується модульність, проте ви говорите про перевірку?
Ейфорія

@Euphoric Під час тестування підрозділів в OOP ви пишете тести для перевірки ... перевірки того, що частина програмного забезпечення працює коректно, але також перевіряє, що ваші проблеми розділені ... тобто модульність та повторне використання ... якщо я правильно це розумію.
leeand00

2
@Euphoric Тільки якщо ви зловживаєте мутацією та успадкуванням і працюєте мовами з недосконалими системами типу (тобто є null).
Довал

@ leeand00 Я думаю, ви неправильно використовуєте термін "перевірка". Модульність та повторне використання не перевіряються безпосередньо за допомогою перевірки програмного забезпечення (хоча, звичайно, відсутність модульності може ускладнити програмне забезпечення в обслуговуванні та повторному використанні, тому вводяться помилки і не відбувається процес перевірки).
Андрес Ф.

Набагато простіше перевірити частини програмного забезпечення, якщо вони написані модульним способом. Таким чином, ви можете мати реальний доказ того, що для деяких функцій функціонує правильно, для інших ви можете писати одиничні тести.
grizwako

Відповіді:


22

Доказ набагато складніший у світі OOP через побічні ефекти, необмежене успадкування та nullчленство кожного типу. Більшість доказів покладаються на принцип індукції, щоб показати, що ви охопили всі можливості, і всі 3 з цих речей ускладнюють доведення.

Скажімо, ми реалізовуємо двійкові дерева, які містять цілі значення (задля спрощення синтаксису, я не вношу в це загальне програмування, хоча це нічого не змінить.) У стандартному ML я б визначив це як це:

datatype tree = Empty | Node of (tree * int * tree)

Це вводить новий тип під назвою tree, значення якого може містити рівно два різновиди (або класи, не плутати з концепцією класу OOP класу) - Emptyзначення, яке не містить інформації, і Nodeзначення, які несуть 3-кортеж, перший і останній елементами є trees, середнім елементом якого є an int. Найближче наближення до цієї декларації в ООП виглядатиме так:

public class Tree {
    private Tree() {} // Prevent external subclassing

    public static final class Empty extends Tree {}

    public static final class Node extends Tree {
        public final Tree leftChild;
        public final int value;
        public final Tree rightChild;

        public Node(Tree leftChild, int value, Tree rightChild) {
            this.leftChild = leftChild;
            this.value = value;
            this.rightChild = rightChild;
        }
    }
}

З застереженням, що змінних типу Дерево ніколи не може бути null.

Тепер напишемо функцію для обчислення висоти (або глибини) дерева, і припустимо, що у нас є доступ до maxфункції, яка повертає більше двох чисел:

fun height(Empty) =
        0
 |  height(Node (leftChild, value, rightChild)) =
        1 + max( height(leftChild), height(rightChild) )

Ми визначили heightфункцію за кейсами - є одне визначення для Emptyдерев і одне визначення для Nodeдерев. Компілятор знає, скільки класів дерев існує, і видав би попередження, якщо ви не визначили обидва випадки. Вираз Node (leftChild, value, rightChild)в сигнатурі функції пов'язує значення 3-кортежу змінних leftChild, valueі , rightChildвідповідно , таким чином , ми можемо звернутися до них у визначенні функції. Це схоже на оголошення локальних змінних на зразок цієї мови на мові OOP:

Tree leftChild = tuple.getFirst();
int value = tuple.getSecond();
Tree rightChild = tuple.getThird();

Як ми можемо довести, що ми реалізували heightправильно? Ми можемо використовувати структурну індукцію , яка складається з: 1. Доведіть, що heightце правильно в базових випадках (-ях) нашого treeтипу ( Empty) 2. Припустимо, що рекурсивні виклики heightє правильними, доведіть, що heightце правильно для не-базового випадку ) (коли дерево насправді а Node).

На кроці 1 ми бачимо, що функція завжди повертає 0, коли аргументом є Emptyдерево. Це правильно за визначенням висоти дерева.

На кроці 2 функція повертається 1 + max( height(leftChild), height(rightChild) ). Припускаючи, що рекурсивні дзвінки справді повертають зріст дітей, ми можемо бачити, що це також правильно.

І це завершує доказ. Кроки 1 і 2 комбінують усі можливості. Зауважте, однак, що у нас немає ні мутації, ні нулів, і є рівно два сорти дерев. Заберуть ці три умови, і доказ швидко ускладнюється, якщо не недоцільно.


EDIT: Оскільки ця відповідь піднялася до вершини, я хотів би додати менш тривіальний приклад доказу та трохи детальніше висвітлити структурну індукцію. Вище ми довели, що якщо heightповертається , його повернене значення є правильним. Ми не довели, що це завжди повертає значення. Ми можемо використовувати структурну індукцію, щоб довести це теж (або будь-яку іншу властивість.) Знову на кроці 2 нам дозволяється припускати властивості рекурсивних викликів до тих пір, поки рекурсивні виклики працюють на прямому дочірньому дерево.

Функція може не повернути значення у двох ситуаціях: якщо вона кидає виняток і якщо вона циклічно назавжди. Спершу докажемо, що якщо жодних винятків не буде, функція припиняється:

  1. Доведіть, що (якщо немає винятків) функція припиняється для базових випадків ( Empty). Оскільки ми беззастережно повертаємо 0, воно припиняється.

  2. Доведіть, що функція припиняється в неосновних випадках ( Node). Там три викликів функцій тут: +, maxі height. Ми це знаємо +і maxприпиняємо, оскільки вони є частиною стандартної бібліотеки мови і вони визначені саме так. Як згадувалося раніше, ми можемо вважати, що властивість, яку ми намагаємося довести, є вірною для рекурсивних дзвінків, якщо вони працюють на безпосередніх підрядках, тому дзвінки також heightприпиняються.

Це завершує доказ. Зауважте, що ви не зможете довести припинення за допомогою одиничного тесту. Тепер все, що залишилося, - це показати, що heightне кидає винятків.

  1. Доведіть, що heightне викидає виключень у базовому випадку ( Empty). Повернення 0 не може кинути виняток, тому ми закінчили.
  2. Доведіть, що heightне викидає виняток у неосновному випадку ( Node). Припустимо ще раз, що ми знаємо +і maxне кидаємо винятків. І структурна індукція дозволяє припустити, що рекурсивні дзвінки також не будуть кидатись (тому що діяти на безпосередніх дітей дерева.) Але зачекайте! Ця функція є рекурсивною, але не хвостовою рекурсивною . Ми могли б підірвати стек! Наші спроби виявили помилку. Ми можемо виправити це, змінивши його heightна рекурсивний хвіст .

Я сподіваюся, що це свідчить про те, що докази не повинні бути страшними або складними. Насправді, кожного разу, коли ви пишете код, ви неофіційно створювали доказ у своїй голові (інакше ви не переконаєтесь, що просто реалізували цю функцію.) Уникаючи нульової, непотрібної мутації та необмеженої спадщини, ви можете довести свою інтуїцію виправити досить легко. Ці обмеження не такі суворі, як ви могли б подумати:

  • null є мовним недоліком і безумовно добре це усунути.
  • Мутація іноді неминуча і необхідна, але вона потрібна набагато рідше, ніж ви думаєте, особливо, коли у вас є стійкі структури даних.
  • Що стосується обмеженої кількості класів (у функціональному сенсі) / підкласів (у розумінні ООП) проти необмеженої їх кількості, то це тема занадто велика для однієї відповіді . Досить сказати, що там є дизайн дизайну - доцільність правильності та гнучкість розширення.

8
  1. Набагато простіше міркувати про код, коли все незмінне . Як результат, петлі частіше записуються як рекурсії. Взагалі простіше перевірити правильність рекурсивного рішення. Часто таке рішення буде читати дуже аналогічно математичному визначенню проблеми.

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

  2. Деякі функціональні мови (особливо з сімейства ML) мають надзвичайно виразні системи типу, які можуть гарантувати набагато повніші гарантії, що система типу C (але деякі ідеї, такі як дженерики, також стали поширеними і в основних мовах). Коли програма проходить перевірку типу, це своєрідний автоматизований доказ. У деяких випадках це зможе виявити деякі помилки (наприклад, забувши базовий регістр в ході рекурсії або забувши обробити певні випадки в шаблоні відповідності).

    З іншого боку, ці системи мають бути обмежені для того, щоб зробити їх вирішальними . Тож у певному сенсі ми отримуємо статичні гарантії, відмовляючись від гнучкості - і ці обмеження є причиною існування складних наукових робіт у руслі " Монадичного рішення вирішеної проблеми в Haskell ".

    Мені подобаються як дуже ліберальні, так і дуже обмежені мови, і обидві мають свої труднощі. Але справа не в тому, що можна було б «краще», кожен просто зручніший для виконання різного роду завдань.

Тоді слід зазначити, що докази та одиничні випробування не є взаємозамінними. Вони обидва дозволяють нам поставити межі щодо правильності програми:

  • Тестування ставить верхню межу правильності: Якщо тест не вдався, програма неправильна, якщо жодних тестів не вдалося, ми впевнені, що програма буде обробляти перевірені випадки, але помилки можуть бути не виявлені.

    int factorial(int n) {
      if (n <= 1) return 1;
      if (n == 2) return 2;
      if (n == 3) return 6;
      return -1;
    }
    
    assert(factorial(0) == 1);
    assert(factorial(1) == 1);
    assert(factorial(3) == 6);
    // oops, we forgot to test that it handles n > 3…
    
  • Докази ставлять нижню межу правильності: може бути неможливо довести певні властивості. Наприклад, може бути легко довести, що функція завжди повертає число (саме так роблять системи типу). Але може бути неможливо довести, що число завжди буде < 10.

    int factorial(int n) {
      return n;  // FIXME this is just a placeholder to make it compile
    }
    
    // type system says this will be OK…
    

1
"Можливо, неможливо довести певні властивості ... Але може бути неможливо довести, що число завжди буде <10". Якщо правильність програми залежить від кількості, меншої за 10, ви можете довести це. Це правда, що система типу не може (принаймні, не виключаючи тону дійсних програм) - але ви можете.
Доваль

@Doval Так. Однак система типів є лише прикладом системи для підтвердження. Системи типів дуже помітно обмежені і не можуть оцінити правдивість певних тверджень. Людина може виконувати набагато складніші докази, але все одно буде обмежена у тому, що може довести. Все ще існує межа, яку неможливо перетнути, вона просто далі.
амон

1
Згоден, я просто думаю, що приклад був трохи оманливим.
Довал

2
На таких мовах, як типи Ідріса, можна навіть довести, що вона повертається нижче 10.
Інго,

2
Можливо, кращим способом вирішити проблему, яку викликає @Doval, було б констатувати, що деякі проблеми не можна визначити (наприклад, проблема зупинки), вимагає занадто багато часу для доказування або знадобиться нова математика, щоб виявити результат. Моя особиста думка полягає в тому, що ви повинні уточнити, що якщо щось доведено правдою, немає необхідності перевіряти це. Доказ вже ставить верхню і нижню межу. Причина, по якій докази та тести не є взаємозамінними, полягає в тому, що доказ може бути занадто важким, або зробити його прямо неможливо. Тести також можуть бути автоматизовані (коли змінюється код).
Томас Едінг

7

Тут може бути попередити слово попередження:

Хоча в цілому вірно те, що тут пишуть інші - коротше кажучи, що системи прогресивного типу, незмінність і прозорість рефератів багато сприяють правильності - це не так, що тестування не робиться у функціональному світі. Навпаки !

Це тому, що у нас є такі інструменти, як Quickcheck, які генерують тестові випадки автоматично та випадковим чином. Ви просто констатуєте закони, яким повинна керуватися функція, і тоді швидка перевірка перевірить ці закони на сотні випадкових тестових випадків.

Розумієте, це дещо вищий рівень, ніж тривіальна перевірка рівності на кількох тестових випадках.

Ось приклад реалізації дерева AVL:

--- A generator for arbitrary Trees with integer keys and string values
aTree = arbitrary :: Gen (Tree Int String)


--- After insertion, a lookup with the same key yields the inserted value        
p_insert = forAll aTree (\t -> 
             forAll arbitrary (\k ->
               forAll arbitrary (\v ->
                lookup (insert t k v) k == Just v)))

--- After deletion of a key, lookup results in Nothing
p_delete = forAll aTree (\t ->
            not (null t) ==> forAll (elements (keys t)) (\k ->
                lookup (delete t k) k == Nothing))

Другий закон (або властивість) ми можемо прочитати так: Для всіх довільних дерев tсправедливо таке: якщо tвін не порожній, то для всіх ключів kцього дерева він утримуватиме те, що шукає kдерево, яке є результатом видалення. kз t, результат буде Nothing(що вказує: не знайдено).

Це перевіряє належну функціональність для видалення наявного ключа. Які закони повинні регулювати видалення неіснуючого ключа? Ми, звичайно, хочемо, щоб отримане дерево було таким самим, як і дерево, яке ми видалили. Ми можемо це легко висловити:

p_delete_nonexistant = forAll aTree (\t ->
                          forAll arbitrary (\k -> 
                              k `notElem` keys t ==> delete t k == t))

Таким чином, тестування дійсно цікаве. Крім того, як тільки ви навчитесь читати властивості швидкої перевірки, вони служать специфікацією машинного тестування .


4

Я точно не розумію, що означає відповідна відповідь під «досягнення модульності за допомогою математичних законів», але я думаю, що маю уявлення про те, що мається на увазі.

Перевірка функції :

Клас Functor визначається так:

 class Functor f where
   fmap :: (a -> b) -> f a -> f b

Це не стосується тестових випадків, а, скоріше, з декількома законами, які необхідно виконати.

Усі екземпляри Functor повинні підкорятися:

 fmap id = id
 fmap (p . q) = (fmap p) . (fmap q)

Скажімо, ви реалізуєте Functor( джерело ):

instance  Functor Maybe  where
    fmap _ Nothing       = Nothing
    fmap f (Just a)      = Just (f a)

Проблема полягає в тому, щоб переконатися, що ваша реалізація відповідає законам. Як ти це робиш?

Один із підходів - написання тестових випадків. Принциповим обмеженням цього підходу є те, що ви перевіряєте поведінку в обмеженій кількості випадків (удача вичерпно тестуючи функцію з 8 параметрами!), І тому проходження тестів не може гарантувати нічого, крім того, що тести проходять.

Інший підхід полягає у використанні математичних міркувань, тобто доказів, заснованих на фактичному визначенні (замість поведінки в обмеженій кількості випадків). Ідея тут полягає в тому, що математичний доказ може бути більш ефективним; однак це залежить від того, наскільки ваша програма піддається математичному підтвердженню.

Я не можу провести вас фактичним формальним доказом того, що вищезазначений Functorекземпляр відповідає законам, але я спробую дати конспект того, як може виглядати доказ:

  1. 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
  2. fmap (p . q) = (fmap p) . (fmap q)
    • якщо ми маємо Nothing
      • fmap (p . q) Nothing= Nothingза частиною 1
      • (fmap p) . (fmap q) $ Nothing= (fmap p) $ Nothing= Nothingза двома додатками частини 1
    • якщо ми маємо Just 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))за двома додатками частини другої

-1

"Остерігайтеся помилок у наведеному вище коді. Я лише довів це правильно, а не пробував." - Дональд Кнут

У ідеальному світі програмісти ідеальні і не помиляються, тому помилок немає.

У ідеальному світі комп'ютерні вчені та математики також ідеальні, і не помиляються.

Але ми не живемо в ідеальному світі. Тож ми не можемо розраховувати на програмістів, щоб не помилитися. Але ми не можемо припустити, що жоден комп'ютерний вчений, який надає математичний доказ правильності програми, не допустив помилок у цьому доказі. Тож я б не звертав уваги на те, щоб хтось намагався довести, що його код працює. Напишіть блок-тести і покажіть мені, що код поводиться відповідно до технічних характеристик. Ніщо інше мене ні в чому не переконує.


5
Одиничні тести можуть також мати помилки. Що ще важливіше, тести можуть показати лише наявність помилок - ніколи їх відсутність. Як заявив @Ingo у своїй відповіді, вони роблять великі перевірки добросовісності та добре доповнюють докази, але вони не замінюють їх.
Довал
Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.