вторник, 17 января 2012 г.

[prog] Прочитал тут статью Аарона Минского “OCaml for the Masses”…

которая на acmqueue с подзаголовком “Why the next language you learn should be functional” (статья не новая, от 27 сентября 2011, но мне попалась на глаза случайно пару дней назад).

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

Развитие темы под катом, поскольку букв много.

Первый пример там стандартный, связанный с неким простеньким интерпретатором выражений, который на OCaml выглядит очень кратко:

type 'a expr = | True 
               | False 
               | And  of  'a expr * 'a  expr 
               | Or   of  'a expr * 'a  expr 
               | Not  of  'a expr 
               | Base of  'a  
 
let  rec eval eval_base expr  = 
   let  eval' x = eval eval_base x in 
   match expr with 
   | True  -> true 
   | False -> false 
   | Base base  -> eval_base base 
   | And  (x,y) -> eval' x && eval' y  
   | Or  (x,y)  -> eval' x || eval' y 
   | Not  x     -> not (eval' x) 

А на Java намного, сильно намного многословнее:

public  abstract class Expr<T> { 
 
  public interface Evaluator<T> { boolean evaluate(T value); } 
  public abstract boolean eval(Evaluator<T> evaluator); 
 
  public class True<T> extends Expr<T> { 
    public boolean eval(Evaluator<T> evaluator) { return true; } 
  } 
  public class False<T> extends Expr<T> { 
    public boolean eval(Evaluator<T> evaluator) { return false; } 
  } 
  public class Base<T> extends Expr<T> { 
    public final T value; 
    public Base(T value) { this.value = value; } 
    public boolean eval(Evaluator<T> evaluator) 
    { return evaluator.evaluate(value); } 
  } 
  public class And<T> extends Expr<T> { 
    public final Expr<T> expr1; 
    public final Expr<T> expr2; 
    public And(Expr<T> expr1, Expr<T> expr2)  { 
      this.expr1 = expr1; 
      this.expr2 = expr2; 
     } 
    public boolean eval(Evaluator<T> evaluator) { 
      return expr1.eval(evaluator) && expr2.eval(evaluator); 
     } 
  } 
  public class Or<T> extends Expr<T> { 
    public final Expr<T> expr1; 
    public final Expr<T> expr2; 
    public Or(Expr<T> expr1, Expr<T> expr2)  { 
      this.expr1 = expr1; 
      this.expr2 = expr2; 
     } 
    public boolean eval(Evaluator<T> evaluator) { 
      return expr1.eval(evaluator) || expr2.eval(evaluator); 
     } 
  } 
  public class Not<T> extends Expr<T> { 
    public final Expr<T> expr; 
    public Not(Expr<T> expr) { this.expr = expr; } 
    public boolean eval(Evaluator<T> evaluator) 
     { return !expr.eval(evaluator); } 
  } 

Тут да, тут не поспоришь. Преимущества OCaml-а очевидны. Поэтому-то такой пример и любят приводить. Иногда даже складывается впечатление, что на ФЯ ничего кроме разбора грамматик и не делают ;)

В качестве небольшой ремарки к данному примеру приведу всего лишь несколько реплик. Как давно кому-нибудь из читателей этой заметки приходилось иметь дело со столь простыми выражениями? Лично я уже и не помню. Несколько раз за всю карьеру приходилось интерпретаторы (много посложнее приведенных в примере), но тогда хватало ума использовать уже готовые генераторы парсеров, поэтому с подобными страшилками и не сталкивался.

В одном же из случаев пришлось что-то вроде императивных конструкций на синтаксис конфигурационного файла натягивать. Тогда у меня на С++ получилось что-то аналогичное Java-примеру. Но и там количество прикладного кода внутри каждого из классов было настолько много, что “синтаксический оверхэд” был сосем не велик. Так что на игрушечном примере все выглядит страшно. Вспоминая же, как оно бывает на практике – уже совсем не так ;)

Следующий пример должен был показать силу системы типов в поиске ошибок. Был приведен пример функции, удаляющей повторно идущие одинаковые элементы, сначала на Python:

# Removes sequential duplicates, e.g.,
# destutter([1,1,4,3,3,2])  = [1,4,3,2]
def destutter(list): 
    l = [] 
    for i in range(len(list)): 
        if list[i] != list[i+1]: 
             l.append(list[i]) 
    return

затем на OCaml:

let rec destutter l = 
  match l with 
  | []             -> [] 
  | x :: y :: rest -> 
    if  x = y then destutter (y :: rest) 
    else  x :: destutter (y :: rest) 

Оба первых варианта кода содержат одинаковую ошибку – нет корректной обработки ситуации, когда в исходном списке всего один элемент. Только для OCaml-а ошибку отыскивает компилятор, т.к. он понимает, что паттерн-матчинг в destutter не содержит всех возможных вариантов. А вот для Python-овского варианта нужно делать тесты.

ИМХО, такой пример скорее вреден, чем полезен. Я считаю, что разработчик должен писать код понимая, что он хочет, и что должно быть получено в итоге. А раз так, то ситуация со списком из одного элемента для разработчика должна быть даже более интересна, чем ситуация с пустым списком. Ведь именно на таком списке i-й элемент есть, а его сравнение с (i+1)-м элементом невозможно. Но разработчик поведением функции в этом случае не озаботился, т.е. проявил свою некомпетентность. Тем не менее, в варианте с OCaml-ом ему это сошло с рук.

Да, я понимаю, что сильно придираюсь, и что все, даже самые компетентные (включая меня самого) ошибаются в тривиальных ситуациях. Но если речь идет о гипотетических тривиальных ошибках, то где гарантия, что OCaml будет их все ловить? Нет такой гарантии. А посему этот пример идет лесом, т.к. если гарантий нет, то все равно нужно будет делать тесты и тогда какая хрен разница, на Python-е написан код или на OCaml?

Ну и последний пример. Который, якобы, более приближен к жизни, чем предыдущие. Вроде того, что есть некое TCP-соединение и нужно хранить в программе его описание. Первый вариант такой:

type connection_state = 
| Connecting 
| Connected 
| Disconnected 
 
type connection_info = { 
  state:                   connection_state; 
  server:                  inet_addr; 
  last_ping_time:          time option; 
  last_ping_id:            int    option; 
  session_id:              string option; 
  when_initiated:          time option; 
  when_disconnected: time  option; 
} 

а второй, использующий возможности и лаконичность OCaml-а, такой:

type connecting   = { when_initiated:  time; } 
type connected    = { last_ping  : (time * int) option; 
                                 session_id: string; } 
type disconnected = { when_disconnected: time;  } 
 
type connection_state = 
| Connecting   of connecting 
| Connected    of connected 
| Disconnected of disconnected 
 
type connection_info = { 
   state:  connection_state; 
   server: inet_addr; 
} 

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

Во-первых, от лаконичности описаний не остается и следа (на каком бы языке программирования оно не было сделано) – на каждую строчку декларации будет приходиться по 5-10-15-и-более строк с комментариями (да еще со специальными тегами вроде @author, @version, @note, @attention и пр.).

Во-вторых, может статься так, что состояний потребуется больше. И что часть информации между ними должна будет разделяться. Например, состояние Connected указывает, что установлено физическое соединение, но еще не начата логическая сессия. А для логической сессии нужно будет ввести состояние SessionOpened. Причем и для Connected, и для SessionOpened нужен будет общий счетчик пингов (например, такая ситуация есть в протоколе SMPP). Вот вновь и вылезут те самые инварианты, от поддержки которых хотелось избавиться. И простая линейная структура connection_state вновь может стать более удобной.

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

В общем, примеры меня ни в чем не убедили. И это главный недостаток статьи. Не языка, о котором идет речь. Поскольку как раз об OCaml-е у меня остались вполне нормальные впечатления – как раз один из тех ФЯ, которые легко осваиваются такими замшелыми императивщиками, как я.

Да и вообще не понятно, зачем статья была написана. Ну есть контора JaneStreet, которая начинала свой ключевой софт в виде VBA-скриптов для Excel, затем пыталась переписать это на Java, потом на C# и OCaml. Ну пишут там сейчас 65 сотрудников на OCaml. Ну написано там за все это время 2 миллиона строк на OCaml (кстати не понятно, почему так мало). Это все понятно. К чему статью-то было писать? Если для JaneStreet OCaml – это конкурентное преимущество, то лучше было молчать в тряпочку и никому не признаваться ;)

Так что статья не понравилась. Ничего нового в ней для себя не нашел. И желание заняться OCaml-ом после нее не возникло. Так что рекомендовать к прочтению не могу.

А вот познакомиться с OCaml-ом имеет смысл. Хотя бы для того, чтобы подружиться с ML-ным синтаксисом :) Тем более, что OCaml – язык живой и даже вполне себе кроссплатформенный.

16 комментариев:

  1. > Но если речь идет о гипотетических тривиальных ошибках, то где гарантия, что OCaml будет их все ловить? Нет такой гарантии. А посему этот пример идет лесом, т.к. если гарантий нет, то все равно нужно будет делать тесты и тогда какая хрен разница, на Python-е написан код или на OCaml?

    Напомнило: "По результатам опроса Московских водителей возят с собой икону 92%, пользуются ремнями безопасности 12%".

    ОтветитьУдалить
  2. @Евгений

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

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

    На этот короткий код времени к сожалению тратится или столько или даже больше чем на длинный эквивалентный код на менее выразительных языках. Я думал это у меня чисто субъективно (слабоват я в ФЯ :) ) но у других также, даже автор этой статьи косвенно это подтверждает "Six months and 80,000 lines of code later"

    По статье да примеры слабоваты согласен, там и в комментариях критикуют. Но объективно сложно привести хорошие примеры, это как и с ООП на мелких примерах скорее увидишь недостатки, а крупные (в контексте окамла под 1000 строк) не воспримут.

    Если для JaneStreet OCaml – это конкурентное преимущество, то лучше было молчать в тряпочку и никому не признаваться

    Насколько я понимаю им выгоднее все-таки расширить нишу, все равно достаточно высокий входной барьер отсеет конкурентов :)

    В-третьих, если уж противопоставлять OCaml и функциональщину объектно-ориентированному виду

    Как раз OCaml противопоставлять ОО не стоит, как раз в ОО стиле он яву обыграет на раз :)

    ОтветитьУдалить
  3. @Евгений
    почему этим функциональным языком должен быть OCaml

    Кстати хаскелисты на rsdn уже говорят что OCaml вовсе не кошерный, а императивный язык, по большому счету они правы.

    ОтветитьУдалить
  4. @Евгений

    Иногда даже складывается впечатление, что на ФЯ ничего кроме разбора грамматик и не делают

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

    type
    file_data = DirEnum.t
    and
    file_root =
    {
    root_path : utf8;
    root_childs : file_tree list
    }
    and
    file_folder =
    {
    folder_path : utf8;
    folder_data : file_data;
    folder_childs : file_tree list
    }
    and
    file_tree =
    Root of file_root |
    Folder of file_folder |
    File of file_data


    и примеры использования

    let print = iter
    (
    function
    | Root r -> printf p"root: %utf\n" r.root_path
    | Folder f -> printf p"[%utf]\n" f.folder_path
    | File f -> printf p" %utf\n" f.DirEnum.file_name
    )


    let rec count_files =
    let aux a = function
    | Root r -> a
    | Folder f -> a
    | File data -> a + 1
    in fold aux 0

    то же самое на C++ будет гораздо более громоздким, на питоне можно добиться похожего:

    def count_files(tree_el):

    case = FileTreeVisitor()

    @case(Root)
    def root(tree_el):
    return reduce(lambda a, b: a + count_files(b), tree_el.childs, 0)

    @case(Folder)
    def folder(tree_el):
    return reduce(lambda a, b: a + count_files(b), tree_el.childs, 0)

    @case(File)
    def file(tree_el):
    return 1

    return case.apply(tree_el)

    но все-равно кода больше и придется писать прилично дополнительного кода + тесты.

    ОтветитьУдалить
  5. @Alexey Zlobin:

    Да, есть что-то похожее :)

    Но еще раз вернусь к примеру. Он мне напомнил, что некоторые C++ компиляторы в ряде случаев обнаруживают забытый return и выдают ошибку -- у Visual C++ она звучит как так "not all control paths return value". ИМХО, это в точности то же самое, что и было продемонстрировано в примере с паттерн-матчингом. Полагаться на такие способности компилятора почти то же самое, что на икону вместо ремней безопасности. Поскольку в более сложных случаях компилятор забытый return не заметит. Так и в случае, если в паттерн-матчинг какой-нибудь программист по ошибке засунет вариант _.

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

    ОтветитьУдалить
  6. @Rustam:

    По поводу 2 миллионов строк кода.

    Допустим, у них 65 человек пишут по одной тысяче строк кода в месяц на каждого. Уже выйдет 65K в месяц, за год это уже будет 780K. Т.е. только за прошедший год они должны были половину своей кодовой базы написать. А OCaml там применяют уже 10 лет. И пусть сначала это были считаные люди, но все равно...

    У меня есть другое объяснение. Там занимаются не столько производством софта, сколько тюнингом уже существующего. Т.е. постоянной шлифовкой алгоритмов и пр. Фактически, дописывание нового функционала происходит реже, чем переписывание уже существующего.

    А это несколько другая ситуация, чем у компаний, которые занимаются производством софта (особенно под заказ). Где как раз мейнстримовые языки и имеют очень сильные позиции.

    ОтветитьУдалить
  7. @Rustam:

    По поводу противопоставления OCaml и ОО. Это не я начал ;)
    Мне показалось, что автор статьи этим занимается с самого начала :))

    ОтветитьУдалить
  8. @Rustam:

    >Вместо грамматики можно любую деревянную структуру подставить, вот например из одной утилитки выдрано:

    Ну вот о чем я и говорю -- примеры плохие. Нет что бы что-то в таком духе в статье привести.

    PS. Кстати говоря, лаконичность OCaml и C++ не очень корректно сравнивать. В первую очерез за счет наличия сборки мусора в OCaml. Поскольку в плюсах ее нет, то много приседаний приходится именно на управление памятью.

    ОтветитьУдалить
  9. @Евгений

    Автор да, как и большинство камлистов явно ООП недолюбливает, хотя именно ОО система выделяет OCaml, такой больше ни у кого нет.

    ОтветитьУдалить
  10. @Евгений

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

    ОтветитьУдалить
  11. @Rustam:

    >Сборка мусора да конечно плюс, но той же яве мало помогает.

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

    ОтветитьУдалить
  12. Я намекал на то, что программистская смекалка скорее сродни иконе, чем ремням :)

    ОтветитьУдалить
  13. если какая-то ошибка ловится языком А и не ловится языком В, то (в этом случае/контексте) язык А лучше языка В

    всякие твои отмазки типа "да оно более сложные ошибки все равно не поймает" (и значит пусть и эту не ловит? так?) никуда не годятся

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

    мое понимание -- А. "пропустить все повторяющиеся элементы, кроме первого", либо В. "добавить элемент, если предыдущий был не равен этому"

    питоновский код выходит за границу, что выявит *любой* тест, не?

    да, и как насчет [1,1,1,2,2,2]? питон и окамл вроде по-разному ведут себя

    а вообще разбор случаев тут полезен, значит и паттерн-матчинг

    ОтветитьУдалить
  14. @имя:

    По основному пункту ответил здесь: http://eao197.blogspot.com/2012/01/prog.html

    ОтветитьУдалить
  15. @имя
    > если какая-то ошибка ловится языком А и не ловится языком В, то (в этом случае/контексте) язык А лучше языка В

    Этому утверждению явно не хватает пары измеримых критериев :) Например что значит "лучше"?

    ОтветитьУдалить
  16. я не понимаю твой вопрос

    лучше в данном случае это "локально лучше", (естественно, это может требовать локального ухудшения -- например, излишней спецификации чего-то разработчиком, но здесь не такой случай)

    ОтветитьУдалить