Usando funciones Zig desde Python
Python resulta excelente para prototipar rápidamente y su ecosistema es extenso, sin embargo, cuando se requiere velocidad real, los lenguajes compilados ofrecen ventajas significativas. Python proporciona una excelente interoperabilidad con C, y Zig puede compartir la misma interfaz binaria de aplicación (ABI) de C, lo que permite establecer esta conexión.
Esta compatibilidad permite escribir las partes críticas en rendimiento en Zig y exponerlas como si fueran funciones Python normales. Zig ofrece características que C no proporciona de forma nativa: gestión de memoria más explícita, ejecución en tiempo de compilación, y un manejo de errores que obliga a considerar todos los casos de fallo.
Es importante aclarar desde el principio: lo acá expuesto no es la única
forma de conectar Python con Zig. Hay diferentes enfoque, que tienen sus
ventajas y desventajas. este artículo muestra una manera directa usando
la FFI de C y la libraría ctypes de Python, lo que tiene la ventaja de
ser clara y no requerir dependencias adicionales. Lo importante es
entender el concepto; una vez comprendido, puedes aplicarlo
método con otras variantes y a diferentes problemas.
Se construirá un cliente HTTP simple para ilustrar el proceso. No es trivial. Hay varios pasos y detalles que requieren atención. Pero siguiendo este mismo procedimiento puedes integrar prácticamente cualquier funcionalidad de Zig en Python. Se verá cómo crear funciones Zig, envolverlas para que C (y Python) las entiendan, y manejar detalles como el manejo de la memoria, las cadenas de carácteres y los errores que transitan entre lenguajes.
La función request en Zig
Se presenta la implementación directa. Se requiere una función que realice peticiones HTTP GET y devuelva la respuesta en un formato que C (y por ende Python) pueda entender. La firma de la función resulta reveladora:
fn request(allocator: std.mem.Allocator, url: [:0]const u8) ![:0]u8
Dos parámetros: primero allocator, que representa la forma de Zig de
hacer la asignación de memoria. No hay llamadas malloc() ocultas—todo es
explícito. Segundo, url, que es un puntero terminado en cero binario
([:0]const u8). Zig tiene su propia forma de representar esto, pero
equivale a const char en C.
El tipo de retorno ![:0]u8 merece atención. El ! inicial indica que
esto puede fallar. Puedes obtener un puntero con la respuesta HTTP, o
puedes obtener un error. El [:0]u8 representa el resultado exitoso:
bytes con un cero al final, exactamente lo que C espera.
La implementación utiliza la API HTTP de Zig 0.15 y un
ArrayListUnmanaged para acumular la respuesta byte por byte:
const std = @import("std");
fn request(allocator: std.mem.Allocator, url: [:0]const u8) ![:0]u8 {
const len = std.mem.len(url);
const url_slice = url[0..len];
const uri = try std.Uri.parse(url_slice);
var client = std.http.Client{ .allocator = allocator };
defer client.deinit();
var req = try client.request(.GET, uri, .{});
defer req.deinit();
try req.sendBodiless();
var redirect_buffer: [4096]u8 = undefined;
var response = try req.receiveHead(&redirect_buffer);
var list = std.ArrayListUnmanaged(u8){};
errdefer list.deinit(allocator);
var reader = response.reader(&.{});
try reader.appendRemainingUnlimited(allocator, &list);
try list.append(allocator, 0);
const owned = try list.toOwnedSlice(allocator);
return @ptrCast(owned.ptr);
}
Esta implementación evita copias de datos innecesarias.
Nótese que acepta directamente [:0]const u8—el mismo formato en que
vienen las cadenas desde C. El proceso es:
- Convierte el puntero a un slice normal para interpretar la URL.
- Parsing de la URL:
std.Uri.parse - Crea el cliente HTTP con el asignador proporcionado
- Construye una petición GET con
client.request - Al servidor:
sendBodiless()la envía sin cuerpo - Respuesta:
receiveHead()lee los headers (con espacio para redirecciones) - Acumulando datos: Lee todo el cuerpo con
ArrayListUnmanaged—eficiente porque crece según necesita - El terminador null: Agrega un cero al final (requisito de C)
- Devuelve un puntero al resultado
Un aspecto destacable de Zig: defer y errdefer. El primero indica
"al salir de esta función, sin importar cómo, limpia esto". El segundo
indica "si ocurre un error, limpia esto". Cada try representa un punto
donde puede fallar algo; si falla, el error se propaga inmediatamente.
Ciertamente parace mucho código, pero resulta claro qué puede fallar y
dónde.
Se puede verificar el funcionamiento con el sistema de testing de Zig:
test "Request" {
const allocator = std.testing.allocator;
const url = "http://localhost";
const response = try request(allocator, url.ptr);
defer {
const len = std.mem.len(response) + 1;
allocator.free(response[0..len]);
}
try std.testing.expect(std.mem.len(response) > 0);
}
Obsérvese cómo se libera la memoria: primero se calcula la longitud
(incluyendo el cero final), luego se convierte a slice, y finalmente se
pasa a allocator.free().
Guardando esto en request.zig se puede ejecutar:
$ zig test request.zig
All 1 tests passed.
Nota: se requiere un servidor HTTP ejecutándose en localhost para que
esta prueba funcione. El testing.allocator es especial; detecta cuando
se olvida liberar memoria. Resulta muy útil para detectar memory leaks
tempranamente.
Los wrappers: hablando el idioma de C
Se presenta ahora la parte donde se hace que Zig hable C. Las funciones Zig son potentes, pero Python no puede llamarlas directamente. Se requiere un traductor—funciones wrapper que C (y por ende Python) pueda entender.
En este caso request() ya devuelve [:0]u8, que es
exactamente lo que C espera. Sin embargo, se necesitan algunos ajustes:
- Gestión de memoria: Proporcionar a Python una forma explícita de liberar lo asignado
- Errores: Convertir las uniones de error de Zig a algo que C entienda (básicamente, en este contexto NULL significa "falló")
Las firmas resultan así:
export fn request_wrapper(url: [:0]const u8) ?[:0]u8
export fn request_deallocate(result: [:0]u8) void
[:0]const u8 es un puntero a bytes terminado en null (equivalente a
const char). El ? antes del tipo de retorno significa
"opcional", puede ser un puntero válido o null. La palabra clave
export instruye al compilador a generar símbolos que C pueda enlazar.
La implementación de request_wrapper:
export fn request_wrapper(url: [:0]const u8) ?[:0]u8 {
const allocator = std.heap.page_allocator;
return request(allocator, url) catch return null;
}
Obsérvese lo directo del enfoque. Como request() ya acepta y devuelve
el formato correcto, no se requieren conversiones complejas:
- Cero conversiones extra:
request()trabaja con[:0]const u8directamente - Cero copias: Llamada directa, sin buffers intermedios
- Una sola asignación: La que realiza
request()internamente - Errores simples:
catch return nullconvierte cualquier error en NULL
La función para liberar memoria es crítica:
export fn request_deallocate(result: [:0]u8) void {
const allocator = std.heap.page_allocator;
const len = std.mem.len(result) + 1;
allocator.free(result[0..len]);
}
Debe usar el mismo page_allocator utilizado para asignar (esto es
importante—no se pueden mezclar allocators). Calcula la longitud total
(incluyendo el cero), convierte el puntero a slice, y finalmente libera.
Esto proporciona un medio y control sobre cuándo liberar la memoria, que
es esencial cuando se cruzan fronteras entre lenguajes.
Se muestra un test para los wrappers:
test "Wrappers" {
const url = "http://localhost";
const body = request_wrapper(url.ptr);
try std.testing.expect(std.mem.len(body.?) > 0);
request_deallocate(body.?);
}
Si todo funciona correctamente:
$ zig test request.zig
All 2 tests passed.
Probando desde C primero
Antes de integrar con Python, conviene verificar que todo funciona desde C. Es más sencillo depurar aquí que cuando hay tres capas involucradas. Se crea un header que C entienda:
#ifndef _REQUEST_H
#define _REQUEST_H 0
char request_wrapper(const char* url);
void request_deallocate(char* content);
#endif // _REQUEST_H
Simple: [:0]const u8 de Zig se traduce a const char* en C, [:0]u8
se traduce a char*. Los tipos opcionales de Zig desaparecen. En C solo
existe el puntero y debes verificar NULL manualmente.
Un programa de prueba en C:
#include <stdio.h>
#include <stdlib.h>
#include "request.h"
int main(int argc, char argv[]) {
if (argc < 2) {
fprintf(stderr, "Usage: %s URL\n", argv[0]);
exit(EXIT_FAILURE);
}
const char url = argv[1];
char content = request_wrapper(url);
if (!content) {
printf("Failed\n");
exit(EXIT_FAILURE);
}
printf("%s\n", content);
request_deallocate(content);
return 0;
}
El patrón clásico de C: se llama request_wrapper(), se verifica que no
sea NULL, se usa el resultado, y luego se llama request_deallocate()
para liberar memoria. Si se omite este último paso, habrá memory leaks.
Compilar y ejecutar:
$ zig build-lib -dynamic request.zig
$ gcc example.c -L. -lrequest -o example
$ export LD_LIBRARY_PATH=.:$LD_LIBRARY_PATH
$ ./example http://localhost
<!DOCTYPE html>
<html>
<head>
<title>Welcome to nginx!</title>
...
Zig genera una shared library (.so en Linux, .dylib en macOS,
.dll en Windows). El -dynamic indica que se desea una biblioteca
compartida y no estática. Luego gcc enlaza el programa C contra la
biblioteca Zig recién creada.
Finalmente: Python
Se presenta ahora la integración con Python. Se envolverá la biblioteca
Zig para que aparezca como cualquier módulo Python. Se utiliza ctypes,
que viene incluido con Python y permite cargar shared libraries y llamar
sus funciones. El desafío está en declarar correctamente los tipos y no
olvidar liberar la memoria.
Una clase Request que encapsula toda la complejidad:
import ctypes
class Request:
def __init__(self):
self.lib = ctypes.CDLL("./librequest.so")
self.lib.request_wrapper.argtypes = [ctypes.c_char_p]
self.lib.request_wrapper.restype = ctypes.POINTER(ctypes.c_char)
def get(self, url: str) -> str:
result = self.lib.request_wrapper(url.encode())
if not result:
raise RuntimeError("Request failed")
i = 0
while result[i] != b'\0':
i += 1
content = result[:i].decode()
self.lib.request_deallocate(result)
return content
Análisis de cada parte:
En __init__: Se carga la biblioteca con CDLL (para funciones que
siguen la convención de llamadas de C). Luego se declaran las
firmas:
argtypes = [ctypes.c_char_p]: la función espera una cadena Crestype = ctypes.POINTER(ctypes.c_char): devuelve un puntero a caracteres
En get: Se maneja todo el ciclo:
- Se convierte la string de Python a bytes (
encode())—ctypes sabe pasar esto como cadena C - Se llama la función Zig y se verifica NULL
- Se busca manualmente dónde termina la cadena (el byte
\0) - Se extraen los bytes y se decodifican a string Python
- Crítico: se llama
request_deallocate()para liberar la memoria
La recompensa, usar esto desde Python:
import request
req = request.Request()
body = req.get("http://localhost")
print(body)
Toda la complejidad del FFI está encapsulada. Para quien lo usa, es simplemente otra libraría de Python.
Conclusión
Se ha conectado Python con Zig usando C como puente. No es mágico, y definitivamente no es trivial—hay varios pasos, tipos que convertir, memoria que manejar con cuidado. Pero una vez comprendido el proceso, es replicable. Los mismos pasos seguidos aquí aplican a cualquier función que desees exponer: procesamiento de imágenes, algoritmos de ML, manejo de protocolos binarios, etc.
Vale la pena recordar: este es un camino, no el camino. Existen otras formas de hacer que Python y Zig se comuniquen, cada una con sus trade-offs. Esta tiene la ventaja de ser relativamente directa y no depender de dependencias externas.
Si deseas extender este ejemplo, podrías:
- Agregar más métodos HTTP (POST, PUT, DELETE)—el patrón es el mismo
- Exponer opciones de configuración (timeouts, headers, auth)
- Integrar algoritmos computacionalmente intensivos escritos en Zig
- Envolver librarías de Zig que interactúan con el sistema operativo
Aspectos clave a recordar:
- La memoria es tu responsabilidad: Cuando cruzas entre lenguajes, debes saber quién asignó qué y quién debe liberarlo. Zig te obliga a ser explícito, lo que ayuda a evitar leaks, pero también requiere disciplina.
- Los tipos importan: Cada lenguaje representa cadenas y arrays a su manera. Las conversiones deben ser exactas o todo falla.
- Errores en traducción: Los errores de Zig no son los errores de C. Debes traducir entre sistemas—aquí se usó NULL, pero existen otras aproximaciones.
- Testear en capas: Primero Zig solo, luego desde C, finalmente desde Python. Así se identifica dónde está el problema cuando algo falla.
Esto se puede aplicar con HTTP, pero también con cualquier otra funcionalidad. Zig está madurando, y es probable ver más bibliotecas que combinan lo mejor de ambos mundos: la expresividad de Python con la velocidad y el control de bajo nivel de Zig.
Recursos
Acá se presenta el enlace a una librería para la integración entre Zig y Python:
Nota: Este artículo cuenta con una tradicción al inglés.