I personally prefer to put class variables at class level and instance variables inside the __init__
. I would consider doing anything else a "hack", or something that leads to confusion, and here's why:
Just adding a typehint in Python does nothing:
class Empty:
x: int
assert hasattr(Empty, 'x') is False
It doesn't matter where (module-, class-, or method- level) you do this; the line is ignored.
ClassVar
exists, but not InstanceVar
You can declare something a class variable using ClassVar
(only for the sake of type checking). Then e.g. Mypy will give you an error when you're trying to modify a class variable via instance (e.g. self.class_var = new_val
).
However, Mypy won't give you an error if you modify what you intended to be an instance variable at class level. That's because it can't really tell what's an instance variable and what's a "class-level default", which is what captain
is in the example given.
And really, the only reason that self.captain = captain
does not change the value of BasicStarship.captain
is because of the way string assingment works (it makes copies).
a_str = 'something'
another_str = a_str
another_str += ' else'
assert a_str == 'something'
assert another_str == 'something else'
A list is an example of something that does not work that way:
a_list = ['something']
another_list = a_list
another_list += [' else']
assert a_list == ['something', ' else']
assert another_list == ['something', ' else']
Lists do not get copied on assignment, so you're modifying a single object here.
This leads to confusion if you've convinced yourself that "class variables are anything addressable via the class object and instance variables are anything addressable via instance object". Let's take the approach of "I want to declare all class- and instance-variable defaults at class-level":
class Something:
instance_str: str = 'instance'
instance_list: list[str] = []
class_str: ClassVar[str] = 'class'
def __init__(self, instance_suffix: str | None = None) -> None:
if instance_suffix is not None:
# I think of this as the equivalent of:
# self.instance_str = Something.instance_str + instance_suffix
self.instance_str += instance_suffix
# This has unintended consequences! It's the equivalent of
# self.instance_list = Something.instance_list # a reference
# self.instance_list.append(instance_suffix) # modify the original!
self.instance_list += [instance_suffix]
def reveal(self) -> None:
print(f' {self.instance_str=}')
print(f' {self.instance_list=}')
print(f' {Something.instance_str=}')
print(f'{Something.instance_list=}')
print(f' {self.class_str=}')
print(f' {Something.class_str=}')
If we could have annotated instance_list
as an InstanceVar
, then Mypy could have told us we're inadvertently modifying the Something.instance_list
in the constructor. But it can't. There's no magic here; it all has to do with how the assignment operator works for a particular type; strings do what you want but lists do not. You can confirm this with the following code:
print('\n\n')
s1 = Something('_s1')
s2 = Something('_s2')
print('\ns1:')
s1.reveal() # Shows changes to `Something.instance_list`; bad!
print('\ns2:')
s2.reveal() # Shows changes to `Something.instance_list`; bad!
print('\n\n')
print('s1.class_str = \'foo\'')
s1.class_str = 'foo' # mypy error: Cannot assign to class variable "class_str" via instance
print('\ns1:')
s1.reveal() # shows change to `self.class_str` but not `Something.class_str`
print('\ns2:')
s2.reveal() # shows no changes
Dissecting BasicStarship
The OP posted this example:
class BasicStarship:
captain: str = 'Picard' # instance variable with default
damage: int # instance variable without default
stats: ClassVar[Dict[str, int]] = {} # class variable`
def __init__(self, damage: int, captain: str = None):
self.damage = damage
if captain:
self.captain = captain # Else keep the default
Here's one way to think about what is happening. At class level:
BasicStarship.captain
exists and its value is 'Picard'
. Thus it is a class variable.
BasicStarship.damage
does not exist
BasicStarship.stats
exists and is an empty dictionary; and again, it's a class variable.
In the constructor:
self.damage
is created. This does not by any means create a BasicStarship.damage
.
self.captain
is created if a captain
value is passed in. It does not matter that BasicStarship.captain
exists; this is entirely the same as self.damage = damage
. If captain
is not passed in, self.captain
is not created, but hasattr(BasicStarship(), 'captain')
returns True
because it will "reach back" into the class looking for that (it's not in BasicStarship().__dict__
).
BasicStarship.stats
exists and care must be taken in how you handle it. If you suddenly say self.stats = {'kills': 0}
, you're creating a new attribute; Mypy will give you an error (because of ClassVar
) but you're safe if you ignore it. If you say self.stats['kills'] = 0
, you're modifying BasicStarship.stats
; Mypy will give you an error and if you ignore it, you have a bug.
This last point is "obvious" in retrospect but I think it requires way too much attention to detail while scanning code. It's also the reason many discourage the use of mutables as function parameter defaults.
For that reason, if someone insisted on advertising instance level attributes at the top of a class, I'd write BasicStarship
like this:
class BasicStarship:
# Instance variables
####################
captain: str
damage: int
# Class variables
#################
stats: ClassVar[dict[str, int]] = {}
def __init__(self, damage: int, captain: str = 'Picard'):
self.damage = damage
self.captain = captain
To be honest, though, I would only annotate instance variables at class level if a) advertising them leads to clarity, i.e. the constructor is huge and complicated, and b) it avoid creating a trivial constructor when subclassing. Otherwise I would just leave that stuff in the constructor. If you use ClassVar
, it's obvious what's a class variable, so this is cleaner to me:
class BasicStarship:
stats: ClassVar[dict[str, int]] = {}
def __init__(self, damage: int, captain: str = 'Picard'):
self.damage = damage
self.captain = captain
Or perhaps, more succinctly:
The constructor gives you access to the instance via the first parameter, so how could you possibly define instance variables at class level, where you, by definition, do not have access to an instance?
captain
anddamage
instance variables in the second example? Aren't they class variables as well? Or is the fact, that they are altered in the init method making them instance variables? If I would have a list, and would alter it withlist.append()
that alteration would be shared over all instances, so it would still be a class variable. – Polandself
orBasicStartship
. More accurately, whether they're addressed by an instance or class. For example, outside the class, if you sayBasicStarship().captain = 'Blackbeard'
, you are not changing the default value of'Picard'
. (At least in Python 3.12; I was definitely confused by this from my Python 2.x days.) – Cullan