15. Macros, unsafe y FFI
Ejemplos completos: examples/l15_macros_unsafe.rs — cargo run --example l15_macros_unsafe — y l15-ffi/, una biblioteca Rust llamada desde una aplicación de consola C#.
Esta última lección es un panorama: cada tema merece su propio libro (enlazado en Fuentes). El objetivo es reconocer estas herramientas en código real y saber cuándo son la respuesta adecuada — lo que ocurre pocas veces.
Macros: código que escribe código
Sección titulada «Macros: código que escribe código»Usas macros desde la lección 1: println!, vec!, assert_eq!, #[derive(Debug)], #[tokio::main]. Se ejecutan en tiempo de compilación y se expanden en código Rust normal, cuyos tipos se comprueban después como todo lo demás.
| Rust | C# | Java |
|---|---|---|
macro_rules! (declarativa) |
— | — |
#[derive(...)] (procedural) |
generadores de código fuente | procesadores de anotaciones, Lombok |
macros de atributo (#[tokio::main]) |
generadores de código fuente + atributos | procesadores de anotaciones |
macros procedurales de tipo función (sqlx::query!) |
generadores de código fuente | — |
Macros declarativas
Sección titulada «Macros declarativas»Una macro macro_rules! es un match sobre la sintaxis: cada regla tiene un patrón y una expansión.
macro_rules! square { ($x:expr) => { $x * $x };}
println!("square!(1 + 2) = {}", square!(1 + 2)); // 9$x:expr captura una expresión completa y la mantiene agrupada, así que square!(1 + 2) es (1 + 2) * (1 + 2) = 9. Un #define SQUARE(x) x * x de C daría 1 + 2 * 1 + 2 = 5. Las macros de Rust trabajan sobre el árbol sintáctico, no sobre el texto.
La repetición, $( … ),*, maneja listas — así está escrito vec!:
macro_rules! hashmap { ($($key:expr => $value:expr),* $(,)?) => {{ let mut map = HashMap::new(); $( map.insert($key, $value); )* map }};}
let ages = hashmap! { "Ada" => 36, "Grace" => 85,};Las reglas se prueban en orden y pueden ser recursivas; las macros también pueden generar elementos como structs:
macro_rules! max_of { ($x:expr) => { $x }; ($x:expr, $($rest:expr),+) => {{ let rest = max_of!($($rest),+); if $x > rest { $x } else { rest } }};}
macro_rules! newtype { ($name:ident, $inner:ty) => { #[derive(Debug, Clone, Copy, PartialEq)] struct $name($inner); };}
newtype!(UserId, u32);newtype!(OrderId, u32);// max_of!(3, 9, 4) = 9// UserId(7) OrderId(7) true| Fragmento | Captura |
|---|---|
expr |
una expresión: 1 + 2, foo() |
ident |
un identificador: UserId |
ty |
un tipo: u32, Vec<String> |
pat |
un patrón: Some(x) |
literal |
un literal: 42, "text" |
block |
un bloque: { … } |
tt |
cualquier árbol de tokens individual — la vía de escape |
Como las macros se ejecutan en el compilador, sus errores son errores de compilación. println! comprueba su cadena de formato contra sus argumentos:
error: 2 positional arguments in format string, but there is 1 argument --> e15_format.rs:3:15 |3 | println!("{} is {} years old", name); | ^^ ^^ ----Y una llamada que no coincide con ninguna regla se rechaza en el punto de llamada:
error: unexpected end of macro invocation --> e15_macro_args.rs:8:20 |1 | macro_rules! square { | ------------------- when calling this macro...8 | println!("{}", square!()); | ^^^^^^^^^ missing tokens in macro arguments |note: while trying to match meta-variable `$x:expr`Macros procedurales
Sección titulada «Macros procedurales»Las macros procedurales son funciones Rust que reciben un flujo de tokens y devuelven otro. Deben vivir en su propio crate con proc-macro = true, y normalmente se escriben con los crates syn (análisis) y quote (generación). Sobre todo las vas a usar:
- derive:
#[derive(Serialize, Deserialize)]de serde genera el soporte de JSON (y de otros formatos), como lo harían la generación de código fuente deSystem.Text.Jsono las anotaciones de Jackson; - de atributo:
#[tokio::main]reescribemainpara arrancar un runtime,#[test]registra un test; - de tipo función:
sqlx::query!("SELECT …")comprueba el SQL contra el esquema de una base de datos en tiempo de compilación.
unsafe: el compilador confía en ti
Sección titulada «unsafe: el compilador confía en ti»El Rust seguro garantiza que no hay punteros colgantes, ni carreras de datos, ni accesos fuera de límites. Algunos programas útiles no pueden ser demostrados seguros por el compilador — hablar con C, escribir un asignador de memoria, implementar el propio Vec. unsafe marca los lugares donde tú asumes la responsabilidad de esas garantías.
Un bloque unsafe desbloquea exactamente cinco operaciones adicionales:
- desreferenciar un puntero crudo (
*const T,*mut T); - llamar a una función
unsafe(incluidas las funciones externas); - leer o escribir un
staticmutable; - implementar un trait
unsafe(comoSendoSynca mano); - acceder a los campos de una
union.
Todo lo demás sigue aplicándose dentro de unsafe: el borrow checker, la comprobación de tipos, la comprobación de límites en los slices. Crear un puntero crudo es seguro; solo usarlo no lo es:
let x = 42;let ptr = &x as *const i32; // permitido en código seguroprintln!("{}", *ptr);error[E0133]: dereference of raw pointer is unsafe and requires unsafe block --> e15_deref.rs:4:20 |4 | println!("{}", *ptr); | ^^^^ dereference of raw pointer | = note: raw pointers may be null, dangling or unaligned; they can violate aliasing rules and cause data races: all of these are undefined behaviorC# tiene la misma idea — la palabra clave unsafe, los punteros y fixed, activados con <AllowUnsafeBlocks> — y Java tiene sun.misc.Unsafe y la Foreign Function & Memory API. La diferencia es que en Rust un comportamiento indefinido en código unsafe puede romper las garantías en cualquier otra parte del programa, así que la convención es estricta.
Abstracciones seguras sobre código unsafe
Sección titulada «Abstracciones seguras sobre código unsafe»El patrón estándar es un pequeño núcleo unsafe envuelto en una función segura que comprueba ella misma cada condición. Devolver referencias mutables al primer elemento y al resto de un slice parece inofensivo, pero el borrow checker no puede ver que las dos partes no se solapan:
fn split_first_rest(values: &mut [i32]) -> Option<(&mut i32, &mut [i32])> { if values.is_empty() { return None; } Some((&mut values[0], &mut values[1..]))}error[E0499]: cannot borrow `*values` as mutable more than once at a time --> e15_split.rs:5:32 |1 | fn split_first_rest(values: &mut [i32]) -> Option<(&mut i32, &mut [i32])> { | - let's call the lifetime of this reference `'1`...5 | Some((&mut values[0], &mut values[1..])) | ---------------------------^^^^^^------- | | | | | | | second mutable borrow occurs here | | first mutable borrow occurs here | returning this value requires that `values[_]` is borrowed for `'1`Con punteros crudos, y un comentario SAFETY que explica por qué se cumplen los invariantes:
fn split_first_rest(values: &mut [i32]) -> Option<(&mut i32, &mut [i32])> { if values.is_empty() { return None; } let len = values.len(); let ptr = values.as_mut_ptr(); // SAFETY: el slice no está vacío, así que `ptr` es válido para `len` elementos; // el elemento 0 y los elementos 1..len no se solapan, así que los dos préstamos mutables son disjuntos. unsafe { Some(( &mut *ptr, std::slice::from_raw_parts_mut(ptr.add(1), len - 1), )) }}// [10, 20, 30] -> first += 1, rest *= 2 -> [11, 40, 60]Quien llama solo ve una firma segura. Así es exactamente como la biblioteca estándar implementa split_at_mut y split_first_mut — lo que significa que deberías usar esas en su lugar (ejercicio 2).
La edición 2024 hizo unsafe más explícito. Dentro de una unsafe fn, las operaciones unsafe necesitan ahora su propio bloque unsafe — el unsafe de la función restringe a quien la llama, no a su cuerpo:
warning[E0133]: dereference of raw pointer is unsafe and requires unsafe block --> w15_unsafe_op.rs:4:5 |4 | *ptr | ^^^^ dereference of raw pointer |note: an unsafe function restricts its caller, but its body is safe by default --> w15_unsafe_op.rs:3:1 |3 | pub unsafe fn read(ptr: *const i32) -> i32 { | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ = note: `#[warn(unsafe_op_in_unsafe_fn)]` (part of `#[warn(rust_2024_compatibility)]`) on by defaultToda unsafe fn pública debería documentar su contrato en una sección # Safety (lección 14); el lint missing_safety_doc de clippy lo comprueba. Miri, un intérprete disponible en nightly, puede detectar muchos tipos de comportamiento indefinido al ejecutar los tests.
FFI: hablar con otros lenguajes
Sección titulada «FFI: hablar con otros lenguajes»La FFI (foreign function interface, interfaz de funciones externas) pasa por la ABI de C: la convención de llamada que entienden todos los lenguajes de la plataforma.
Llamar a C desde Rust
Sección titulada «Llamar a C desde Rust»unsafe extern "C" { fn abs(input: i32) -> i32;}
// SAFETY: `abs` no tiene precondiciones para esta entrada.println!("abs(-3) from C = {}", unsafe { abs(-3) }); // 3El bloque es unsafe extern porque Rust no puede comprobar que la declaración coincide con la función C real; desde la edición 2024, olvidar unsafe es un error (extern blocks must be unsafe). Llamar a la función también necesita unsafe:
error[E0133]: call to unsafe function `abs` is unsafe and requires unsafe block --> e15_call_unsafe.rs:6:20 |6 | println!("{}", abs(-3)); | ^^^^^^^ call to unsafe function | = note: consult the function's documentation for information on how to avoid undefined behaviorPara bibliotecas C reales, la herramienta bindgen genera estas declaraciones a partir de las cabeceras C.
Llamar a Rust desde C#
Sección titulada «Llamar a Rust desde C#»Es la dirección inversa, y la más útil para un desarrollador C#: escribir en Rust una pieza crítica para el rendimiento o compartida, y llamarla desde .NET con P/Invoke. La carpeta l15-ffi contiene los dos lados.
Lado Rust — una biblioteca compilada como biblioteca dinámica nativa:
[lib]# cdylib: una .dll / .so / .dylib nativa con ABI de C; rlib: para que `cargo test` pueda enlazarlacrate-type = ["cdylib", "rlib"]use std::ffi::{CStr, CString, c_char};
/// Añade un `percent` % de IVA a un importe en céntimos.#[unsafe(no_mangle)]pub extern "C" fn pricing_add_vat(cents: u64, percent: u32) -> u64 { cents * (100 + u64::from(percent)) / 100}
/// Suma `len` precios.////// # Safety////// `prices` debe apuntar a `len` valores `f64` inicializados, o ser nulo.#[unsafe(no_mangle)]pub unsafe extern "C" fn pricing_sum(prices: *const f64, len: usize) -> f64 { if prices.is_null() { return 0.0; } // SAFETY: quien llama garantiza que `prices` apunta a `len` valores. let prices = unsafe { std::slice::from_raw_parts(prices, len) }; prices.iter().sum()}
/// Formatea `name: 42.50` en una cadena asignada por Rust./// Quien llama debe liberarla con [`pricing_free_string`].////// # Safety////// `name` debe ser una cadena válida terminada en NUL, o ser nulo.#[unsafe(no_mangle)]pub unsafe extern "C" fn pricing_label(name: *const c_char, cents: u64) -> *mut c_char { if name.is_null() { return std::ptr::null_mut(); } // SAFETY: quien llama garantiza una cadena válida terminada en NUL. let name = unsafe { CStr::from_ptr(name) }.to_string_lossy(); let label = format!("{name}: {}.{:02}", cents / 100, cents % 100); CString::new(label).map_or(std::ptr::null_mut(), CString::into_raw)}
/// Libera una cadena devuelta por [`pricing_label`].////// # Safety////// `label` debe proceder de `pricing_label` y no debe volver a usarse ni liberarse.#[unsafe(no_mangle)]pub unsafe extern "C" fn pricing_free_string(label: *mut c_char) { if !label.is_null() { // SAFETY: el puntero lo creó CString::into_raw en pricing_label. drop(unsafe { CString::from_raw(label) }); }}extern "C"usa la convención de llamada de C;#[unsafe(no_mangle)]conserva el nombre de símbolopricing_add_vaten lugar de un nombre Rust decorado (mangled). En la edición 2024 es un atributo unsafe: dos bibliotecas que exportaran el mismo nombre entrarían en conflicto, y el compilador no puede comprobarlo.- Solo cruzan la frontera tipos compatibles con C: enteros, flotantes,
bool, punteros crudos, structs#[repr(C)]. Nada deString,VecniResult. - Quien asigna, libera. Una cadena asignada por Rust debe volver a Rust (
pricing_free_string), nunca aMarshal.FreeHGlobal.
Lado C# — [LibraryImport], el sucesor de [DllImport] basado en generación de código fuente:
using System.Runtime.InteropServices;
Console.WriteLine($"1000 cents + 20% VAT = {Native.AddVat(1000, 20)}");
double[] prices = [19.99, 5.0, 12.5];Console.WriteLine($"sum computed in Rust: {Native.Sum(prices, (nuint)prices.Length):F2}");
// Rust asignó esta cadena, así que Rust debe liberarlanint label = Native.Label("book", 4250);try{ Console.WriteLine(Marshal.PtrToStringUTF8(label));}finally{ Native.FreeString(label);}
static partial class Native{ // Se resuelve como pricing_ffi.dll en Windows, libpricing_ffi.so en Linux, libpricing_ffi.dylib en macOS private const string Lib = "pricing_ffi";
[LibraryImport(Lib, EntryPoint = "pricing_add_vat")] internal static partial ulong AddVat(ulong cents, uint percent);
[LibraryImport(Lib, EntryPoint = "pricing_sum")] internal static partial double Sum([In] double[] prices, nuint len);
[LibraryImport(Lib, EntryPoint = "pricing_label", StringMarshalling = StringMarshalling.Utf8)] internal static partial nint Label(string name, ulong cents);
[LibraryImport(Lib, EntryPoint = "pricing_free_string")] internal static partial void FreeString(nint label);}Las cadenas de .NET están en UTF-16; StringMarshalling.Utf8 las convierte al UTF-8 terminado en NUL que espera CStr. El double[] se fija en memoria y se pasa como puntero, sin copia.
El archivo de proyecto copia la biblioteca nativa junto al ejecutable. Compila primero la biblioteca Rust y después ejecuta la aplicación C#:
cd code\rust-for-csharp-java\l15-fficargo build --release # target\release\pricing_ffi.dlldotnet run --project dotnetcd code/rust-for-csharp-java/l15-fficargo build --release # target/release/libpricing_ffi.sodotnet run --project dotnetcd code/rust-for-csharp-java/l15-fficargo build --release # target/release/libpricing_ffi.dylibdotnet run --project dotnet1000 cents + 20% VAT = 1200sum computed in Rust: 37.49book: 42.50El mismo nombre de DllImport, pricing_ffi, funciona en todos los sistemas operativos: .NET añade el prefijo y la extensión de la plataforma cuando busca la biblioteca.
Escribir los dos lados a mano no escala. Hay herramientas que los generan: cbindgen escribe una cabecera C a partir de Rust, csbindgen escribe declaraciones DllImport de C#, y uniffi genera bindings para Kotlin, Swift y Python. Para Java, la Foreign Function & Memory API (java.lang.foreign, definitiva desde Java 22) sustituye a JNI para este tipo de llamadas.
Puntos clave
Sección titulada «Puntos clave»- Las macros se expanden en tiempo de compilación en código Rust comprobado;
macro_rules!hace coincidir sintaxis, y las macros procedurales son plugins del compilador que sobre todo consumes mediantederivey atributos. unsafedesbloquea cinco operaciones y nada más; el borrow checker sigue funcionando.- Envuelve los pequeños núcleos
unsafeen funciones seguras que hagan cumplir los invariantes, y documéntalos con comentariosSAFETYy secciones# Safety. - La FFI pasa por la ABI de C:
extern "C",#[unsafe(no_mangle)], tipos compatibles con C, y quien asigna libera. - Un
cdylibde Rust más[LibraryImport]es una forma práctica de usar Rust desde .NET en Windows, Linux y macOS.
Ejercicios
Sección titulada «Ejercicios»- Escribe una macro
strings!para questrings!["Ada", "Grace", 42]produzca unVec<String>(["Ada", "Grace", "42"]), ystrings![]uno vacío. Admite una coma final.
Solución
macro_rules! strings { ($($s:expr),* $(,)?) => { vec![$($s.to_string()),*] };}
let names: Vec<String> = strings!["Ada", "Grace", 42];assert_eq!(names, ["Ada", "Grace", "42"]);let empty: Vec<String> = strings![];assert!(empty.is_empty());$(,)? acepta una coma final opcional. Cualquier tipo que implemente Display tiene to_string(), así que el entero también funciona. Las macros pueden invocarse con (), [] o {}; [] solo hace que se parezca a vec!.
- Reescribe
split_first_restsinunsafe, de dos maneras: consplit_first_muty consplit_at_mut.
Solución
fn split_first_rest(values: &mut [i32]) -> Option<(&mut i32, &mut [i32])> { values.split_first_mut()}
fn split_first_rest_at(values: &mut [i32]) -> Option<(&mut i32, &mut [i32])> { if values.is_empty() { return None; } let (head, rest) = values.split_at_mut(1); Some((&mut head[0], rest))}
let mut scores = [10, 20, 30];if let Some((first, rest)) = split_first_rest(&mut scores) { *first += 1; rest[0] *= 2;}if let Some((first, rest)) = split_first_rest_at(&mut scores) { *first += 1; rest[1] *= 2;}assert_eq!(scores, [12, 40, 60]);assert!(split_first_rest(&mut []).is_none());El unsafe sigue existiendo, dentro de la biblioteca estándar, revisado y probado una sola vez para todos. La mayor parte del código de aplicación nunca necesita su propio bloque unsafe.
- Añade
pricing_average(prices, len, average)a la biblioteca FFI: escribe la media a través de un puntero de salida y devuelvefalsecuandolenes 0. Llámala desde C# con un parámetroout double.
Solución
/// Escribe la media de `len` precios en `*average`./// Devuelve `false`, sin tocar `*average`, cuando `len` es 0.////// # Safety////// `prices` debe apuntar a `len` valores `f64` inicializados y `average`/// debe ser un puntero válido a memoria en la que se pueda escribir.#[unsafe(no_mangle)]pub unsafe extern "C" fn pricing_average( prices: *const f64, len: usize, average: *mut f64,) -> bool { if prices.is_null() || average.is_null() || len == 0 { return false; } // SAFETY: lo garantiza quien llama, ver arriba. let prices = unsafe { std::slice::from_raw_parts(prices, len) }; unsafe { *average = prices.iter().sum::<f64>() / len as f64 }; true}if (Native.Average(prices, (nuint)prices.Length, out double average)){ Console.WriteLine($"average: {average:F2}");}Console.WriteLine($"average of nothing: {Native.Average([], 0, out _)}");
// en la clase Native:[LibraryImport(Lib, EntryPoint = "pricing_average")][return: MarshalAs(UnmanagedType.U1)]internal static partial bool Average([In] double[] prices, nuint len, out double average);average: 12.50average of nothing: FalseUn out double de C# se pasa como puntero, que es lo que recibe *mut f64. El bool de Rust ocupa un byte. [LibraryImport] se niega a adivinar cómo se serializa un bool — sin el atributo, la compilación falla con SYSLIB1051: Marshalling bool without explicit marshalling information is not supported — así que [return: MarshalAs(UnmanagedType.U1)] dice: un byte. (El antiguo [DllImport] suponía en silencio un BOOL de Win32 de 4 bytes.) Coloca la nueva función Rust encima del módulo #[cfg(test)]: el lint items_after_test_module de clippy rechaza los elementos colocados después de él.
Cómo seguir
Sección titulada «Cómo seguir»Esta ha sido la última lección del curso principal. La continuación, Rust en la práctica: IX y compañía, aplica estas ideas a código real del workspace de IX.
Fuentes
Sección titulada «Fuentes»- The Book, ch. 20.1 — Unsafe Rust y ch. 20.5 — Macros
- The Little Book of Rust Macros
- The Rustonomicon — la referencia para el código unsafe
- Edition Guide — los cambios de unsafe en Rust 2024
- The Rust Reference — los bloques
externy la ABI de C - .NET — generación de código fuente de P/Invoke (
LibraryImport) - Java — Foreign Function & Memory API