Статически типизированные языки обычно вынуждают писать типы переменных по любому поводу. Но уже далеко не всегда: теория и практика языков программирования значительно ушли вперед, просто эти достижения не сразу принимаются индустрией. В этой статье мы поговорим о языке программирования 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 будет большим упущением.