Які функціональні еквіваленти обов'язкових заяв про перерви та інших перевірок циклу?


36

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

    public int doSomeCalc(int[] array)
    {
        int answer = 0;
        if(array!=null)
        {
            for(int e: array)
            {
                answer += e;
                if(answer == 10) break;
                if(answer == 150) answer += 100;
            }
        }
        return answer;
    }

Приклади в більшості блогів, статей ... Я бачу, як раз пояснюється простий випадок однієї прямої математичної функції, сказаної "Сума". Але у мене є логіка, схожа на вище написану на Java, і я хотів би перенести її на функціональний код у Clojure. Якщо ми не зможемо зробити вищезазначене на FP, тоді промоції для FP не вказують це прямо.

Я знаю, що наведений вище код абсолютно необхідний. Це не було написано з думкою про перехід його до FP у майбутньому.


1
Зауважте, що комбінація breakта return answerможе бути замінена returnвнутрішньою петлею. На FP ви могли реалізувати це раннє повернення, використовуючи продовження, див., Наприклад, en.wikipedia.org/wiki/Continuation
Джорджіо

1
@ Продовження Джорджіо тут були б величезною надмірністю. Це цикл у будь-якому випадку, для виклику його наступної ітерації ви робите хвостовий дзвінок, так що для перерви ви просто більше не називаєте його та просто повертаєте відповідь. Для вкладених циклів або іншого складного потоку управління саме тут ви можете використовувати продовження замість того, щоб переробляти для реструктуризації вашого коду, використовуючи вищеописану просту техніку (яка завжди має бути можливою, але може призвести до надмірно складної структури коду, яка б більш-менш пояснювалась продовження; і для більшої кількості точок виходу вони вам неодмінно знадобляться).
Чи буде Несс

8
У цьому випадку: takeWhile.
Джонатан У ролях

1
@WillNess: Я просто хотів згадати про це, оскільки його можна використовувати, щоб залишити складні обчислення в будь-якій точці. Це, мабуть, не найкраще рішення для конкретного прикладу ОП.
Джорджіо

@Giorgio ви маєте рацію, це найповніший, взагалі. насправді це питання дуже широке, IYKWIM (тобто було б закрите на SO у серцебитті).
Чи буде Несс

Відповіді:


45

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

doSomeCalc :: [Int] -> Int
doSomeCalc values = foldr1 combine values
  where combine v1 v2 | v1 == 10  = v1
                      | v1 == 150 = v1 + 100 + v2
                      | otherwise = v1 + v2

Розбиваючи цю лінію за рядком у випадку, якщо ви не знайомі з синтаксисом Haskell, це працює так:

doSomeCalc :: [Int] -> Int

Визначає тип функції, приймаючи список вкладень та повертаючи один int.

doSomeCalc values = foldr1 combine values

Основна частина функції: заданий аргумент values, зворотний foldr1виклик з аргументами combine(які ми визначимо нижче) та values. foldr1є варіантом примітиву згортання, який починається з акумулятора, встановленого на перше значення списку (звідси і 1в назві функції), а потім поєднує його за допомогою визначеної користувачем функції зліва направо (яку зазвичай називають правою складкою , звідси і rназва функції). Так що foldr1 f [1,2,3]еквівалентно f 1 (f 2 3)(або f(1,f(2,3))в більш звичайному C-подібному синтаксисі).

  where combine v1 v2 | v1 == 10  = v1

Визначення combineлокальної функції: вона отримує два аргументи v1та v2. Коли v110, він просто повертається v1. У цьому випадку v2 ніколи не оцінюється , тому цикл тут зупиняється.

                      | v1 == 150 = v1 + 100 + v2

Крім того, коли v1 дорівнює 150, додає до нього додаткових 100 і додає v2.

                      | otherwise = v1 + v2

І якщо жодне з цих умов не відповідає дійсності, просто додаємо v1 до v2.

Тепер це рішення дещо специфічне для Haskell, оскільки той факт, що правильна складка припиняється, якщо функція, що поєднує, не оцінює її другий аргумент, викликана лінивою стратегією оцінки Haskell. Я не знаю Clojure, але я вважаю, що він використовує сувору оцінку, тому я би очікував, що він матиме foldфункцію у своїй стандартній бібліотеці, яка включає конкретну підтримку для дострокового припинення. Це часто називають foldWhile, foldUntilабо аналогічний.

Швидкий огляд документації бібліотеки Clojure говорить про те, що вона мало відрізняється від більшості функціональних мов іменування, і foldце не те, що ви шукаєте (це більш досконалий механізм, спрямований на паралельні обчислення), але reduceє більш прямим еквівалент. Дострокове припинення відбувається, якщо reducedфункція викликається у вашій функції поєднання. Я не на 100% впевнений, що розумію синтаксис, але підозрюю, що ти шукаєш щось таке:

(reduce 
    (fn [v1 v2]
        (if (= v1 10) 
             (reduced v1)
             (+ v1 v2 (if (= v1 150) 100 0))))
    array)

Примітка: обидва переклади, Haskell і Clojure, не зовсім підходять для цього конкретного коду; але вони передають загальну суть цього питання - див. обговорення в коментарях нижче щодо конкретних проблем із цими прикладами.


11
імена v1 v2плутають: v1це "значення з масиву", але v2це накопичений результат. і ваш переклад помилковий, я вважаю, що цикл ОП виходить, коли накопичене (зліва) значення потрапляє на 10, а не на якийсь елемент у масиві. Те ж саме із збільшенням на 100. Якщо тут використовувати складки, використовуйте ліву складку з раннім виходом, дещо змінюється foldlWhile тут .
Чи буде Несс

2
це є смішно , як самий неправильну відповідь отримує більшість upvotes на SE .... це нормально , щоб зробити помилки, ви в хорошій компанії :) , теж. Але механізм виявлення знань на SO / SE безумовно порушений.
Чи буде Несс

1
Код Clojure майже правильний, але умова (= v1 150)використання значення до нього v2(ака. e) Підсумовується.
NikoNyrh

1
Breaking this down line by line in case you're not familiar with Haskell's syntax-- Ви - мій герой. Хаскелл для мене загадка.
Капітан Людина

15
@WillNess Це викликано тим, що це найбільш одразу зрозумілий переклад та пояснення. Факт, що це неправильно, є соромним, але відносно неважливим, оскільки незначні помилки не відмовляють від того, що відповідь інакше корисна. Але це, звичайно, слід виправити.
Конрад Рудольф

33

Ви можете легко перетворити його на рекурсію. І він має хороший оптимізований для хвоста рекурсивний дзвінок.

Псевдокод:

public int doSomeCalc(int[] array)
{
    return doSomeCalcInner(array, 0);
}

public int doSomeCalcInner(int[] array, int answer)
{
    if (array is empty) return answer;

    // not sure how to efficiently implement head/tails array split in clojure
    var head = array[0] // first element of array
    var tail = array[1..] // remainder of array

    answer += head;
    if (answer == 10) return answer;
    if (answer == 150) answer += 100;

    return doSomeCalcInner(tail, answer);
}

14
Так. Функціональним еквівалентом циклу є хвоста-рекурсія, а функціональний еквівалент умовному - все-таки умовний.
Йорг W Міттаг

4
@ JörgWMittag Я б сказала, що хвоста рекурсія є функціональним еквівалентом GOTO. (Не зовсім так погано, але все ще досить незручно.) Еквівалент петлі, як каже Жуль, - це відповідна складка.
близько

6
@leftaroundabout Я фактично не погоджуюся. Я б сказав, що хвіст рекурсії є більш обмеженим, ніж гото, враховуючи необхідність стрибати до себе і лише в положенні хвоста. Він принципово рівнозначний циклічній конструкції. Я б сказав, що рекурсія взагалі еквівалентна GOTO. У будь-якому випадку, коли ви компілюєте хвостову рекурсію, вона здебільшого просто зводиться до while (true)циклу з тілом функції, де раннє повернення є лише breakтвердженням. Складка, хоча ви вірно вважаєте, що це петля, насправді є більш обмеженою, ніж загальна циклічна конструкція; це більше схоже на цикл "для кожного"
J_mie6

1
@ J_mie6 Причина, яку я вважаю рекурсією хвоста більшою мірою, як це, GOTOце те, що вам потрібно непогано вести бухгалтерський облік того, які аргументи в якому стані передаються рекурсивному виклику, щоб переконатися, що він насправді веде себе так, як задумано. Це не потрібно в тій же мірі в гідно написаних імперативних циклах (де цілком зрозуміло, що таке змінні стану і як вони змінюються в кожній ітерації), ні в наївній рекурсії (де зазвичай не багато робиться з аргументами, а натомість результат збирається досить інтуїтивно). ...
близько

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

13

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

baseSums = scanl (+) 0

offsets = scanl (\offset sum -> if sum == 150 then offset + 100 else offset) 0

zipWithOffsets xs = zipWith (+) xs (offsets xs)

stopAt10 xs = if 10 `elem` xs then 10 else last xs

result = stopAt10 . zipWithOffsets . baseSums

result [1..]         -- 10
result [11..1000000] -- 500000499945

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


логіка тут розсіяна всюди. цей код буде нелегким у обслуговуванні. stopAt10це НЕ хороший споживач. Ваш відповідь це краще , ніж той , який ви процитувати в тому , що він правильно ізолює основний виробник scanl (+) 0значень. їхнє споживання має включати логіку управління безпосередньо, хоча краще реалізується лише з двома spans та a last, явно. що також уважно слідкувати за оригінальною структурою коду та логікою, і було б легко у обслуговуванні.
Чи буде Несс

6

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

Це досить прямий (можливо, не ідіоматичний) переклад на Clojure:

(defn doSomeCalc 
  ([lst] (doSomeCalc lst 0))
  ([lst sum]
    (if (empty? lst) sum
        (if (= sum 10) sum
            (let [sum (+ sum (first lst))]
                 [sum (if (= sum 150) (+ sum 100) sum)]
               (recur (rest lst) sum))))))) 

Edit: Жюль відзначають, що reduceв Clojure зробити підтримку раннього виходу. Використовувати це більш елегантно:

(defn doSomeCalc [lst]  
  (reduce (fn [sum val]
    (if (= sum 10) (reduced sum)
        (let [sum (+ sum val)]
             [sum (if (= sum 150) (+ sum 100) sum)]
           sum))
   lst)))

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


дивіться редагування, яке я щойно додав до своєї відповіді: reduceОперація Clojure підтримує ранній вихід.
Жуль

@Jules: Cool - це, мабуть, більш ідіоматичне рішення.
ЖакБ

Неправильно - чи це takeWhileне «звичайна операція»?
Джонатан У ролях

@jcast - хоча takeWhileце звичайна операція, вона не особливо корисна в цьому випадку, тому що вам потрібні результати вашої трансформації, перш ніж ви зможете вирішити, чи варто припиняти. Для ледачої мови це не має значення: ви можете використовувати scanі takeWhileрезультати сканування (див. Відповідь Карла Білефельдта, яка, хоча вона не використовується, takeWhileможе бути легко переписана для цього), але для такої суворої мови, як clojure, це означають обробку всього списку, а потім відкидання результатів. Однак функції генератора могли б це вирішити, і я вважаю, що клоджур їх підтримує.
Жуль

@Jules take-whileу Clojure створює ледачу послідовність (згідно з документами). Іншим способом вирішити це було б з перетворювачами (можливо, найкращим).
Чи буде Несс

4

Як вказували інші відповіді, Clojure повинен reducedприпинити скорочення:

(defn some-calc [coll]
  (reduce (fn [answer e]
            (let [answer (+ answer e)]
               (case answer
                 10  (reduced answer)
                 150 (+ answer 100)
                 answer)))
          0 coll))

Це найкраще рішення для вашої конкретної ситуації. Ви також можете отримати багато пробіг від об'єднання reducedз transduce, що дозволяє використовувати датчики з map, і filterтак далі Проте це далеко НЕ повну відповідь на ваш загальний питання.

Продовження евакуації - це узагальнена версія заяв про перерви та повернення. Вони безпосередньо реалізовані в деяких схемах ( call-with-escape-continuation), Common Lisp ( block+ return, catch+ throw) і навіть C ( setjmp+ longjmp). Більш загальні обмежені або необоронені продовження, як це знайдено в стандартній схемі або як монади продовження в Хаскеллі і Скалі, також можуть використовуватися як продовження втечі.

Наприклад, в Racket ви можете використовувати let/ecтак:

(define (some-calc ls)
  (let/ec break ; let break be an escape continuation
    (foldl (lambda (answer e)
             (let ([answer (+ answer e)])
               (case answer
                 [(10)  (break answer)] ; return answer immediately
                 [(150) (+ answer 100)]
                 [else  answer])))
           0 ls)))

Багато інших мов також мають подібні до продовження конструкції у вигляді обробки винятків. У Haskell ви також можете використати один із різних монадів помилок foldM. Оскільки вони в основному стосуються конструкцій з помилками, які використовують винятки або монади помилок для раннього повернення, як правило, культурно неприйнятні і, можливо, досить повільні.

Ви також можете перейти від функцій вищого порядку до хвостових викликів.

Під час використання циклів ви вводите наступну ітерацію автоматично, коли доходите до кінця тіла циклу. Ви можете ввести наступну ітерацію на початку із continueциклом або вийти з нього break(або return). Використовуючи хвостові дзвінки (або loopконструкцію Clojure, яка імітує рекурсію хвоста), ви завжди повинні робити явний виклик, щоб ввести наступну ітерацію. Для припинення циклу просто не здійснюйте рекурсивний дзвінок, а надайте значення безпосередньо:

(defn some-calc [coll]
  (loop [answer 0, [e es :as coll] coll]
    (if (empty? coll)
      answer
      (let [answer (+ answer e)]
        (case answer
          10 answer
          150 (recur (+ answer 100) es)
          (recur answer es))))))

1
Якщо ви використовуєте монади помилок у Haskell, я не вірю, що тут є реальна покарання за продуктивність. Вони, як правило, замислюються над принципами обробки винятків, але вони не працюють однаково, і не потрібно ніякої ходи по стеку, тому насправді це не повинно бути проблемою при використанні цього способу. Крім того, навіть якщо є культурні причини, щоб не використовувати щось на кшталт MonadError, в основному еквівалент Eitherне має такого упередженості щодо поводження з помилками, тому легко може використовуватися як заміна.
Жуль

@Jules Я думаю, що повернення ліворуч не заважає складці відвідувати весь список (або іншу послідовність). Хоча не тісно знайомий із стандартними внутрішніми бібліотеками Haskell.
nilern

2

Складна частина - це петля. Почнемо з цього. Цикл зазвичай перетворюється у функціональний стиль, виражаючи ітерацію однією функцією. Ітерація - це перетворення змінної циклу.

Ось функціональна реалізація загального циклу:

loop : v -> (v -> v) -> (v -> Bool) -> v
loop init iter cond_to_cont = 
    if cond_to_cont init 
        then loop (iter init) iter cond
        else init

Це займає (початкове значення змінної циклу, функція, яка виражає одну ітерацію [на змінній циклу]) (умова продовження циклу).

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

module Array (foldlc) where

foldlc : v -> (v -> e -> v) -> (v -> Bool) -> Array e -> v
foldlc init iter cond_to_cont arr = 
    loop 
        (init, 0)
        (λ (val, next_pos) -> (iter val (at next_pos arr), next_pos + 1))
        (λ (val, next_pos) -> and (cond_to_cont val) (next_pos < size arr))

В цьому :

Я використовую пару ((val, next_pos)), яка містить змінну циклу, видиму зовні і положення в масиві, яке приховує ця функція.

Функція ітерації трохи складніша, ніж у загальному циклі, ця версія дає можливість використовувати поточний елемент масиву. [Це у витриманому вигляді.]

Такі функції зазвичай називають "скласти".

Я ставлю "l" в імені, щоб вказати, що накопичення елементів масиву відбувається в ліво-асоціативному вигляді; імітувати звичку імперативних мов програмування для ітерації масиву від низького до високого індексу.

Я поставив "c" в імені, щоб вказати, що ця версія складки має умову, яка контролює, якщо і коли цикл потрібно зупинити достроково.

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

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

Змінна у вашому циклі - пара ('відповідь', булева, що кодує, чи слід продовжувати).

iter : (Int, Bool) -> Int -> (Int, Bool)
iter (answer, cont) collection_element = 
  let new_answer = answer + collection_element
  in case new_answer of
    10 -> (new_answer, false)
    150 -> (new_answer + 100, true)
    _ -> (new_answer, true)

Зауважте, що я використовував нову "змінну" "new_answer". Це тому, що у функціональному програмуванні я не можу змінити значення вже ініціалізованої "змінної". Я не переживаю за ефективність, компілятор може скористатися повторним використанням пам'яті "відповісти" для "new_answer" за допомогою аналізу життєвого часу, якщо він вважає, що це більш ефективно.

Включення цього в нашу функцію циклу, розроблену раніше:

doSomeCalc :: Array Int -> Int
doSomeCalc arr = fst (Array.foldlc (0, true) iter snd arr)

"Масив" - це назва модуля, який експортує функцію foldlc.

"кулак", "другий" стенд для функцій, що повертають перший, другий компонент пара його пари

fst : (x, y) -> x
snd : (x, y) -> y

У цьому випадку стиль «без точки» збільшує читабельність реалізації doSomeCalc:

doSomeCalc = Array.foldlc (0, true) iter snd >>> fst

(>>>) - це функціональний склад: (>>>) : (a -> b) -> (b -> c) -> (a -> c)

Це те саме, що вище, просто параметр "arr" залишається з обох сторін визначального рівняння.

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


0

По-перше, злегка перепишіть цикл, щоб кожна ітерація циклу була або на початку, або мутація answerрівно один раз:

    public int doSomeCalc(int[] array)
    {
        int answer = 0;
        if(array!=null)
        {
            for(int e: array)
            {
                if(answer + e == 10) return answer + e;
                else if(answer + e == 150) answer = answer + e + 100;
                else answer = answer + e;
            }
        }
        return answer;
    }

Повинно бути зрозуміло, що поведінка цієї версії точно така ж, як і раніше, але зараз набагато простіше переходити до рекурсивного стилю. Ось прямий переклад Haskell:

doSomeCalc :: [Int] -> Int
doSomeCalc = recurse 0
  where recurse :: Int -> [Int] -> Int
        recurse answer [] = answer
        recurse answer (e:array)
          | answer + e == 10 = answer + e
          | answer + e == 150 = recurse (answer + e + 100) array
          | otherwise = recurse (answer + e) array

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

import Control.Monad (foldM)

doSomeCalc :: [Int] -> Int
doSomeCalc = either id id . foldM go 0
  where go :: Int -> Int -> Either Int Int
        go answer e
          | answer + e == 10 = Left (answer + e)
          | answer + e == 150 = Right (answer + e + 100)
          | otherwise = Right (answer + e)

У цьому контексті він Leftрано припиняє свою цінність і Rightпродовжує рекурсію зі своїм значенням.


Тепер це можна трохи спростити, як-от так:

import Control.Monad (foldM)

doSomeCalc :: [Int] -> Int
doSomeCalc = either id id . foldM go 0
  where go :: Int -> Int -> Either Int Int
        go answer e
          | answer' == 10 = Left 10
          | answer' == 150 = Right 250
          | otherwise = Right answer'
          where answer' = answer + e

Це краще, ніж кінцевий код Haskell, але тепер трохи менш зрозуміло, як він відображається до початкової Java.

Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.