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
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
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
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 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:
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:
The star can also sit in the middle, with fixed targets both before and
after it — Python's own extended-unpacking form, unchanged:
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:
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:
means: not: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:
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).
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:
(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:
= 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:
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:
skip: