Бэкенд 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-битные числа передаются функции как две переменные: первая содержит знак и старшие биты. BoolCharAnyByteArray#-
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 с аргументами-указателями, функции-обёртки должны:
- выделить буфер в куче Emscripten (с помощью
_malloc), чтобы получить допустимый указатель Emscripten; - скопировать байты из объекта JS в буфер в куче Emscripten;
- вызвать функцию C, используя указатель Emscripten;
- при необходимости скопировать байты обратно из кучи Emscripten, если вызов мог изменить содержимое буфера;
- освободить буфер Emscripten (с помощью
_free).
RTS JavaScript в GHC предоставляет вспомогательные функции для этого в rts/js/mem.js. См. h$copyFromHeap, h$copyToHeap, h$initHeapBuffer и т. д.
14.3.3. Обратные вызовы
Некоторые функции C принимают указатели на функции в качестве аргументов (например, обратные вызовы). Бэкенд JavaScript поддерживает это, но для работы требуются некоторые действия со стороны функций-обёрток.
- На стороне Haskell можно создать указатель на функцию Haskell (
FunPtr) с помощью внешнего импорта «wrapper». См. документацию поbase:Foreign.Ptr.FunPtr. - Этот
FunPtrможно передать функции-обёртке JavaScript. Однако он реализован какStablePtrи должен быть преобразован в указатель на функцию, который понимает Emscripten. Это можно сделать с помощьюh$registerFunPtrOnHeap. - Когда обратный вызов больше не нужен, его можно освободить с помощью
h$unregisterFunPtrFromHeap.
Обратите внимание: в некоторых случаях может быть нежелательно напрямую регистрировать функцию Haskell в качестве обратного вызова. Обычные функции JavaScript можно регистрировать и освобождать как функции Emscripten с помощью Module.addFunction и Module.removeFunction. Именно это и делают упомянутые выше вспомогательные функции.