⚡ Zig Guide LiveUnofficialbut fully verified
✓ Zig 0.18.0-dev.828+9da287565What's newOn an older Zig?Blograylib-zig

String Primitives

Updated · Code verified against Zig 0.18.0-dev.828+9da287565

The other chapters in this track need four operations on text: measure, compare, copy and trim. Write these four functions one time. You learn more from that than from a description, because three of the four functions have a well-known mistake.

The program

const std = @import("std");

/// Walk the string until the zero byte. C calls this function `strlen`. Its
/// cost is the reason that a slice holds its length. Each call is O(n). A call
/// inside a loop changes an O(n) job into O(n^2) work.
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 the copy. If `src` is too large for
/// `dest`, return null. Do not write a part of `src`.
///
/// The `+ 1` is for the terminator. A 5-byte string needs 6 bytes of space.
/// A version that checks `src.len > dest.len` compiles and passes a simple
/// test. But that version 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';
}

/// Trim both ends, and do not move a byte. A trim does not need a copy. The
/// result is a slice of the bytes that 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 space 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",
    });

    // This buffer is separate from `buf`. An earlier version gave `buf` here.
    // Then `buf` held the data of the writer and the data of `escape`, and
    // this garbled the first line of output. Both slices hold a length. Thus the
    // mistake garbled the output, and did not write past the end of a buffer.
    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();
}

/// Show whitespace as visible text, so that you can check the trim 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 the string, and .len does not. Both printed 5. strLen counted bytes until it found a zero. .len read a number that the slice already holds.

For a five-byte greeting, the difference is almost zero. In a loop, the difference can be large:

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

The loop does an O(n) job with O(n²) work. This is a common performance bug in C. You cannot write this bug with a slice, because no call exists that you can repeat by accident.

strEq compares the lengths first. When the lengths are different, a comparison of the contents is unnecessary work. More important, a loop without the length check reads past the end of the shorter string. "cat" against "cats" is false after one comparison.

The copy refused the text and did not truncate it. A buffer of six bytes accepted "hello", and a buffer of five bytes did not. The check is src.len + 1 > dest.len. The + 1 is necessary because a 5-byte string needs 6 bytes: the terminator is also a byte.

Another version of the check is src.len > dest.len. That version looks correct and compiles. It also passes a test with short strings. But it writes exactly one byte past the end of the buffer.

That one byte is the classic C buffer overflow. It does not need a bad pointer or malicious input. It needs only an off-by-one error in a bounds check, after a reviewer read the check two times and approved it.

The trim returned a view. " padded " became "padded", and the function copied nothing and allocated nothing. The obvious implementation moves the bytes to the front, and that work is unnecessary. The trimmed text is already in the input. The function only returns a slice that covers a smaller part of the input.

Check yourself

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

Not without a copy of the string or a write to the string. To trim the front is easy, because the function can point further into the string. To trim the back, the string must end earlier, and a C string ends at its zero byte.

Thus the function must write a zero over the first trailing space, and that write changes the data of the caller. strtok does exactly this, and this behavior makes strtok difficult to use. A slice can describe a shorter part of the same bytes and not change them. For this reason, a slice does not need a terminator.

If you know C

You wrote these four functions before, possibly more than one time:

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 function is strcpy, and it does not know the size of the destination. The problem is in the signature, not in the implementation. The caller never gives the size to the function, so the function cannot check the size.

strncpy was the fix, and it added a new problem. It does not terminate the result when the source is too long. Thus the safer replacement for strncpy is strlcpy, and strlcpy is not in the C standard.

That is three attempts at one function. All three are difficult to use because a char * is a pointer with no length. Each function above takes a slice. Thus the length is not a separate argument that a caller can give incorrectly.

Next: tokenize a command line. A shell does this step first.