Zig dla narzędzi CLI: migracja z 3000 linii C na 1000 linii bez błędów segmentacji
Programiści narzędzi CLI często borykają się z problemami C: ręczne zarządzanie pamięcią, błędy segmentacji i rozwlekły kod. Przepisanie analizatora logów logz na Zig zmniejszyło objętość z 3147 linii do 1089, przyspieszyło wykonanie o 33% i wyeliminowało awarie. Analiza migracji, przykłady kodu i benchmarki.
Problemy C w rzeczywistym projekcie
Analizator logów logz parsuje access.log i error.log nginx/apache, filtruje według poziomu (DEBUG, INFO, WARN, ERROR), daty, IP, buduje ranking błędów i wykresy słupkowe w terminalu przez unicode.
W C kod rozrósł się do 3147 linii:
- Funkcja
process_file— 400 linii monolitu. - Błędy segmentacji od przepełnienia buforów (256 bajtów wobec 300-bajtowych linii).
- Wycieki pamięci, debugowanie przez Valgrind.
- Parser daty z błędami off-by-one.
Obsługa błędów przez -1/NULL/errno zaciera kontekst. Makefile był zapominany, kompilacja zwalniała bez -MMD.
Prosty parser linii 2026-01-17 ERROR [nginx] 192.168.1.100 500 wymagał 50 linii: strtok, sprawdzanie NULL, strncpy, zwalnianie buforów.
Zalety Zig nad C i Rust
Zig zachowuje kontrolę C, ale dodaje narzędzia:
defer file.close()— gwarantowane sprzątanie bez ukrytego przepływu sterowania.!Tw sygnaturze — unia błędów, kompilator wymaga obsługi.- Kompilacja krzyżowa:
zig build -Dtarget=x86_64-windowsbez toolchainów. - Szybka kompilacja małych projektów.
Rust z borrow checker jest nadmiarowy dla CLI, które czyta plik do stdout.
Architektura logz — pipeline z 5 modułów:
- Parsowanie argumentów.
- Czytanie pliku.
- Parsowanie linii.
- Filtrowanie.
- Statystyki i wyjście.
W C wszystko mieszało się w process_file. W Zig dane płyną ściśle w dół.
Porównanie kodu: argumenty CLI
Wersja C (~80 linii):
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];
} // analogicznie dla --level, --top-ip
}
return 0;
}
Zig (~40 linii):
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);
} // analogicznie
}
return cfg;
}
orelse return error.MissingValue zastępuje 3 sprawdzenia. Błąd jest typowany.
Parsowanie logów: od malloc do unii błędów
C (~65 linii):
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; }
// parsowanie timestamp, level, 40 linii...
return entry; // free na wywołującym
}
Zig (~35 linii):
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(); // metoda HTTP
const status_str = iter.next() orelse return error.InvalidFormat;
const status = try std.fmt.parseInt(u16, status_str, 10);
return LogEntry{...};
}
Brak malloc/free/strtok. Sygnatura !LogEntry gwarantuje obsługę błędów.
Praca z plikami: defer w akcji
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 zamyka zasoby przy każdym wyjściu. W C wymagane było fclose przed każdym return.
Benchmarki i metryki
Test: 5 mln linii, 800 MB.
| Metryka | C | Zig |
|------------------|--------|--------|
| Czas (sek) | 4.2 | 2.8 |
| Binarka (KB) | 47 | 32 |
| Pamięć (MB) | 124 | 89 |
| Kompilacja (sek) | 3.1 | 1.4 |
| Linii kodu | 3147 | 1089 |
Przyspieszenie dzięki bufferedReader vs fgets. Kod 3 razy bardziej zwarty.
Typowe błędy migracji
- Allocators: page_allocator wszędzie → ArenaAllocator dla CLI (deinit na końcu).
- Slices:
[]const u8bez \0, dla C-API —.ptr+ null-termination. - Kompilator: długie błędy, czytaj pierwszą linię.
- Zmienność: Zig 0.13, przykłady 2022–2023 nie kompilują się — docs.ziglang.org.
Co jest ważne
- Zig redukuje kod CLI 3-krotnie dzięki unii błędów i defer.
- Brak błędów segmentacji: typy pamięci i brak ukrytych alokacji.
- Szybszy niż C o 30–40% przy prawidłowej buforyzacji.
- Idealny dla narzędzi, embedded, kompilacji krzyżowej.
- Dla dużych projektów z ekosystemem — Rust lepszy.
— Editorial Team
Brak komentarzy.