Calls¶
Lucid keeps Python's call syntax but tightens a few of its looser corners: argument elision, argument order, partial application, and one grammar special-case Python carries for generator expressions.
skip in calls¶
The skip keyword may appear as a positional or keyword argument. A
positional argument that reaches skip is omitted. A keyword argument whose
value reaches skip is omitted.
Positional arguments precede keyword arguments¶
Python's rule looks simple — positional arguments come before keyword arguments — but its grammar (PEP 448) treats star-unpacking as an exception: an unpacked iterable still binds positionally, but is allowed to appear in the source after a keyword argument, as long as nothing double-star-unpacked has appeared yet. Source order and binding order can disagree:
c=3 is written first but binds to the last parameter; *[1, 2],
written last, supplies the first two. The call has to be read back to
front to see what it does.
Lucid closes the exception: positional arguments — plain and star-unpacked alike — must precede every keyword argument, including double-star unpacking, with no interleaving permitted.
Within the positional section, plain arguments and*-unpacking can mix
freely in any relative order, since both bind purely by position, in the
order written. The same holds for named arguments and **-unpacking
within the keyword section. The one rule that never bends is between the
two sections: positional, then keyword, always.
Partial application with _¶
Python's tool for fixing some of a call's arguments ahead of time is
functools.partial: partial(score, weights) returns a
functools.partial object, not an ordinary function, and type checkers
have long struggled to treat it as the Callable it behaves like.
Lucid reuses _ for this — the same "unspecified" marker already used
for black-hole assignment (see Ordinary binding)
and the match wildcard (see
Exhaustive pattern matching), now in a call's
argument list. An argument position filled with _ is not a value; it
leaves that position open, and the call itself becomes a new callable
awaiting whatever positions were left unfilled:
score(weights, _) is not a call to score — it is a value of type
(Item) -> float, built without calling score at all, ready to be
called once sorted supplies the missing argument itself. No import,
no wrapper object, no separate type to teach a checker about.
Multiple holes fill left to right, matching the order they appear:
A keyword hole stays keyword in the result: The type of a partial application is ordinary generic inference, not a special case: givenf: (A, B) -> R, f(x, _) has type
(B) -> R, the same way substituting one type parameter of any other
generic leaves the rest.
Anonymous functions¶
Python's lambda is a second, narrower construct for the same idea a
def already covers, with its own separate keyword borrowed from
lambda calculus. Lucid has no lambda. An anonymous function is a
def with no name, written as a single expression, always, whose value
it returns:
add: (int, int) -> int = def(x, y): x + y # x, y: int, from add's own annotation
cache.get_or_put("ada", def(): load_user("ada")) # (): User, from get_or_put's signature
def:
f = def(x, y): x + y # error: no expected type to infer x, y from
f = def(x: int, y: int): x + y # fine
() entirely — there is nothing to
write between def and : when there are no parameters to name:
Writing def: at the call site is visible, not a hidden cost: a
reader sees immediately that the argument may not run, and a linter can
insert it automatically wherever a parameter's declared type calls for
one. A parameter that is sometimes cheap enough to build eagerly and
sometimes not accepts either shape directly, str | () -> str,
rather than forcing every caller through the deferred form even when
there is nothing expensive to defer:
def debug(self, message: str | () -> str) -> none:
if self.level <= Level.debug:
match message:
case str:
self.emit(message)
case _:
self.emit(message())
log.debug("starting up") # cheap: plain str
log.debug(def: f"state: {expensive_dump()}") # expensive: deferred
precondition-style helper takes str | () ->
str for exactly the same reason debug does:
def precondition(ok: bool, message: str | () -> str) -> none:
if not ok:
match message:
case str:
raise AssertionError(message)
case _:
raise AssertionError(message())
precondition(x > 0, "x must be positive")
precondition(x > 0, def: f"x must be positive, got {expensive_repr(x)}")
def has no block form and no return — one expression
is the whole body, full stop. Anything that needs more than one statement
needs a name. This is the same discipline
Exhaustive pattern matching already enforces for
match: a block that sometimes doubles as a value, depending on what
its last line happens to be, is exactly the ambiguity Lucid avoids
everywhere else, and an anonymous def with a block body would be that
same ambiguity one level up. Naming it removes the ambiguity instead of
special-casing around it — and loses nothing capability-wise, since a
named def nested inside another function closes over its enclosing
scope exactly as well as an inline one would:
def make_handler(threshold: int) -> (int) -> bool:
def check(x: int) -> bool:
if x > threshold:
log(f"exceeded: {x}")
return x > threshold
return check
Generator call expansion¶
Python's grammar special-cases exactly this shape: a bare generator
expression is allowed as a call's sole argument, borrowing the call's own
parentheses as the generator expression's parentheses. It is not a
consequence of how expressions normally work in a call — it is a dedicated
grammar production for this one case, and it is fragile in a way that shows
the special-casing: sum(x for x in items) works, but adding a second
argument breaks it, because now there are two arguments and the borrowed
parentheses no longer apply. sum(x for x in items, start=0) is a syntax
error; the generator expression needs its own parentheses back —
sum((x for x in items), start=0). Whether the parentheses can be omitted
depends on argument count, not on what the expression means.
Lucid needs no special case: a generator expression written as a call argument is treated the same as any other expression written there, and argument expansion follows regardless of how many other arguments are present.
means: To pass an actual generator object, parenthesize the generator expression: