Функциональное программирование в Swift

30 мая 2016

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

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

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

Заметка

Этот туториал предполагает, что вы уже знаете основы Swift. Если вы новичок, то мы рекомендуем вам начать с нулевого и первого курсов по Swift.

Пришло время повеселиться… в функциональном программировании!

Простая фильтрация массива

Вы начнете с очень простого: с математики. Ваша первая задача состоит в том, чтобы создать простой Swift скрипт, находящий все четные числа от 1 до 10 (включительно). Довольно простая задача и отличное введение в функциональное программирование!

Фильтрация старым способом

Создайте новый файл в плейграунде Swift и сохранить его. Замените содержимое созданного файла следующим:

var evens = [Int]()
for i in 1...10 {
  if i % 2 == 0 {
    evens.append(i)
  }
}
print(evens)

Это приведет к желаемому результату:

[2, 4, 6, 8, 10]

(Если вы не видите консоль, то вам нужно, чтобы показался Assistant Editor, вы можете его вызвать через View/Assistant Editor/Show Assistant Editor.)

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

  1. Вы создаете пустой (и изменяемый) массив.
  2. Цикл for перебирает числа от 1 до 10 (помните, "..." включительно!).
  3. Если условие (о том, что число должно быть четным) удовлетворяется, то вы добавляете его в массив.

Приведенный выше код является императивным по своей сути. Инструкции указывают компьютеру, как располагать четные числа через четкие инструкции, использующие основные структуры контроля, в данном случае if и for-in.

Код работает просто отлично, но есть важное "но" - тестирование на то, является ли число четным, закопано внутри цикла. Существует также глубокая связь: желаемое действие добавления числа в массив находится внутри условия. Если вы хотите напечатать каждое четное число где-то еще в вашем приложении, то кроме копи-пейста лучшего варианта у вас и не будет.

Функциональная фильтрация

Добавьте следующее в конец вашего плейграунда:

func isEven(number: Int) -> Bool {
  return number % 2 == 0
}
evens = Array(1...10).filter(isEven)
print(evens)

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

[2, 4, 6, 8, 10]

Давайте рассмотрим подробнее функциональную версию. Она состоит из двух частей:

  1. Раздел Array(1...10) представляет собой простой и удобный способ создания массива и содержит числа от 1 до 10. Оператор диапазона 1...10 создает диапазон, который вы передаете инициализатору массива.
  2. Выражение filter - это то место, где происходит магия функционального программирования. Этот метод, раскрытый через Array, создает и возвращает новый массив, содержащий только элементы, для которых данная функция возвращает значение true. В этом примере isEven подается на filter.

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

evens = Array(1...10).filter { (number) in number % 2 == 0 }
print(evens)

Убедитесь, что результаты всех трех вариантов одинаковые. Приведенный выше пример показывает, что компилятор выводит тип параметра числа и возвращает типы замыкания из контекста использования.

Если вы хотите, чтобы ваш код был еще более кратким, то попробуйте следующее:

evens = Array(1...10).filter { $0 % 2 == 0 }
print(evens)

Код выше использует сокращенное обозначение аргумента, неявные возвращения …, вывод типа ... И это работает!

Заметка

Использование сокращенного обозначения аргумента является вопросом предпочтений. Лично я считаю, что для простых примеров, таких как приведен выше, сокращенное обозначение - это то, что нужно. Тем не менее, я бы выбрал явные имена аргументов для чего-то более сложного. Компиляторы не связаны с именами переменных, но с их помощью мир станет гораздо разнообразнее!

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

  1. Higher-order functions (Функции высшего порядка): Это функции, которые вы передаете в качестве аргументов другим функциям. В этом простом примере, фильтр требует передачи функции высшего порядка.
  2. First-class functions (Функции первого класса): Вы можете рассматривать функции, как любую другую переменную. Вы можете присвоить их переменным и передать их в качестве аргументов другим функциям.
  3. Closures (Замыкания): Это фактические анонимные функции, которые вы создаете на месте.

Вы могли заметить, что в Objective-C тоже есть демонстрация некоторых из этих функций за счет использования блоков. Swift, однако, идет дальше, чем Objective-C в развитии функционального программирования, соединяя более лаконичный синтаксис и встроенные функциональные возможности, такие как filter.

Магия фильтров

Массивы в Swift имеют ряд функциональных методов, таких как map, join и reduce. Что, собственно, происходит за кулисами этих методов?

Пришло время прикоснуться к магии фильтров и добавить собственную реализацию.

В этом же плейграунде, добавьте следующую функцию:

func myFilter(source: [T], predicate:(T) -> Bool) -> [T] {
  var result = [T]()
  for i in source {
    if predicate(i) {
      result.append(i)
    }
  }
  return result
}

Выше вы видите общую (generic) функцию, которая принимает в качестве своих входных параметров исходный массив типа  T, и предекат, или функцию, которая принимает экземпляр типа T и возвращает Bool.

Реализация MyFilter выглядит практически также, как императивная версия, которую вы добавили в начале. Основное отличие заключается в том, что у вас есть условие, которое проверяется как функция, а не просто жестко hard-code (определять в коде конкретные значения переменных вместо того, чтобы получать их из внешних источников).

Поработаем с добавленной реализацией фильтра, добавив следующий код:

evens = myFilter(Array(1...10)) { $0 % 2 == 0 }
print(evens)

Опять результат такой же!

Задачка!

Вышеуказанная функция фильтра является глобальной, а вы сможете сделать ее методом массива?

Подсказка №1

Вы можете добавить myFilter в массив с помощью расширения класса.

Подсказка №2

Вы можете расширить Array, но не Array<T>. Это означает, что, когда вы перебираете элементы массива через себя, вы должны выполнить приведение.

Функция Reduce

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

Создайте новый плейграунд и ждите новое задание!

Своя функция Reduce

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

Я уверен, что вы с успехом справитесь и сами, но в любом случае ответ ниже! Добавьте следующую строку в ваш плейграунд:

var evens = [Int]()
for i in 1...10 {
  if i % 2 == 0 {
    evens.append(i)
  }
}
 
var evenSum = 0
for i in evens {
  evenSum += i
}
 
print(evenSum)

В Assistant Editor будет следующий результат:

30

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

Давайте посмотрим, как выглядит функциональный эквивалент!

Функциональная Reduce

Добавьте следующую строку в ваш плейграунд:

evenSum = Array(1...10)
    .filter { (number) in number % 2 == 0 }
    .reduce(0) { (total, number) in total + number }
 
print(evenSum)

Вы увидите точно такой же результат:

30

Предыдущий раздел охватывал создание массива и использование filter. Конечным результатом этих двух операций является массив с пятью числами [2, 4, 6, 8, 10]. Новым шагом в коде выше стало использование reduce.

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

Для того чтобы понять, как работает метод reduce, нужно посмотреть на его описание:

func reduce(initial: U, combine: (U, T) -> U) -> U

Первый параметр - это начальное значение типа U. В вашем текущем коде, начальное значение равно 0, и имеет тип Int (следовательно, U это в данном случае Int). Второй аргумент - это функция combine, и она выполняется один раз для каждого элемента массива.

combine принимает два аргумента: первый, типа U, является результатом предыдущего вызова combine, второй - значением элемента массива, с которым он объединен. Результат, возвращаемый reduce,  это значение, возвращаемое последним вызовом combine.

Давайте разберем все шаг за шагом.

В вашем коде, первая итерация reduce приводит к следующему:

Сначала total имеет значение 0, а первый элемент входного массива равен 2. Если мы просуммируем эти значения, то на выходе (result) получится 2.

Вторая итерация показана ниже:

Во второй итерации, входное значение равно значению предыдущей итерации и следующего элемента из входного массива. Объединение их приводит к 2 + 4 = 6.

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

Обозначенное звездочкой число в правом нижнем углу - общий результат.

Это довольно простой пример, на практике же вы можете выполнять любые виды интересных и сильных преобразований с reduce. Ниже приведены несколько простых примеров.

Добавьте следующую строку в ваш плейграунд:

let maxNumber = Array(1...10)
            .reduce(0) { (total, number) in max(total, number) }
print(maxNumber)

Этот код использует reduce, чтобы найти максимальное значение в массиве целых чисел. В этом случае результат весьма очевиден! Помните, что здесь total на самом деле просто максимальный результат max последней итерации reduce.

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

Примеры, которые вы видели, все уменьшают массивы целых чисел до одиночных целочисленных значений. Конечно, у reduce есть два типа параметров, U и T и они могут быть разными, и, конечно, не должны быть интеджерами. Это означает, что вы можете уменьшить массив одного типа до совершенно другого типа.

Добавьте следующую строку в ваш плейграунд:

let numbers = Array(1...10)
  	.reduce("numbers: ") {(total, number) in total + "\(number) "}
print(numbers)

Это приводит к следующему выводу:

numbers: 1 2 3 4 5 6 7 8 9 10

Этот пример понижает массив целых чисел до строки, указанной выше.

Немного практики и вы будете использовать reduce по-всякому! 

Задача

Можете ли вы использовать reduce для того, чтобы преобразовать массив digits в целое число, если массив ввода такой:

let digits = ["3", "1", "4", "1"]

Ваш понижающий метод должен возвращать Int со значением 3141.

Магия Reduce

В предыдущем разделе, вы разработали свою собственную реализацию filter, что оказалось удивительно просто. Теперь вы увидите, что то же самое можно сделать и для reduce.

Добавьте следующий код в ваш плейграунд:

extension Array {
  func myReduce(seed:U, combiner:(U, T) -> U) -> U {
    var current = seed
    for item in self {
      current = combiner(current, item as! T)
    }
    return current
  }
}

Код выше добавляет метод myReduce в Array, который имитирует встроенную функцию Array. Этот метод просто перебирает каждый элемента массива, вызывая на каждом этапе combiner.

Чтобы это проверить, замените один из методов reduce в вашем плейграунде на myReduce.

Вы, наверное, на этом этапе думаете о том, почему вам должно хотеться реализовать filter или reduce? Ответ: “А и не должно хотеться!”

Но тем не менее, вы можете захотеть расширить использование функциональной парадигмы в Swift и реализовать собственные функциональные методы. Видеть и понимать, насколько это легко реализовывать такие действенные методы, как reduce, очень важно и нужно.

Построение индекса

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

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

В новом плейграунде добавьте:

import Foundation
 
let words = ["Cat", "Chicken", "fish", "Dog",
                      "Mouse", "Guinea Pig", "monkey"]

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

Добавьте следующее:

typealias Entry = (Character, [String])
 
func buildIndex(words: [String]) -> [Entry] {
  return [Entry]()
}
print(buildIndex(words))

typealias Entry определяет тип кортежа для каждой записи индекса. Использование typealias в этом примере делает код более удобным для чтения, устраняя необходимость повторно указать тип кортежа в полном объеме. Вам нужно добавить код, создающий индекс, в buildIndex.

Строим индекс императивно

Начнем с императивного подхода; обновите buildIndex следующим:

func buildIndex(words: [String]) -> [Entry] {
  var result = [Entry]()
  
  var letters = [Character]()
  for word in words {
    let firstLetter = Character(word.substringToIndex(word.startIndex.advancedBy(1)).uppercaseString)
    
    if !letters.contains(firstLetter) {
      letters.append(firstLetter)
    }
  }
  
  for letter in letters {
    var wordsForLetter = [String]()
    for word in words {
      let firstLetter = Character(word.substringToIndex(word.startIndex.advancedBy(1)).uppercaseString)
      
      if firstLetter == letter {
        wordsForLetter.append(word)
      }
    }
    result.append((letter, wordsForLetter))
  }
  return result
}

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

После реализации, вы увидите желаемый результат:

[("C", ["Cat", "Chicken"]), 
 ("F", ["fish"]), 
 ("D", ["Dog"]), 
 ("M", ["Mouse", "monkey"]), 
 ("G", ["Guinea Pig"])]

(Текст выше немного отформатирован для ясности.)

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

Создание индекса функциональным путем

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

import Foundation

let words = ["Cat", "Chicken", "fish", "Dog",
             "Mouse", "Guinea Pig", "monkey"]

typealias Entry = (Character, [String])

func buildIndex(words: [String]) -> [Entry] {
  return [Entry]()
}

print(buildIndex(words))

На данном этапе, заявление print будет выводить пустой массив:

[]

Первым шагом на пути создания индекса будет преобразование слов в массив, содержащий только первые буквы. Обновите buildIndex следующим:

func buildIndex(words: [String]) -> [Entry] {
  let letters = words.map {
    (word) -> Character in
    Character(word.substringToIndex(word.startIndex.advancedBy(1)).uppercaseString)
    
  }
  print(letters)
  
  return [Entry]()
}

Плейграунд в настоящее время выводит массив букв верхнего регистра, каждой букве соответствует слово в вводном массиве.

[C, C, F, D, M, G, M]

В предыдущих разделах, вы столкнулись с filter и reduce. Приведенный выше код представляет map, другой функциональный метод, являющийся частью API массива.

map создает новый массив с результатами вызовов прилагаемого замыкания для каждого элемента в каждом массиве. Вы можете использовать map для выполнения преобразований. В этом случае map преобразует массив типа [String] в массив типа [Character].

Сейчас массив букв содержит дубликаты, а у нужного вам индекса должно быть только одно вхождение каждой буквы. К сожалению, у типа массива в Swift нет метода дедупликации. Это то, что вам нужно написать самим!

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

Добавьте следующую функцию в вашем плейграунде перед buildIndex:

func distinct(source: [T]) -> [T] {
  var unique = [T]()
  for item in source {
    if !unique.contains(item) {
      unique.append(item)
    }
  }
  return unique
}

При построении нового массива, содержащего только уникальные элементы, distinct перебирает все элементы массива.

Обновите buildIndex, чтобы привести в работу distinct:

func buildIndex(words: [String]) -> [Entry] {
  let letters = words.map {
    (word) -> Character in
    Character(word.substringToIndex(word.startIndex.advancedBy(1)).uppercaseString)
    
  }
  let distinctLetters = distinct(letters)
  print(distinctLetters)
  
  return [Entry]()
}

Ваша плейграунд теперь будет выводить уникальные буквы:

[C, F, D, M, G]

Теперь, когда у вас есть массив различных букв, следующей задачей в создании индекса, будет преобразование каждой буквы в экземпляр Entry. Не звучит как преобразование? Еще одна работа для map!

Обновите buildIndex следующим образом:

func buildIndex(words: [String]) -> [Entry] {
  let letters = words.map {
    (word) -> Character in
    Character(word.substringToIndex(word.startIndex.advancedBy(1)).uppercaseString)
    
  }
  let distinctLetters = distinct(letters)
  return distinctLetters.map {
    (letter) -> Entry in
    return (letter, [])
  }
}

Второй вызов map принимает массив символов и выводит массив экземпляров Entry:

[(C, []), 

 (F, []), 

 (D, []), 

 (M, []), 

 (G, [])]

(Опять же, текст выше для ясности отформатирован.)

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

func buildIndex(words: [String]) -> [Entry] {
  let letters = words.map {
    (word) -> Character in
    Character(word.substringToIndex(word.startIndex.advancedBy(1)).uppercaseString)
    
  }
  
  let distinctLetters = distinct(letters)
  return distinctLetters.map {
    (letter) -> Entry in
    
    return (letter, words.filter {
        (word) -> Bool in
      Character(word.substringToIndex(word.startIndex.advancedBy(1)).uppercaseString) == letter
  
      })
  }
}

Это даст вам желаемый результат:

[(C, [Cat, Chicken]),

 (F, [fish]),

 (D, [Dog]),

 (M, [Mouse, monkey]),

 (G, [Guinea Pig])]

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

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

Если бы это был код на Objective-C, у вас было бы несколько вариантов: Вы могли бы создать метод, который бы выполнял эту функцию  или, возможно, вы могли бы добавить этот метод непосредственно к NSString через категорию класса. Тем не менее, если вам нужно выполнить эту задачу в рамках buildIndex, то в служебном методе не достаточно смысловой ясности и использование категории класса будет излишним.

К счастью, Swift предлагает отличный вариант!

Обновите buildIndex следующим:

func buildIndex(words: [String]) -> [Entry] {
  func firstLetter(str: String) -> Character {
    return Character(str.substringToIndex(str.startIndex.advancedBy(1)).uppercaseString)
  }
  
  let letters = words.map {
    (word) -> Character in
    Character(word.substringToIndex(word.startIndex.advancedBy(1)).uppercaseString)
    
  }
  
  
  return distinct(words.map(firstLetter))
    .map {
      (letter) -> Entry in
      
      return (letter, words.filter {
        (word) -> Bool in
        Character(word.substringToIndex(word.startIndex.advancedBy(1)).uppercaseString) == letter
        })
  }
}

Вы увидите точно такой же результат, как видели и раньше.

Приведенный выше код добавляет функцию firstLetter, вложенную в buildIndex, и в результате, является полностью локальной по отношению к внешней функции. Такой вариант получает преимущество функций первого класса в Swift, которые вы можете использовать, как переменные, что позволяет их присваивать и определять их область использования.

Новый код удаляет дублирование, но вы можете сделать еще больше, чтобы очистить buildIndex.

Первый шаг map, направленный на построение массива букв, принимает замыкание, тип которого (String) -> Character. Вы можете заметить, что этот тип точно такой же, как у функции firstLetter, которую вы только что добавили, что означает, что вы можете передать ее непосредственно на map.

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

func buildIndex(words: [String]) -> [Entry] {
  func firstLetter(str: String) -> Character {
    return Character(str.substringToIndex(str.startIndex.advancedBy(1)).uppercaseString)
  }
  
  
  return distinct(words.map(firstLetter))
    .map {
      (letter) -> Entry in
      
      return (letter, words.filter {
        (word) -> Bool in
        Character(word.substringToIndex(word.startIndex.advancedBy(1)).uppercaseString) == letter
        })
  }
}

Конечный результат краткий, но весьма выразительный.

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

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

Заметка

Сейчас buildIndex возвращает неотсортированный индекс; порядок экземпляров Entry зависит от порядка слов в вводном массиве. Ваша задача состоит в том, чтобы отсортировать индекс в алфавитном порядке. Для примера массив строк, то это дало бы следующий вывод:

[(C, [Cat, Chicken]),
 (D, [Dog]),
 (F, [fish]),
 (G, [Guinea Pig]),
 (M, [Mouse, monkey])]

Подсказка:

Тип массива в Swift имеет метод sort, но этот метод изменяет массив, а не возвращает новый, отсортированный экземпляр. Для работы он требует изменяемый массив. В общем, безопаснее иметь дело с неизменяемыми данными, поэтому я бы не советовал этот метод! В качестве альтернативы, использовать метод сортировки, который возвращает второй отсортированный массив.

Что дальше?

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

Урок подготовил: Акулов Иван

Источник урока: Источник

Содержание