Skip to main content

Class Fundamentals and Instance

Calculating read time…

Imagine you are an architect designing houses. Your blueprint (the class) defines the structure—rooms, doors, windows. Each house built from that blueprint (the instance) is a unique home with its own furniture, color, and family. In Python, classes are your blueprints, and instances are the individual houses you create and live in. Let’s build your first blueprint together, step by step.

What is a Class and Why Should You Care?

A class is a template or a cookie-cutter. It defines what data an object can hold and what actions it can perform. Think of it as a factory machine that produces toys. The machine (class) defines the toy's shape and features. Every toy that comes out (instance) is unique but follows the same design.

Why learn this? Because without classes, your code is like a pile of loose Lego blocks. With classes, you can build organized, reusable, and powerful structures like robots, cars, or even entire game characters. It’s how professional Python code is structured.

Your First Blueprint: The __init__ Method

The __init__ method is the first thing that runs when you create a new instance from a class. Its job is to set up the initial state—like assigning furniture to a new house when it's built.

Think of __init__ as the construction crew that takes the empty house (the raw memory space) and puts in the basic essentials so it's ready to live in.

Let’s Build a Simple Student Class

What this code does: This defines a Student blueprint whose constructor accepts three pieces of info — name, age, and major — and immediately stores each one onto the new object using self. The moment you write Student("Alice", 20, "Computer Science"), Python silently creates an empty object first, then hands it to __init__ as self so the assignments have somewhere to live. Notice student1 and student2 each keep their own separate name/age/major — proven when we print student1.name and student2.major independently.
class Student:
    def __init__(self, name, age, major):
        self.name = name
        self.age = age
        self.major = major
        print(f"A new student named {name} has been enrolled!")

# Creating instances (actual students)
student1 = Student("Alice", 20, "Computer Science")
student2 = Student("Bob", 22, "Physics")

print(student1.name)  # Output: Alice
print(student2.major) # Output: Physics

What just happened?

  • We defined a Student blueprint with an __init__ method.
  • Every time we create a new Student, Python automatically calls __init__.
  • The self parameter is the new, empty instance being built.
  • We used self.name = name to attach the provided name to this specific instance.
Do: Use the __init__ method to set up all the essential attributes your instance needs to exist. It's your object's birth certificate and initial setup.
Don't: Put complex calculations or file-reading operations inside __init__. Keep it simple—just assignment and basic setup. Heavy logic belongs in other methods.

Understanding the Secret Agent: The self Parameter

self is the most important yet mysterious word for beginners. Let’s demystify it.

Analogy: Imagine you are at a party. You want to talk about yourself. You say, "I am having fun." That "I" refers to you, the specific person. In a class method, self is that "I". It's the specific instance that is calling the method.

Python automatically passes the instance as the first argument to every instance method. By convention, we call it self, but you could call it this_instance or me—it’s just a name. But always use self so others can read your code.

How self Connects Data to an Instance

What this code does: This builds two completely independent bank accounts, alice_account and bob_account, from the same BankAccount class. When we call alice_account.deposit(25), self inside deposit() automatically refers to alice_account — not Bob's account, not any account, specifically hers. That's why depositing into Alice's account leaves Bob's balance completely untouched at $50, even though both objects were built from identical code.
class BankAccount:
    def __init__(self, owner, balance=0):
        self.owner = owner   # Attaches 'owner' to THIS account
        self.balance = balance # Attaches 'balance' to THIS account

    def deposit(self, amount):
        # 'self.balance' means the balance of THIS specific account
        self.balance += amount
        print(f"New balance for {self.owner}: ${self.balance}")

# Creating two separate accounts
alice_account = BankAccount("Alice", 100)
bob_account = BankAccount("Bob", 50)

# Alice deposits to HER account
alice_account.deposit(25)  # Output: New balance for Alice: $125
# Bob's account is untouched!
print(bob_account.balance)  # Output: 50

The Magic: When we call alice_account.deposit(25), Python secretly does this: BankAccount.deposit(alice_account, 25). It passes the instance (alice_account) as the first argument (self). That’s how the method knows whose balance to update!

Tip: If you forget self in a method definition, Python will throw an error when you call it because it will receive the wrong number of arguments. "self" is the bridge between the general blueprint and the specific object.

Bringing Your Objects to Life: Instance Methods

Methods are the actions your objects can perform. They are functions defined inside a class that operate on the instance's data via self.

Let's give our Student some behaviors.

What this code does: Three instance methods work together in a chain here. calculate_average() does the raw math (with a safety check for an empty grades list). has_passed() doesn't repeat that math — it simply calls self.calculate_average() internally and compares the result to a passing threshold. Finally, get_report() calls both of the previous methods through self to build one readable sentence. This is a common real-world pattern: small methods composed together, each doing one job, all connected through self.
class Student:
    def __init__(self, name, grades):
        self.name = name
        self.grades = grades  # Assume grades is a list of numbers

    # Instance Method 1: Calculate Average
    def calculate_average(self):
        if not self.grades:  # Check if list is empty
            return 0
        return sum(self.grades) / len(self.grades)

    # Instance Method 2: Check Pass/Fail
    def has_passed(self, passing_grade=50):
        avg = self.calculate_average()  # Calls another method using self
        return avg >= passing_grade

    # Instance Method 3: Get Status Report
    def get_report(self):
        status = "passed" if self.has_passed() else "failed"
        return f"Student {self.name} has an average of {self.calculate_average():.2f} and has {status}."

# Using the methods
alice = Student("Alice", [85, 92, 78])
print(alice.calculate_average())  # Output: 85.0
print(alice.has_passed())         # Output: True
print(alice.get_report())
# Output: Student Alice has an average of 85.00 and has passed.

Key Takeaway: Instance methods use self to access and modify the data (name, grades) that belongs to the specific instance they are called on. They define the object's behavior.

Class-Level Superpowers: Class Methods

Sometimes you need an action that is related to the class itself, not to any single instance. For example, creating a student from a different data format, or tracking how many students you've created in total. This is where Class Methods shine.

They are defined with a @classmethod decorator and take cls (for class) as their first parameter, not self.

Real-World Use Case: An Alternative Constructor

What this code does: total_students is a class attribute — a shared counter — that increments by one inside __init__ every single time any student object is created, no matter which route was used to create it. from_string() is a classmethod: instead of taking raw name/age/major arguments, it accepts one formatted string, splits it apart, and then calls cls(name) — which is just Student(name) in disguise — so the normal __init__ (and its counter increment) still fires. That's why get_total_enrolled() correctly reports 2 total students at the end, regardless of which constructor path built them.
class Student:
    total_students = 0  # This is a class attribute

    def __init__(self, name):
        self.name = name
        Student.total_students += 1  # Update the class-level counter

    @classmethod
    def from_string(cls, student_string):
        """Creates a Student from a string like 'Name:Age:Major'"""
        name, age, major = student_string.split(':')
        # 'cls' here refers to the Student class itself
        new_student = cls(name)  # This calls Student.__init__
        new_student.age = int(age) # Add extra attributes
        new_student.major = major
        return new_student

    @classmethod
    def get_total_enrolled(cls):
        """Returns the total number of students created."""
        return cls.total_students

# Using the standard constructor
s1 = Student("Charlie")
print(Student.get_total_enrolled())  # Output: 1

# Using the class method as an alternative constructor
data_string = "Diana:21:Mathematics"
s2 = Student.from_string(data_string)
print(s2.name, s2.age, s2.major)  # Output: Diana 21 Mathematics
print(Student.get_total_enrolled())  # Output: 2

Why is this powerful? It keeps your interface clean. Users of your class can create objects in multiple, intuitive ways without needing to know the internal parsing logic.

Do: Use @classmethod to create alternative, intuitive constructors (like from_csv, from_dict) or for methods that need to operate on the class as a whole (like managing a counter).

Smart Attributes: Class Properties

Properties let you disguise method calls as attribute access. They are the ultimate tool for data control and encapsulation.

Problem: You have an attribute that should be derived from other attributes (like a full name from first and last name) or needs validation when it's set (like ensuring age is not negative).

Solution: The @property decorator.

Example: A Controlled Rectangle Class

What this code does: _width and _height are stored as "protected" internal values, but width is exposed as a clean public property. Reading rect.width quietly calls the getter function; writing rect.width = 10 quietly calls the setter, which validates the new value and raises a ValueError if it's not positive — all while looking like plain attribute access to whoever uses the class. area and is_square are read-only properties with no setter at all: they're calculated fresh every single time you access them, so they can never go stale or be set to a wrong value directly.
class Rectangle:
    def __init__(self, width, height):
        self._width = width   # Note the underscore: a hint it's "protected"
        self._height = height

    @property
    def width(self):
        """Getter for width. Called when we do `rect.width`"""
        return self._width

    @width.setter
    def width(self, new_width):
        """Setter for width. Called when we do `rect.width = 10`"""
        if new_width <= 0:
            raise ValueError("Width must be positive.")
        self._width = new_width

    @property
    def area(self):
        """A read-only property! Calculated on the fly."""
        return self._width * self._height

    @property
    def is_square(self):
        """Another read-only property."""
        return self._width == self._height

# Let's use it
rect = Rectangle(5, 3)
print(rect.width)    # Uses the getter -> Output: 5
print(rect.area)     # Uses the property -> Output: 15 (5 * 3)
print(rect.is_square)# Output: False

rect.width = 10      # Uses the setter
print(rect.area)     # Output: 30 (10 * 3)

# This will raise a ValueError
# rect.width = -5

The Beauty of Properties:

  • Encapsulation: The user interacts with simple attributes (rect.area), not method calls (rect.calculate_area()).
  • Control: You can validate data, trigger actions, or calculate values on-the-fly whenever an attribute is accessed or changed.
  • Backward Compatibility: You can start with a simple public attribute and later change it to a property without breaking the user's code. They still use rect.width.
Warning: Notice the single underscore in _width. This is a convention meaning "protected - treat this as private." The property getter/setter methods are the official, controlled interface to this data. It's how you politely tell other developers: "Use the property, not the raw attribute directly."

Putting It All Together: A Complete Mini-Project

Let's build a LibraryBook class that uses everything we've learned.

What this code does: This combines every concept from the article into one working system. library_name is a class attribute shared by every book. check_out() and return_book() are instance methods that flip an internal _is_checked_out flag and track who has the book. status is a read-only property that computes a friendly message on the fly — you never store "Available on shelf" as text, it's derived live from _is_checked_out. reader is a property with a setter that deliberately does nothing useful when you try to assign it directly — it just prints a warning — forcing everyone to go through check_out() instead, which is a real encapsulation technique to protect internal state. Finally, create_from_dict() and create_from_string() are two classmethod constructors offering flexible ways to build a book from different data sources, both funneling back into the same __init__.
class LibraryBook:
    library_name = "Python Public Library"  # Class Attribute

    def __init__(self, title, author, pages):
        self.title = title
        self.author = author
        self.pages = pages
        self._is_checked_out = False  # Internal state
        self._reader = None

    # Instance Method
    def check_out(self, reader_name):
        if self._is_checked_out:
            print(f"Sorry, '{self.title}' is already checked out.")
            return False
        self._is_checked_out = True
        self._reader = reader_name
        print(f"'{self.title}' checked out to {reader_name}.")
        return True

    # Another Instance Method
    def return_book(self):
        self._is_checked_out = False
        old_reader = self._reader
        self._reader = None
        print(f"'{self.title}' returned by {old_reader}.")

    # Property - Read-Only
    @property
    def status(self):
        if self._is_checked_out:
            return f"Checked out to {self._reader}"
        else:
            return "Available on shelf"

    # Property with Getter and Setter
    @property
    def reader(self):
        return self._reader

    @reader.setter
    def reader(self, new_reader):
        print("Cannot assign reader directly! Use check_out() method.")
        # Ignore the assignment. This makes 'reader' effectively read-only.

    # Class Method
    @classmethod
    def create_from_dict(cls, book_dict):
        """Creates a book from a dictionary."""
        return cls(book_dict['title'], book_dict['author'], book_dict['pages'])

    # Class Method - Alternative Constructor
    @classmethod
    def create_from_string(cls, book_string):
        """Creates a book from 'Title|Author|Pages' string."""
        title, author, pages = book_string.split('|')
        return cls(title, author, int(pages))

# --- Using our complete class ---
print(f"Welcome to {LibraryBook.library_name}")

# Standard constructor
book1 = LibraryBook("Python 101", "A. Coder", 300)
print(book1.status)  # Output: Available on shelf

# Using instance methods
book1.check_out("Alice")  # Output: 'Python 101' checked out to Alice.
print(book1.status)       # Output: Checked out to Alice

# Trying to misuse the setter (it won't work)
book1.reader = "Bob"      # Output: Cannot assign reader directly...
print(book1.reader)       # Output: Alice (unchanged)

# Using a class method as constructor
book_data = {"title": "Data Science", "author": "D. Scientist", "pages": 450}
book2 = LibraryBook.create_from_dict(book_data)
print(book2.title)        # Output: Data Science

# Using another class method constructor
book3 = LibraryBook.create_from_string("Algorithms|T. Author|600")
print(book3.author)       # Output: T. Author

Quick Summary & Roadmap

You've Built a Strong Foundation:

  • __init__(self, ...): The object's initializer. Sets up the initial state. Think of it as the object's birth.
  • self: The reference to the current instance. It's how methods access their own data. It's the word "I" for your object.
  • Instance Methods: Functions defined in a class that operate on a specific instance. They define what your objects can do.
  • @classmethod: Methods that belong to the class itself. They use cls and are great for alternative constructors or class-wide operations.
  • @property: A decorator to make methods behave like attributes. It gives you control over getting, setting, and deleting attribute values, enabling data validation and computed attributes.

Happy coding.

Frequently Asked Questions

What's the practical difference between a @classmethod and a @staticmethod, and when should you use each?

A classmethod receives cls automatically, so it can access or modify class-level state (like total_students) and can call cls(...) to build new instances — exactly what from_string() does. A staticmethod receives neither self nor cls; it's just a regular function grouped inside the class for organizational purposes, with no access to instance or class data at all. Use @classmethod for alternative constructors or anything that needs to reference the class itself; use @staticmethod for a utility function that's logically related to the class but doesn't touch its state.

Why does the reader.setter in LibraryBook print a warning instead of raising an exception when someone tries to assign it directly?

Raising a ValueError or AttributeError would be the stricter, more "correct" production choice, since it forces the calling code to handle the failure explicitly. Printing a message instead is a softer, more forgiving choice often used in teaching examples or internal tools — it warns the developer without crashing their script. In a real production system, you'd almost always want to raise an exception here so bad assignments don't silently fail and get missed in code review.

Does wrapping an attribute with @property, like _width, introduce meaningful performance overhead compared to a plain attribute?

Technically yes — a property call goes through a descriptor lookup instead of a direct dictionary access, so it's marginally slower. In practice, this overhead is negligible for the vast majority of applications; it only matters in extremely hot loops running millions of iterations per second. The real-world benefit of validation and computed values almost always outweighs this tiny cost, which is why properties are considered a Python best practice rather than a performance risk.

Why is total_students incremented inside __init__ rather than inside from_string(), and what would break if you incremented it in both places?

Incrementing it inside __init__ guarantees the counter goes up exactly once per object, no matter which constructor path was used to build it — because from_string() still calls cls(name), which triggers __init__ anyway. If you added a second increment inside from_string(), every student created that way would be counted twice, silently corrupting your total. This is a good general rule: put shared setup logic in the one place every path is guaranteed to pass through.

What's the difference between the single-underscore convention (_width) and Python's double-underscore name mangling (__width)?

A single leading underscore is purely a convention — Python enforces nothing, it just signals to other developers "treat this as internal, don't touch it directly," while still allowing full access if needed. A double leading underscore triggers actual name mangling: Python internally renames it to _ClassName__width, making it harder (though not impossible) to access accidentally from outside or from subclasses. Most Python codebases prefer the single-underscore convention paired with properties, reserving double-underscore mangling for narrower cases like avoiding attribute name clashes in inheritance hierarchies.

In the Rectangle example, why doesn't area have an @area.setter, and what would it mean to add one?

area is intentionally read-only because it's a derived value — it only makes sense as a calculation of width * height, so allowing someone to set it directly (rect.area = 100) would let width and height silently fall out of sync with the area you just assigned. If you did add a setter, you'd need to decide how to redistribute that new area back into width and height, which is often ambiguous or undesirable — which is exactly why leaving it read-only is the correct design choice here.

If you needed to serialize a LibraryBook object to JSON for an API response, what's the cleanest way to expose only specific attributes and properties?

The most common approach is writing a small method (often called to_dict()) that explicitly builds a dictionary from the public attributes and properties you want exposed — like title, author, and status — while deliberately leaving out internal state like _reader. This keeps you in control of your public "contract" even as internal implementation details change, rather than blindly dumping self.__dict__, which would expose every underscored internal variable to the outside world.

Why does calling self.has_passed() inside get_report() still correctly call self.calculate_average() internally — what's the lookup mechanism happening here?

Every time you write self.some_method(), Python looks up some_method on the specific instance first, then walks up to the class if it's not found directly on the instance — this is Python's standard attribute resolution order. Because self is passed through consistently at every call in the chain, each nested method call still resolves back to the exact same object's data, which is why get_report(), has_passed(), and calculate_average() all agree on the same student's grades without you passing any data around manually.

What's the risk of using a classmethod alternative constructor like from_string() without validating the number of parts after .split(':')?

If the input string doesn't contain exactly the expected number of colon-separated values, name, age, major = student_string.split(':') will raise an unhandled ValueError about too many or too few values to unpack, likely crashing the calling code with a confusing low-level error. In production code, it's best practice to validate the split result count first and raise a clear, custom error message — so whoever calls from_string() with malformed data gets an obvious explanation instead of a cryptic unpacking failure.

Comments