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

Ownership and Borrowing

Flint uses ownership and borrowing to control moves, mutation, and aliasing, but keeps the source language surface explicit and lightweight. You do not write borrow operators, dereference operators, or lifetime annotations in ordinary Flint code. Instead, you express intent through parameter modes such as default read-only access, mut for mutable access, and owned for consuming a value. If you are coming from Rust, the underlying discipline is familiar, but Flint handles the reference mechanics for you.

Core Rules

  1. Every non-copy value has exactly one owner.
  2. Assigning a non-copy value moves ownership.
  3. Returning a non-copy value moves ownership.
  4. Use-after-move is a compile error.
  5. Mutable aliasing is not allowed.

Copy Types

These types are automatically duplicated on assignment, with no move:

  • All integer types (u8, u16, u32, u64, i8, i16, i32, i64, usize, isize)
  • bool, char, f32
  • Structs and enums whose fields are all copy types

Everything else is move-only. If you need a duplicate of a non-Copy value, call .clone() explicitly.

Built-In Traits and Clone

Flint keeps ownership-related duplication rules at the language and standard-library level, not as ad hoc method-name conventions.

  • Copy is a compiler-known structural property, not a user-defined trait.
  • Clone is the standard built-in trait for explicit duplication of non-Copy values.
  • Clone lives in core/clone and is available through the global prelude, so impl Clone for MyType works without use core/clone.
struct Buffer {
    data: [u8; 1024]
}

impl Clone for Buffer {
    fn clone(self) -> Self {
        return Self { data: self.data }
    }
}

let a = Buffer { data: [0; 1024] }
let b = a.clone()     // explicit duplicate

Use Clone when the type has meaningful duplication semantics and a plain move would be too restrictive. If a type is already Copy, you do not need Clone for ordinary assignment or passing by value.

A common example is a multi-producer channel sender. In an MPSC setup, you often want several parts of the program to send into the same channel. That is a good fit for Clone: cloning the sender gives you another handle to the same channel endpoint, rather than moving the only sender away from the original owner.

use std/sync

let ends = sync.channel<u32>(16)?
let tx1 = ends.tx
let tx2 = tx1.clone()

tx1.send(1)?
tx2.send(2)?

Here tx1 and tx2 both refer to the same channel’s sending side. Clone is the right model because the duplication is intentional and semantically meaningful.

Move Semantics

fn send(owned packet: Packet) -> Result<(), Error> {
    // packet is consumed here
}

let packet = Packet.new(data)
send(packet)       // packet is moved into send()
log.info(packet)   // error: use of moved value

let packet2 = Packet.new(data)
let saved = packet2     // packet2 is moved into saved
log.info(packet2)       // error: use of moved value

Assigning over a live value drops the previous value first:

let mut buf = Buffer.new(64)
buf = Buffer.new(128)    // previous buf is dropped, then new value stored

Parameter Modes

Flint puts ownership intent on the function signature, not the call site. There are three parameter modes:

Default (Read-Only Borrow)

Non-copy values are passed by implicit read-only reference. The caller retains ownership:

fn measure(data: List<u8>) -> usize {
    return data.len()
}

let samples = read_adc()
let n = measure(samples)   // samples is still live after this call

Copy types are passed by value.

mut (Mutable Borrow)

The callee may mutate the value. The caller retains ownership:

fn append(mut buf: List<u8>, byte: u8) {
    buf.push(byte)
}

let mut data = List.new()
append(data, 0xFF)
log.info(data.len())   // 1 (mutation is visible to the caller)

owned (Consume / Move)

The callee takes ownership. The caller loses the value:

fn send(owned packet: Packet) -> Result<(), Error> {
    // packet is consumed here
}

let p = Packet.new(payload)
send(p)?              // p is moved into send
// p is no longer accessible here

Self Receivers

The same modes apply to method receivers:

impl Buffer {
    fn len(self) -> usize { ... }           // read-only
    fn push(mut self, byte: u8) { ... }        // mutating
    fn consume(owned self) -> slice<u8> { ... } // consuming
}
let mut buf = Buffer.new(32)
let n = buf.len()        // ok, immutable method
buf.push(0x01)           // ok, mutable method
let data = buf.consume() // buf is moved into consume, no longer accessible

Partial Moves

You can move an individual field out of an owned struct without moving every field at once. When that happens, the original struct becomes partially moved.

This can feel unusual at first, but the rule is straightforward:

  • the field you moved now has a new owner
  • fields you did not move are still accessible on their own
  • the original struct can no longer be used as a complete value

The reason is safety and consistency. Once one field has been moved away, the compiler can no longer treat the original struct as fully intact. Allowing you to use the whole struct again would mean pretending that all of its parts are still present, which is no longer true.

Basic Example

struct Request {
    headers: Map<string, string>
    body: List<u8>
}

let req = Request { headers: ..., body: ... }
let body = req.body          // move body out
process(body)
// req is now partially moved; req.headers is still accessible:
let auth = req.headers.get("Authorization")
// but cannot use req as a whole:
let r = req    // error: partial move

req.body moved into body, so req is no longer a complete Request. The compiler still lets you use req.headers because that field was never moved.

What Is Still Allowed

You can keep working with the fields that remain in place:

struct Response {
    status: u16
    payload: List<u8>
}

let response = Response { status: 200, payload: bytes }
let payload = response.payload

let code = response.status   // ok, status is still present
use_payload(payload)
log.info(code)

This is valid because status and payload are tracked independently. Moving payload does not erase status.

What Is Not Allowed

You cannot pass, return, assign, or pattern-match the original struct as though it were still complete:

struct Job {
    id: u32
    data: List<u8>
}

let job = Job { id: 7, data: bytes }
let data = job.data

submit(job)        // error: job is partially moved
let saved = job    // error: job is partially moved

In both lines, the operation needs the whole Job, but the data field has already been moved out.

Why This Is Useful

Partial moves let you take ownership of exactly the part you need without forcing unnecessary cloning or awkward restructuring.

For example, if you only need the request body for a parser, moving just that field is more direct than duplicating the entire request:

let body = req.body
parse(body)

That is efficient, but the tradeoff is that req can no longer be treated as a complete value afterward.

If You Still Need the Whole Value

If you need to keep using the whole struct later, do not move the field out directly. Instead:

  • borrow the struct through a read-only or mut parameter
  • clone just the field you need
  • reorganize the code so the whole struct is no longer needed after the move
let body = req.body.clone()
archive(req)           // ok, req is still complete
parse(body)

The key idea is simple: moving a field splits ownership of the struct into pieces, and once that happens, the compiler stops treating the original binding as one complete object.

Interior Mutability

Normally, mutation in Flint requires a mut binding or a mut parameter. Interior mutability is the deliberate exception: a type may allow mutation through an immutable outer binding because the type itself enforces the safety rule.

This does not weaken ownership. It moves the rule from “the binding must be mutable” to “this API is responsible for making mutation safe”. In practice, Flint uses this pattern for synchronization and one-time initialization primitives such as core/sync.OnceCell<T>, core/sync.Lazy<T>, atomics, and std/sync.Mutex<T>.

Why It Exists

Some values need to change even when the outer handle should stay fixed:

  • global one-time initialization
  • shared state behind a lock
  • atomic counters updated from multiple places

The outer binding stays immutable, but the wrapper type controls when mutation is legal.

Example: One-Time Initialization

OnceCell<T> is interior mutability in a simple form. The static binding is immutable, but the cell can transition from “empty” to “initialized” exactly once:

use core/sync

static DEVICE_ID: sync.OnceCell<u32> = sync.OnceCell.new()

fn init() -> () {
    DEVICE_ID.set(read_chip_id())
}

fn device_id() -> u32 {
    return DEVICE_ID.get().unwrap_or(0)
}

DEVICE_ID is not declared with mut, and that is correct. The mutation happens inside OnceCell, which only permits a single successful write.

Example: Shared Mutable State Behind a Lock

With Mutex<T>, the immutable outer handle gives controlled mutable access to the inner value:

use std/sync

let queue: sync.Mutex<List<u8>> = sync.Mutex.new(List.new())?

fn push_byte(byte: u8) -> Result<(), Error> {
    let mut guard = queue.lock()?
    defer guard.release()

    guard.value.push(byte)?
    return Ok()
}

The binding queue is immutable. The mutable access appears only after lock() returns a guard. That guard is the proof that mutation is currently exclusive, so guard.value.push(...) is allowed without violating the ownership model.

Mental Model

Interior mutability means “immutable handle, controlled mutable interior”. Use it when the wrapper type is specifically designed for that job. Do not treat it as a general escape hatch around borrowing rules.

No Borrow Syntax

There are no &, &mut, *, or lifetime annotations in Flint source. The compiler handles all of this internally based on parameter modes. The ABI details (hidden address vs. value register) are implementation concerns, not source language concerns.

If you are coming from Rust: think of Flint as a language where the borrow checker exists but is invisible to you. The rules are real; you just don’t write them by hand.