Str and StrView in practice
Str owns its bytes and can grow. StrView borrows bytes that something else owns and never allocates. The working pattern is to put text into a Str once, then pass StrViews around to read it, search it and cut it. Copy a view into a new Str only when the bytes must outlive the owner. Every example below is a file in notes/str-examples/. Each output block is the real output of running that file.
Running the examples
Run these from the repository root. The examples read data/pg2554.txt, which is the Project Gutenberg text of Crime and Punishment. Each file includes src/mem/mem.c and src/str/str.c directly, the same way main.c does, so a single compiler call builds it.
cc -std=c11 -Isrc -fsanitize=address,undefined \
notes/str-examples/01_build.c -o build/ex && ./build/ex
Keep the sanitizer flag on while you learn. It turns most memory mistakes into a report that names the line. The mistakes section shows two of those reports.
The two types
Both types hold a pointer and a length. The difference is ownership. A Str is responsible for its heap buffer, so it must be freed. A StrView points at bytes that belong to someone else, so it is never freed. It is only valid while the owner is alive and has not moved its buffer.
Str text data ──► C r i m e a n d P u n i s h m e n t \0 · · · · · · · · · ·
len 20, capacity 30 spare capacity ─┘
▲ ▲
StrView word data ┘ len 5 │ word = "Crime"
StrView tail data ┘ len 10 tail = "Punishment"
The views above cost nothing to make. They are two machine words each. Slicing text a thousand times allocates nothing.
Str | StrView | |
|---|---|---|
| Fields | data, len, capacity, alloc | data, len |
| Owns memory | Yes. Call str_free when done. A Str in an arena is also freed by arena_free. | No. Never free it. |
| Can change | Yes, through str_* calls. | No. The bytes are const. |
| Calls can fail | Calls that may allocate return bool. | Never. No call allocates. |
| NUL-terminated | Yes. str_cstr is safe to pass to C APIs. | Not in general. A slice ends mid-string. |
| Start as | Str s = { 0 }; | SV("lit"), sv_from_cstr(p), str_view(&s), sv_slice(v, a, b) |
| Pass to functions as | Str *. Never by value. | StrView by value. It is small. |
| Valid until | str_free | The owner is freed or grows |
Str on its own
Build, read, free
A zeroed Str is a valid empty string that owns nothing. The first append allocates. Every call that might allocate returns bool, and a failed call leaves the string as it was. That makes it safe to chain calls with &&: the chain stops at the first failure and nothing after it runs.
notes/str-examples/01_build.c
/* Str on its own: build a string, read it, free it. */
#include "mem/mem.c"
#include "str/str.c"
#include <stdio.h>
#include <stdlib.h>
int main(void) {
Str s = { 0 };
/* A failed call leaves `s` unchanged, so && stops at the first failure. */
bool ok = str_append_cstr(&s, "Rodion") &&
str_push(&s, ' ') &&
str_append(&s, SV("Raskolnikov")) &&
str_appendf(&s, ", former student, age %d", 23);
if (!ok) {
str_free(&s);
return EXIT_FAILURE;
}
printf("%.*s\n", STR_FMT(s));
printf("len %zu, capacity %zu\n", s.len, s.capacity);
puts(str_cstr(&s));
str_free(&s);
return EXIT_SUCCESS;
}
Rodion Raskolnikov, former student, age 23
len 42, capacity 60
Rodion Raskolnikov, former student, age 23
Two ways to print are shown. STR_FMT passes the length and the pointer to %.*s, so printf prints exactly len bytes. It works on a Str and on a StrView. str_cstr returns a NUL-terminated pointer for APIs like puts and fopen. It never returns null, even for an empty string.
The capacity is 60 because the buffer starts at 15 bytes and doubles when full: 15, 30, 60. Doubling means appending one byte at a time still costs a constant amount per byte on average.
Reuse one buffer
str_clear sets the length to zero and keeps the allocation. In a loop that builds a string per item, clear and refill one Str instead of creating and freeing one per pass.
notes/str-examples/02_reuse.c
/* Str on its own: clear and refill one buffer instead of allocating per line.
*/
#include "mem/mem.c"
#include "str/str.c"
#include <stdio.h>
#include <stdlib.h>
int main(void) {
Str line = { 0 };
for (int part = 1; part <= 3; part++) {
str_clear(&line);
if (!str_appendf(&line, "PART %d", part)) {
str_free(&line);
return EXIT_FAILURE;
}
printf("%-8s capacity %zu, data %p\n", str_cstr(&line), line.capacity,
(void *)line.data);
}
str_free(&line);
return EXIT_SUCCESS;
}
PART 1 capacity 15, data 0x6020000000f0
PART 2 capacity 15, data 0x6020000000f0
PART 3 capacity 15, data 0x6020000000f0
The address is the same on every pass. One allocation served all three strings.
Read a whole file
str_append_file reads until end of file. If s is empty it takes the read buffer over instead of copying it. On failure it leaves s unchanged, and ferror tells you whether the read failed or memory ran out. Check ferror before fclose, because the stream is gone after.
notes/str-examples/03_file.c
/* Str on its own: read a whole file into memory. */
#include "mem/mem.c"
#include "str/str.c"
#include <stdio.h>
#include <stdlib.h>
int main(int argc, char **argv) {
const char *path = argc > 1 ? argv[1] : "data/pg2554.txt";
FILE *f = fopen(path, "rb");
if (f == NULL) {
perror(path);
return EXIT_FAILURE;
}
Str text = { 0 };
bool ok = str_append_file(&text, f);
bool read_error = ferror(f);
fclose(f);
if (!ok) {
fprintf(stderr, "%s: %s\n", path,
read_error ? "read error" : "out of memory");
return EXIT_FAILURE;
}
printf("%s: %zu bytes\n", path, text.len);
str_free(&text);
return EXIT_SUCCESS;
}
data/pg2554.txt: 1202773 bytes
StrView on its own
A view needs bytes that already exist. There are four ways to get one:
SV("text")- A string literal. The length is computed at compile time. Use it only on a literal, never on a pointer.
sv_from_cstr(p)- A NUL-terminated
char *from a C API, such asargv[i]orgetenv. It measures withstrlen. Null gives an empty view. str_view(&s)- The whole contents of a
Str. sv_slice(v, from, to)- Bytes
fromup to but not includingtoof another view. It asserts the range is insidev.
Inspect and slice
This parses a header line in the format Gutenberg uses. Trimming, finding and slicing all return new views into the same literal. Nothing is copied.
notes/str-examples/04_views.c
/* StrView on its own: inspect and slice text without allocating. */
#include "mem/mem.c"
#include "str/str.c"
#include <stdio.h>
#include <stdlib.h>
int main(int argc, char **argv) {
StrView header = SV(" Title: Crime and Punishment\r\n");
StrView line = sv_trim(header);
printf("[%.*s]\n", STR_FMT(line));
if (sv_starts_with(line, SV("Title:"))) {
size_t colon = sv_find_byte(line, ':');
StrView key = sv_slice(line, 0, colon);
StrView value = sv_trim(sv_slice(line, colon + 1, line.len));
printf("key [%.*s] value [%.*s]\n", STR_FMT(key), STR_FMT(value));
/* Wrong: %s runs on to the next NUL, past the end of the view. */
printf("%%s on key prints [%s]\n", key.data);
}
size_t at = sv_find(line, SV("Punishment"));
if (at != SV_NPOS) printf("Punishment starts at byte %zu\n", at);
printf("ends with \"ment\": %d\n", sv_ends_with(line, SV("ment")));
/* argv strings already exist, so a view just borrows them. */
for (int i = 1; i < argc; i++) {
StrView arg = sv_from_cstr(argv[i]);
if (str_eq(arg, SV("--verbose"))) puts("verbose on");
}
return EXIT_SUCCESS;
}
$ ./build/ex --verbose
[Title: Crime and Punishment]
key [Title] value [Crime and Punishment]
%s on key prints [Title: Crime and Punishment
]
Punishment starts at byte 17
ends with "ment": 1
verbose on
The third line shows the bug on purpose. key is five bytes long, but %s ignores the length and prints until it finds a NUL. Here that printed the rest of the line, including the \r\n. On a view with no NUL after it, the same mistake reads past the buffer. Always print a view with %.*s and STR_FMT.
The find functions return SV_NPOS when there is no match. Always compare against it before you use the index.
Split and tokenize
Both functions follow the same loop shape. You pass a view of the remaining text by pointer. Each call cuts one piece off the front, stores it in the output view, and returns false when nothing is left.
notes/str-examples/05_split.c
/* StrView on its own: split fields and tokens. */
#include "mem/mem.c"
#include "str/str.c"
#include <stdio.h>
#include <stdlib.h>
int main(void) {
/* Split keeps empty fields: the right tool for CSV-like data. */
StrView rest = SV("Raskolnikov,Sonya,,Dunya,");
StrView field;
while (sv_split_next(&rest, SV(","), &field)) {
printf("field [%.*s]\n", STR_FMT(field));
}
/* Token skips runs of delimiters: the right tool for words. */
rest = SV(" Pale, feverish and restless. ");
StrView word;
while (sv_token_next(&rest, SV(" ,."), &word)) {
printf("word [%.*s]\n", STR_FMT(word));
}
return EXIT_SUCCESS;
}
field [Raskolnikov]
field [Sonya]
field []
field [Dunya]
word [Pale]
word [feverish]
word [and]
word [restless]
sv_split_next | sv_token_next | |
|---|---|---|
| Separator | One multi-byte string, such as "," or "\r\n" | A set of single bytes, any of which separates |
| Empty pieces | Kept. a,,b gives a, empty, b. | Skipped. Runs of delimiters count as one. |
| Trailing separator | No empty piece after the last one | Ignored |
| Use for | CSV fields, lines, key=value | Words, whitespace-separated input |
sv_token_next is strtok without its problems. It does not write NULs into your text. It keeps no hidden state, so two loops can run at once.
Sort
A view is a plain struct, so an array of them sorts with qsort. sv_cmp orders bytes like memcmp, and a prefix sorts before the longer string.
notes/str-examples/06_sort.c
/* StrView on its own: sort an array of views with qsort. */
#include "mem/mem.c"
#include "str/str.c"
#include <stdio.h>
#include <stdlib.h>
static int cmp_views(const void *a, const void *b) {
return sv_cmp(*(const StrView *)a, *(const StrView *)b);
}
int main(void) {
StrView names[] = { SV("Sonya"), SV("Dunya"), SV("Razumikhin"),
SV("Raskolnikov"), SV("Porfiry") };
size_t n = sizeof names / sizeof names[0];
qsort(names, n, sizeof names[0], cmp_views);
for (size_t i = 0; i < n; i++) printf("%.*s\n", STR_FMT(names[i]));
return EXIT_SUCCESS;
}
Dunya
Porfiry
Raskolnikov
Razumikhin
Sonya
Using them together
One owner, many views
This is the shape most text processing takes. One Str holds the whole file. Every line, word and match is a view into it. The program makes one allocation for the file and none for the 22,750 lines.
notes/str-examples/07_lines.c
/* Together: one Str owns the file, views walk its lines. */
#include "mem/mem.c"
#include "str/str.c"
#include <stdio.h>
#include <stdlib.h>
int main(void) {
FILE *f = fopen("data/pg2554.txt", "rb");
if (f == NULL) {
perror("data/pg2554.txt");
return EXIT_FAILURE;
}
Str text = { 0 };
bool ok = str_append_file(&text, f);
fclose(f);
if (!ok) return EXIT_FAILURE;
size_t lines = 0, chapters = 0, mentions = 0;
StrView rest = str_view(&text);
StrView line;
while (sv_split_next(&rest, SV("\n"), &line)) {
line = sv_trim(line); /* the file uses \r\n; trim drops the \r */
lines++;
if (sv_starts_with(line, SV("Author:"))) {
printf("%.*s\n", STR_FMT(line));
}
if (sv_starts_with(line, SV("CHAPTER "))) chapters++;
/* Count every occurrence in the line by sliding past each match. */
StrView scan = line;
size_t at;
while ((at = sv_find(scan, SV("Raskolnikov"))) != SV_NPOS) {
mentions++;
scan = sv_slice(scan, at + 1, scan.len);
}
}
printf("%zu lines, %zu chapters, %zu mentions of Raskolnikov\n", lines,
chapters, mentions);
str_free(&text); /* every view into it dies here */
return EXIT_SUCCESS;
}
Author: Fyodor Dostoyevsky
22750 lines, 78 chapters, 784 mentions of Raskolnikov
Two details matter. The file has Windows line endings, so each line split on "\n" still ends in \r. sv_trim removes it along with any other surrounding whitespace. Second, nothing writes to text while the loop runs. That is what keeps every view valid. The count of 784 agrees with grep -o Raskolnikov data/pg2554.txt | wc -l.
Copy when it must outlive the source
A view dies with its owner. When you need a piece of text after the owner is freed, copy it into a Str of its own. Here a struct keeps the title and author while the 1.2 MB file buffer is released.
notes/str-examples/08_keep.c
/* Together: copy a view into its own Str when it must outlive its source. */
#include "mem/mem.c"
#include "str/str.c"
#include <stdio.h>
#include <stdlib.h>
typedef struct Book {
Str title;
Str author;
} Book;
static void book_free(Book *b) {
str_free(&b->title);
str_free(&b->author);
}
/* Fill `b` from a Gutenberg header. Views in, owned copies out. */
static bool book_parse(Book *b, StrView text) {
StrView line;
while (sv_split_next(&text, SV("\n"), &line)) {
line = sv_trim(line);
if (sv_starts_with(line, SV("Title: "))) {
str_clear(&b->title);
if (!str_append(&b->title, sv_slice(line, 7, line.len)))
return false;
} else if (sv_starts_with(line, SV("Author: "))) {
str_clear(&b->author);
if (!str_append(&b->author, sv_slice(line, 8, line.len)))
return false;
}
}
return true;
}
int main(void) {
FILE *f = fopen("data/pg2554.txt", "rb");
if (f == NULL) return EXIT_FAILURE;
Str text = { 0 };
bool ok = str_append_file(&text, f);
fclose(f);
if (!ok) return EXIT_FAILURE;
Book book = { 0 };
ok = book_parse(&book, str_view(&text));
str_free(&text); /* the 1.2 MB buffer goes; the book keeps its copies */
if (!ok) {
book_free(&book);
return EXIT_FAILURE;
}
printf("%s by %s\n", str_cstr(&book.title), str_cstr(&book.author));
book_free(&book);
return EXIT_SUCCESS;
}
Crime and Punishment by Fyodor Dostoyevsky
A struct that holds Str fields owns them. Give it a matching _free function that frees each field, like book_free. Zero-initialise the struct with = { 0 } so the free function is safe to call even if parsing stopped part way.
Writing your own functions
The library's own signatures are the pattern to copy. A function that only reads text takes a StrView. A function that produces text takes a Str * to append to and returns bool. The caller then decides where the output goes and whether to clear it first.
notes/str-examples/09_replace.c
/* Together: write your own function with views in and a Str out. */
#include "mem/mem.c"
#include "str/str.c"
#include <stdio.h>
#include <stdlib.h>
/* Append `text` to `out` with every `from` replaced by `to`. */
static bool replace_all(Str *out, StrView text, StrView from, StrView to) {
size_t at;
while ((at = sv_find(text, from)) != SV_NPOS) {
if (!str_append(out, sv_slice(text, 0, at)) || !str_append(out, to)) {
return false;
}
text = sv_slice(text, at + from.len, text.len);
}
return str_append(out, text);
}
int main(void) {
Str out = { 0 };
if (!replace_all(&out, SV("Rodya went out. Rodya came back."), SV("Rodya"),
SV("Raskolnikov"))) {
str_free(&out);
return EXIT_FAILURE;
}
printf("%.*s\n", STR_FMT(out));
/* Appending a Str to itself is safe: str_append handles the overlap. */
if (!str_append(&out, str_view(&out))) {
str_free(&out);
return EXIT_FAILURE;
}
printf("%zu bytes after doubling\n", out.len);
str_free(&out);
return EXIT_SUCCESS;
}
Raskolnikov went out. Raskolnikov came back.
88 bytes after doubling
| Your function needs to | Signature shape | Example |
|---|---|---|
| Read text | Take StrView | bool is_heading(StrView line) |
| Return part of its input | Return StrView. No bool, it cannot fail. | StrView first_word(StrView line) |
| Produce new text | Take Str *out, append, return bool | bool replace_all(Str *out, StrView text, ...) |
| Keep text in a struct | Str field and a _free function | Book and book_free |
Taking a StrView makes a function accept every kind of string. A caller with a literal passes SV("..."). A caller with a Str passes str_view(&s). A caller with a char * passes sv_from_cstr(p). One function serves all three.
The last call in the example appends a string to itself. That is safe. str_append notices that the view points into its own buffer and recomputes the address after growing.
Mistakes and what they look like
A view kept across a reallocation
This is the mistake to understand first. Any call that may grow a Str can move its buffer, and every view into the old buffer then points at freed memory.
notes/str-examples/10_dangling.c
/* Bug on purpose: a view kept across a call that reallocates. */
#include "mem/mem.c"
#include "str/str.c"
#include <stdio.h>
#include <stdlib.h>
int main(void) {
Str s = { 0 };
if (!str_append_cstr(&s, "Sonya")) return EXIT_FAILURE;
StrView name = str_view(&s);
/* 15 bytes fit; this append needs more, so the buffer moves. */
if (!str_append_cstr(&s, " Semyonovna Marmeladova")) return EXIT_FAILURE;
printf("%.*s\n", STR_FMT(name)); /* reads freed memory */
str_free(&s);
return EXIT_SUCCESS;
}
ERROR: AddressSanitizer: heap-use-after-free on address 0x6020000000f0
READ of size 1 at 0x6020000000f0 thread T0
#5 in main 10_dangling.c:15
freed by thread T0 here:
#0 in realloc
#1 in str_grow_ str.c:25
#4 in str_append_cstr str.c:114
#5 in main 10_dangling.c:13
previously allocated by thread T0 here:
#0 in realloc
#5 in main 10_dangling.c:9
The report is trimmed to the useful frames. Read it bottom up. Line 9 allocated the buffer. Line 13 grew it, and realloc freed the old one. Line 15 read through the stale view. There are two fixes. Take the view after the last call that grows the string. Or call str_reserve for the full size first, so the appends fit and the buffer never moves.
Copying a Str struct
Str b = a; copies the pointer, not the bytes. Now two structs think they own one buffer, and freeing both is a double free.
notes/str-examples/11_double_free.c
/* Bug on purpose: copying a Str struct makes two owners of one buffer. */
#include "mem/mem.c"
#include "str/str.c"
#include <stdlib.h>
int main(void) {
Str a = { 0 };
if (!str_append_cstr(&a, "Dunya")) return EXIT_FAILURE;
Str b = a; /* copies the pointer, not the bytes */
str_free(&a);
str_free(&b); /* frees the same buffer again */
return EXIT_SUCCESS;
}
ERROR: AddressSanitizer: attempting double-free on 0x6020000000f0 in thread T0:
#1 in str_free str.c:49
#2 in main 11_double_free.c:11
freed by thread T0 here:
#1 in str_free str.c:49
#2 in main 11_double_free.c:10
To duplicate a string, make a real copy with str_from(&b, str_view(&a)). To hand ownership over, copy the struct and then reset the source with a = (Str){ 0 }; so only one owner remains. Pass a Str to functions as Str * so this never happens by accident.
Smaller traps
| Mistake | What happens | Do this instead |
|---|---|---|
printf("%s", v.data) on a view | Prints past the end of the view, as shown in Inspect and slice | printf("%.*s", STR_FMT(v)) |
SV(p) where p is a char * | A compile error, expected '}', because SV only takes a literal. Before 0.0.1 it compiled and silently used sizeof(char *) - 1 as the length. | sv_from_cstr(p) |
Ignoring the bool | warning: ignoring return value of function declared with 'warn_unused_result' attribute | Check it, or chain the calls with && |
Passing s.data to a C API | Null for an empty Str, which many APIs crash on | str_cstr(&s), which never returns null |
Forgetting str_free | A leak. AddressSanitizer on macOS does not report leaks, so nothing warns you. | Write the str_free as soon as you write the Str s = { 0 }; |
Which function for which job
| I want to | Use |
|---|---|
| Build text piece by piece | str_append, str_append_cstr, str_push, str_appendf |
| Start with a copy of some text | str_from, str_from_cstr |
| Read a whole file | str_append_file |
| Avoid repeated growth when I know the size | str_with_capacity, str_reserve |
| Empty a string but keep its memory | str_clear |
| Give back unused capacity | str_shrink_to_fit |
| Release the memory | str_free |
| Pass a literal where a view is wanted | SV("...") |
Wrap a char * from a C API | sv_from_cstr |
Hand my Str to code that only reads | str_view(&s) |
Hand my Str to a C API | str_cstr(&s) |
| Print either type | printf("%.*s", STR_FMT(x)) |
| Compare | str_eq for equality, sv_cmp for order |
| Search | sv_find_byte, sv_find, sv_starts_with, sv_ends_with |
| Cut out a piece | sv_slice, sv_trim |
| Walk fields or lines | sv_split_next |
| Walk words | sv_token_next |
| Keep a piece after its source is freed | str_from(©, view) |
Exercises
Each one starts from an example file. Keep the sanitizer on and check the output against what you expect.
- Fix 10_dangling.c both ways: once by moving
str_viewbelow the append, once withstr_reserve. Confirm the sanitizer stays quiet. - Extend 08_keep.c so
Bookalso keeps theRelease date:line. Remember the new field inbook_free. - Count every word in the novel with
sv_token_nextin 07_lines.c. Decide which bytes count as delimiters. - Write
bool join(Str *out, const StrView *parts, size_t n, StrView sep)and use it to print the sorted names from 06_sort.c as one comma-separated line. - Write
StrView path_basename(StrView path)that returns everything after the last/. It returns a view, so it cannot fail. Test it on"","a","a/"and"/usr/lib/libc.so".