bigonotes
CS ConceptsOOP

Class Keywords and Concepts

How self, class attributes, static and class methods, Final, super() and access conventions work in Python classes.

5 min readLast updated OOP

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 Final name is assigned exactly once.
  • A Final instance 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 __name only 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.
  • _name class attribute: class-level state used internally.
  • @property over a _name attribute: public read access without public writes.

Key takeaways#

  • self is the current object; super() reaches the parent's version of a method.
  • Class attributes are shared by every instance; instance attributes are per object.
  • Final and @final declare "assign once", "don't override" and "don't subclass"; type checkers enforce them.
  • Python's privacy is by convention: _internal, __mangled, and @property for controlled access.
  • Overriding __init__ means calling super().__init__(...) yourself.

Found a mistake? Tell me on LinkedIn ↗

Further reading#