Пространственная сложность потоков в Схеме - PullRequest
5 голосов
/ 25 января 2020

Я читаю Структура и интерпретация компьютерных программ (SICP) и хотел бы убедиться в правильности моего мышления.

Рассмотрим следующий простой поток, используя рекурсивное определение:

(define (integers-starting-from n)
    (cons-stream n (integers-starting-from (+ n 1))))

(define ints (integers-starting-from 1))

(car (cdr-stream (cdr-stream (cdr-stream (cdr-stream ints)))))

Если мы примем реализацию в SICP, всякий раз, когда мы cons-stream, мы фактически используем переменную и лямбда-функцию (для отложенной оценки). Таким образом, когда мы cdr-stream вдоль этого потока, создаются вложенные лямбда-функции и сохраняется цепочка кадров для оценки лямбда-функций. Эти кадры необходимы, так как лямбда-функции оценивают выражения и находят их во вложенном кадре. Поэтому я полагаю, что для оценки n-го элемента потока вам необходимо сохранить n дополнительных кадров, занимающих линейное пространство.

Это отличается от поведения итераторы на других языках. Если вам нужно go далеко вниз по течению, много места будет занято. Конечно, можно только сохранить прямую вмещающую раму и выбросить все остальные наследственные рамы. Это то, что делает фактическая реализация схемы?

Ответы [ 3 ]

2 голосов
/ 26 января 2020

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

Например, вот совершенно наивная реализация force & delay в Racket:

(define-syntax-rule (delay form)
  (λ () form))

(define (force p)
  (p))

И мы можем построить достаточно чего-нибудь, немного совместимого с потоками SICP, чтобы быть опасными для этого:

(define-syntax-rule (cons-stream kar kdr)
  ;; Both car & cdr can be delayed: why not?  I think the normal thing is
  ;; just to delay the cdr
  (cons (delay kar) (delay kdr)))

(define (stream-car s)
  (force (car s)))

(define (stream-cdr s)
  (force (cdr s)))

(define (stream-nth s n)
  (if (zero? n)
      (stream-car s)
      (stream-nth (stream-cdr s) (- n 1))))

(Обратите внимание, что здесь много чего не хватает, потому что я ленивый.)

И на этом мы можем построить потоки целых чисел:

(define (integers-starting-from n)
  (cons-stream n (integers-starting-from (+ n 1))))

И теперь мы можем попробовать это:

(define naturals (integers-starting-from 0))

(stream-nth naturals 10000000)

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

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

(define-syntax-rule (delay form)
  (let ([thunk/value (λ () form)]
        [forced? #f])
    (λ ()
      (if forced?
          thunk/value
          (let ([value (thunk/value)])
            (set! thunk/value value)
            (set! forced? #t)
            value)))))

Весь остальной код одинаков.

Теперь, когда вы звоните (stream-nth naturals 10000000), у вас, вероятно, будет довольно плохое время: в частности, вам, скорее всего, не хватит памяти.

Причина, по которой вы будете иметь плохое время - это две вещи:

  • есть ссылка на весь поток в виде naturals;
  • , причудливые обещания запоминают свои значения, то есть весь хвост потока.

Это означает, что по мере того, как вы идете вниз по потоку, вы расходуете все большие объемы памяти до тех пор, пока не исчерпаете себя: сложность пространства программы идет как размер аргумента до stream-nth в последней строке.

Проблема здесь в том, что delay пытается быть умным, что бесполезно в этом случае. В частности, если вы думаете о потоках как об объектах, которые вы обычно просматриваете один раз, то запоминание их просто бесполезно: вы тщательно запомнили значение, которое никогда не будете использовать снова.

Версии delay & force предоставленный Racket memoize, и в этом случае он также будет использовать огромные объемы памяти.

Вы можете избежать этого либо не запоминать, либо быть уверенным, что никогда не держитесь за начало потока, поэтому G C можно забрать. В частности, эта программа

(define (silly-nth-natural n)
  (define naturals (integers-starting-from 0))
  (stream-nth naturals n))

не будет использовать пробел, пропорциональный n, потому что после того, как будет сделан первый хвостовой вызов stream-nth, ничто больше не будет удерживать начало потока.

Другой подход заключается в том, чтобы запомненное значение удерживалось только слабо, чтобы в случае отчаяния системы оно могло его отбросить. Вот хакерская и в основном не проверенная реализация этого (это очень специфично для Racket c):

(define-syntax-rule (delay form)
  ;; a version of delay which memoizes weakly
  (let ([thunk (λ () form)]
        [value-box #f])
    (λ ()
      (if value-box
          ;; the promise has been forced
          (let ([value-maybe (weak-box-value value-box value-box)])
            ;; two things that can't be in the box are the thunk
            ;; or the box itself, since we made those ourselves
            (if (eq? value-maybe value-box)
                ;; the value has been GCd
                (let ([value (thunk)])
                  (set! value-box (make-weak-box value))
                  value)
                ;; the value is good
                value-maybe))
          ;; the promise has not yet been forced
          (let ((value (thunk)))
            (set! value-box (make-weak-box value))
            value)))))

Я подозреваю, что огромное количество слабых блоков может заставить G C выполнять большую работу .

2 голосов
/ 26 января 2020

Короткий ответ, да, при правильных обстоятельствах непосредственно окружающая среда выбрасывается.

Я не думаю, что это произойдет в случае (car (cdr-stream (cdr-stream (cdr-stream (..., но если вы вместо этого посмотрите на stream-ref в разделе 3.5.1:

(define (stream-ref s n)
  (if (= n 0)
      (stream-car s)
      (stream-ref (stream-cdr s) (- n 1))))

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

Так что, возможно, ваш вопрос можно перефразировать следующим образом: " Учитывая то, что я теперь знаю об экологической модели оценки, как итерационные процессы используют постоянное пространство?"

Как вы говорите, это потому, что наследственные рамки выброшены. Как именно это происходит, описано далее в книге в главе 5, например, секта. 4.2 «Оценка последовательности и рекурсия хвоста», или, если вам нравятся видеоролики с лекциями, в лекция 9b .

Значительная часть главы 4 и главы 5 охватывает детали, необходимые для ответа этот вопрос явно. Или, как говорят авторы, рассеять волхвов c.

1 голос
/ 26 января 2020

"созданы вложенные лямбда-функции"

Нет. Существует нет вложенной области видимости. В

(define integers-starting-from 
  (lambda (n)
    (cons-stream n (integers-starting-from (+ n 1)))))

аргумент для вложенного вызова integers-starting-from в форме (integers-starting-from (+ n 1)), выражение (+ n 1), относится к привязке n в исходном вызове (integers-starting-from n), но (+ n 1) оценивается до совершения вызова .

Схема является нетерпеливым языком программирования, а не ленивым языком.

Таким образом, лямбда в результате cons-stream содержит ссылку на фрейм вызова, да, но нет вложенности окружений. Это значение уже получено до создания новой лямбды и возвращается как часть следующей ячейки cons, представляющей следующее состояние потока.

(define ints (integers-starting-from 1))
=
(define ints (let ((n 1))
    (cons-stream n (integers-starting-from (+ n 1)))))
=
(define ints (let ((n 1))
    (cons n (lambda () (integers-starting-from (+ n 1))))))

и звонок продолжается

(car (cdr-stream (cdr-stream ints)))
=
(let* ((ints         (let ((n 1))
                       (cons n 
                          (lambda () (integers-starting-from (+ n 1))))))
       (cdr-ints     ((cdr ints)))
       (cdr-cdr-ints ((cdr cdr-ints)))
       (res          (car cdr-cdr-ints)))
  res)
=
(let* ((ints         (let ((n 1))
                       (cons n 
                          (lambda () (integers-starting-from (+ n 1))))))
       (cdr-ints     ((cdr ints))
                     =
                     ((let ((n 1))
                         (lambda () (integers-starting-from (+ n 1)))))
                     =
                     (integers-starting-from 2)   ;; args before calls!
                     =
                     (let ((n 2))
                       (cons n 
                          (lambda () (integers-starting-from (+ n 1)))))
          )
       (cdr-cdr-ints ((cdr cdr-ints)))
       (res          (car cdr-cdr-ints)))
  res)
=
(let* ((ints         (let ((n 1))
                       (cons n 
                          (lambda () (integers-starting-from (+ n 1))))))
       (cdr-ints     (let ((n 2))
                       (cons n 
                          (lambda () (integers-starting-from (+ n 1))))))
       (cdr-cdr-ints (let ((n 3))
                       (cons n 
                          (lambda () (integers-starting-from (+ n 1))))))
       (res          (car cdr-cdr-ints)))
  res)
=
3

Так что здесь нет вложенных лямбд. Даже не цепочка лямбд, потому что реализация не запоминающаяся. Значения cdr-ints и cdr-cdr-ints являются эфемерными и могут быть собраны мусором во время вычисления третьего элемента. Ничто не имеет к ним никакого отношения.

Таким образом, получение n -го элемента выполняется в постоянном пространстве по модулю мусора , поскольку все промежуточные O (n) пробелы имеют право быть сборщиком мусора.

В ( одной возможной ) реализации запоминания каждая lambda будет фактически заменена своим результатом в ячейке cons , и там будет цепочка из трех - все еще не вложенных - лямбд, конгруэнтных открытому списку

(1 . (2 . (3 . <procedure-to-go-next>)))

В программах, которые не держат верхнюю запись таких цепочек, все промежуточные cons будут также иметь право на сборку мусора.

Одним из таких примеров, даже с не запоминающими потоками SICP, является сито Эратосфена . Его рабочие характеристики соответствуют отсутствию сохранения в памяти префиксных частей его внутренних потоков.

...