Сила OCaml. Разбираемся с типизацией и пишем скрипты
Источник: t.me/USBKiller
Содержание статьи
OCaml и семейство ML Почему OCaml? Установка Разбираем язык Особенности исключений Просмотр выведенных типов Типобезопасный printf Инфиксные операторы и частичное применение функций Чтение файлов Изменяемые переменные Веб-скрепинг Заключение
OCaml и семейство ML
OCaml относится к семейству языков ML. К нему же относятся ныне редкий Standard ML, Microsoft F#, который во многом представляет собой клон OCaml, и, с оговорками, Haskell.
Многие языки семейства ML способны производить статическую проверку типов в коде, где нет ни одного объявления типа переменной благодаря механизму вывода типов. Ограниченную форму вывода типов многие уже видели в Go, где можно не объявлять примитивные типы, а просто писать var x = 10
. Swift предоставляет те же возможности. OCaml идет гораздо дальше и выводит типы функций.
let sqr x = x * x (* sqr : int -> int *)
Теоретическая основа вывода типов — алгоритм Хиндли — Милнера. Детерминированный вывод типов возможен не во всех системах. В частности, Haskell использует тот же подход, но функции в нем требуют явного объявления типа. Цена детерминированности — отсутствие полиморфизма ad hoc (перегрузки функций). Каждая функция в OCaml может иметь один и только один тип. Отсутствие перегрузки функций компенсируется «функторами» — параметризованными модулями.
Swift заимствовал многие концепции из ML, в частности алгебраические типы и параметрический полиморфизм, которые обеспечивают удобную и безопасную работу функций с коллекциями (списками) независимо от типа значений. Новые языки для JS также зачастую заимствуют из них, так что знакомство с ML полезно для их понимания.
Почему OCaml?
Традиционное применение OCaml и языков семейства ML — разработка компиляторов, средств статического анализа и автоматического доказательства теорем. К примеру, на нем был написан компилятор Rust до того, как он научился компилировать сам себя. OCaml также нашел применение в финансовой сфере, где ошибка в коде может за пару минут довести компанию до банкротства, например в Lexifi и Jane Street.
Пригодным к применению в качестве скриптового его делает особенность реализации: он предоставляет одновременно интерпретатор, компилятор в байт-код и компилятор в машинный код для популярных платформ, в том числе x86 и ARM. Можно начать разработку в интерактивной оболочке интерпретатора, затем записать код в файл. А если скрипт превращается в полноценную программу, скомпилировать ее в машинный код.
INFO
С помощью сторонних инструментов вроде js_of_ocaml и BuckleScript код на OCaml также можно компилировать в JavaScript. В Facebook таким способом переписали большую часть Facebook Messenger на ReasonML, это альтернативный синтаксис для OCaml.
В отличие от Haskell, в OCaml используется строгая, а не ленивая модель вычислений, что упрощает ввод-вывод и анализ производительности. Кроме того, он поддерживает изменяемые переменные (ссылки) и прочие средства императивного программирования.
Мы не будем затрагивать веб-скрипты и сосредоточимся на традиционных. В этой статье я также намеренно опускаю создание собственных типов, систему модулей и многие другие возможности. Первая цель — показать «вкус» языка.
INFO
В этой статье предполагается использование совместимой с POSIX системы: Linux, FreeBSD или macOS. Сам OCaml работает на Windows, но менеджер пакетов OPAM пока не поддерживает эту систему, поэтому библиотеки пришлось бы собирать вручную со всеми зависимостями. Теоретически использовать OPAM можно в Cygwin, но я не пробовал.
Установка
Стандартным менеджером пакетов для OCaml стал OPAM. Он может устанавливать сам компилятор, библиотеки и переключаться между разными версиями. Многие системы предоставляют какую-то версию OCaml в репозиториях (часто устаревшую), но с помощью OPAM легко поставить самую свежую от имени обычного пользователя. Для Linux и macOS авторы предоставляют статически скомпилированную версию.
WWW
Как установить OPAM, ты можешь прочитать в документации.
После установки мы поставим самую новую на настоящий момент версию компилятора 4.07 и несколько утилит и библиотек, которые потребуются нам в примерах.
$ opam switch 4.05 $ opam install utop lambdasoup
Для проверки работоспособности запустим utop
— интерактивную оболочку интерпретатора. Стандартный интерпретатор (ocaml
) слишком «спартанский» — без поддержки истории команд и автодополнения, поэтому его мы будем использовать только в неинтерактивном режиме.
$ utop utop # print_endline "hello world" ;; hello world - : unit = ()
Интерактивный интерпретатор позволяет вводить многострочные выражения, но из-за этого для завершения ввода нужно применять символ ;;
. Использовать его в исходном коде допустимо, но излишне.
INFO
Компилятор в байт-код называется ocamlc
, а нативный компилятор — ocamlopt
. Сегодня мы столкнемся с ocamlc
, но рассматривать компиляцию не будем.
Разбираем язык
Для первого примера мы напишем традиционный hello world с дополнительной фичей — возможностью установить приветствие с помощью переменной окружения GREETING
.
(* 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
и попробуем запустить:
$ ocaml ./hello.ml Hello world $ GREETING="Hi" ocaml ./hello.ml Hi world
Вместо выполнения из командной строки мы можем импортировать файл в интерактивный интерпретатор с помощью директивы #use
. В качестве бонуса мы также увидим выведенные типы всех переменных и функций.
$ 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 <выражение>
. Такие объявления могут быть вложенными.
let n = let x = 1 in let y = 2 in x + y
Затем мы определяем функцию, которая выдает значение переменной окружения GREETING
, если она определена, или значение по умолчанию, если нет. Синтаксис функций мало отличается от синтаксиса для переменных: let <имя> <список формальных параметров> = <тело>
.
В качестве формального параметра мы берем ()
— значение типа unit
, которое часто используется как заглушка: функций без аргументов и возвращаемых значений в OCaml быть не может. Как и во всех функциональных языках, оператора return
тут нет — функции не возвращают значения, а вычисляются в них.
Если переменная не определена, функция Sys.getenv
выдает исключение Not_found
, которое мы обрабатываем с помощью конструкции try ... with ...
.
Особенности исключений
Исключения в OCaml не являются объектами, и в нем нет иерархии исключений. Можно создавать новые исключения всего одной строкой и передавать в них значения любых типов:
exception Error_without_message exception Error_with_message of string exception Error_with_number of int
Далее мы определяем функцию с настоящим аргументом — адресатом приветствия. Выделенной точки входа, вроде main
в C, в OCaml нет. Все выражения программы просто вычисляются сверху вниз. Мы вызываем функцию greet
и игнорируем ее значение с помощью конструкции let _ = greet "world"
.
INFO
Более правильным способом будет let () = greet "world"
, потому что попытка использовать значение не типа unit
на правой стороне выражения станет ошибкой типизации. Это частный случай сопоставления с образцом.
Просмотр выведенных типов
Мы уже видели, что интерактивный интерпретатор показывает все выведенные типы, но это можно сделать и в неинтерактивном режиме. Самый простой способ увидеть типы переменных и функций — запустить ocamlc -i
. Эта же команда часто применяется для автоматической генерации интерфейсов модулей.
$ 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
— функция от двух целочисленных значений, которая возвращает строку.
INFO
Быстро просматривать выведенные типы очень помогает сторонний инструмент под названием Merlin. Он интегрируется со многими популярными редакторами и позволяет увидеть тип выделенного выражения в тексте.
Типобезопасный printf
Попробуем внести в нашу программу ошибки. Например, используем неправильный формат в printf
. В большинстве языков это будет ошибкой времени выполнения или ошибкой сегментации, но не в OCaml. Заменим %s
на %d
и запустим:
$ 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
:
$ 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 нет, зато можно создавать свои инфиксные операторы. Операторы определяются так же, как и функции, но их символы нужно заключить в скобки.
let (++) x y = x + y + y let x = 2 ++ 3 let () = Printf.printf "%d\n" x
Если оператор начинается с символа *
, следует поставить после скобки пробел, чтобы компилятор не принял выражение за начало комментария:
let ( *@ ) x y = x * y * y
Стандартная библиотека предоставляет ряд полезных операторов вроде |>
, который позволяет легко строить цепочки выражений без лишних скобок. Если бы его не было, его можно было бы легко определить как let (|>) x f = f x
.
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
с фиксированным первым аргументом, которую сможем применить ко второму.
let greet = Printf.printf "%s %s\n" "Hello" let () = greet "world"
Чтение файлов
Теперь напишем тривиальный аналог cat
— программу, которая читает строки из файла и выдает их на стандартный вывод. Здесь мы задействуем оператор |>
и средства императивного программирования.
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
через точку с запятой. Очень важно не забывать получать значение ссылки с помощью !
, иначе будет ошибка компиляции — значения и ссылки на них четко разделены.
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
со стандартного ввода.
#!/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
и выполним:
$ curl https://xakep.ru 2>/dev/null | ./x_news.ml | head -n 2
Вымогатели по-прежнему захватывают установки MongoDB
Google Adiantum сделает надежное шифрование доступным
Что происходит в этом коде? Сначала мы подключаем библиотеки. Механизм их подключения не является частью языка, а реализован в библиотеке компилятора topfind
. Сначала мы импортируем ее с помощью #use "topfind"
. Затем мы используем директиву #require
из этой библиотеки, чтобы подключить пакет lambdasoup
, установленный из OPAM. В utop
мы можем сразу использовать #require
, потому что он автоматически подключает topfind
, но в стандартном интерпретаторе этот шаг необходим.
INFO
Директивы интерпретатора также не являются частью языка, а реализуются через расширения. При желании можно даже создать свои.
С помощью 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 будет большим упущением.