In C library APIs, one pointer can mean many different things:
int parse_packet(const uint8_t *data, size_t len, struct Packet *out);
void normalize(uint8_t *data, size_t len);
void consume_buffer(uint8_t *data, size_t len);
uint8_t *make_packet(size_t *out_len);
These signatures all involve pointers, but the API meaning differs:
- read-only input
- in-place mutation
- output result
- taking over a caller-owned resource
- creating a new resource for the caller
The first layer of Rust API design is making those intentions visible in the type.
Read-Only Input: Borrow
C read-only buffer:
uint32_t checksum(const uint8_t *data, size_t len);
Rust uses a slice:
fn checksum(data: &[u8]) -> u32 {
data.iter().map(|b| *b as u32).sum()
}
&[u8] means the function temporarily reads bytes. It does not own the memory and cannot mutate it.
Text is similar. Do not write:
fn print_name(name: String) {
println!("{name}");
}
If the function only reads:
fn print_name(name: &str) {
println!("{name}");
}
Callers can pass a string literal or a borrowed String:
print_name("sensor");
let name = String::from("device");
print_name(&name);
First rule:
function only reads: prefer &T / &[T] / &str
In-Place Mutation: Mutable Borrow
C mutates a caller buffer:
void xor_in_place(uint8_t *data, size_t len, uint8_t key);
Rust:
fn xor_in_place(data: &mut [u8], key: u8) {
for byte in data {
*byte ^= key;
}
}
The caller must explicitly pass a mutable borrow:
let mut data = vec![1_u8, 2, 3];
xor_in_place(&mut data, 0xAA);
For a struct:
fn reset_config(config: &mut Config) {
config.retry = 0;
}
Second rule:
function mutates caller data in place: take &mut T / &mut [T]
Creating New Data: Return an Owning Type
C often returns allocated memory plus an output length:
uint8_t *encode_packet(const uint8_t *payload, size_t len, size_t *out_len);
Ordinary Rust APIs should return the owned value:
fn encode_packet(payload: &[u8]) -> Vec<u8> {
let mut out = Vec::with_capacity(payload.len() + 2);
out.push(0xAA);
out.extend_from_slice(payload);
out.push(0x55);
out
}
If it can fail:
fn encode_packet(payload: &[u8]) -> Result<Vec<u8>, EncodeError> {
if payload.len() > u8::MAX as usize {
return Err(EncodeError::TooLarge);
}
let mut out = Vec::with_capacity(payload.len() + 2);
out.push(0xAA);
out.extend_from_slice(payload);
out.push(0x55);
Ok(out)
}
Text is similar:
fn make_topic(device: &str) -> String {
format!("devices/{device}/status")
}
Third rule:
function creates new data: return Vec<T> / String / Box<T> / a custom owned type
Output Parameters: Return When You Can
C output parameters are common:
int parse_header(const uint8_t *data, size_t len, struct Header *out);
Rust can return the parsed value:
fn parse_header(data: &[u8]) -> Result<Header, ParseError> {
if data.len() < 4 {
return Err(ParseError::TooShort);
}
Ok(Header {
version: data[0],
flags: data[1],
len: u16::from_be_bytes([data[2], data[3]]),
})
}
For multiple results, return a tuple or struct:
fn parse_field(data: &[u8]) -> Result<(Field, usize), ParseError> {
let field = parse_one_field(data)?;
Ok((field, field.encoded_len()))
}
Use a mutable output buffer only when you need to reuse caller memory, avoid allocation, or stay close to a low-level interface:
fn encode_into(payload: &[u8], out: &mut [u8]) -> Result<usize, EncodeError> {
if out.len() < payload.len() + 2 {
return Err(EncodeError::TooSmall);
}
out[0] = 0xAA;
out[1..1 + payload.len()].copy_from_slice(payload);
out[1 + payload.len()] = 0x55;
Ok(payload.len() + 2)
}
Fourth rule:
C output parameter: Rust should usually return a value; use &mut only when caller storage matters
Taking Over a Resource: Take by Value
Some C APIs take ownership of caller memory:
void queue_take_packet(uint8_t *packet, size_t len);
After the call, the caller must not free or use the buffer. In Rust, this API should take an owned value:
fn queue_take_packet(packet: Vec<u8>) {
println!("queued {}", packet.len());
}
Caller:
let packet = vec![1_u8, 2, 3];
queue_take_packet(packet);
// packet cannot be used again
If the function only inspects the packet, do not take Vec<u8>:
fn inspect_packet(packet: &[u8]) {
println!("len = {}", packet.len());
}
Fifth rule:
function takes over a resource: take Vec<T> / String / File / a custom owned type by value
function only uses a resource temporarily: take a borrow
Struct Fields Are API Too
API shape also appears in struct fields.
If a struct owns configuration text:
struct Config {
name: String,
}
If it is only a view into external data, it can store a reference, but that exposes a lifetime parameter:
struct View<'a> {
data: &'a [u8],
}
For first-layer library APIs, if you do not want to expose lifetimes to callers, owning the data is often simpler:
struct Packet {
data: Vec<u8>,
}
This does not mean reference fields are wrong. It means they make “this struct cannot outlive the borrowed data” part of the API. Do not make every field a reference by default.
A Decision Table
When designing a Rust library API, ask these questions first:
1. Does the function only read?
-> &T / &[T] / &str
2. Does it mutate in place?
-> &mut T / &mut [T]
3. Does it create new data for the caller?
-> return Vec<T> / String / Box<T> / custom type
4. Does it take over a caller resource?
-> take Vec<T> / String / File / custom owned type by value
5. Does it reuse caller buffer to avoid allocation?
-> input &[T], output &mut [T], return written length or Result<usize, E>
This is more useful than asking whether Rust “wants ownership.” API design is about resource relationships, not syntax.
What to Keep
The first C-to-Rust API mapping:
const T * -> &T
const T * + len -> &[T]
T * + len in-place write -> &mut [T]
return malloc pointer -> return Vec<T> / String / Box<T>
out parameter -> usually return value / Result<T, E>
takes over caller resource -> take an owned type by value
temporary use of resource -> take a borrow
A good Rust API does not make everything Vec, String, or raw pointer. The signature should say whether the function reads, mutates, creates, or takes over.