groove-x / trio-util

Utility library for the Python Trio async/await framework
https://trio-util.readthedocs.io/
MIT License
68 stars 6 forks source link

compose and transform without context managers? #17

Open belm0 opened 3 years ago

belm0 commented 3 years ago

Currently compose and transform functionality take the form of context managers. This is the orderly way, ensuring that derived values don't outlive the scope of their parents, and that callbacks (e.g. the transform function) won't be called after the corresponding AsyncValue has been finalized.

with compose_values(x=async_x, y=async_y) as async_xy, \
        async_xy.open_transform(lambda val: val.x * val.y) as x_mul_y \
        async_xy.open_transform(lambda val: val.x / val.y) as x_div_y:
    ...

This form is a bit uncomfortable at first, and requires creative use of ExitStack()-- but it works.

How would things be without context managers?

async_xy = compose_values(x=async_x, y=async_y)
x_mul_y = async_xy.transform(lambda val: val.x * val.y)
x_div_y = async_xy.transform(lambda val: val.x / val.y)

The concern is about lifetimes, and avoiding cycles that might keep objects around forever.

Once transform() works without a context manager, compose_values() could be implemented on top of it by taking advantage of callback side effects.

approach A: weak collection of transform funcs in parent, strong ref in child

The parent AsyncValue would need a weak collection of transform functions, and the child AsyncValue would hold the main ref to the function. The transform callback may be called for a while even after the child expires, until the GC cleans up the weak ref. (Users may be surprised by this, especially if the transform function has side effects.)

However using a static transform function would be problematic, as the function could live for the entire program session:

foo = async_xy.transform(foo_transform)

approach B: weak collection of children in parent

The parent has a weak-key dict of child:transform.

Like (A), the transform callback may be called for a while even after the child expires, until the GC cleans up the weak ref. (Users may be surprised by this, especially if the transform function has side effects.)

approach C: child has a ref to parent and unregisters transform in finalizer

The parent would prepare a finalizer callback on child AsyncValue that would unregister the transform.

However, now it's a situation where the parent could be finalized, and silently the child would stop receiving updates. Adding an additional ref from parent to child isn't too attractive, as it would cause cycles in the garbage graph.