Skip to content

Data modeling

TurboPython programs model data with the same building blocks as Python. Classes describe records with behavior, containers hold collections, tuples group a fixed set of values, and unions describe a value that is one of several alternatives. All are written as in Python, and the largest departure is that method calls bind at compile time.

Classes

Fields are declared as annotations and form a fixed set. Methods are written as in Python, and self is a borrowed parameter (Functions and API boundaries covers borrowing). Dunder methods work too — __str__ drives print and f-strings, and __eq__ drives ==. Class-level field defaults, @property, and @staticmethod all work.

The Reading example from the earlier pages, extended to a full class:

from tpy import Own, Int32

class Reading:
    sensor: str
    values: list[float]

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

    def add(self, v: float) -> None:
        self.values.append(v)

    def mean(self) -> float:
        return sum(self.values) / len(self.values)

    @property
    def count(self) -> Int32:
        return len(self.values)

    def __str__(self) -> str:
        return f"Reading({self.sensor}, n={len(self.values)})"
def main():
    r = Reading("boiler-3", [20.5, 21.0])
    r.add(21.7)
    print(r)                  # __str__: Reading(boiler-3, n=3)
    print(r.count, r.mean())  # 3 21.066...

main()

Inheritance and dispatch

Inheritance works, but overriding does not dispatch dynamically. A call through a base-typed reference calls the base method, and the compiler warns about every override that would behave differently under CPython:

class Animal:
    name: str
    def __init__(self, name: str):
        self.name = name
    def sound(self) -> str:
        return "..."

class Dog(Animal):
    def __init__(self, name: str):
        super().__init__(name)
    def sound(self) -> str:   # warning: hides Animal.sound (bound statically)
        return "woof"
warning: Method 'Dog.sound' hides 'Animal.sound' -- any 'Animal' reference
will call 'Animal.sound', not 'Dog.sound' (differs from Python's dynamic
dispatch); make 'Animal' a @dynamic protocol for runtime dispatch

Differs from CPython

In CPython, calling sound() on a Dog held in an Animal-typed variable runs Dog.sound — dispatch is dynamic. In TurboPython the call binds at compile time to Animal.sound. The compiler flags every such override with the hides ... differs from Python's dynamic dispatch warning shown above. The fix is a @dynamic protocol, which restores runtime dispatch where a design needs it (Beyond the core).

In TurboPython, polymorphism is usually modeled without inheritance: a protocol when several types share behavior, a union when a value is one of a fixed set of alternatives. In both, the data stays on the concrete classes; the protocol or union carries only the choice.

Protocols

A protocol is a named interface — a set of method signatures with no bodies. Any class whose methods match satisfies the protocol automatically, with no inheritance and no registration. Conformance is checked at compile time:

from typing import Protocol

class Measurable(Protocol):
    def size(self) -> int: ...

class Batch:
    n: int
    def __init__(self, n: int):
        self.n = n
    def size(self) -> int:
        return self.n

def total(m: Measurable) -> int:
    return m.size()

def main():
    print(total(Batch(5)))   # 5

main()

By default a protocol is resolved statically. Each call site is compiled against the concrete type it receives, at no runtime cost. A protocol marked @dynamic (from tpy) instead dispatches through a vtable, so a single reference can hold different implementers chosen at runtime, the way inheritance does in Python. Beyond the core covers @dynamic protocols.

Containers

list, dict, and set work as in Python and nest freely. A dict keeps insertion order, as in Python. A container owns its elements (Ownership and references).

def main():
    by_sensor: dict[str, list[float]] = {}
    by_sensor["boiler-3"] = [20.5, 21.0]
    by_sensor["boiler-7"] = [19.0]
    if "boiler-3" in by_sensor:
        by_sensor["boiler-3"].append(21.7)
    for name in by_sensor:            # insertion order, as in Python
        print(name, len(by_sensor[name]))
    seen: set[str] = set()
    seen.add("boiler-3")
    seen.add("boiler-3")
    print(len(seen))                  # 1

main()

Sorting accepts Python's key functions, including lambdas:

def main():
    xs: list[float] = [3.0, 1.0, 2.0]
    print(sorted(xs, key=lambda v: -v))   # [3.0, 2.0, 1.0]

main()

Tuples

A tuple groups a fixed number of values, each of its own type, and behaves as in Python. Elements are reached by a constant index or by unpacking, and tuples compare element by element. A tuple of hashable values also works as a dictionary key.

def main():
    pair: tuple[str, float] = ("mean", 20.9)
    label, value = pair                # unpack
    print(label, value)                # mean 20.9
    print(pair[0])                     # index with a constant
    print(("a", 1) == ("a", 1))        # True -- element-wise comparison
    seen: dict[tuple[int, int], str] = {(1, 2): "cell"}
    print((1, 2) in seen)              # True -- a tuple works as a dict key

main()

Each element keeps the passing semantics it would have on its own. A value-type element is copied; a reference-type element is held by reference, the same way a standalone parameter or return of that type would be. Wrapping a value in a tuple slot therefore does not change how it is passed: a function whose return type goes from Reading to tuple[Reading, bool] still returns the Reading by reference. Ownership and references covers the rules the elements follow.

Unions

A union describes a value that is exactly one of several classes, written A | B. A match statement branches on the member a value holds and binds its fields in the pattern:

class Circle:
    radius: float
    def __init__(self, radius: float):
        self.radius = radius

class Square:
    side: float
    def __init__(self, side: float):
        self.side = side

def area(shape: Circle | Square) -> float:
    match shape:
        case Circle(radius=r):
            return 3.14159 * r * r
        case Square(side=s):
            return s * s

def main():
    print(area(Circle(1.0)))    # 3.14159
    print(area(Square(2.0)))    # 4.0

main()

A union used in more than one place can be named with a type alias, written type Shape = Circle | Square, and the name then stands for the union anywhere a type is expected.

A match that misses a member cannot fall through silently. With a declared return type, every path must return, and a guarded case does not count as covering its member, so the bare case Sample(): below is required:

class Sample:
    value: float
    def __init__(self, value: float):
        self.value = value

class Gap:
    reason: str
    def __init__(self, reason: str):
        self.reason = reason

def describe(x: Sample | Gap) -> str:
    match x:
        case Sample(value=v) if v > 21.0:
            return "high"
        case Sample():
            return "normal"
        case Gap(reason=r):
            return f"missing: {r}"

def main():
    print(describe(Sample(21.5)))            # high
    print(describe(Sample(20.0)))            # normal
    print(describe(Gap("sensor offline")))   # missing: sensor offline

main()

A union stores in containers like any other value, and case _: is a valid catch-all pattern:

def main():
    stream: list[Sample | Gap] = [Sample(20.5), Gap("offline"), Sample(21.5)]
    good = 0
    for x in stream:
        match x:
            case Sample():
                good += 1
            case _:
                pass
    print(good, len(stream))   # 2 3

main()

For a one-off test, isinstance(x, Sample) is also valid on a union value. Unions with match replace isinstance chains, and often replace a whole inheritance hierarchy.

Dataclasses

@dataclass writes the methods a plain class spells out by hand. It is a built-in compile-time macro that generates __init__, __repr__, and __eq__ from the annotated fields. Passing frozen=True additionally makes instances hashable, so they can serve as dict keys or set members. The decorator is imported from dataclasses, as in Python.

from dataclasses import dataclass

@dataclass
class Point:
    x: float
    y: float

@dataclass(frozen=True)
class Cell:
    row: int
    col: int

def main():
    p = Point(1.0, 2.0)
    print(p)                        # Point(x=1.0, y=2.0)
    print(p == Point(1.0, 2.0))     # True
    grid: dict[Cell, str] = {Cell(0, 0): "origin"}
    print(grid[Cell(0, 0)])         # origin -- frozen is hashable

main()

How programs branch and fail — None narrowing, try/except, iterators — is the next page: Control flow, None & errors.