Object-Oriented Programming in Python. Part 1, Basics.¶
Magic Methods¶
Magic (also called dunder) class methods are overloads that allow classes to define their own behavior in relation to language operators.
They are called magic because they are almost never explicitly called; they are invoked by built-in functions or syntactic constructs. For example, the len()
function calls the __len__()
method of the passed object. The method __add__(self, other)
is automatically called when using the addition operator +
.
Examples of magic methods:
__init__
: class constructor
__add__
: addition with another object
__eq__
: comparison for equality with another object
__cmp__
: comparison (greater, less, equal)
__iter__
: used when the object is iterated over in a loop
__new__
: static method called to create an instance of a class. The official documentation explains the purpose of this method quite clearly–it is mainly intended to allow subclasses of immutable types (such as int, str, or tuple) to customize instance creation or to override it in custom metaclasses for customizing class creation.
print(dir(int), "\n")
class A: # An empty class
...
a = A()
print(dir(a), "\n")
print(repr(a), "\n")
print(str(a))
['__abs__', '__add__', '__and__', '__bool__', '__ceil__', '__class__', '__delattr__', '__dir__', '__divmod__', '__doc__', '__eq__', '__float__', '__floor__', '__floordiv__', '__format__', '__ge__', '__getattribute__', '__getnewargs__', '__gt__', '__hash__', '__index__', '__init__', '__init_subclass__', '__int__', '__invert__', '__le__', '__lshift__', '__lt__', '__mod__', '__mul__', '__ne__', '__neg__', '__new__', '__or__', '__pos__', '__pow__', '__radd__', '__rand__', '__rdivmod__', '__reduce__', '__reduce_ex__', '__repr__', '__rfloordiv__', '__rlshift__', '__rmod__', '__rmul__', '__ror__', '__round__', '__rpow__', '__rrshift__', '__rshift__', '__rsub__', '__rtruediv__', '__rxor__', '__setattr__', '__sizeof__', '__str__', '__sub__', '__subclasshook__', '__truediv__', '__trunc__', '__xor__', 'as_integer_ratio', 'bit_count', 'bit_length', 'conjugate', 'denominator', 'from_bytes', 'imag', 'numerator', 'real', 'to_bytes'] ['__class__', '__delattr__', '__dict__', '__dir__', '__doc__', '__eq__', '__format__', '__ge__', '__getattribute__', '__gt__', '__hash__', '__init__', '__init_subclass__', '__le__', '__lt__', '__module__', '__ne__', '__new__', '__reduce__', '__reduce_ex__', '__repr__', '__setattr__', '__sizeof__', '__str__', '__subclasshook__', '__weakref__'] <__main__.A object at 0x000001A1FFF3AF20> <__main__.A object at 0x000001A1FFF3AF20>
A distinctive feature of the __init__
method is that it should not return anything. Attempting to return data will generate an exception.
__repr__
(representation) returns a more or less machine-readable representation of an object, which is useful for debugging.
Sometime __repr__
may contain enough information to reconstruct an object.
__str__
returns a human-readable message. If __str__
is not defined, str
uses repr
.
class Person: # A simple class with init, repr and str methods
def __init__(self, name: str):
self.name: str = name
def __repr__(self):
return f"Person '{self.name}'"
def __str__(self):
return f"{self.name}"
def say_hi(self):
print("Hi, my name is", self.name)
p = Person("Charlie")
p.say_hi()
print(repr(p))
print(str(p))
Hi, my name is Charlie Person 'Charlie' Charlie
import math
class Circle:
def __init__(self, radius, max_radius):
self._radius = radius
self.max_radius = max_radius
@property
def radius(self):
return self._radius
@radius.setter
def radius(self, value):
if value <= self.max_radius:
self._radius = value
else:
raise ValueError
@property
def area(self):
return 2 * self.radius * math.pi
circle = Circle(10, 100)
circle.radius = 20 # OK
# circle.radius = 101 # Raises ValueError
print(circle.area)
125.66370614359172
@staticmethod¶
A regular method (i.e., not marked by the @staticmethod or @classmethod decorators) has access to the properties of a specific instance of the class.
@staticmethod denotes a method that belongs to the class rather than an instance of the class. It can be called without creating an instance since the method does not have access to instance properties. The @staticmethod decorator marks functionality that is logically related to the class but does not require access to instance properties.
@classmethod, cls, self¶
If a method does not need to have access to the properties of a specific instance of the class (like @staticmethod) but does need access to other methods and variables of the class, @classmethod should be used.
class B(object):
def foo(self, x):
print(f"Run foo({self}, {x})")
@classmethod
def class_foo(cls, x):
print(f"Run class_foo({cls}, {x})")
@staticmethod
def static_foo(x):
print(f"Run static_foo({x})")
b = B()
b.foo(1)
b.class_foo(1)
b.static_foo(1)
Run foo(<__main__.B object at 0x000001A1FFF3A980>, 1) Run class_foo(<class '__main__.B'>, 1) Run static_foo(1)
A @classmethod method must have cls
(the class) as the first parameter, whereas a regular method has self
(the class instance) as the first parameter.
The @staticmethod method does not require either cls
or self
.
__dict__¶
Each class and each object has a __dict__
attribute. This is a "system" attribute defined by the interpreter; it doesn't need to be created manually. __dict__
is a dictionary that stores user-defined attributes; in it, the key is the name of the attribute, and the value is, accordingly, the value of the attribute.
class Supercriminal:
publisher = 'DC Comics'
Riddler = Supercriminal()
print(Supercriminal.__dict__)
print(Riddler.__dict__)
Riddler.name = 'Edward Nygma'
print(Riddler.__dict__) # Values from object __dict__
print(Riddler.publisher) # Value from class __dict__
{'__module__': '__main__', 'publisher': 'DC Comics', '__dict__': <attribute '__dict__' of 'Supercriminal' objects>, '__weakref__': <attribute '__weakref__' of 'Supercriminal' objects>, '__doc__': None} {} {'name': 'Edward Nygma'} DC Comics
Each time a user-defined attribute is requested, Python sequentially searches the object itself, the object's class, and the classes from which the object's class is inherited.
__slots__¶
If you recall the difference between lists and tuples, as well as between sets and frozensets, you will notice that the creators of Python try to provide developers with a choice between convenience and speed. Another such language feature aimed at increasing performance and reducing memory usage is __slots__
.
Here is the official documentation for __slots__
, and here are some additional clarifications from one of the developers of the official documentation. When choosing "slots or not slots," remember the existence of PEP 412 – Key-Sharing Dictionary, which has complicated what was once a straightforward approach to __slots__
.
The __dict__
attribute, considered above, is a mutable structure, and you can add and remove fields from a class on the fly, which is convenient but sometimes slow. You can trade off convenience for speed and memory usage by creating __slots__
—a fixed list of predefined attributes that reserves memory and prohibits the further creation of __dict__
and __weakref__
. Slots can be used when a class may have many fields, for example, in ORM, or when performance is critical.
class Clan:
__slots__ = ["first", "second"]
clan = Clan()
clan.first = "Joker"
clan.second = "Lex Luthor"
# clan.third = "Green Goblin" # Raises AttributeError
# print(clan.__dict__) # Raises AttributeError
Slots are used, for example, in the requests library (__slots__ = ["url", "netloc", "simple_url", "pypi_url", "file_storage_domain"]
) or the ORM peewee (__slots__ = ('stack', '_sql', '_values', 'alias_manager', 'state')
).
Inheritance of __slots__
has its specific characteristics and will be considered later.
To understand the performance improvement and memory reduction, let's make a simple comparison:
import timeit
import pympler.asizeof # In our case, sys.getsizeof is not the best option; we take a third-party solution
class NotSlotted:
pass
class Slotted:
__slots__ = 'foo'
not_slotted = NotSlotted()
slotted = Slotted()
def get_set_delete_fn(obj):
def get_set_delete():
obj.foo = "Never Ending Song of Love"
del obj.foo
return get_set_delete
ns = min(timeit.repeat(get_set_delete_fn(not_slotted)))
s = min((timeit.repeat(get_set_delete_fn(slotted))))
print(ns, s, f'{(ns - s) / s * 100} %')
print(pympler.asizeof.asizeof(not_slotted), 'bytes')
print(pympler.asizeof.asizeof(slotted), 'bytes')
0.10838449979200959 0.08712740009650588 24.39772066187959 % 280 bytes 40 bytes
In Python 3.12 on Windows 10, I see a 24% difference.
As a reminder, always run unclear code examples in your IDE; they can and should be analyzed, adjusted, and modified. Try, for instance, to check the memory consumption by objects with __dict__
and __slots__
. Also, practically test the long-awaited and finally available from Python 3.10 symbiosis between __slots__
and dataclass.