
On Sun, Oct 31, 2021 at 1:28 PM David Mertz, Ph.D. <david.mertz@gmail.com> wrote:
On Sat, Oct 30, 2021, 9:40 PM Chris Angelico <rosuav@gmail.com> wrote:
I'm not sure what I think of a general statement like:
@do_later = fun1(data) + fun2(data)
I.e. we expect to evaluate the first class object `do_later` in some other context, but only if requested within a program branch where `data` is in scope.
If you want to create a "deferred" type, go ahead, but it won't conflict with this. There wouldn't be much to gain by restricting it to function arguments.
I agree there's no gain in restricting deferred computation to function arguments, but that's EXACTLY what your proposal is.
That's if you were to create a deferred *type*. An object which can be evaluated later. My proposal is NOT doing that, because there is no object that represents the unevaluated expression. You can't pull that expression out and evaluate it somewhere else. It wouldn't be meaningful.
The way you've written it, it's bound to an assignment, which seems very odd. Are you creating an arbitrary object which can be evaluated in some other context? Wouldn't that be some sort of constructor call?
It's true I don't particularly like the @ syntax. I was just speculating on continuity with Steven's syntax.
Here's my general proposal, which I actually want, but indeed don't have an implementation for. I think a soft keyword is best, such as 'defer' or 'later', but let's call it 'delay' for now to avoid the prior hang up on the word.
(A)
def foo(a: list, size: int = delay len(a)) -> None: print("The list has length", size)
Whenever a name is referenced in this future Python, the interpreter first asks if it is a special delayed object. If not, do exactly what is done now. However, if it *IS* that special kind of object, instead do something akin to 'eval()'.
Okay. Picture this situation: def ctx(): dflt, scratch = 1, 2 def set_default(x): nonlocal dflt; dflt = x def frob1(a=>dflt): print(a) def frob2(a = delay dflt): print(a) return set_default, frob With frob1, the compiler knows exactly which names mean which variables, just as in all current code. In this case, it knows that dflt is a nonlocal, and CPython will use LOAD_DEREF to look it up. With frob2, a is some opaque object that happens to be a deferred expression. That expression could be evaluated in any context. The compiler has to snapshot every variable in every containing scope (in this case, 'scratch'), in case the evaluation of a might happen to refer to them. In fact, it's worse. *EVERY* closure has to retain *EVERY* containing variable, just in case it's used in this way. Either that, or these delayed expressions are just eval'd strings, and need to be explicitly passed their globals and locals, which fails for closures anyway. In contrast, frob1 puts the code right there in the function, so everything behaves correctly.
No, the delayed object probably shouldn't just contain a string, but perhaps a chunk of compiled bytecode.
I don't know about other Python implementations, but in CPython, bytecode needs to know what kind of name a thing represents - LOAD_FAST, LOAD_DEREF, LOAD_GLOBAL - and for closures (dereferences), it needs to ensure that the surrounding context uses DEREF as well, when it otherwise would use FAST. Plus, every name reference in CPython bytecode is a lookup to a table of names (so it'll say, for instance, LOAD_GLOBAL 3 and the third entry in the name list might be "print", so it'll look up the global named print). CPython bytecode isn't well suited to this. So it would have to be some other sort of bytecode - something that retains very little context, and only has a basic parse. In fact, I think the AST is probably the closest (at least in CPython - again, I don't know other Pythons) to what you want here. It's basically the same thing as source code, but tokenized and turned into a logical tree. Unfortunately, AST can't be executed as such, and needs to be compiled into something ready to use.
So what if we don't want to evaluate the delayed object? Either the same or a different keyword can do that:
def foo(a: list, size: int = delay len(a)) -> None: a.append(42) bar(a, delay size)
def bar(a, the_length): print("The expanded list has length", the_length)
If it's the same keyword, you have a fundamental ambiguity: does that mean "use the name size in the target context", or "use the deferred object with its existing names"?
What if we want to be more general with delaying (and potentially skipping) actions?
expensive1 = delay big_computation(data) expensive2 = delay slow_lookup(data)
def get_answer(data): # use globals for the example, less so in practice if approximate_compute_cost(data) > 1_000_000: return expensive2 else: return expensive1
That's it. It covers everything in your PEP, and great deal more that is far more important, all using the same syntax.
It's a very different proposal. I don't think it's as related as it seems. For one thing, part of the point of these delayed expressions is that they are, well, delayed. Consider this difference: def spam1(a, n=>len(a)): a.append(3) print(n) def spam2(a, n=delay len(a)): a.append(4) print(n) A delayed expression should be evaluated at the point where it is used. A default argument expression should be evaluated as part of the function header. And it gets worse with multiple evaluations: def add_twice(item, lst=>[]): lst.append(item) lst.append(item) return lst def add_twice(item, lst=defer []): # as above My expectation from the first is that, if you don't specify a second argument, a new empty list is constructed, appended to twice, and then returned. With the defer expression, does it collapse to a value? And if so, when? Please, please, start a new discussion about delayed evaluation. I would love to participate. But I don't think it's a generalization of default argument expressions. ChrisA