[Из песочницы] 6 концепций функционального программирования. Польза и примеры использования
Функциональное программирование — это парадигма программирования, которая акцентируется на вычислении через функции в математическом стиле, неизменяемость, выразительность и уменьшение использования переменных и состояний (ссылка).
Существует 6 основных концепций:
- концепция первого класса и функций высшего порядка
- концепция чистых функций
- концепция неизменяемого состояния
- концепция опциональности и сопоставления с образом
- концепция ленивости и бесконечных структур данных
- концепция лямбда-исчислений
Функция первого класса
Что это
Это сущность, которая поддерживает операции, обычно доступные для других сущностей. Эти операции, как правило. включают в себя: передачу сущности как аргумента, возвращение сущности из функции и присваивание оной в переменную.
Чем полезна
Упрощает работу с функциями, давая больше возможностей и способов для их использования.
Примеры использования
typedef void (*callback_func_t) (char*);
void list_files(char* path, callback_func_t callback) {
// recursive read directory structure ...
}
void print_file_path(char* file_path) {
printf("%s\n", file_path);
}
callback_func_t get_print_func() {
callback_func_t callback_var = &print_file_path;
return callback_var;
}
list_files("/tmp/", get_print_func());
В приведенном примере функция get_print_func создает переменную, в которой хранит ссылку на функцию, а затем возвращает ее. А ниже по коду мы передаем результат, возвращенный нам функцией get_print_func в другую функцию. Это и есть операции, доступные нам благодаря функциям первого класса.
Функция высшего порядка
Что это
Это функция, которая оперирует другими функциями. Оперирует, получая их в качестве параметра или возвращая их.
Чем полезна
Как и в случае функций первого порядка, эта концепция дает нам больше возможностей для работы с функциями. Также эта концепция открывает нам возможность использования функций как обработчиков событий, о которых система или какая-либо библиотека может нам сообщать посредством вызова переданной ей функции первого класса.
Примеры использования
Cм. предыдущий пример. Здесь есть функция, которая читает директорию. И рекурсивно читает все подпапки. Для каждого найденного файла вызывает функцию, которую мы передали — callback.
Пример на С показывает, что еще в 70-ых годах прошлого века можно было оперировать функциями первого класса и высшего порядка. В Objective-C появились блоки. В отличие от функций они могут захватывать переменные и какое-то состояние. В Swift появились Closure. По сути, это то же самое, что блоки на Objective-C.
Чистая функция
Что это
Это функция, которая выполняет два условия. Функция всегда возвращает один и тот же результат при одних и тех же входных параметрах. И вычисление результата не вызывает видимых семантических побочных эффектов или вывода во вне.
Чем полезна
Чистая функция, обычно, является показателем хорошо написанного кода, так как такие функции легко покрывать тестами, их можно легко переносить и повторно использовать.
Примеры использования
В приведенном ниже фрагменте показаны примеры чистых функций (они помечены комментарием «pure»).
func quad1(x: Int) -> Int { // pure
func square() -> Int {
return x * x
}
return square() * square()
}
func quad2(x: Int) -> Int { // pure
func square(y: Int) -> Int { // pure
return y * y
}
return square(x) * square(x)
}
func square(x: Int) -> Int { // pure
return x * x
}
func cube(x: Int) -> Int {
return square(x) * x
}
func printSquareOf(x: Int) {
print(square(x))
}
let screenScale = 2.0
func pixelsToScreenPixels(pixels: Int) -> Int {
return pixels * Int(screenScale)
}
Используются повсеместно. Например, стандартные математические библиотеки почти всех языков программирования содержат в основном только чистые функции.
Неизменяемое состояние
Что это
Неизменяемое состояние — состояние объекта, которое не может быть изменено после того, как объект был создан. Под состоянием объекта здесь, подразумевается набор значений его свойств.
Чем полезено
Так как неизменяемые объекты гарантируют нам, что на протяжении своего жизненного цикла они не могут менять свое состояние, то мы можем быть уверены, что использование или передача таких объектов в другие места программы не приведет к каким либо непредвиденным последствиям. Это особенно важно при работе в многопоточном окружении.
В языке C «из коробки» нет возможности создавать неизменяемые объекты. Ключевое слово const запрещает изменять значение только в текущем контексте, однако, если мы передаем ссылку на это значение в функцию, то эта функция сможет изменить данные, находящиеся по этой ссылке. Можно решить эту проблему через инкапсуляцию (через публичные и приватные заголовочные файлы). Однако, в этом случае мы должны самостоятельно реализовать механизм «защиты» данных от изменений.
В Objective-C тоже ничего нового не пришло. Добавились только базовые классы, которые не дают изменять свое внутреннее состояние и их изменяемые (мутабельные) аналоги.
В Swift у нас появилось ключевое слово let, которое гарантирует нам, что переменная или структура не может быть изменена после создания.
Примеры использования
Пример использования неизменяемых значений в Swift:
let one = 1
one = 2 // compile error
let hello = "hello"
hello = "bye" // compile error
let argv = ["uptime", "--help"]
argv = ["man", "reboot"] // compile error
argv[0] = "man" // compile error
Опциональный тип
Что это
Опциональный тип — обобщенный (generic) тип, который представляет инкапсуляцию опционального значения. Такой тип содержит в себе либо определенное значение, либо пустое (null) значение.
Чем полезен
Выносит понятие о нулевом (null) значении на более высокий уровень. Позволяет работать с опциональными значениями при помощи синтаксических конструкций языка.
Примеры использования
Практически во всех современных, особенно молодых, языках присутствует понятие опционального типа и синтаксические конструкции для работы с ним. В Swift это конструкция if let, либо switch case:
let some: String? = nil
switch (some) {
case .None:
print("no string")
case .Some(let str):
print("string is: \(str)")
}
Pattern Matching
Pattern Matching — акт проверки последовательности токенов на совпадение с определенным шаблоном.
Чем полезен
Позволяет нам писать более краткий, сосредоточенный на решении проблемы код.
Примеры использования
Вот пример на Haskell. На мой взгляд, самый лучший пример Pattern Matching.
sum :: (Num a) => [a] -> a
sum [] = 0 -- no elements
sum (x:[]) = x -- one element
sum (x:xs) = x + sum xs -- many elements
Функция sum принимает на вход массив объектов. Если функция sum получает пустой массив, сумма элементов будет 0. Если массив содержит один объект, просто получаем этот объект Если больше объектов, то складываем первый объект и хвост массива, затем операцию повторяем рекурсивно пока у нас есть элементы в хвосте массива. Эту функцию мы описали как паттерн. Это значит, что мы описываем все возможные (либо необходимые нам в данный момент) варианты работы этой функции в зависимости от входных значений. Без if и прочих условных операторов.
addOne :: Maybe Int -> Maybe Int
addOne (Just a) = Just (a + 1) -- not empty value
addOne Nothing = Nothing -- empty value
Функция addOne добавляет к числу единицу. На вход она принимает аргумент типа Maybe Int и на выходе возвращает значение аналогичного типа. Maybe — это монада, которая содержит в себе либо значение (Just a), либо ничего (Nothing). Функция addOne работает следующим образом: если в аргументе функции есть значение, (Just a) то добавляем единицу и возвращаем аргумент, если ничего нет (Nothing), то возвращаем ничего (Nothing).
В Swift pattern-matching выглядит так:
let somePoint = (1, 1)
switch somePoint {
case (0, 0):
print("point at the origin")
case (_, 0):
print("(point on the x-axis")
case (0, _):
print("point on the y-axis")
case (-2...2, -2...2):
print("point inside the box")
default:
print("point outside of the box")
}
Pattern Matching, на мой взгляд, довольно ограничен в Swift, можно только проверить кейсы в операторе switch, однако, делать это можно довольно гибко.
Ленивость или ленивые вычисления
Что это
Ленивое вычисление — стратегия вычисления, которая откладывает вычисления выражения до момента, когда значение этого выражения будет необходимо.
Чем полезно
Позволяет отложить вычисление некоторого кода до определенного или заранее неопределенного момента времени.
Пример использования
let dateFormatter = NSDateFormatter()
struct Post {
let id: Int
let title: String
let creationDate: String
lazy var createdAt: NSDate? = {
return dateFormatter.dateFromString(self.creationDate)
}()
}
Может применяться для инициализации полей у класса, после его инициализации. Этот прием позволяет избежать дублирования кода инициализации поля в нескольких конструкторах класса и отложить инициализацию этого поля до момента, когда в нем возникнет необходимость. В приведенном выше примере значение поля createdAt вычисляется в момент первого обращения к нему.
Бесконечная структура данных
Бесконечная структура данных — структура, чье определение дано в терминах бесконечных диапазонов или непрекращающейся рекурсии, но реальные значения вычисляются только в момент, когда они необходимы.
Чем полезна
Позволяет нам определять структуры данных бесконечной или огромной величины, не затрачивая ресурсы на вычисление значений этой структуры.
Примеры использования
Вот пример на Swift. Берем Range от одного до биллиона. Делаем map на этот Range — превращаем биллион значений в строки. Такое количество строк едва ли уместится в оперативной памяти персонального компьютера. Но, тем не менее мы можем спокойно это сделать и взять нужные значения. В примере лямбда, переданная в функцию map, вызывается только два раза. Все выполняется очень лениво.
let bigRange = 1...1_000_000_000_000 // from one to one billion
let lazyResult = bigRange.lazy.map { "Number \($0)" } // called 3 times
let fourHundredItem = lazyResult[400] // "Number 400"
let lazySlice = lazyResult[401...450] // Slice, String>>
let fiveHundredItem = lazyResult[500] // "Number 500"
В Swift мы всегда ограничены Range«ем. Мы не может создать бесконечный ряд значений. Можно исхитриться и сделать это иначе, но «из коробки» этого нет. Зато в Haskell есть.
Можно сделать список от одного до бесконечности. Делаем map ко всем элементам (номер и число, которые превратятся в строку). Затем берем любые элементы либо срез или список. Срез тоже будет возвращен ленивым списком.
infiniteList = [1..]
mappedList = map (\x -> "Number " ++ show x) infiniteList -- called 2 times
fourHundredItem = mappedList !! 400 -- "Number 400”
lazySlice = take (450 - 400) (drop 400 mappedList) -- [401..450]
fiveHundredItem = mappedList !! 500 -- "Number 500”
lazyArray = [2+1, 3*2, 1/0, 5-4] -- item values not evaluated
lengthOfArray = length lazyArray -- still not evaluated
Haskell — самый ленивый язык из всех, которые я видел. В нем массивы могут содержать boxed (упакованные) и unboxed (распакованные) элементы. В случае, когда элементы массива упакованы (еще не вычислены) при операциях с массивом, которые не требуют получения значения элементов, эти значения вычислены не будут. Примером такой операции может служить метод length.
Лямбда-исчисления
Что это
Лямбда исчисление — формальная система в математической логике для выражения вычисления на основе операций аппликации и абстракции функций при помощи связывания и замены переменных.
Чем полезны
Концепция лямбда-исчисления приносит в языки программирования понятие анонимных функций, которые могут захватывать внешние (по отношению к функции) переменные.
Пример использования
Ниже — пример на Swift, где мы используем лямбду вместо именованных функций.
let numbers = [0,1,2,3,4,5,6,7,8,9,10]
let stringNumbers = numbers.map { String($0) } // ["0","1","2","3","4","5","6","7","8","9","10"]
let sum = numbers.reduce(0, combine: { $0 + $1 }) // 55
let avg = numbers.reduce(0.0, combine: { $0 + Double($1) / Double(numbers.count) }) // 5.0
let from4To7SquareNumbers = numbers.filter { $0 > 3 }.filter { $0 < 7 }.map { $0 * $0 } // [16, 25, 36]
Здесь показан пример, как вычислить сумму или среднее в одну строчку. И профильтровать.
Также концепция ламбда-исчисления привносит в языки программирования понятие каррирования. Каррирование позволяет нам разбить функцию с несколькими параметрами на несколько функций с одним параметром. Это дает нам возможность получать результат вычисления промежуточных функций и применять к этим функциям разные аргументы для получения нескольких результатов.
Пример использования каррирования
func raiseToPowerThenAdd(array: [Double], power: Double) -> ((Double) -> [Double]) {
let poweredArray = array.map { pow($0, power) }
return { value in
return poweredArray.map { $0 + value }
}
}
let array = [3.0, 4.0, 5.0]
let intermediateResult = raiseToPowerThenAdd(array, power: 3)
intermediateResult(0) // [27, 64, 125]
intermediateResult(5) // [32, 69, 230]
intermediateResult(10) // [37, 74, 135]
Здесь мы получаем результат вычисления степени чисел в массиве и потом добавляем к этому результату определенное число. Важно обратить внимание на то, что вычисление степеней происходит только один раз при вызове функции raiseToPowerThenAdd.
Заключение
На мой взгляд, самыми важными концепциями для разработки мобильного ПО (в плане качества кода) являются: концепция чистых функций и концепция опциональности. Первая — дает нам понятную и простую идею как сделать наш код более переносимым, качественным и тестируемым. Вторая — заставляет нас думать о возможных крайних случаях и ошибках, которые могут прийти извне, и обрабатывать их корректно.
Надеюсь, материал будет полезен и ваш код станет еще качественней.
Иван Смолин, iOS-разработчик.
Комментарии (1)
6 ноября 2016 в 11:33
0↑
↓
А чем єта не чистая? func cube (x: Int) → Int {
return square (x) * x
}