Предлагаем вашему вниманию статью Дэвида Коупленда, автора книги о написании приложений для командной строки на 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 нашпигован предметно-ориентированными микроязыками, неудобным синтаксисом, метапрограммированием, а оказывается, что на нем можно сделать многое, даже не пользуясь классами! Возможно, есть простой способ решения стоящей перед вами проблемы? Может быть, не требуется никаких изысков, достаточно самых простых составляющих языка?
Релоцировались? Теперь вы можете комментировать без верификации аккаунта.