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.

asked Apr 16, 2020 at 10:07
\$\endgroup\$

3 Answers 3

2
\$\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?

answered Apr 16, 2020 at 11:43
\$\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\$ Commented 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\$ Commented 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\$ Commented 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\$ Commented May 8, 2020 at 18:55
  • \$\begingroup\$ with more code [than you expressed in your answer] \$\endgroup\$ Commented May 8, 2020 at 19:01
1
\$\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
answered May 8, 2020 at 21:06
\$\endgroup\$
0
\$\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.

answered May 8, 2020 at 18:58
\$\endgroup\$

Your Answer

Draft saved
Draft discarded

Sign up or log in

Sign up using Google
Sign up using Email and Password

Post as a guest

Required, but never shown

Post as a guest

Required, but never shown

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

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.