Previous Up Next

Chapter 3  Функциональный и императивный стиль

Введение

Языки функционального и императивного программирования различаются контролем выполнения программы и управлением памятью.

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

Исторически обе эти парадигмы программирования существовали в разных сферах: символьные программы для первого случая и числовые для второго. Однако, с тех времен кое–что изменилось, в частности появилась техника компиляции функциональных языков и выросла эффективность GC. С другой стороны, устойчивость выполнения стала важным критерием, иногда даже важнейшим критерием качества программного обеспечения. В подтверждение этому “коммерческий аргумент” языка Java: эффективность не должна преобладать над правильностью, оставаясь при этом благоразумно хорошей. Эта идея приобретает с каждым днем новых сторонников в мире производителей программного обеспечения.

Objective CAML придерживается этой позиции: он объединяет обе парадигмы, расширяя таким образом область своего применения и облегчая написание алгоритмов в том или ином стиле. Он сохраняет, однако, хорошие свойства правильности выполнения благодаря статической типизации, GC и механизму исключений. Исключения — это первая структура контроля выполнения позволяющая приостановить/продолжить расчет при возникновении определенных условий. Эта особенность находится на границе двух стилей программирования, хоть она и не изменяет результат, но может изменить порядок вычислений. Введение физически изменяемых значений может повлиять на чисто функциональную часть языка. Порядок вычисления аргументов функции становится определимым если это вычисление производит побочный эффект. По этим причинам подобные языки называются “не чистые функциональные языки”. Мы теряем часть абстракции, так как программист должен учитывать модель памяти и выполнение программы. Это не всегда плохо, в частности для читаемости кода. Однако, императивные особенности изменяют систему типов языка: некоторые функциональные программы, с теоретически правильными типами, не являются правильными на практике из-за введения ссылок (reference). Хотя такие программы могут быть легко переписаны.

План главы

В этой главе мы сравним функциональную и императивную модель Objective CAML по критерию контроля выполнения программы и представления значений в памяти. Смесь обоих стилей позволяет конструировать новые структуры данных. Это будет рассмотрено в первом разделе. Во втором разделе мы обсудим выбор между композицией функций (composition of functions) или последовательности (sequencing) с одной стороны и разделение (sharing) или копирование значений с другой. Третий раздел выявляет интерес к смешиванию двух стилей для создания функциональных изменяемых данных (mutable functional data), что позволит создавать не полностью вычисленные (evaluated) данные. В четвертом разделе рассмотрены streams, потенциально бесконечные потоки данных и их интеграция, посредством сопоставления с образцом.

3.1  Сравнение между функциональным и императивным стилями

Воспользуемся строками (string) и списками ('a list) для иллюстрации разницы между двумя стилями.

3.1.1  С функциональной стороны

map это одна из классических функций в среде функциональных языков. В чистом функциональном стиле она пишется так:

# let rec map f l = match l with [] -> [] | t::q -> (f t) :: (map f q) ;; val map : ('a -> 'b) -> 'a list -> 'b list = <fun>

Конечный список состоит из результатов применения функции f к элементам списка переданного в аргументе. Он рекурсивно создается указывая начальный элемент (заголовок) (f t) и последующую часть (хвост) (map f q). В частности, программа не указывает какой из них будет вычислен первым.

При этом, для написания такой функции программисту не нужно знать физическое представление данных. Проблемы выделения памяти и разделения данных решаются Objective CAML без внешнего вмешательства программиста. Следующий пример иллюстрирует это:

# let example = [ "one" ; "two" ; "three" ] ;; val example : string list = ["one"; "two"; "three"] # let result = map (function x -> x) example ;; val result : string list = ["one"; "two"; "three"]

Оба списка example и result содержат одинаковые значения:

# example = result ;; - : bool = true

Оба значения имеют одинаковую структуру, хоть они и представлены в памяти по разному. Убедимся в этом при помощи проверки на физическое равенство:

# example == result ;; - : bool = false (List.tl example) == (List.tl result) ;; - : bool = false

3.1.2  Императивная сторона

Вернемся к предыдущему примеру и изменим строку списка result.

(List.hd result).[1] <- 's' ;; - : unit = () # result ;; - : string list = ["ose"; "two"; "three"] # example ;; - : string list = ["ose"; "two"; "three"]

Определено, изменив список result, мы изменили список example. То есть знание физической структуры необходимо как только мы пользуемся императивными особенностями.

Рассмотрим теперь как порядок вычисления аргументов функции может стать ловушкой в императивном программировании. Определим структуру изменяемого списка, а так же функции создания, добавления и доступа:

# type 'a ilist = { mutable c : 'a list } ;; type 'a ilist = { mutable c: 'a list } # let icreate () = { c = [] } let iempty l = (l.c = []) let icons x y = y.c <- x::y.c ; y let ihd x = List.hd x.c let itl x = x.c <- List.tl x.c ; x ;; val icreate : unit -> 'a ilist = <fun> val iempty : 'a ilist -> bool = <fun> val icons : 'a -> 'a ilist -> 'a ilist = <fun> val ihd : 'a ilist -> 'a = <fun> val itl : 'a ilist -> 'a ilist = <fun> # let rec imap f l = if iempty l then icreate() else icons (f (ihd l)) (imap f (itl l)) ;; val imap : ('a -> 'b) -> 'a ilist -> 'b ilist = <fun>

Несмотря на то что мы переняли общую структуру функции map предыдущего параграфа, с imap мы получим другой результат:

# let example = icons "one" (icons "two" (icons "three" (icreate()))) ;; val example : string ilist = {c=["one"; "two"; "three"]} # imap (function x -> x) example ;; Uncaught exception: Failure("hd")

В чем тут дело? Вычисление (itl l) произошло раньше чем (ihd l) и в последней итерации imap, список l пустой в момент обращения к его заголовку. Список example теперь пуст, хоть мы и не получили никакого результата:

# example ;; - : string ilist = {c=[]}

Проблема функции imap в недостаточном контроле смеси стилей программирования: мы предоставили системе выбор порядка вычисления. Переформулируем функцию imap явно указав порядок при помощи конструкции let .. in ..

# let rec imap f l = if iempty l then icreate() else let h = ihd l in icons (f h) (imap f (itl l)) ;; val imap : ('a -> 'b) -> 'a ilist -> 'b ilist = <fun> # let example = icons "one" (icons "two" (icons "three" (icreate()))) ;; val example : string ilist = {c=["one"; "two"; "three"]} # imap (function x -> x) example ;; - : string ilist = {c=["one"; "two"; "three"]}

Однако, начальный список опять утерян:

# example ;; - : string ilist = {c=[]}

Использование оператора последовательности (sequencing operator) и цикла есть другой способ явного указания порядка.

# let imap f l = let l_res = icreate () in while not (iempty l) do ignore (icons (f (ihd l)) l_res) ; ignore (itl l) done ; { l_res with c = List.rev l_res.c } ;; val imap : ('a -> 'b) -> 'a ilist -> 'b ilist = <fun> # let example = icons "one" (icons "two" (icons "three" (icreate()))) ;; val example : string ilist = {c=["one"; "two"; "three"]} # imap (function x -> x) example ;; - : string ilist = {c=["one"; "two"; "three"]}

Присутствие ignore — это факт того что нас интересует побочный эффект функций над ее аргументами, а не их (функций) результат. Так же было необходимо выстроить в правильном порядке элементы результата (функцией List.rev).

3.1.3  Рекурсия или итерация

Часто ошибочно ассоциируют рекурсию с функциональным стилем и императивный с итерацией. Чисто функциональная программа не может быть итеративной, так как значение условия цикла не меняется никогда. Тогда как императивная программа может быть рекурсивной: функция imap тому пример.

Значения аргументов функции хранятся во время ее выполнения. Если она (функция) вызовет другую функцию, то эта последняя сохранит и свои аргументы. Эти значения хранятся в стеке выполнения. При возврате из функции эти значения извлечены из стека. Зная что пространство памяти ограничено, мы можем дойти до ее предела, используя функцию с очень большой глубиной рекурсии. В подобных случаях Objective CAML возбуждает исключение Stack_overflow.

# let rec succ n = if n = 0 then 1 else 1 + succ (n-1) ;; val succ : int -> int = <fun> # succ 100000 ;; Stack overflow during evaluation (looping recursion?).

В итеративной версии место занимаемое функцией succ_iter в стеке не зависит от величины аргумента.

# let succ_iter n = let i = ref 0 in for j=0 to n do incr i done ; !i ;; val succ_iter : int -> int = <fun> # succ_iter 100000 ;; - : int = 100001

У следующей версии функции a priori аналогичная глубина рекурсии, однако она успешно выполняется с тем же аргументом.

# let succ_rt n = let rec succ_aux n accu = if n = 0 then accu else succ_aux (n-1) (accu+1) in succ_aux 1 n ;; val succ_rt : int -> int = <fun> # succ_rt 100000 ;; - : int = 100001

Данная функция имеет специальную форму рекурсивного вызова, так называемую хвостовую рекурсию при которой результат вызова функции будет результатом функции без дополнительных вычислений. Таким образом отпадает необходимость хранить аргументы функции во время вычисления рекурсивного вызова. Если Objective CAML распознал конечную рекурсивную функцию, то перед ее вызовом аргументы извлекаются из стека.

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

3.2  Какой стиль выбрать?

Естественно, речь не идет о “священных” либо эстетических для каждого понятиях, a priori не существует стиля красивее или лучше чем другой. Однако, в зависимости от решаемой проблемы, один стиль может быть более подходящим или более адаптированным чем другой.

Первое правило — простота. Желаемый алгоритм (будь то в книге или лишь в голове программиста) уже определен в каком-то стиле, вполне естественно его и использовать при реализации.

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

3.2.1  Последовательность или композиция функций

Мы уже видели, что как только в программе появляются побочные эффекты, необходимо явно указывать порядок выполнения элементов программы. Это может быть сделано двумя способами:

функциональным
опираясь на то что Objective CAML строгий язык, то есть аргумент вычислен до того как он будет передан функции. В выражение (f (g x)) сначала вычисляется (g x) и затем передача этого результат функции f. В более сложных выражениях, промежуточный результат может быть именован конструкцией let in, но принцип остается тем же: let aux=(g x) in (f aux).
императивный
используя последовательность или другую структуру контроля (цикл). В этом случае, результатом будет побочный эффект над памятью, а не значение возвращенное функцией : aux:=(g x) ; (f!aux).

Давайте рассмотрим данную проблему выбора стиля на следующем примере. Рекурсивный алгоритм быстрой сортировки вектора описывается следующим образом:

  1. выбрать опорную точку: выбрать индекс элемента в векторе
  2. переставить элементы вокруг этой точки: переставить элементы вектора так чтобы значения меньше значения в опорной точке были слева от нее, а большие значения справа
  3. отсортировать (тем же алгоритмом) два полученных вектора: элементы после опорной точки и до нее

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

Начнем с определения функции переставляющей элементы вектора.

# let permute_element vec n p = let aux = vec.(n) in vec.(n) <- vec.(p) ; vec.(p) <- aux ;; val permute_element : 'a array -> int -> int -> unit = <fun>

Выбор правильной точки опоры важен для эффективности алгоритма, но мы ограничимся самым простым способом: вернем индекс первого элемента вектора.

# let choose_pivot vec start finish = start ;; val choose_pivot : 'a -> 'b -> 'c -> 'b = <fun>

Напишем теперь желаемый алгоритм переставляющий элементы вектора вокруг выбранной точки.

При реализации алгоритма мы естественно воспользуемся императивными управляющими структурами.

# let permute_pivot vec start finish ind_pivot = permute_element vec start ind_pivot ; let i = ref (start+1) and j = ref finish and pivot = vec.(start) in while !i < !j do if vec.(!j) >= pivot then decr j else begin permute_element vec !i !j ; incr i end done ; if vec.(!i) > pivot then decr i ; permute_element vec start !i ; !i ;; val permute_pivot : 'a array -> int -> int -> int -> int = <fun>

Кроме эффекта над вектором, функция возвращает индекс опорной точки.

Нам остается лишь собрать воедино различные этапы и написать рекурсивный вызов для подвекторов.

# let rec quick vec start finish = if start < finish then let pivot = choose_pivot vec start finish in let place_pivot = permute_pivot vec start finish pivot in quick (quick vec start (place_pivot-1)) (place_pivot+1) finish else vec ;; val quick : 'a array -> int -> int -> 'a array = <fun>

Здесь мы воспользовались двумя стилями. Выбранная опорная точка служит аргументом при перестановке вокруг нее и ее порядок в векторе после этой процедуры служит аргументом рекурсивного вызова. Зато, полученный после перестановки вектор мы получаем в результате побочного эффекта, а не как возвращенное значение функции permute_pivot. Тогда как функция quick возвращает вектор и сортировка подвекторов реализуется композицией рекурсивных вызовов.

Теперь главная функция:

# let quicksort tab = quick tab 0 ((Array.length tab)-1) ;; val quicksort : 'a array -> 'a array = <fun>

Это полиморфная функция, так как отношение порядка < полиморфное.

# let t1 = [|4;8;1;12;7;3;1;9|] ;; val t1 : int array = [|4; 8; 1; 12; 7; 3; 1; 9|] # quicksort t1 ;; - : int array = [|1; 1; 3; 4; 7; 8; 9; 12|] # t1 ;; - : int array = [|1; 1; 3; 4; 7; 8; 9; 12|] # let t2 = [|"the"; "little"; "cat"; "is"; "dead"|] ;; val t2 : string array = [|"the"; "little"; "cat"; "is"; "dead"|] # quicksort t2 ;; - : string array = [|"cat"; "dead"; "is"; "little"; "the"|] # t2 ;; - : string array = [|"cat"; "dead"; "is"; "little"; "the"|]

3.2.2  Общее использование или копии значений

До тех пор пока наши данные не изменяемые, нет необходимости знать используются они совместно или нет.

# let id x = x ;; val id : 'a -> 'a = <fun> # let a = [ 1; 2; 3 ] ;; val a : int list = [1; 2; 3] # let b = id a ;; val b : int list = [1; 2; 3]

Является ли b копией a или же это один и тот же список не имеет значения, так как по любому это “неосязаемые” значения. Однако, если на место целых чисел, мы поместим изменяемые значения, необходимо будет знать скажется ли изменение одного значения на другое.

Реализация полиморфизма Objective CAML вызывает (causes) копирование непосредственных значений (immediate values) и разделение (sharing) структурных значений. Хоть передача аргументов осуществляется копированием, в случае структурных значений передается лишь указатель. Как в случае с функцией id.

# let a = [| 1 ; 2 ; 3 |] ;; val a : int array = [|1; 2; 3|] # let b = id a ;; val b : int array = [|1; 2; 3|] # a.(1) <- 4 ;; - : unit = () # a ;; - : int array = [|1; 4; 3|] # b ;; - : int array = [|1; 4; 3|]

Здесь мы действительно имеем случай выбора стиля программирования, от которого зависит эффективность представления данных. С одной стороны, использование изменяемых значений позволяет немедленное манипулирование данными (без дополнительного выделения памяти). Однако это вынуждает, в некоторых случаях, делать копии там, где использование неизменяемого значения позволило бы разделение. Проиллюстрируем это двумя способами реализации списков.

# type 'a list_immutable = LInil | LIcons of 'a * 'a list_immutable ;; # type 'a list_mutable = LMnil | LMcons of 'a * 'a list_mutable ref ;;

Фиксированные списки строго эквивалентны спискам Objective CAML, тогда как изменяемые больше в стиле C, где ячейка состоит из значения и ссылки на следующую ячейку.

С фиксированными списками существует единственный способ реализовать конкатенацию и он вынуждает копирование структуры первого списка, тогда как второй список может быть разделен с конечным списком.

# let rec concat l1 l2 = match l1 with LInil -> l2 | LIcons (a,l11) -> LIcons(a, (concat l11 l2)) ;; val concat : 'a list_immutable -> 'a list_immutable -> 'a list_immutable = <fun> # let li1 = LIcons(1, LIcons(2, LInil)) and li2 = LIcons(3, LIcons(4, LInil)) ;; val li1 : int list_immutable = LIcons (1, LIcons (2, LInil)) val li2 : int list_immutable = LIcons (3, LIcons (4, LInil)) # let li3 = concat li1 li2 ;; val li3 : int list_immutable = LIcons (1, LIcons (2, LIcons (3, LIcons (4, LInil)))) # li1==li3 ;; - : bool = false # let tlLI l = match l with LInil -> failwith "Liste vide" | LIcons(_,x) -> x ;; val tlLI : 'a list_immutable -> 'a list_immutable = <fun> # tlLI(tlLI(li3)) == li2 ;; - : bool = true

В этом примере мы видим что первые ячейки li1 и li3 различны, тогда как вторая часть li3 есть именно li2.

С изменяемыми списками можно изменить аргументы (функция concat_share) или создать новое значение (функция concat_copy)

# let rec concat_copy l1 l2 = match l1 with LMnil -> l2 | LMcons (x,l11) -> LMcons(x, ref (concat_copy !l11 l2)) ;; val concat_copy : 'a list_mutable -> 'a list_mutable -> 'a list_mutable = <fun>

Это решение, concat_copy, аналогично предыдущей функции concat. Вот второе вариант, с общим использованием.

# let concat_share l1 l2 = match l1 with LMnil -> l2 | _ -> let rec set_last = function LMnil -> failwith "concat_share : impossible case!!" | LMcons(_,l) -> if !l=LMnil then l:=l2 else set_last !l in set_last l1 ; l1 ;; val concat_share : 'a list_mutable -> 'a list_mutable -> 'a list_mutable = <fun>

Конкатенация с общим использованием не нуждается в выделении памяти (мы не используем конструктор LMcons), мы лишь ограничиваемся тем что последняя ячейка первого списка теперь указывает на второй список. При этом, конкатенация способна изменить аргументы переданные функции.

# let lm1 = LMcons(1, ref (LMcons(2, ref LMnil))) and lm2 = LMcons(3, ref (LMcons(4, ref LMnil))) ;; val lm1 : int list_mutable = LMcons (1, {contents=LMcons (2, {contents=LMnil})}) val lm2 : int list_mutable = LMcons (3, {contents=LMcons (4, {contents=LMnil})}) # let lm3 = concat_share lm1 lm2 ;; val lm3 : int list_mutable = LMcons (1, {contents=LMcons (2, {contents=LMcons (...)})})

Мы получили ожидаемый результат для lm3, однако значение lm1 изменено.

# lm1 ;; - : int list_mutable = LMcons (1, {contents=LMcons (2, {contents=LMcons (...)})})

Таким образом это может повлиять на оставшуюся часть программы.

3.2.3  Критерии выбора

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

С другой стороны, программа написанная в императивном стиле, унаследует следующие достоинства Objective CAML: правильность статической типизации, автоматическое управление памятью, механизм исключений, параметризованный полиморфизм и вывод типов.

Выбор между стилем функциональным и императивным зависит от программного обеспечения которое вы хотите реализовать. Выбор может быть сделан в соответствии со следующими характеристиками:

выбор структур данных:
от использования или нет изменяемых структур данных будет зависеть выбор стиля программирования. Действительно, функциональный стиль по своей природе не совместим с изменением значений. Однако, создание и просмотр значения не зависят от ее свойств. Таким образом мы возвращаемся к дискуссии “изменение на месте vs копия” в 3.2.2 на стр.  ??, к которой мы вернемся для обсуждения критерия эффективности.
структура данных существует:
если в программе необходимо менять изменяемые структуры данных (modify mutable data structures), то императивный стиль является единственно возможным. Однако, если необходимо лишь читать значения, то применение функционального стиля гарантирует целостность данных. Использование рекурсивных структур данных подразумевает рекурсивные функции, которые могут быть определены используя особенности того или иного стиля программирования. Однако в общем случае бывает проще истолковывать создание значения следуя рекуррентному определению. Этот подход более близок к функциональному стилю, чем повторяющаяся обработка этого значения рекурсией.
критерий эффективности:
немедленное изменение без сомнения лучше чем создание значения. В случае когда эффективность кода является главенствующим критерием, чаша весов перевешивает в сторону императивного стиля. Однако отметим что совместное использование значений может оказаться нелегкой задачей и в конце концов более дорогостоящей, чем копирование значений с самого начала. Чистая функциональность имеет определенную цену: частичное применение (partial application) и использование функций переданных в виде аргумента другой функции несет более серьезные накладные расходы в процессе выполнения программы, чем явное применение видимой функции. Стоит избегать использования этой функциональной особенности в тех местах, где критерий производительности является решающим.
критерий разработки:
более высокий уровень абстракции функционального стиля программирования позволяет более быстрое написание компактного кода, содержащего меньше ошибок чем императивный вариант, который по своей природе является более “многословным”. Таким образом, функциональный стиль является выгодным при разработке значительных программ. Независимость функции к своему контексту окружения позволяет разделить код на более маленькие части и тем самым облегчить его проверку и читаемость. Более высокая модульность функционального стиля плюс возможность передавать функции (а значит и обработку) в аргумент другим функциям повышает вероятность повторного использования программ.

Эти несколько замечаний подтверждают тот факт, что часто смешанное использование обоих стилей является рациональным выбором. Функциональный стиль быстрее в разработке и обеспечивает более простую организацию программы, однако части кода где необходимо повысить скорость выполнения лучше написать в императивном стиле.

3.3  Смесь стилей

Как мы уже заметили, язык программирования имеющий функциональные и императивные особенности дает свободу выбора наиболее подходящего стиля для реализации того или иного алгоритма. Конечно, мы можем использовать оба аспекта Objective CAML совместно в одной и той же функции. Именно этим мы сейчас и займемся.

3.3.1  Замыкание и побочные эффекты

Обычно функция с побочным эффектом рассматривается как процедура и она возвращает значение () типа unit. Однако, иногда бывает полезно произвести побочный эффект внутри функции и вернуть определенное значение. Мы уже использовали подобный коктейль стилей в функции permute_pivot в быстрой сортировке.

В следующем примере реализуем генератор символов, который создает новый символ при каждом вызове функции. Мы используем счетчик, значение которого увеличивается с каждым вызовом.

# let c = ref 0;; val c : int ref = {contents=0} # let reset_symb = function () -> c:=0 ;; val reset_symb : unit -> unit = <fun> # let new_symb = function s -> c:=!c+1 ; s^(string_of_int !c) ;; val new_symb : string -> string = <fun> # new_symb "VAR" ;; - : string = "VAR1" # new_symb "VAR" ;; - : string = "VAR2" # reset_symb () ;; - : unit = () # new_symb "WAR" ;; - : string = "WAR1" # new_symb "WAR" ;; - : string = "WAR2"

А теперь спрячем ссылку на c от всей программы следующим образом:

# let (reset_s , new_s) = let c = ref 0 in let f1 () = c := 0 and f2 s = c := !c+1 ; s^(string_of_int !c) in (f1,f2) ;; val reset_s : unit -> unit = <fun> val new_s : string -> string = <fun>

Таким образом мы объявили пару функций, разделяющие локальную (для декларации) переменную c. Использование обеих функций производит тот же результат что и раньше.

# new_s "VAR";; - : string = "VAR1" # new_s "VAR";; - : string = "VAR2" # reset_s();; - : unit = () # new_s "WAR";; - : string = "WAR1" # new_s "WAR";; - : string = "WAR2"

Этот пример иллюстрирует способ представления замыкания. Замыкание можно рассматривать как пару, состоящую из кода (то есть часть function) и локальное окружение, содержащее значения свободных переменных замыкания с другой стороны. На диаграмме 3.1 мы можем увидеть представление в памяти замыканий reset_s и new_s.


Figure 3.1: Представление в памяти замыканий

Оба эти замыкания разделяют одно и тоже окружение (значение c). Когда одно из них меняет ссылку c, то оно меняет содержимое памяти разделяемое с другим окружением.

3.3.2  Физическое изменение и исключения

Исключения позволяет отслеживать ситуацию, при которой дальнейшее продолжение программы невозможно. В подобном случае сборщик исключений позволит продолжить вычисление, зная что произошла ошибка. В момент возбуждения исключения может возникнуть проблема побочного эффекта с состоянием модифицируемых данных. Это состояние не может быть гарантированно если произошли физические изменения в ответвлении неудавшегося вычисления.

Определим функцию увеличения (++), с аналогичным результатом что и в C:

# let (++) x = x:=!x+1; x;; val ++ : int ref -> int ref = <fun>

Следующий пример, иллюстрирует небольшой расчет в котором деление на ноль совпадает с побочным эффектом:

# let x = ref 2;; val x : int ref = {contents=2} (* 1 *) !((++) x) * (1/0) ;; Uncaught exception: Division_by_zero # x;; - : int ref = {contents=2} (* 2 *) (1/0) * !((++) x) ;; Uncaught exception: Division_by_zero # x;; - : int ref = {contents=3}

Переменная x не изменяется во время вычисления выражения (*1*), тогда как она изменяется во время (*2*). Если заранее не сохранить начальные значения, конструкция try .. with .. не должна (в части with ..) зависеть от изменяемых переменных, которые участвуют в вычислении возбудившем исключение.

3.3.3  Изменяемые функциональные структуры данных

В функциональном программирование программа, как функциональное выражение, является в то же время данными — чтобы уяснить этот момент напишем список ассоциированных значений в виде функционального выражения. Этот список ассоциаций ('a * 'b) list можно рассматривать как частичную функцию из 'a (множество ключей) в 'b (множество соответствующих значений). Другими словами, функция 'a -> 'b.

Пустой список это неопределенная функция, которую мы будем моделировать возбуждением исключения:

# let nil_assoc = function x -> raise Not_found ;; val nil_assoc : 'a -> 'b = <fun>

Теперь напишем функцию add_assoc добавляющую элемент в список или другими словами расширим функцию новыми значениями.

# let add_assoc (k,v) l = function x -> if x = k then v else l x ;; val add_assoc : 'a * 'b -> ('a -> 'b) -> 'a -> 'b = <fun> # let l = add_assoc ('1', 1) (add_assoc ('2', 2) nil_assoc) ;; val l : char -> int = <fun> # l '2' ;; - : int = 2 # l 'x' ;; Uncaught exception: Not_found

Перепишем функцию mem_assoc:

# let mem_assoc k l = try (l k) ; true with Not_found -> false ;; val mem_assoc : 'a -> ('a -> 'b) -> bool = <fun> # mem_assoc '2' l ;; - : bool = true # mem_assoc 'x' l ;; - : bool = false

Однако, написание функции удаляющей элементы из списка, не совсем простое дело. У нас больше нет доступа к значениям входящим в замыкание. Для этого мы спрячем старое значение возбудив исключения Not_found.

# let rem_assoc k l = function x -> if x=k then raise Not_found else l x ;; val rem_assoc : 'a -> ('a -> 'b) -> 'a -> 'b = <fun> # let l = rem_assoc '2' l ;; val l : char -> int = <fun> # l '2' ;; Uncaught exception: Not_found

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

# let add_assoc_again (k,v) l = l := (function x -> if x=k then v else !l x) ;; val add_assoc_again : 'a * 'b -> ('a -> 'b) ref -> unit = <fun>

Мы получили функцию l которая указывает сама на саму себя и значит будет зацикливаться. Этот досадный побочных эффект есть результат того что разыменование !l находится внутри замыкания function x ->. Значение !l вычислено при выполнении, а не компиляции. В этот момент, l указывает на значение измененное add_assoc. Необходимо исправить наше определение используя замыкание полученное определением add_assoc:

# let add_assoc_again (k, v) l = l := add_assoc (k, v) !l ;; val add_assoc_again : 'a * 'b -> ('a -> 'b) ref -> unit = <fun> # let l = ref nil_assoc ;; val l : ('_a -> '_b) ref = {contents=<fun>} # add_assoc_again ('1',1) l ;; - : unit = () # add_assoc_again ('2',2) l ;; - : unit = () !l '1' ;; - : int = 1 !l 'x' ;; Uncaught exception: Not_found

3.3.4  Ленивые изменяемые структуры

Смесь императивных особенностей с функциональным языком образует хорошие средства реализации языков программирования. В данном параграфе мы проиллюстрируем эту особенность в реализации структур данных с отсроченным вычислением. Такая структура данных не вычисляется полностью, вычисление продвигается в зависимости от использования структуры.

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

Определим тип vm элементы которого либо уже вычисленное значение (конструктор Imm) либо значение которое будет вычислено (конструктор Deferred).

# type 'a v = Imm of 'a | Deferred of (unit -> 'a);; # type 'a vm = {mutable c : 'a v };;

Откладывание вычислений может быть получено инкапсуляцией в замыкание. Функция вычисляющая такое значение должна или вернуть значение если оно уже вычислено или, в противном случае, вычислить и сохранить результат.

# let eval e = match e.c with Imm a -> a | Deferred f -> let u = f () in e.c <- Imm u ; u ;; val eval : 'a vm -> 'a = <fun>

Операции задержки и активации вычисления также называют замораживанием и размораживанием значения.

Напишем условный контроль в виде функции:

# let if_deferred c e1 e2 = if eval c then eval e1 else eval e2;; val if_deferred : bool vm -> 'a vm -> 'a vm -> 'a = <fun>

А теперь воспользуемся этим в рекурсивной функции подсчета факториала.

# let rec facr n = si_ret {c=Ret(fun () -> n = 0)} {c=Ret(fun () -> 1)} {c=Ret(fun () -> n*(facr(n-1)))};; val facr : int -> int = <fun> # facr 5;; - : int = 120

Заметим, что классический if не может быть записан в виде функции. Действительно, определим функцию if_function следующим образом:

# let if_function c e1 e2 = if c then e1 else e2;; val if_function : bool -> 'a -> 'a -> 'a = <fun>

Дело в том что все три аргумента вычисляются, это приводит к зацикливанию, так как рекурсивный вызов fact(n-1) всегда вычисляется, даже в случае когда n=0.

# let rec fact n = if_function (n=0) 1 (n*fact(n-1)) ;; val fact : int -> int = <fun> # fact 5 ;; Stack overflow during evaluation (looping recursion?).

Модуль Lazy

Трудности во внедрении замороженных значений происходят от конструкции выражений с отложенным вычислением в контексте немедленного вычисления Objective CAML. Мы это увидели при попытке переопределить условное выражение. Нельзя написать функцию замораживающую значение при конструкции объекта типа vm.

# let gele e = { c = Ret (fun () -> e) };; val gele : 'a -> 'a vm = <fun>

Эта функция следует стратегии вычисления Objective CAML, то есть выражение e вычисляется перед тем как создать замыкание fun () ->e. Проиллюстрируем это в следующем примере:

# freeze (print_string "trace"; print_newline(); 4*5);; trace - : int vm = {c=Deferred <fun>}

По этой причине была введена следующая синтаксическая форма:

Синтаксис

lazy expr


Warning
Это особенность является расширением языка и может измениться в следующих версиях.

Когда к выражению применяется ключевое слово lazy то создается значение особого типа, который определен в модуле Lazy:

# let x = lazy (print_string "Hello"; 3*4) ;; val x : int Lazy.status ref = {contents=Lazy.Delayed <fun>}

Выражение (print_string “Hello”) не вычислено, так как не было никакого вывода на экран. При помощи функции Lazy.force мы можем вынудить вычисление выражения.

# Lazy.force x ;; Hello- : int = 12

Тут мы замечаем, что значение x изменилось:

# x ;; - : int Lazy.t = {contents=Lazy.Value 12}

Теперь это значение замороженного выражения, в данном случае 12.

Новый вызов функции force просто возвращает вычисленное значение:

# Lazy.force x ;; - : int = 12

Строка “Hello” больше не выводится на экран.

“Бесконечные” структуры данных

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

Определим настраиваемую (generic) структуру 'a enum с помощью которой мы будем определять элементы множества.

# type 'a enum = { mutable i : 'a; f :'a -> 'a } ;; type 'a enum = { mutable i: 'a; f: 'a -> 'a } # let next e = let x = e.i in e.i <- (e.f e.i) ; x ;; val next : 'a enum -> 'a = <fun>

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

# let nat = { i=0; f=fun x -> x + 1 };; val nat : int enum = {i=0; f=<fun>} # next nat;; - : int = 0 # next nat;; - : int = 1 # next nat;; - : int = 2

Другой пример — ряд чисел Фибоначчи, который определен как:

  



      u0=1
      u1=1
      un+2=un+un+1;
 

Функция, вычисляющая текущее значение, должна использовать значения un−1 и un−2. Для этого воспользуемся состоянием c в следующем замыкании.

# let fib = let fx = let c = ref 0 in fun v -> let r = !c + v in c:=v ; r in { i=1 ; f=fx } ;; val fib : int enum = {i=1; f=<fun>} # for i=0 to 10 do print_int (next fib); print_string " " done ;; 1 1 2 3 5 8 13 21 34 55 89 - : unit = ()

3.4  Поток данных

Потоки это потенциально бесконечная последовательность данных определенного рода. Вычисление части потока выполняется по необходимости для текущего вычисления — пассивные структуры данных.

stream абстрактный тип данных, реализация которого неизвестна. Мы манипулируем объектами этого типа при помощи функций конструкции и деструкции. Для удобства пользователя, Objective CAML предоставляет пользователю простые синтаксические конструкции для создания и доступа к элементам потока.


Warning
Это особенность является расширением языка и может измениться в следующих версиях.

3.4.1  Конструкция

Упрощенный синтаксис конструкции потоков похож на синтаксис конструкции списков или векторов. Пустой поток создается следующим способом:

[< >] ;; - : 'a Stream.t = <abstr>

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

[< '0; '2; '4 >] ;; - : int Stream.t = <abstr>

Выражения, перед которыми не стоит апостроф, рассматриваются как под–потоки.

[< '0; [< '1; '2; '3 >]; '4 >] ;; - : int Stream.t = <abstr> # let s1 = [< '1; '2; '3 >] in [< s1; '4 >] ;; - : int Stream.t = <abstr> # let concat_stream a b = [< a ; b >] ;; val concat_stream : 'a Stream.t -> 'a Stream.t -> 'a Stream.t = <fun> # concat_stream [< '"if"; '"c";'"then";'"1" >] [< '"else";'"2" >] ;; - : string Stream.t = <abstr>

Остальные функции сгруппированы в модуле Stream. На пример, функции of_channel и of_string возвращают поток состоящий из символов полученных из входного потока или строки символов.

# Stream.of_channel ;; - : in_channel -> char Stream.t = <fun> # Stream.of_string ;; - : string -> char Stream.t = <fun>

Отложенное вычисление потоков позволяет использовать бесконечные структуры данных, как это было в случае с типом 'a enum на стр. ??. Определим поток натуральных чисел при помощи первого элемента и функции вычисляющей поток из следующих элементов.

# let rec nat_stream n = [< 'n ; nat_stream (n+1) >] ;; val nat_stream : int -> int Stream.t = <fun> # let nat = nat_stream 0 ;; val nat : int Stream.t = <abstr>

3.4.2  Деструкция и сопоставление потока

Элементарная операция next одновременно вычисляет, возвращает и извлекает первый элемент потока.

# for i=0 to 10 do print_int (Stream.next nat) ; print_string " " done ;; 0 1 2 3 4 5 6 7 8 9 10 - : unit = () # Stream.next nat ;; - : int = 11

Когда данные в потоке закончились возбуждается исключение.

# Stream.next [< >] ;; Uncaught exception: Stream.Failure

Для использования потоков Objective CAML предоставляет специальное сопоставление для потоков — уничтожающее сопоставление (destructive matching). Сопоставляемое значение вычисляется и удаляется из потока. Понятие исчерпываемости сопоставления (exhaustive match) не существует для потоков, так как мы используем пассивные и потенциально бесконечные структуры данных. Синтаксис сопоставления следующий:

Синтаксис

match expr with parser [< 'p1 ...>] -> expr1 | ...

Функция next может быть переписана в следующей форме:

# let next s = match s with parser [< 'x >] -> x ;; val next : 'a Stream.t -> 'a = <fun> # next nat;; - : int = 12

Заметьте, что перечисление чисел началось с того места где мы остановились.

Существует другая форма синтаксиса для фильтрации функционального параметра типа Stream.t.

Синтаксис

parser p -> ...

Перепишем функцию next используя новый синтаксис.

# let next = parser [<'x>] -> x ;; val next : 'a Stream.t -> 'a = <fun> # next nat ;; - : int = 13

Мы можем сопоставлять пустой поток, но необходимо помнить о следующем: образец потока [<>] сопоставляется с каким угодно потоком. То есть, поток s всегда равен потоку [< [<>]; s >]. Поэтому нужно поменять обычный порядок сопоставления.

# let rec it_stream f s = match s with parser [< 'x ; ss >] -> f x ; it_stream f ss | [<>] -> () ;; val it_stream : ('a -> 'b) -> 'a Stream.t -> unit = <fun> # let print_int1 n = print_int n ; print_string" " ;; val print_int1 : int -> unit = <fun> # it_stream print_int1 [<'1; '2; '3>] ;; 1 2 3 - : unit = ()

Используя тот факт что сопоставление уничтожающее, перепишем предыдущую функцию.

# let rec it_stream f s = match s with parser [< 'x >] -> f x ; it_stream f s | [<>] -> () ;; val it_stream : ('a -> 'b) -> 'a Stream.t -> unit = <fun> # it_stream print_int1 [<'1; '2; '3>] ;; 1 2 3 - : unit = ()

Несмотря на то что потоки пассивные, они добровольно и с “восторгом” отдают свой первый элемент, который после этого будет утерян для потока. Эта особенность отображается на сопоставлении. Следующая функция есть попытка (обреченная на неудачу) вывода на экран двух чисел из потока, в конце потока может остаться один элемент.

# let print_int2 n1 n2 = print_string "(" ; print_int n1 ; print_string "," ; print_int n2 ; print_string ")" ;; val print_int2 : int -> int -> unit = <fun> # let rec print_stream s = match s with parser [< 'x; 'y >] -> print_int2 x y; print_stream s | [< 'z >] -> print_int1 z; print_stream s | [<>] -> print_newline() ;; val print_stream : int Stream.t -> unit = <fun> # print_stream [<'1; '2; '3>];; (1,2)Uncaught exception: Stream.Error("")

Два первых элемента потока были выведены на экран без проблем, однако во время вычисления рекурсивного вызова (print_stream [< 3 >]) образец обнаружил x, который был “употреблен”, но для y в потоке ничего нет. Это и привело к ошибке. Дело в том что второй образец абсолютно бесполезный, если поток не пустой то первый образец всегда совпадет.

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

# let rec print_stream s = match s with parser [< 'x >] -> (match s with parser [< 'y >] -> print_int2 x y; print_stream s | [<>] -> print_int1 x; print_stream s) | [<>] -> print_newline() ;; val print_stream : int Stream.t -> unit = <fun> # print_stream [<'1; '2; '3>];; (1,2)3 - : unit = ()

Если сопоставление не срабатывает на первом элементе образца, то фильтр работает как обычно.

# let rec print_stream s = match s with parser [< '1; 'y >] -> print_int2 1 y; print_stream s | [< 'z >] -> print_int1 z; print_stream s | [<>] -> print_newline() ;; val print_stream : int Stream.t -> unit = <fun> # print_stream [<'1; '2; '3>] ;; (1,2)3 - : unit = ()

Пределы сопоставления

Из–за своего свойства уничтожения сопоставление потоков отличается от сопоставления типов сумма. Давайте рассмотрим на сколько глубоко они отличаются.

Вот простейшая функция складывающая два элемента потока.

# let rec sum s = match s with parser [< 'n; ss >] -> n+(sum ss) | [<>] -> 0 ;; val sum : int Stream.t -> int = <fun> # sum [<'1; '2; '3; '4>] ;; - : int = 10

Однако, мы можем поглотить поток “изнутри” придав имя полученному результату.

# let rec sum s = match s with parser [< 'n; r = sum >] -> n+r | [<>] -> 0 ;; val sum : int Stream.t -> int = <fun> # sum [<'1; '2; '3; '4>] ;; - : int = 10

В главе 10, посвященной лексическому и синтаксическому анализу, мы рассмотрим другие примеры использования потоков. В частности, мы увидим как “поглощение” потока изнутри можно с выгодой использовать.

3.5  Резюме

В этой главе мы сравнили функциональный и императивный стили программирования. Основные различия состоят в контроле выполнения (неявный в функциональном и явный в императивном стиле) и представлении в памяти данных (явное разделение или копирования в императивном стиле, не имеющее такой важности в функциональном). Реализация алгоритмов в функциональном и императивном стилях подразумевает эти различия. Выбор между обоими стилями на самом деле приводит к их одновременному использованию. Это позволяет явно выразить представление замыкания, оптимизировать критические части программы и создать изменяемые функциональные данные. Физическое изменение значений в окружении замыкания помогает нам лучше понять что такое функциональное значение. Одновременное использование обоих стилей предоставляет мощные средства для реализации. Мы воспользовались этим при создании потенциально бесконечных данных.


Previous Up Next