Lazy evaluation of expressions (original) (raw)

There have been a lot of suggestions to how lazy (deferred/late-bound) variables might be added to Python. Notably PEP 671 – Syntax for late-bound function argument defaults | peps.python.org and a general discussion as part of Backquotes for deferred expression, plus many more proposals and discussions. So why another proposal? Because the others have not got support.

Whilst in the same general space as these other proposals this proposal is different than the proceeding proposals in that it has no new syntax and has quite different semantics. Variables are annotated as having type Lazy, which is generic. Writes to Lazys are wrapped by Lazy(lambda: <exp>). Reads have a call appended.

Details

There is a new type:

class Lazy[T]:
    def __init__(self, expr: Callable[[], T]):
        self.__exp = expr

    def __call__(self) -> T:
        if not hasattr(self, '_Lazy__value'):
            self.__value = self.__exp()
            self.__exp = None
        return self.__value

Variables/arguments/attributes annotated as type Lazy get special treatment when accessed. For example defining a switch statement:

def switch[T](n: int, *exprs: Lazy[T]) -> T:
    return exprs[n]

Note that the elements of the expression list are of type Lazy. The proposal is that the compiler translates all reads of a Lazy into a call on the instance. Therefore above is translated by the compiler into:

def switch[T](which: int, *exprs: Lazy[T]) -> T:
    return exprs[which]()

Note the call added to the read, lst[n]().

Using switch EG:

n = 1
x = switch(
    0, 
    cbrt(n), 
    exp(n), 
    exp2(n), 
    expm1(n), 
    log1p(n), 
    log2(n), 
    log10(n), 
    sqrt(n),
)

Which the compiler translates into:

x = switch(
    0,
    Lazy(lambda: cbrt(n)),
    Lazy(lambda: exp(n)),
    Lazy(lambda: exp2(n)),
    Lazy(lambda: expm1(n)),
    Lazy(lambda: log1p(n)),
    Lazy(lambda: log2(n)),
    Lazy(lambda: log10(n)),
    Lazy(lambda: sqrt(n)),
)

Each write is wrapped in Lazy(lambda: <expr>)

Rosuav (Chris Angelico) May 4, 2025, 2:47am 2

So what this means is that the call site has to be aware of what function is being called, in order to magically transform all these expressions into function definitions. That implies a HUGE amount of spooky action at a distance. Every single function call, when compiled, has to first figure out what the destination function is, which entirely breaks all types of generic wrappers, and makes it impossible to figure out the meaning of a line of code.

Massive -1 from me for the sheer level of magic involved.

hlovatt (Howard Lovatt) May 4, 2025, 3:08am 3

Type checkers and optimisers already do this, so quite possible. Is your objection to the implicit nature of the transformation? If so, would you want some syntax hint?

The semantics are not ‘magical’, other than delayed evaluation. Note how the result is cached so that multiple reads without a write give same value. This contrasts with other proposals in this space.

hlovatt (Howard Lovatt) May 4, 2025, 3:22am 4

Also, people are used to lazy evaluation without any syntax hint from and and or.

Rosuav (Chris Angelico) May 4, 2025, 4:26am 5

Do what already?

Consider these two function calls:

func1(func2())
func3(func4())

Do you know whether func2 and func4 will be called? According to your proposal, you cannot know this without first inspecting func1 and func3. And those functions might not even exist yet. You are proposing that, in order to correctly compile code, you need to first know the destination of every call.

The semantics ARE magical, in that the presence or absence of an annotation on the target function will affect the caller of that function. That is a huge and unprecedented level of magic.

I’ve no idea what you mean by this, please clarify?

barry-scott (Barry Scott) May 4, 2025, 5:16am 6

This is called short circuit evaluation.
Nothing lazy about it.

What you are noticing is that based on the value of the first expression python knows if it must evaluate the second expression.

xitop (Xitop) May 4, 2025, 6:17am 7

This is a popular topic. Recently I did a small related proposal too (it was a special type of lambda).

I’m getting used to the idea we won’t get a lazy evaluation in Python. What if we should rather accept it and write functions that accept both arg=... and arg_factory=...?

hlovatt (Howard Lovatt) May 4, 2025, 8:10am 8

Lazy is a generalisation of short-circuit. In my switch example n is evaluated, but only one of the expressions. Lazy lets you write and like functions. A hypothetical and function:

def my_and(lhs: bool, rhs: Lazy[bool]) -> bool:
    if not lhs:
        return False
    return rhs

jorenham (Joren Hammudoglu) May 4, 2025, 8:10am 9

Are you suggesting something like a top-level functools.cached_property? Or would it be limited to the scope of a function?

hlovatt (Howard Lovatt) May 4, 2025, 8:13am 10

Yeah, I currently wrap arguments in lambdas. It’s not as good as other languages though that let you define arguments that are to be lazily evaluated :frowning: .

hlovatt (Howard Lovatt) May 4, 2025, 8:19am 11

Existing type-checkers and optimisers know the declared type of the arguments of a function call, hence a compiler could also know argument declared type at lease to the extent that a type-cker/optimiser can.

hlovatt (Howard Lovatt) May 4, 2025, 8:20am 12

See my_and example in response to @barry-scott

hlovatt (Howard Lovatt) May 4, 2025, 8:31am 13

I am proposing caching the result of an evaluation like cached_property does, but rest is different. The difference is that assignment to any variable/argument/attribute (not just a property) is wrapped in Lazy(lambda: <expr>). Reads have a call appended that does the caching as well as the evaluation.

I am partially wanting caching to avoid needless evaluation like cached_property does, so in the same general space. But I’m also wanting not to evaluate some things at all for other reasons like side effects.

ImogenBits (Imogen) May 4, 2025, 9:33am 14

FWIW, the compiler also having to do type inference would be a massive change and require a lot of work to fully specify the type system. It also would break one of the fundamental guarantees of typing in Python, that annotations are essentially ignored by Python itself and only used by external tooling if the user so chooses.

But more fundamentally, type inference doesn’t always work. You just don’t always know the exact type of a function. Currently that’s completely fine since that only gives you some amount of uncertainty of program correctness without changing the semantics. But with your proposal the actual behaviour of the code can’t even be determined. For example, what would this function do:

def higher_order(func: Callable[[int], int] | Callable[[Lazy[int]], int]) -> int:
    return func(side_effects())

It is completely unknowable from just looking at this code whether this is a lazy function invocation or a normal one. If you want to have those semantics in the language I think the best way is to convince enough people that such a big change is worth it rather than trying to say that it’s not actually all that of a new thing.

Rosuav (Chris Angelico) May 4, 2025, 10:39am 15

(I don’t know what you mean about optimisers in this context, feel free to point to an example of this if you can.)

Type checkers are allowed to be wrong. If you write code that doesn’t follow certain patterns, and your editor starts autocompleting incorrectly, Python isn’t broken. This is a vital distinction.

That’s a VERY loose way to describe it, and IMO it’s about as helpful as saying that goto is a generalisation of a for loop and should therefore be implemented.

Python already has a way to pass an unevaluated expression as an argument, and it’s called a function (possibly a lambda function). In order to convince people that your proposal is worth pursuing, you need to show that what you have is better than that - not simply that unevaluated expressions can be useful.

With short-circuit evaluation, we can look at an expression and completely understand its semantics:

x = foo() and bar()
# works like:
x = foo()
if x: x = bar()

We don’t need to know anything about foo or bar in order to recognize this equivalence. And we can see that bar() might and mightn’t happen, depending on the truthiness of foo()'s return value.

With your proposal, we would need to understand every function in order to know whether its arguments get evaluated. This is not a generalisation that makes sense, because it is no longer local.

pf_moore (Paul Moore) May 4, 2025, 10:42am 16

Proposals like this typically start off optimistically demonstrating straightforward cases that seem to work just fine, but fall apart once more awkward cases start getting explored. Often, the easiest way to see this is to try to implement the proposed feature, because that’s when you have to be precise about the details.

Have you tried (or do you plan on trying) to implement your proposed mechanism?

hlovatt (Howard Lovatt) May 6, 2025, 4:37am 17

I haven’t tried implementing it. If people are enthusiastic about the proposal I would be willing to try. But if people are saying they don’t want this then there is no point putting the effort in (and it sounds like it would be some effort).

I really don’t know how to gauge interest to judge if worthwhile?

blhsing (Ben Hsing) May 6, 2025, 6:28am 18

The very fundamental problem with every proposal of lazy evaluation ever is when exactly the lazy object is evaluated.

If the proposal favors an implicit syntax such that an lazy object is evaluated upon its first read access, which is what your proposal suggests, it would then be impossible to write a wrapper function that forwards a lazy argument as-is in a call to the core function. It would also be ambiguous as to whether an assignment of a lazy object should trigger an evaluation. If the proposal states that assignments and argument forwarding shouldn’t trigger an evaluation, then it is unclear how forwarding a lazy argument to a call to a C function should be consistently handled.

If the proposal favors an explicit syntax such that there is either an operator that explicitly preserves a lazy object or a method that explicitly evaluates it, it would then be no materially different from the status quo of using a lambda function.

So any proposal of lazy evaluation is essentially DOA if it doesn’t address this fundamental problem.

Rosuav (Chris Angelico) May 6, 2025, 7:07am 19

If YOU are enthusiastic about the proposal, try implementing it. You’ll learn far more by doing so than by having people try to explain things to you. And then, if you succeed, you will be able to (a) use it yourself, regardless of whether it gets accepted into core; and (b) point people to your fork of Python, saying “here, try it out and see how you like it”. Both very useful effects.

But mainly, you’ll figure out exactly what the problems really are, and how to overcome them (perhaps).

hlovatt (Howard Lovatt) May 6, 2025, 11:01pm 20

You’re correct I should have spelled out my thoughts on this.

I’m intending that if any function (including C functions) has an argument type of Lazy[T] then it is passed unevaluated, conversely if the argument is of type T then it is passed evaluated.