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