Ксенофункции: FFI и нативные модули
открытый урокЭтот раздел читается без входа. Войди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти
Ксенофункции: FFI и нативные модули
Ксено значит чужой: ксенофункции из заголовка это функции, живущие за границей языка, в мире C. Janet сам написан на C и предлагает два способа с этим C разговаривать. Первый путь короче, второй быстрее и мощнее. Разберём оба и границу между ними.
Два пути
FFI (Foreign Function Interface, интерфейс вызова чужих функций) это вызов уже существующей библиотеки прямо из Janet. Компилятор не нужен: описываешь сигнатуру функции и вызываешь её.
Нативный модуль это собственный код на C, собранный в модуль, который импортируется как обычный. Нужен компилятор, зато получаешь полный доступ к C API языка.
Урок требует особых условий: FFI работает, только если твой Janet собран с его поддержкой (в стандартных сборках она есть). Проверить просто, (ffi/size :int) не должно падать. Нативные модули дополнительно требуют компилятора C и заголовков Janet.
FFI за четыре шага
Разберём на strlen из libc.
Шаг 1, загрузить библиотеку. ffi/native принимает путь к разделяемой библиотеке. Особый случай это nil, символы самого процесса, а libc в него уже загружен:
(def libc (ffi/native nil))
(type libc) # :core/ffi-native
Шаг 2, найти символ:
(def ptr (ffi/lookup libc "strlen"))
(type ptr) # :pointer
Шаг 3, описать сигнатуру. Соглашение о вызовах, тип результата, список типов аргументов:
(def sig (ffi/signature :default :size [:ptr]))
:default это соглашение по умолчанию для твоей платформы, и почти всегда нужен именно он. Остальные значения существуют для Windows API и вариативных функций.
Шаг 4, вызвать:
(ffi/call ptr sig ["hello"]) # <core/u64 5>
Аргументы передаются коллекцией. Обрати внимание на квадратные скобки: ffi/call принимает аргументы одним кортежем, а не россыпью. Попытка написать (ffi/call ptr sig "hello") даст bad slot #2, expected array or tuple.
Форма покороче
Для нескольких функций подряд удобнее ffi/context и ffi/defbind:
(ffi/context nil) # библиотека по умолчанию
(ffi/defbind strlen :size [s :ptr])
(ffi/defbind toupper :int [c :int])
(strlen "janet") # <core/u64 5>
(toupper 97) # 65
ffi/defbind объявляет обычную функцию Janet, за которой стоит вызов C. Одна строка на функцию вместо четырёх.
Типы и набивка
Основные типы FFI и их размеры на 64-битной машине:
(ffi/size :char) # 1
(ffi/size :int) # 4
(ffi/size :long) # 8
(ffi/size :double) # 8
(ffi/size :ptr) # 8
Структуры собираются через ffi/struct. И вот пример, где арифметика не сходится:
(ffi/size (ffi/struct :char :int)) # 8
(+ (ffi/size :char) (ffi/size :int)) # 5
Разница это набивка: компилятор выравнивает int по границе четырёх байт, поэтому после однобайтового char вставляются три пустых.
Собери структуру руками и посмотри на байты. Попробуй переставить поля от большего к меньшему:
Знать это важно: описывая структуру для FFI, нельзя просто складывать размеры полей. Выравнивание каждого типа доступно отдельно через ffi/align. Ту же механику мы разбирали в уроке про модель памяти процессора, здесь она вылезает практическим следствием.
Ловушка: целые из C это не числа Janet
Тип :size вернул нам не число, а особое значение:
(def r (strlen "hello"))
r # <core/u64 5>
(type r) # :core/u64
(number? r) # false
Числа в Janet это double, а 64-битное целое в double помещается не полностью. Поэтому для таких типов есть отдельное представление. И вот последствие:
(= 5 r) # false, разные типы
(compare= 5 r) # true, а так правильно
(int/to-number r) # 5, явное приведение
Это тот же вид ошибки, что (= @[1] [1]) из урока про значения и ссылки: = строг к типам. Тест (= 5 (strlen "hello")) провалится, и причина будет совершенно неочевидна. Приводи результат явно через int/to-number либо сравнивай через compare=.
Арифметика при этом работает без приведения, оставаясь в том же типе:
(+ r 1) # <core/u64 6>
(string r) # "5"
Нативный модуль на C
Нужны три вещи: исходник, объявление в project.janet и сборка.
(declare-project :name "greeting")
(declare-native
:name "greeting"
:source ["src/greeting.c"])
#include <janet.h>
static Janet cfun_twice(int32_t argc, Janet *argv) {
janet_fixarity(argc, 1);
double x = janet_getnumber(argv, 0);
return janet_wrap_number(x * 2);
}
static const JanetReg cfuns[] = {
{"twice", cfun_twice, "(twice x)\n\nУдваивает число."},
{NULL, NULL, NULL}
};
JANET_MODULE_ENTRY(JanetTable *env) {
janet_cfuns(env, "greeting", cfuns);
}
Что здесь что:
janet_fixarity(argc, 1)проверяет число аргументов. Не пройдёт, Janet сам бросит понятную ошибку.janet_getnumber(argv, 0)достаёт аргумент с проверкой типа. Есть парныеjanet_getcstring,janet_getinteger,janet_getbufferи другие.janet_wrap_numberупаковывает результат обратно в значение Janet.JanetRegэто таблица “имя, функция, докстрока”, обязательно с{NULL, NULL, NULL}в конце.JANET_MODULE_ENTRYэто точка входа, вызывается при импорте.
Третье поле JanetReg это настоящая докстрока: после импорта (doc twice) покажет её так же, как для функции на Janet.
jpm --local build
jpm --local test
Сборка кладёт модуль в build/, и голое (import greeting) его там не найдёт: этого каталога нет в путях поиска из 31-janet/16 · Модули: файл, таблица, окружение. Два рабочих способа. Быстрый, по явному пути:
(import ./build/greeting :as g)
(g/twice 21) # 42
И постоянный: jpm --local install кладёт модуль в jpm_tree, после чего jpm --local janet находит его по голому имени, как любой пакет.
Снаружи нативный модуль неотличим от обычного: тот же import, те же докстроки, те же правила приватности.
Что выбрать
| FFI | Нативный модуль | |
|---|---|---|
| Компилятор при установке | не нужен | нужен |
| Скорость вызова | медленнее | быстрее |
| Доступ к C API Janet | нет | полный |
| Сложные структуры и обратные вызовы | тяжело | просто |
| Подходит для | готовых библиотек | своей логики |
Бери FFI, когда надо дёрнуть пару функций из системной библиотеки и не хочется заставлять пользователей собирать C-код.
Бери нативный модуль, когда пишешь свой код на C, нужна скорость в горячем цикле или требуется работать со значениями Janet напрямую.
Память и сборщик мусора
Здесь заканчивается безопасность языка и начинается твоя ответственность.
Сборщик мусора не видит указатели, лежащие в C. Если ты сохранил значение Janet в структуре на стороне C и в Janet на него больше никто не ссылается, GC вправе его собрать. Для удержания есть janet_gcroot и janet_gcunroot.
Память, выделенная через ffi/malloc, освобождается вручную через ffi/free. Она не под управлением GC. Рабочий шаблон: освобождение вешаем на defer из 31-janet/15 · Универсальный loop и нелокальные переходы, тогда оно переживёт и ранний выход, и ошибку:
(def ptr (ffi/malloc 8))
(defer (ffi/free ptr)
(def buf (ffi/pointer-buffer ptr 8))
(ffi/write :int 42 buf)
(ffi/read :int buf)) # 42, память освобождена на выходе
С ошибками на границе два случая, и их важно различать. Ошибка, поднятая через C API (janet_panic, та же janet_fixarity), становится обычной ошибкой Janet и ловится try, как любая другая. А вот падение внутри чужой библиотеки, segfault или abort, роняет процесс целиком: до try управление уже не дойдёт.
Правило границы. Держи слой C как можно тоньше. Всё, что можно сделать на Janet, делай на Janet: там работают GC, отладчик и понятные ошибки. На C оставляй только то, ради чего ты туда пошёл: вызов чужой библиотеки или горячий цикл.
Что запомнить
- FFI не требует компилятора, нативный модуль требует, но быстрее и мощнее.
- Четыре шага FFI:
ffi/native,ffi/lookup,ffi/signature,ffi/call. ffi/callпринимает аргументы одним кортежем.ffi/contextплюсffi/defbindэто короткая форма.- Размер структуры не равен сумме размеров полей из-за набивки.
- Целые из C это отдельный тип.
=с числом Janet дастfalse, приводи черезint/to-number. - В нативном модуле:
janet_fixarity,janet_get*,janet_wrap_*, таблицаJanetReg,JANET_MODULE_ENTRY. - GC не видит указатели в C,
ffi/mallocосвобождай черезdeferсffi/free. - Паника через C API ловится
try, а segfault в чужой библиотеке роняет процесс.
Упражнения
Дальше
Мы научились звать C из Janet. Следующий урок разворачивает направление: как встроить Janet внутрь программы на C и дать ей скриптовый язык.
домашка