Zig para utilidades CLI: Migrando de 3000 líneas de C a 1000 líneas sin segfaults
Los desarrolladores de herramientas CLI a menudo enfrentan los problemas de C: gestión manual de memoria, segfaults y código inflado. Reescribir el analizador de logs logz en Zig redujo su tamaño de 3147 líneas a 1089, aceleró la ejecución un 33% y eliminó los fallos. Un análisis de la migración, ejemplos de código y benchmarks.
Los problemas reales con C
El analizador de logs logz analiza archivos access.log y error.log de nginx/apache, filtra por nivel (DEBUG, INFO, WARN, ERROR), fecha e IP, construye listas de errores principales y renderiza gráficos de barras en la terminal usando unicode.
En C, el código se infló a 3147 líneas:
- La función
process_file— un monolito de 400 líneas. - Segfaults por desbordamientos de búfer (256 bytes vs líneas de 300 bytes).
- Fugas de memoria, depuradas con Valgrind.
- Un analizador de fechas con errores off-by-one.
El manejo de errores mediante -1/NULL/errno oscurecía el contexto. El Makefile a menudo se olvidaba, y las compilaciones eran lentas sin -MMD.
Analizar una línea simple como 2026-01-17 ERROR [nginx] 192.168.1.100 500 requería 50 líneas: strtok, comprobaciones NULL, strncpy, liberación de búferes.
Ventajas de Zig sobre C y Rust
Zig conserva el control de C pero añade herramientas potentes:
defer file.close()— limpieza garantizada sin flujo de control oculto.!Ten firmas — uniones de error, el compilador obliga a manejarlas.- Compilación cruzada:
zig build -Dtarget=x86_64-windowssin toolchains. - Compilaciones rápidas para proyectos pequeños.
El borrow checker de Rust es excesivo para una herramienta CLI que lee un archivo y escribe en stdout.
La arquitectura de logz es un pipeline de 5 módulos:
- Análisis de argumentos.
- Lectura de archivos.
- Análisis de líneas.
- Filtrado.
- Estadísticas y salida.
En C, todo estaba mezclado en process_file. En Zig, los datos fluyen estrictamente hacia abajo.
Comparación de código: Argumentos CLI
Versión C (~80 líneas):
int parse_args(int argc, char **argv, Config *cfg) {
for (int i = 1; i < argc; i++) {
if (strcmp(argv[i], "--file") == 0) {
if (i + 1 >= argc) {
fprintf(stderr, "error: --file requires a value\n");
return -1;
}
cfg->filename = argv[++i];
} // similarly for --level, --top-ip
}
return 0;
}
Versión Zig (~40 líneas):
const Config = struct {
filename: []const u8 = "",
level: ?LogLevel = null,
top_ip: u32 = 10,
from_date: ?i64 = null,
};
fn parseArgs(allocator: std.mem.Allocator) !Config {
var args = try std.process.argsWithAllocator(allocator);
defer args.deinit();
var cfg = Config{};
_ = args.next();
while (args.next()) |arg| {
if (std.mem.eql(u8, arg, "--file")) {
cfg.filename = args.next() orelse return error.MissingValue;
} else if (std.mem.eql(u8, arg, "--level")) {
const lvl = args.next() orelse return error.MissingValue;
cfg.level = try LogLevel.parse(lvl);
} // similarly
}
return cfg;
}
orelse return error.MissingValue reemplaza 3 comprobaciones. El error está tipado.
Análisis de logs: De malloc a uniones de error
C (~65 líneas):
LogEntry *parse_line(char *line) {
LogEntry *entry = malloc(sizeof(LogEntry));
if (!entry) return NULL;
char *saveptr;
char *token = strtok_r(line, " ", &saveptr);
if (!token) { free(entry); return NULL; }
// parsing timestamp, level, 40 lines...
return entry; // free is the caller's responsibility
}
Zig (~35 líneas):
const LogEntry = struct {
timestamp: i64,
level: LogLevel,
source: []const u8,
ip: []const u8,
status: u16,
};
fn parseLine(line: []const u8) !LogEntry {
var iter = std.mem.splitScalar(u8, line, ' ');
const ts_str = iter.next() orelse return error.InvalidFormat;
const timestamp = try parseTimestamp(ts_str);
const level_str = iter.next() orelse return error.InvalidFormat;
const level = try LogLevel.parse(level_str);
const source = iter.next() orelse return error.InvalidFormat;
const ip = iter.next() orelse return error.InvalidFormat;
_ = iter.next(); // HTTP method
const status_str = iter.next() orelse return error.InvalidFormat;
const status = try std.fmt.parseInt(u16, status_str, 10);
return LogEntry{...};
}
Sin malloc/free/strtok. La firma !LogEntry garantiza el manejo de errores.
Trabajando con archivos: defer en acción
pub fn processFile(
allocator: std.mem.Allocator,
path: []const u8,
cfg: Config,
) !Stats {
const file = try std.fs.cwd().openFile(path, .{});
defer file.close();
var buffered = std.io.bufferedReader(file.reader());
var reader = buffered.reader();
var stats = Stats.init(allocator);
defer stats.deinit();
var line_buf: [8192]u8 = undefined;
while (try reader.readUntilDelimiterOrEof(&line_buf, '\n')) |line| {
const entry = parseLine(line) catch continue;
if (cfg.matches(entry)) {
try stats.add(entry);
}
}
return stats;
}
defer cierra recursos en cualquier salida. En C, fclose era necesario antes de cada return.
Benchmarks y métricas
Prueba: 5 millones de líneas, 800 MB.
| Métrica | C | Zig |
|------------------|--------|--------|
| Tiempo (seg) | 4.2 | 2.8 |
| Binario (KB) | 47 | 32 |
| Memoria (MB) | 124 | 89 |
| Compilación (seg)| 3.1 | 1.4 |
| Líneas de código | 3147 | 1089 |
Aceleración debido a bufferedReader vs fgets. El código es 3 veces más compacto.
Errores comunes en la migración
- Allocators: Usar page_allocator en todas partes → ArenaAllocator para CLI (deinit al final).
- Slices:
[]const u8sin \0, para API-C —.ptr+ terminación nula. - Compilador: Mensajes de error largos, leer la primera línea.
- Volatilidad: Zig 0.13, ejemplos de 2022–2023 pueden no compilar — consultar docs.ziglang.org.
Conclusiones clave
- Zig reduce el código CLI 3x gracias a uniones de error y defer.
- Sin segfaults: seguridad de memoria y sin asignaciones ocultas.
- 30–40% más rápido que C con buffering adecuado.
- Ideal para utilidades, sistemas embebidos, compilación cruzada.
- Para proyectos grandes con ecosistema — Rust es preferible.
— Editorial Team
Aún no hay comentarios.