4
\$\begingroup\$

I'm primarily a C++ developer, but I find myself writing significant amounts of Python these days. One C++ feature I miss in Python are function-scope static variables (variables which are initialised once, but retain their value across calls to the function). So I wrote a decorator for adding static variables to a function, and I'd like some feedback on it.

from functools import wraps


class Statics:
    class Current:
        class Value:
            def __init__(self, name):
                self.name = name

        def __init__(self, statics):
            self.__statics = statics

        def __getattr__(self, name):
            if hasattr(self.__statics, name):
                return self.Value(name)
            else:
                return None

    def __init__(self):
        self.current = self.Current(self)

    def __setattr__(self, name, value):
        if isinstance(value, self.Current.Value):
            assert value.name == name, \
                f"static.current.{value.name} can only be use to assign to " \
                f"static.{value.name}, not to static.{name}"
        else:
            super(Statics, self).__setattr__(name, value)


def with_statics(f):
    """Add static variables to a function.

    A function decorated with @with_statics must accept a "static variables"
    or "statics" object as its very first argument; the recommended name for
    this parameter is 'static'. This "statics" object is used access the
    function's static variables.

    A static variable is initialised with a value the first time control flow
    reaches its initialisation, and retains its value after that, even across
    several calls to the function. To initialise a static variable, use the
    following syntax: `static.x = static.current.x or expression`. When
    executing this statement for the first time, `expression` will be
    evaluated and stored in `static.x`. On all subsequent executions of this
    statement (even on subsequent calls to the containing function), the
    statement does nothing and `expression` is guaranteed to *not* be
    evaluated.

    Here's an example of using statics to implement a call counter:

    >>> @with_statics
    ... def counter(static):
    ...     static.val = static.current.val or 0
    ...     val = static.val
    ...     static.val += 1
    ...     return val
    >>> (counter(), counter(), counter())
    (0, 1, 2)

    The initialisation expression is guaranteed to only execute once:
    >>> def get_string():
    ...     print("Getting string")
    ...     return ""
    ... 
    >>> @with_statics
    ... def record(static, text):
    ...     static.recorded = static.current.recorded or get_string()
    ...     static.recorded += text
    ...     return static.recorded
    ... 
    >>> record("Hello")
    Getting string
    'Hello'
    >>> record(", world!")
    'Hello, world!'

    Notice the absence of "Getting string" after the second call.
    """
    statics = Statics()
    @wraps(f)
    def wrapper(*args, **kwargs):
        return f(statics, *args, **kwargs)
    return wrapper

Relevant parts of its test file:

import doctest
import unittest

import statics
from statics import *


class TestWithStatics(unittest.TestCase):
    def test_simple(self):
        @with_statics
        def counter(static):
            static.x = static.current.x or 0
            static.x += 1
            return static.x
        self.assertSequenceEqual(
            (counter(), counter(), counter()),
            (1, 2, 3)
        )

    def test_unique_init_calls(self):
        @with_statics
        def counter(static, init):
            static.x = static.current.x or init()
            static.x += 1
            return static.x
        inits = []
        def init():
            inits.append(None)
            return 0
        self.assertSequenceEqual(
            (counter(init), counter(init), counter(init)),
            (1, 2, 3)
        )
        self.assertSequenceEqual(inits, [None])

    def test_unique_init_loop(self):
        @with_statics
        def counter(static, init, count):
            for i in range(count):
                static.x = static.current.x or init()
                static.x += 1
            return static.x
        inits = []
        def init():
            inits.append(None)
            return 0
        self.assertEqual(counter(init, 3), 3)
        self.assertSequenceEqual(inits, [None])

    @unittest.skipUnless(__debug__, "requires __debug__ run of Python")
    def test_name_mismatch_assertion(self):
        @with_statics
        def function(static):
            static.x = static.current.x or 0
            static.y = static.current.x or 1
            return static.y
        with self.assertRaises(AssertionError) as ex:
            function()
        msg = str(ex.exception)
        self.assertRegex(msg, r"static\.current\.x")
        self.assertRegex(msg, r"static\.x")
        self.assertRegex(msg, r"static\.y")

    def test_instance_method(self):
        class Subject:
            def __init__(self):
                self.val = 0
            @with_statics
            def act(static, self):
                static.x = static.current.x or 0
                self.val += static.x
                static.x += 1
                return self.val
        s = Subject()
        self.assertSequenceEqual(
            (s.act(), s.act(), s.act()),
            (0, 1, 3)
        )

    def test_class_method(self):
        class Subject:
            val = 0
            @classmethod
            @with_statics
            def act(static, cls):
                static.x = static.current.x or 0
                cls.val += static.x
                static.x += 1
                return cls.val
        self.assertSequenceEqual(
            (Subject.act(), Subject.act(), Subject.act()),
            (0, 1, 3)
        )


def run():
    if not doctest.testmod(statics)[0]:
        print("doctest: OK")
    unittest.main()


if __name__ == "__main__":
    run()

I'm primarily looking for feedback in these areas:

  • Is the implementation Pythonic? Being primarily a C++ developer, Python idioms don't come naturally to me; that's one thing I'm constantly trying to improve.
  • Is the idea itself Pythonic (or at least neutral), or does it feel like something "true Python shouldn't have" for some reason?
  • Am I reinventing a wheel and something like this already exists?

I'll welcome any other feedback too, of course.

\$\endgroup\$

3 Answers 3

1
\$\begingroup\$
  • I really don't think Value is a help here.
    Whilst I can see the allure of only assigning x to static.x. This just looks like a smart, but not exactly good, work around to get default values.

    You can just have with_statics take keyword arguments as the defaults.

    def with_statics(**defaults):
        statics = Statics(defaults)  # Implementation left to imagination
        def inner(f):
            @wraps(f)
            def wrapper(*args, **kwargs):
                return f(statics, *args, **kwargs)
            return wrapper
        return inner
    
  • Defining Value inside of two other classes is not great. You can just move it to the global scope and change the isinstance check to not need to use indexing.

    If you still want the class on the other classes then you can just assign it as a class attribute.

    class Current:
        Value = Value
    
        ...
    
  • I think I'm fairly well versed in the magical Python arts. However your code is just confusing.

  • Your code looks like it's a hack when a hack isn't needed.

    To highlight this I will convert your doctests to not use your code.

def counter():
    counter.val += 1
    return counter.val


counter.val = 0
print(counter(), counter(), counter())


def get_string():
    print("Getting string")
    return ""


def record(text):
    if record.recorded is None:
        record.recorded = get_string()
    record.recorded += text
    return record.recorded


record.recorded = None
print(record("Hello"))
print(record(", world!"))

Yes it works. However, honestly, why would you want this?

\$\endgroup\$
6
  • \$\begingroup\$ i agree with most of your sentiments, but your example of having with_statics take a defaults dictionary isn't by itself expressive enough to emulate OP's code. it's fine if the initialisation value were known at compile-time - but this isn't necessarily the case. \$\endgroup\$ May 8, 2020 at 17:47
  • \$\begingroup\$ @two_pc_turkey_breast_mince You can always not provide a default, or set it to SENTINEL (object()). If you show me something it wouldn't be able to do, I'm sure I can show you it's able to do that. \$\endgroup\$
    – Peilonrayz
    May 8, 2020 at 17:55
  • \$\begingroup\$ i'm sure you could - with more code. all i mean to say is that the suggestion taken alone is incomplete if the intent is to replicate all of OP's functionality. \$\endgroup\$ May 8, 2020 at 17:58
  • \$\begingroup\$ @two_pc_turkey_breast_mince Not really, the OP's code is basically just a long winded if ...: foo = bar. They'd both be one line of code. \$\endgroup\$
    – Peilonrayz
    May 8, 2020 at 18:55
  • \$\begingroup\$ with more code [than you expressed in your answer] \$\endgroup\$ May 8, 2020 at 19:01
0
\$\begingroup\$

In the few cases I've wanted C-style static variables, I've used a Class with a __call__() method:

class Foo:
    a = 1
    b = 2

    def __call__(self):
        print(self.a, self.b)
foo = Foo()

foo()

Or, you could use a function decorator

def staticvars(**vars):
    def f(func):
        for k,v in vars.items():
            setattr(func, k, v)
        return func
    return f

@staticvars(a=1,b=2)
def foo():
    print(foo.a, foo.b)

Or, under the idea "it's easier to get forgiveness than permission":

def counter():
    try:
        counter.x += 1

    except AttributeError:
        counter.x = 1

    return counter.x


print(counter(), counter(), counter())   # prints: 1, 2, 3
\$\endgroup\$
-1
\$\begingroup\$

I think your implementation is broken. It states in the docstring

The initialisation expression is guaranteed to only execute once

but I think the initialisation expression could be run multiple times with multithreading (race condition). Initialisation of function static variables in late C++ employs synchronisation under-the-hood.

Otherwise, I think the implementation is rather clever, but I wouldn't use it. I agree with most of this answer's criticisms, especially about nesting a class inside a class inside a class. That answer's final suggestion of using the function instance itself to store the static variables seems to miss the mark, though, in my opinion. One of the more useful properties of a function static variable is to minimise the scope of the variable. Ideally, it shouldn't be visible externally or depend on externally accessible state.

Pythonic in my experience just seems to mean "constructs with which we're familiar". I've toyed with replicating statics in Python, too, but I've never seen it used anywhere in the wild, including in the workplace. Implementing a generic language feature atop a language never seems to come off naturally, and the syntax for your statics is, in my opinion, clunky and incurs a fair runtime overhead - but that's not to say that I think it's possible to come up with something much better. I'd forgo this altogether.

\$\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.