Реклама в Telegram-каналах DzikPic и dev.by теперь дешевле. Узнать подробности 👨🏻‍💻
Support us

Опыт функционального программирования на Ruby

Оставить комментарий
Опыт функционального программирования на Ruby

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

О том, что у него получилось, далее

В этой статье мы отправимся на экскурсию по упрощенной версии Ruby. Надеюсь, она натолкнет вас на новые идеи о функциональном программировании, простоте и дизайне API.

Предположим, что мы пишем на Ruby, но можем организовывать код только в виде лямбда-выражений, а данные у нас структурируются только в виде массивов:

 

square = ->(x) { x * x }
square.(4) # => 16

person = ["Dave",:male]
print_person = ->((name,gender)) {
  puts "#{name} is a #{gender}"
}
print_person.(person)

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

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

Вот как мы будем взаимодействовать с нашим хранилищем данных:

insert_person.(name,birthdate,gender) # => returns an id
update_person.(new_name,new_birthdate,new_gender,id)
delete_person.(id)
fetch_person.(id) # => returns the name, birthdate, and gender as an array

<returns an id>  возвращает Id
<returns the name…> возвращает имя, дату рождения и пол в виде массива

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

puts "Name?"
name = gets

puts "Birthdate?"
birthdate = gets

puts "Gender?"
gender = gets

Нужна функция, которая будет заниматься валидацией и добавлять персоналии в базу данных. Как могла бы выглядеть эта функция? Она должна принимать атрибуты персоны и возвращать либо id (в случае успешной валидации и вставки), либо сообщение об ошибке, описывающее, что идет не так. Но поскольку у нас нет исключений или хешей — только массивы — придется покреативить.

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

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

add_person = ->(name,birthdate,gender) {
  return [nil,"Name is required"]                  if String(name) == ''
  return [nil,"Birthdate is required"]             if String(birthdate) == ''
  return [nil,"Gender is required"]                if String(gender) == ''
  return [nil,"Gender must be 'male' or 'female'"] if gender != 'male' && gender != 'female'

  id = insert_person.(name,birthdate,gender)
  [[name,birthdate,gender,id],nil]
}

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

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

invalid = true
while invalid
  puts "Name?"
  name = gets
  puts "Birthdate?"
  birthdate = gets
  puts "Gender?"
  gender = gets
  result = add_person.(name,birthdate,gender)
  if result[1] == nil
    puts "Successfully added person #{result[0][0]}"
    invalid = false
  else
    puts "Problem: #{result[1]}"
  end
end

Мы, кажется, забыли о циклах  while . Предположим, у нас их тоже нет.

Циклы — это просто функции (вызываемые рекурсивно)

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

get_new_person = -> {
  puts "Name?"
  name = gets
  puts "Birthdate?"
  birthdate = gets
  puts "Gender?"
  gender = gets
  result = add_person.(name,birthdate,gender)
  if result[1] == nil
    puts "Successfully added person #{result[0][0]}"
    result[0]
  else
    puts "Problem: #{result[1]}"
    get_new_person.()
  end
}

person = get_new_person.()

Логично предположить, что в нашем коде будет множество if result[1] == nil. Давайте заключим такие конструкции в функцию. Самая интересная черта функций заключается в том, что они позволяют переиспользовать структуру, чего не скажешь о логике. В данном случае структура сводится к проверке наличия ошибки и к выполнению одного действия в случае наличия такой ошибки и другого — в случае отсутствия.

handle_result = ->(result,on_success,on_error) {
  if result[1] == nil
    on_success.(result[0])
  else
    on_error.(result[1])
  end
}

Далее абстрагируем обработку ошибок в нашей функции  get_new_person:

get_new_person = -> {
  puts "Name?"
  name = gets.chomp
  puts "Birthdate?"
  birthdate = gets.chomp
  puts "Gender?"
  gender = gets.chomp

  result = add_person.(name,birthdate,gender)

  handle_result.(result,
    ->((id,name,birthdate,gender)) {
      puts "Successfully added person #{id}"
      [id,name,birthdate,gender,id]
    },
    ->(error_message) {
      puts "Problem: #{error_message}"
      get_new_person.()
    }
  )
}

person = get_new_person.()

Обратите внимание: пользуясь handle_result, мы можем явно именовать переменные, а не прибегать к разыменованию массива. Мы не только можем назвать error_message, но и, при помощи применяемого в Ruby синтаксиса извлечения массивов, «разбирать» наш массив с персоналиями на его атрибуты. Для этого применяется синтаксис ((id,name,birthdate,gender)).

Пока все нормально. Возможно, этот код кажется странноватым, но он не отличается ни излишней пространностью, ни сложностью.

Чем чище код, тем больше функций

Одна деталь может показаться странной: у нашей персоналии нет реальной структуры и формального определения. У нас просто есть массив и соглашение о том, что его первый элемент содержит имя, второй — дату рождения и т. д. Наша предметная область незамысловата, но давайте предположим, что нам понадобилось добавить новое поле: title. Что произойдет с кодом при этом?

Наши коллеги, занятые разработкой базы данных, дают нам новые версии insert_person и update_person:

insert_person.(name,birthdate,gender,title)
update_person.(name,birthdate,gender,title,id)

После этого потребуется обновить метод add_person:

add_person = ->(name,birthdate,gender,title) {
  return [nil,"Name is required"]                  if String(name) == ''
  return [nil,"Birthdate is required"]             if String(birthdate) == ''
  return [nil,"Gender is required"]                if String(gender) == ''
  return [nil,"Gender must be 'male' or 'female'"] if gender != 'male' && gender != 'female'

  id = insert_person.(name,birthdate,gender,title)

  [[name,birthdate,gender,title,id],nil]
}

И, поскольку эти извлечения используются в get_new_person, это тоже нужно изменить. Уф!

get_new_person = -> {
  puts "Name?"
  name = gets.chomp
  puts "Birthdate?"
  birthdate = gets.chomp
  puts "Gender?"
  gender = gets.chomp
  puts "Title?"
  title = gets.chomp

  result = add_person.(name,birthdate,gender,title)

  handle_result.(result,
    ->((name,birthdate,gender,title,id)) {
      puts "Successfully added person #{id}"
      [id,name,birthdate,gender,title,id]
    },
    ->(error_message) {
      puts "Problem: #{error_message}"
      get_new_person.()
    }
  )
}

Это типичный случай сильной связи. Функция get_new_person действительно не должна иметь дело с конкретными полями персоны; она просто считывает поля и передает их в add_person. Давайте посмотрим, как это сделать, выделив часть кода в новые функции:

read_person_from_user = -> {
  puts "Name?"
  name = gets.chomp
  puts "Birthdate?"
  birthdate = gets.chomp
  puts "Gender?"
  gender = gets.chomp
  puts "Title?"
  title = gets.chomp
  [name,birthdate,gender,title]
}

person_id = ->(*_,id) { id }

get_new_person = -> {
  handle_result.(add_person.(*read_person_from_user.())
    ->(person) {
      puts "Successfully added person #{person_id.(person)}"
      person
    },
    ->(error_message) {
      puts "Problem: #{error_message}"
      get_new_person.()
    }
  )
}

Теперь мы абстрагировали процесс сохранения персоны, преобразовав его в две функции: read_person_from_user и person_id. На данном этапе не потребуется изменять get_new_person, если мы решим добавить в информацию о персоне новые поля.

Если вы не совсем понимаете, что в этом коде делает *, коротко объясню: * позволяет нам обращаться с массивом как со списком аргументов и наоборот. В person_id мы используем список параметров *_,id, приказывающий Ruby передать в функцию все аргументы, кроме последнего, в переменной _ (она так называется, поскольку нас не интересует ее значение), а последний аргумент записать в переменную id. Такой механизм работает только в Ruby 1.9; в Ruby 1.8 лишь последний аргумент функции может использовать синтаксис с *. Далее, при вызове add_person мы применяем * к результатам read_person_from_user.

Поскольку  read_person_from_user возвращает массив, мы собираемся работать с этим массивом как со списком аргументов, ведь add_person принимает явные аргументы. * делает это за нас. Отлично!

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

Итак, нам требуется создать структуру данных.

Структуры данных — это просто функции 

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

new_person = ->(name,birthdate,gender,title,id=nil) {
  return ->(attribute) {
    return id        if attribute == :id
    return name      if attribute == :name
    return birthdate if attribute == :birthdate
    return gender    if attribute == :gender
    return title     if attribute == :title
    nil
  }
}

dave = new_person.("Dave","06-01-1974","male","Baron")
puts dave.(:name)   # => "Dave"
puts dave.(:gender) # => "male"

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

class Person
  attr_reader :id, :name, :birthdate, :gender, :title
  def initialize(name,birthdate,gender,title,id=nil)
    @id = id
    @name = name
    @birthdate = birthdate
    @gender = gender
    @title = title
  end
end

dave = Person.new("Dave","06-01-1974","male","Baron")
puts dave.name
puts dave.gender

Интересно. По размеру два предыдущих фрагмента кода почти одинаковы, но во второй версии (класс) полно специальных форм. Специальные формы — это магия, обеспечиваемая языком или средой времени исполнения. Чтобы понять этот код, необходимо знать:

• что означает class

• что при вызове new применительно к имени класса вызываются методы initialize

• каковы методы

• что если перед переменной стоит @, она становится приватной для экземпляра класса

• в чем заключается разница между классом и экземпляром

• что делает attr_reader

А в функциональной версии нужно знать всего лишь:

• как определить функцию

• как вызвать функцию

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

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

read_person_from_user = -> {
  puts "Name?"
  name = gets.chomp
  puts "Birthdate?"
  birthdate = gets.chomp
  puts "Gender?"
  gender = gets.chomp
  puts "Title?"
  title = gets.chomp

  new_person.(name,birthdate,gender,title)
}

add_person = ->(person) {
  return [nil,"Name is required"]                  if String(person.(:name)) == ''
  return [nil,"Birthdate is required"]             if String(person.(:birthdate)) == ''
  return [nil,"Gender is required"]                if String(person.(:gender)) == ''
  return [nil,"Gender must be 'male' or 'female'"] if person.(:gender) != 'male' &&
                                                      person.(:gender) != 'female'

  id = insert_person.(person.(:name),person.(:birthdate),person.(:gender),person.(:title))
  [new_person.(person.(:name),person.(:birthdate),person.(:gender),person.(:title),id),nil]
}

get_new_person = -> {
  handle_result.(add_person.(read_person_from_user.()),
    ->(person) {
      puts "Successfully added person #{person.(:id)}"
      person
    },
    ->(error_message) {
      puts "Problem: #{error_message}"
      get_new_person.()
    }
  )
}

add_person немного более запутан из-за синтаксиса вызова атрибута, но мы можем с легкостью добавлять новые поля и держать все элементы в структуре.

Объектная ориентация — это просто функции

Мы можем добавлять и производные поля. Допустим, мы хотим запрограммировать для персоны приветствие, в котором упоминается пост (title) этого человека. Можно сделать его атрибутом персоны.

new_person = ->(name,birthdate,gender,title,id) {
  return ->(attribute) {
    return id        if attribute == :id
    return name      if attribute == :name
    return birthdate if attribute == :birthdate
    return gender    if attribute == :gender
    return title     if attribute == :title
    if attribute == :salutation
      if String(title) == ''
        return name
      else
        return title + " " + name
      end
    end
    nil
  }
}

Мы даже можем создавать полномасштабные методы в объектно-ориентированном стиле:

new_person = ->(name,birthdate,gender,title,id) {
  return ->(attribute) {
    return id        if attribute == :id
    return name      if attribute == :name
    return birthdate if attribute == :birthdate
    return gender    if attribute == :gender
    return title     if attribute == :title
    if attribute == :salutation
      if String(title) == ''
        return name
      else
        return title + " " + name
      end
    elsif attribute == :update
      update_person.(name,birthdate,gender,title,id)
    elsif attribute == :destroy
      delete_person.(id)
    end
    nil
  }
}

some_person.(:update)
some_person.(:destroy)

Если уж мы зашли так далеко, давайте добавим наследование! Допустим, у нас есть сотрудник, который является персоной, но имеет id сотрудника:

new_employee = ->(name,birthdate,gender,title,employee_id_number,id) {
  person = new_person.(name,birthdate,gender,title,id)
  return ->(attribute) {
    return employee_id_number if attribute == :employee_id_number
    return person.(attribute)
  }
}

Мы создали классы, объекты и наследование — пользуясь лишь функциями и написав совсем немного кода.

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

Хотя синтаксис для доступа к атрибутам немного неуклюж, я бы не сказал, что при отсутствии классов испытываю огромные проблемы. На данном этапе классы практически становятся синтаксическим сахаром, а не каким-то основополагающим концептом.

Один аспект, который кажется проблематичным, — это мутация. Взгляните, насколько пространен код add_person. Здесь мы вызываем insert_person, чтобы записать персону в базу данных и возвратить ее ID. После этого нам придется создать совершенно новую персону лишь для того, чтобы задать ID. В классическом объектно-ориентированном стиле мы бы сделали просто person.id = id.

Насколько красиво изменяемое состояние вписывается в этот конструкт? Я считаю наиболее красивой деталью компактность кода, а тот факт, что эта компактность реализована при помощи изменяемого состояния, случаен. Если только мы не работаем в системе, испытывающей жесткий дефицит памяти, с активной сборкой мусора, то создание новых объектов не доставит нам каких-либо проблем. Нас, конечно, будет раздражать излишняя повторяемость при создании новых объектов с нуля. Поскольку мы уже знаем, как добавлять функции к нашей, ммм… функции, давайте сделаем и такую функцию, которая будет вновь делать синтаксис более компактным.

new_person = ->(name,birthdate,gender,title,id=nil) {
  return ->(attribute,*args) {
    return id        if attribute == :id
    return name      if attribute == :name
    return birthdate if attribute == :birthdate
    return gender    if attribute == :gender
    return title     if attribute == :title
    if attribute == :salutation
      if String(title) == ''
        return name
      else
        return title + " " + name
      end
    end

    if attribute == :with_id # <===
      return new_person.(name,birthdate,gender,title,args[0])
    end

    nil
  }
}

Теперь add_person становится еще проще:

add_person = ->(person) {
  return [nil,"Name is required"]                  if String(person.(:name)) == ''
  return [nil,"Birthdate is required"]             if String(person.(:birthdate)) == ''
  return [nil,"Gender is required"]                if String(person.(:gender)) == ''
  return [nil,"Gender must be 'male' or 'female'"] if person.(:gender) != 'male' &&
                                                      person.(:gender) != 'female'

  id = insert_person.(person.(:name),person.(:birthdate),person.(:gender),person.(:title))
  [person.(:with_id,id),nil] # <====
}

Код не так чист, как person.id = id, но он достаточно лаконичен и по-прежнему удобочитаем.

Пространства имен — это просто функции

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

В настоящее время у нас есть только один тип структуры данных — массив, а методов у нас нет, так как нет классов. Те массивы, которые у нас есть, — это, в сущности, кортежи, а единственная универсальная операция, которую мы можем выполнять, — это извлечение данных из массива.

first = ->((f,*rest)) { f    } # or should I name this car? :)
rest  = ->((f,*rest)) { rest }

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

empty_map = []
add = ->(map,key,value) {
  [key,value,map]
}
get = ->(map,key) {
  return nil if map == nil
  return map[1] if map[0] == key
  return get.(map[2],key)
}

Можно сделать так:

map = add.(empty_map,:foo,:bar)
map = add.(map,:baz,:quux)
get.(map,:foo)  # => :bar
get.(map,:baz)  # => :quux
get.(map,:blah) # => nil

Итак, проблема с пространствами имен решена:

people = add.(empty_map ,:insert ,insert_person)
people = add.(people    ,:update ,update_person)
people = add.(people    ,:delete ,delete_person)
people = add.(people    ,:fetch  ,fetch_person)
people = add.(people    ,:new    ,new_person)

add_person = ->(person) {
  return [nil,"Name is required"]                  if String(person.(:name)) == ''
  return [nil,"Birthdate is required"]             if String(person.(:birthdate)) == ''
  return [nil,"Gender is required"]                if String(person.(:gender)) == ''
  return [nil,"Gender must be 'male' or 'female'"] if person.(:gender) != 'male' &&
                                                      person.(:gender) != 'female'

  id = get(people,:insert).(person.(:name),
                            person.(:birthdate),
                            person.(:gender),
                            person.(:title))

  [get(people,:new).(:with_id,id),nil]
}

Разумеется, мы можем заменить нашу реализацию new_person словарем, но не помешало бы иметь явный список поддерживаемых атрибутов. Поэтому оставим new_person без изменений.

И последнее волшебство. Include — это приятный элемент Ruby, позволяющий вносить модули в область видимости и обходиться без пространства имен. Можем ли мы это сделать? Ну почти:

include_namespace = ->(namespace,code) {
  code.(->(key) { get(namespace,key) })
}

add_person = ->(person) {
  return [nil,"Name is required"]                  if String(person.(:name)) == ''
  return [nil,"Birthdate is required"]             if String(person.(:birthdate)) == ''
  return [nil,"Gender is required"]                if String(person.(:gender)) == ''
  return [nil,"Gender must be 'male' or 'female'"] if person.(:gender) != 'male' &&
                                                      person.(:gender) != 'female'

  include_namespace(people, ->(_) {
    id = _(:insert).(person.(:name),
                     person.(:birthdate),
                     person.(:gender),
                     person.(:title))

    [_(:new).(:with_id,id),nil]
  }
}

Возможно, это уже перебор, но мне кажется очень интересной идея о том, что элемент вроде include просто позволяет «напечатать меньше букв» — путем применения функций.

Заключение

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

Источник

Новый рекламный формат в наших телеграм-каналах.

Купить 500 символов за $150

Читайте также
7 отличных курсов по финансам. Уплыть «с галеры» и основать свой стартап
7 отличных курсов по финансам. Уплыть «с галеры» и основать свой стартап
7 отличных курсов по финансам. Уплыть «с галеры» и основать свой стартап
Если вы посмотрели «Волк с Уолл-стрит» и хотите, как Леонардо ди Каприо прогуливаться по яхте с бокалом вина в руках, но не знаете, с чего начать, подборка курсов Digitaldefynd станет для вас отличным стартом. Здесь представлены как платные, так и бесплатные программы, которые помогут вам освоить финансовое моделирование. Они подойдут не только для начинающих слушателей, но и для экспертов.
Не Paint-ом единым. 13 курсов по UX/UI-дизайну для продвинутых и не только
Не Paint-ом единым. 13 курсов по UX/UI-дизайну для продвинутых и не только
Не Paint-ом единым. 13 курсов по UX/UI-дизайну для продвинутых и не только
Если вам нравится думать о том, как с минимальными затратами получить максимум эффективности, то проектирование пользовательских интерфейсов определенно вас заинтересует. DigitalDefynd сделал подборку курсов по UX/UI-дизайну как для новичков, так и для продвинутых специалистов. 
Как оплачиваются самые популярные языки GitHub и какой прогноз
Как оплачиваются самые популярные языки GitHub и какой прогноз
Как оплачиваются самые популярные языки GitHub и какой прогноз
Компания в 200+ человек ждёт зарплату две недели. Завис перевод в Цептер Банк?
Компания в 200+ человек ждёт зарплату две недели. Завис перевод в Цептер Банк?
Компания в 200+ человек ждёт зарплату две недели. Завис перевод в Цептер Банк?
26 комментариев

Хотите сообщить важную новость? Пишите в Telegram-бот

Главные события и полезные ссылки в нашем Telegram-канале

Обсуждение
Комментируйте без ограничений

Релоцировались? Теперь вы можете комментировать без верификации аккаунта.

Комментариев пока нет.