Що таке продовження Scala і навіщо їх використовувати?


85

Я щойно закінчив програмування в Scala і вивчаю зміни між Scala 2.7 та 2.8. Найбільш важливим є плагін для продовжень, але я не розумію, для чого він корисний і як він працює. Я переконався, що це добре для асинхронного вводу-виводу, але мені не вдалося з’ясувати, чому. Деякі з найбільш популярних ресурсів на цю тему:

І це питання щодо переповнення стека:

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

reset {
    ...
    shift { k: (Int=>Int) =>  // The continuation k will be the '_ + 1' below.
        k(7)
    } + 1
}
// Result: 8

Чому результат 8? Це, мабуть, допомогло б мені розпочати.


Відповіді:


38

Мій щоденник пояснює, що resetі чим shiftробити, тож ви можете прочитати це ще раз.

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

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

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

Тепер, ви не розумієте , навіть простий приклад на сторінці Scala, так само читають мій блог. У ньому мене цікавить лише пояснення цих основ, чому такий результат 8.


Я перечитав запис у вашому блозі, і цього разу затримався - думаю, я маю кращу уяву про те, що відбувається. Я не дуже багато отримав від сторінки Вікіпедії (я вже знаю продовження Lisp), але стиль відстрочки скидання / зсуву або як би там не називали мене запнув. Для нетерплячих (тобто мене самого) ваш опис було нормальним, але людям доведеться обов’язково дотримуватися його до «Результат скидання - це результат коду всередині зміни». параграф ... Я до цього моменту був безнадійно загублений, але це стає зрозумілішим! Я подивлюся на Рой, бо мені все ще цікаво, для чого це. Дякую!
Дейв

Так, потрібен час, поки все почне мати сенс. Я не відчував, що зможу втекти, роблячи пояснення швидше.
Даніель К. Собрал,

Це все склалося для мене, коли я прийшов до усвідомлення того, що "скидання обмежує область продовження. (Тобто: змінні та оператори, які слід включити.)
JeffV,

1
Ваше пояснення було багатослівним і не дійшло до суті розуміння. Приклади були довгими, я не отримав достатнього розуміння в перших абзацах, щоб надихнути мене прочитати це все. Тож я проголосував проти. SO відображає повідомлення після того, як я проголосую, просячи мене додати коментар, тому я відповідаю. Вибачаюся за мою відвертість.
Шелбі Мур III

1
Я писав про це в блозі, зосереджуючись на розумінні потоку управління (без обговорення деталей реалізації). wherenullpoints.com/2014/04/scala-continuations.html
Александрос

31

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

Коли викликається функція продовження cf:

  1. Виконання пропускає решту shiftблоку і починається знову в кінці його
    • переданий параметр - cfце те, що shiftблок "оцінює" при продовженні виконання. це може бути різним для кожного дзвінкаcf
  2. Виконання триває до кінця resetблоку (або до виклику, resetякщо блоку немає)
    • результат resetблоку (або параметр to reset(), якщо блоку немає) - це те, що cfповертається
  3. Виконання триває після cfзакінчення shiftблоку
  4. Виконання пропускається до кінця resetблоку (або виклик для скидання?)

Отже, у цьому прикладі дотримуйтесь літер від А до Я

reset {
  // A
  shift { cf: (Int=>Int) =>
    // B
    val eleven = cf(10)
    // E
    println(eleven)
    val oneHundredOne = cf(100)
    // H
    println(oneHundredOne)
    oneHundredOne
  }
  // C execution continues here with the 10 as the context
  // F execution continues here with 100
  + 1
  // D 10.+(1) has been executed - 11 is returned from cf which gets assigned to eleven
  // G 100.+(1) has been executed and 101 is returned and assigned to oneHundredOne
}
// I

Це друкує:

11
101

2
я отримав помилку кажучи «не може обчислення типу для КПСА-трансформованого результату функції» , коли я намагався скомпілювати його .. я не впевнений , що це не є ні як це виправити
Фабіо Veronez

@Fabio Veronez Додати оператор повернення до кінця зміни: зміна println(oneHundredOne) }, скажімо, println(oneHundredOne); oneHundredOne }.
folone

Гарне пояснення жахливого синтаксису. Оголошення функції продовження дивно відірване від її тіла. Я не хотів би ділитися таким кодом, що дряпає голову, з іншими.
joeytwiddle

Щоб уникнути cannot compute type for CPS-transformed function resultпомилки, +1слід слідувати відразу після oneHundredOne}. Коментарі, що зараз перебувають між ними, якось порушують граматику.
lcn

9

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

def f(k: Int => Int): Int = k(k(k(7)))
reset(
  shift(f) + 1   // replace from here down with `f(k)` and move to `k`
) * 2

Плагін Scala трансформує цей приклад таким чином, що обчислення (в межах аргументу введення reset), починаючи з кожного shiftдо виклику reset, замінюється функцією (наприклад f) input в shift.

Замінене обчислення переміщується (тобто переміщується) у функцію k. Функція fвводить функцію k, де k містить замінені обчислення, kвводи x: Int, а обчислення kзамінює shift(f)на x.

f(k) * 2
def k(x: Int): Int = x + 1

Що має такий самий ефект, як:

k(k(k(7))) * 2
def k(x: Int): Int = x + 1

Зверніть увагу, що тип Intвхідного параметра x(тобто підпис типу k) був заданий підписом типу вхідного параметра f.

Інший запозичений приклад із концептуально еквівалентною абстракцією, тобто readвведення функції до shift:

def read(callback: Byte => Unit): Unit = myCallback = callback
reset {
  val byte = "byte"

  val byte1 = shift(read)   // replace from here with `read(callback)` and move to `callback`
  println(byte + "1 = " + byte1)
  val byte2 = shift(read)   // replace from here with `read(callback)` and move to `callback`
  println(byte + "2 = " + byte2)
}

Я вважаю, що це було б перекладено на логічний еквівалент:

val byte = "byte"

read(callback)
def callback(x: Byte): Unit {
  val byte1 = x
  println(byte + "1 = " + byte1)
  read(callback2)
  def callback2(x: Byte): Unit {
    val byte2 = x
    println(byte + "2 = " + byte1)
  }
}

Сподіваюсь, це з’ясовує узгоджену загальну абстракцію, яка була дещо затуманена попереднім викладом цих двох прикладів. Наприклад, канонічний перший приклад був представлений в науково - дослідницькій роботі в якості анонімної функції, а НЕ мого імені f, таким чином , це було не відразу зрозуміло деякими читачам , що це було абстрактно , аналогічним тим , readв запозиченому другому прикладі.

Таким чином розмежовані продовження створюють ілюзію інверсії контролю від "ти кличеш мене ззовні reset" до "Я кличу тебе зсередини reset".

Зверніть увагу, що тип повернення fмає, але kне є, таким самим, що і тип повернення reset, тобто fмає свободу оголошувати будь-який тип повернення k, якщо fповертає той самий тип, що і reset. Так само для readі capture(див. Також ENVнижче).


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

Ми можемо явно досягти чистих функцій з обмеженими продовженнями.

def aread(env: ENV): Tuple2[Byte,ENV] {
  def read(callback: Tuple2[Byte,ENV] => ENV): ENV = env.myCallback(callback)
  shift(read)
}
def pure(val env: ENV): ENV {
  reset {
    val (byte1, env) = aread(env)
    val env = env.println("byte1 = " + byte1)
    val (byte2, env) = aread(env)
    val env = env.println("byte2 = " + byte2)
  }
}

Я вважаю, що це було б перекладено на логічний еквівалент:

def read(callback: Tuple2[Byte,ENV] => ENV, env: ENV): ENV =
  env.myCallback(callback)
def pure(val env: ENV): ENV {
  read(callback,env)
  def callback(x: Tuple2[Byte,ENV]): ENV {
    val (byte1, env) = x
    val env = env.println("byte1 = " + byte1)
    read(callback2,env)
    def callback2(x: Tuple2[Byte,ENV]): ENV {
      val (byte2, env) = x
      val env = env.println("byte2 = " + byte2)
    }
  }
}

Це стає галасливим через явне оточення.

Тангенціально зауважимо, що Scala не має висновку глобального типу Хаскелла, і, отже, наскільки мені відомо, не може підтримувати неявний підйом до монади штату unit(як одну із можливих стратегій приховування явного середовища), оскільки висновок загального типу (Хіндлі-Мілнер) Хаскелла залежить від непідтримки багатократного віртуального успадкування алмазів .


Я пропоную , що reset/ shiftзмінити на delimit/ replace. І за угодою, що fі readбуде with, і kта callbackбути replaced, captured, continuationабо callback.
Шелбі Мур III

з - це ключове слово. PS Деякі з ваших скидів мають (), що має бути {} У будь-якому випадку, це чудовий запис!
нафг

@nafg дякую, тому я запропоную replacementзамість with. Afaik, ()також дозволено? Afaik - {}це "полегшений синтаксис Scala для закриття" , який приховує основний виклик функції. Наприклад, подивіться, як я переписав Даніельsequence (зверніть увагу, що код ніколи не був складений або перевірений, тому, будь ласка, виправте мене).
Шелбі Мур III

1
Блок - тобто вираз, що містить кілька операторів - вимагає фігурних дужок.
нафг

@nafg, правильно. Afaik - shift resetце функції бібліотеки, а не ключові слова. Таким чином, {}або ()може використовуватися, коли функція очікує лише одного параметра . Scala має параметри By-name (див. Розділ "9.5 Абстракції управління" програмування в Scala, 2-е видання, стор. 218), де, якщо параметр має тип, () => ...його () =>можна виключити. Я припускаю, Unitа не по імені, тому що блок повинен оцінювати до того, як resetбуде викликаний, але мені потрібні {}кілька операторів. Моє використання для shiftправильне, оскільки воно, очевидно, вводить тип функції.
Шелбі Мур III

8

Продовження фіксує стан обчислення, яке буде викликане пізніше.

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

Я думаю, що значення, яке повертає вираз reset, є значенням виразу всередині виразу shift після =>, але щодо цього я не зовсім впевнений.

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

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

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


5

З моєї точки зору, найкраще пояснення було дано тут: http://jim-mcbeath.blogspot.ru/2010/08/delimited-continuations.html

Один із прикладів:

Щоб трохи чіткіше побачити потік управління, ви можете виконати цей фрагмент коду:

reset {
    println("A")
    shift { k1: (Unit=>Unit) =>
        println("B")
        k1()
        println("C")
    }
    println("D")
    shift { k2: (Unit=>Unit) =>
        println("E")
        k2()
        println("F")
    }
    println("G")
}

Ось результат, який видає наведений вище код:

A
B
D
E
G
F
C

1

Інша (нещодавно - травень 2016 р.) Стаття про продовження Scala:
Подорож у часі в Scala: CPS у Scala (продовження Scala)Шиванша Шрівастави ( shiv4nsh) .
Це також відноситься до Jim McBeath «s статті згадується в Дмитро Беспалов » s відповідь .

Але перед цим він описує продовження так:

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

Щоб пояснити це далі, ми можемо мати один з найбільш класичних прикладів,

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

У цьому описі sandwichце частина програмних даних (наприклад, об'єкт у купі), і замість того, щоб викликати процедуру “ make sandwich”, а потім повертатися, людина називає процедуру “ make sandwich with current continuation”, яка створює сендвіч, а потім продовжує там, де виконується залишився позаду.

Це вже сказано, як було оголошено в квітні 2014 року для Scala 2.11.0-RC1

Ми шукаємо тех, хто підтримує, щоб взяти на себе наступні модулі: скала-гойдалка , шкала-продовження .
2.12 не включатиме їх, якщо не буде знайдено нового супровідника .
Ми, ймовірно, продовжуватимемо підтримувати інші модулі (scala-xml, scala-parser-комбінатори), але допомога все ще дуже вдячна.


0

Продовження масштабу на значущих прикладах

Визначимо, from0to10що виражає ідею ітерації від 0 до 10:

def from0to10() = shift { (cont: Int => Unit) =>
   for ( i <- 0 to 10 ) {
     cont(i)
   }
}

Зараз,

reset {
  val x = from0to10()
  print(s"$x ")
}
println()

відбитки:

0 1 2 3 4 5 6 7 8 9 10 

Насправді нам не потрібно x:

reset {
  print(s"${from0to10()} ")
}
println()

друкує той самий результат.

І

reset {
  print(s"(${from0to10()},${from0to10()}) ")
}
println()

друкує всі пари:

(0,0) (0,1) (0,2) (0,3) (0,4) (0,5) (0,6) (0,7) (0,8) (0,9) (0,10) (1,0) (1,1) (1,2) (1,3) (1,4) (1,5) (1,6) (1,7) (1,8) (1,9) (1,10) (2,0) (2,1) (2,2) (2,3) (2,4) (2,5) (2,6) (2,7) (2,8) (2,9) (2,10) (3,0) (3,1) (3,2) (3,3) (3,4) (3,5) (3,6) (3,7) (3,8) (3,9) (3,10) (4,0) (4,1) (4,2) (4,3) (4,4) (4,5) (4,6) (4,7) (4,8) (4,9) (4,10) (5,0) (5,1) (5,2) (5,3) (5,4) (5,5) (5,6) (5,7) (5,8) (5,9) (5,10) (6,0) (6,1) (6,2) (6,3) (6,4) (6,5) (6,6) (6,7) (6,8) (6,9) (6,10) (7,0) (7,1) (7,2) (7,3) (7,4) (7,5) (7,6) (7,7) (7,8) (7,9) (7,10) (8,0) (8,1) (8,2) (8,3) (8,4) (8,5) (8,6) (8,7) (8,8) (8,9) (8,10) (9,0) (9,1) (9,2) (9,3) (9,4) (9,5) (9,6) (9,7) (9,8) (9,9) (9,10) (10,0) (10,1) (10,2) (10,3) (10,4) (10,5) (10,6) (10,7) (10,8) (10,9) (10,10) 

Тепер, як це працює?

Існує код , from0to10що викликається , та код , що телефонує . У цьому випадку це блок, який слід далі reset. Одним із параметрів, що передаються викликаному коду, є адреса повернення, яка показує, яка частина викличного коду ще не виконана (**). Ця частина телефонного коду є продовженням . Викликаний код може робити з цим параметром все, що він вирішить: передавати йому контроль, ігнорувати або викликати його кілька разів. Осьfrom0to10 викликається це продовження для кожного цілого числа в діапазоні 0..10.

def from0to10() = shift { (cont: Int => Unit) =>
   for ( i <- 0 to 10 ) {
     cont(i) // call the continuation
   }
}

Але де закінчення продовження? Це важливо, оскільки останній returnз продовження повертає керування до викликаного коду,from0to10 . У Scala він закінчується там, де resetзакінчується блок (*).

Тепер ми бачимо, що продовження оголошено як cont: Int => Unit. Чому? Ми викликаємо from0to10як val x = from0to10(), і Intце тип значення, яке переходить x.Unitозначає, що блок після не resetповинен повертати значення (інакше буде помилка типу). Загалом існує 4 підписи типу: введення функції, введення продовження, результат продовження, результат функції. Усі чотири повинні відповідати контексту виклику.

Вище ми надрукували пари значень. Давайте надрукуємо таблицю множення. Але як ми виводимо\n після кожного рядка?

Функція backдозволяє вказати, що потрібно робити, коли контроль повертається назад, від продовження до коду, який його викликав.

def back(action: => Unit) = shift { (cont: Unit => Unit) =>
  cont()
  action
}

backспочатку називає його продовження, а потім виконує дію .

reset {
  val i = from0to10()
  back { println() }
  val j = from0to10
  print(f"${i*j}%4d ") // printf-like formatted i*j
}

Друкується:

   0    0    0    0    0    0    0    0    0    0    0 
   0    1    2    3    4    5    6    7    8    9   10 
   0    2    4    6    8   10   12   14   16   18   20 
   0    3    6    9   12   15   18   21   24   27   30 
   0    4    8   12   16   20   24   28   32   36   40 
   0    5   10   15   20   25   30   35   40   45   50 
   0    6   12   18   24   30   36   42   48   54   60 
   0    7   14   21   28   35   42   49   56   63   70 
   0    8   16   24   32   40   48   56   64   72   80 
   0    9   18   27   36   45   54   63   72   81   90 
   0   10   20   30   40   50   60   70   80   90  100 

Що ж, зараз настав час для кількох мозкових крутиз. Є два виклики from0to10. Що є продовженням для першого from0to10? З цього випливає виклик from0to10в двійковому коді , але у вихідному коді також включає в себе оператор присвоювання val i =. Він закінчується там, де закінчується resetблок, але кінець resetблоку не повертає керування першим from0to10. Кінець resetблоку повертає управління до 2-го from0to10, що, в свою чергу, в кінцевому підсумку повертає керування до back, і саме це backповертає управління до першого виклику from0to10. Коли перший (так! 1-й!)from0to10 Вихід, виходить весь resetблок.

Такий метод повернення контролю назад називається зворотним відстеженням , це дуже стара техніка, відома принаймні з часів похідних Пролога та ШІ, орієнтованих на ШІ.

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

Примітка

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

(**) Одним із параметрів викликаного коду є адреса повернення, яка показує, яка частина викличного коду ще не виконана. Ну, у Scala для цього використовується послідовність адрес повернення. Скільки? Усі адреси повернення, розміщені в стеці викликів з моменту введення resetблоку.


UPD Частина 2 Відкидання продовжень: Фільтрування

def onEven(x:Int) = shift { (cont: Unit => Unit) =>
  if ((x&1)==0) {
    cont() // call continuation only for even numbers
  }
}
reset {
  back { println() }
  val x = from0to10()
  onEven(x)
  print(s"$x ")
}

Це друкує:

0 2 4 6 8 10 

Давайте розберемо дві важливі операції: відкидання продовження ( fail()) та передачу контролю на нього ( succ()):

// fail: just discard the continuation, force control to return back
def fail() = shift { (cont: Unit => Unit) => }
// succ: does nothing (well, passes control to the continuation), but has a funny signature
def succ():Unit @cpsParam[Unit,Unit] = { }
// def succ() = shift { (cont: Unit => Unit) => cont() }

Обидві версії succ()(вище) працюють. Виявляється, він shiftмає кумедний підпис, і хоча succ()нічого не робить, він повинен мати цей підпис для балансу типу.

reset {
  back { println() }
  val x = from0to10()
  if ((x&1)==0) {
    succ()
  } else {
    fail()
  }
  print(s"$x ")
}

як і слід було друкувати

0 2 4 6 8 10

У межах функції succ()не потрібно:

def onTrue(b:Boolean) = {
  if(!b) {
    fail()
  }
}
reset {
  back { println() }
  val x = from0to10()
  onTrue ((x&1)==0)
  print(s"$x ")
}

знову ж друкується

0 2 4 6 8 10

Тепер визначимось onOdd()за допомогою onEven():

// negation: the hard way
class ControlTransferException extends Exception {}
def onOdd(x:Int) = shift { (cont: Unit => Unit) =>
  try {
    reset {
      onEven(x)
      throw new ControlTransferException() // return is not allowed here
    }
    cont()
  } catch {
    case e: ControlTransferException =>
    case t: Throwable => throw t
  }
}
reset {
  back { println() }
  val x = from0to10()
  onOdd(x)
  print(s"$x ")
}

Вище, якщо xпарне, викидається виняток і продовження не викликається; якщо xнепарно, виняток не створюється, а викликається продовження. Наведений вище код друкує:

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