Сила 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 будет большим упущением.

ПОДПИСАТЬСЯ - USBKiller