Введение

Если вы думаете о неразборчивом море скобок, когда слышите термин «функциональное программирование», вы не одиноки. Функциональное программирование может показаться пугающим, чуждым и непрактичным, особенно если исходить из прочного опыта в императивном или объектно-ориентированном языке, таком как C или Java. Возможно, вы видели или использовали реализацию LISP, языка, разработанного почти 60 лет назад, и пропустили роскошь и синтаксис более современных языков. Хорошая новость заключается в том, что мы узнали много нового о языках программирования с 1958 года, и функциональное программирование не должно пугать. Фактически, если вы регулярно работаете с Ruby, вы, вероятно, использовали его аспекты, возможно, даже не подозревая об этом.

Что такое функциональное программирование?

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

Блоки и процессы

Наиболее распространенными функциональными аспектами Ruby являются функции, которые перебирают списки, например, «each»:

array = ["Bob", "Jane", "Joe"]
array.each do |name|
  puts name
end

Если вы провели много времени с Ruby, вы, вероятно, видели что-то подобное раньше и интуитивно понимали, что он делает. Он читается почти как псевдокод: «Для каждого имени в массиве выведите это имя». Однако то, что на самом деле происходит под капотом, является одной из самых фундаментальных идей в функциональном программировании с небольшим привкусом Ruby. Код между do и end - это то, что в Ruby называется «блоком», и он представляет собой функциональный литерал так же, как 3 представляет собой целочисленный литерал. В приведенном выше коде происходит то, что функция определяется как блок и сразу же передается как своего рода аргумент каждой функции. Однако, чтобы блок обрабатывался как данные сам по себе, он должен быть упакован в специальный класс Ruby под названием Proc. Proc принимает блок в качестве параметра аналогично каждому из них, но позволяет сохранять блок и взаимодействовать с ним, как и с другими объектами Ruby. Позже, чтобы запустить функцию, вы вызываете для нее метод call. Давайте разберемся с каждым из вышеперечисленных блоков, чтобы более четко увидеть, как это работает.

people = ["Bob", "Jane", "Joe"]
print_arg = Proc.new do |arg|
  puts arg
end
# prints Linda to the console
print_arg.call("Linda")
# prints Bob, Jane, and Joe to the console
people.each(&print_arg)

Блок был явно определен как Proc и назначен переменной. Теперь легче сказать, что блок - это просто функция, которая передается каждому в качестве аргумента. `&` Перед `print_arg` берет объект Proc и распаковывает из него блок для каждого, прямо противоположно тому, что делает` Proc.new`. Имея этот блок в руке, `each` затем перебирает каждый элемент в массиве и вызывает функцию с этим элементом в качестве аргумента. Самое интересное в этом то, что, поскольку Procs - это просто объекты, вы можете определить любое количество различных Procs, назначить их переменным и даже решить во время выполнения, какой из них вы собираетесь использовать!

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: в зависимости от времени суток один из этих двух процессов будет сохранен в greet. Если слишком рано утром, сохраняется grumpy_greeting, в противном случае - nice_greeting. Обратите внимание, что это условие выполняется только один раз, а не один раз для каждого элемента в списках. Когда у нас есть нужный Proc, мы передаем его каждому в качестве параметра. Когда последняя строка будет выполнена днем, функция, хранящаяся в `nice_greeting`, будет выполнена 3 раза, по одному разу для каждого имени в людях. Такое использование Procs дает массу гибкости даже для такого гибкого языка, как Ruby.

Функции как композиция

Представьте, что вы создаете клон 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 @position[: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] >= BOTTOM_BOUND
  end
end

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

class MissileEnemy < Enemy
  def initialize(position)
    super(position)
  end
  def shoot
    Missile.new(@position)
  end
end

Сейчас это довольно прилично; Большинство врагов будут двигаться вперед и назад, стреляя лазерами, некоторые враги будут двигаться по диагонали, стреляя лазерами, а некоторые враги будут двигаться вперед и назад, стреляя ракетами. Вы можете продолжить добавлять другие классы, которые представляют особенное поведение, но в какой-то момент вы, вероятно, зададите себе ужасный вопрос: «Что, если я хочу, чтобы враг двигался по диагонали и стрелял ракетами?» Традиционные иерархии классов не имеют простого решения этой проблемы. Множественное наследование очень быстро становится некрасивым, и к настоящему времени мы поняли, что это не лучшее решение. Вы можете создать класс, расширяющий только «DiagonalEnemy», и скопировать метод съемки, или, наоборот, расширить «MissileEnemy» и скопировать метод перемещения. Возможно, вместо того, чтобы играть в избранное, вы просто создаете новый класс и скопируете в него оба метода. Каким бы способом вы ни пытались использовать наследование, вы получите дублированный код, а это значит, что вам придется поддерживать один и тот же код в двух местах. Это увеличивает усилия и увеличивает вероятность ошибок.

Однако если подумать, `DiagnoalEnemy`,` MissileEnemy` и `MissileDiagonalEnemy` не описывают новые вещи, которые похожи на Enemy, они описывают вариации поведения , которые есть у вражеских объектов. «Поведение» ужасно похоже на «функцию». Фактически, новые классы просто определяют функции, изменяющие поведение. Зачем нам нужны отдельные классы для хранения этих функций? Оказывается, нет! Процедуры идеально подходят для описания такого поведения. Вот как может выглядеть исправленная версия с использованием Procs:

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_missile = Proc.new do |position|
  Missile.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_missile)
challenge_boss_enemy = Enemy.new({x: 0, y: 0}, move_diagonally, shoot_missile)

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

shoot_both = Proc.new do |position|
  shoot_laser.call(position)
  shoot_missile.call(position)
end
two_shot_enemy = Enemy.new({x: 0, y: 0}, move_back_and_forth, shoot_both)
shoot_and_move = Proc.new do |position, direction|
  shoot_laser.call(position)
  move_back_and_forth.call(position, direction)
end
run_and_gun_enemy = Enemy.new({x: 0, y: 0}, shoot_and_move, shoot_laser)

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

Резюме

Хотя функциональное программирование поначалу может показаться немного чуждым, Ruby отлично справляется с тем, чтобы заставить его чувствовать себя естественной частью языка. Как только вы осознаете, как это работает, это действительно может открыть новые интересные способы осмысления проблем. Мы коснулись здесь только поверхности; есть целый мир потрясающих функциональных идей, которые вы можете воплотить, когда поймете основы. Объектно-ориентированное программирование полезно при работе с определенными типами абстракций, но не всегда лучший инструмент. Поскольку Ruby содержит как объектно-ориентированные, так и функциональные возможности, вы всегда можете использовать лучший инструмент для решения данной проблемы.

Вы можете проверить больше интересных постов в Инженерном блоге по рукопожатию.