Construction¶
Building an instance and introspecting a class's own fields are two related jobs, both centered on the factory as where they meet — neither about what kinds of members a class body can declare, which Class members covers on its own. A factory field can also be filled with a value captured from its own call site, a mechanism that applies to any function, not just factories, covered in Call-site captured values.
Factory construction¶
Python splits construction across __new__, __init__,
dataclass-generated initializers, __post_init__, InitVar, and field
options such as init=False or kw_only. Lucid has one construction model:
factories return fully constructed objects through the factory-only
construct keyword.
Factories receive cls, but they construct the exact class where they are
defined. Calling a class calls its __init__ factory. Calling a named factory
uses the factory name on the class.
class Point:
x: float
y: float
factory __init__(cls, x: float, y: float):
return construct(x, y)
factory origin(cls):
return construct(0.0, 0.0)
p = Point(1.0, 2.0)
origin = Point.origin()
__init__ is unspecified, Lucid generates the obvious field-based
constructor. This class:
gets this default __init__ factory:
construct is a keyword, not an ordinary function. It can only appear inside
a factory. At runtime, a construct expression creates an instance of the
exact class whose factory is running, assigns the supplied values to that
class's declared fields in field order, and returns the fully initialized
object.
Factories are not inherited, but a subclass factory can still build on
a parent factory's own result directly: call it by name, spread the
object it returns into construct, and add whatever the subclass
adds.
class Point:
x: int
y: int
factory on_diagonal(cls, z: int):
return construct(z, z)
class Point3D(Point):
z: int
factory on_diagonal(cls, z: int):
point = Point.on_diagonal(z)
return construct(***point, z)
***point spreads Point's own fields positionally — Spread
already gives every class this for free — filling x and y, in
that order; Point3D's own trailing field, z, is the one
positional slot ***point never supplied, the same
inherited-fields-first order Parameters's own construction
already follows. No override rule is needed to combine them: z
isn't replacing anything ***point provided, it's filling the one
field Point never had.
Every class has a generated replace method, and it is not inherited
either — a subclass gets its own replace, scoped to exactly its own
declared fields, the same way it gets its own __init__ rather than
its parent's. replace works like Python's __replace__ protocol:
given any changed field values as keyword arguments, it builds a new
instance of the same exact class with unchanged fields copied from
self, and hands back an ordinary, mutable value — regardless of
which view called it:
class Point:
x: float
y: float
p: !Point = freeze(Point(1.0, 2.0))
q: Point = p.replace(y=3.0) # a fresh, ordinary Point, not !Point
replace has no Liskov substitution
obligation to satisfy. An inherited method has to behave compatibly
across every subclass that relies on the inherited version, since
callers see one shared contract; a generated, per-class replace
never is that shared version — every class, subclasses included,
already has its own, so there is nothing for a subclass's replace
to stay substitutable for.
replace only ever reads self to build the new object; it never
writes to it, so it takes self: ~Self, callable through a mutable,
read-only, or frozen view alike. This does not strain
freezing being deep: that rule
governs what a view exposes about the original object's own
storage, and replace never exposes that storage — it constructs a
brand new instance, as free to be ordinarily mutable as any other
freshly constructed value.
Constructor calls infer as final¶
A call naming the class it constructs can only ever produce an
instance of exactly that class — building a subclass instead needs its
own constructor call, B(), not A(). Lucid infers this precisely:
A()'s type is final A, not plain A:
1 infers
as Literal[1] and widens to int wherever a declaration governs
it, and final A widens to A in exactly the same places:
class B(A): ...
class C:
x: A = A()
def g(c: C):
c.x = B() # ok — C.x's declared type is A, not final A
items: list[A] = [A()] # list[A], not list[final A]
Field reflection with fields¶
replace and the default constructor both already have to walk a
class's fields generically. fields exposes that same walk directly,
the way Python's dataclasses.fields does, dispatched on what it's
given — an instance, a class, a trait, or a module:
def dispatch fields[T](obj: T) -> Iterable[(name: str, value: object, doc: str | none, metadata: dict[str, object])]:
...
def dispatch fields[T](cls: class[T]) -> Iterable[(name: str, doc: str | none, metadata: dict[str, object])]:
...
def dispatch fields(trait: type Trait) -> Iterable[(name: str, obligation: bool, doc: str | none, metadata: dict[str, object])]:
...
def dispatch fields(mod: Module) -> Iterable[(name: str, doc: str | none)]:
...
doc and
metadata from Field docstrings and metadata, none
and {:} respectively when a field declares neither.
class Config:
name: str
; "the user's display name"
c = Config("Ada")
list(fields(c))[0] # (name="name", value="Ada", doc="the user's display name", metadata={:})
list(fields(Config))[0] # (name="name", doc="the user's display name", metadata={:})
fields's receivers — it's neither a class,
a trait, nor a module, and giving it a fifth, differently-shaped
dispatch case would bolt on a special case rather than reuse one.
Reading a function's own parameters, metadata included, is
Callable's .parameters property
instead.
The trait form walks a trait's own declared members — fields,
getters, setters, methods, classmethods, and factories alike —
reporting whether each one is a bodyless obligation or a default with
a body (Body or no body), the same
distinction the checker already uses to decide whether a class using
the trait still has something left to implement. type Trait reifies
the trait the same way Reifying a type
expression already reifies any
other type expression, since a trait is not itself a callable value
the way a class is:
dir(): names in declaration order, each with
its own docstring, rather than an unordered list of strings with
nothing else attached.