A handful of keywords and conventions control how Python classes behave: who owns an attribute, whether it should change, who should touch it, and how a child talks to its parent. You will see self, class attributes, @staticmethod, @classmethod, Final, super() and underscore names in nearly every Python codebase, so it is worth knowing exactly what each one does.
self: the current object#
self is the object the method is running on. Python passes it in automatically as the first argument.
class Person:
# Use 1: tell the attribute apart from a parameter with the same name
def __init__(self, name: str, age: int = 0):
self.name = name # self.name is the attribute, name is the parameter
self.age = age
# Use 2: return the current object to allow method chaining
def set_name(self, name: str) -> "Person":
self.name = name
return self
p = Person("Ana").set_name("Ana Lima")
print(p.name, p.age) # Ana Lima 0
Without self., name = name just rebinds the local parameter and the attribute never changes. Python has no constructor overloading; a default argument (age: int = 0) or a @classmethod factory covers the same need.
Class attributes and @staticmethod / @classmethod#
A class attribute belongs to the class itself, not to any one object. There is one copy shared by every instance, and you can use it without creating an object.
class Student:
school_name = "Python High" # one copy for all students
total_students = 0
def __init__(self, name: str):
self.name = name # one copy per student
Student.total_students += 1 # update the shared counter
Student("Ana")
Student("Ben")
print(Student.total_students) # access via the class name: 2
Assign through the class (Student.total_students += 1). Writing self.total_students += 1 would create a separate instance attribute and leave the shared one unchanged.
Methods come in three kinds:
class Calculator:
@staticmethod
def add(a: int, b: int) -> int: # no self, no class: a plain function
return a + b
@classmethod
def describe(cls) -> str: # receives the class itself
return f"I am {cls.__name__}"
print(Calculator.add(5, 3)) # 8: no object needed
print(Calculator.describe()) # I am Calculator
- Class attribute / static or class method: one copy for the whole class.
- Instance attribute / regular method: works on one object via
self.
Final and @final: should not change#
Python doesn't enforce immutability at runtime, but the typing module lets you declare intent, and type checkers like mypy reject violations.
from typing import Final, final
class Circle:
PI: Final = 3.14159 # a constant: never reassign
def __init__(self, radius: int):
self.radius: Final = radius # set once, in the constructor
class Parent:
@final
def print_id(self) -> None: # subclasses should not override this
print("ID-123")
@final
class CantExtendMe: # no subclasses allowed (checked by type checkers)
pass
To make an object genuinely read-only at runtime, use a read-only @property or a frozen dataclass (@dataclass(frozen=True)).
Rules:
- A
Finalname is assigned exactly once. - A
Finalinstance attribute is assigned in__init__. - By convention, constants use
ALL_CAPS.
super(): the parent class#
super() gives you the parent class's version of a method. Use it to run the parent's __init__ or a parent method you have overridden.
class Animal:
def __init__(self, name: str):
self.name = name
def eat(self) -> None:
print("Animal eating")
class Dog(Animal):
def __init__(self, name: str, breed: str):
super().__init__(name) # let the parent set up its part
self.breed = breed
def eat(self) -> None:
super().eat() # run the parent's version first
print("Dog eating loudly!")
Dog("Rex", "Lab").eat()
Python does not call the parent __init__ for you. If you override __init__ and forget super().__init__(...), the parent's attributes are never set. Calling it first is the usual convention, though Python doesn't require it.
Access conventions#
Python has no access-modifier keywords. Visibility is a naming convention:
| Name | Meaning | Enforced? |
|---|---|---|
balance |
Public: part of the class's API | n/a |
_balance |
Internal: "don't touch from outside" | No, convention only |
__balance |
Name-mangled to _BankAccount__balance |
Only against accidental clashes in subclasses |
class BankAccount:
def __init__(self, holder: str):
self.holder = holder # public
self._balance = 0.0 # internal: use the property instead
@property
def balance(self) -> float: # controlled, read-only access
return self._balance
def deposit(self, amount: float) -> None:
if amount <= 0:
raise ValueError("Deposit must be positive")
self._balance += amount
self._log_transaction(amount)
def _log_transaction(self, amount: float) -> None:
print(f"deposit {amount}") # internal helper
acct = BankAccount("Ana")
acct.deposit(50)
print(acct.balance) # 50.0
Good defaults:
- Prefix internal state with
_, especially sensitive data. - Expose behavior through public methods and
@property. - Use
__nameonly to avoid name clashes in subclasses, not for security. - See Encapsulation for more on name mangling.
The __main__ guard#
Python runs a file top to bottom; there is no required main method. The guard below runs code only when the file is executed directly, not when it is imported:
def main() -> None:
print("program starts here")
if __name__ == "__main__":
main()
When you run python app.py, __name__ is "__main__". When another module imports app, __name__ is "app" and main() doesn't run.
Putting it together#
This example uses every idea above in one small hierarchy:
from typing import ClassVar, Final, final
class Vehicle:
MANUFACTURER: Final = "AutoCorp" # shared constant
total_vehicles: ClassVar[int] = 0 # shared counter
def __init__(self, brand: str, year: int, max_speed: int):
self.brand = brand
self._year = year # internal
self.max_speed: Final = max_speed # set once per object
Vehicle.total_vehicles += 1
def start(self) -> None:
print("Vehicle starting")
@final
def show_manufacturer(self) -> None: # should not be overridden
print(self.MANUFACTURER)
class Car(Vehicle):
def __init__(self, brand: str, year: int, model: str, vin: str):
super().__init__(brand, year, 180) # parent's setup first
self.model = model
self._vin: Final = vin
def start(self) -> None:
super().start()
print("Car engine roaring!")
@classmethod
def print_total(cls) -> None:
print("Total vehicles:", cls.total_vehicles)
if __name__ == "__main__":
my_car = Car("Toyota", 2020, "Camry", "ABC123")
my_car.start()
Car.print_total()
print("Manufacturer:", Vehicle.MANUFACTURER)
Quick reference#
Most of these map to a keyword in other languages; in Python several are conventions.
| Construct | Meaning | Remember |
|---|---|---|
self |
Current object | Disambiguates attributes from parameters |
super() |
Parent class | Call super().__init__(...) yourself |
| Class attribute | Belongs to the class | One copy shared by all objects |
@staticmethod |
No self or cls |
A function that lives on the class |
@classmethod |
Receives cls |
Factories and class-level helpers |
Final / @final |
Should not change | Checked by type checkers, not at runtime |
_name |
Internal | Convention only |
__name |
Name-mangled | Avoids subclass clashes |
Common combinations:
NAME: Final = ...at class or module level: a constant, e.g.math.pi.self.x: Final = ...in__init__: a per-object value that never changes after construction._nameclass attribute: class-level state used internally.@propertyover a_nameattribute: public read access without public writes.
Key takeaways#
selfis the current object;super()reaches the parent's version of a method.- Class attributes are shared by every instance; instance attributes are per object.
Finaland@finaldeclare "assign once", "don't override" and "don't subclass"; type checkers enforce them.- Python's privacy is by convention:
_internal,__mangled, and@propertyfor controlled access. - Overriding
__init__means callingsuper().__init__(...)yourself.