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 является одним из таких типов.

Существует ряд поддерживаемых типов, которые могут передаваться непосредственно таким образом и действуют как примитивы в JavaScript RTS 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. 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.2. 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! в качестве аргумента. В последней строке освобождается память обратного вызова. Так как Haskell JS-временному выполнению не известно, ссылается ли функция на код 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.3. Обработчики событий как внешние экспорты

Обработчики событий 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 JavaScript-бекенда и форматом, ожидаемым функциями, скомпилированными Emscripten. Поскольку функции C часто используются там, где производительность более критична, есть вероятность, что преобразования данных снизят эту производительность.

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

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

Когда JavaScript-бекенд генерирует код для импорта C FFI, он вызовет функцию, имя которой указано в строке импорта, с префиксом 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 — функции, предоставленной JavaScript RTS 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 (и, возможно, мест использования также). Затем можно добавить некоторую комбинацию импортов JavaScript 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 заменяется импортом JavaScript FFI getMonotonicTimeMSec, который импортирует стандартную JavaScript-функцию performance.now. Однако, поскольку эта JavaScript-реализация возвращает время как Double миллисекунд с плавающей точкой, она должна быть обернута Haskell-функцией для извлечения целочисленного значения, которое ожидается.

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

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

14.3. Связывание с C-источниками

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

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

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 и другие для sqlite C-библиотеки).

Вы можете использовать оба предиката столько раз, сколько нужно. В конечном итоге все записи попадают в наборы функций, передаваемые линковщику 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 состоит из одного массива байтов. Для вызова C-функций, преобразованных в JavaScript, которые имеют аргументы-указатели, функции-обёртки должны:

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

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

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

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

  1. Со стороны Haskell возможно создать указатель на Haskell-функцию (a FunPtr) с использованием «обёртки» внешнего импорта. См. документацию 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.12.1/docs/users_guide/javascript.html

Spec-Zone.ru

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