Статьи

Функциональный стиль — часть 6

Функции высшего порядка II: карри.

Ранее в части 5 мы видели, как вы можете использовать композицию функций для организации вашего кода в последовательности шагов, представляющих основной «счастливый» поток, в то время как альтернативные «несчастные» пути выполнения инкапсулированы в многократно используемую структуру, известную как монада. Составление функций требует выполнения функций, которые принимают ровно один аргумент. Но у вас может быть функция с более чем одним аргументом, так что вы тогда делаете? Тебе не повезло?

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

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

1
(defn add [a b] (+ a b))

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

1
(def add-one (partial add 1))

и мы видим, что это дает правильный результат:

1
2
user=> (add-one 2)
3

Но это не эзотерическая Clojure магия. Чтобы увидеть, насколько это тривиально, давайте сделаем это и в Javascript. Вот функция add :

1
2
3
function add(a, b) {
    return a + b
}

и вот версия с одним из исправленных параметров:

1
2
3
function addOne(a) {
    return add(a, 1)
}

Итак, это не совсем то , что мы сделали в Clojure. У нас была функция более высокого порядка, называемая partial которая может принимать любую функцию и фиксировать некоторые ее аргументы. Вот пример Javascript, который ближе к версии Clojure. Нам нужно создать собственную partial функцию, но это очень просто сделать:

1
2
3
4
5
function partial(f, fixedValue) {
    return function(a) {
        return f(fixedValue, a)
    }
}

Затем мы можем использовать его для возврата карри-версии функции add :

1
2
3
4
var addTwo = partial(add, 2)
undefined
addTwo(3)
5

Используя карри, чтобы очистить мины.

Пока что так бесполезно. Как я уже сказал, это становится действительно эффективной техникой программирования, когда вы используете ее динамически для создания частично примененных функций по требованию во время выполнения. Итак, давайте попробуем пример, который использует технику, которая немного интереснее. Возможно, вы помните игру «Сапер», которая раньше поставлялась бесплатно вместе с Windows (вы все равно можете скачать ее бесплатно для Windows 10).

Функции высшего порядка

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

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

01
02
03
04
05
06
07
08
09
10
"      *   \n"
"     *   *\n"
"**        \n"
"  *     * \n"
"  ***   * \n"
"     * *  \n"
"      *   \n"
"          \n"
"  *       \n"
"        * \n"

он должен произвести следующий вывод:

01
02
03
04
05
06
07
08
09
10
"    12*111\n"
"221 1*211*\n"
"**21111122\n"
"24*421 2*2\n"
" 2***223*2\n"
" 1233*3*21\n"
"    12*21 \n"
" 111 111  \n"
" 1*1   111\n"
" 111   1*1\n"

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

Знакомство с соседями.

Первый шаг — найти соседей для любой ячейки. Чтобы сделать это проще, мы расширим домен, предполагая две вещи:

  • Доска бесконечна.
  • Отрицательные координаты в порядке.

Как мы увидим, ни одно из этих предположений не повлияет на окончательное решение. Чтобы вычислить соседей для ячейки, мы можем сделать:

1
2
3
4
5
6
7
(def neighbours
  [[-11] [01] [11]
   [-10]         [10]
   [-1, -1] [0, -1] [1, -1]])
 
(defn neighbours-of [x y]
  (set (map (fn [[x-offs y-offs]] [(+ x-offs x) (+ y-offs y)]) neighbours)))

Мы определили neighbours как вектор пар смещения координат, соответствующих восьми ячейкам, смежным с любой позицией доски. Я отформатировал это, чтобы сделать это более ясным: отверстие в середине представляет позицию доски. Функция neighbours-of отображает лямбду на neighbours , чтобы добавить смещения x и y каждого соседа в предоставленную ячейку. Таким образом, он создает набор, содержащий все восемь соседей ячейки, например:

1
2
user=> (neighbours-of 2 2)
#{[2 3] [3 3] [1 1] [1 3] [3 1] [2 1] [1 2] [3 2]}

которые действительно являются координатами всех соседей ячейки (2, 2). Получив это, мы можем построить доску с соседями только для одной шахты. Идея состоит в том, что мы будем проверять входную доску ячейка за ячейкой, генерировать промежуточную плату для каждой ячейки на входе и затем объединять их все в окончательный результат. Если рассматриваемая ячейка содержит мину, то сгенерированная доска будет содержать 1 в каждой соседней ячейке; все остальные ячейки будут содержать нули. Наконец, мы сведем все сгенерированные промежуточные доски в таблицу результатов, суммируя соответствующие ячейки на каждой промежуточной доске.

Строим доску.

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

1
2
(defn generate-cell [neighbours y x]
  (if (contains? neighbours [x y]) 1 0))

Аргумент neighbours — это набор позиций ячеек, смежных с шахтой, которые генерируются neighbours-of . Если рассматриваемая ячейка содержит мину, то она будет содержать позиции ее соседей, в противном случае это будет пустой набор. Аргументы y и x — это координаты создаваемой ячейки. Они находятся в таком порядке по определенной причине, которую мы скоро увидим. Кроме того, функция очень проста: если сгенерированная ячейка оказывается смежной с шахтой, она возвращает 1, иначе 0:

1
2
3
4
user=> (generate-cell (neighbours-of 2 2) 2 3)
1
user=> (generate-cell (neighbours-of 2 2) 4 5)
0

Таким образом, мы можем использовать generate-cell для генерации линии ячеек, сопоставляя ее с последовательностью позиций x:

1
2
3
(defn generate-line [neighbours width y]
  (map (partial generate-cell neighbours y)
       (range 0 width)))

Теперь мы видим немного карри. Мы частично применили первые два аргумента к generate-cell , чтобы динамически создать новую функцию, которая принимает только один аргумент, который является положением х ячейки. Вот почему аргументы x и y были изменены. Затем мы отображаем эту новую функцию на последовательность, сгенерированную (range 0 width) которая формирует координаты x для каждой ячейки в строке:

1
2
user=> (range 0 10)
(0 1 2 3 4 5 6 7 8 9)

Теперь, когда у нас есть функция, которая генерирует линию, вызывая эту функцию для диапазона координат y, мы видим, как доска начинает обретать форму. Чтобы полностью понять этот пример, представьте, что мы генерируем доску 5 × 5 для ячейки в (2, 2), которая, как оказалось, содержит шахту:

01
02
03
04
05
06
07
08
09
10
user=> (generate-line (neighbours-of 2 2) 5 0)
(0 0 0 0 0)
user=> (generate-line (neighbours-of 2 2) 5 1)
(0 1 1 1 0)
user=> (generate-line (neighbours-of 2 2) 5 2)
(0 1 0 1 0)
user=> (generate-line (neighbours-of 2 2) 5 3)
(0 1 1 1 0)
user=> (generate-line (neighbours-of 2 2) 5 4)
(0 0 0 0 0)

Тогда должно быть довольно легко увидеть, как написать функцию для генерации всей доски:

1
2
3
(defn generate-board [dimensions neighbours]
  (mapcat (partial generate-line neighbours (dimensions :w))
          (range 0 (dimensions :h))))

Эта функция ожидает, чтобы dimensions были картой, содержащей ширину и высоту доски, например, для доски 10 × 10: {:h 10 :w 10} . Еще раз, он частично применяет первые два аргумента (соседей и ширину) к генерируемой линии, а затем отображает эту частично примененную функцию на последовательность, сгенерированную (range 0 (dimensions :h)) которая дает координаты y всех линий.

Подождите. Что такое mapcat?

Вам может быть интересно, что делает mapcat. Итак, вот что делает функция generate-board , если мы используем map вместо mapcat:

1
2
user=> (generate-board {:h 5 :w 5} (neighbours-of 2 2))
((0 0 0 0 0) (0 1 1 1 0) (0 1 0 1 0) (0 1 1 1 0) (0 0 0 0 0))

и это то, что он делает с mapcat вместо этого:

1
2
user=> (generate-board {:h 5 :w 5} (neighbours-of 2 2))
(0 0 0 0 0 0 1 1 1 0 0 1 0 1 0 0 1 1 1 0 0 0 0 0 0)

Как видите, список списков сведен в один список. Другими словами, он делает то же самое, что Stream.flatMap в Java. Это облегчит нам жизнь, потому что, как я сказал ранее, мы собираемся создать одну из этих плат для каждой ячейки ввода. Если в ячейке есть мина, то сгенерированная доска будет содержать 1, где находится каждый из ее соседей (хотя она не будет содержать саму мину: это будет накладываться на конечный результат как последний шаг). Затем мы сведем эти сгенерированные доски к одной доске, суммируя значения ячеек в каждой позиции.

Генерация промежуточных плат.

Вот код, который берет одну ячейку из входных данных и генерирует их выходную плату:

1
2
3
4
5
(defn mine? [cell]
  (= \* cell))
 
(defn board-for-cell [dimensions y x cell]
  (generate-board dimensions (if (mine? cell) (neighbours-of x y))))

Здесь в форме if отсутствует условие else. Когда условие возвращает значение Falsey, оно возвращает nil , что в данном случае нас вполне устраивает. Как обычно, лучший способ понять, что он делает, — выполнить его в REPL:

1
2
3
4
user=> (board-for-cell {:w 5 :h 5} 1 1 \space)
(0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0)
user=> (board-for-cell {:w 5 :h 5} 1 1 \*)
(1 1 1 0 0 1 0 1 0 0 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0)

Теперь мы можем сгенерировать набор плат для данной строки ввода:

1
2
3
4
(defn boards-for-line [dimensions line y]
  (map (partial board-for-cell dimensions y)
       (range 0 (dimensions :w))
       line))

который делает это при выполнении в REPL с различными входами. Вывод на самом деле не выглядит так, потому что я отформатировал его для ясности, но структура данных точна:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
user=> (boards-for-line {:w 3 :h 3} "*  " 0)
((0 1 0
  1 1 0
  0 0 0)
 (0 0 0
  0 0 0
  0 0 0)
 (0 0 0
  0 0 0
  0 0 0))
user=> (boards-for-line {:w 3 :h 3} " * " 1)
((0 0 0
  0 0 0
  0 0 0)
 (1 1 1
  1 0 1
  1 1 1)
 (0 0 0
  0 0 0
  0 0 0))
user=> (boards-for-line {:w 3 :h 3} "  *" 2)
((0 0 0
  0 0 0
  0 0 0)
 (0 0 0
  0 0 0
  0 0 0)
 (0 0 0
  0 1 1
  0 1 0))

Я отформатировал вывод, разбив внутренние списки на массивы 3 × 3, чтобы помочь вам понять, что происходит. Надеюсь, это имеет смысл.

Теперь у нас есть все, что нам нужно, чтобы собрать сердце нашего решения:

1
2
3
4
5
6
(let [input-board "*  \n * \n  *",
      lines (clojure.string/split-lines input-board),
      dimensions {:h (count lines), :w (count (first lines))}]
  (mapcat (partial boards-for-line dimensions)
          lines
          (range 0 (dimensions :h))))

Это приводит к следующему выводу (немного отформатирован для облегчения понимания):

1
2
3
4
5
6
7
8
9
((0 1 0 1 1 0 0 0 0)
 (0 0 0 0 0 0 0 0 0)
 (0 0 0 0 0 0 0 0 0)
 (0 0 0 0 0 0 0 0 0)
 (1 1 1 1 0 1 1 1 1)
 (0 0 0 0 0 0 0 0 0)
 (0 0 0 0 0 0 0 0 0)
 (0 0 0 0 0 0 0 0 0)
 (0 0 0 0 1 1 0 1 0))

Подведение промежуточных досок.

Это набор плат, который мы должны сократить до одной платы, добавив соответствующие значения ячеек, что мы и будем делать дальше. Во-первых, однако, орлиные глаза могли заметить, что функция карри (partial boards-for-line dimensions) отображается на две последовательности: lines и (range 0 (dimensions :h)) которая представляет собой последовательность целых чисел от 0 до (number of lines - 1) . Это хорошая вещь в Clojure: вы можете отобразить функцию на несколько последовательностей одновременно. Отображаемая функция должна принимать столько аргументов, сколько имеется последовательностей, и сопоставление продолжается до тех пор, пока не истечет самая короткая последовательность. Например:

1
2
3
(map #(str %1 "-" %2)
     (list 1 2 3 4)
     (list "one" "two" "three"))

производит:

1
("1-one" "2-two" "3-three")

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

1
2
(defn sum-up [& vals]
  (reduce + vals))

Быстрый тест доказывает, что это работает:

1
2
user=> (sum-up 0 1 1 0 0 1 0 1 1 1 0 0 1 1 1 0 1)
10

Теперь мы можем использовать суммирование, чтобы уменьшить количество досок до одной:

1
2
3
4
5
6
7
(defn draw [input-board]
  (let [lines (str/split-lines input-board),
        dimensions {:h (count lines), :w (count (first lines))}]
    (->> (mapcat (partial boards-for-line dimensions)
                 lines
                 (range 0 (dimensions :h)))
         (apply map sum-up))))

с быстрым тестом, отформатированным снова, чтобы помочь пониманию:

1
2
3
4
5
user=> (minesweeper/draw "*   \n *  \n  * \n   *")
(1 2 1 0
 2 2 2 1
 1 2 2 2
 0 1 2 1)

Здесь есть две вещи, которые мы раньше не видели. Один из них — apply : то, что это делает, это применить предоставленную функцию к последовательности, как если бы элементы в последовательности были аргументами функции. Например:

1
(apply str (list "one" "," "two" "," "three"))

делает то же самое, что и:

1
(str "one" "," "two" "," "three")

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

01
02
03
04
05
06
07
08
09
10
(map sum-up
     '(0 1 0 1 1 0 0 0 0)
     '(0 0 0 0 0 0 0 0 0)
     '(0 0 0 0 0 0 0 0 0)
     '(0 0 0 0 0 0 0 0 0)
     '(1 1 1 1 0 1 1 1 1)
     '(0 0 0 0 0 0 0 0 0)
     '(0 0 0 0 0 0 0 0 0)
     '(0 0 0 0 0 0 0 0 0)
     '(0 0 0 0 1 1 0 1 0))

что дает результат (1 2 1 2 2 2 1 2 1) . (NB. Кавычки заставляют Clojure обрабатывать списки буквально, а не как вызовы функций).

Другая вещь, которую раньше не видели, это макрос последнего потока ->> . Это аналог макроса «первый поток», который мы видели в части 4. Это синтаксический сахар в Clojure, чтобы сделать составные функции более читабельными. Это позволяет вам переписать это:

1
(function-3 (function-2 (function-1 value)))

как это:

1
(->> value function-1 function-2 function-3)

Последние штрихи.

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

1
2
3
4
5
(->> (mapcat (partial boards-for-line dimensions)
             lines
             (range 0 (dimensions :h)))
     (apply map sum-up)
     (map cell-as-text))

где cell-as-text заменяет нули пробелами и преобразует другие значения в строки:

1
2
(defn cell-as-text [cell-value]
  (if (zero? cell-value) \space (str cell-value)))

Теперь поведение таково:

1
2
user=> (draw "*   \n *  \n  * \n   *")
("1" "2" "1" \space "2" "2" "2" "1" "1" "2" "2" "2" \space "1" "2" "1")

Далее нам нужно разбить вывод на строки, что мы делаем с помощью partition :

1
2
3
4
5
6
(->> (mapcat (partial boards-for-line dimensions)
             lines
             (range 0 (dimensions :h)))
     (apply map sum-up)
     (map cell-as-text)
     (partition (dimensions :w)))

и это дает нам:

1
2
user=> (draw "*   \n *  \n  * \n   *")
(("1" "2" "1" \space) ("2" "2" "2" "1") ("1" "2" "2" "2") (\space "1" "2" "1"))

Теперь нам нужно связать внутренние списки вместе, что достигается еще большим каррированием:

1
2
3
4
5
6
7
(->> (mapcat (partial boards-for-line dimensions)
             lines
             (range 0 (dimensions :h)))
     (apply map sum-up)
     (map cell-as-text)
     (partition (dimensions :w))
     (map (partial reduce str)))

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

1
2
user=> (draw "*   \n *  \n  * \n   *")
("121 " "2221" "1222" " 121")

Мы вставляем символы новой строки между строк:

1
2
3
4
5
6
7
8
(->> (mapcat (partial boards-for-line dimensions)
             lines
             (range 0 (dimensions :h)))
     (apply map sum-up)
     (map cell-as-text)
     (partition (dimensions :w))
     (map (partial reduce str))
     (interpose \newline))
1
2
user=> (draw "*   \n *  \n  * \n   *")
("121 " \newline "2221" \newline "1222" \newline " 121")

а затем соединить строки:

1
2
3
4
5
6
7
8
9
(->> (mapcat (partial boards-for-line dimensions)
             lines
             (range 0 (dimensions :h)))
     (apply map sum-up)
     (map cell-as-text)
     (partition (dimensions :w))
     (map (partial reduce str))
     (interpose \newline)
     (reduce str))
1
2
user=> (draw "*   \n *  \n  * \n   *")
"121 \n2221\n1222\n 121"

Наложение мин.

Теперь остается сделать только одно: нам нужно наложить мины из исходной строки ввода поверх результата. Это очень просто сделать:

1
2
(defn overlay-cell [top bottom]
  (if (mine? top) top bottom))

и если мы попробуем это в REPL, мы получим это:

1
2
3
4
user=> (map minesweeper/overlay-cell
            "*   \n *  \n  * \n   *"
            "121 \n2221\n1222\n 121")
(\* \2 \1 \space \newline \2 \* \2 \1 \newline \1 \2 \* \2 \newline \space \1 \2 \*)

Наш вывод вышел в виде списка, потому что это то, что всегда делает map , поэтому нам нужно снова привести его в соответствие:

1
2
(defn overlay-boards [top bottom]
  (reduce str (map overlay-cell top bottom)))

Таким образом, наше окончательное решение выглядит так:

01
02
03
04
05
06
07
08
09
10
11
12
13
(defn draw [input-board]
  (let [lines (str/split-lines input-board),
        dimensions {:h (count lines), :w (count (first lines))}]
    (->> (mapcat (partial boards-for-line dimensions)
                 lines
                 (range 0 (dimensions :h)))
         (apply map sum-up)
         (map cell-as-text)
         (partition (dimensions :w))
         (map (partial reduce str))
         (interpose \newline)
         (reduce str)
         (overlay-boards input-board))))

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

1
2
3
4
5
6
user=> (draw "*    \n *   \n  *  \n   * \n    *")
"*21  \n
 2*21 \n
 12*21\n
  12*2\n
   12*"

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

REPL-ориентированная разработка.

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

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

В следующий раз.

Как любит говорить мой коллега Хорхе, мы уходим в бесконечность … и дальше! В следующей статье мы рассмотрим ленивую оценку и то, как она обещает вам все — до тех пор, пока вы на самом деле не просите об этом.

Опубликовано на Java Code Geeks с разрешения Ричарда Уайлда, партнера нашей программы JCG . Смотреть оригинальную статью здесь: Функциональный стиль — Часть 6

Мнения, высказанные участниками Java Code Geeks, являются их собственными.