Zig: mis primeros pasos

Zig: el lenguaje que promete competir con C (o quizás ya está en ello)

Zig logo
Logo del lenguaje Zig

Estos días he estado jugueteando con este lenguaje completamente desconocido por mi, salvo unas menciones sobre su uso en Bun (que ahora fue reemplazado por Rust tras decisiones cuestionables a nivel de ingenieria de software, pero eso es otro tema). Su premisa es simple: ser un C moderno, rápido, más sencillo y que solucione los problemas históricos que a tantos devs les causó dolores de cabeza, asi que si, Zig entra en el abanico de opciones para programar “cerca del metal”, proponiendo nuevas ideas frente a C, C++ y Rust que serian otras alternativas.

¿Por qué escogí aprenderlo?

Pues dentro de las limitaciones y frustraciones de hardware que tengo, mi anhelo por buscar siempre la máxima optimización y rendimiento en el software me llevó irremediablemenete a Zig, ¿y por qué no C? lo cierto es que he intentado aprender C en varias ocasiones y por algún motivo nunca profundicé demasiado en él. Probablemente si ahora lo intento logre tener más consistencia, pero Zig me ha logrado cautivar bastante. Además, ¿como puedo autodenominarme un “first-performance dev” sin saber un lenguaje de bajo nivel? seria un tanto cuestionable. Entonces, decidí aprender Zig por:

  • Aprender desarrollo de software de bajo nivel.
  • Es un lenguaje excepcionalmente rápido.
  • Aprende de los errores de C y le da una visión más moderna.
  • Es más sencillo que opciones como C++ y Rust., brinda control absoluto al dev sobre las intrucciones a la máquina.
  • Cero abstracciones y magia negra bajo el capó.
  • Es compilado.
  • Compila rápido.
  • Aprender Zig te obliga a aprender conceptos que el programador promedio en Python, JS u otras opciones interpretadas desconocen del bajo nivel.

¿Qué he hecho con Zig?

Estoy muy lejos de dominarlo. De hecho a la fecha en la que escribo esto desconozco conceptos como “comptime” o “inline” característicos de Zig. ¿Pero para eso estamos no? para aprender, además, tengo el peso doble de adaptarme a escribir código de bajo nivel, recordarme a mi mismo buenas practicas sobre memoria, etc…, pero mi primer proyecto más o menos útil es mi propia version de ls para listar directorios en linux. Es literalmente el primer proyecto de cualquier programador que aprende un lenguaje ajeno al frontend, pero lo cierto es que siempre resulta interesante intentar replicar y brindar nuestro toque personal a estas herramientas esenciales de los OS.

Pero antes de ponerte el código de mi ls, veamos cómo hacer un hola mundo en Zig.

Hola mundo en zig

Lo primero que noté es que hay varias formas de mostrar texto (print()) en Zig. Puede ser fácilmente el hola mundo más tedioso que he hecho y eso es bueno:

const std = @import("std");

pub fn main() !void{
    std.debug.print("{s}\n", .{"Hola mundo!"});
}

Esta es la forma más sencilla que hay de hacer un hola mundo (y de imprimir texto en general) que nos proporciona Zig. Desglosemos el código:

  • const std = @import("std"); importa la librería estándar de Zig que nos proporciona todas las funcionalidades vitales para desarrollar.
  • pub fn main() !void{} es el punto de entrada de nuestro programa. Es exactamente igual que int main(){} en C. Todo inicia aquí, ¿pero que rayos es !void? pues como te puedes imaginar, void indica que la función no retorna nada. El signo de exclamación (!) indica que la función puede retornar un error, esa es al sintaxis que tenemos para especificar que una función puede fallar y retornar un error.
  • std.debug.print() la función print que nos proporciona Zig en su libreria estándar para imprimir cosas, generalmente con fines de depuración al momento de desarrollar (debug). Recibe 2 argumentos: el formato, y los argumentos de ese formato. Noten que en ese código específicamente, lo que está en {s} se sustituye por el seugndo argumento, que es un struct con que contiene a “Hola mundo!”. Para ejemplificar mejor, ¿Y si quiero imprimir varios valores? sería:
std.debug.print("{s} {d} {s}\n", .{"Primer argumento", 2, "{d} sirve para imprimir enteros."});

El caracter ‘\n’ representa un salto de línea, similar a cuando presionamos Enter para escribir en la línea de abajo.

¿Pero, por qué digo que hay varias formas? si prestaste atención, notarás que lo anterior es preferiblemente (y deberia) usarse solo para depurar, en esos momentos que queremos ver comportamiento de nuestro código sin complicarnos tanto. ¿Cuál es la forma eficiente a nivel de performance?

Pues:

const std = @import("std");

pub fn main(init: std.process.Init) !void {
    try std.Io.File.stdout().writeStreamingAll(init.io, "Hello, World!\n");
}

Este es el otro ejemplo dado por la documentación de Zig en su web. Pese a seguir en una línea, es mucho más verboso pero reduce overhead y peso en nuestro programa, estamos creando manualmente el stdout o salida estándar para mostrar texto en nuestro OS y escribiendo en él directamente. No llamamos herramientas de depuración o chequeo para imprimir un texto, aquí brindamos el string e imprime, más nada.

Hay varias cosas nuevas en este código:

  • try es la palabra reservada para decir: “ejecuta esto. Si hay un error, finaliza el programa. Si todo va bien, retorna el valor”.
  • std.Io.File.stdout().writeStreamingAll() accedemos a la salida estándar dentro del módulo IO, y llamamos al método writeStreamingAll() para escribir en ‘streaming’ algo un poco más dificil de explicar, pero básicamente se usaría para textos grandes o que no queramos almacenar en memoria, por su peso. (Explicado a medias, también es nuevo para mí) pero entiendo el concepto. Espero que hayas captado la idea. Pero y entonces, ¿es ideal para un hola mundo, o imprimir strings que no ocupen cientos de lineas? pues no.

Aqui la forma que hasta ahora he usado para imprimir texto en Zig:

const std = @import("std");

pub fn main(init: std.process.Init) !void {
    var buffer: [1024]u8 = undefined;
    var stdout_writer = std.Io.File.stdout().writer(init.io, &buffer);
    var stdout = &stdout_writer.interface;

    try stdout.writeAll("Hola mundo!\n");
    try stdout.flush();
}

Mucho más extenso y manual. No dedicaré demasiado texto a explicar esto pero resumidamente creamos el buffer donde se escribirá el texto con capacidad de hasta 1024 bytes, creamos nuestro writer para que use ese buffer e intentamos escribir “Hola mundo!” en el buffer, después se limpia para mostrar en pantalla. Este tipo de print lo hacemos si:

  • Realmente necesitamos máximo control y eficiencia.
  • Simplemente quieres hacer todo pensando en el consumo de memoria y como disminuirla.

Lo que noto que resulta más eficiente es que en tiempo de desarrollo es conveniente usar std.debug, pero para el momento final de optimización y mejorar el código aplicaría esto último. Igual en Zig no es tan obvio o sencillo que via usar para imprimir texto, depende de los casos.

¿Y qué hay de mi ls?

No hay que seguir explicando, aquí el código:

// Importamos la libreria estandar (std)
const std = @import("std");

// Struct que almacena los argumentos del usuario, o usa valores por default
const PrintOptions = struct {
    show_all: bool = false,
    want_size: bool = false,
    path: []const u8 = ".",
};

// Validar y guardar los argumentos en user_options
pub fn createOptions(io: std.Io, args: []const [:0]const u8) !PrintOptions {
    var buffer: [1024]u8 = undefined;
    var stderr_writer = std.Io.File.stderr().writer(io, &buffer);
    var stderr = &stderr_writer.interface;

    var user_options = PrintOptions{};

    for (args[1..]) |arg| {
        if (std.mem.eql(u8, arg, "-a") or std.mem.eql(u8, arg, "-A")) {
            user_options.show_all = true;
        } else if (std.mem.eql(u8, arg, "-s") or std.mem.eql(u8, arg, "-S")) {
            user_options.want_size = true;
        } else if (arg[0] != '-') {
            user_options.path = arg;
        } else {
            try stderr.print("Error: argumento '{s}' inválido. -a, -s o la ruta son los admitidos.\n", .{arg});
            try stderr.flush();
            return error.InvalidArg;
        }
    }

    return user_options;
}
// Escribir el tamaño del directorio/archivo 
pub fn writeSize(stdout: *std.Io.Writer, size: u64) !void {
    if (size < 1024) {
        try stdout.print("{d}B ", .{size});
    } else if (size < 1048576) {
        const kb = size / 1024;
        try stdout.print("{d}KB ", .{kb});
    } else {
        const mb = size / 1048576;

        const decimal = (size % 1048576) * 10 / 1048576;
        try stdout.print("{d}.{d}MB ", .{ mb, decimal });
    }
}

// Función principal encargada de imprimir cada entrada
pub fn printDir(io: std.Io, stdout: *std.Io.Writer, dir: std.Io.Dir, opt: PrintOptions) !void {
    var itr = dir.iterate();

    while (try itr.next(io)) |entry| {
        if (!opt.show_all and entry.name[0] == '.') continue;
        if (opt.want_size) {
            const entry_stat = try dir.statFile(io, entry.name, .{});
            try writeSize(stdout, entry_stat.size);
        }
        if (entry.kind == .directory) try stdout.writeByte('/');
        try stdout.writeAll(entry.name);

        try stdout.writeAll("    ");
    }

    try stdout.writeByte('\n');
}

// Nuestra función main
pub fn main(init: std.process.Init) !void {
    // Configuración del stdout
    var buffer: [1024]u8 = undefined;
    var writer = std.Io.File.stdout().writer(init.io, &buffer);
    var stdout = &writer.interface;

    // Extraer argumentos de nuestro programa
    const args = try init.minimal.args.toSlice(init.arena.allocator());
    const options = try createOptions(init.io, args);

    // Conseguir el directorio actual del usuario o intentar usar el path si se brindó en los argumentos
    const cwd = std.Io.Dir.cwd();
    const iterable_cwd = cwd.openDir(init.io, options.path, .{ .iterate = true }) catch |err| { // <- Si esto falla, el usuarió nos dió un path inválido
        const err_name = @errorName(err);
        try stdout.print("{s} '{s}'. {s}\n Error: {s}", .{ "Error al momento de leer la ruta", options.path, "Asegurate de proporcionar un path/directorio válido.", err_name });
        try stdout.flush();
        std.process.exit(1);
    };
    // Cerrar el directorio al finalizar
    defer iterable_cwd.close(init.io);

    // Escribir entradas en el buffer
    try printDir(init.io, stdout, iterable_cwd, options);

    // Limpiar la salida
    try stdout.flush();
}

Aproximadamente 87 líneas para un comando ls que se ve así:

  • Con el argumento -a
Ejemplo de mi ls con argumento -a
nixls -a
  • Con el argumento -s
Ejemplo de mi ls con argumento -s
nixls -s
  • Con argumento inválido, ej: ruta no existente
Error en mi ls
nixls holamundo (error)

Como pueden apreciar es simple en funcionalidades y estética. Aunque personalmente, no creo que requiera mucho más. El binario resultante lo tengo instalado como un binario cualquiera en mi OS y lo uso para no dejarlo tirado, pero me sirve. Aquí les proporcioné el código centralizado en un archivo, pero en mi repositorio lo subiré modularizado y tal vez con alguna mejora de diseño.

¿Cumple en ligereza este ls?

Pues, compilado especificamente para un binario pequeño con ReleaseSmall, pesa 145Kb. Dada la simplicidad del comando me esperaba incluso algo menos, pero reconozco que es bastante ligero en comparación con otros lenguajes.

De ahora en adelante dedicaré mi aprendizaje a este lenguaje, y hacer software eficiente con las herramientas que proporciona. Tengo varios proyectos en mente, pero antes tengo que mejorar. Espero que te haya parecido interesante este lenguaje como me lo parece a mí, sin duda una herramienta excelente para crear programas rápidos. Nos vemos en la próxima.

Haz click aquí para ir a el repositorio de este ls

  • Nix.