Two more OOP building blocks round out the section: how Python signals "don't touch this from outside," and how to make an object behave well with Python's own built-in functions.
Some languages have real private/protected keywords the language enforces. Python has no true private properties — instead, a leading underscore is a convention signaling "internal, don't use this from outside," which Python trusts you to respect rather than blocking:
class BankAccount:
def __init__(self):
self._balance = 0 # single underscore: "internal, please don't touch"
def deposit(self, amount):
self._balance += amount
def get_balance(self):
return self._balance
account = BankAccount()
account.deposit(500)
print(account.get_balance()) # 500
print(account._balance) # 500 — this works! Python doesn't block it, even though the underscore says not toSingle underscore is the normal habit
A double underscore prefix (__balance) triggers "name mangling," which makes accidental access from outside genuinely awkward, though still not impossible. In practice, a single underscore plus trusting the convention is far more common in real Python code than trying to fully lock a property down.
You already met one — __init__. Python has many more special methods, all following the __name__ pattern, that let an object plug into the language's own built-in behavior:
class Student:
def __init__(self, name):
self.name = name
def __str__(self):
return f"Student: {self.name}"
s = Student("Priya")
print(s) # "Student: Priya" — print() calls __str__ automatically
print(str(s)) # same thingWithout __str__, print(s) would show something unhelpful like <__main__.Student object at 0x...>. Defining it is what controls what gets shown instead.
| Dunder method | Lets an object work with |
|---|---|
| __init__ | Student(...) — creating an instance |
| __str__ | print(obj), str(obj) |
| __len__ | len(obj) |
| __eq__ | obj1 == obj2 |
This section has covered the ones you'll hit constantly. The full list of special methods is on python.org for anything more advanced.
@property lets a method be accessed like a plain attribute, with no parentheses — this is how Python builds a getter that can also run validation logic, without callers needing to know it's a method underneath:
class BankAccount:
def __init__(self):
self._balance = 0
@property
def balance(self):
return self._balance
@balance.setter
def balance(self, value):
if value < 0:
raise ValueError("Balance cannot be negative.")
self._balance = value
account = BankAccount()
account.balance = 500 # looks like a plain attribute, actually calls the setter
print(account.balance) # 500 — looks like a plain attribute, actually calls the getter
# account.balance = -10 # ValueError — the setter blocks thisThis gets you the benefit of explicit getter/setter methods without the boilerplate — the calling code stays as simple as a direct attribute, while the class still gets to enforce its own rules.