Обёртку можно написать вручную, когда API состоит из небольшого количества функций и сигнатура этих функций меняется нечасто. FMOD — звуковой движок с большим количеством функций, точно не тот случай:
Types: 19
Callbacks: 96
Structures: 57
Structure Fields: 352
Enumerations: 77
Enumeration Variants: 693
Functions: 573
Function Arguments: 1590
Стоимость ручной разработки обёртки для такого API будет высокой, поддержка при каждом обновлении мучительной, не говоря уже об ошибках и опечатках, которые появятся в процессе редактирования.
К счастью, FMOD библиотеки поставляются вместе с хорошо оформленными файлами заголовков, которые можно использовать для автоматического создания обёртки.
В экосистеме Rust наиболее популярным инструментом является bindgen — библиотека, которая генерирует Rust код на основе разбора указанных файлов заголовков C. Но инструмент не идеальный, по нескольким причинам:
Bindgen генерирует определения типов и объявления FFI, но на минимально необходимом уровне, с сырыми указателями и unsafe блоками языка, использовать это в идиоматичном Rust приложении будет проблематично
Поддерживает все возможности языка C но не учитывает контекст конкретного API, например, не разберёт: битовые флаги, макросы с предустановленными значениями, маппинг ошибок
Требует установки LLVM с libclang в комплекте, это будет проблемой на некоторых платформах, например Windows
Вместо bindgen, используем собственное решение, которое состоит из трех шагов:
Разбор API из файлов заголовков в промежуточное представление
Генерация кода FFI объявлений
Генерация кода Rust обёртки
Разбор API
Для разбора файлов заголовков FMOD будем использовать парсер общего назначения pest. Парсер использует грамматики PEG, которые аналогичны регулярным выражениям, но дают больше выразительности, необходимой для разбора сложных синтаксических конструкций.
Простой пример грамматики для списка буквенно-цифровых идентификаторов:
alpha = { 'a'..'z' | 'A'..'Z' }
digit = { '0'..'9' }
ident = { (alpha | digit)+ }
Таким образом напишем правила разбора для всех объявлений в файлах заголовков. Полный разбор языка C делать не нужно, только используемые абстракции и программные элементы FMOD. Это сокращает время реализации.
declaration = _{ Function | Directive | ExternLinkage }
API = { SOI ~ declaration* ~ EOI }
Типаж Parser
— ядро pest, которое предоставляет интерфейс для синтаксического разбора. Реализовывать вручную интерфейс не нужно, макрос из библиотеки pest_derive
может автоматически сгенерировать код из грамматики:
#[derive(Parser)]
#[grammar = "./grammars/fmod.pest"]
struct FmodParser;
Получение данных для обработки сводится к перебору и интерпретации правил указанных в этих грамматиках:
pub struct Header {
pub functions: Vec<Function>,
}
pub fn parse(source: &str) -> Result<Header, Error> {
let mut header = Header::default();
let declarations = FmodParser::parse(Rule::API, source)?
.next()
.ok_or(Error::FileMalformed)?;
for declaration in declarations.into_inner() {
match declaration.as_rule() {
Rule::Function => header.add_function(declaration),
Rule::Directive => ...,
Rule::ExternLinkage => ...,
}
}
Ok(header)
}
Непрозрачные типы
Все объекты FMOD возвращаются в виде указателя на структуру. Эта структура называется “непрозрачной” потому что скрывает детали реализации, будь то дескриптор или прямой указатель на объект. Для обозначения имени типа используется typedef
, например:
typedef struct FMOD_SYSTEM FMOD_SYSTEM;
typedef struct FMOD_SOUND FMOD_SOUND;
typedef struct FMOD_CHANNEL FMOD_CHANNEL;
Грамматические правила разбора состоят из выражений. Эти выражения представляют собой формальное описание соответствия строки определению. Выражения можно компоновать из других выражений, вкладывать друг в друга.
Оператор выбора записывается с помощью знака |
. Парсер выбирает в указанном порядке первое успешное сопоставление.
Оператор последовательности записывается с помощью знака ~
. Он указывает на то, что строка должна соответствовать всем выражениям в указанном порядке.
Ручное определение правил разбора пробелов и отступов затрудняет чтение и редактирование грамматик. Если определить специальное правило WHITESPACE
, оно будет обрабатываться неявно между другими выражениями.
Имя типа включает цифры, английские буквы и символы подчеркивания. Оператор повторения +
указывает что имя состоит хотя бы из одного символа. Между этими символами не должно быть пробелов, поэтому правило отмечено как атомарное с помощью знака @
.
WHITESPACE = _{ " " | "\\t" }
name = @{ ("_" | ASCII_ALPHANUMERIC)+ }
OpaqueType = { "typedef" ~ "struct" ~ name ~ name ~ ";"}
Перечисления
Перечисление — это определяемый пользователем тип, состоящий из набора именованных целочисленных констант. По стандарту C эти именованные константы называются "перечислителями”. Возможно явное определение значения перечислителя, как показано здесь:
typedef enum FMOD_PORT_TYPE
{
FMOD_PORT_TYPE_MUSIC,
FMOD_PORT_TYPE_VOICE,
FMOD_PORT_TYPE_FORCEINT = 65536
} FMOD_PORT_TYPE;
Оператор повторения *
указывает на то, что выражение встречается произвольное количество раз. Список перечислителей определяем отдельным правилом enumerators
, это пригодится дальше на этапе промежуточного представления разобранных данных.
Оператор повторения ?
указывает на то, что выражение необязательно — может встречаться ноль или один раз.
Enumerator = { ","? ~ name ~ ("=" ~ value)? }
enumerators = { Enumerator* }
Enumeration = {
"typedef" ~ "enum" ~ name ~ "{" ~ enumerators ~ "}" ~ name ~ ";"
}
Битовые флаги
В FMOD часто используются битовые флаги, вместо длинной последовательности логических значений в аргументах функций или определении структур. Флаги объявляются с помощью typedef
как синоним базового типа, это дает некоторую безопасность типов. С помощью директивы #define
создаются макросы, которые именуют конкретные значения флагов:
typedef unsigned int FMOD_CHANNELMASK;
#define FMOD_CHANNELMASK_FRONT_LEFT 0x00000001
#define FMOD_CHANNELMASK_FRONT_RIGHT 0x00000002
#define FMOD_CHANNELMASK_MONO (FMOD_CHANNELMASK_FRONT_LEFT)
#define FMOD_CHANNELMASK_STEREO (FMOD_CHANNELMASK_FRONT_LEFT | FMOD_CHANNELMASK_FRONT_RIGHT)
Если подходить к разбору такого объявления по стандарту языка С, то определить битовые флаги невозможно, получается набор не связанных между собой значений. Pest позволяет написать правило разбора, которое будет устанавливать контекст объявления битовых флагов.
Макрос может именовать не только литералы, но и произвольные вычисления. Чтобы не разбирать все возможные сценарии, вычисления описываются как последовательность любых символов ANY
в скобках. Упреждающий оператор !
ограничивает включение закрывающей скобки.
Calculation = { "(" ~ (!")" ~ ANY)* ~ ")" }
Literal = { ("-" | "_" | ASCII_ALPHANUMERIC)+ }
value = @{ Calculation | Literal }
Flag = { "#define" ~ name ~ value }
flags = { Flag+ }
flags_type = { FundamentalType }
Flags = { "typedef" ~ flags_type ~ name ~ ";" ~ flags }
Структуры
Структура — это определяемый пользователем составной тип, инкапсулирующий набор именованных полей различных типов. Некоторые структуры имеют сложное определение. В них используются объединения и массивы:
typedef struct FMOD_DSP_PARAMETER_DESC
{
FMOD_DSP_PARAMETER_TYPE type;
char name[16];
char label[16];
const char *description;
union
{
FMOD_DSP_PARAMETER_DESC_FLOAT floatdesc;
FMOD_DSP_PARAMETER_DESC_INT intdesc;
FMOD_DSP_PARAMETER_DESC_BOOL booldesc;
FMOD_DSP_PARAMETER_DESC_DATA datadesc;
};
} FMOD_DSP_PARAMETER_DESC;
Типы полей:
Фундаментальные типы. Определены стандартом языка, встроены в компилятор. Нет необходимости разбирать все фундаментальные типы, достаточно тех что использует FMOD
Пользовательские типы. Синонимы, перечисления и структуры объявленные под уникальными именами в файлах заголовков FMOD
FundamentalType = {
"char" | "unsigned char" | "signed char" |
"int" | "unsigned int" |
"short" | "unsigned short" |
"long long" | "long" | "unsigned long long" | "unsigned long" |
"void" |
"float"
}
UserType = @{ name }
field_type = { FundamentalType | UserType }
Поля указатели объявляются с помощью символа *
. Модификатор типа const
может использоваться для защиты значения поля от возможного изменения по указателю.
as_const = { "const" }
NormalPointer = { "*" }
DoublePointer = { "**" }
pointer = { DoublePointer | NormalPointer }
Объединение определяет набор полей и хранит только одно из них. По стандарту C, объединения можно именовать тегом, но в FMOD это не используется. Объявление объединения подчиняются тем же правилам, что и объявление структуры:
union = { "union" ~ "{" ~ fields ~ "}" ~ ";" }
Объявление массива это часть именования поля, а не определения типа. Число элементов массива может быть определено через константу:
as_array = { "[" ~ ("_" | ASCII_ALPHANUMERIC)+ ~ "]" }
Благодаря выразительности грамматик полное правило разбора структур умещается буквально в пару десятков строк:
as_const = { "const" }
as_array = { "[" ~ ("_" | ASCII_ALPHANUMERIC)+ ~ "]" }
NormalPointer = { "*" }
DoublePointer = { "**" }
pointer = { DoublePointer | NormalPointer }
field_type = { FundamentalType | UserType }
Field = { as_const? ~ field_type ~ pointer? ~ name ~ as_array? ~ ";" }
fields = { Field* }
union = { "union" ~ "{" ~ fields ~ "}" ~ ";" }
Structure = {
"typedef"? ~ "struct" ~ name ~ "{" ~
fields ~
union? ~
"}" ~ name? ~ ";"
}
Функции
Функция — ключевой программный элемент, с помощью которого решаются задачи FMOD. Объявление функций отдельно от определения содержится в файлах заголовков, включает в себя имя, аргументы, возвращаемый тип и атрибуты функции.
FMOD_RESULT F_API FMOD_System_Create
(FMOD_SYSTEM **system, unsigned int headerversion);
Только у глобальных функции FMOD произвольный возвращаемый тип. Функции объектов всегда возвращают статус выполнения FMOD_RESULT
, а возвращаемые значения через указатели в аргументах.
Аргументы определяют набор именованных значений, которые будут передаваться в функцию при вызове. Тип аргумента может быть любым, фундаментальным или пользовательским.
Атрибуты функций скрытые за макросом F_API
определяют специфичные для платформы соглашения о вызовах функции, например __stdcall
.
argument_type = { FundamentalType | UserType }
Argument = { as_const? ~ argument_type ~ pointer? ~ name }
arguments = { (","? ~ Argument)* }
return_type = { FundamentalType | UserType }
Function = {
return_type ~ "F_API" ~ name ~
"(" ~ arguments ~ ")" ";"
}
Функции обратного вызова
Функции обратного вызова передаются через указатель. Но функции, в отличие от других примитивов имеют возвращаемое значение и список аргументов, это приводит к длинному и сложному определению указателя. Объявление через typedef
упрощает работу с указателями на функцию.
Некоторые функции FMOD определены с переменным количеством аргументов. Список аргументов должен заканчиваться запятой и тремя точками, чтобы указать, что в функцию могут быть переданы дополнительные аргументы.
typedef void (F_CALL *FMOD_DSP_LOG_FUNC)
(FMOD_DEBUG_FLAGS level, const char *file, const char *string, ...);
Большинство правил разбора можно использовать повторно, при достаточном уровне гранулярности этих правил:
varargs = { "," ~ "..." }
Callback = {
"typedef" ~ return_type ~ pointer? ~ ("F_CALL" ~ "*" ~ name ~ ")" ~
"(" ~ arguments ~ varargs? ~ ")" ~ ";"
}
Промежуточное представление API
Чтобы извлечь данные, необходимые для генерации, нужно вручную пройти по дереву разбора:
for declaration in declarations.into_inner() {
match declaration.as_rule() {
Rule::Function => {
let mut inner_rules = line.into_inner();
let name = inner_rules.next().unwrap().as_str();
let return_type_rule = inner_rules.next().unwrap().as_rule();
let return_type = match return_type_rule {
Rule::FundamentalType => return_type_rule.as_str(),
Rule::UserType => return_type_rule.as_str(),
_ => unreachable!()
}
// ...
},
_ => continue,
}
}
Это откровенно слабое место pest. Делает решение подверженным ошибкам, трудным для чтения и сложным в обновлении правил грамматики. Даже подсветка кода в IDE на этом участке не работает. Так происходит потому, что парсер и правила генерируются автоматически pest_derive
только в момент компиляции Rust приложения.
Существуют решения, которые пытаются исправить эту проблему, например pest_consume
. Он предоставляет типизированный обход дерева.
Ситуацию можно сделать еще лучше, если дерево разбора сначала в общем виде привести к промежуточному формату. А затем из этого формата получить данные в типизированных структурах. Например, можно использовать serde_json
и трансляцию в JSON.
Дерево разбора pest представляет из себя список списков. Чтобы определить где заканчивается определение атрибутов одного объекта и начинается перечисление вложенных объектов, явно указываем список правил, которые содержат эти перечисления.
Код JSON конвертера
pub struct JsonConverter {
pub arrays: Vec<String>,
}
impl JsonConverter {
pub fn new(arrays: Vec<String>) -> Self {
JsonConverter { arrays }
}
pub fn convert<T, R>(&self, pair: Pair<'_, R>) -> Result<T, serde_json::Error>
where
T: DeserializeOwned,
R: RuleType,
{
let value = self.create_value(pair);
serde_json::from_value(value)
}
pub fn create_value<R>(&self, pair: Pair<'_, R>) -> Value
where
R: RuleType,
{
let rule = format!("{:?}", pair.as_rule());
let data = pair.as_str();
let inner = pair.into_inner();
if inner.peek().is_none() {
Value::String(data.into())
} else {
if self.arrays.contains(&rule) {
let values = inner.map(|pair| self.create_value(pair)).collect();
Value::Array(values)
} else {
let map = inner.map(|pair| {
let key = format!("{:?}", pair.as_rule());
let value = self.create_value(pair);
(key, value)
});
Value::Object(Map::from_iter(map))
}
}
}
}
В итоге получаем типизированные структуры которые содержат все необходимые данные для генерации:
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub enum Type {
FundamentalType(String),
UserType(String),
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct Argument {
pub as_const: Option<String>,
pub argument_type: Type,
pub pointer: Option<Pointer>,
pub name: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct Function {
pub return_type: Type,
pub name: String,
pub arguments: Vec<Argument>,
}
Генерация FFI
Библиотека quote предоставляет механизм квази-цитирования. Идея в том, что в макросе quote!
пишем что-то очень похожее на итоговый Rust код. При этом работают все удобства редактирования в IDE: подсветка синтаксиса, авто-дополнение, отступы. И в результате получаем поток токенов исходного кода, с которым можно обращаться как с данными.
#[proc_macro]
pub fn make_pub(item: TokenStream) -> TokenStream {
quote! {
pub #item
}
}
make_pub! {
const X: u32 = 42;
}
Изначально этот механизм создан для написания процедурных макросов, но по сути это универсальное решение. Его можно использовать вместо обычного шаблонизатора для генерации Rust кода, получив при этом несколько преимуществ:
Меньше синтаксических ошибок, быстрее разработка
Не нужно думать над представлением данных для шаблонизатора
Модульность, токены произведенные
quote!
могут быть вложены в другойquote!
pub fn generate_ffi_code(api: &Api) -> Result<TokenStream, Error> {
let opaque_types = api.opaque_types.iter().map(generate_opaque_type);
// let type_aliases = ...
Ok(quote! {
#![allow(non_camel_case_types)]
#![allow(non_snake_case)]
#![allow(unused_parens)]
use std::mem::size_of;
use std::ptr::null_mut;
#(#opaque_types)*
#(#type_aliases)*
#(#constants)*
#(#enumerations)*
#(#flags)*
#(#structures)*
#(#presets)*
#(#callbacks)*
#(#libraries)*
#errors
})
}
Непрозрачные типы
Включая хотя бы одно приватное поле и не используя конструктор, можно реализовать непрозрачную структуру в Rust, экземпляр которой нельзя создать вне FMOD. Приватное поле _unused
необходимо, потому что структура без поля может быть создана кем угодно. Для использования этого типа в FFI нужно добавить маркер #[repr(C)]
.
Интерполяция данных в quote!
выполняется с помощью #name
. Это захватывает переменную name
, и вставляет ее значение в указанное место в потоке токенов.
Интерполяция работает для всех базовых типов Rust. Для строки это будет выглядеть как вставка строкового литерала в кавычках. Чтобы создать токен идентификатора, например для именования структуры, нужно использовать макрос format_ident!
.
pub fn generate_opaque_type(value: &OpaqueType) -> TokenStream {
let name = format_ident!("{}", value.name);
quote! {
#[repr(C)]
#[derive(Debug, Copy, Clone)]
pub struct #name {
_unused: [u8; 0]
}
}
}
Поскольку для каждого FMOD объекта мы генерируем разные структуры, получаем типобезопасноть — не сможем, например, случайно отправить указатель на FMOD_SYSTEM
туда, где ожидается объект FMOD_SOUND
.
#[repr(C)]
#[derive(Debug, Copy, Clone)]
pub struct FMOD_SYSTEM {
_unused: [u8; 0],
}
#[repr(C)]
#[derive(Debug, Copy, Clone)]
pub struct FMOD_SOUND {
_unused: [u8; 0],
}
Перечисления
В Rust нет стандартного выражения для целочисленных перечислений. Для определения типа перечисления используем псевдоним на существующий базовый тип с помощью ключевого слова type
. Перечислители объявляем константами.
Каждому перечислителю присваивается целочисленное значение, соответствующее его месту в порядке значений. По умолчанию первому значению присваивается 0, следующему присваивается 1 и далее. Так же значения могут быть указаны явно.
Повторение в quote!
выполняется с помощью #(#enumerators)*
. Это перебирает элементы переменной enumerators
, и вставляет результат интерполяции каждого из них в указанное место.
pub fn generate_enumeration(
enumeration: &Enumeration
) -> Result<TokenStream, Error> {
let name = format_ident!("{}", enumeration.name);
let mut value: i32 = -1;
let mut enumerators = vec![];
for enumerator in &enumeration.enumerators {
let label = format_ident!("{}", &enumerator.name);
let value = match &enumerator.value {
None => {
value += 1;
value
}
Some(repr) => {
value = repr.parse()?;
value
}
};
let literal = Literal::i32_unsuffixed(value);
enumerators.push(quote! {
pub const #label: #name = #literal;
});
}
Ok(quote! {
pub type #name = c_int;
#(#enumerators)*
})
}
Псевдонимы не гарантируют безопасность их использования. Вместо конкретного перечисления можно передать любое целочисленное значение по ошибке. В интерфейсе Rust обёртки это будет исправлено, а на уровне FFI это увеличивает читаемость сигнатур функций и структур:
pub type FMOD_PLUGINTYPE = c_int;
pub const FMOD_PLUGINTYPE_OUTPUT: FMOD_PLUGINTYPE = 0;
pub const FMOD_PLUGINTYPE_CODEC: FMOD_PLUGINTYPE = 1;
pub const FMOD_PLUGINTYPE_DSP: FMOD_PLUGINTYPE = 2;
pub const FMOD_PLUGINTYPE_MAX: FMOD_PLUGINTYPE = 3;
pub const FMOD_PLUGINTYPE_FORCEINT: FMOD_PLUGINTYPE = 65536;
Битовые флаги
Битовые флаги — это представление нескольких логических значений или нескольких состояний одним целым числом. Количество значений, которые можно хранить внутри одного числа зависит от разрядности. Например, 32-разрядное число может содержать 32 значения по одному биту на каждое состояние.
В FMOD встречается несколько вариантов определения значений этих флагов:
Литералы в шестнадцатеричной форме
0x00000400
Сдвиги
(1 << 4)
Битовые операции
(CHANNELMASK_FRONT_LEFT | CHANNELMASK_FRONT_RIGHT)
Все эти варианты — корректные синтаксические конструкции и в Rust, поэтому полный разбор и интерпретацию делать не нужно, достаточно представить их в набор токенов:
let value = TokenStream::from_str(&flag.value)?;
В Rust нет стандартного решения для реализации битовых флагов. По аналогии с перечислениями, можно использовать псевдоним на базовый тип и константы для каждого значения.
pub fn generate_flags(flags: &Flags) -> Result<TokenStream, Error> {
let name = format_ident!("{}", flags.name);
let base_type = map_c_type(&flags.flags_type);
let mut values = vec![];
for flag in &flags.flags {
let value = TokenStream::from_str(&flag.value)?;
let flag = format_ident!("{}", flag.name);
values.push(quote! {
pub const #flag: #name = #value;
})
}
Ok(quote! {
pub type #name = #base_type;
#(#values)*
})
}
Пример кода битовых флагов:
pub type FMOD_DEBUG_FLAGS = c_uint;
pub const FMOD_DEBUG_TYPE_CODEC: FMOD_DEBUG_FLAGS = 0x00000400;
pub const FMOD_DEBUG_TYPE_TRACE: FMOD_DEBUG_FLAGS = 0x00000800;
Структуры
Для представления FMOD структур используем обычные Rust структуры с именованными полями. Для корректной работы нужно только добавить маркер #[repr(C)]
.
pub fn generate_structure(structure: &Structure) -> TokenStream {
let name = format_ident!("{}", structure.name);
let fields = structure.fields.iter().map(generate_field);
let default = generate_structure_default(&structure);
quote! {
#[repr(C)]
#[derive(Debug, Copy, Clone)]
pub struct #name {
#(#fields),*
}
#default
}
}
Каждое поле, определенное в структуре, имеет имя и тип. Добавление pub
к полю делает его видимым для кода в других модулях, а также позволяет напрямую обращаться к нему.
pub fn generate_field(field: &Field) -> TokenStream {
let name = format_rust_ident(&field.name);
let field_type = format_rust_type(
&field.field_type,
&field.as_const,
&field.pointer,
&field.array(),
);
quote! {
pub #name: #field_type
}
}
Для заполнения возвращаемых из функций FMOD структур по ссылке нужен конструктор, который бы выделял память под эти структуры на стороне Rust. Но Rust не может абстрагироваться от всего что можно создать в конструкторе, это сильно зависит от конкретных типов. Для этого был создан типаж Default
, который предоставляет значение по умолчанию:
pub fn generate_structure_default(structure: &Structure) -> TokenStream {
let name = format_ident!("{}", structure.name);
let defaults = structure
.fields
.iter()
.map(|field| generate_field_default(&structure.name, field));
quote! {
impl Default for #name {
fn default() -> Self {
Self {
#(#defaults),*
}
}
}
}
}
Пример кода простой структуры:
#[repr(C)]
#[derive(Debug, Copy, Clone)]
pub struct FMOD_VECTOR {
pub x: c_float,
pub y: c_float,
pub z: c_float,
}
impl Default for FMOD_VECTOR {
fn default() -> Self {
Self {
x: Default::default(),
y: Default::default(),
z: Default::default(),
}
}
}
Объявление объединения использует тот же синтаксис, что и объявление структуры с ключевым словом union
вместо struct
:
pub fn generate_structure_union(union: &Union) -> TokenStream {
let fields = union.fields.iter().map(generate_field);
quote! {
#[repr(C)]
#[derive(Copy, Clone)]
pub union #name {
#(#fields),*
}
}
}
Значение объединения в структуре можно получить таким же образом как и при обращении к полю:
match &structure.union {
Some(union) => {
let union_name = format_ident!("{}_UNION", structure.name);
let union = generate_structure_union(&union_name, union);
quote! {
#[repr(C)]
#[derive(Copy, Clone)]
pub struct #name {
#(#fields),*,
pub union: #union_name
}
#default
#union
}
}
}
Пример кода структуры с объединением:
#[repr(C)]
#[derive(Copy, Clone)]
pub struct FMOD_STUDIO_USER_PROPERTY {
pub name: *const c_char,
pub type_: FMOD_STUDIO_USER_PROPERTY_TYPE,
pub union: FMOD_STUDIO_USER_PROPERTY_UNION,
}
#[repr(C)]
#[derive(Copy, Clone)]
pub union FMOD_STUDIO_USER_PROPERTY_UNION {
pub intvalue: c_int,
pub boolvalue: FMOD_BOOL,
pub floatvalue: c_float,
pub stringvalue: *const c_char,
}
Функции
Для объявления интерфейсов, с помощью которых Rust может вызывать внешние функции нужно использовать ключевое слово extern
и указать соглашение о вызовах ABI, в нашем случае “C”
потому, что мы будем использовать библиотеку FMOD предоставляющую C API.
Атрибут #[link(name=libname)]
указывает компоновщику слинковать библиотеку libname
. FMOD предоставляет две библиотеки: fmod, fmodstudio.
pub fn generate_extern(link: &String, api: &Vec<Function>) -> TokenStream {
let mut functions = api.iter().map(generate_function);
quote! {
#[link(name = #link)]
extern "C" {
#(#functions)*
}
}
}
При объявлении типов аргументов внешней функции компилятор Rust не может проверить правильность объявления, поэтому правильная генерация интерфейсов на основе разобранных данных API — ключевой момент для надежности и работоспособности Rust обёртки.
pub fn generate_function(function: &Function) -> TokenStream {
let name = format_ident!("{}", function.name);
let arguments = function.arguments.iter().map(generate_argument);
let return_type = map_c_type(&function.return_type);
quote! {
pub fn #name(#(#arguments),*) -> #return_type;
}
}
Пример объявления интерфейса функции:
#[link(name = "fmod")]
extern "C" {
pub fn FMOD_System_GetCPUUsage(
system: *mut FMOD_SYSTEM,
usage: *mut FMOD_CPU_USAGE,
) -> FMOD_RESULT;
}
Функции обратного вызова
Чтобы сообщить о событиях или промежуточном состоянии FMOD использует около сотни функций обратного вызова.
Функции определенные в Rust можно передавать во внешнюю С библиотеку как функции обратного вызова. Для этого она должна быть отмечена с помощью ключевого слова extern
с правильным соглашением о вызовах “C”
, чтобы ее можно было вызвать из C кода.
Некоторые типы Rust определены так, чтобы никогда не быть нулевыми, в том числе указатели на функции. Но при взаимодействии с FMOD C API допускается что эти указатели могут быть нулевыми. Это распространенная история, поэтому в Rust есть решение, которое упрощает работу, не прибегая к небезопасному коду и ручным манипуляциям с указателями — псевдоним на стандартный тип Option<unsafe extern fn(...)>
где None
соответствует нулевому указателю.
В C функции могут принимать переменное количество аргументов. Этого можно добиться и в Rust, указав ...
в списке аргументов объявления внешней функции:
pub fn generate_callback(callback: &Callback) -> TokenStream {
let name = format_ident!("{}", callback.name);
let arguments = callback.arguments.iter().map(generate_argument);
let varargs = if callback.varargs.is_some() {
Some(quote! {, ...})
} else {
None
};
let return_type = if let Some(return_type) = callback.returns() {
Some(quote! { -> #return_type })
} else {
None
};
quote! {
pub type #name = Option<
unsafe extern "C" fn(
#(#arguments),* #varargs
) #return_type
>;
}
}
Пример указателя на функцию обратного вызова:
pub type FMOD_CODEC_FILE_SEEK_FUNC = Option<
unsafe extern "C" fn(
codec_state: *mut FMOD_CODEC_STATE,
pos: c_uint,
method: FMOD_CODEC_SEEK_METHOD,
) -> FMOD_RESULT;
>;
Маппинг FFI типов
Интерфейс FMOD естественно не обходится без использования базовых типов C, которые не так подробно определены как типы Rust. Чтобы маппинг был правильным, используем псевдонимы из модуля std::os:raw
.
Пользовательские типы FMOD так или иначе передаются в FFI без преобразований, благодаря близости Rust к системному уровню и C в частности. Поэтому все что нам нужно, это правильно указать имя типа, маппинг не требуется:
pub fn map_c_type(c_type: &Type) -> TokenStream {
let name = match c_type {
FundamentalType(name) => {
let name = name.replace("unsigned", "u").replace(" ", "");
format_ident!("c_{}", name)
}
Type::UserType(name) => format_ident!("{}", name),
};
quote! { #name }
}
Аргументы функций и поля структур могут быть указателями. В Rust они представлены как “сырые” указатели с помощью конструкций *mut T
и *const T
.
Кроме того, в C можно определить указатель на указатель. Первый используется для хранения адреса переменной, второй для хранения адреса первого. Его так же называют “двойным указателем”:
pub fn describe_pointer<'a>(
as_const: &'a Option<String>,
pointer: &'a Option<Pointer>
) -> &'a str {
let description = match (as_const, pointer) {
(None, None) => "",
(None, Some(Pointer::NormalPointer(_))) => "*mut",
(None, Some(Pointer::DoublePointer(_))) => "*mut *mut",
(Some(_), Some(Pointer::NormalPointer(_))) => "*const",
(Some(_), Some(Pointer::DoublePointer(_))) => "*const *const",
(Some(_), None) => "",
};
description
}
Массивы в Rust тоже представлены, его размер и тип переменной записываются с помощью квадратных скобок [T; usize]
. FMOD в некоторых случаях использует константы для определения размера массива. Эти константы имеют тип unsigned int
, поэтому приводим их к типу as usize
, который используется Rust.
Полный код маппинга типов FFI:
pub fn format_rust_type(
c_type: &Type,
as_const: &Option<String>,
pointer: &Option<Pointer>,
as_array: &Option<TokenStream>,
) -> TokenStream {
let name = map_c_type(c_type);
let pointer = describe_pointer(as_const, pointer);
let rust_type = quote! { #pointer #name };
match as_array {
Some(dimension) => quote! { [#rust_type; #dimension as usize] },
None => rust_type,
}
}
Генерация Rust обёртки
Есть две причины существования обёртки над FFI объявлениями:
Обёртка предоставляет только безопасный высокоуровневый интерфейс и скрывает небезопасные внутренние детали: заполнение массива из указателя, обработка C строк, преобразование типов, и прочее.
Соглашение об именовании программных элементов. Название структур и функций FMOD C API длинные, включают в себя избыточную информацию по организации кода, и просто не совпадают со стилем Rust кода. Например, тип
FMOD_STUDIO_EVENTDESCRIPTION
в Rust обёртке может выглядеть и читаться проще какEventDescription
Процесс генерации сводится к использованию механизма квази-цитирования по аналогии с генерацией FFI.
Непрозрачные типы
Все объекты FMOD возвращаются в виде указателя на структуру. Использование этих указателей выходит за рамки модели безопасной работы Rust. Для каждого такого объекта создадим обёртку, которая будет скрывать работу с указателем.
pub fn generate_opaque_type(
key: &String,
methods: &Vec<&Function>,
api: &Api
) -> TokenStream {
let name = format_struct_ident(key);
let opaque_type = format_ident!("{}", key);
quote! {
#[derive(Debug, Clone, Copy)]
pub struct #name {
pointer: *mut ffi::#opaque_type,
}
impl #name {
pub fn from(pointer: *mut ffi::#opaque_type) -> Self {
Self { pointer }
}
pub fn as_mut_ptr(&self) -> *mut ffi::#opaque_type {
self.pointer
}
}
}
}
Пример Rust объекта:
#[derive(Debug, Clone, Copy)]
pub struct EventDescription {
pointer: *mut ffi::FMOD_STUDIO_EVENTDESCRIPTION,
}
impl EventDescription {
pub fn from(pointer: *mut ffi::FMOD_STUDIO_EVENTDESCRIPTION) -> Self {
Self { pointer }
}
pub fn as_mut_ptr(&self) -> *mut ffi::FMOD_STUDIO_EVENTDESCRIPTION {
self.pointer
}
}
Перечисления
Перечисления в Rust отличаются от перечислений в C. Фактически это не набор целочисленных констант, а алгебраический тип данных. Нужно использовать ключевое слово enum
, чтобы определить тип и набор вариантов, которые будут соответствовать перечислителям.
FMOD часто использует отрицательные значения в перечислениях. Специально даже указывают закрывающий перечислитель _FORCEINT
со значением 65536
который затем не используется, но заставляет компилятор С использовать тип знакового числа. Эти технические перечислители пропускаем, чтобы сделать использование Rust перечислений удобнее.
pub fn generate_enumeration(enumeration: &Enumeration) -> TokenStream {
let name = format_struct_ident(&enumeration.name);
let mut variants = vec![];
for enumerator in &enumeration.enumerators {
if enumerator.name.ends_with("FORCEINT") {
continue;
}
let variant = format_variant(&enumeration.name, &enumerator.name);
variants.push(variant);
}
quote! {
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum #name {
#(#variants),*
}
}
}
Для того чтобы передать значение Rust перечисления в FFI функции, нужно реализовать преобразование. Типаж From
позволяет типу определить, как создать себя из другого типа, тем самым предоставляет интерфейс преобразования. А ключевое слово match
реализует механизм сопоставления с образцом, которой в данном случае для каждого варианта Rust перечисления возвращает соответствующие значение FFI перечислителя.
enumerator_arms.push(quote! {
#name::#variant => ffi::#enumerator
});
...
impl From<#name> for ffi::#enumeration {
fn from(value: #name) -> ffi::#enumeration {
match value {
#(#enumerator_arms),*
}
}
}
Один из механизмов безопасной работы заключается в том, что Rust обеспечивает проверку полноты сопоставления вариантов в конструкции match
. Например, компилятор делает вывод, что FFI перечисление FMOD_PLUGINTYPE
может иметь любое целочисленное значение. Символ подчеркивания _
захватывает все не указанные значения и позволяет обработать ошибку без исключений, в случае если FMOD вернет неожиданное значение:
variant_arms.push(quote! {
ffi::#enumerator => Ok(#name::#variant)
});
...
impl #name {
pub fn from(value: ffi::#enumeration) -> Result<#name, Error> {
match value {
#(#variant_arms),*,
_ => Err(err_enum!(#enumeration_name, value)),
}
}
}
Пример Rust перечисления:
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum PluginType {
Output,
Codec,
Dsp,
Max,
}
impl From<PluginType> for ffi::FMOD_PLUGINTYPE {
fn from(value: PluginType) -> ffi::FMOD_PLUGINTYPE {
match value {
PluginType::Output => ffi::FMOD_PLUGINTYPE_OUTPUT,
PluginType::Codec => ffi::FMOD_PLUGINTYPE_CODEC,
PluginType::Dsp => ffi::FMOD_PLUGINTYPE_DSP,
PluginType::Max => ffi::FMOD_PLUGINTYPE_MAX,
}
}
}
impl PluginType {
pub fn from(value: ffi::FMOD_PLUGINTYPE) -> Result<PluginType, Error> {
match value {
ffi::FMOD_PLUGINTYPE_OUTPUT => Ok(PluginType::Output),
ffi::FMOD_PLUGINTYPE_CODEC => Ok(PluginType::Codec),
ffi::FMOD_PLUGINTYPE_DSP => Ok(PluginType::Dsp),
ffi::FMOD_PLUGINTYPE_MAX => Ok(PluginType::Max),
_ => Err(err_enum!("FMOD_PLUGINTYPE", value)),
}
}
}
Структуры
Чтобы определить структуру, используем ключевое слово struct
и название. Затем внутри фигурных скобок определяем имена и типы полей, который будут больше соответствовать идиоматичному Rust.
pub fn generate_structure_code(
structure: &Structure,
api: &Api
) -> TokenStream {
let ident = format_ident!("{}", structure.name);
let name = format_struct_ident(&structure.name);
let mut field = structure
.fields
.iter()
.filter(|field| is_convertable(&structure, field))
.map(|field| generate_field(structure, field, api));
let presets = generate_presets(structure, api);
let into = generate_structure_into(structure, api);
let try_from = generate_structure_try_from(structure, api);
quote! {
#[derive(Debug, Clone)]
pub struct #name {
#(#fields),*
}
#presets
#try_from
#into
}
}
Для того чтобы заполнить структуру из возвращаемых значений FFI функций, нужно реализовать преобразование. Типаж TryFrom
предоставляет интерфейс преобразования, которой подразумевает что может произойти ошибка. Актуально, потому что мы не можем гарантировать корректность работы внешней библиотеки.
pub fn generate_structure_try_from(
structure: &Structure,
api: &Api
) -> TokenStream {
let ident = format_ident!("{}", structure.name);
let name = format_struct_ident(&structure.name);
let conversion = structure
.fields
.iter()
.map(|field| generate_field_from(&structure.name, field, api));
quote! {
impl TryFrom<ffi::#ident> for #name {
type Error = Error;
fn try_from(value: ffi::#ident) -> Result<Self, Self::Error> {
unsafe {
Ok(#name {
#(#conversion),*
})
}
}
}
}
}
Чтобы передать значение структуры в FFI функции реализуем обратное преобразование с помощью типажа Into
.
pub fn generate_structure_into(
structure: &Structure,
api: &Api
) -> TokenStream {
let ident = format_ident!("{}", structure.name);
let name = format_struct_ident(&structure.name);
let conversion = &structure
.fields
.iter()
.map(|field| generate_field_into(&structure.name, field, api));
quote! {
impl Into<ffi::#ident> for #name {
pub fn into(self) -> ffi::#ident {
ffi::#ident {
#(#conversion),*
}
}
}
}
}
Пример Rust структуры:
#[derive(Debug, Clone)]
pub struct Vector {
pub x: f32,
pub y: f32,
pub z: f32,
}
impl TryFrom<ffi::FMOD_VECTOR> for Vector {
type Error = Error;
fn try_from(value: ffi::FMOD_VECTOR) -> Result<Self, Self::Error> {
unsafe {
Ok(Vector {
x: value.x,
y: value.y,
z: value.z,
})
}
}
}
impl Into<ffi::FMOD_VECTOR> for Vector {
fn into(self) -> ffi::FMOD_VECTOR {
ffi::FMOD_VECTOR {
x: self.x,
y: self.y,
z: self.z,
}
}
}
Методы
Практически все функции FMOD — методы, то есть функции которые связаны с определенным типом объектов и вызываются для конкретного экземпляра этого типа.
Все методы возвращают статус код FMOD_RESULT
— целочисленное перечисление кодов ошибок. Эти ошибки расшифруем с помощью макроса err_fmod!
и приведем к типу Result<T,E>
который используется в Rust для работы с ошибками.
macro_rules! err_fmod {
($ function : expr , $ code : expr) => {
Error::Fmod {
function: $function.to_string(),
code: $code,
message: ffi::map_fmod_error($code).to_string(),
}
};
}
pub fn map_fmod_error(result: FMOD_RESULT) -> &'static str {
match result {
FMOD_OK => "No errors.",
FMOD_ERR_FILE_BAD => "Error loading file.",
// ...
}
}
Внешние функции считаются небезопасными, поэтому вызовы к ним должны быть заключены в unsafe
как обещание компилятору Rust, что все, что содержится внутри, действительно безопасно. Это один из ключевых моментов создания Rust обертки.
pub fn generate_method(
owner: &str,
function: &Function,
api: &Api
) -> TokenStream {
let mut signature = Signature::new();
for argument in &function.arguments { ... }
let (arguments, inputs, out, output, returns) = signature.define();
let method_name = extract_method_name(&function.name);
let method = format_ident!("{}", method_name);
let function_name = &function.name;
let function = format_ident!("{}", function_name);
quote! {
pub fn #method( #(#arguments),* ) -> Result<#returns, Error> {
unsafe {
#(#out)*
match ffi::#function( #(#inputs),* ) {
ffi::FMOD_OK => Ok(#output),
error => Err(err_fmod!(#function_name, error)),
}
}
}
}
}
Пример метода:
impl System {
pub fn get_cpu_usage(&self) -> Result<CpuUsage, Error> {
unsafe {
let mut usage = ffi::FMOD_CPU_USAGE::default();
match ffi::FMOD_System_GetCPUUsage(self.pointer, &mut usage) {
ffi::FMOD_OK => Ok(CpuUsage::from(usage)?),
error => Err(err_fmod!("FMOD_System_GetCPUUsage", error)),
}
}
}
}
Преобразование аргументов методов
В общем аргументы можно разделить на три типа:
Входящие данные
Опциональные входящие данные
Возвращаемые данные
Для входящих данных необходимо указать определение аргумента в сигнатуре метода param
и выражение преобразования аргумента для передачи в FFI функцию input
.
struct InArgument {
pub param: TokenStream,
pub input: TokenStream,
}
Опциональные входящие данные, как правило, связаны с обработкой нулевых указателей и всё сводится к обертке типов в Option<T>
в выражениях param
и input
.
FMOD все значения возвращает через аргументы указатели. Поэтому для возвращаемых данных в первую очередь нужно определить переменную target
, которая будет содержать участок памяти, целевой для заполнения через указатель. Выражение для передачи указателя source
. Преобразование возвращаемого значения output
в подходящий Rust тип. И само определение типа в сигнатуре метода retype
.
struct OutArgument {
pub target: TokenStream,
pub source: TokenStream,
pub output: TokenStream,
pub retype: TokenStream,
}
Так что для каждого аргумента функции определяем его тип (модификатор) и генерируем необходимый набор Rust кода:
if !signature.overwrites(owner, function, argument) {
match api.get_modifier(&function.name, &argument.name) {
Modifier::None => signature += map_input(argument, api),
Modifier::Opt => signature += map_optional(argument, api),
Modifier::Out => signature += map_output(argument, api),
}
}
В некоторых случаях проще использовать хардкод, то есть вручную определять желаемый результат в коде Rust обёртки, вместо того чтобы разрабатывать сложное общее решение. Этот хардкод определен в методе overwrites. Там же можно корректировать интерфейс Rust обёртки, например упрощать некоторые методы:
pub fn overwrites(
&mut self,
owner: &str,
function: &Function,
argument: &Argument
) -> bool {
if function.name == "FMOD_Studio_System_Create" && argument.name == "headerversion" {
self.inputs.push(quote! { ffi::FMOD_VERSION });
return true;
}
...
}
Ключевое слово self
представляет экземпляр структуры, для которой вызывается метод. Выражение &self
это сокращение для self: &Self
, которое означает ссылку на тип в котором реализован метод. Наличие self
определяем из названия типа аргумента и названия владельца (структуры для которой генерируем метод):
if arguments.is_empty()
&& argument_type.is_user_type(owner)
&& pointer == "*mut"
{
arguments.push(quote! { &self });
sources.push(quote! { self.pointer });
}
Преобразование входящих данных зависит от C типа и указателя:
fn map_input(argument: &Argument, api: &Api) -> InArgument {
let pointer = ffi::describe_pointer(&argument.as_const, &argument.pointer);
let argument_type = &argument.argument_type;
let argument = format_argument_ident(&argument.name);
match argument_type {
FundamentalType(type_name) => {
match &format!("{}:{}", pointer, type_name)[..] {
":float" => InArgument {
param: quote! { #argument: f32 },
input: quote! { #argument },
},
// ...
},
UserType(type_name) => {
let rust_type = format_struct_ident(&type_name);
let ident = format_ident!("{}", type_name);
match (pointer, api.describe_user_type(&type_name)) {
("*mut", UserTypeDesc::OpaqueType) => InArgument {
param: quote! { #argument: #rust_type },
input: quote! { #argument.as_mut_ptr() },
},
// ...
}
}
}
}
}
В Rust, в отличии от C, строка не заканчивается на \0
. Поэтому мы должно использовать стандартный тип CString
для проверки нуля и отправлять значение как указатель на блок памяти в котором содержится строка:
"*const:char" => InArgument {
param: quote! { #argument: &str },
input: quote! { CString::new(#argument)?.as_ptr() },
},
Перечисления преобразовываем с помощью ранее определенного типажа Into
:
("", UserTypeDesc::Enumeration) => InArgument {
param: quote! { #argument: #rust_type },
input: quote! { #argument.into() },
},
В некоторых случаях FMOD ожидает передачу структур через указатель не подразумевая под этим изменения значений этих структур. Для этого используем стандартный механизм Rust ссылок с помощью &
и &mut
:
("*mut", UserTypeDesc::Structure) => InArgument {
param: quote! { #argument: #rust_type },
input: quote! { &mut #argument.into() },
},
("", UserTypeDesc::Structure) => InArgument {
param: quote! { #argument: #rust_type },
input: quote! { #argument.into() },
},
Иногда FMOD предоставляет динамический интерфейс в который можно по указателю передать любое значение. В этом случае не обойтись без использования сырых указателей на c_void
:
"*const:void" => InArgument {
param: quote! { #argument: *const c_void },
input: quote! { #argument },
},
Пример метода с входящими данными:
pub fn set_parameter_float(&self, index: i32, value: f32) -> Result<(), Error> {
unsafe {
match ffi::FMOD_DSP_SetParameterFloat(self.pointer, index, value) {
ffi::FMOD_OK => Ok(()),
error => Err(err_fmod!("FMOD_DSP_SetParameterFloat", error)),
}
}
}
Преобразование возвращаемых данных зависит от типа и указателя:
fn map_output(argument: &Argument, api: &Api) -> OutArgument {
let pointer = ffi::describe_pointer(&argument.as_const, &argument.pointer);
let arg = format_argument_ident(&argument.name);
match &argument.argument_type {
FundamentalType(type_name) => match &format!("{}:{}", pointer, type_name)[..] {
"*mut:float" => OutArgument {
target: quote! { let mut #arg = f32::default(); },
source: quote! { &mut #arg },
output: quote! { #arg },
retype: quote! { f32 },
},
// ...
},
UserType(user_type) => {
let type_name = format_struct_ident(&user_type);
let ident = format_ident!("{}", user_type);
match (pointer, api.describe_user_type(&user_type)) {
("*mut", UserTypeDesc::Structure) => OutArgument {
target: quote! { let mut #arg = ffi::#ident::default(); },
source: quote! { &mut #arg },
output: quote! { #type_name::try_from(#arg)? },
retype: quote! { #type_name },
},
// ...
}
}
}
}
Кортеж в Rust — составной тип данных, который хранит более одного значения одновременно. Используем кортеж когда функция возвращает больше одного значения:
pub fn get_wet_dry_mix(&self) -> Result<(f32, f32, f32), Error> {
unsafe {
let mut prewet = f32::default();
let mut postwet = f32::default();
let mut dry = f32::default();
match ffi::FMOD_DSP_GetWetDryMix(
self.pointer,
&mut prewet,
&mut postwet,
&mut dry
) {
ffi::FMOD_OK => Ok((prewet, postwet, dry)),
error => Err(err_fmod!("FMOD_DSP_GetWetDryMix", error)),
}
}
}
Заключение
Разработка этого решения заняла в общем около двух человеко-недель. Едва ли ручное создание обёртки в сопоставимом объёме займёт меньше времени. Поэтому затраты в любом случае оправданы.
Удобство использования Rust обёртки против FFI можно сравнить на наглядном примере:
fn initialize_fmod_studio() -> Result<(), Error> {
let studio = Studio::create()?;
let system = studio.get_core_system()?;
system.set_software_format(None, Some(SpeakerMode::Quad), None)?;
studio.initialize(1024, FMOD_STUDIO_INIT_NORMAL, FMOD_INIT_NORMAL, None)?;
}
// Rust vs FFI
fn initialize_fmod_studio_via_ffi() -> Result<(), Error> {
let mut studio = null_mut();
let result = unsafe { FMOD_Studio_System_Create(&mut studio, FMOD_VERSION) };
if result != FMOD_OK {
return Err(Error(result));
}
let mut system = null_mut();
let result = unsafe { FMOD_Studio_System_GetCoreSystem(studio, &mut system) };
if result != FMOD_OK {
return Err(Error(result));
}
let result = unsafe { FMOD_System_SetSoftwareFormat(system, 0, FMOD_SPEAKERMODE_QUAD, 0) };
if result != FMOD_OK {
return Err(Error(result));
}
let result = unsafe {
FMOD_Studio_System_Initialize(
studio,
1024,
FMOD_STUDIO_INIT_NORMAL,
FMOD_INIT_NORMAL,
null_mut(),
)
};
if result != FMOD_OK {
return Err(Error(result));
}
}
Полный код решения открыт в репозитории libfmod-gen.
Попробовать FMOD в Rust можно с помощью библиотеки libfmod.
Комментарии (11)
lain8dono
20.03.2022 16:25А что такого интересного предоставляет FMOD в сравнении с открытыми решениями (типа всяких kira).
lebedec Автор
20.03.2022 19:15FMOD не только звуковой движок, но и рабочая среда FMOD Studio, в которой имеют опыт работы многие звукорежиссеры потому что это один из стандартов в отрасли.
ZyXI
21.03.2022 02:49+3В Rust нет стандартного выражения для целочисленных перечислений. Для определения типа перечисления используем псевдоним на существующий базовый тип с помощью ключевого слова type. Перечислители объявляем константами.
Вообще‐то есть. У пакета num-traits также есть FromPrimitive, упрощающий каст в такой enum (derive для него поддерживается в отдельном пакете).
lebedec Автор
21.03.2022 07:58Не знаю как мог упустить такой очевидный момент. Спасибо! Переделаю, это сильно упрощает обработку перечислений
mayorovp
21.03.2022 13:26+2Ну нельзя же делать вот так:
#[derive(Debug, Clone, Copy)] pub struct EventDescription { pointer: *mut ffi::FMOD_STUDIO_EVENTDESCRIPTION, }
Если вы используете FMOD_STUDIO_EVENTDESCRIPTION, и одновременно выгружаете систему через FMOD_Studio_System_Release — будет ошибка. Нужно либо удерживать сильную ссылку на System, чтобы предотвратить такую выгрузку:
#[derive(Debug, Clone)] pub struct EventDescription { owner: Arc<System>, pointer: *mut ffi::FMOD_STUDIO_EVENTDESCRIPTION, }
Либо использовать возможности Rust чтобы не дать этого сделать на этапе компиляции:
#[derive(Debug, Clone, Copy)] pub struct EventDescription<`a> { pointer: *mut ffi::FMOD_STUDIO_EVENTDESCRIPTION, owner: PhantomData<&`a System>, }
А ещё лучше, раз уж вы всё равно генерируете код — использовать сразу оба подхода.
И да, надеюсь что у вас System реализует Drop.
lebedec Автор
21.03.2022 15:28Спасибо, дельное замечание. Сначала тоже думал переложить на Rust высвобождение ресурсов, реализовать в Drop вызов
FMOD_Studio_System_Release
и прочее. Но FMOD в любом случае подразумевает ответственность со стороны разработчика за порядок работы с API. Например:Если пойти в обратную сторону, любой работе с FMOD объектами должен предшествовать
FMOD_Studio_System_Initialize
, а это подразумевает строгость типов вроде отдельныхSystem
иInitializedSystem
Банки выгружать через Drop нельзя, потому что есть сценарии, где это должно происходить только вручную
В итоге хорошо бы сделать более безопасный и строгий интерфейс, но это уже скорее следующий уровень абстракции, который предполагает изменение FMOD API в Rust стиле. Это, на мой взгляд, выходит за рамки обёртки, которая просто скрывает работу с указателями и небезопасным FFI.
mayorovp
21.03.2022 15:56+2Но FMOD в любом случае подразумевает ответственность со стороны разработчика за порядок работы с API.
Вот именно этот порядок и надо переложить на Rust, благо механизмы есть. Иначе вы, по-хорошему, не можете называть ваше API словом "safe".
Если пойти в обратную сторону, любой работе с FMOD объектами должен предшествовать FMOD_Studio_System_Initialize, а это подразумевает строгость типов вроде отдельных System и InitializedSystem
Отдельные System и InitializedSystem не нужны, потому что вы можете скомбинировать эти вызовы.
Банки выгружать через Drop нельзя, потому что есть сценарии, где это должно происходить только вручную
Только вручную потому что ..? Если потому что требуется соблюдение инвариантов — то за ними должен следить компилятор, а не программист. Если потому что программисту требуется определённое время жизни — ну так пусть держит ссылку.
В общем, не должно быть таких сценариев.
lebedec Автор
21.03.2022 16:43-1Идейно я с вами согласен. Но в данном случае приходится учитывать несовершенство реального мира. Эта Rust обёртка небезопасна настолько, насколько это заложено в оригинальном FMOD API, в контексте наших примеров:
Комбинировать
FMOD_Studio_System_Create
иFMOD_Studio_System_Initialize
нельзя потому что между ними могут быть другие вызовыFMOD работает как "отдельный процесс", загруженные банки могут использоваться в процессе независимо от количества ссылок на них в Rust приложении
Чтобы это исправить и сделать "как должно быть" нужно отдельное решение, которое бы опиралось на принципы и механизмы Rust, а не фактический FMOD C API. А это уже сложнее чем генератор обёртки, дороже, и вопрос целесообразности.
Приходится идти на компромисс, я об этом.
mayorovp
21.03.2022 21:23Но это неправильно. API обёртки должно "обернуть", в том числе, и гарантии.
Комбинировать FMOD_Studio_System_Create и FMOD_Studio_System_Initialize нельзя потому что между ними могут быть другие вызовы
Вы же сами написали, что при любой работе с FMOD надо сначала инициализировать систему. Так откуда возьмутся другие вызовы и что они там будут делать?
FMOD работает как "отдельный процесс", загруженные банки могут использоваться в процессе независимо от количества ссылок на них в Rust приложении
И чем это мешает сделать нормальное API?
А это уже сложнее чем генератор обёртки, дороже, и вопрос целесообразности.
Не виду ничего особенно сложного. Просто частично вывести из генерируемой обёртки структуры системы и банков данных.
NN1
Пробовали использовать bitfield ?
lebedec Автор
Кажется это популярное решение для работы с флагами, не стал его использовать только потому что соблазнила мысль сделать итоговую библиотеку без зависимостей. Но может быть пересмотрю этот момент в будущем.