ЛЕКЦИЯ 3
СТРУКТУРЫ ДАННЫХ И БАЗИСНЫЕ ОПЕРАЦИИ – 2
В этой лекции продолжается описание структур данных и базовых операций, начатое в лекции 2. Более или менее подробно рассматриваются другие аспекты функциональной парадигмы программирования.
Типы в функциональных языках
Как известно, аргументами функций могут быть не только переменные базовых типов, но и другие функции. В этом случае появляется понятие функций высшего порядка. Но для рассмотрения функций высшего порядка необходимо ввести понятие функционального типа (или тип, возвращаемый функцией).
Пусть некоторая функция f — это функция одной переменной из множества A, принимающая значение из множества B. Тогда по определению: #(f) : A -> B
Знак #(f) обозначает "тип функции f". Таким образом, типы, в которых есть символ стрелки ->, носят название функциональных типов. Иногда для их обозначения используется более изощренная запись: BA (далее будет использоваться только стрелочная запись, т.к. для некоторых функций их типы чрезвычайно сложно представить при помощи степеней).
Например:
#(sin) : Real -> Real
#(Length) : List (A) -> Integer
Для функций многих аргументов определение типа можно выводить при помощи операции декартового произведения (например, #(add(x, y)) : Real x Real -> Real) . Однако в функциональном программировании такой способ определения типов функций многих переменных не прижился.
В 1924 году М. Шёнфинкель предложил представлять функции многих аргументов как последовательность функций одного аргумента. В этом случае тип функции, которая складывает два действительных числа, выглядит так: Real (Real Real). Т.е. тип таких функций получается последовательным применением символа стрелки ->. Пояснить этот процесс можно на следующем примере:
Пример 8. Тип функции add (x, y).
Предположительно, каждый из аргументов функции add уже означен, пусть x = 5, y = 7 . В этом случае из функции add путем удаления первого аргумента получается новая функция — add5 , которая прибавляет к своему единственному аргументу число 5. Ее тип получается легко, он по определению таков: Real -> Real . Теперь, возвращаясь назад, можно понять, почему тип функции add равен Real -> (Real -> Real).
Для того чтобы не изощряться с написанием функций типа add5 (как в предыдущем примере), была придумана специальная аппликативная форма записи в виде "оператор – операнд". Предпосылкой для этого послужило новое видение на функции в функциональном программировании. Ведь если традиционно считалось, что выражение f (5) обозначает "применение функции f к значению аргумента, равному 5" (т.е. вычисляется только аргумент), то в функциональном программировании считается, что значение функции также вычисляется. Так, возвращаясь к примеру 8, функцию add можно записать как (add (x)) y, а когда аргументы получают конкретные значения (например, (add (5)) 7) , сначала вычисляются все функции, пока не появится функция одного аргумента, которая применяется к последнему.
Таким образом, если функция f имеет тип A1 -> (A2 -> ( ... (An -> B) ... )) , то чтобы полностью вычислить значение f (a1, a2, ..., an) необходимо последовательно провести вычисление ( ... (f (a1) a2) ... ) an. И результатом вычисления будет объект типа B. Соответственно выражение, в котором все функции рассматриваются как функции одного аргумента, а единственной операцией является аппликация (применение), называются выражениями в форме "оператор – операнд". Такие функции получили название "каррированные", а сам процесс сведения типа функции к виду, приведенному в предыдущем абзаце — каррированием (по имени Карри Хаскелла).
Если вспомнить l-исчисление, то обнаружится, что в нем уже есть математическая абстракция для аппликативных форм записей.
Например:
f (x) = x2 + 5 <=> lx.(x2 + 5)
f (x, y) = x + y <=> ly.lx.(x + y)
f (x, y, z) = x2 + y2 + z2 <=> lz.ly.lx.(x2 + y2 + z2)
И так далее...
Несколько слов о нотации абстрактного языка
Образцы и клозы
Необходимо отметить, что в нотации абстрактного функционального языка, который использовался до сих пор для написания примеров функций, можно было бы использовать такую конструкцию, как if-then-else. Например, при описании функции Append (см. пример 7), её тело можно было бы записать следующим образом:
Append (L1, L2) = if (L1 == []) then L2
else head (L1) : Append (tail (L1), L2)
Однако данная запись чревата непониманием и трудным разбором. Поэтому даже в примере 7 была использована нотация, которая поддерживает так называемые "образцы".
Определение:
Образцом называется выражение, построенное с помощью операций конструирования данных, которое используется для сопоставления с данными.
Переменные обозначаются прописными буквами, константы — строчными.
Примеры образцов:
5 — просто числовая константа
X — просто переменная
X : (Y : Z) — пара
[X, Y] — список
К образцам предъявляется одно требование, которое должно выполняться беспрекословно, иначе сопоставление с образцами будет выполнено неверно. Требование это звучит следующим образом: при сопоставлении образца с данными означивание переменных должно происходить единственным образом. Т.е., например, выражение (1 + X ==> 5) можно использовать как образец, т.к. означивание переменной X происходит единственным образом (X = 4) , а выражение (X + Y ==> 5) использовать в качестве образца нельзя, ибо означить переменные X и Y можно различным образом. Кроме образцов в функциональном программировании вводится такое понятие, как "клоз" (от англ. "clause"). По определению клозы выглядят так:
def f p1, ..., pn = expr
где:
def и = — константы абстрактного языка
f — имя определяемой функции
pi — последовательность образцов (при этом n >= 0)
expr — выражение
Таким образом, определение функции в функциональном программировании есть просто последовательность клозов (возможно состоящая из одного элемента).
Для того, чтобы упростить запись определений функций, далее слово def будет опускаться.
Пример 9. Образцы и клозы в функции Length .
Length ([]) = 0
Length (H:T) = 1 + Length (T)
Пусть вызов функции Length произведен с параметром [a, b, c] . В этом случае начнет свою работу механизм сопоставления с образцом. Последовательно перебираются все клозы и делаются попытки сопоставления. В данном случае удачное сопоставление будет только во втором клозе (т.к. список [a, b, c] не пуст). Интерпретация вызова функции заключается в нахождении первого по порядку сверху вниз образца, успешно сопоставимого с фактическим параметром.
Значение переменных образца, означиваемые в результате сопоставления, подставляются в правую часть клоза (выражение expr ), вычисленное значение которой в данном контексте объявляется значением вызова функции.
Охрана
При написании функций в абстрактной нотации допустимо использовать так называемую охрану, т.е. ограничения на значения переменных образца. Например, при использовании охраны функция Length будет выглядеть примерно следующим образом:
Length (L) = 0 when L == []
Length (L) = 1 + Length (tail (L)) otherwise
В рассмотренном коде слова when (когда) и otherwise (в противном случае) являются зарезервированными словами языка. Однако использование этих слов не является необходимым условием для организации охраны. Охрану можно организовывать различными способами, в том числе и с помощью l-исчисления:
Append = l[].(lL.L)
Append = l(H:T).(lL.H : Append (T, L))
Представленная запись не очень читабельна, поэтому использоваться она будет только в крайних случаях по необходимости.
Локальные переменные
Как уже говорилось, использование локальных переменных представляет собой побочный эффект, поэтому оно недопустимо в функциональных языках. Однако в некоторых случаях использование локальных переменных носит оптимизирующий характер, что позволяет сэкономить время и ресурсы во время вычислений.
Пусть f и h — функции, и необходимо вычислить выражение h (f (X), f(X)) . Если в языке нет оптимизирующих методов, то в этом случае произойдет двойное вычисление выражения f (X) . Чтобы этого не произошло, можно прибегнуть к такому изощренному способу: (lv.h (v, v))(f (X)). Естественно, что в этом случает выражение f (X) вычислится первым и один раз. Для того, чтобы минимизировать использование l-исчисления, далее будет применяться следующая форма записи:
let v = f (X) in h (v, v)
(слова let, = и in — зарезервированы в языке). В этом случае v будет
называться локальной переменной.
Элементы программирования
Накапливающий параметр — аккумулятор
Бывает так, что при выполнении функции исключительно серьезно встает проблема расхода памяти. Эту проблему можно пояснить на примере функции, вычисляющей факториал числа:
Factorial (0) = 1
Factorial (N) = N * Factorial (N - 1)
Если провести пример вычисления этой функции с аргументом 3, то можно будет увидеть следующую последовательность:
Factorial (3)
3 * Factorial (2)
3 * 2 * Factorial (1)
3 * 2 * 1 * Factorial (0)
3 * 2 * 1 * 1
3 * 2 * 1
3 * 2
6
На примере этого вычисления наглядно видно, что при рекурсивных вызовах функций очень сильно используется память. В данном случае количество памяти пропорционально значению аргумента, но аргументов может быть большее число, к примеру. Возникает резонный вопрос: можно ли так написать функцию вычисления факториала (и ей подобные), чтобы память использовалась минимально?
Чтобы ответить на данный вопрос положительно, необходимо рассмотреть понятие аккумулятора (накопителя). Для этого можно рассмотреть следующий пример:
Пример 10. Функция вычисления факториала с аккумулятором.
Factorial_A (N) = F (N, 1)
F (0, A) = A
F (N, A) = F ((N - 1), (N * A))
В этом примере второй параметр функции F выполняет роль аккумулирующей переменной, именно в ней содержится результат, который возвращается по окончании рекурсии. Сама же рекурсия в этом случае принимает вид "хвостовой", память при этом расходуется только на хранение адресов возврата значения функции.
Хвостовая рекурсия представляет собой специальный вид рекурсии, в которой имеется единственный вызов рекурсивной функции и при этом этот вызов выполняется после всех вычислений.
При реализации вычисления хвостовой рекурсии могут выполняться при помощи итераций в постоянном объеме памяти. На практике это обозначает, что "хороший" транслятор функционального языка должен "уметь" распознавать хвостовую рекурсию и реализовывать её в виде цикла. В свою очередь, метод накапливающего параметра не всегда приводит к хвостовой рекурсии, однако он однозначно помогает уменьшить общий объём памяти.
Принципы построения определений с накапливающим параметром:
1. Вводится новая функция с дополнительным аргументом (аккумулятором), в котором накапливаются результаты вычислений.
2. Начальное значение аккумулирующего аргумента задается в равенстве, связывающем старую и новую функции.
3. Те равенства исходной функции, которые соответствуют выходу из рекурсии, заменяются возвращением аккумулятора.
4. Равенства, соответствующие рекурсивному определению, выглядят как обращения к новой функции, в котором аккумулятор получает то значение, которое возвращается исходной функцией.
Возникает вопрос: любую ли функцию можно преобразовать для вычисления с аккумулятором? Очевидно, что ответ на этот вопрос отрицателен. Построение функций с накапливающим параметром — приём не универсальный, и он не гарантирует получения хвостовой рекурсии. С другой стороны, построение определений с накапливающим параметром является делом творческим. В этом процессе необходимы некоторые эвристики.
Определение:
Общий вид рекурсивных определений, позволяющих при трансляции обеспечить вычисления в постоянном объёме памяти через итерацию, называется равенствами в итеративной форме. Общий вид равенств в итеративной форме может быть описан следующим образом:
fi (pij) = eij
При этом на выражения eij накладываются следующие ограничения:
1. eij — "простое" выражение, т.е. оно не содержит рекурсивных вызовов, а только операции над данными.
2. eij имеет вид fk (vk) , при этом vk — последовательность простых выражений. Это и есть хвостовая рекурсия.
3. eij — условное выражение с простым выражением в условии, ветви которого определяются этими же тремя пунктами.
Упражнения:
1. Построить функции, работающие со списками. При необходимости воспользоваться дополнительными функциями и теми, что определены в лекции.
2. a. Reverse_all — функция, получающая на вход списочную структуру и обращающая все её элементы, а также её саму.
b. Position — функция, возвращающая номер первого вхождения заданного атома в список.
c. Set — функция, возвращающая список, содержащий единичные вхождения атомов заданного списка.
d. Frequency — функция, возвращающая список пар (символ, частота). Каждая пара определяет атом из заданного списка и частоту его вхождения в этот список.
2. Написать (если это возможно) функции с накапливающим параметром для заданий из упражнения 1 лекции 2.
Ответы для самопроверки
1. Следующие описания реализуют заявленные функции. В некоторых пунктах реализованы дополнительные функции, обойтись без которых представляется сложным.
a. Reverse_all:
Reverse_all (L) = L when atom (L)
Reverse_all ([]) = []
Reverse_all (H:T) = Append (Reverse_all (T), Reverse_all (H)) otherwise
b. Position:
Position (A, L) = Position_N (A, L, 0)
Position_N (A, (A:L), N) = N + 1
Position_N (A, (B:L), N) = Position_N (A, L, N + 1)
Position_N (A, [], N) = 0
c. Set:
Set ([]) = []
Set (H:T) = Include (H, Set (T))
Include (A, []) = [A]
Include (A, A:L) = A:L
Include (A, B:L) = prefix (B, Include (A, L))
d. Frequency:
Frequency L = F ([], L)
F (L, []) = L
F (L, H:T) = F (Corrector (H, L), T)
Corrector (A, []) = [A:1]
Corrector (A, (A:N):T) = prefix ((A:N + 1), T)
Corrector (A, H:T) = prefix (H, Corrector (A, T))
2. Для всех функций из упражнения 1 лекции 2 можно построить определения с накапливающим параметром. С другой стороны, возможно некоторые из вновь построенных функций не будут оптимизированы.
a. Power_A:
Power_A (X, N) = P (X, N, 1)
P (X, 0, A) = A
P (X, N, A) = P (X, N - 1, X * A)
b. Summ_T_A:
Summ_T_A (N) = ST (N, 0)
ST (0, A) = A
ST (N, A) = ST (N - 1, N + A)
c. Summ_P_A:
Summ_P_A (N) = SP (N, 0)
SP (0, A) = A
SP (N, A) = SP (N - 1, Summ_T_A (N) + A)
d. Summ_Power_A:
Summ_Power_A (N, P) = SPA (N, P, 0)
SPA (N, 0, A) = A
SPA (N, P, A) = SPA (N, P - 1, (1 / Power_A (N, P)) + A)
e. Exponent_A:
Exponent_A (N, P) = E (N, P, 0)
E (N, 0, A) = A
E (N, P - 1, (Power_A (N, P) / Factorial_A (P)) + A )
Похожие статьи: