Binary data uses the ordinary sequence types. There is no nominal bytes or
Buffer struct.
| Spelling | Meaning |
|---|---|
[]byte |
owned, growable List[byte] |
[..]byte |
borrowed, fixed-length Slice[byte] |
Bytes |
the read-only byte protocol both implement |
Buffer |
the grow/append protocol only []byte implements |
byte is a spelling alias for u8, so byte literals carry the u8 suffix.
fn main(): void {
mut greeting: []byte = []
greeting.append_string("hello")
greeting.append(33u8) // '!'
println(greeting.decode_utf8() ?? "")
}Building a buffer
A mut []byte is the string builder. append adds one byte, append_string
adds a string’s UTF-8 bytes, append_all copies any Bytes source, and
append_int/append_float write decimal ASCII straight into the reserved tail
with no intermediate string allocation.
fn status_line(code: int, reason: string): []byte {
mut out: []byte = List.with_capacity[byte](64)
out.append_string("HTTP/1.1 ")
out.append_int(code)
out.append(32u8) // space
out.append_string(reason)
out.append_string("\r\n")
return out
}
fn main(): void {
println(status_line(404, "Not Found").decode_utf8() ?? "")
}List.with_capacity[byte](n) reserves storage up front; reserve(additional)
grows an existing buffer without changing its logical length. Neither changes
what byte_len() reports.
clear() resets the length to zero but keeps the capacity, which makes a
single buffer reusable across a loop instead of allocating per iteration:
fn render_rows(count: int): []string {
mut buf: []byte = List.with_capacity[byte](64)
mut rows: []string = []
for i in 0..count {
buf.clear()
buf.append_string("row ")
buf.append_int(i.to_int())
rows.add(buf.decode_utf8() ?? "")
}
return rows
}
fn main(): void {
for row in render_rows(3) {
println(row)
}
}append_all accepts anything implementing Bytes, so an owned buffer can copy
from another list, a borrowed slice, or a string’s byte view:
fn concat(header: string, payload: []byte, trailer: [..]byte): []byte {
mut packet: []byte = []
packet.append_all(header.bytes())
packet.append_all(payload)
packet.append_all(trailer)
return packet
}Borrowed views
A [..]byte slice borrows the parent allocation. Creating one copies nothing,
which makes it the right parameter type for anything that only reads.
fn checksum(data: [..]byte): int {
mut sum := 0
mut i := 0
for i < data.byte_len() {
sum = (sum + data.byte_at(i).unwrap_or(0u8).to_int()) % 65_536
i = i + 1
}
return sum
}
fn main(): void {
frame := "payload".to_bytes()
println("${checksum(frame.slice(0..4) ?? "".bytes())}")
}slice(range) on a []byte returns Slice[byte]?. Ranges are half-open, and
an out-of-bounds end or a start past the end gives None — carve the view once
and handle absence explicitly:
error FrameError { Short }
fn body(frame: []byte, header_len: int): [..]byte ! FrameError {
match frame.slice(header_len..frame.len()) {
Some(view) => return view
None => error Short
}
}A slice cannot grow — Buffer is physically impossible on a borrow, and the
compiler says so:
fn grow(mut view: [..]byte): void {
view.append(1u8)
}Keep the parent alive for the view’s lifetime, and copy out with to_list()
when the bytes must outlive the parent.
The Bytes protocol
Both []byte and [..]byte implement Bytes, and so does anything else you
implement it for. The trait has two primitives and four derived operations.
| Method | Meaning |
|---|---|
byte_len() |
number of bytes |
byte_ptr() |
arena-relative pointer to byte 0 |
byte_is_empty() |
whether the length is zero |
byte_at(index) |
bounds-checked byte? |
is_valid_utf8() |
strict UTF-8 validation |
decode_utf8() |
string? — validate and copy out |
byte_at returns None for negative and out-of-range indexes, so a scan never
needs its own bounds check.
Writing a function against impl Bytes lets one implementation serve owned
buffers, borrowed views, and string byte views alike:
const HEX: string = "0123456789abcdef"
fn hex_digit(nibble: int): byte {
return HEX.to_bytes().get(nibble.to_u32()) ?? 48u8
}
fn hexdump(data: impl Bytes): string {
mut out: []byte = List.with_capacity[byte](data.byte_len() * 3)
mut i := 0
for i < data.byte_len() {
if i > 0 { out.append(32u8) }
b := (data.byte_at(i) ?? 0u8).to_int()
out.append(hex_digit((b >> 4) & 0xF))
out.append(hex_digit(b & 0xF))
i = i + 1
}
return out.decode_utf8() ?? ""
}
fn main(): void {
owned := "AB".to_bytes()
println(hexdump(owned))
println(hexdump("AB".bytes()))
}Note the shape of that loop: the output buffer is the accumulator. There is no
string.join to reach for — it is declared in the prelude but has no lowering
path yet (see Availability below), and a []byte you append to
is faster anyway.
byte_ptr() is a low-level arena-relative pointer intended for the prelude and
host wrappers. Do not retain it across buffer mutation, scope exit, or a
suspension point unless a lower-level contract pins the allocation.
Text conversion
Text and bytes never convert implicitly. The compiler rejects the confusion:
fn as_text(data: []byte): string {
return data
}Four explicit conversions cover the traffic in both directions:
fn conversions(text: string, data: []byte): int {
owned: []byte = text.to_bytes() // copies
borrowed: [..]byte = text.bytes() // borrows, no copy
decoded: string? = data.decode_utf8()
valid: bool = data.is_valid_utf8()
return owned.len() + borrowed.len() + (decoded ?? "").len() + (if valid { 1 } else { 0 })
}decode_utf8 performs strict Unicode validation before copying into a fresh
string: overlong encodings, surrogate code points, truncated sequences, stray
continuation bytes, and values above U+10FFFF are all rejected. It returns
string?, and forgetting that is a type error, not a silent lossy decode:
fn decode(data: []byte): string {
return data.decode_utf8()
}If your protocol wants replacement characters instead of rejection, implement that policy yourself — the standard decoder is strict, never lossy.
error WireError { NotUtf8 }
fn strict(payload: []byte): string ! WireError {
match payload.decode_utf8() {
Some(text) => return text
None => error NotUtf8
}
}
fn lenient(payload: []byte): string {
return payload.decode_utf8() ?? "<binary>"
}Empty buffers
An empty []byte or [..]byte reports byte_len() == 0 and
byte_is_empty() == true. Its byte_ptr() value is an implementation detail
and may be null-like, so never dereference it without checking the length
first. The safe protocol methods already enforce that rule, which is another
reason to prefer byte_at over pointer arithmetic.
fn first_byte(data: impl Bytes): byte? {
if data.byte_is_empty() { return None }
return data.byte_at(0)
}A worked example
A length-prefixed frame codec: build with Buffer, parse with Bytes and
slice, and decode text only at the boundary.
error CodecError { Short, BadLength, NotUtf8 }
const HEADER_LEN: int = 5
fn encode(kind: byte, payload: string): []byte {
body := payload.to_bytes()
mut frame: []byte = List.with_capacity[byte](HEADER_LEN + body.len())
frame.append(kind)
// 4-byte big-endian length.
n := body.len()
frame.append(((n >> 24) & 0xFF).to_u8())
frame.append(((n >> 16) & 0xFF).to_u8())
frame.append(((n >> 8) & 0xFF).to_u8())
frame.append((n & 0xFF).to_u8())
frame.append_all(body)
return frame
}
fn read_length(frame: impl Bytes): int {
mut n := 0
mut i := 1
for i < HEADER_LEN {
n = (n << 8) | (frame.byte_at(i) ?? 0u8).to_int()
i = i + 1
}
return n
}
fn decode(frame: []byte): string ! CodecError {
if frame.byte_len() < HEADER_LEN { error Short }
declared := read_length(frame)
if declared != frame.byte_len() - HEADER_LEN { error BadLength }
match frame.slice(HEADER_LEN..frame.len()) {
None => error Short
Some(body) => match body.decode_utf8() {
Some(text) => return text
None => error NotUtf8
}
}
}
fn main(): void {
frame := encode(7u8, "ping")
match decode(frame) {
Ok(text) => println("kind=${frame.byte_at(0) ?? 0u8} payload=${text}")
Err(e) => println("bad frame")
}
}Availability
Everything on this page compiles. Two neighbouring signatures that a byte-handling
program reaches for do not, and atoll check will not warn you — the failure
arrives at atoll build as ATOLL2004: builtin method ... has no lowering path.
| Signature | Status |
|---|---|
string.join(items, sep) |
declared in the prelude, no lowering path yet |
int.to_radix(base) |
declared in the prelude, no lowering path yet |
Accumulate into a mut []byte instead of joining, and format hex or binary from
a digit table as hexdump above does.