CodingNic

Object Oriented Programming

Introduction to Object Oriented Programming in Python

Object Oriented Programming 25 min read

Introduction to Object Oriented Programming in Python

Objectives

By the end of this chapter, you should be able to:

  • Describe object-oriented programming without talking about code
  • Explain encapsulation and abstraction
  • Create classes in Python
  • Write instance methods, class methods, and static methods
  • Control attribute access with @property, and generate boilerplate with @dataclass

💡 Why this matters: Real programs model real things: users, orders, game pieces. OOP is how you group each thing’s data and behavior together instead of scattering it across loose functions.

What Is Object Oriented Programming?

Everything in Python 3 is an object. You can check what kind with type(), and you’ve already seen this with built-in types like booleans and strings. This module is about creating your own types, using classes.

Two terms worth defining up front, with a tiny example so they’re concrete right away instead of just abstract definitions:

python
class Dog:
    pass

fido = Dog()
buddy = Dog()
  • Class: a blueprint for objects. Dog is the class here: it describes what a dog is, but on its own it isn’t a dog you can do anything with.
  • Instance: an object created from a class by calling it. fido and buddy are both instances of Dog, created by calling Dog(). They’re separate objects, even though they came from the same blueprint.
python
type(fido)                  # <class '__main__.Dog'>
fido is buddy               # False, two different instances
type(fido) is type(buddy)   # True, same class

Classes exist mainly to avoid duplication: instead of writing near-identical code for every dog, you write the Dog blueprint once and create as many instances as you need.

A Poker Example

Imagine modeling a game of poker. Without classes, you might use a list for the deck and other lists for each player’s hand, plus a pile of standalone functions for dealing, drawing, and determining the winner. As that grows, it gets hard to manage.

Instead, you could break the problem into classes: a Card, a Deck, a Hand, a Player, a Game. Take Deck as an example:

  • Cards: the deck holds 52 distinct playing cards
  • Shuffle: the deck can shuffle itself
  • Deal a card: remove one card from the deck and hand it to a player
  • Deal a hand: deal several cards to one player, or several players

Each of these becomes a method on a Deck class, operating on the deck’s own data. You’ll build exactly this class in a later exercise; for now, the smaller Vehicle and Person examples below show how classes actually get written.

Encapsulation

Encapsulation means the data and the operations on that data belong to a class: nothing else should reach in and change that data directly. Instead, other code interacts with the data through the class’s own methods.

python
class BankAccount:
    def __init__(self, balance):
        self.balance = balance

    def deposit(self, amount):
        self.balance += amount

account = BankAccount(100)
account.deposit(50)
account.balance  # 150

balance belongs to the BankAccount instance, and deposit() is the sanctioned way this example changes it. Python doesn’t actually stop you from writing account.balance = -999999 directly (it doesn’t enforce private attributes the way some other languages do), but the design intent behind encapsulation is that other code goes through deposit(), withdraw(), and similar methods instead of editing balance by hand.

In the poker example from before, a Player shouldn’t be able to pick any card it wants out of the Deck or reorder it manually; it can only be dealt a hand through the deck’s own methods. The deck’s list of cards is encapsulated: owned by the Deck class, not exposed for anyone else to poke at.

Abstraction

Abstraction is what encapsulation buys you: the ability to use a class at a high level, without needing to know how it works internally.

python
account.deposit(50)

To call this, you don’t need to know that deposit() internally does self.balance += amount. You just need to know it exists and what it does. That’s abstraction: a simple interface hiding an implementation detail. The same applies to a Deck class with a .shuffle() method and a .deal() method: you understand what it does without reading a line of its implementation.

Inheritance and polymorphism, two more hallmarks of OOP, are coming up in the next couple of lessons.

Creating a Class

python
class Vehicle:
    def __init__(self, make, model, year):
        self.make = make
        self.model = model
        self.year = year

Every class needs an __init__ method: it runs automatically every time you create a new instance. self inside it refers to the instance being created. Creating (or “instantiating”) a class looks like calling a function:

python
v = Vehicle("toyota", "corolla", 2012)
v.make   # "toyota"
v.year   # 2012

Instance Methods

Define a method inside the class, with self as its first parameter. That’s how it gets access to the instance’s own data:

python
class Person:
    def __init__(self, first_name, last_name):
        self.first_name = first_name
        self.last_name = last_name

    def full_name(self):
        return f"My name is {self.first_name} {self.last_name}"

    def likes(self, thing):
        return f"{self.first_name} likes {thing}!"

p = Person("Jordan", "Reyes")
p.full_name()        # "My name is Jordan Reyes"
p.likes("computers") # "Jordan Reyes likes computers!"

self is always the first parameter when you define an instance method, but you never pass it in yourself when you call one.

Class Methods and Static Methods

Instance methods operate on a specific instance. Sometimes you want a method on the class itself instead. Python gives you two decorators for that.

@classmethod receives the class itself (conventionally named cls) as its first argument:

python
class Person:
    @classmethod
    def say_hello(cls):
        return "HI!"

Person.say_hello()  # "HI!"

@staticmethod takes neither self nor cls: it’s just a regular function that happens to live inside the class, for organizational purposes:

python
class Person:
    @staticmethod
    def say_hello():
        return "HI!"

Person.say_hello()  # "HI!"

The difference matters once a method actually needs to reference the class or an instance: a class method can, a static method can’t.

Controlling Attribute Access with @property

Normally, accessing an attribute (person.first_name) just reads a plain value with no logic attached. @property lets you run code every time an attribute is read, while callers still use the same plain-looking syntax (no () required):

python
class Person:
    def __init__(self, first_name, last_name):
        self.first_name = first_name
        self.last_name = last_name

    @property
    def full_name(self):
        return f"{self.first_name} {self.last_name}"

p = Person("Erin", "Reyes")
p.full_name  # "Erin Reyes", accessed like an attribute, not called like a method

Without @property, full_name would need parentheses to call it (p.full_name()). With it, p.full_name looks exactly like reading a stored value, even though it’s computed fresh every time.

Pair a property with a setter to also validate or react to assignment, instead of just reading:

python
class Person:
    def __init__(self, first_name, last_name):
        self.first_name = first_name
        self.last_name = last_name

    @property
    def full_name(self):
        return f"{self.first_name} {self.last_name}"

    @full_name.setter
    def full_name(self, value):
        self.first_name, self.last_name = value.split(" ", 1)

p = Person("Erin", "Reyes")
p.full_name = "Jordan Reyes"
p.first_name  # "Jordan"
p.last_name   # "Reyes"

p.full_name = "Jordan Reyes" looks like a plain assignment, but it actually runs the @full_name.setter method, splitting the string and updating both underlying attributes. This is the same pattern behind properties you’ve likely seen in other libraries and frameworks, without realizing a decorator was involved.

A Shortcut for Simple Classes: @dataclass

Writing __init__ by hand for a class that’s mostly just a bundle of attributes gets repetitive. The dataclasses module’s @dataclass decorator generates that boilerplate for you:

python
from dataclasses import dataclass

@dataclass
class Point:
    x: int
    y: int

p = Point(3, 4)
p.x               # 3
p                 # Point(x=3, y=4), a readable __repr__ generated for you too
p == Point(3, 4)  # True, equality compares field values, not identity

@dataclass generates __init__, __repr__, and __eq__ automatically from the type-hinted fields you declare. No need to write any of them yourself. It’s not a replacement for a full class with real behavior and methods, but it’s a natural fit for simple, data-holding classes.

Try It

  1. Write a class with an __init__ method and at least one instance method, then create two different instances and call the method on each.
  2. Add a @classmethod to that class that returns something about the class itself, and call it directly on the class.
  3. Add a @staticmethod and explain, in your own words, why it doesn’t need self or cls.
  4. Add a @property to a class of your own, then add a matching setter and confirm assigning to it runs your setter code.
  5. Rewrite a simple data-holding class as a @dataclass, and confirm __init__, __repr__, and __eq__ all work without you writing them.

Recap

  • A class is a blueprint; an instance is a specific object built from it, created by calling the class.
  • Encapsulation keeps a class’s data under its own control; abstraction is the payoff: you can use a class without knowing its internals.
  • __init__ runs on every new instance, with self referring to that instance; instance methods always take self as their first parameter.
  • @classmethod methods operate on the class itself (cls); @staticmethod methods take neither self nor cls.
  • @property lets an attribute run code on read; pairing it with @x.setter does the same for assignment: both look like plain attribute access to the caller.
  • @dataclass generates __init__, __repr__, and __eq__ for a simple, data-holding class from its type-hinted fields.

Next lesson: inheritance, sharing behavior between classes instead of duplicating it.