Serialization and Deserialization patterns

I tried to illustrate the problem I have with scenario 2 (deserialize unbounded list of integers.

The problem I have is that it is difficult to express a reserve first API. Its difficult for me to provide an API (to myself) that is not programmer-error prone that allows me to express my assumptions about the size of the input.

For example, what if I know that there will be at most 5 integers to deserialize?

Lets take a look at how I can encode this assumption into my program:

const std = @import("std");

// scenario 2: unbounded list of integers
/// caller must free returned memory
fn deserializeIntsAlloc(allocator: std.mem.Allocator, reader: *std.Io.Reader) ![]u16 {
    var list = std.ArrayList(u16).empty;
    errdefer list.deinit(allocator);
    while (true) {
        const bytes_containing_int: []const u8 = reader.takeDelimiterExclusive(' ') catch |err| switch (err) {
            error.EndOfStream => break,
            error.StreamTooLong => return error.StreamTooLong,
            error.ReadFailed => return error.ReadFailed,
        };
        const num = try std.fmt.parseInt(u16, bytes_containing_int, 10);
        try list.append(allocator, num);
    }
    return try list.toOwnedSlice(allocator);
}

test "alloc four ints" {
    const serialized: []const u8 = "1 51 765 65535";
    var reader = std.Io.Reader.fixed(serialized);

    const deserialized = try deserializeIntsAlloc(std.testing.allocator, &reader);
    defer std.testing.allocator.free(deserialized);

    const expected: []const u16 = &.{ 1, 51, 765, 65535 };
    try std.testing.expectEqualSlices(u16, expected, deserialized);
}

// scenario 2: how can we provide errorless  / reserve first API?
// For example, the user knows there are only up to 5 ints ahead of time

// this sucks!
test {
    const serialized: []const u8 = "1 51 765 65535";
    var reader = std.Io.Reader.fixed(serialized);

    // Guessing sizes here! Depends on the efficiency of the allocator!
    // Look at how much work it is to do "reserve first", something
    // we should encourage!
    var stack_memory: [4096]u8 = undefined;
    var fba = std.heap.FixedBufferAllocator.init(&stack_memory);

    const deserialized = deserializeIntsAlloc(fba.allocator(), &reader) catch |err| switch (err) {
        error.OutOfMemory => unreachable,
        error.ReadFailed => return error.ReadFailed,
        error.StreamTooLong => return error.StreamTooLong,
        error.Overflow => return error.Overflow,
        error.InvalidCharacter => return error.InvalidCharacter,
    };

    const expected: []const u16 = &.{ 1, 51, 765, 65535 };
    try std.testing.expectEqualSlices(u16, expected, deserialized);
}


And here is my bonus solution to scenario 1 (returning the de serialized values on the stack)

// scenario 1: 4 fixed-width integers

/// return 4 ints on the stack because its small and we prefer not to allocate
/// if we do not have to.
fn deserializeFourInts(reader: *std.Io.Reader) ![4]u16 {
    var result: [4]u16 = undefined;
    for (&result) |*num| {
        const bytes_containing_int: []const u8 = try reader.takeDelimiterExclusive(' ');
        num.* = try std.fmt.parseInt(u16, bytes_containing_int, 10);
    }
    return result;
}

test "four ints" {
    const serialized: []const u8 = "1 51 765 65535";
    var reader = std.Io.Reader.fixed(serialized);
    const deserialized = try deserializeFourInts(&reader);

    const expected: []const u16 = &.{ 1, 51, 765, 65535 };
    try std.testing.expectEqualSlices(u16, expected, &deserialized);
}