- PVSM.RU - https://www.pvsm.ru -

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

Занимательное функциональное программирование в Ruby
Эта статья посвящена бесцельному путешествию по вырожденной форме 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) # => возвращает id
update_person.(new_name,new_birthdate,new_gender,id)
delete_person.(id)
fetch_person.(id) # => возвращает имя, дату рождения и пол в виде массива

Во первых, нам нужно уметь добавлять человека в базу данных. При этом входные данные должны пройти проверку. Будем извлекать эти данные со стандартного потока ввода (предполагаем, что gets и puts являются встроенными функциями и работают как ожидается):

puts "Name?"
name = gets

puts "Birthdate?"
birthdate = gets

puts "Gender?"
gender = gets

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

Давайте договоримся, что в нашем приложении все методы бизнес-логики возвращают массив из двух элементов: первый элемент — значение функции при её успешном завершении, а второй элемент — строка с сообщением об ошибке. Наличие или отсутствие значения (nil) в одной из ячеек массива говорит об успехе или неудаче выполнения операции.

Теперь, когда мы знаем, что нужно принимать и что нужно возвращать, приступим к написанию самой функции:

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(), эта функция приводит возвращает пустую строку, если ей передано значение nil.

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

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

Конечно, мы не говорили, что циклы использовать нельзя :) Но предположим, что их у нас нет.

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

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

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, но еще и «разбить» массив на части и использовать его как отдельные параметры функции, используя синтаксис вида ((id,name,birthdate,gender)).

Пока все хорошо. Этот код, возможно, выглядит немного странно, но он не многословный и не запутанный.

Больше функций — чище код

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

Теперь база данных предоставляет новые версии 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; в 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 теперь выглядит менее красиво из-за синтаксиса получения атрибута, но зато теперь мы можем без труда добавлять новые поля, сохраняя структуру программы.

Объектная ориентированность — это лишь функции

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

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)

Пока мы заговорили об ООП, давайте добавим наследование! Пусть у нас есть сотрудник, который является человеком, но еще имеет свой номер сотрудника:

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))
  [new_person.(:with_id,id),nil] # <====
}

Это выглядит, конечно, не так чисто, как person.id = id, но это выглядит достаточно прилично, для того, чтобы быть читаемым. Код от этого стал только лучше.

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

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

В данный момент единственная структура данных, которая у нас есть — массив. У нас нет методов массива, поскольку у нас нет классов.

Массивы в Ruby на самом деле являются кортежами, и самая общая операция, которую мы можем над ними проводить, — извлечение данных. Например:

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 перегружен узкоспециализированными конструкциями, сложным синтаксисом и метапрограммированием, однако нам удалось реализовать многое даже не используя классов! Может, вашу проблему можно решить более простым способом? Может, стоит просто положиться на самые очевидные части языка, чем пытаться использовать все «самые крутые фичи»?

Автор: kharvd


Сайт-источник PVSM.RU: https://www.pvsm.ru

Путь до страницы источника: https://www.pvsm.ru/ruby/11686