3
\$\begingroup\$

Over the holidays I played around with coroutines and their use as two-in-one getter and setter methods:

#! /usr/bin/env python3
#  coroproperty.py - Coroutine-based two-in-one properties.
#
#  Copyright (C) 2020 Richard Neumann <mail at richard dash neumann period de>
#
#  This program is free software: you can redistribute it and/or modify
#  it under the terms of the GNU General Public License as published by
#  the Free Software Foundation, either version 3 of the License, or
#  (at your option) any later version.
#
#  This program is distributed in the hope that it will be useful,
#  but WITHOUT ANY WARRANTY; without even the implied warranty of
#  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
#  GNU General Public License for more details.
#
#  You should have received a copy of the GNU General Public License
#  along with this program.  If not, see <https://www.gnu.org/licenses/>.
"""Coroutine-based two-in-one decorator."""

from contextlib import suppress
from math import pi, sqrt
from typing import Any, Callable


def coroproperty(method: Callable) -> property:
    """Single decorator for getter and setter methods."""

    def getter(self) -> Any:
        """Property getter function."""
        coro = method(self)
        value = next(coro)
        coro.close()
        return value

    def setter(self, value: Any):
        """Property setter function."""
        coro = method(self)
        next(coro)
        next(coro)

        with suppress(StopIteration):
            coro.send(value)

    return property(getter, setter)


class Circle:
    """Information about a circle."""

    def __init__(self, radius: float):
        """Initializes the circle with its radius."""
        self.radius = radius

    @coroproperty
    def diameter(self):
        """Gets and sets the diameter."""
        yield self.radius * 2
        self.radius = (yield) / 2

    @coroproperty
    def circumference(self):
        """Gets and sets the circumference."""
        yield self.diameter * pi
        self.diameter = (yield) / pi

    @coroproperty
    def area(self):
        """Gets and sets the area."""
        yield pow(self.radius, 2) * pi
        self.radius = sqrt((yield) / pi)

The downsides I already discovered are, that each set access implies that an unused get is performed before. Furthermore, these decorators are significantly slower than a "classical" property with separate getter and setter methods:

Coro get: 0.2394869327545166
Classic get: 0.09408378601074219
Ratio coro / classic: 2.5454644515174247
Coro set: 0.7087152004241943
Classic set: 0.10921454429626465
Ratio coro / classic: 6.489201644257868

What other side-effects could arise when using these kind of properties?

Note that this code is not intended for use in productive environments, but is of academic interest only.

\$\endgroup\$

1 Answer 1

3
\$\begingroup\$

The proper way to prime a generator for accepting values is to send an initial None value.

Yield will simultaneously return a value to the caller, and accept a value sent from the caller.

Using those two facts, you could rewrite the code like:

...

def coroproperty(method: Callable) -> property:

    ...

    def setter(self, value: Any) -> None:
        coro = method(self)
        coro.send(None)
        with suppress(StopIteration):
            coro.send(value)

    ...

class Circle:

    ...

    @coroproperty
    def diameter(self):
        self.radius = (yield self.radius * 2) / 2

    ...

Side-effects

Wasted time and computation. Setting the diameter, circumference and/or area first involves computing those quantities, which is unnecessary.

You could circumvent that by accepting a new value before returning the value.

    @coroproperty
    def diameter(self):
        diameter = yield None
        if diameter is not None:
           self.radius = diameter / 2
        else:
           yield self.radius * 2

The getter would need to call next(coro) twice, ignoring the first returned value: None.

If you desired a property which could store None, you'd need to create and use your own sentinel value.


Assignment to diameter, even if not changing the value, will change the radius. In this example, it is changed from an int to a float:

>>> c.radius = 100
>>> c.radius
100
>>> c.diameter = c.diameter
>>> c.radius
100.0

Add in sqrt, and multiplication & division by π, and accuracy can be lost:

>>> c.radius = 1_000_000_000_000
>>> c.area = c.area
>>> c.radius
999999999999.9999
\$\endgroup\$

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Not the answer you're looking for? Browse other questions tagged or ask your own question.