Раздел 31 · Janet на практике

Ксенофункции: FFI и нативные модули

senior~40 мин

открытый урокЭтот раздел читается без входа. Войди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти

Ксенофункции: 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 и дать ей скриптовый язык.

домашка

Домашка