ruk·si

🐍 Python
Classes

Updated at 2018-11-19 17:16

Class definition. Always use self as the first argument to instance methods. Always use cls as the first argument to class methods.

class Human:
    # Class attributes are shared by all instances of this class.
    species = 'H. sapiens'

    # Constructor
    def __init__(self, name: str) -> None:
        self.name: str = name

    # Method
    def say(self, msg: str) -> str:
        return '%s: %s' % (self.name, msg)

    # Class methods are shared among all instances of this class.
    @classmethod
    def get_species(cls) -> str:
        return cls.species

    # Static methods are called without instance reference.
    @staticmethod
    def grunt() -> str:
        return '*grunt*'

Class usage.

ian = Human('Ian')
joel = Human(name='Joel')
assert ian.say('hi') == 'Ian: hi'

assert ian.get_species() == 'H. sapiens'
Human.species = 'H. neanderthalensis'
assert joel.get_species() == 'H. neanderthalensis'

assert Human.grunt() == '*grunt*'

Classes should inherit object, but Python 3 does this automatically. Makes properties work properly and defines special methods that implement default semantics of object.

class OuterClass:
    class InnerClass:
        ...

class ChildClass(OuterClass):
    ...

Custom exceptions should derive from Exception. Consider naming custom exceptions ending with Error if exception is an error.

class ValidationError(Exception):
    pass

raise ValidationError("value is too high")
# => ValidationError: value is too high

Consider if you want to offer direct member access or wrap them.

# direct access
class Point:

    def __init__(self, x: float, y: float) -> None:
        self.x: float = x
        self.y: float = y

# indirect access
class Point:

    def __init__(self, x: float, y: float) -> None:
        self._x: float = x
        self._y: float = y

    @property
    def x(self) -> float:
        return self._x

    @x.setter
    def x(self, value) -> None:
        self._x = value

Offer constructors that help with the basic class usage.

# bad
point = Point()
point.x = 12
point.y = 5

# good
point = Point(x=12, y=5)

Define __repr__ for your classes. Defining instance representation for your classes improves maintainability of your code. __repr__ is for debugging.

# BAD CLASS
class Car:

    def __init__(self, color) -> None:
        self.color = color

my_car = Car('red')
assert '<__main__.Car object at' in str(my_car)
assert '<__main__.Car object at' in repr(my_car)

# GOOD CLASS
class Bike:

    def __init__(self, color) -> None:
        self.color = color

    def __repr__(self) -> str:
        return f'{self.__class__.__name__}({self.color!r})'
        # !r here does repr(self.color) instead of str(self.color)

my_bike = Bike('blue')
assert "Bike('blue')" == str(my_bike)
assert "Bike('blue')" == repr(my_bike)

Optionally define __str__  for your classes. Python calls dunder string whenever an instance of the class is converted to a string e.g. when printing. If __str__ is missing, Python will use __repr__ if defined. __str__ is should be ok to be shown to end-user, not just for debugging.

Watch out for instance variables "shadowing" class variables. Always refer to class variables though __class__ or the class itself when modifying them.

class Counted:
    num_instances: int = 0

    def __init__(self) -> None:
        self.__class__.num_instances += 1  # GOOD, CLASS VARIABLE

assert Counted.num_instances == 0
assert Counted().num_instances == 1
assert Counted().num_instances == 2
assert Counted().num_instances == 3
assert Counted.num_instances == 3

class BuggyCounted:
    num_instances: int = 0

    def __init__(self) -> None:
        self.num_instances += 1  # BAD, INSTANCE VARIABLE

assert BuggyCounted.num_instances == 0
assert BuggyCounted().num_instances == 1
assert BuggyCounted().num_instances == 1
assert BuggyCounted().num_instances == 1
assert BuggyCounted.num_instances == 0

Python is smart in duck typing and tries various methods to fulfill an interface.

class Foo:
    def __getitem__(self, position: int) -> int:
        return range(0, 30, 10)[position]

f = Foo()
assert f[1] == 10

# __iter__ fallbacks to __getitem__
assert [n for n in f] == [0, 10, 20]

# __contains__ fallbacks to __getitem__
assert 20 in f

__new__ can be used to change how to construct an instance of the class. What __new__ returns is passed to __init__ if __new__ returns an instance of the class the __new__ is defined in.

import json
import keyword
import typing

class FrozenJSON:

    def __new__(cls: typing.Type['FrozenJSON'], arg: typing.Any):
        if isinstance(arg, typing.Mapping):
            return super().__new__(cls)
        elif isinstance(arg, typing.MutableSequence):
            return [cls(item) for item in arg]
        else:
            return arg

    def __init__(self, mapping: typing.Mapping):
        self._data = {}
        for key, value in mapping.items():
            if keyword.iskeyword(key):
                key += '_'
            self._data[key] = value

    def __getattr__(self, name: str) -> 'FrozenJSON':
        if hasattr(self._data, name):
            return getattr(self._data, name)
        else:
            return FrozenJSON(self._data[name])

raw = '{"people": [{"name": "John", "class": "6A"}, {"name": "Alice", "class": "6B"}]}'
f = FrozenJSON(json.loads(raw))
assert len(f.people) == 2
assert f.people[0].name == 'John' and f.people[0].class_ == '6A'
assert f.people[1].name == 'Alice' and f.people[1].class_ == '6B'

Other useful built-in functions.

getattr(obj, name[, default])  # also searches parents
hasattr(obj, name)             # also searches parents
setattr(obj, name, value)      # only assigns to the obj
dir(obj)                       # show attributes in the obj
vars(obj)                      # show __dict__

Sources