Object-Oriented Programming V2

📖
What is OOP?

Object-Oriented Programming bundles data and the functions that work on that data into one unit called an object. Instead of data floating around with separate functions, everything lives together. Organized chaos.

The shift:

  • Before (imperative): Write instructions, use functions
  • Now (OOP): Create objects that contain both data AND behavior

You've Been Using OOP All Along

Plot twist: every data type in Python is already a class.

# Lists are objects
my_list = [1, 2, 3]
my_list.append(4)      # Method call
my_list.reverse()      # Another method
# Strings are objects
name = "hello"
name.upper()           # Method call
# Even integers are objects
x = 5
x.__add__(3)           # Same as x + 3
ℹ️
Pro Tip

Use help(list) or help(str) to see all methods of a class.


Level 1: Your First Class

The Syntax

class ClassName:
    # stuff goes here
    pass

A Simple Counter

Let's build step by step.

Step 1: Empty class

class Counter:
    pass

Step 2: The constructor

class Counter:
    def __init__(self, value):
        self.val = value

Step 3: A method

class Counter:
    def __init__(self, value):
        self.val = value
    
    def tick(self):
        self.val = self.val + 1

Step 4: More methods

class Counter:
    def __init__(self, value):
        self.val = value
    
    def tick(self):
        self.val = self.val + 1
    
    def reset(self):
        self.val = 0
    
    def value(self):
        return self.val

Step 5: Use it

c1 = Counter(0)
c2 = Counter(3)

c1.tick()
c2.tick()

print(c1.value())    # 1
print(c2.value())    # 4

Level 2: Understanding the Pieces

The Constructor: __init__

📝
What is __init__?

The constructor runs automatically when you create an object. It sets up the initial state.

def __init__(self, value):
    self.val = value

When you write Counter(5):

  1. Python creates a new Counter object
  2. Calls __init__ with value = 5
  3. Returns the object

The self Parameter

self = "this object I'm working on"

def tick(self):
    self.val = self.val + 1
  • self.val means "the val that belongs to THIS object"
  • Each object has its own copy of self.val
c1 = Counter(0)
c2 = Counter(100)

c1.tick()           # c1.val becomes 1
print(c2.value())   # Still 100, different object
⚠️
Don't Forget self!

Every method needs self as the first parameter. But when calling, you don't pass it — Python does that automatically.

# Defining: include self
def tick(self):
    ...

# Calling: don't include self
c1.tick()    # NOT c1.tick(c1)

Instance Variables

Variables attached to self are instance variables — each object gets its own copy.

def __init__(self, value):
    self.val = value      # Instance variable
    self.count = 0        # Another one

Level 3: Special Methods (Magic Methods)

Python has special method names that enable built-in behaviors.

__str__ — For print()

class Counter:
    def __init__(self, value):
        self.val = value
    
    def __str__(self):
        return f"Counter: {self.val}"
c = Counter(5)
print(c)    # Counter: 5

Without __str__, you'd get something ugly like <__main__.Counter object at 0x7f...>

__add__ — For the + operator

class Counter:
    def __init__(self, value):
        self.val = value
    
    def __add__(self, other):
        return Counter(self.val + other.val)
c1 = Counter(3)
c2 = Counter(7)
c3 = c1 + c2        # Calls c1.__add__(c2)
print(c3.val)       # 10

__len__ — For len()

def __len__(self):
    return self.val
c = Counter(5)
print(len(c))    # 5

__getitem__ — For indexing [ ]

def __getitem__(self, index):
    return something[index]
📝
Common Special Methods

__init__ → Constructor
__str__ → print() and str()
__add__ → + operator
__sub__ → - operator
__mul__ → * operator
__eq__ → == operator
__len__ → len()
__getitem__ → obj[index]


Level 4: Encapsulation

📖
Encapsulation

The idea that you should access data through methods, not directly. This lets you change the internals without breaking code that uses the class.

Bad (direct access):

c = Counter(5)
c.val = -100       # Directly messing with internal data

Good (through methods):

c = Counter(5)
c.reset()          # Using the provided interface
💡
Python's Approach

Python doesn't enforce encapsulation — it trusts you. Convention: prefix "private" variables with underscore: self._val


Level 5: Designing Classes

When creating a class, think about:

QuestionBecomes
What thing am I modeling?Class name
What data does it have?Instance variables
What can it do?Methods

Example: Student

  • Class: Student
  • Data: name, age, grades
  • Behavior: add_grade(), average(), pass_course()

Full Example: Card Deck

Let's see a more complex class.

Step 1: Constructor — Create all cards

class Deck:
    def __init__(self):
        self.cards = []
        for num in range(1, 11):
            for suit in ["Clubs", "Spades", "Hearts", "Diamonds"]:
                self.cards.append((num, suit))

Step 2: Shuffle method

from random import randint

class Deck:
    def __init__(self):
        self.cards = []
        for num in range(1, 11):
            for suit in ["Clubs", "Spades", "Hearts", "Diamonds"]:
                self.cards.append((num, suit))
    
    def shuffle(self):
        for i in range(200):
            x = randint(0, len(self.cards) - 1)
            y = randint(0, len(self.cards) - 1)
            # Swap
            self.cards[x], self.cards[y] = self.cards[y], self.cards[x]

Step 3: Special methods

def __len__(self):
    return len(self.cards)

def __getitem__(self, i):
    return self.cards[i]

def __str__(self):
    return f"I am a {len(self)} card deck"

Step 4: Using it

deck = Deck()
print(deck)              # I am a 40 card deck
print(deck[0])           # (1, 'Clubs')

deck.shuffle()
print(deck[0])           # Something random now

Complete Counter Example

Putting it all together:

class Counter:
    def __init__(self, value):
        self.val = value
    
    def tick(self):
        self.val = self.val + 1
    
    def reset(self):
        self.val = 0
    
    def value(self):
        return self.val
    
    def __str__(self):
        return f"Counter: {self.val}"
    
    def __add__(self, other):
        return Counter(self.val + other.val)
c1 = Counter(0)
c2 = Counter(3)

c1.tick()
c2.tick()

c3 = c1 + c2

print(c1.value())    # 1
print(c2)            # Counter: 4
print(c3)            # Counter: 5

Quick Reference

📝
OOP Cheat Sheet

class Name: → Define a class
def __init__(self): → Constructor
self.var = x → Instance variable
obj = Class() → Create object
obj.method() → Call method
__str__ → For print()
__add__ → For +
__eq__ → For ==
__len__ → For len()
__getitem__ → For [ ]


Why Bother?

Classes help organize large programs. Also, many frameworks (like PyTorch for machine learning) require you to define your own classes. So yeah, you need this. Sorry.