⚡ Zig Guide LiveUnofficialbut fully verified
✓ Zig 0.17.0-dev.1516+8a4b5424dWhat's newOn an older Zig?

String Primitives

Nothing else in this track can be built until text can be measured, compared, copied and trimmed. Those four operations are the floor, and writing them out once is worth more than reading about them, because three of the four contain a mistake that is famous for a reason.

The program

const std = @import("std");

/// Walk until the zero. This is the function C calls `strlen`, and its cost is
/// the reason a slice carries its length instead: this is O(n) every time you
/// ask, and code that asks inside a loop turns an O(n) job into O(n^2).
fn strLen(s: [*:0]const u8) usize {
    var i: usize = 0;
    while (s[i] != 0) i += 1;
    return i;
}

fn strEq(a: []const u8, b: []const u8) bool {
    if (a.len != b.len) return false;
    for (a, b) |x, y| {
        if (x != y) return false;
    }
    return true;
}

/// Copy `src` into `dest` and terminate it. Returns null when it will not fit,
/// rather than writing what it can and hoping.
///
/// The `+ 1` is the whole lesson. A 5-byte string needs 6 bytes of room, and
/// the version of this that checks `src.len > dest.len` compiles, passes a
/// casual test, and writes one byte past the end of the buffer.
fn strCopy(dest: []u8, src: []const u8) ?[:0]u8 {
    if (src.len + 1 > dest.len) return null;
    @memcpy(dest[0..src.len], src);
    dest[src.len] = 0;
    return dest[0..src.len :0];
}

fn isSpace(c: u8) bool {
    return c == ' ' or c == '\t' or c == '\n' or c == '\r';
}

/// Both ends, without moving anything. Trimming by copying is the obvious
/// implementation and the wrong one: the answer is a view of what you already
/// have.
fn strTrim(s: []const u8) []const u8 {
    var start: usize = 0;
    var end: usize = s.len;
    while (start < end and isSpace(s[start])) start += 1;
    while (end > start and isSpace(s[end - 1])) end -= 1;
    return s[start..end];
}

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

    const greeting = "hello";
    try out.print("strLen(\"hello\")   = {d}\n", .{strLen(greeting)});
    try out.print("greeting.len      = {d}   (already known, no walk)\n\n", .{greeting.len});

    try out.print("strEq(\"cat\",\"cat\") = {}\n", .{strEq("cat", "cat")});
    try out.print("strEq(\"cat\",\"cot\") = {}\n", .{strEq("cat", "cot")});
    try out.print("strEq(\"cat\",\"cats\")= {}\n\n", .{strEq("cat", "cats")});

    // Six bytes of room for five bytes of text.
    var room: [6]u8 = undefined;
    if (strCopy(&room, "hello")) |copied| {
        try out.print("copied \"{s}\" into 6 bytes, terminator at index {d}\n", .{ copied, copied.len });
    }
    var tight: [5]u8 = undefined;
    try out.print("copying \"hello\" into 5 bytes -> {s}\n\n", .{
        if (strCopy(&tight, "hello") == null) "refused" else "wrote",
    });

    // Its own buffer. Passing `buf` here aliased the writer's own storage and
    // the first line of output came out shredded, which is the kind of bug a
    // slice makes visible only because both sides had a length.
    var scratch: [64]u8 = undefined;
    for ([_][]const u8{ "  padded  ", "\tboth\n", "none", "   ", "" }) |sample| {
        try out.print("trim(\"{s}\") -> \"{s}\"\n", .{ escape(sample, &scratch), strTrim(sample) });
    }

    try out.flush();
}

/// Render whitespace visibly so the trimming can be checked by eye.
fn escape(s: []const u8, scratch: []u8) []const u8 {
    var n: usize = 0;
    for (s) |c| {
        const replacement: []const u8 = switch (c) {
            '\t' => "\\t",
            '\n' => "\\n",
            else => &[_]u8{c},
        };
        @memcpy(scratch[n..][0..replacement.len], replacement);
        n += replacement.len;
    }
    return scratch[0..n];
}

What just happened

strLen walks, and .len does not. Both printed 5. One of them counted bytes until it found a zero; the other read a number that was already there. The difference costs nothing on a five-byte greeting and everything in a loop:

for (int i = 0; i < strlen(s); i++)   /* walks the string on every iteration */

That is an O(n) job written as O(n²), it is one of the most common performance bugs in C, and it cannot be written at all with a slice, because there is no call to accidentally repeat.

strEq checks the length first. Comparing contents when the lengths differ is wasted work, and more importantly the loop that forgets to would read past the end of the shorter one. "cat" against "cats" is false in one comparison.

The copy refused rather than truncating. Six bytes of room took "hello"; five bytes did not. The check is src.len + 1 > dest.len, and that + 1 is the entire lesson of this chapter. A 5-byte string needs 6 bytes, because the terminator is a byte too. The version that checks src.len > dest.len looks right, compiles, passes a test with short strings, and writes exactly one byte past the end of the buffer.

That single byte is the classic C buffer overflow. Not a wild pointer or a malicious input: an off-by-one in a bounds check that somebody read twice and approved.

Trimming returned a view. " padded " became "padded" with nothing copied and nothing allocated. The obvious implementation moves the bytes to the front, and it is pure waste: the answer was already there, and all that was needed was to point at less of it.

Check yourself

strTrim takes []const u8 and returns []const u8. Could it take a null-terminated C string and return one?

Not without either copying or writing. Trimming the front is fine, since you can just point further in. Trimming the back means the string has to end earlier, and a C string ends where its zero is, so you would have to write a zero over the first trailing space, modifying the caller’s data. That is exactly what strtok does and why it is so unpleasant to use. A slice can describe a shorter piece of the same bytes without touching them, which is the whole reason the terminator stopped being necessary.

If you have written C

You have written all four of these, and probably more than once:

size_t str_len(const char *s) { size_t i = 0; while (s[i]) i++; return i; }
int    str_eq(const char *a, const char *b) { return strcmp(a, b) == 0; }
void   str_copy(char *d, const char *s) { while ((*d++ = *s++)); }   /* no bounds at all */

The third one is strcpy, and it has no idea how big the destination is. That is not an implementation flaw, it is the signature: the information is not there to check. strncpy was the fix and introduced its own trap, since it does not terminate the result when the source is too long, so the safe version of the safe version is strlcpy, which is not in the C standard.

Three attempts at one function, and the reason all three are awkward is that a char * is a pointer with no length. Everything above takes a slice, so the length is not a separate argument anybody can pass wrongly.

Next: tokenizing a command line, which is the first thing a shell has to do.