Skip to content

Functions and API boundaries

A function signature in TurboPython is a contract the compiler enforces. Where Python treats an annotation as a hint the runtime ignores, a TurboPython annotation is binding. A function accepts and returns only the types its signature allows, and a call that passes anything else does not compile. The compiler generates the calling code from that same signature, which also fixes how each argument is represented. That is where the ownership model becomes visible. A call either copies its argument, mutates it, or keeps it. This page covers what a signature promises and how to design small, clear APIs, reusing the Reading class from the Types page.

from tpy import Own

class Reading:
    sensor: str
    values: list[float]
    def __init__(self, sensor: str, values: Own[list[float]]):
        self.sensor = sensor
        self.values = values

Signatures must be fully typed

Parameter and return types are mandatory; the compiler rejects an unannotated signature. Default values, keyword arguments, and annotated *args work as in Python:

from tpy import Int32

def scale(x: float, factor: float = 2.0) -> float:
    return x * factor

def total(*xs: Int32) -> Int32:
    t: Int32 = 0
    for x in xs:
        t += x
    return t

def main():
    print(scale(3.0))               # 6.0
    print(scale(3.0, factor=0.5))   # 1.5
    print(total(1, 2, 3))           # 6

main()

Passing borrows

Passing a reference-type object never copies it. The parameter borrows the caller's object — in Python terms, it aliases it: it becomes another name for the same object. A mutation made inside the function is therefore visible to the caller. (Ownership and references, the next page, defines borrowing in full.)

def add_value(r: Reading, v: float) -> None:
    r.values.append(v)

def main():
    r = Reading("boiler-3", [20.5])
    add_value(r, 21.0)
    print(len(r.values), r.values[1])   # 2 21.0

main()

Methods follow the same rules. self is a borrowed parameter, and a mutation through it is visible to the caller. Classes have their own page: Data modeling.

Value types (Int32, float, str, ...) are passed as copies. Reassigning the parameter changes the copy, never the caller's variable — observably the same as Python:

from tpy import Int32

def bump(x: Int32) -> Int32:
    x += 1        # changes the local copy only
    return x

def main():
    n: Int32 = 5
    m = bump(n)
    print(n, m)   # 5 6

main()

Returning a reference or a fresh object

For reference types, a plain return type returns a reference. That is the right shape when the object outlives the function — an element of a caller's list, a field of a parameter. A freshly built object needs Own[...], which hands ownership of the object to the caller. (Value types are returned by copy, as always.)

def first(items: list[Reading]) -> Reading:
    return items[0]          # a reference into the caller's list

def load(sensor: str) -> Own[Reading]:
    return Reading(sensor, [20.5, 21.0])   # ownership moves to the caller

Returning a local by plain reference is a compile error, covered by Ownership and references.

Taking ownership at the boundary

An Own[T] parameter declares that the function keeps the object — typically to store it. Reading's own constructor does this with its list: values: Own[list[Float64]] moves the caller's list into the field.

At the call site the caller writes nothing extra. An Own return is used like any other object, and passing to an Own parameter at the argument's last use hands the object over with no copy:

def archive(store: list[Reading], r: Own[Reading]) -> None:
    store.append(r)          # the function keeps the object

def main():
    store: list[Reading] = []
    r = load("boiler-3")     # r is used like any other Reading
    print(r.sensor)          # boiler-3
    archive(store, r)        # last use of r -- handed over, no copy
    print(len(store))        # 1

main()

A caller that keeps using the argument afterwards should pass copy(...); otherwise the compiler makes the copy anyway and warns about it. The mechanics are on the ownership page.

Optional results

A function that may find nothing returns T | None, and the caller narrows before use:

def find(items: list[Reading], sensor: str) -> Reading | None:
    for r in items:
        if r.sensor == sensor:
            return r
    return None

def main():
    items: list[Reading] = [Reading("a", [1.0]), Reading("b", [2.0])]
    hit = find(items, "b")
    if hit is not None:
        print(hit.sensor)            # b
    print(find(items, "zz") is None) # True

main()

Own[...] appears only where an object crosses into or out of long-lived storage; a plain T parameter and return cover everything else. The next page, Ownership and references, defines the model behind all of this.