NotesPythonObject-Oriented Programming

Encapsulation & Dunder Methods

Naming conventions for 'private' attributes, @property, and hooking into Python's built-in syntax.

Python doesn't have true private attributes enforced by the language — it relies on naming convention instead:

  • self.name — public, use freely.
  • self._name — "protected" by convention: a signal to other developers "internal, don't touch directly," but nothing stops access.
  • self.__namename-mangled: Python internally renames it to _ClassName__name, which makes accidental external access unlikely (though not impossible).
class YouTuber:
    def __init__(self, name, subscribers):
        self.name = name
        self.__subscribers = subscribers   # name-mangled

    def get_subscribers(self):
        return self.__subscribers

munna = YouTuber("CodeWithMunnaX", 250_000)
print(munna.get_subscribers())   # 250000
# print(munna.__subscribers)     # AttributeError — it's actually stored as _YouTuber__subscribers

@property — controlled access that still looks like an attribute

A property lets you run code on attribute access/assignment while callers still use plain attribute syntax (obj.subscribers, not obj.get_subscribers()).

class YouTuber:
    def __init__(self, name, subscribers):
        self.name = name
        self._subscribers = subscribers

    @property
    def subscribers(self):
        return self._subscribers

    @subscribers.setter
    def subscribers(self, value):
        if value < 0:
            raise ValueError("Subscribers can't be negative")
        self._subscribers = value

munna = YouTuber("CodeWithMunnaX", 250_000)
print(munna.subscribers)      # calls the getter, looks like a plain attribute
munna.subscribers = 260_000   # calls the setter, validated

Dunder ("double underscore") methods

Methods like __str__, __eq__, __len__ hook your class into Python's built-in syntax and functions — this is called operator overloading.

class YouTuber:
    def __init__(self, name, subscribers):
        self.name = name
        self.subscribers = subscribers

    def __str__(self):
        return f"{self.name} ({self.subscribers:,} subs)"

    def __eq__(self, other):
        return self.name == other.name

    def __len__(self):
        return self.subscribers

munna = YouTuber("CodeWithMunnaX", 250_000)
print(munna)                 # uses __str__, not the default <...object at 0x...>
print(len(munna))            # uses __len__ -> 250000
print(munna == YouTuber("CodeWithMunnaX", 0))   # uses __eq__ -> True, same name

Without __str__, print(munna) would show something unhelpful like <__main__.YouTuber object at 0x7f...> — defining it is one of the first things worth adding to any class you want to debug or display easily.

Key points to remember
  • _name signals 'internal, don't touch' by convention only; __name is name-mangled to _ClassName__name, making outside access harder but not impossible.
  • @property lets a method be accessed like a plain attribute; pairing it with @x.setter lets you validate assignments too.
  • Dunder methods (__str__, __eq__, __len__, ...) hook your class into built-in syntax — print(), ==, len() all check for them.
  • Defining __str__ is one of the highest-value small additions to any class — it makes print(obj) actually useful for debugging.
  • Python has no true private attributes enforced by the language — encapsulation here is convention plus name-mangling, not a hard restriction.

@property with validation, and dunder methods

Revision Flashcards

1 / 3

Tap a card to flip it and see the answer.