Docs contents

Ownership

Contents · Guide · Types

To give a function a string it may keep, pass an owned value. In Python, assigning a list to another name makes both names refer to the same list. Nagi distinguishes giving a value away, lending it for reading, and sharing ownership.

def use_name(name: str):
    print(name)

def main():
    name = "alice"
    use_name(name)
    # print(name) would use a moved value

This complete program prints alice. After the call, the original name cannot use that string. Giving the value away is called a move; its new owner also takes responsibility for cleanup. Moving is not itself closing or destroying the value.

Uncommenting print(name) makes check reject use after move. If the function only reads, accept view[str] and pass view(name). If it needs an independent string, pass copy(view(name)). See the runnable borrowing example.

Here is a common mistake and two fixes:

from std.ownership import move

def show(name: view[str]):
    print(name)

def main():
    name = "Nagi"
    destination = move(name)
    print(name)  # check rejects this: name no longer owns a value

If destination should own the value, use print(destination) instead. If both names should remain usable, make an explicit copy with destination = copy(view(name)), or borrow with show(view(name)) and keep using name. Moving does not make the original variable usable again.

On this page

Assignment and explicit move

The explicit move and assignment rules below are published in Nagi 0.1.11. Confirm that the installed compiler is version 0.1.11 or later. Python assignment keeps another reference to the same value. Nagi uses ordinary assignment for Copy values, such as numbers and bools. To transfer an existing non-Copy local, use move; ordinary destination = name is rejected.

Writing move makes it clear that assignment gives the original variable's value away. The compiler does not choose a copy or shared ownership for you when you want to keep using the original.

from std.ownership import move

def main():
    count = 2
    same_count = count
    print(count + same_count)
    name = "Nagi"
    destination = move(name)
    print(destination)
    name = "new"
    print(name)

Output: 4, Nagi, new. destination = move(name) transfers ownership and cleanup responsibility. At the source level, this requests a transfer rather than a clone or another shared handle; Nagi does not promise that a transfer has zero runtime cost.

Reading name after the move and before reassignment is rejected. The variable name is still available: assigning a new value allows reuse. To keep the original string too, replace the move assignment above with destination = copy(view(name)). To read without owning, use view(name); to retain the same value in several places, use share and clone_shared.

Fresh construction, including name = "Nagi" and a = User(...), needs no move annotation. This change covers a bare owned non-Copy local RHS in a declaration, annotated assignment, or reassignment, including parentheses. Arguments, returns, field/index extraction, try, and match retain their existing consumption rules. move(name) may also be passed or returned, without bypassing borrow restrictions.

For a Copy type, even move(count) leaves the original count usable. Copy rules are unchanged: views, function values (including supported local async function aliases), UUIDs, timestamps, and classes/enums/nullable/owned values whose contents are Copy. Result and shared remain non-Copy even with Copy payloads. A copied view still requires its owner to remain valid; copying a function value does not allow storing its Future. See types, async, and design decisions.

move is imported from std.ownership, accepts one inferred argument, and does not accept explicit type arguments. Use ownership.move(name) after import std.ownership as ownership, or transfer(name) after from std.ownership import move as transfer. User functions named move keep their own meaning. Standard operations are not first-class function values. Non-Copy fields cannot be taken from borrowed/shared parents. Futures and nested Futures cannot be inputs; move(await operation(...)) is allowed when the awaited result type is supported.

Retain the same value in several places

To retain a shared value instead of making independent copies, use share and clone_shared. Python's two-name reference assignment does not require these explicit operations. This complete Nagi example uses a class with a string field:

class Label:
    text: str

def main():
    label = share(Label(text="Nagi"))
    another = clone_shared(label)
    duplicate = copy(view(another.text))
    print(label.text)
    print(duplicate)

Output: Nagi, Nagi. share(value) takes ownership and returns shared[T]. clone_shared(label) creates another handle to the same value without copying the payload. copy(view(another.text)) instead creates an independent owned string.

Trying text = another.text would move a non-Copy field out of shared data, so check rejects it. Read the field, or copy it as above. Ordinary shared access does not permit arbitrary writes; resources with internal state need APIs that define their synchronization and operation rules. Sharing does not make every T thread-safe, and some resources cannot be shared or independently copied. See built-ins and concurrency.

The last shared handle releasing its value is distinct from an external service completing shutdown. Reference cycles can also retain shared values; there is no promise that arbitrary sharing graphs are automatically collected. Use the resource's explicit shutdown/close API when completion matters.

Taking a class field

Assigning a string field to another variable moves ownership of that string. You cannot reuse the moved field, but the other fields remain available.

class Person:
    name: str
    age: i64

def main():
    person = Person(name="Nagi", age=1)
    name = person.name
    print(name)
    print(person.age)
    # print(person.name) would use a moved value

To keep the original field too, change the assignment above to name = copy(view(person.name)). Make the copy before moving the value. Reading a string with print or len does not move it.

For a function that only needs to read, see Borrow with view.

Using a value in a loop

Passing a string created outside a loop as an owned argument moves it on the first iteration. It is then unavailable on the next iteration, so check rejects this reuse. The same rule applies to taking fields and passing owned values in a while condition.

To keep the original value, make a copy on each iteration. For a function that only reads, declare its argument as view[str] and pass view(name).

def use_name(name: str):
    print(name)

def main():
    name = "Nagi"
    for number in range(2):
        use_name(copy(view(name)))
    print(name)

You can also assign a new value before the next iteration.

def use_name(name: str):
    print(name)

def main():
    name = "first"
    for number in range(2):
        use_name(name)
        name = "next"
    print(name)

If reassignment depends on a branch, every path that continues to the next iteration must provide a value. A path that ends the function with return does not affect the next iteration or subsequent statements. Values created inside the loop are available anew on each iteration.

The checker accounts for repetition and zero iterations without calculating the iteration count. An assignment inside a loop therefore does not always make a value available after the loop. A while condition is also evaluated when the loop exits.

Borrowing and the limits of checking

for value in values borrows the array being iterated. On paths that continue to the next iteration, check rejects appending to, reassigning, or moving that array. This also applies when iterating through view(values) or a variable holding that view. Other arrays can be changed, and the iterator's borrow ends when the loop finishes.

def main():
    values = [1, 2]
    output: List[i64] = []
    for value in values:
        append(output, value * 2)
    append(values, 3)

Copy elements, such as numbers, are copied into the loop variable. Non-Copy elements, such as classes containing strings, are borrowed for reading. Iteration does not clone their strings or records.

class User:
    name: str
    score: i64

def main():
    users = [User(name="Nagi", score=10)]
    for user in users:
        print(user.name)
        print(user.score)
        name = copy(view(user.name))
        print(name)
    append(users, User(name="凪", score=20))

A borrowed element cannot be reassigned. You cannot pass or return the whole element, or a non-Copy field, as an owned value. To keep a string or array, copy the field explicitly as shown above. A field view created inside the loop cannot be stored outside it. Borrowing a non-Copy enum, nullable, or Result payload through match is not yet supported.

Borrowing one field leaves separate fields available. For example, while view(data.values) is in use, you can still take data.name. You cannot move all of data or its values field.

While a view borrows data, moving, reassigning, or appending to that data is restricted. The checker tracks borrows by code blocks; it does not determine the end of a borrow as precisely as Rust.

A view-containing return type can accept a directly constructed value with no borrow, such as return None, return [], or return ok(None). Returning a local of a view-containing type still requires a tracked borrowing origin, even if it currently holds an empty value.

At the top level of a block that ends in return, a view can temporarily borrow local data, then be restored to an input view and returned. Generated Rust gives each assignment its own inferred borrow lifetime. Updates used after a branch or by later loop iterations remain mutations; this adds no owned-value copies. The execution tests cover High, saved Low, and handwritten Low. See the Changelog for release availability.

Lists, Results, and Options containing views can temporarily hold local views, restore input views, and be returned. This covers moves through aliases, nested values, if/match branches, for/while, and async functions. Generation gives successive values separate storage, evaluates the RHS before releasing the old value, and adds no owned-value copies to extend lifetimes. The generation/execution tests and allocation/cleanup tests cover High, saved Low, and handwritten Low. See the Changelog for release availability.

The real-runtime scope tests also cover this pattern inside scopes: normal completion, error propagation, parent cancellation, and body panic. Normal error exits await child cancellation. Dropping the parent Future or unwinding requests child termination, but synchronous destruction alone cannot await completion.

Restoring a view does not make every program valid. The checker still rejects escaping local borrows. For example, returning a local view-containing binding requires a tracked origin even when its current value is empty. User-defined classes/enums containing views and arbitrary async function values remain unsupported.

Nagi's checker provides move and borrow diagnostics at Nagi source locations. It does not replace the checker for generated Rust. Nagi can also conservatively reject code that Rust would accept.

Matching Rust adapter signatures, the Clone required by copy, Send for async work, and Sync for shared state are also ultimately checked by build. Wrapping T in shared[T] does not itself make T suitable for concurrent use. Both Nagi and Rust checks must pass to produce an executable.

To keep an independent owned copy of the original data, write copy(view(data)).

Nagi 0.2.0 · DocsPage source