HSHimanshu Sharma
Python Notes

Python OOP part 1: classes, objects, self, constructors and variable types

Python OOP part 1: classes, objects, self, constructors and variable types Advertisement

Until now you have probably written programs as a sequence of functions that pass data around. Object-oriented programming (OOP) bundles the data and the functions that work on it into one unit, called an object. A bank account, a student, a shopping cart and a game character each have data (balance, name, items, health) and behaviour (deposit, enrol, add, move). OOP lets you describe that once, as a class, and create as many objects as you like. This first part covers classes and objects, self, constructors, the three kinds of variables, the three kinds of methods, and how Python cleans up objects. The code was run on Python 3.9.

What is a class?

A class is a blueprint: it describes what data its objects hold and what they can do. It uses no memory for any particular student; it is the plan every student object follows. A class is created with the class keyword, and by convention its name uses CapitalWords.

class Student:
    """A student with a name and marks."""
    pass

print(Student, type(Student), Student.__name__)
# <class '__main__.Student'> <class 'type'> Student

This empty class does nothing yet, but it already exists. In Python even classes are objects: type(Student) is type.

What is an object?

An object (also called an instance) is one concrete thing built from the class. You create it by calling the class like a function:

s1 = Student()
s2 = Student()
print(type(s1), s1 is s2, isinstance(s1, Student))
# <class '__main__.Student'> False True

s1 and s2 are two different objects of the same class. An object has three parts: an identity (id(s1), its address in memory), a type (its class) and its state (the values of its variables).

Reference variables

The names s1 and s2 are reference variables: they do not contain the object, they point to it. Assigning one to another copies the pointer, not the object:

s3 = s1
print(s3 is s1, id(s3) == id(s1))        # True True
s3.course = "Python"
print(s1.course)                          # Python

Because s3 and s1 refer to the same object, a change through one is visible through the other. This is the same behaviour you saw with lists.

self and the constructor

Real classes need data. The special method __init__ is the constructor: Python calls it automatically right after creating an object, so it is the place to set up the object's starting data.

class Student:
    def __init__(self, name, marks):
        self.name = name
        self.marks = marks

    def grade(self):
        return "pass" if self.marks >= 40 else "fail"

    def __repr__(self):
        return f"Student({self.name!r}, {self.marks})"

s1 = Student("Asha", 82)
s2 = Student("Ravi", 35)
print(s1, s2.grade(), s1.name, s2.marks)
# Student('Asha', 82) fail Asha 35

self is the first parameter of every instance method. It is the object the method is working on. When you write s1.grade(), Python calls Student.grade(s1), so self is s1:

print(Student.grade(s2), s2.grade())      # fail fail

The name self is a strong convention, not a keyword, but always use it. You never pass self yourself. Variables attached to self (like self.name) belong to that one object. Without self., a name inside the constructor would be a plain local variable that disappears when __init__ returns.

__repr__ returns the text shown when you print the object; without it you would see something like <__main__.Student object at 0x...>.

Constructor versus method

Constructor (__init__) Method
Name always __init__ any name
Runs automatically, once, when the object is created only when you call it
Purpose set up the initial state perform operations on the state
Returns must return None can return any value

A class has only one __init__; Python has no constructor overloading. Use default values to allow several call styles:

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

print(Point().x, Point(3).x, Point(3, 4).y)       # 0 3 4

Types of variables

Variables in a class live at three levels, depending on where you create them.

Instance variables (object level)

Created with self.name = .... Every object has its own copy. Use them for data that differs from one object to the next, such as a student's name.

class Counter:
    def __init__(self):
        self.value = 0

a, b = Counter(), Counter()
a.value += 5
print(a.value, b.value)           # 5 0

Python lets you add instance variables later, in any method, or even from outside (a.extra = 1), but defining them all in __init__ keeps the class easy to understand.

Static (class-level) variables

Created inside the class body, outside any method. There is one copy, shared by all objects. Use them for shared facts or counters.

class Student:
    school = "Green Valley"
    count = 0

    def __init__(self, name):
        self.name = name
        Student.count += 1

a, b = Student("Asha"), Student("Ravi")
print(Student.count, a.school, Student.school)       # 2 Green Valley Green Valley
Student.school = "Blue Hills"
print(a.school, b.school)                             # Blue Hills Blue Hills

Read a class variable with the class name (Student.count) so it is obvious where it lives. A trap: assigning through an object creates a new instance variable that hides the class one for that object only.

a.school = "Own School"
print(a.school, b.school, Student.school)            # Own School Blue Hills Blue Hills
print("school" in a.__dict__, "school" in b.__dict__)   # True False

Each object keeps its instance variables in a dictionary called __dict__, which makes this easy to inspect.

Local variables (method level)

Created inside a method and visible only there. They disappear when the method returns:

class Calc:
    def add(self, a, b):
        total = a + b          # local variable
        return total

print(Calc().add(2, 3))        # 5
Variable Created in Shared? Accessed as
Instance __init__ / methods, via self one per object obj.name
Static / class class body, or ClassName.x = ... one for the class ClassName.x
Local inside a method no (temporary) by name, inside the method

Types of methods

A class can contain three kinds of methods. They differ in what their first parameter is.

class Student:
    school = "Green Valley"

    def __init__(self, name, marks):
        self.name, self.marks = name, marks

    def grade(self):                       # instance method
        return "pass" if self.marks >= 40 else "fail"

    @classmethod
    def change_school(cls, new_name):      # class method
        cls.school = new_name

    @classmethod
    def from_text(cls, text):              # alternative constructor
        name, marks = text.split(",")
        return cls(name, int(marks))

    @staticmethod
    def is_valid_mark(m):                  # static method
        return 0 <= m <= 100

s = Student.from_text("Asha,82")
Student.change_school("Blue Hills")
print(s.grade(), s.school, Student.is_valid_mark(120))
# pass Blue Hills False
Kind Decorator First parameter Can use Typical use
Instance none self (the object) instance and class variables anything about one object
Class @classmethod cls (the class) class variables change shared data; alternative constructors
Static @staticmethod none neither a utility that belongs with the class but needs no data

A static method is just a function that lives inside a class for organisation. A class method receives the class, so cls(name, int(marks)) still works if someone later makes a subclass.

Getters, setters and properties

Because anyone can write s.marks = -500, classes often protect their data. Python's way is the @property decorator, which makes a method look like an attribute:

class Student:
    def __init__(self, marks):
        self.marks = marks            # goes through the setter below

    @property
    def marks(self):
        return self._marks

    @marks.setter
    def marks(self, value):
        if not 0 <= value <= 100:
            raise ValueError("marks must be between 0 and 100")
        self._marks = value

s = Student(80)
s.marks = 95
print(s.marks)                        # 95
try:
    s.marks = 120
except ValueError as e:
    print(e)                          # marks must be between 0 and 100

The leading underscore in _marks is a convention that means "internal; do not touch from outside". Python does not enforce privacy, but the convention is respected. A double underscore (__marks) triggers name mangling, which makes accidental access harder.

Garbage collection

You never free memory yourself. Python counts the references to every object, and when the count reaches zero the object is removed automatically. This is reference counting, with an extra collector for objects that refer to each other in circles.

import sys, gc
class Thing: pass
t = Thing()
u = t
print(sys.getrefcount(t) - 1)         # 2   (t and u; getrefcount counts its own argument)
del u
print(sys.getrefcount(t) - 1)         # 1
print(gc.isenabled())                 # True

gc.collect() forces a collection pass and returns the number of unreachable objects it found; you rarely need it.

Destructors

The special method __del__ is the destructor. Python calls it just before an object is destroyed:

class Resource:
    def __init__(self, name):
        self.name = name
        print("opened", name)
    def __del__(self):
        print("closed", self.name)

r = Resource("db")
r2 = r
del r
print("still alive")
del r2
print("end")
opened db
still alive
closed db
end

del r only removed one name; the object lived on because r2 still pointed to it. It was destroyed when the last reference went. Do not rely on __del__ for important clean-up: the timing is not guaranteed on every Python implementation, and exceptions inside it are ignored. For files, connections and locks use a with statement (see file handling) or a try/finally block.

Putting it together: a small bank account

class Account:
    interest_rate = 0.04
    accounts = 0

    def __init__(self, owner, balance=0):
        self.owner = owner
        self.balance = balance
        Account.accounts += 1

    def deposit(self, amount):
        if amount <= 0:
            raise ValueError("deposit must be positive")
        self.balance += amount
        return self.balance

    def withdraw(self, amount):
        if amount > self.balance:
            raise ValueError("insufficient funds")
        self.balance -= amount
        return self.balance

    def yearly_interest(self):
        return self.balance * Account.interest_rate

    def __repr__(self):
        return f"Account({self.owner!r}, {self.balance})"

acc = Account("Asha", 1000)
acc.deposit(500)
acc.withdraw(200)
print(acc, acc.yearly_interest(), Account.accounts)
# Account('Asha', 1300) 52.0 1

Common mistakes

  • Forgetting self as the first parameter of a method, which gives TypeError: ... takes 0 positional arguments but 1 was given.
  • Writing name = name instead of self.name = name in __init__.
  • Using a mutable class variable (such as a list) when each object needs its own; create it in __init__.
  • Expecting __init__ to be able to return a value.
  • Relying on __del__ for clean-up.
  • Confusing a class (the plan) with an object (a thing built from the plan).

Practice

Write a Book class with title, author and price, a method that applies a discount percentage, and a class variable that counts how many books exist. Add a class method that builds a book from the text "Title|Author|250" and a static method that checks whether a price is positive. Then create three books, print them, and list their total price.

Continue

The next part, inheritance and the "is-a" and "has-a" relationships, builds on these ideas. Review functions if self still feels unfamiliar, and watch the matching video lessons: Episode 61: Python OOPs, Session 1, Episode 62: Python OOPs, Session 2 and Episode 63: Python OOPs, Session 3. The Python video course has the whole series.

Advertisement