Введение
Если вы думаете о неразборчивом море скобок, когда слышите термин «функциональное программирование», вы не одиноки. Функциональное программирование может показаться пугающим, чуждым и непрактичным, особенно если исходить из прочного опыта в императивном или объектно-ориентированном языке, таком как 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 содержит как объектно-ориентированные, так и функциональные возможности, вы всегда можете использовать лучший инструмент для решения данной проблемы.
Вы можете проверить больше интересных постов в Инженерном блоге по рукопожатию.