cv.cm

Programación · Lenguajes de programación

Tutorial de Zig para principiantes

8 min de lectura

Respuesta corta

Zig es un lenguaje de programación de sistemas de propósito general y una cadena de herramientas pensada como alternativa moderna a C. No tiene flujo de control oculto, ni recolector de basura, ni asignaciones de memoria ocultas: pasas un allocator de forma explícita, manejas los errores con uniones de error y try, y limpias con defer. La ejecución de código en tiempo de compilación (comptime) sustituye a las macros y a los genéricos. Ejecuta un archivo con zig run hello.zig. Zig todavía no ha llegado a la versión 1.0, así que los detalles del lenguaje y de la biblioteca estándar cambian entre versiones.

Qué es Zig y quién lo usa

Zig lo creó Andrew Kelley y lo desarrolla la Zig Software Foundation, una organización sin ánimo de lucro. Apunta al mismo trabajo que C: componentes de sistemas operativos, firmware embebido, motores de videojuegos, bases de datos y herramientas de línea de comandos rápidas. El compilador de Zig también puede compilar código C y C++ (zig cc) y compilar de forma cruzada para muchos destinos, así que algunos proyectos lo usan solo como cadena de herramientas de C.

Entre los proyectos conocidos escritos en Zig están el entorno de ejecución de JavaScript Bun, la base de datos financiera TigerBeetle y el emulador de terminal Ghostty.

Instala Zig y ejecuta Hola, mundo

Descarga un archivo precompilado para tu sistema desde ziglang.org/download, descomprímelo y añade la carpeta a tu PATH, o instala Zig con el gestor de paquetes de tu sistema. No hay un instalador ni un entorno de ejecución aparte.

// hello.zig
const std = @import("std");
pub fn main() void {
    std.debug.print("Hello, world!\n", .{});
}
zig run hello.zig         # compile and run
# Hello, world!
zig build-exe hello.zig   # produce an executable named hello
zig init                  # create a project with build.zig

std.debug.print escribe en la salida de error estándar y es la forma más sencilla de imprimir en todas las versiones recientes. El segundo argumento es una tupla de valores para los marcadores de formato; .{} es una tupla vacía. La API para escribir en la salida estándar ha cambiado entre versiones, así que consulta la documentación de la biblioteca estándar de tu versión.

Variables y tipos básicos

const declara un valor que nunca cambia y var declara una variable mutable. El compilador es estricto: una variable local sin usar es un error, y un var que nunca se modifica también lo es, así que usa const por defecto.

TipoEjemploNotas
Enteros con signoi8, i32, i64Se permite cualquier ancho, p. ej. i7
Enteros sin signou8, u32, usizeusize tiene el tamaño de un puntero y se usa para longitudes e índices
Flotantesf32, f64IEEE 754
booltrue, falseSin conversión implícita desde enteros
Arreglo[3]u8Longitud fija conocida en tiempo de compilación
Slice[]const u8Puntero más longitud; las cadenas son slices de bytes
Opcional?i32Un valor o null
Unión de error!u32Un valor o un error
const std = @import("std");
pub fn main() void {
    const age: u32 = 30;
    var count: i32 = 0;
    count += 1;
    const name = "Ana"; // string literal, coerces to []const u8
    std.debug.print("{s} is {d}, count = {d}\n", .{ name, age, count });
    var maybe: ?i32 = null;
    maybe = 5;
    const value = maybe orelse 0;
    std.debug.print("value = {d}\n", .{value});
}

Los marcadores de formato indican cómo imprimir: {d} para números, {s} para cadenas, {c} para un solo byte como carácter y {any} para depurar cualquier valor.

Control de flujo

if, switch, while y for pueden usarse todos como expresiones. switch debe cubrir todos los valores posibles, por lo que normalmente añades una rama else.

const std = @import("std");
pub fn main() void {
    const n: i32 = -2;
    const sign: []const u8 = if (n > 0) "positive" else if (n < 0) "negative" else "zero";
    const score: u8 = 85;
    const letter: u8 = switch (score) {
        90...100 => 'A',
        80...89 => 'B',
        else => 'C',
    };
    std.debug.print("{s} {c}\n", .{ sign, letter }); // negative B
    var i: u32 = 0;
    while (i < 3) : (i += 1) {
        std.debug.print("{d} ", .{i}); // 0 1 2
    }
    const items = [_]u8{ 10, 20, 30 };
    for (items, 0..) |item, index| {
        std.debug.print("\n{d}: {d}", .{ index, item });
    }
    std.debug.print("\n", .{});
}

[_]u8{ ... } permite que el compilador cuente la longitud del arreglo. for (items, 0..) recorre los elementos y un contador a la vez; los valores entre barras verticales se llaman capturas.

Funciones y comptime

Las funciones declaran los tipos de los parámetros y el tipo de retorno. Los argumentos son inmutables dentro de la función. El código genérico se escribe con parámetros comptime: un tipo es un valor que existe en tiempo de compilación, así que una función puede recibir un tipo como argumento.

const std = @import("std");
fn add(a: i32, b: i32) i32 {
    return a + b;
}
fn max(comptime T: type, a: T, b: T) T {
    return if (a > b) a else b;
}
pub fn main() void {
    std.debug.print("{d}\n", .{add(2, 3)});            // 5
    std.debug.print("{d}\n", .{max(i32, 3, 7)});       // 7
    std.debug.print("{d}\n", .{max(f64, 1.5, 0.5)});   // 1.5
}

El compilador crea una versión separada de max para cada tipo con el que se llama. El mismo mecanismo se usa para construir estructuras de datos genéricas en la biblioteca estándar: una función que recibe un tipo y devuelve un nuevo tipo struct.

Manejo de errores con uniones de error, try y defer

Los errores son valores. Un tipo de retorno !u32 significa «un u32 o un error». try expr devuelve el error al llamador si lo hay; catch lo trata en el acto. Los errores no pueden ignorarse en silencio.

const std = @import("std");
fn parseAge(s: []const u8) !u32 {
    const n = try std.fmt.parseInt(i32, s, 10);
    if (n < 0) return error.Negative;
    return @intCast(n);
}
pub fn main() void {
    const age = parseAge("-5") catch |err| {
        std.debug.print("error: {s}\n", .{@errorName(err)});
        return;
    };
    std.debug.print("age {d}\n", .{age});
}
// error: Negative

defer ejecuta una instrucción cuando el bloque actual termina, y errdefer la ejecuta solo cuando el bloque termina con un error. Mantienen el código de limpieza junto al código que adquirió el recurso.

Memoria y allocators

Zig no tiene recolector de basura ni un allocator global oculto. Cualquier función que necesite memoria del heap recibe un parámetro std.mem.Allocator, de modo que quien llama decide de dónde sale la memoria (el page allocator, un arena, un búfer fijo o un allocator de depuración que detecta fugas).

const std = @import("std");
pub fn main() !void {
    const allocator = std.heap.page_allocator;
    const buf = try allocator.alloc(u32, 5);
    defer allocator.free(buf);
    for (buf, 0..) |*slot, i| {
        slot.* = @intCast(i * i);
    }
    std.debug.print("{any}\n", .{buf}); // { 0, 1, 4, 9, 16 }
}

|*slot| captura un puntero a cada elemento para que el bucle pueda escribir en él, y slot.* desreferencia el puntero. En las compilaciones Debug y ReleaseSafe, el desbordamiento de enteros y la indexación fuera de rango detienen el programa con un panic claro en lugar de causar comportamiento indefinido.

Un programa pequeño y completo: promedio de números

const std = @import("std");
fn average(numbers: []const i64) ?f64 {
    if (numbers.len == 0) return null;
    var sum: i64 = 0;
    for (numbers) |n| sum += n;
    const total: f64 = @floatFromInt(sum);
    const len: f64 = @floatFromInt(numbers.len);
    return total / len;
}
pub fn main() !void {
    const allocator = std.heap.page_allocator;
    const buf = try allocator.alloc(i64, 16);
    defer allocator.free(buf);
    var it = std.mem.tokenizeScalar(u8, "4 8 15 16 23 42", ' ');
    var count: usize = 0;
    while (it.next()) |word| : (count += 1) {
        buf[count] = try std.fmt.parseInt(i64, word, 10);
    }
    if (average(buf[0..count])) |avg| {
        std.debug.print("count={d} avg={d:.2}\n", .{ count, avg });
    }
}
// count=6 avg=18.00

El programa divide una cadena por espacios, analiza cada trozo con try, guarda los números en memoria del heap que libera defer y devuelve un promedio opcional que es null para una lista vacía.

Siguientes pasos

  • La referencia del lenguaje en ziglang.org/documentation, eligiendo la página de la versión que tienes instalada.
  • La sección Learn de ziglang.org, incluidos «Getting Started» y una visión general del lenguaje.
  • Ziglings, un conjunto de pequeños programas rotos que se arreglan uno por uno, es un conjunto de ejercicios muy popular en la comunidad.
  • Lee el código fuente de la biblioteca estándar: está escrita en Zig y es la mejor referencia de las APIs actuales.

Preguntas frecuentes

¿Zig está listo para producción?

Algunas empresas usan Zig en producción, pero el lenguaje está en fase previa a la 1.0, así que debes esperar cambios incompatibles al actualizar el compilador y reservar tiempo para ellos.

¿Zig es un reemplazo de C o de Rust?

Zig se parece más a C: gestión manual de memoria con mejores comprobaciones de seguridad, manejo de errores y código en tiempo de compilación. Rust garantiza la seguridad de memoria mediante su sistema de propiedad (ownership) y su borrow checker, que Zig no tiene.

¿Zig puede usar bibliotecas de C?

Sí. Zig puede importar archivos de cabecera de C y llamar directamente a funciones de C, y el compilador de Zig puede compilar archivos fuente de C y C++, lo que hace sencillo mezclar los lenguajes.

¿Qué significa comptime en Zig?

comptime marca el código o los parámetros que se evalúan durante la compilación. Zig lo usa para genéricos, tablas constantes y comprobaciones en tiempo de compilación en lugar de un lenguaje de macros aparte.

Todos los tutoriales de programación