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.
3 Answers 3
I really don't think
Value
is a help here.
Whilst I can see the allure of only assigningx
tostatic.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 theisinstance
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?
-
\$\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\$two_pc_turkey_breast_mince– two_pc_turkey_breast_mince2020年05月08日 17:47:52 +00:00Commented 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\$2020年05月08日 17:55:47 +00:00Commented 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\$two_pc_turkey_breast_mince– two_pc_turkey_breast_mince2020年05月08日 17:58:53 +00:00Commented 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\$2020年05月08日 18:55:29 +00:00Commented May 8, 2020 at 18:55 -
\$\begingroup\$ with more code [than you expressed in your answer] \$\endgroup\$two_pc_turkey_breast_mince– two_pc_turkey_breast_mince2020年05月08日 19:01:04 +00:00Commented May 8, 2020 at 19:01
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
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.