Введение
Если вы думаете о хаотичном океане скобочек, когда слышите термин «функциональное программирование», вы не одиноки. Функциональное программирование может показаться пугающим, чужим и ненужным, особенно если вы обладаете опытом в императивном или объектно-ориентированном языке, как C или Java. Возможно вы уже видели или даже использовали какую-нибудь имплементацию LISP, языка созданного почти 60 лет назад, без синтаксической роскоши более современных языков. Хорошие новости: после 1958 года мы узнали много нового о программировании, и функциональное программирование больше не должно никого пугать. На самом деле, если вы регулярно работаете с руби, вы наверняка уже пользовались функциональными аспектами языка, возможно даже не подозревая об этом.
Оригинал этой заметки — Functional Aspects of Ruby — был опубликован в блоге «Handshake» 22 январь 2016, автор: Брендон Гаффорд.
Что такое функциональное программирование?
Прежде чем начинать, давайте закрепим понимание термина «функциональное программирование». В основе своей, функциональное программирование это организация кода вокруг функций, а не вокруг объектов. Чтобы это работало, функции должны рассматриваться как тип данных первого класса в рамках языка программирования. Это лишь модный способ сказать, что функции могут храниться в переменных, возвращаться из других функций, использоваться в качестве параметров, потенциально даже быть изменены, так же как любая другая часть программы. Вместо того чтобы погружаться глубже в теорию, давайте перейдём к примерам.
Проки и блоки
Наиболее широко известный функциональный аспект руби это функции итерирующие по спискам, как например each
:
array = ["Bob", "Jane", "Joe"]
array.each do |name|
puts name
end
Если вы уже давно в руби, вы вероятно видели что-то подобное ранее и догадались что эта штука делает, довольно интуитивно. Это читается почти как псевдокод: «for each name in array, print that name.» Хотя то что происходит под капотом — одна из самых фундаментальных идей функционального программирования, с привкусом руби разумеется. Код между do
и end
— то что в руби называется блок, и он представляет собой литерал функции, также как 3 представляет литерал целого числа. Функция, определённая как блок, передаётся в качестве аргумента функции each
— вот что происходит в коде выше. Для того чтобы блок мог рассматриваться как данные, он должен быть упакован в специальный руби класс, т.н. Proc
. Proc
принимает блок в качестве аргумента, точно также как each
, и позволяет хранить и пользоваться блоком как любым другим руби объектом. Далее, чтобы запустить функцию, вызовем метод call
на ней. Давайте разберём по блок частям, чтобы посмотреть как же он работает.
people = ["Bob", "Jane", "Joe"]
print_arg = Proc.new do |arg|
puts arg
end
# выводит Linda в консоль
print_arg.call("Linda")
# выводит Bob, Jane и Joe в консоль
people.each(&print_arg)
Блок был эксплицитно определён как Proc
и назначен переменной. Теперь можно сказать, что блок это функция передаваемая в качестве аргумента методу each
. Амперсанд (&
) перед print_arg
берёт Proc
объект и распаковывает блок для каждой итерации — супротив тому что делает Proc.new
. С помощью этого блока, each
проходит каждый элемент массива, вызывая функцию и передавая ей элемент в качестве аргумента. Самое классное в Proc
то, что т.к. они являются объектами, вы можете держать сколько угодно проков, назначать их переменным, и даже даже динамически выбирать какой именно использовать.
people = ["Bob", "Jane", "Joe"]
nice_greeting = Proc.new do |arg|
puts "Hey #{arg}!"
end
grumpy_greeting = Proc.new do |arg|
puts "I still need my coffee, #{arg}"
end
if Time.now.hour < 9
greet = grumpy_greeting
else
greet = happy_greeting
end
people.each(&greet)
Сначала мы определяем два разных прока и сохраняем их в переменные: nice_greeting
и grumpy_greeting
. Магия происходит внутри if
, в зависимости от времени дня, один из этих Proc
будет назначен переменной greet
. Если ещё слишком рано, будет сохранён grumpy_greetig
, если же нет —сохраняется nice_greeting
. Обратите внимание, условие выполняется только один раз, а не для каждого элемента списка. Как только мы получили нужный прок, мы передаём его each
в качестве параметра. Если последняя строка выполняется в обед, функция хранимая в nice_greeting
будет запущена 3 раза, по разу для каждого имени в массиве people
. Такое использование Proc
вносит дополнительную гибкость в и без того гибкий руби.
Функции как композиции
Допустим вы создаёте клон Galaga (видеоигра в жанре фиксированного шутера, — прим. переводчика), и вам нужно разработать вражеский корабль. Корабль должен уметь двигаться взад и вперёд, и стрелять в игрока из лазерных пушек. Традиционная объектно ориентированная парадигма подразумевает представление корабля как класс Enemy, вероятно со свойством представляющем координаты, и методами движения и стрельбы. Всё это может выглядеть как-то так:
class Enemy
attr_accessor :position
def initialize(position)
@position = position
@direction = 1
end
def move
@position[:x] += @direction
@direction = -@direction if @position[:x] <= LEFT_BOUND or @postion[:x] >= RIGHT_BOUND
end
def shoot
Laser.new(@position)
end
end
Теперь враги могут двигаться в двух направлениях, и стрелять в игроков. Чтобы сделать игру посложнее, некоторые враги, в дополнение к двум направлениям, будут двигаться по диагонали. Т.к. враги разделяют базовый функционал класса Enemy
, исключая движения, было бы логично расширить класс Enemy
, назовём его DiagonalEnemy
:
class DiagonalEnemy < Enemy
def initialize(position)
super(position)
end
def move
@position[:x] += @direction
@position[:y] += @direction
@direction = -@direction if @position[:x] >= RIGHT_BOUND or @position[:x] <= LEFT_BOUND or @position[:y] <= TOP_BOUND or @position[:y] >= BOTTON_BOUND
end
end
В итоге игра всё равно слишком простая. Добавим корабли «боссы», которые будут стрелять самонаводящимися ракетами, вместо обычных лазеров. Опять же, базовый функционал в классе Enemy, кроме, на этот раз, стрельбы. Создадим новый класс:
class MissleEnemy < Enemy
def initialize(position)
super(position)
end
def shoot
Missle.new(@position)
end
end
Теперь это вполне достойная игра. Большая часть вражеских кораблей двигается взад и вперёд, стреляя из лазеров, некоторые двигаются по диагонали, а некоторые стреляют ракетами (однако двигаются только в двух направлениях). Можно продолжить добавлять новые классы расширяя поведение, однако скоро встанет вопрос «что если нужен корабль который умеет двигаться по диагонали и стрелять ракетами одновременно?» Традиционная иерархия классов не решит этой проблемы. Множественное наследование очень быстро превращается в кашу, и к тому моменту вы уже поймёте что это не лучшее решение. Можно создать класс расширяющий только DiagonalEnemy
и скопировать метод shoot
, или наооборот расширить MissleEnemy
и скопировать метод move
. Возможно вместо того чтобы мучаться выбором, лучше создать новый класс и скопировать оба метода в него. В любом случае, если вы воспользуетесь наследованием, вы получите дублированный код, а значит вам придётся поддерживать один тот же код в двух местах. Что требует больше усилий и увеличивает вероятность получить баг. Подумайте, несмотря на то что DiagonalEnemy
, MissleEnemy
и MissleDiagonalEnemy
не описывают новых вещей имеющихся у врагов, они описывают вариации поведения, которым обладают обладают вражеские объекты. «Поведение» звучит ужасно похоже на «функцию». В сущности, новые классы лишь определяют функции, изменяющие поведение. Почему бы нам не разделить классы содержащие эти функции? Выясняется что и не нужно! Proc
идеально подходят для описания этого поведения. Вот как может выглядеть ревизия нашей игры с проками:
class Enemy
attr_accessor :position
def initialize(position, move, shoot)
@position = position
@move = move
@shoot = shoot
@direction = 1
end
def move
@position, @direction = @move.call(position, direction)
end
def shoot
@shoot.call(@position)
end
end
move_back_and_forth = Proc.new do |position, direction|
position[:x] += direction
direction = -direction if position[:x] <= LEFT_BOUND or position[:x] >= RIGHT_BOUND
[position, direction]
end
move_diagonally = Proc.new do |position, direction|
position[:x] += direction
position[:y] += direction
direction = -direction if position[:x] >= RIGHT_BOUND or position[:x] <= LEFT_BOUND or position[:y] <= TOP_BOUND or position[:y] >= BOTTOM_BOUND
[position, direction]
end
shoot_laser = Proc.new do |position|
Laser.new(position)
end
shoot_missle = Proc.new do |position|
Missle.new(position)
end
normal_enemy = Enemy.new({x: 0, y: 0}, move_back_and_forth, shoot_laser)
diagonal_enemy = Enemy.new({x: 0, y: 0}, move_diagonally, shoot_laser)
boss_enemy = Enemy.new({x:0, y: 0}, move_back_and_forth, shoot_missle)
challenge_boss_enemy = Enemy.new({x: 0, y: 0}, move_diagonally, shoot_missle)
Теперь класс Enemy
получает желаемое поведение от передаваемых ему проков, просто вызывая их чтобы определить что делать. Проки определённые далее, используют ту же логику что и предыдущий пример, за исключением того что теперь они не опираются на классы и наследование чтобы хранить её. В конце приведены различные возможные поведения, для демонстрации той легкости с который их можно переиспользовать и комбинировать. Однако это ещё не всё. Эти поведения могут быть переиспользованы, для определения ещё более сложного поведения. Например если вы хотите чтобы враг мог стрелять как из лазера, так и ракетами, или стрелять из лазера во время движения, просто предайте Enemy
прок, комбинирующий эти базовые поведения:
shoot_both = Proc.new do |position|
shoot_laser.call(position)
shoot_missle.call(position)
end
two_shot_enemy = Enemy.new({x: 0, y: 0}, move_back_and_forth, shoot_booth)
shoot_and_move = Proc.new do |position, direction|
shoot_laser.call(postion)
move_back_and_forth.call(position, direction)
end
run_and_gun_enemy = Enemy.new({x: 0, y: 0}, shoot_and_move, shoot_laser)
Если однажды вы решите изменить то чем сейчас является стрельба из лазера или ракетами, вам не нужно изменять это в каждом месте, где это происходит — нужно лишь изменить соответствующий метод.
Резюме
Пока функциональное программирование остаётся немного чужеродным, руби прекрасно справляется с задачей превращения его в органичную часть языка. Как только вы поймёте как оно работает, перед вами откроется множество новых способов решения проблем. Здесь мы только слегка оглядели поверхность этого огромного и прекрасного мира функционального программирования. Объектно ориентированное программирование полезно только с некоторыми типами абстракций, и не всегда является лучшей парадигмой. Однако, т.к. руби содержит и объектно ориентированные и функциональные возможности, вы всегда можете выбрать тот инструмент, который лучше всего подходит для решения вашей задачи.
Комментарии (3)
MentalBlood
07.12.2022 09:51+1функции могут храниться в переменных, возвращаться из других функций, использоваться в качестве параметров, потенциально даже быть изменены, так же как любая другая часть программы
Не очень шарю за ФП, но звучит как ООП где все является объектом
mehatron
07.12.2022 18:08+1ФП это, конечно, круто, но по сути вы просто реализовали ООП паттерн Стратегия на проках, а от ФП тут мало.
shoot_both = Proc.new do |position| shoot_laser.call(position) shoot_missle.call(position) end
Было бы интересно показать это хотя бы в виде композиции проков
chernish2
В начале статьи было бы уместно объяснить, что есть ФП, чем оно отличается от других видов, в чём преимущества.