Статически типизированные языки обычно вынуждают писать типы переменных по любому поводу. Но уже далеко не всегда: теория и практика языков программирования значительно ушли вперед, просто эти достижения не сразу принимаются индустрией. В этой статье мы поговорим о языке программирования OCaml и увидим, что статическая типизация необязательно связана с неудобствами.
OCaml и семейство ML
OCaml относится к семейству языков ML. К нему же относятся ныне редкий Standard ML, Microsoft F#, который во многом представляет собой клон OCaml, и, с оговорками, Haskell.
Многие языки семейства ML способны производить статическую проверку типов в коде, где нет ни одного объявления типа переменной благодаря механизму вывода типов. Ограниченную форму вывода типов многие уже видели в Go, где можно не объявлять примитивные типы, а просто писать var x = 10. Swift предоставляет те же возможности. OCaml идет гораздо дальше и выводит типы функций.
1 |
let sqr x = x * x (* sqr : int -> int *) |
Теоретическая основа вывода типов — алгоритм Хиндли — Милнера. Детерминированный вывод типов возможен не во всех системах. В частности, Haskell использует тот же подход, но функции в нем требуют явного объявления типа. Цена детерминированности — отсутствие полиморфизма ad hoc (перегрузки функций). Каждая функция в OCaml может иметь один и только один тип. Отсутствие перегрузки функций компенсируется «функторами» — параметризованными модулями.
РЕКОМЕНДУЕМ:
— Язык программирования Ada
— Программирование на языке Ada
Swift заимствовал многие концепции из ML, в частности алгебраические типы и параметрический полиморфизм, которые обеспечивают удобную и безопасную работу функций с коллекциями (списками) независимо от типа значений. Новые языки для JS также зачастую заимствуют из них, так что знакомство с ML полезно для их понимания.
Почему OCaml?
Традиционное применение OCaml и языков семейства ML — разработка компиляторов, средств статического анализа и автоматического доказательства теорем. К примеру, на нем был написан компилятор Rust до того, как он научился компилировать сам себя. OCaml также нашел применение в финансовой сфере, где ошибка в коде может за пару минут довести компанию до банкротства, например в Lexifi и Jane Street.
Пригодным к применению в качестве скриптового его делает особенность реализации: он предоставляет одновременно интерпретатор, компилятор в байт-код и компилятор в машинный код для популярных платформ, в том числе x86 и ARM. Можно начать разработку в интерактивной оболочке интерпретатора, затем записать код в файл. А если скрипт превращается в полноценную программу, скомпилировать ее в машинный код.
С помощью сторонних инструментов вроде js_of_ocaml и BuckleScript код на OCaml также можно компилировать в JavaScript. В Facebook таким способом переписали большую часть Facebook Messenger на ReasonML, это альтернативный синтаксис для OCaml.
В отличие от Haskell, в OCaml используется строгая, а не ленивая модель вычислений, что упрощает ввод-вывод и анализ производительности. Кроме того, он поддерживает изменяемые переменные (ссылки) и прочие средства императивного программирования.
Мы не будем затрагивать веб-скрипты и сосредоточимся на традиционных. В этой статье я также намеренно опускаю создание собственных типов, систему модулей и многие другие возможности. Первая цель — показать «вкус» языка.
В этой статье предполагается использование совместимой с POSIX системы: Linux, FreeBSD или macOS. Сам OCaml работает на Windows, но менеджер пакетов OPAM пока не поддерживает эту систему, поэтому библиотеки пришлось бы собирать вручную со всеми зависимостями. Теоретически использовать OPAM можно в Cygwin, но я не пробовал.
Установка OCaml
Стандартным менеджером пакетов для OCaml стал OPAM. Кроме библиотек, он также может устанавливать сам компилятор и переключаться между разными версиями. Многие системы предоставляют какую-то версию OCaml в репозиториях (часто устаревшую), но с помощью OPAM легко поставить самую свежую от имени обычного пользователя. Для Linux и macOS авторы предоставляют статически скомпилированную версию.
Как установить OPAM, вы можете прочитать в документации.
После установки мы поставим самую новую на настоящий момент версию компилятора 4.07 и несколько утилит и библиотек, которые потребуются нам в примерах.
1 2 |
$ opam switch 4.05 $ opam install utop lambdasoup |
Для проверки работоспособности запустим utop — интерактивную оболочку интерпретатора. Стандартный интерпретатор ( ocaml) слишком «спартанский» — без поддержки истории команд и автодополнения, поэтому его мы будем использовать только в неинтерактивном режиме.
1 2 3 4 5 |
$ utop utop # print_endline "hello world" ;; hello world - : unit = () |
Интерактивный интерпретатор позволяет вводить многострочные выражения, но из-за этого для завершения ввода нужно применять символ ;;. Использовать его в исходном коде допустимо, но излишне.
Компилятор в байт-код называется ocamlc, а нативный компилятор — ocamlopt. Сегодня мы столкнемся с ocamlc, но рассматривать компиляцию не будем.
Работа в OCaml
Для первого примера мы напишем традиционный hello world с дополнительной фичей — возможностью установить приветствие с помощью переменной окружения GREETING.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
(* Hello world program *) let greeting_env_var = "GREETING" let default_greeting = "Hello" let get_greeting () = try Sys.getenv greeting_env_var with Not_found -> default_greeting let greet someone = let greeting = get_greeting () in Printf.printf "%s %s\n" greeting someone let _ = greet "world" |
Сохраним код в файл hello.ml и попробуем запустить:
1 2 3 4 5 |
$ ocaml ./hello.ml Hello world $ GREETING="Hi" ocaml ./hello.ml Hi world |
Вместо выполнения из командной строки мы можем импортировать файл в интерактивный интерпретатор с помощью директивы #use. В качестве бонуса мы также увидим выведенные типы всех переменных и функций.
1 2 3 4 5 6 7 8 9 10 11 12 13 |
$ utop utop # #use "hello.ml";; val greeting_env_var : string = "GREETING" val default_greeting : string = "Hello" val get_greeting : unit -> string = <fun> val greet : string -> unit = <fun> Hello world - : unit = () utop # greet "hacker" ;; Hello hacker - : unit = () |
Для простоты выполнения можно сделать файл исполняемым и добавить #!/usr/bin/env ocaml. На работу интерпретатора это не повлияет, но с точки зрения компиляторов будет ошибкой синтаксиса, поэтому в нашем примере мы этого не делаем.
Теперь разберем, что происходит в этом коде. Код начинается с комментария, комментарии заключаются в символы (* ... *).
Сначала мы определяем две переменные с помощью ключевого слова let. Синтаксис: let <имя> = <значение>. Кроме глобальных объявлений переменных, мы будем использовать локальные — с помощью конструкции let <имя> = <значение> in <выражение>. Такие объявления могут быть выложенными.
1 2 3 4 |
let n = let x = 1 in let y = 2 in x + y |
Затем мы определяем функцию, которая выдает значение переменной окружения GREETING, если она определена, или значение по умолчанию, если нет. Синтаксис функций мало отличается от синтаксиса для переменных: let <имя> <список формальных параметров> = <тело>.
В качестве формального параметра мы берем () — значение типа unit, которое часто используется как заглушка: функций без аргументов и возвращаемых значений в OCaml быть не может. Как и во всех функциональных языках, оператора return тут нет — функции не возвращают значения, а вычисляются в них.
Если переменная не определена, функция Sys.getenv выдает исключение Not_found, которое мы обрабатываем с помощью конструкции try ... with ....
Особенности исключений в OCaml
Исключения в OCaml не являются объектами, и в нем нет иерархии исключений. Можно создавать новые исключения всего одной строкой и передавать в них значения любых типов:
1 2 3 |
exception Error_without_message exception Error_with_message of string exception Error_with_number of int |
Далее мы определяем функцию с настоящим аргументом — адресатом приветствия. Выделенной точки входа, вроде main в C, в OCaml нет. Все выражения программы просто вычисляются сверху вниз. Мы вызываем функцию greet и игнорируем ее значение с помощью конструкции let _ = greet "world".
Более правильным способом будет let () = greet "world", потому что попытка использовать значение не типа unit на правой стороне выражения станет ошибкой типизации. Это частный случай сопоставления с образцом.
Просмотр выведенных типов
Мы уже видели, что интерактивный интерпретатор показывает все выведенные типы, но это можно сделать и в неинтерактивном режиме. Самый простой способ увидеть типы переменных и функций — запустить ocamlc -i. Эта же команда часто применяется для автоматической генерации интерфейсов модулей.
1 2 3 4 5 |
$ ocamlc -i ./hello.ml val greeting_env_var : string val default_greeting : string val get_greeting : unit -> string val greet : string -> unit |
Типы функций пишутся через стрелки, в unit -> string на левой стороне стрелки — тип аргумента, а на правой — тип возвращаемого значения. У функции со многими аргументами стрелок будет больше: int -> int -> string — функция от двух целочисленных значений, которая возвращает строку.
Быстро просматривать выведенные типы очень помогает сторонний инструмент под названием Merlin. Он интегрируется со многими популярными редакторами и позволяет увидеть тип выделенного выражения в тексте.
Типобезопасный printf
Попробуем внести в нашу программу ошибки. Например, используем неправильный формат в printf. В большинстве языков это будет ошибкой времени выполнения или ошибкой сегментации, но не в OCaml. Заменим %s на %d и запустим:
1 2 3 4 |
$ ocaml ./hello.ml File "./hello.ml", line 14, characters 26-34: Error: This expression has type string but an expression was expected of type int |
Тип выражения с printf выводится в зависимости от строки формата, и несовпадение формата с типом фактического параметра приводит к ошибке компиляции.
Попробуем заменить greet "world" на greet 42:
1 2 3 4 |
$ ocaml ./hello.ml File "./hello.ml", line 16, characters 14-16: Error: This expression has type int but an expression was expected of type string |
Эффект тот же: компилятор понял, что greet имеет тип string -> unit, и возмутился, когда вместо строки в качестве аргумента использовали число.
Инфиксные операторы и частичное применение функций
Перегрузки функций и операторов в OCaml нет, зато можно создавать свои инфиксные операторы. Операторы определяются так же, как и функции, но их символы нужно заключить в скобки.
1 2 3 |
let (++) x y = x + y + y let x = 2 ++ 3 let () = Printf.printf "%d\n" x |
Если оператор начинается с символа *, следует поставить после скобки пробел, чтобы компилятор не принял выражение за начало комментария:
1 |
let ( *@ ) x y = x * y * y |
Стандартная библиотека предоставляет ряд полезных операторов вроде |>, который позволяет легко строить цепочки выражений без лишних скобок. Если бы его не было, его можно было бы легко определить как let (|>) x f = f x.
1 |
let () = "hello world" |> String.length |> Printf.printf "%d\n" |
Здесь значение hello world передается функции String.length, а затем значение передается дальше в printf. Особенно удобно это бывает в интерактивном интерпретаторе, когда нужно применить еще одну функцию к предыдущему выражению.
Можно заметить, что у printf здесь указан только один аргумент — форматная строка. Дело в том, что в OCaml любую функцию со многими аргументами можно применить частично (partial application), просто опустив часть аргументов.
Тип функции Printf.printf "%s %s\n" будет string -> string -> unit — формат зафиксирован, но аргументы для строк свободны. Если указать еще один аргумент, мы получим функцию типа string -> unit с фиксированным первым аргументом, которую сможем применить ко второму.
1 2 |
let greet = Printf.printf "%s %s\n" "Hello" let () = greet "world" |
Чтение файлов
Теперь напишем тривиальный аналог cat — программу, которая читает строки из файла и выдает их на стандартный вывод. Здесь мы задействуем оператор |> и средства императивного программирования.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
let fail msg = print_endline msg; exit 1 let open_file path = try open_in path with Sys_error msg -> fail msg let read_file input_channel = try while true do input_line input_channel |> print_endline done with End_of_file -> close_in input_channel let () = open_file Sys.argv.(1) |> read_file |
Функция fail выводит сообщение об ошибке и завершает выполнение программы с кодом 1. Два выражения разделены точкой с запятой, как в большинстве императивных языков. Нужно только помнить, что в OCaml точка с запятой не завершает выражения, а разделяет их. Ставить ее после последнего выражения нельзя. Последнее выражение в цепочке становится возвращаемым значением функции.
РЕКОМЕНДУЕМ:
Программирование в консоли
Далее мы пишем функцию, которая открывает файл на чтение и возвращает его дескриптор либо завершает программы с ошибкой, если возникло исключение.
Файл мы открываем с помощью функции open_in : string -> in_channel. В OCaml дескрипторы файлов для чтения и записи — это значения разных несовместимых типов ( in_channel и out_channel), поэтому попытки писать в файл, открытый только для чтения или наоборот, будут пойманы еще на этапе проверки типов.
В функции read_file мы используем цикл while с достаточно очевидным синтаксисом.
Изменяемые переменные
По умолчанию все переменные в OСaml неизменяемые. Их значения можно переопределить, но модифицировать нельзя. Такой подход гораздо безопаснее, но иногда обойтись без присваивания сложно.
Для этой цели применяются ссылки. Создать ссылку можно с помощью функции ref, которая возвращает значения типа 'a ref. Буква с апострофом означает, что этот тип полиморфный — на ее месте может быть любой тип, например int ref или string ref.
Присвоить ссылке новое значение можно с помощью оператора :=, а получить ее текущее значение — с помощью !.
Для демонстрации мы напишем аналог wc, который считает строки в файле. Ради простоты возьмем наш cat, но прочитанные строки проигнорируем с помощью let _ = и добавим lines := !lines + 1 через точку с запятой. Очень важно не забывать получать значение ссылки с помощью !, иначе будет ошибка компиляции — значения и ссылки на них четко разделены.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
let lines = ref 0 let wc path = let input_channel = open_in Sys.argv.(1) in try while true do let _ = input_line input_channel in lines := !lines + 1 done with End_of_file -> close_in input_channel; Printf.printf "%d %s\n" !lines path let () = wc Sys.argv.(1) |
Веб-скрепинг
Можно поспорить, что предыдущие примеры не были такими уж «скриптовыми». В завершение я продемонстрирую извлечение заголовков новостей из главной страницы сайта с помощью библиотеки lambdasoup.
Мы могли бы выполнить запрос HTTP из самого скрипта, например с помощью библиотеки cohttp, но для экономии времени будем читать вывод curl со стандартного ввода.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
#!/usr/bin/env ocaml #use "topfind";; #require "lambdasoup";; open Soup.Infix let default default_value option_value = match option_value with Some str -> str | None -> default_value let get_news_header node = node $ "span" |> Soup.leaf_text |> default "" let soup = Soup.read_channel stdin |> Soup.parse let () = let news = soup $$ ".entry-title" in Soup.iter (fun x -> get_news_header x |> print_endline) news |
Сохраним код в файл ./x_news.ml и выполним:
1 2 3 |
$ curl https://tech-geek.ru 2>/dev/null | ./x_news.ml | head -n 2 Аналоги Microsoft Office для Linux Как новичку определиться с языком программирования |
Что происходит в этом коде? Сначала мы подключаем библиотеки. Механизм их подключения не является частью языка, а реализован в библиотеке компилятора topfind. Импортируем ее с помощью #use "topfind". Затем мы используем директиву #require из этой библиотеки, чтобы подключить пакет lambdasoup, установленный из OPAM. В utop мы можем сразу использовать #require, потому что он автоматически подключает topfind, но в стандартном интерпретаторе этот шаг необходим.
Директивы интерпретатора также не являются частью языка, а реализуются через расширения. При желании можно даже создать свои.
С помощью open Soup.Infix мы импортировали модуль, где определены инфиксные операторы LambdaSoup в основное пространство имен. К остальным его функциям мы будем обращаться по их полным названиям, вроде Soup.read_channel. Оператор $$ выглядит знакомо для каждого пользователя библиотек вроде jQuery — он извлекает соответствующие селектору элементы из дерева HTML, которое мы получили из Soup.parse.
Поскольку на главной странице сайта заголовки новостей находятся в тегах <h3> с классом entry-title, а сам текст заголовка — в его дочернем теге <span>, в get_news_header мы применяем селектор span и передаем результат функции Soup.leaf_text, которая извлекает из элемента текст.
Элементы в HTML могут не иметь текста, поэтому для функции Soup.leaf_text авторы использовали особый тип: string option. Значения типа string option могут быть двух видов: None или Some <значение>. Такие типы — альтернатива исключениям, которые в принципе невозможно проигнорировать, поскольку тип string option несовместим со string и, чтобы извлечь значение, нужно обработать и ситуацию с None.
В функции default мы разбираем оба случая с помощью оператора match, который можно рассматривать как эквивалент case из C и Java, хотя его возможности шире. В случае с None мы возвращаем указанное в аргументе значение по умолчанию, а в случае с Some — возвращаем присоединенную к Some строку.
Наконец, в Soup.iter мы передаем анонимную функцию, которую она применит к каждому элементу из news.
Заключение
Компактный синтаксис, отсутствие необходимости писать объявления типов и интерактивные средства разработки делают OCaml пригодным для больших серьезных проектов и для небольших программ и скриптов. В то же время статическая проверка типов помогает писать надежные и безопасные программы, а возможность компиляции в машинный код или JS обеспечивает языку широкий спектр применения.
Многие возможности системы типов остались за кадром — например, в ряде случаев она может обнаружить проблемы с логикой кода вроде бесконечных циклов, а не только простое несовпадение типов переменных.
Из проблем можно отметить меньшее количество библиотек, чем у других языков, некоторый минимализм стандартной библиотеки и недостаточную поддержку Windows, но эта ситуация постепенно улучшается. Также текущий механизм сборки мусора ограничивает производительность на многопроцессорных системах из-за блокировок в библиотеке времени выполнения, но и в этом направлении ведутся работы.
Если вы ищете новый язык для прикладных программ, не посмотреть на OCaml будет большим упущением.