Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Pack bytes in a compact buffer

For a binary record, frame, or file body, std.bytes.Bytes provides checked byte-range operations and works with byte-oriented I/O. Its contiguous buffer stores one byte per element. A list[u8] also packs its elements into bytes; use its ordinary list operations and element references when those suit the task.

import std.bytes
from std.bytes import Bytes, BytesError

def checksum(view: &Bytes, start: i64, stop: i64) -> Result[i64, OverflowError[i64]]:
    mut total = 0
    for index in range(start, stop):
        total += i64(view.get(index))
    Ok(total)

def main() -> Result[(), AllocError | IoError | BytesError | OverflowError[i64]]:
    mut frame = std.bytes.with_capacity(8)?
    frame.push(2u8)?
    frame.extend(&std.bytes.from_text("hi")?)?
    frame.push(0u8)?
    frame.push(255u8)?
    print(frame.len(), frame.capacity(), frame)?
    print(checksum(&frame, 1, 3)?)?
    payload = frame.slice(1, 3)?
    print(payload.to_text()?)?
    print(str.repr(frame.to_text())?)?
    Ok(())
5 8 b"\x02hi\x00\xff"
209
hi
Result[str, std.bytes.BytesError].Err(std.bytes.BytesError.InvalidUtf8)

Growth and copies can fail to allocate, so they return AllocError; to_text returns BytesError. A range is start up to but not including stop, and must lie inside the buffer: there are no negative offsets and nothing is clamped. A range outside the buffer, such as checksum(&frame, 3, 9) on five bytes, is a bug in the caller, so it stops the program with a trap report naming the method, Bytes.get: index is outside 0..len(), and your calling line. Check a range that comes from input first and return your own error. slice and copy make independent buffers; nothing is copied unless you ask. to_text succeeds only for valid UTF-8, and a zero byte is ordinary data. Printing a buffer spells it as a byte literal, with \xhh for bytes that are not printable ASCII; for hex output, see printing bytes.

checksum shows how to work on part of a buffer: borrow it and pass the range. While that borrow is in use the owner cannot change, so growth can never move bytes out from under a reader:

import std.bytes

def main() -> Result[(), AllocError | IoError | std.bytes.BytesError]:
    mut frame = std.bytes.from_text("hi")?
    view = &frame
    frame.push(0u8)?
    print(view.len())?
    Ok(())
conflicting borrow: cannot modify or exclusively borrow `frame` while it is borrowed

File-reading and argument builtins still return list[u8]; from_list converts one with an explicit copy. The output builtins write_stdout_bytes, write_stderr_bytes and write_bytes take a &[u8] view, so pass a buffer or a range of it with no conversion:

import std.bytes

def main() -> Result[(), AllocError | IoError]:
    mut record = std.bytes.from_list(&encode_utf8("name=")?)?
    record.extend(b"\xff\0")?
    write_stdout_bytes(&record[0:5])?
    print(record.len(), record.find(61u8, 0, record.len()))?
    Ok(())
name=7 Option[i64].Some(4)

A parser should not call get once per byte: each call costs far more than the comparison it feeds. The scans search a whole range in one call and return an absolute index. find_in stops at any byte of a set, find_not_in at the end of a run, and matches_text compares in place without copying. to_text_range copies one range out as checked text, with no intermediate Bytes:

import std.bytes

def main() -> Result[(), AllocError | IoError | std.bytes.BytesError | OverflowError[i64]]:
    line = std.bytes.from_text("name=Ada;  role=engineer")?
    stops = std.bytes.from_text("=;")?
    blank = std.bytes.from_text(" ")?
    mut at = 0
    while at < line.len():
        match line.find_in(&stops, at, line.len()):
            case Some(index):
                print(index, line.to_text_range(at, index)?)?
                at = index + 1
            case Nothing:
                print("rest", line.to_text_range(at, line.len())?)?
                at = line.len()
    print(line.find_not_in(&blank, 9, line.len()))?
    print(line.matches_text(11, "role"), line.count(32u8, 0, line.len()))?
    Ok(())
4 name
8 Ada
15   role
rest engineer
Option[i64].Some(11)
True 2

Index syntax reads and writes single bytes, with the same rules as a list index: a negative index counts from the end, and a missing one stops the program with index out of bounds. Iterating over &buffer reads each byte without consuming the buffer:

import std.bytes

def main() -> Result[(), AllocError | IoError | std.bytes.BytesError | OverflowError[u8] | OverflowError[i64]]:
    mut word = std.bytes.from_text("plenty")?
    word[0] = 80u8
    word[-1] -= 32u8
    print(word.to_text()?, word[1])?
    mut vowels = 0
    for b in &word:
        if b == 101u8 or b == 89u8:
            vowels += 1
    print(vowels)?
    word.fill(1, 5, 46u8)
    print(word.to_text()?)?
    Ok(())
PlentY 108
2
P....Y

get and set take the same checked index without counting from the end. for b in &mut word yields &mut u8 references into the buffer, so *b = 0u8 in its body clears every byte in place. A slice such as word[1:3]? copies with the list rules, clamping its bounds. The slice method keeps the range rule instead: frame.slice(1, 3)? copies exactly that range, and a range outside the buffer traps. Both report only AllocError.

Fixed binary data, such as a file signature or a protocol delimiter, is written as a byte literal: b"..." holds ASCII characters, the string escapes, and \xHH for any other byte. Its type is bytes, an immutable value in the program’s read-only data, so it never allocates. It has a length, indexing, and iteration:

import std.bytes

def main() -> Result[(), AllocError | IoError | OverflowError[i64]]:
    magic = b"\x89PNG"
    print(magic, len(magic), magic[1])?
    mut total = 0
    for byte in magic:
        total += i64(byte)
    print(total)?
    Ok(())
b"\x89PNG" 4 80
366

Operations that only read another sequence of bytes, such as find_bytes, matches, find_in, and extend, take a literal as readily as a buffer. Their parameter is a &[u8] slice view: a literal, &buffer, or part of either, &buffer[a:b], converts to it, so each call reads the bytes where they are and nothing is copied. Your own functions can accept every form the same way:

import std.bytes

def starts_png(data: &[u8]) -> bool:
    len(data) >= 4 and data[0] == 137u8 and data[1] == 80u8

def main() -> Result[(), AllocError | IoError]:
    magic = b"\x89PNG"
    mut header = std.bytes.from_bytes(&magic)?
    header.extend(b"\r\n\x1a\n")?
    print(header.find_bytes(b"\r\n", 0, header.len()))?
    print(header.matches(0, &magic[:4]), header.find_in(b"\n\r", 0, header.len()))?
    print(starts_png(&header), starts_png(&header[4:]), starts_png(b"GIF8"))?
    Ok(())
Option[i64].Some(4)
True Option[i64].Some(4)
True False False

The literal is never converted into a Bytes behind your back. To change bytes, or to call a Bytes method such as find on them, copy any view with from_bytes, as header does; the copy allocates, so it returns a Result. A slice of a literal, such as b"GET /index"[4:]?, is also an owned Bytes. Stream writes in std.io accept a literal the same way, as the next lessons show.

A byte literal accepts only ASCII characters, so b"é" is an error; use from_text for the UTF-8 encoding of text. See byte literals for the full rules.

See the module reference for every operation, its costs, and what views cannot do yet.