Сопоставление с образцом (Pattern matching)
Сопоставление с образцом не такая уж новая или инновационная идея. На
самом деле она имеет слабое отношение к функциональному
программированию. Единственная причина, по которой его часто связывают с
ФП, это то, что с некоторых пор в функциональных языках есть
сопоставление с образцом, а в императивных — нет.
Давайте начнём наше знакомство с Pattern matching следующим примером. Вот функция вычисления чисел Фибоначи на Java:
int fib(int n) {
if(n == 0) return 1;
if(n == 1) return 1;
return fib(n - 2) + fib(n - 1);
}
А вот пример на Java-подобном языке с поддержкой Pattern matching-а
int fib(0) {
return 1;
}
int fib(1) {
return 1;
}
int fib(int n) {
return fib(n - 2) + fib(n - 1);
}
В чём разница? Компилятор реализует ветвление за нас.
Подумаешь, велика важность! Действительно важность не велика. Было подмечено, что большое количество функций содержат сложные switch
конструкции (это отчасти верно для функциональных программ), и было
принято решение выделить этот момент. Определение функции разбивается на
несколько вариантов, и устанавливается паттерн на месте аргументов
функции (это напоминает перегрузку методов). Когда происходит вызов
функции, компилятор на лету сравнивает аргументы со всеми определениями и
выбирает наиболее подходящий. Обычно выбор падает на самое
специализированное определение функции. Например int fib(int n) может быть вызвана при n равном 1, но не будет, ведь int fib(1) — более специализированное определение.
Сопоставление с образцом обычно выглядит сложнее, чем в нашем примере.
Например сложная система Pattern matching позволяет писать следующий
код:
int f(int n < 10) { ... }
int f(int n) { ... }
Когда сопоставление с образцом может быть полезно? Список таких случаев
на удивление очень большой! Каждый раз, когда вы используете сложные
конструкции вложенных if , pattern matching может справиться лучше с меньшим количеством кода. В голову приходит хороший пример с функцией WndProc ,
которая реализуется в каждой Win32 программе (даже если она спрятана от
программиста за высоким забором абстракций). Обычно сопоставление с
образцом может даже проверять содержимое коллекций. Например, если вы
передаёте массив в функцию, то вы можете отбирать все массивы, у которых
первый элемент равен 1, а третий элемент больше 3.
Ещё одни преимуществом Pattern matching является то, что в случае
внесения изменений вам не придётся копаться в одной огромной функции.
Вам достаточно будет добавить (или изменить) некоторые определения
функций. Тем самым мы избавляется от целого пласта паттернов из
знаменитой книги Банды Четырёх. Чем сложнее и ветвистее условия, тем
полезнее будет использовать Pattern matching. Как только вы начнёте их
использовать, то удивитесь, как вы могли раньше без них обходится.
Замыкания
До сих пор мы обсуждали особенности ФП в контексте «чисто»
функциональных языков — языков, которые являются реализацией лямбда
исчисления и не содержат особенностей, противоречащих формальной системе
Чёрча. Тем не менее, многие черты функциональных языков используются за
пределами лямбда исчисления. Хотя реализация аксиоматической системы
интересна с точки зрения программирования в терминах математических
выражений, это не всегда может быть применимо на практике. Многие языки
предпочитать использовать элементы функциональных языков не
придерживаясь строгой функциональной доктрины. Некоторые такие языки
(например Common Lisp) не требуют от переменных быть final —
их значения можно менять. Они даже не требуют, чтобы функции зависели
только от своих аргументов — функциям дозволенно обращаться к состоянию
за пределом своей области видимости. Но при этом они включают в себя
такие особенности, как функции высшего порядка. Передача функции в
не-чистом языке немного отличается от аналогичной операции в пределах
лямбда исчисления и требует наличия интересной особенности под
названием: лексическое замыкание. Давайте взглянем на следующий пример.
Помните, что в данном случае переменные не final и функция может обращаться к переменным за пределом своей области видимости:
Function makePowerFn(int power) {
int powerFn(int base) {
return pow(base, power);
}
return powerFn;
}
Function square = makePowerFn(2);
square(3);
Функция make-power-fn возвращает функцию, которая принимает
один аргумент и возводит его в определённую степень. Что произойдёт,
когда мы попробуем вычислить square(3) ? Переменная power находится вне области видимости powerFn , потому что makePowerFn уже завершилась, и её стек уничтожен. Как же тогда работает square ? Язык должен каким-либо образом сохранить значение power , чтобы функция square могла работать. А что если мы создадим ещё одну функцию cube , которая возводит число в третью степень? Язык должен будет сохранять два значения power для каждой созданной в make-power-fn
функции. Феномен хранения этих значений и называется замыканием.
Замыкание не только сохраняет аргументы верхней функции. Например
замыкание может выглядеть следующим образом:
Function makeIncrementer() {
int n = 0;
int increment() {
return ++n;
}
}
Function inc1 = makeIncrementer();
Function inc2 = makeIncrementer();
inc1();
inc1();
inc1();
inc2();
inc2();
inc2();
В процессе выполнения значения n сохраняются, и счётчики имеют доступ к ним. Более того у каждого счётчика своя копия n , не смотря на то, что они должны были исчезнуть после того, как функция makeIncrementer
отработает. Как же компилятор умудряется это скомпилировать? Что
происходит за кулисами замыканий? К счастью у нас есть волшебный
пропуск.
Всё сделано достаточно логично. С первого взгляда ясно, что локальные
переменные больше не подчиняются правилам области видимости и их время
жизни не определено. Очевидно, что они больше не хранятся в стеке — их
нужно держать в куче (heap) [8].
Замыкание, следовательно, сделано как обычная функция, которую мы
обсуждали ранее, за исключением того, что в нём есть дополнительная
ссылка на окружающие переменные:
class some_function_t {
SymbolTable parentScope;
}
Если замыкание обращается к переменной, которой нет в локальной области
видимости, тогда оно принимает во внимание родительскую область. Вот и
всё! Замыкание связывает функциональный мир с миром ООП. Каждый раз,
когда вы создаёте класс, который хранит некоторое состояние, и передаёте
его куда-то, вспомните про замыкания. Замыкание — это всего лишь
объект, который создаёт «атрибуты» на лету, забирая их из области
видимости, чтобы вам не пришлось делать это самим.
Что теперь?
Эта статья проходится лишь по верхушке айсберга Функционального
Программирования. Вы можете копнуть глубже и увидеть нечто действительно
большое, а в нашем случае ещё и хорошее. В будущем я планирую написать о
теория категорий, монадах, функциональных структурах данных, системе
типов в функциональных языках, функциональной многопоточности,
функциональных базах данных, и ещё о многих вещах. Если у меня получится
написать (и изучить в процессе) хотя бы о половине из этих тем, моя
жизнь пройдёт не зря. А пока, Google — ваш верный друг.
Комментарии?
Если у вас есть вопросы, комментарии или предложения, черканите
записочку на адрес coffeemug [собачка] gmail.com. Буду рад любым вашим
отзывам.
Примечания
[1].
Когда я искал работу осенью 2005 года, я частенько задавал этот вопрос.
Было очень занятно видеть пустой взгляд вместо ответа. Я-то думал, что
за зарплату $300,000 у этих людей должно быть хорошее понимание всех
доступных инструментов.
[2].
Этот вопрос неоднозначный. Физики и математики вынуждены признать, что
нет ничего предельно ясного во вселенной, что можно было бы описать
математически.
[3].
Я ненавидел уроки истории, которые предлагали только сухую хронологию
дат, имён и событий. Для меня история — это жизни людей, которые
изменили мир. Это те их личные причины, которая стоят за их действиями, и
механизмы, которыми они оказывали влияние на миллионы душ. По этой
причине исторический раздел этой статьи безнадёжно неполон. Здесь
описываются только наиболее значимые люди и события.
[4].
Когда я только начинал изучать функциональное программирование, меня
очень нервировал термин «лямбда», потому что я не мог до конца понять,
что же он обозначает. В данном контексте лямбда — это функция. А
греческая буква используется для удобства математической записи. Каждый
раз, когда вы слышите «лямбда» в разговоре о функциональном
программировании, переводите это про себя в «функцию».
[5]. Занимательно, что строки в Java не изменяются. Интересно было бы выяснить причину такого вероломства, но не будем отвлекаться.
[6].
Большинство компиляторов функциональных языков умеют оптимизировать
рекурсивные функции превращая их в циклы по мере возможностей. Это
называется оптимизация хвостовой рекурсии.
[7]. Обратное не всегда верно. Если иногда возможно доказать эквивалентность двух участков кода, но в общем случае это не возможно.
[8].
На самом деле это не медленнее, чем хранить в стеке, поскольку при
использовании сборщика мусора выделение памяти занимает O(1) операций.
19 апреля 2012, 11:45
740
комментарии ()
Классный текст! Правда я так и не понял когда имеет смысл
использовать ФП, а когда — нет. В статье очень много слов посвящено
тому, как на ФП можно программировать в смысле ООП. А все преимущества
описаны как возможный потенциал: возможность распараллеливание,
возможность оптимизации без участия человека.
Хотелось бы конкретных примеров:
1. Распараллеливание — берем известный алгоритм, пишем программу на ФП и на ООП так, как нам удобно и сравниваем.
2. Оптимизация — берем известный алгоритм, пишем программу на ФП и на ООП так, как нам удобно и сравниваем.
Я так понимаю, что на ФП код должен получиться существенно короче и понятней, скорость примерно одинаковой, так?
Относительно ФП у меня сложилось впечатление, что его преимущества
проявляются при решении для больших задач. Для решения простых кусочков
большой проблемы код пишется на ООП, а вот чтобы составить из этих
кусочков решение всей проблемы стОит применять ФП.
FP очень доставляет при работе с большим количеством данных. Data mining, etc.
Да и просто для релаксации иногда расчехляю тот же F#.
А как в этом случае с переполнением стека и нагрузкой на каналы передачи данных? Не становится ли она сильно избыточной?
Не понял что вы имеете ввиду под «нагрузка на каналы».
А насчет стека: есть катая замечательная вещь как «хвостовая рекурсия» — компилятор такие чудеса с ней творит )
Когда у нас переменные не изменяются, то фактически это означает, что
память одноразовая. И если бы работаем со сложной структурой, о которой
думаем как о чем-то неделимом, то мы не можем изменить какой-то ее
кусочек, мы обязаны менять ее целиком. Т.е. заводить новую и туда
копировать данные. Это не создает чрезмерную нагрузку на память?
Если мы работаем со сложной структурой, о которой думаем как о чем-то
неделимом — то она должна быть неделимой вне зависимости от подхода.
А копирование памяти — операция довольно быстрая.
> Если мы работаем со сложной структурой, о которой думаем как о
чем-то неделимом — то она должна быть неделимой вне зависимости от
подхода.
Я неточно выразился. Возможно лучше s/неделимой/цельной/.
> А копирование памяти — операция довольно быстрая.
Все относительно. Но избыточного копирования больших объемов данных стараются избегать.
Впрочем, это не важно. Кажется я понял как сформулировать то, что меня
напрягает в ФП: в ФП нет привычных структур данных, привычных в том
смысле, что мы можем их схематично нарисовать на бумаге, и их хранение в
памяти компьютера будет примерно таким же, как наша рукописная схема.
Пример: как в ФП будет хранится матрица целых чисел? Будет ли это
непрерывный массив ячеек памяти длинных n^2? Если да, то на сколько
эффективна будет работа с ним, когда нам потребуется изменять только
несколько ячеек? Если нет, то насколько эффективна будет работа с ним,
когда нам потребуется работать со всеми ячейками?
Возможно это покажется некорректным, когда в для реализации одних
алгоритмов мы хотим понимать структуру данных как нечто целое и
неделимое, а для других алгоритмов как — нечто составное. Но, наверное, в
этом и состоит основная задача разработки удобных структур данных —
чтобы они были удобные и допускали разные уровни абстракции.
Матрицу лучше реализовать на основе одноуровневого списка. Никаких
проблем с выборкой и изменением не будет: можно написать что-то типа:
Matrix.select ( sub {
… // выбираем нужные индексы матрицы
$cc->( $result );
},
sub {
… // изменяем полученные индексы как нам надо
return $newMatrix;
} );
|