Функциональные аспекты Ruby

Введение

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

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

Резюме

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

)
Welcome to a place where words matter. On Medium, smart voices and original ideas take center stage - with no ads in sight. Watch
Follow all the topics you care about, and we’ll deliver the best stories for you to your homepage and inbox. Explore
Get unlimited access to the best stories on Medium — and support writers while you’re at it. Just $5/month. Upgrade