Featured image

Introduction Link to heading

In the second article we defined our first classes and came across some funny-looking attributes wrapped in double underscores: init, class, and the long list returned by dir(). The time has come to explore these!

These double-underscore are magic methods (or dunder methods, short for double underscore) and they are one of the fundamental features of the language. We will also be covering introspection, that is Python’s ability to examine objects at runtime, and a feature that is in many ways similar to C# reflection.

What Are Magic Methods? Link to heading

Magic methods are special methods that Python calls automatically in response to certain operations. They allow your custom classes to integrate natively with Python’s built-in features: things like printing an object, comparing two instances, using arithmetic operators, or iterating over a custom collection.

In C#, many of these behaviours are achieved through operator overloading (using the operator keyword) or by implementing an interface. In Python, you implement the magic method on your class. As a quick example: when you write len(my_list), Python is calling my_list.len() under the hood. When you use the + operator on two objects, Python calls add. You generally never call magic methods directly; you implement them, and Python calls them for you.

str and repr Link to heading

Two good examples of useful magic methods are str and repr. Their C# counterpart is ToString(), though Python makes a more granular distinction between the two purposes.

repr is intended to provide an unambiguous, developer-facing representation of the object, ideally one that could be used to recreate it. str is the human-readable version, shown when you pass an object to print() or convert it to a string with str(). If a class defines only repr, Python will fall back to it for both uses.

class Point:
    def __init__(self, x, y):
        self.x = x
        self.y = y

    def __repr__(self):
        return f"Point({self.x}, {self.y})"

    def __str__(self):
        return f"({self.x}, {self.y})"


p = Point(3, 4)
print(str(p))    # (3, 4)
print(repr(p))   # Point(3, 4)
print(p)         # (3, 4); print() uses __str__

As a rule of thumb, it’s not a bad idea to always implement repr, as helps immensely during debugging. Add str when you want a cleaner, friendlier display for end users or output.

Comparison Magic Methods Link to heading

Operator overloading in C# is done by declaring public static T operator ==(T a, T b) and friends as static methods on a class. Python achieves the same through a set of magic methods, one per comparison operator:

  • == maps to eq
  • != maps to ne
  • < maps to lt
  • <= maps to le
  • > maps to gt
  • >= maps to ge

Let’s extend our Point class to support equality and ordering by distance from the origin:

class Point:
    def __init__(self, x, y):
        self.x = x
        self.y = y

    def __repr__(self):
        return f"Point({self.x}, {self.y})"

    def __eq__(self, other):
        return self.x == other.x and self.y == other.y

    def __lt__(self, other):
        return (self.x ** 2 + self.y ** 2) < (other.x ** 2 + other.y ** 2)


p1 = Point(1, 2)
p2 = Point(1, 2)
p3 = Point(3, 4)

print(p1 == p2)  # True
print(p1 == p3)  # False
print(p1 < p3)   # True

A nice convenience Python offers is the @functools.total_ordering decorator from the standard library: define eq and just one of the ordering methods, and Python will fill in the remaining comparison magic methods for you automatically. It is well worth knowing about when building value types.

One important note: by default (i.e, without any override) Python’s eq compares identity rather than value, just as == on reference types in C# compares references unless overridden. Override it as above to compare values instead.

Arithmetic Magic Methods Link to heading

The same pattern applies to arithmetic operators:

class Vector:
    def __init__(self, x, y):
        self.x = x
        self.y = y

    def __repr__(self):
        return f"Vector({self.x}, {self.y})"

    def __add__(self, other):
        return Vector(self.x + other.x, self.y + other.y)

    def __sub__(self, other):
        return Vector(self.x - other.x, self.y - other.y)

    def __mul__(self, scalar):
        return Vector(self.x * scalar, self.y * scalar)


v1 = Vector(1, 2)
v2 = Vector(3, 4)

print(v1 + v2)  # Vector(4, 6)
print(v2 - v1)  # Vector(2, 2)
print(v1 * 3)   # Vector(3, 6)

Container Magic Methods Link to heading

If you want your custom class to behave like a collection (and thus support indexing and the in keyword, or len()) there are magic methods for those too. You can enable this functionality with len, getitem, and contains:

class Playlist:
    def __init__(self):
        self._tracks = []

    def add(self, track):
        self._tracks.append(track)

    def __len__(self):
        return len(self._tracks)

    def __getitem__(self, index):
        return self._tracks[index]

    def __contains__(self, track):
        return track in self._tracks


playlist = Playlist()
playlist.add("Bohemian Rhapsody")
playlist.add("Take Me Somewhere Nice")
playlist.add("Comfortably Numb")

print(len(playlist))                    # 3
print(playlist[0])                      # Bohemian Rhapsody
print("Comfortably Numb" in playlist)  # True
print("Wonderwall" in playlist)         # False

for track in playlist:                  # __getitem__ enables iteration too
    print(track)
3
Bohemian Rhapsody
True
False
Bohemian Rhapsody
Take Me Somewhere Nice
Comfortably Numb

Notice that by implementing getitem, you get iteration support for free; Python will keep calling it with incrementing indices until an IndexError is raised. For more fine-grained control, you can implement iter and next explicitly, in a feature involving generators and iterators.

@property: Python’s Getter/Setter Link to heading

C# developers tend to reach for properties with explicit getters and setters. Python has a clean equivalent in the @property decorator, which lets you expose a method as if it were a plain attribute, with no parentheses at the call site:

// C#
private double _temperature;

public double Temperature
{
    get => _temperature;
    set
    {
        if (value < -273.15)
            throw new ArgumentException("Temperature cannot be below absolute zero.");
        _temperature = value;
    }
}
# Python
class Thermometer:
    def __init__(self, temperature):
        self._temperature = temperature

    @property
    def temperature(self):
        return self._temperature

    @temperature.setter
    def temperature(self, value):
        if value < -273.15:
            raise ValueError("Temperature cannot be below absolute zero.")
        self._temperature = value


t = Thermometer(20)
print(t.temperature)  # 20; accessed like an attribute
t.temperature = 100
t.temperature = -300  # ValueError: Temperature cannot be below absolute zero.

The leading underscore on _temperature signals “private by convention”, as we discussed in the first article. Python has no access modifiers, but the underscore prefix is the widely accepted indicator that an attribute is not intended for direct external use. The @property decorator then provides a clean public interface with any validation logic baked in, exactly as C# properties do. If you want a read-only property, simply omit the setter.

Introspection Link to heading

We briefly saw dir() in action in part two. Python’s introspection capabilities go considerably further, and they carry none of the performance overhead typically associated with C#’s reflection.

The most commonly used introspection tools are:

* <code>type(obj)</code>: returns the type of an object.
* <code>isinstance(obj, cls)</code>: checks whether an object is an instance of a class or any of its subclasses.
* <code>issubclass(cls, parent)</code>: checks whether one class is a subclass of another.
* <code>hasattr(obj, name)</code>: checks whether an object has a given attribute.
* <code>getattr(obj, name, default)</code>: retrieves an attribute by name, with an optional default if it is absent.
* <code>setattr(obj, name, value)</code>: sets an attribute by name dynamically.
* <code>dir(obj)</code>: lists all attributes and methods of an object.
class Animal:
    def __init__(self, name):
        self.name = name

    def speak(self):
        pass


class Dog(Animal):
    pass


dog = Dog("Ollie")

print(type(dog))                      # <class '__main__.Dog'>
print(isinstance(dog, Dog))           # True
print(isinstance(dog, Animal))        # True; works up the hierarchy
print(issubclass(Dog, Animal))        # True
print(hasattr(dog, "name"))           # True
print(hasattr(dog, "breed"))          # False
print(getattr(dog, "name"))           # Ollie
print(getattr(dog, "breed", "N/A"))   # N/A; default returned if attribute absent

This kind of dynamic attribute access is enormously useful when writing frameworks, serialisers, or any code that needs to work with objects it does not know about at write time. In C#, achieving the same typically requires reflection with its associated verbosity and runtime cost.

One of the most practical real-world uses of these tools is building a generic description of any object without knowing its class ahead of time. vars(obj) returns the instance’s dict, a dictionary of all its instance attributes, and combines well with the comprehensions we covered in part three:

def describe(obj):
    class_name = type(obj).__name__
    attrs = {k: v for k, v in vars(obj).items() if not k.startswith("_")}
    details = ", ".join(f"{k}={v!r}" for k, v in attrs.items())
    return f"{class_name}({details})"


dog = Dog("Ollie")
print(describe(dog))  # Dog(name='Ollie')

The !r format specifier inside the f-string calls repr() on each value, producing a properly quoted and unambiguous output. Combining vars(), dictionary comprehensions, and type() like this gives you a lot of power with very little code.

In the final article of the series, we will be stepping back from the language itself and taking a look at the Python ecosystem as a whole: package management with pip, virtual environments, some useful libraries to know about, and how the tooling landscape compares with the .NET world. See you in the final article!

Additional Resources Link to heading