Skip to content

Collections

{} is a set and {:} is a dictionary

Python uses {} for an empty dictionary and requires set() for an empty set. Lucid uses {} for an empty set and {:} for an empty dictionary. A braced literal with key-value pairs is also a dictionary.

Immutable collection literals

The immutable marker ! before a collection literal constructs the immutable variant. !{a, b} constructs a frozenset. !{a: b} constructs a frozendict. !{} is an empty frozenset, and !{:} is an empty frozendict.

A mutable set or dict cannot be hashed: its contents could change after insertion, which would silently corrupt every hash-based container it was placed in. A frozenset or frozendict has no such problem — it is hashable whenever its own values are hashable — so it can be used anywhere a hashable value is required, including as a dict key or as a member of another set:

empty_set = {}
empty_dict = {:}
empty_frozenset = !{}
empty_frozendict = !{:}
names = {"Ada", "Grace"}
scores = {"Ada": 10, "Grace": 9}
immutable_names = !{"Ada", "Grace"}
immutable_scores = !{"Ada": 10, "Grace": 9}

groups: dict[frozenset[str], int] = {immutable_names: 2}

The Set trait

Python's Set/MutableSet split the set-algebra operators (__and__, __or__, __sub__, __xor__, isdisjoint) from the mutating ones (add, discard, clear, pop, remove) across two separate ABCs. Lucid needs only the first: the mutating half is already the ordinary job of the mutable view !set, the same split ~T/!T already draws for every other container, so there is no separate MutableSet to add:

trait Set[T](Collection[T]):
    def dispatch __and__(lhs: Self, rhs: Self) -> Self
    def dispatch __or__(lhs: Self, rhs: Self) -> Self
    def dispatch __sub__(lhs: Self, rhs: Self) -> Self
    def dispatch __xor__(lhs: Self, rhs: Self) -> Self
    def isdisjoint(self: ~Self, other: Self) -> bool

set and the frozenset !{...} produces both satisfy Set — the algebra operators work the same way regardless of which view holds the value, since they are ordinary multiple-dispatch operators (Multiple dispatch), ordinary values returned rather than one side mutated in place.

No tuple or namedtuple type

Lucid has no tuple type and no namedtuple. Python uses a tuple for two different jobs — a small hashable ordered sequence, and a lightweight heterogeneous record — and conflating them costs more than it saves.

Why not positional records

Fixed-position fields are fragile as an API grows. A function returning a 2-tuple that later grows a third value breaks every positional unpacking call site:

def bounding_box():
    return width, height          # 2-tuple

w, h = bounding_box()

def bounding_box():
    return width, height, depth   # grew to 3-tuple

w, h = bounding_box()  # ValueError: too many values to unpack
Written with a named-field return type, the same growth is not a breaking change:

def bounding_box() -> (width: int, height: int):
    return (width=2, height=3)

bb = bounding_box()
bb.width + bb.height

def bounding_box() -> (width: int, height: int, depth: int):
    return (width=2, height=3, depth=1)

bb = bounding_box()
bb.width + bb.height  # still fine -- callers that never asked about depth don't need it
Two same-typed fields can also be silently swapped with no type error at all, since position is the only thing that says which is which:

def stats():
    return mean, median

m, med = stats()

def stats():
    return median, mean  # reordered during a refactor — still type-checks

m, med = stats()  # silently wrong: m is now the median
namedtuple does not fix this: it still compares equal to a plain tuple, and to an unrelated namedtuple of the same shape, because equality is inherited from tuple and never looks at the type:

Point = namedtuple("Point", ["x", "y"])
Color = namedtuple("Color", ["r", "g"])

Point(1, 2) == Color(1, 2)  # True
A class instance sidesteps all of this: fields are read by name, not by position or by unpacking (see Unpacking below). That is more extensible, since a producer can add fields without breaking any caller who only reads the fields they asked for; more legible, since every field is read by name instead of by position; and simpler, since there is no separate tuple-versus-list question to answer for every new piece of data.

Having no tuple type closes a related footgun too, on top of Assert's own fix for it. Python's assert takes a bare, comma-separated condition and message, so wrapping them in parentheses the way any other call gets formatted looks like a harmless choice:

assert (x == y, "x and y should match")   # always true in Python
A non-empty tuple is always truthy, so the assertion silently never fires — a mistake so common Python's own linters specifically watch for it. Lucid's own assert already requires those parentheses, so the mistake cannot arise to begin with; even without that fix, there would be no tuple type left to build a silently-truthy value with.

Unpacking

Multiple assignment still unpacks any Iterable, positionally, the same way Python does:

first, second = [1, 2]
A plain class is not Iterable (see No getitem iteration fallback), so a class instance cannot be unpacked this way — reading its fields by name is the only way in. A starred target on the left-hand side collects the remaining elements into a list, not a tuple:

first, *rest = [1, 2, 3, 4]
rest: list[int] = [2, 3, 4]
The star can also sit in the middle, with fixed targets both before and after it — Python's own extended-unpacking form, unchanged:

first, *middle, last = [1, 2, 3, 4, 5]
middle: list[int] = [2, 3, 4]
The same */** also splice an existing collection's contents into a new literal — the reverse direction from unpacking a target, building a collection instead of taking one apart:

first = [1, 2]
combined: list[int] = [*first, 3, 4]
merged: dict[str, int] = {**{"a": 1}, "b": 2}
This is also the one way to build a list, set, or dict from an already-unpacked source: list, set, and dict's own constructors take exactly the single positional argument Python's already give them — an iterable, or for dict, a mapping — never a second positional argument and never a keyword argument, so list(*a) or dict(**kw) are errors rather than a second spelling of [*a] and {**kw}.

Lucid uses Python's operators and Python's order of operations unless this document says otherwise. In an expression, as opposed to an assignment target, unpacking binds tighter than binary operators:

*x + y
means:

(*x) + y
not:

*(x + y)

Hashable sequences

For a hashable, immutable ordered sequence — the job a tuple is actually suited for — use the immutable marker on a list literal, ![...], the same ! that constructs a frozenset or frozendict:

point: !list[int] = ![3, 4]
cache: dict[!list[int], float] = {:}
cache[![3, 4]] = distance(3, 4)

Heterogeneous records

For a small heterogeneous set of named fields, use a class. Every class is already a dataclass: fields are declared in the class body and a field-based constructor comes for free (see Modern type specification).

class Point:
    x: int
    y: int

Anonymous record shapes

A record's shape can also be written directly as a type, without declaring a named class, using (...) with each field's name and type:

type Point2D = (x: int, y: int)

def midpoint(a: Point2D, b: Point2D) -> Point2D:
    ...
(x: int, y: int) is structural: any class with at least those fields at those types satisfies it, the same way Point above would, with no declared relationship required — the same way a plain dict satisfies a TypedDict shape. The immutable marker applies here too: !(x: int, y: int) is the frozen version of the same shape.

Outside a type expression, the same (...) syntax constructs an anonymous value of that shape directly, using = instead of : before each field's value — no named class required:

origin: Point2D = (x=0, y=0)
origin.x
Constructing with = instead of : is not just a style choice: it means the construction site already reads like a call, so replacing the anonymous shape with a named class later is a small edit, not a rewrite — (x=0, y=0) becomes Point2D(x=0, y=0).

asdict converts a record you already have — anonymous or a named class instance, built elsewhere or passed in — into a plain dict[str, object], walking fields() the way dataclasses.asdict does in Python:

def as_json_payload(point: Point2D) -> dict[str, object]:
    return asdict(point)
It is not a second way to originate a dict with identifier-shaped keys. asdict((x=0, y=0)) and {"x": 0, "y": 0} produce the same value, but the first builds a record purely to immediately flatten it — the linter always prefers the direct literal when nothing else uses the record on its own.

skip in collection literals

skip is not a value and cannot be returned, assigned, or passed through ordinary expressions. It is valid only inside elidable collection entries and call arguments. In list and set literals, an entry that reaches skip is omitted:

[1, 2, 3 if false else skip, 4] == [1, 2, 4]
In dictionary literals, an entry is omitted if either the key expression or the value expression reaches skip:

{1: 2, 3: skip, skip: 6} == {1: 2}