Backquotes for deferred expression

That might be an option, however inspect is a fairly high level pure python module.

Given that this is quite unique functionality, I think it could be possible, that it deserves its own module, eg. delayed.

import delayed

class proxy:
    # superclass for proxy protocol
    def __init__(self, value):
        self.value = value

def truetype(obj):
    ...

def proxyattrs(obj: proxy) -> tuple | namedtuple?:
    ...

class DeferExpr(proxy):
    def __init__(self, func, *args, **kwds)

class CachedDeferExpr(DeferExpr):
    def __init__(self, func, *args, **kwds)

I really think these classes deserve more convenient snake case name. e.g. lazy & cached_lazy.

Just need to avoid clashes with soft keywords (if these are decided to be used). I think it will be clearer further down the line.

1 Like

Could you elaborate a bit on this?

Edit:
Never mind. Caught up. Yes, it should probably be an expression.

However, back in a day I also had an idea for multi-line syntax, which would create zero-arg-def instead of lambda. E.g.:

lazy_expr = lazy:
    b = a + 1
    return b

Throwing in a brief update for what’s just being supported in the demo implementation:

  1. DeferExpr renamed as defer. Added two new static methods to help construct mutable and immutable defer objects respectively.

    Their signatures are defer.Mutable(fn, *args, **kwargs) and defer.Immutable(fn, *args, **kwargs)

  2. defer() constructor now supports optional positional arguments and keyword arguments.

  3. When used as decorator, one can use ellipsis as the first argument (i.e. @defer(..., *args, **kwargs))

Demostration
Python 3.14.0a1+ (heads/feat/defer-expr:180ffae64c, Nov 17 2024, 18:19:02) [Clang 16.0.0 (clang-1600.0.26.4)] on darwin
>>> # Demonstrating *args and **kwargs
>>> hello = defer(print, "hello", "defer", end="!\n")
>>> hello
hello defer!
None
>>> # Demonstrating ellipsis as 1st argument (for decorators)
>>> @defer(..., 1, 2, 3, hello="world")
... def hello(*args, **kwargs):
...     return args, kwargs
... 
>>> hello
((1, 2, 3), {'hello': 'world'})
>>> # We can view and change args and kwargs through defer.reveal()
>>> # it was formerly known as "expose()"
>>> defer.reveal(hello).args
(1, 2, 3)
>>> defer.reveal(hello).args = (4, 5, 6)
>>> hello
((4, 5, 6), {'hello': 'world'})
>>> defer.reveal(hello).kwargs
{'hello': 'world'}
>>> defer.reveal(hello).kwargs = dict(defer="awesome")
>>> hello
((4, 5, 6), {'defer': 'awesome'})

BTW type(x) now returns type of the proxied value (not <class defer>). I am working on is statement.

I will also open a new repo to host a more consolidated specification when I get a 50%+ done draft for it.

1 Like

All in all, that looks pretty good.

I like the decorator option!

I quite like defer, will see if it sticks.

I think default should be immutable.
And I suspect sooner or later it will need to be worked out whether there is reason to have mutable version at all.

I still think the code would be simpler if back-door was done via simple functions as opposed to needing whole class.

Personally, I don’t mind - it is quite nice from user’s perspective, but if I had to maintain the code, this would be unnecessary complexity. So just heads up for likelihood of that. From what I have seen, “Everything should be as simple as it can be, but not simpler” is a pretty good description of what is usually expected from PR in this respect.

So what is left?
a) Dealing with is. Are there any other similar ones?
b) Function to check if it is defer. Either truetype or isdefer. Or the way that it is, would this work: defer.reveal(obj) is not None? Although this would have overhead?
c) Function to evaluate explicitly. Something like:

def observe(d, maybe=True):
    if maybe and not isdefer(d):
        return d
    return PyObserve(d)

Not sure about observe naming. I used compute, which I took from dask, but I don’t like it very much either. Probably observe is better.

Not sure about naming in general, I guess just stick with anything for now. Look at how much it took to decide on one name here :smiley: Bikeshedding: a method to refresh os.environ. So it is likely that this will be a process in itself once the rest is complete.

1 Like

For now this is exactly how it works :smiley:

Currently I hide it inside defer as static methods (i.e. defer.snapshot() and defer.freeze()). I am not good at naming things so I will leave it open for discussion…

1 Like

I think we should clarify and set expectations properly.

You are making a list of what is left, but what is left before what? What is the end-goal that you three are working toward? I say three because the last ~30 replies have been only three people.

Where is this proposal headed? What is the expected outcome?

4 Likes

I have been using Pure Python implementation of this for a while now, and it is the first concept in the area of “Deferred Evaluation” that I have identified to provide manageable & useful component that is worth attempting to implement in CPython, and is also a good complement to the rest of Python’s toolkit.

OP has picked up on some of my thoughts, combined it with his own ideas and proposed it.
Thus, it has taken some turns in the process as me and OP had to align.

By now I think this proposal is aligned to the concept that I had in mind to the degree that I am happy about. I.e. It closely adheres to it in aspects that I have taken fair amount of time to think about.

So the concept the way I see it is fairly simple and can be described as follows:

It is an object (currently its type is named defer), which:

  1. Is a proxy of return value of wrapped callable.
  2. It has optional args/kwargs, thus has similar constructor signature as functools.partial
  3. It evaluates on any operation (or only on 1st operation in its cached version).
  4. operation is defined as complete set of all Python’s operations, with exception of assignment. Thus, it’s a complete proxy object for underlying return value of wrapped callable.
  5. Implementation of proxy features is largely similar to: weakref.proxy or proxy objects of wrapt, but also addresses couple of extra operations.
  6. Couple of accompanying utilities are provided for direct access to defer object, instead of return value of wrapped callable.

The reason this thread has turned out to be this way is because there was (still is) uncertainty whether “addressing couple of extra operations”, namely is,type is possible without making a big mess. It was (still is) important to make sure that complexity is justified by the benefit this brings. And complexity in question is largely limited to addressing the behaviour of is and type.

So to put it in code:

a = 1
d = defer(lambda: a + 1)
# Whatever you do with `d` (except assignment and special utilities)
#     evaluates underlying callable and acts on it's result
print(type(d))   # int (calculates) 
print(d + 1)     # 2 (calculates)
a = 2
print(d + 1)     # 3 (calculates)

# To only evaluate once and store result
a = 1
d = cached_defer(lambda: a + 1)
print(d + 1)     # 2 (calculates)
a = 2
print(d + 1)     # 2 (retrieves cached value)

# To bind arguments on definition:
a = 1
d = defer(lambda arg: arg + 1, a)
print(d + 1)     # 2 (calculates)
a = 2
print(d + 1)     # 2 (calculates)

What concerns expressions, it does not introduce any new logic itself.
Instead behaviour is fully dependent on its inputs.

The main problem that this solves can be summarised as follows:

“It allows lazy values without the need of implementing additional logic for it and it would introduce lazy default functionality to all existing defaults were lazy functionality is not implemented”

The simplest example is:

d = dict({'a': 1})
result = d.get('a', defer(math.factorial, 1_000_000));
# defer(math.factorial, 1_000_000) is not evaluated

The way that things are now, the only way to robustly implement lazy defaults is by implementing the logic manually with extra flag:

class dict:
    def get(self, key, default=None, lazy=False):
        if key not in self:
            return default() if lazy else default
        return self[key]

There is no robust way to implement it without extra keyword flag, because there is no robust check on the default value that could signal that it is lazy. I.e. “What if default value is meant to be unevaluated lambda?”

There are some ways around it, but all of them would result in additional complexity and discrepancies between different implementations. And many packages or even objects in CPython stdlib simply don’t implement it.

So this solution offers lazily evaluated arguments/defaults/values without changing any of the existing code structure and localising this problem to one concept.

There are things that the user will need to be mindful of, but those are nothing out of the ordinary. So far, I have not identified any ambiguities in expected behaviour of defer object which adheres to the concept above.

This is what I have been worrying about. I guess the conversation has been narrowed down to too few people because it’s too long for others to follow.

And those opposing the proposal might hold back their opinions when seeing us devoting actual efforts on it.

Thanks, but can you be very clear: are you all hoping that this will be adopted as a change to the Python language, which to be honest is unlikely, or are you working out the details of a third-party package of some sort?

d = dict({'a': 1})
result = d['a'] if 'a' in d else math.factorial(1_000_000)

Python already has many lazy evaluation constructs, although not with dedicated syntax.

This cannot be a third party library, it requires support deep within the python interpreter since it wants to change the behavior of e.g. is and other foundational APIs in python.

I also still believe that this is a misguided attempt tbh, primarily because I still don’t see this working. I don’t think the issue of third party C libraries has been addressed - it is IMO an unacceptable cost of having those have to be aware of potentially being passed a defer object, and I can’t imagine a way to make this transparent for them while keeping a decent approximation of the intended semantics.

2 Likes

Given that I am satisfied with simplicity, elegance and robustness of implementation, I would definitely make an effort to build a proper case for consideration for inclusion to Python language.

I don’t think it is possible to do this in 3rd party package.
It requires small tweaks in CPython internals.

The issue is that it is impossible to address is and type behaviour in 3rd party package.
And without addressing these, satisfiable level of robustness can not be reached for this to be properly useful.
Behaviour of is,type is the main (and only) reason why this would need to be part of Python and not 3rd party package. If not for these, I would be happy with my Pure Python implementation that I am currently using.

My bad, I see now. Big issues.

It might be possible, but I need more time to figure out how.

This is the reason I am holding back the final specification & proposal - we cannot specify something that is technically impossible.

Ok, so what is the list of possible failings in third party C libraries? Is it limited to exactly same operations that require change in C API internals - Py_TYPE, Py_Is & similar?

One issue that I see is that often simply == is used instead of Py_Is.
A lot of code would need to be changed and Py_Is would have to be enforced.
Which is how it should have been from the beginning, but given this identity existed for a long time, I guess it was impossible to resist temptation.

My personal end goal is to try my best to turn this into a proposal which is logically sound, technically feasible and easy to understand & use.

I have no expectation that it must be adopted by the community. A lot of comments showed up in this thread asking why reinventing lambda functions. I expect more to come when the proposal is out (I will need a sponsor for that, not sure if I can find one…)

1 Like

To elaborate on this, one possibility I’ve been considering is adding a C method flag.

There are two options for consideration:

  1. Maximize coverage of new feature:

    We can introduce flag METH_NO_DEFER for C methods who do not work well with defer objects. Python core will expand all defer objects in args and kwargs before passing them into such methods (only depth 1 traversal, no recursion).

  2. Maximize backward compatibility:

    We can introduce flag METH_ALLOW_DEFER. Methods which are capable of handling defer objects need to explicitly set this flag so python core will preserve all defer objects in their argument list. Otherwise, defer objects will be replaced with observation of them before passing down to C methods.

One thing to note is that some C API hooks do not come with flags (e.g. tp_new, tp_call, etc.)

CC @MegaIng @gcewing - Could you comment a bit on this?

Can you explain how this piece of code utilizes lazy evaluation? I did not see any.

(Did you mean short-circuit evaluation?)

result = d.get('a', defer(math.factorial, 1_000_000));

In this example, the default value will be evaluated to a defer object. You need to access the deferred value within dict.get() if necessary, for example, using a method like defer.eval(). As shown, it won’t evaluate itself; it has already been evaluated into a defer object. This is similar to a generator object, range(), or itertools.count().

See PEP 671:

A simple rule of thumb is: “target=expression” is evaluated when the function is defined, and “target=>expression” is evaluated when the function is called.

Also, refer to the table of short-circuit support in various programming languages. The table shows both eager operators and short-circuit operators.

I don’t think this is an issue. In this case:

result = d.get('a', defer(math.factorial, 1_000_000))
truetype(result)    # defer

If someone wants to eval within a function, then it is ok, but just passing through unevaluated deferral is a very sensible behaviour.

In other words, it automatically evaluates on “use”. And “use” is defined as “all” operations except assignment.

Although I think conceptual work at Pure Python level is far from complete, but I think it is in a fairly good state.



C behaviour is a major issue now:

def sum(iter):
    for item in iter:
        if PyLong_CheckExact(item):    # ???
        # == Py_IS_TYPE(item, &PyLong_Type)
        # == _PyObject_CAST(ob)->ob_type == (type)
            

So there will be many cases, where object is inspected at C level and C code ideally needs to evaluate defer object to its true value. Then it goes on using internal C attributes of that object.

Another, a bit simpler case:

math.factorial(defer(lambda: 1))

def factorial(n):
    x = PyLong_AsLongAndOverflow(n, &overflow);
    # Uses: PyLong_Check
    # Then: _PyNumber_Index
    # Etc...

I can see 2 paths here:

  1. Modify very many C functions to deal with defer objects appropriately.

E.g. PyLong_AsLongAndOverflow would evaluate defer argument implicitly. Py_Is would need to evaluate and return type of evaluated value.

Further issue here is that many people are not using Py_Is, but just do a == b. Although I think that anyone not using Py_Is is not doing the correct thing, but the reality is that this is prevalent even in CPython repo (I have done it…). I had a quick encounter on this issue with @ZeroIntensity, and thought it is ok at the time, but that was before I ran into this. Now I can clearly see that he was right to question bad practice. And now I fully agree that a == b should only be used strategically in the “core” and Py_Is should be enforced everywhere else.

  1. By default all defer objects are evaluated when passed into arguments of C function. And C function needs to manually opt-in to allow unevaluated defer arguments.

The issue here is that it is nearly impossible to convert all defer argument objects. It can only be done for certain depth for certain set of containers.



So both paths above have serious problems.

I hope that maybe with time we can come up with something ingenious to overcome this.