Spec-Zone.ru › Haskell 9

14. FFI и бэкенд JavaScript

Бэкенд JavaScript в GHC поддерживает собственное соглашение о вызовах для внешних импортов, специфичных для JavaScript. Поддерживаются любые неприменённые функции, включая имена функций. Обычно внешние импорты JavaScript записываются как неприменённая JavaScript-стрелочная функция, но также поддерживаются анонимные функции с ключевым словом function.

Если рассматривать строку импорта как неприменённую функцию, в импорт можно включить произвольный код JavaScript. Простой пример может выглядеть так:

foreign import javascript "((x,y) => { return x + y; })"
  js_add :: Int -> Int -> Int

14.1. Типы JavaScript FFI

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

Существует ряд поддерживаемых типов, которые можно передавать напрямую таким образом и которые в RTS JavaScript в GHC выступают в роли примитивов. В отличие от них, структуры данных, реализованные на Haskell, например String, не имеют примитивной реализации в JavaScript и не эквивалентны строке JavaScript, поскольку это список.

Таким способом поддерживаются следующие типы:

  • Int, включая Int32 и другие числовые значения фиксированного размера.
  • Int64 и другие 64-битные числа передаются функции как две переменные: первая содержит знак и старшие биты.
  • Bool
  • Char
  • Any
  • ByteArray#
  • Double и Float
  • MVar# и другие объекты RTS
  • Распакованные кортежи (например, (# a, b #)) могут использоваться в типе возвращаемого значения и создаются в JavaScript с помощью таких макросов, как RETURN_UBX_TUP2(x, y).

Как и в C FFI, типы в JavaScript FFI нельзя проверить на соответствие внешнему коду, поэтому следующий пример успешно скомпилируется, несмотря на то что 5 не является допустимым значением JavaScript для типа Haskell Bool:

foreign import javascript "((x) => { return 5; })"
  type_error :: Bool -> Bool

14.1.1. Прерываемое соглашение о вызовах

Внешний импорт можно объявить с соглашением о вызовах interruptible:

foreign import javascript interruptible "((i,cont) => { ...; return cont(17); })"
  foo :: Int -> IO Int

В этом случае JavaScript-функции будет передан один дополнительный аргумент в последней позиции (в примере он обозначен как cont): это функция продолжения, которую нужно вызвать из JavaScript, чтобы вернуть значение из внешнего вызова и возобновить выполнение кода Haskell.

14.1.2. JSVal

В бэкенде JavaScript есть понятие нетипизированного «обычного» значения JavaScript, представленное типом JSVal. Такие значения в основном непрозрачны для кода Haskell: можно представить JSVal как тип данных, конструкторы которого не открыты. Чаще всего этот тип используется для передачи непрозрачных значений JavaScript из одного вызова FFI в другой.

Тем не менее модуль GHC.JS.Prim из base содержит функции для работы с внешними объектами JSVal. На данный момент в нём доступны следующие преобразования:

  • Int <-> JSVal (toJSInt, fromJSInt)
  • String <-> JSVal (toJSString, fromJSString)
  • [JSVal] <-> JSVal (toJSArray, fromJSArray)

В модуле также есть функции для работы с объектами:

  • jsNull :: JSVal — JavaScript-null
  • isNull :: JSVal -> Bool — проверка на JavaScript-null
  • isUndefined :: JSVal -> Bool — проверка на JavaScript-undefined
  • getProp :: JSVal -> String -> JSVal — доступ к полям объекта

14.1.3. Обратные вызовы JavaScript

Модель выполнения JavaScript основана на функциях обратного вызова. Бэкенд JavaScript в GHC реализует их как тип, чтобы поддерживать полезные программы для браузера и программы, взаимодействующие с библиотеками JavaScript.

Модуль GHC.JS.Foreign.Callback из base определяет тип Callback a, а также несколько функций для создания обратных вызовов из функций Haskell с числом аргументов до трёх JSVal. В отличие от обычной функции, функция Callback передаётся через FFI как обычная функция JavaScript, что позволяет вызывать её из JavaScript:

foreign import javascript "((f) => { f('Example!'); })"
  callback_example :: Callback (JSVal -> IO ()) -> IO ()

printJSValAsString :: JSVal -> IO ()
printJSValAsString = putStrLn . fromJSString

main :: IO ()
main = do
  printJS <- syncCallback1 ThrowWouldBlock printJSValAsString
  callback_example printJS
  releaseCallback printJS

В этом примере наша функция printJSValAsString будет вызвана через JavaScript с JavaScript-строкой Example! в качестве аргумента. На последней строке освобождается память обратного вызова. Поскольку среда выполнения JS для Haskell не может определить, используется ли ещё функция в коде JavaScript, память необходимо освободить вручную, когда она больше не нужна.

В первой строке main видно, где именно создаётся Callback с помощью syncCallback1. У syncCallback есть варианты для числа аргументов до трёх, включая вариант без суффикса с нулём аргументов. Для передачи через обратные вызовы более трёх единиц данных рекомендуется при необходимости упаковывать данные в объекты или массивы JavaScript.

Функции для создания обратных вызовов делятся на три категории. Для наглядности здесь приведены сигнатуры типов для арности 1:

  • syncCallback1 :: OnBlocked -> (JSVal -> IO ()) -> IO (Callback (JSVal -> IO ())): синхронные обратные вызовы, не возвращающие значение. Они принимают дополнительный аргумент data OnBlocked = ThrowWouldBlock | ContinueAsync, который используется, если поток блокируется, например, на транзакции MVar.
  • syncCallback1' :: (JSVal -> IO JSVal) -> IO (Callback (JSVal -> IO JSVal)): синхронные обратные вызовы, возвращающие значение. Из-за возвращаемого значения продолжить выполнение асинхронно невозможно, поэтому аргумент OnBlocked не передаётся.
  • asyncCallback1 :: (JSVal -> IO ()) -> IO (Callback (JSVal -> IO ())): асинхронные обратные вызовы, которые сразу запускаются в новом потоке. Не могут возвращать значение.

Соответствие переданных аргументов обратному вызову не проверяется, поэтому следующий пример компилируется и правильно выводит 10, несмотря на то что аргумент передаётся как Int в Callback, принимающий JSVal:

foreign import javascript "((f,x) => { return f(x); })"
  apply_int :: Callback (JSVal -> IO JSVal) -> Int -> IO Int

main :: IO ()
main = do
  add3 <- syncCallback1' (return . (+3))
  print =<< apply_int add3 7
  releaseCallback add3

14.1.4. Обратные вызовы как внешние экспорты

Обратные вызовы JavaScript позволяют организовать своего рода экспорт FFI через импорты FFI. Для этого задаётся глобальная переменная JavaScript, которую затем можно вызывать в тех случаях, где доступны обычные функции JavaScript, например в интерактивных HTML-элементах. Это может выглядеть так:

foreign import javascript "((f) => { globalF = f })"
  setF :: Callback (JSVal -> IO ()) -> IO ()

main :: IO ()
main = do
  log <- syncCallback1 ThrowWouldBlock (print . fromJSString)
  setF log
  -- don't releaseCallback log
<button onClick="globalF('Button pressed!")>Example</button>

Не используйте releaseCallback для функций, доступных из HTML, поскольку такие функции должны постоянно оставаться в памяти.

14.2. Написание заменяющих реализаций для библиотек с функциями C FFI

Многие библиотеки используют функции C FFI для низкоуровневых или критичных к производительности операций — так называемые cbits, которые часто хранятся в папке с таким названием. Чтобы такая библиотека поддерживала бэкенд JavaScript, для cbits необходимо предоставить заменяющие реализации.

Теоретически бэкенд JavaScript может автоматически компилировать cbits с помощью Emscripten, но для этого потребуются обёртки, преобразующие данные из формата данных RTS бэкенда JS в формат, ожидаемый функциями, скомпилированными Emscripten. Поскольку функции C часто используются там, где производительность особенно важна, преобразование данных может свести на нет это преимущество.

Вместо этого библиотеке эффективнее предоставить альтернативную реализацию функций, использующих C FFI: либо напрямую реализовать соответствующие функции JavaScript один к одному, либо использовать директивы препроцессора C, чтобы заменить импорты C FFI комбинацией импортов JS FFI и реализации на чистом Haskell.

14.2.1. Прямая реализация импортов C FFI на JavaScript в виде jsbits

При генерации кода для импорта C FFI бэкенд JavaScript вызывает функцию, указанную в строке импорта, добавляя перед её именем h$. Поэтому для импортированной функции C open будет искаться функция JavaScript h$open. Наличие таких функций в связанных файлах JavaScript не проверяется, поэтому при вызове отсутствующей функции JavaScript во время выполнения могут возникнуть ошибки.

Исходя из этого, для реализации функции C на JavaScript достаточно определить функцию нужной формы (согласно сигнатуре типа импорта C FFI) в любом из связанных исходных файлов JavaScript. Внешние исходные файлы JavaScript связываются либо при передаче их GHC в качестве аргумента, либо при указании их в поле js-sources файла cabal. Обычно в последнем случае это поле помещают в условие, проверяющее архитектуру javascript, например:

library

  if arch(javascript)
    js-sources:
      jsbits/example.js

Обратите внимание: для использования js-sources с целевыми объектами библиотек требуется Cabal 3.10, а с целевыми объектами исполняемых файлов — Cabal 3.12.

Требования к форме функции JavaScript зависят от используемых типов C:

  • примитивы, такие как CInt, напрямую отображаются в один аргумент JavaScript с использованием примитивов JavaScript. В случае CInt это будет число JavaScript. Учтите, что возвращаемые значения в виде чисел JavaScript обычно нужно округлить или привести обратно к целочисленному значению, если применялись математические операции;
  • значения-указатели, включая CString, передаются как распакованная пара (ptr, offset). Для аргументов распакованное представление означает, что они передаются функции как два аргумента верхнего уровня. Возвращаемые значения в распакованном виде следует возвращать из функций JavaScript с помощью специального макроса препроцессора C, RETURN_UBX_TUP2(ptr, offset)
  • CString, помимо описанной выше обработки указателей, необходимо декодировать и кодировать для преобразования между массивами символов и строками JavaScript.
  • другие примитивные типы RTS описаны выше в разделе Типы JavaScript FFI.

В качестве примера рассмотрим реализацию getcwd:

-- unix:System.Posix.Directory

foreign import ccall unsafe "getcwd" c_getcwd :: Ptr CChar -> CSize -> IO (Ptr CChar)
// libraries/base/jsbits/base.js

//#OPTIONS: CPP

function h$getcwd(buf, off, buf_size) {
  try {
    var cwd = h$encodeUtf8(process.cwd());
    if (buf_size < cwd.len && buf_size !== 0) {
      h$setErrno("ERANGE");
      RETURN_UBX_TUP2(null, 0);
    } else if (buf !== null) {
      h$copyMutableByteArray(cwd, 0, buf, off, cwd.len);
      RETURN_UBX_TUP2(buf, off);
    } else if (buf_size === 0) {
      RETURN_UBX_TUP2(cwd, 0);
    } else {
      var out = h$newByteArray(buf_size);
      h$copyMutableByteArray(cwd, 0, out, off, cwd.len);
    }
  } catch (e) {
    h$setErrno(e);
    RETURN_UBX_TUP2(null, 0);
  }
}

Здесь функция C getcwd сопоставляется с функцией JavaScript h$getcwd, находящейся в файле .js в подкаталоге base jsbits. Функция h$getcwd принимает CString (передаваемый как эквивалент Ptr CChar) и аргумент CSize. В результате функция JavaScript получает три аргумента: два для указателя и смещения строки и один для размера, передаваемый как число JavaScript.

Далее функция JavaScript h$getcwd демонстрирует несколько особенностей:

  • В блоке try значение cwd сначала извлекается с помощью метода, предоставляемого NodeJS. Затем это значение сразу кодируется функцией h$encodeUtf8, предоставляемой бэкендом JavaScript. Эта функция возвращает только указатель на закодированное значение, а смещение всегда равно 0.
  • Затем выбирается один из нескольких вариантов в зависимости от спецификации имитируемой функции C.
  • В первом случае, когда заданный размер буфера слишком мал, но не равен нулю, функция должна установить код ошибки ERANGE. Здесь для этого используется h$setErrno, после чего возвращается null. Как видно из аргументов функции, указатели передаются как пара (ptr, offset), то есть null представляется возвращаемой распакованной парой (null, 0)
  • Во втором случае, когда в buf достаточно места для успешного копирования байтов, это выполняется с помощью h$copyMutableByteArray — функции, предоставляемой RTS JavaScript в GHC.
  • В третьем случае, когда buf_size равно 0, согласно спецификации функции C можно выделить новый буфер подходящего размера для возврата. Такой буфер у нас уже есть в виде ранее закодированного cwd, поэтому достаточно вернуть его со смещением 0.
  • В последнем случае, когда buf равно null, а buf_size достаточно велико, выделяется новый буфер размером buf_size байт с помощью h$newByteArray, после чего снова выполняется изменяемое копирование.
  • Чтобы использовать макросы препроцессора C в связанных файлах JavaScript, файл должен начинаться со строки //#OPTIONS: CPP, как показано в начале этого фрагмента.
  • Если возникает ошибка, блок catch передаёт её в h$setErrno и возвращает пару указателя и смещения (null, 0) — именно такое поведение ожидает функция C в случае ошибки.

14.2.2. Написание функций JavaScript с учётом NodeJS и браузера

В приведённом выше примере реализации getcwd функция JavaScript использует NodeJS, и её поведение не имеет смысла реализовывать в браузере. Поэтому фактическая реализация будет содержать условие препроцессора C для проверки, выполняется ли компиляция для браузера. В этом случае будет вызвана h$unsupported(-1). Существуют и другие среды выполнения JavaScript, не являющиеся браузерами, поэтому также нужно во время выполнения проверять, используется ли NodeJS.

function h$getcwd(buf, off, buf_size) {
#ifndef GHCJS_BROWSER
  if (h$isNode()) {
    try {
      var cwd = h$encodeUtf8(process.cwd());
      if (buf_size < cwd.len && buf_size !== 0) {
        h$setErrno("ERANGE");
        return (null, 0);
      } else if (buf !== null) {
        h$copyMutableByteArray(cwd, 0, buf, off, cwd.len);
        RETURN_UBX_TUP2(buf, off);
      } else if (buf_size === 0) {
        RETURN_UBX_TUP2(cwd, 0);
      } else {
        var out = h$newByteArray(buf_size);
        h$copyMutableByteArray(cwd, 0, out, off, cwd.len);
      }
    } catch (e) {
      h$setErrno(e);
      RETURN_UBX_TUP2(null, 0);
    }
  } else
#endif
    h$unsupported();
    RETURN_UBX_TUP2(null, 0);
}

14.2.3. Замена импортов C FFI реализацией на чистом Haskell и JavaScript

Вместо предоставления прямой реализации JavaScript для каждого импорта C FFI можно использовать препроцессор C, чтобы условно удалять эти импорты C (а при необходимости и места их использования). Затем можно добавить комбинацию импортов JS FFI и реализации на Haskell. Как и в разделе о прямой реализации, все связанные файлы JavaScript обычно следует поместить в условие if arch(javascript) в файле cabal.

В качестве примера смешанной реализации на Haskell и JavaScript, заменяющей реализацию на C, рассмотрим base:GHC.Clock:

#if defined(javascript_HOST_ARCH)
getMonotonicTimeNSec :: IO Word64
getMonotonicTimeNSec = do
  w <- getMonotonicTimeMSec
  return (floor w * 1000000)

foreign import javascript unsafe "performance.now"
  getMonotonicTimeMSec :: IO Double

#else
foreign import ccall unsafe "getMonotonicNSec"
  getMonotonicTimeNSec :: IO Word64
#endif

Здесь импорт C FFI getMonotonicTimeNSec заменяется импортом JS FFI getMonotonicTimeMSec, который импортирует стандартную функцию JavaScript performance.now. Однако эта реализация JavaScript возвращает время в виде Double миллисекунд с плавающей точкой, поэтому её нужно обернуть функцией Haskell, чтобы извлечь ожидаемое целочисленное значение.

В данном случае смешанная реализация на Haskell и JavaScript выбрана из-за того, что часы доступны в виде системных вызовов. Во многих случаях функции C используются для аналогичных системных операций. В таких случаях рекомендуется импортировать нужные системные функции из стандартных библиотек JavaScript (или из среды выполнения, как потребовалось для getcwd) и использовать функции-обёртки на Haskell для преобразования импортированных функций в нужный формат.

В других случаях функции C используются ради производительности. Для совместимости с бэкендом JavaScript в первую очередь предпочтительны реализации на чистом Haskell, поскольку они лучше защищены от будущих изменений формата данных RTS. В зависимости от конкретной задачи оптимизированный компилятором код JS может быть трудно превзойти вручную написанным JavaScript. Как правило, наибольший прирост производительности от ручного кода JavaScript вероятен для функций, которые долго работают с данными в виде примитивных типов JavaScript, особенно строками. Для этого JSVal позволяет передавать значения между Haskell и JavaScript без затрат на маршалинг.

14.3. Компоновка с исходными файлами C

GHC поддерживает компиляцию исходных файлов C в JavaScript (с помощью Emscripten) и их компоновку с остальным кодом JavaScript (сгенерированным из кода Haskell и RTS).

В JavaScript к именам функций C, скомпилированных с помощью Emscripten, добавляется префикс «_». Например, имя C-функции «malloc» в JavaScript становится «_malloc».

14.3.1. Прагмы EMCC

По умолчанию компоновщик EMCC удаляет код, который считает неиспользуемым, и не может определить, какой код используется из-за вызова из Haskell или обёртки JavaScript. Поэтому необходимо явно добавить несколько прагм в начало одного из файлов .js, чтобы указать, какие функции используются:

` //#OPTIONS:EMCC:EXPORTED_RUNTIME_METHODS=foo,bar `

Включает методы foo и bar из среды выполнения Emscripten. Это используется для таких методов, как ccall, cwrap, addFunction, removeFunction… описанных в документации Emscripten.

` //#OPTIONS:EMCC:EXPORTED_FUNCTIONS=_foo,_bar `

Экспортирует функции C foo и bar соответственно как _foo и _bar (с добавлением префикса _). Это используется для функций библиотеки C (например, _malloc, _free и т. д.) и кода C, скомпилированного вместе с проектом (например, _sqlite3_open и других функций библиотеки C sqlite).

Каждую из этих прагм можно использовать сколько угодно раз. В итоге все записи попадают в наборы функций, передаваемые компоновщику Emscripten через -sEXPORTED_RUNTIME_METHODS и -sEXPORTED_FUNCTIONS (которые можно передать только один раз; последний аргумент переопределяет предыдущие).

` //#OPTIONS:EMCC:EXTRA=-foo,-bar `

Эта прагма позволяет при необходимости передавать Emscripten дополнительные параметры. Мы уже передаём:

  • -sSINGLE_FILE=1: требуется для создания единого файла .js в качестве артефакта (иначе файлы .wasm, соответствующие коду C, должны находиться в текущем рабочем каталоге при запуске итогового файла .js);
  • -sALLOW_TABLE_GROWTH: требуется для поддержки addFunction
  • -sEXPORTED_RUNTIME_METHODS и -sEXPORTED_FUNCTIONS: см. выше.

Будьте осторожны: некоторые дополнительные аргументы могут неожиданным образом нарушить сборку.

14.3.2. Обёртки

Бэкенд JavaScript не генерирует обёртки для внешних импортов, которые напрямую вызывали бы скомпилированный код C. Например, для следующего внешнего импорта:

`haskell foreign import ccall "foo" foo :: ... `

Бэкенд JavaScript заменит вызовы foo вызовами функции JavaScript h$foo. При этом программист сам решает, вызывать ли _foo из h$foo в каждом конкретном случае. Если h$foo вызывает сгенерированную из кода C функцию _foo, то h$foo называется функцией-обёрткой. Такие функции-обёртки используются для маршалинга аргументов и возвращаемых значений между кучей JS и кучей Emscripten.

С одной стороны, бэкенд JavaScript в GHC создаёт отдельный массив байтов для каждого выделения памяти (чтобы использовать сборщик мусора движка JavaScript). С другой стороны, куча C в Emscripten состоит из одного массива байтов. Чтобы вызывать преобразованные в JavaScript функции C с аргументами-указателями, функции-обёртки должны:

  1. выделить буфер в куче Emscripten (с помощью _malloc), чтобы получить допустимый указатель Emscripten;
  2. скопировать байты из объекта JS в буфер в куче Emscripten;
  3. вызвать функцию C, используя указатель Emscripten;
  4. при необходимости скопировать байты обратно из кучи Emscripten, если вызов мог изменить содержимое буфера;
  5. освободить буфер Emscripten (с помощью _free).

RTS JavaScript в GHC предоставляет вспомогательные функции для этого в rts/js/mem.js. См. h$copyFromHeap, h$copyToHeap, h$initHeapBuffer и т. д.

14.3.3. Обратные вызовы

Некоторые функции C принимают указатели на функции в качестве аргументов (например, обратные вызовы). Бэкенд JavaScript поддерживает это, но для работы требуются некоторые действия со стороны функций-обёрток.

  1. На стороне Haskell можно создать указатель на функцию Haskell (FunPtr) с помощью внешнего импорта «wrapper». См. документацию по base:Foreign.Ptr.FunPtr.
  2. Этот FunPtr можно передать функции-обёртке JavaScript. Однако он реализован как StablePtr и должен быть преобразован в указатель на функцию, который понимает Emscripten. Это можно сделать с помощью h$registerFunPtrOnHeap.
  3. Когда обратный вызов больше не нужен, его можно освободить с помощью h$unregisterFunPtrFromHeap.

Обратите внимание: в некоторых случаях может быть нежелательно напрямую регистрировать функцию Haskell в качестве обратного вызова. Обычные функции JavaScript можно регистрировать и освобождать как функции Emscripten с помощью Module.addFunction и Module.removeFunction. Именно это и делают упомянутые выше вспомогательные функции.

© 2002–2007 The University Court of the University of Glasgow. All rights reserved.
Licensed under the Glasgow Haskell Compiler License.
https://downloads.haskell.org/~ghc/9.14.1/docs/users_guide/javascript.html

Spec-Zone.ru

Настройки Оффлайн Что нового Помощь О нас
Spec-Zone .ru
спецификации, руководства, описания, API