¡Hola a todos! 🙌 ¡Vamos a sumergirnos en uno de los conceptos más apasionantes y poderosos de Zig: el comptime
! Esta característica, si no has oído hablar de ella, puede cambiar completamente tu perspectiva sobre cómo escribir código. 😮
¿Qué es comptime?
El término comptime
viene de “Compile Time” que, traducido al español, sería “Tiempo de Compilación”. En realidad has usado esa funcionalidad muchas veces, en su forma más básica, cada vez que utilizas una constante. En Zig, puedes decidir qué parte de tu código se ejecutará en tiempo de compilación y cuál en tiempo de ejecución. 🕓
Principales ventajas de comptime
Quizás te estés preguntando, ¿por qué querríamos hacer cálculos en tiempo de compilación?
-
Flexibilidad: Gracias a
comptime
, puedes generar código en tiempo de compilación de forma dinámica. Esto significa que puedes decidir cómo se comportará tu programa en función de las condiciones existentes durante la compilación. 😏 -
Eficiencia: Al mover ciertas operaciones al tiempo de compilación, puedes mejorar significativamente la eficiencia de tu código en tiempo de ejecución. En lugar de calcular algo cada vez que se ejecuta tu programa, lo calculas una vez durante la compilación y ya está. Es como preparar toda la comida de antemano para que, cuando lleguen los invitados, ¡solo tengas que encender el fuego! 🍳⏲️
-
Seguridad:
comptime
puede ayudarte a detectar errores en tiempo de compilación en lugar de en tiempo de ejecución. Cuanto antes detectes los errores, más segura será tu aplicación. 💪 -
Abstracción sin costo: A diferencia de otros lenguajes que pagan un precio por abstracciones de alto nivel (con rendimiento más lento o más memoria usada), Zig permite abstracciones sin costos adicionales, gracias al poder de
comptime
.🎉
Ejemplo básico de uso de comptime
En su forma más básica comptime
es la forma en la que una variable se calcula en tiempo de compilación para convertirse en una constante en tiempo de ejecución. Aquí hay un pequeño fragmento de código para que te hagas una idea de cómo se utiliza comptime
.
⚠️ ¡Atención, programador! Este post utiliza la versión 0.11.0-dev.3971 de Zig
fn suma() u64 {
var r: u64 = 0;
for (1..100) |i| {
r += i;
}
return r;
}
pub fn main() void {
var s = comptime suma();
std.debug.print("{}", .{s});
sumaB();
}
Cuando compiles este código, verás que suma
se calcula en tiempo de compilación y su resultado es simplemente una constante. En tiempo de ejecución, este bloque comptime
no tiene ningún efecto.
example.main:
push rbp
mov rbp, rsp
sub rsp, 16
mov qword ptr [rbp - 8], 4950
call debug.print__anon_3471
add rsp, 16
pop rbp
ret
Explicación del código
Este código es esamblador de arquitectura x86-64 (o AMD64):
-
push rbp: Guarda el valor actual del puntero base del marco (frame base pointer) en la pila. Esto se hace generalmente al inicio de una función para preservar el valor actual de rbp para poder restaurarlo más tarde.
-
mov rbp, rsp: Establece el puntero base del marco al valor actual del puntero de la pila (stack pointer). Esto se hace para que la función tenga un punto de referencia fijo para acceder a las variables locales y los argumentos.
-
sub rsp, 16: Reserva 16 bytes en la pila para las variables locales de esta función, moviendo el puntero de la pila hacia abajo (en la mayoría de las arquitecturas, la pila crece hacia abajo en la memoria).
-
mov qword ptr [rbp - 8], 4950: Guarda el valor 4950 en la ubicación de la pila 8 bytes por debajo del puntero base del marco. Probablemente esto está inicializando una variable local.
-
call debug.print__anon_3471: Llama a la función
debug.print__anon_3471
. Esta función pinta el valor de la variable local que acabamos de inicializar. -
add rsp, 16: Restablece el puntero de la pila a su posición antes de que reserváramos espacio para las variables locales. Esto efectivamente “libera” ese espacio de la pila.
-
pop rbp: Restaura el puntero base del marco a su valor antes de la llamada a la función, esencialmente limpiando después de que la función haya terminado.
-
ret: Retorna de la función, saltando de vuelta a la dirección de retorno almacenada en la pila por la instrucción
call
que llamó a esta función.
Otra forma de asignar un cálculo en comptime
a una variable o constate es usando break :etiqueta valor
en un bloque:
var s = comptime e: {
var r: u64 = 0;
for (1..100) |i| {
r += i;
}
break :e r;
};
¿Pero que pasaría si nos encontramos un bucle demasiado largo en tiempo de compilación? Comptime tiene un limitador de la cantidad de ramificaciones que se pueden evaluar en tiempo de compilación, lo cual es útil para prevenir bucles infinitos y otros problemas que pueden consumir una cantidad excesiva de recursos durante la compilación. Se puede cambiar el límite con la función @setEvalBranchQuota()
, que por defecto es 1000
.
Conviene recordarlo si nos encontramos con un error similar a este compilando:
error: evaluation exceeded 1000 backwards branches
note: use @setEvalBranchQuota() to raise the branch limit from 1000
Comparación con tiempo de ejecución
Entonces, ¿cómo se compara comptime
con los cálculos en tiempo de ejecución tradicionales? Cuando un programa Zig se está ejecutando, a menudo hay decisiones que tomar, como elegir qué ramas de código ejecutar. Normalmente, esto ocurre en tiempo de ejecución, pero con comptime
, estas decisiones se pueden tomar en tiempo de compilación. Es como resolver la mayor parte del rompecabezas antes de que empiece a correr el reloj. ⏳🧩
Aplicaciones prácticas de comptime
De acuerdo, la teoría es buena, ¡pero veamos comptime
en acción! Te guiaré a través de algunos ejemplos prácticos donde comptime
puede brillar, como cuando trabajas con metaprogramación o programación genérica. ¡Aquí es donde comptime
pasa de ser simplemente ‘genial’ a ‘absolutamente fantástico’! 🌟🔧
Qué es @compileLog
En Zig, @compileLog
es una función incorporada que te permite registrar mensajes durante el tiempo de compilación. Es una herramienta maravillosa para la depuración o para entender lo que está sucediendo en tiempo de compilación. Aquí tienes un ejemplo rápido:
comptime {
const a = 5;
@compileLog("Valor de a en tiempo de compilación: ", a);
}
En el código anterior, @compileLog
imprimirá el mensaje “Valor de a en tiempo de compilación: 5” durante el proceso de compilación. ¡Esto puede ser muy útil cuando estás lidiando con expresiones o funciones comptime
complejas! 🤔💭
Hay que hace notar que, a día de hoy, la compilación fallará si se ha ejecutado cualquier @compileLog
. La razón es evitar que las librerías polucionen de mensajes la compilación de un programa.
Qué es @compileError
@compileError
es otra función incorporada en Zig que se utiliza para lanzar un error en tiempo de compilación. Esto es súper útil para asegurar ciertas condiciones durante el tiempo de compilación y detener la compilación si no se cumplen. Así es cómo puedes usarlo:
comptime {
if (!std.builtin.target.isWasm()) {
@compileError("WasmPageAllocator is only available for wasm32 arch");
}
}
En este ejemplo, si la arquitectura en la se está intentando compilar el programa no es la adecuada se lanzará un error de compilación con @compileError
💥🛑
Comptime para metaprogramación
La metaprogramación se refiere a la capacidad de un programa para tratar su código como datos y manipularlo. En Zig, comptime
proporciona una forma poderosa de lograr la metaprogramación. Por ejemplo, puedes generar funciones especializadas para diferentes tipos:
fn createAdder(comptime T: type) type {
return struct {
fn add(a: T, b: T) T {
return a + b;
}
};
}
const IntAdder = createAdder(i32);
const FloatAdder = createAdder(f32);
var i = IntAdder.add(10, 20);
var f = FloatAdder.add(1.0, 2.0);
En el código anterior, estamos generando dos estructuras en tiempo de compilación: IntAdder
y FloatAdder
. Cada una tiene una función add
, pero trabajan con tipos diferentes (i32
y f32
). ¡Esto es solo la punta del iceberg cuando se trata de metaprogramación con comptime
! 🧠🔮
Por ejemplo, podemos crear estructuras completas en tiempo de compilación:
fn makeType(comptime n: usize) type {
var fields: [n]std.builtin.Type.StructField = undefined;
inline for (0..n) |v| {
const default_value: u32 = v;
fields[v] = .{
.name = std.fmt.comptimePrint("field{}", .{v}),
.type = @TypeOf(default_value),
.default_value = @ptrCast(*const anyopaque, &default_value),
.is_comptime = false,
.alignment = @alignOf(@TypeOf(default_value)),
};
}
return @Type(.{ .Struct = .{
.layout = .Auto,
.fields = fields[0..],
.decls = &.{},
.is_tuple = false,
} });
}
const T2 = comptime makeType(2);
std.debug.print("{any}\n", .{T2{}});
const T3 = comptime makeType(3);
std.debug.print("{any}\n", .{T3{}});
sample.makeType(2){ .field0 = 0, .field1 = 1 }
sample.makeType(3){ .field0 = 0, .field1 = 1, .field2 = 2 }
Detalles importantes en este código:
-
La matriz
fields
tiene un tamaño fijo determinado en tiempo de compilación. -
El bucle
inline for
itera sobre el rango0..n
. Es por tanto un bucle que se desenrrolla. ¿Crees que es obligatorio este desenrollado? ¿En necesario?
Comptime y asignación de memoria
Lo ideal sería poder utilizar todas las estructuras de datos disponibles en Zig para poder ejecutar cálculos en tiempo de compilación. Aunque de momento no existe en la librería estándar un asignador de memoria (allocator) de Zig en para tiempo de compilación, podemos implementar el nuestro propio con facilidad. Así, con un poco de cuidado, podremos usar en comptime, de forma auxilia,r estructuras como std.ArrayList
:
fn makeSlice(allocator: std.mem.Allocator) ![]u8 {
var list = std.ArrayList(u8).init(allocator);
defer list.deinit();
try list.append(1);
try list.append(2);
return try list.toOwnedSlice();
}
const array = comptime e: {
var buffer: [1024]u8 = undefined;
var cfba = ComptimeFixedBufferAllocator.init(&buffer);
const v = makeSlice(cfba.allocator()) catch {
@compileError("allocation error, increase fixed buffer size");
};
break :e v[0..].*;
};
std.debug.print("{any}\n", .{array});
⚠️ ¡Atención, programador! Ten en cuenta que siempre debes transformar los slices en matrices constantes para evitar incrustar todo el buffer de memoria del allocator en el programa compilado final, fíjate en
break :e v[0..].*;
. Esto es importante.
Ver código de ejemplo de un allocator para comptime
Es básicamente una copia de std.heap.FixedBufferAllocator
. Por cierto, ¿por qué crees que no podemos usarlo en comptime?:
pub const ComptimeFixedBufferAllocator = struct {
end_index: usize,
buffer: []u8,
pub fn init(buffer: []u8) ComptimeFixedBufferAllocator {
return ComptimeFixedBufferAllocator{
.buffer = buffer,
.end_index = 0,
};
}
pub fn allocator(self: *ComptimeFixedBufferAllocator) std.mem.Allocator {
return .{
.ptr = self,
.vtable = &.{
.alloc = alloc,
.resize = resize,
.free = free,
},
};
}
pub fn isLastAllocation(self: *ComptimeFixedBufferAllocator, buf: []u8) bool {
return buf.ptr + buf.len == self.buffer.ptr + self.end_index;
}
fn alloc(ctx: *anyopaque, n: usize, log2_ptr_align: u8, ra: usize) ?[*]u8 {
const self = @ptrCast(*ComptimeFixedBufferAllocator, @alignCast(@alignOf(ComptimeFixedBufferAllocator), ctx));
_ = ra;
const ptr_align = @as(usize, 1) << @intCast(std.mem.Allocator.Log2Align, log2_ptr_align);
const adjust_off = std.mem.alignPointerOffset(self.buffer.ptr + self.end_index, ptr_align) orelse return null;
const adjusted_index = self.end_index + adjust_off;
const new_end_index = adjusted_index + n;
if (new_end_index > self.buffer.len) return null;
self.end_index = new_end_index;
return self.buffer.ptr + adjusted_index;
}
fn resize(
ctx: *anyopaque,
buf: []u8,
log2_buf_align: u8,
new_size: usize,
return_address: usize,
) bool {
const self = @ptrCast(*ComptimeFixedBufferAllocator, @alignCast(@alignOf(ComptimeFixedBufferAllocator), ctx));
_ = log2_buf_align;
_ = return_address;
if (!self.isLastAllocation(buf)) {
if (new_size > buf.len) return false;
return true;
}
if (new_size <= buf.len) {
const sub = buf.len - new_size;
self.end_index -= sub;
return true;
}
const add = new_size - buf.len;
if (add + self.end_index > self.buffer.len) return false;
self.end_index += add;
return true;
}
fn free(
ctx: *anyopaque,
buf: []u8,
log2_buf_align: u8,
return_address: usize,
) void {
const self = @ptrCast(*ComptimeFixedBufferAllocator, @alignCast(@alignOf(ComptimeFixedBufferAllocator), ctx));
_ = log2_buf_align;
_ = return_address;
if (self.isLastAllocation(buf)) {
self.end_index -= buf.len;
}
}
pub fn reset(self: *ComptimeFixedBufferAllocator) void {
self.end_index = 0;
}
};
Recuerda que aunque comptime
nos permite realizar algunos trucos poderosos, no es una solución milagrosa. Es simplemente otra herramienta en nuestro kit de herramientas Zig que, cuando se usa adecuadamente, puede conducir a un código más limpio, eficiente y robusto. ¡Pero como siempre, con un gran poder viene una gran responsabilidad! Así que usemos comptime
sabiamente…
Limitaciones de comptime
️🕸️ Hay algunos posibles problemas al usar comptime
de los que debes estar al tanto 😅. Los principales son:
-
Sin operaciones de I/O: Durante el tiempo de compilación, no puedes realizar ninguna operación de entrada/salida. Así es, no puedes leer archivos, escribir en la consola, comunicarte a través de la red, ¡nada! Recuerda,
comptime
opera en una máquina abstracta idealizada sin acceso al mundo exterior. 🌐🔒 -
Sin acceso a variables de entorno: De manera similar,
comptime
no tiene acceso a las variables de entorno en tiempo de ejecución. Esto incluye cosas como la hora del sistema, el identificador del proceso o las variables de entorno específicas del usuario. ¡Todo se trata del código, amigos! 🖥️🚫 -
Asignación de memoria: Hablamos antes de cómo
comptime
gestiona la memoria y la falta de un allocator para comptime. Es importante recordar quecomptime
y el tiempo de ejecución son dos dominios diferentes. 🧙♂️🏰
¡Y ya está, amigos!. Un profundo viaje al mundo mágico de comptime
en Zig. Espero que hayas disfrutado nuestro recorrido y que ahora estés tan entusiasmado con comptime
como yo. Recuerda, el aprendizaje es un proceso continuo, así que nunca dejes de explorar y experimentar con Zig. ¡Sigue codificando con pasión y creatividad! 🚀💻