Classes and objects in Python: basics of object-oriented programming

онлайн тренажер по питону
Online Python Trainer for Beginners

Learn Python easily without overwhelming theory. Solve practical tasks with automatic checking, get hints in Russian, and write code directly in your browser — no installation required.

Start Course

Object-Oriented Programming in Python: A Comprehensive Guide

Object-oriented programming (OOP) is a cornerstone paradigm in modern software development. Python provides robust support for OOP, making it a versatile tool for both novice and expert programmers.

OOP enables real-world modeling, enhances code readability, promotes component reuse, and simplifies project complexity. Key concepts include classes, objects, inheritance, encapsulation, and polymorphism.

This approach organizes a program into interacting objects, each an instance of a class. This modularity improves code clarity and maintainability.

Foundations of Classes and Objects in Python

Defining Classes and Objects

A class is a blueprint or template for creating objects, defining their structure and methods.

An object is a specific instance of a class, possessing its own data and capable of performing actions defined by the class.

class Car:
    def drive(self):
        print("The car is moving")

my_car = Car()  # Object (class instance)
my_car.drive()

In the code above, the Car class defines a template for creating car objects, with the drive() method specifying the behavior of all instances of this class.

Python Class Structure

The basic structure of a class in Python is as follows:

class ClassName:
    def __init__(self, arguments):
        self.properties = values

    def method(self):
        actions

Key components include:

  • __init__: The class constructor, called automatically upon object creation.
  • self: A special parameter representing the current object instance.
  • Attributes: Variables that store object data.
  • Methods: Functions defined within the class and associated with the object.

Creating and Using Classes

Practical Example of Class Creation

Let's create a Person class to model a person:

class Person:
    def __init__(self, name, age):
        self.name = name
        self.age = age

    def greet(self):
        print(f"Hello, my name is {self.name}")

    def get_info(self):
        return f"Name: {self.name}, Age: {self.age}"

p = Person("Anna", 30)
p.greet()
print(p.get_info())

In this example, the __init__ constructor takes name and age parameters, which are stored as instance attributes. The greet() and get_info() methods provide functionality for interacting with the object's data.

Creating Class Instances

Creating an instance of a class in Python involves calling the class like a function:

p1 = Person("Ivan", 25)
p2 = Person("Maria", 28)

print(p1.name)  # Ivan
print(p1.age)   # 25
print(p2.name)  # Maria

Each call to the class constructor generates a new, independent object with its own attributes and state.

Attributes in Python Classes

Instance Attributes

Instance attributes are unique to each object. They are created and initialized in the __init__ method and can be modified during program execution:

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

    def update_grade(self, new_grade):
        self.grade = new_grade

student1 = Student("Alexey", 85)
student1.update_grade(92)
print(student1.grade)  # 92

Class Attributes

Class attributes are shared among all instances of the class. They are defined at the class level and are common to all objects:

class Dog:
    species = "Canis lupus"  # Class attribute

    def __init__(self, name, breed):
        self.name = name      # Instance attribute
        self.breed = breed    # Instance attribute

dog1 = Dog("Bobik", "Labrador")
dog2 = Dog("Sharik", "Shepherd")

print(Dog.species)     # Canis lupus
print(dog1.species)    # Canis lupus
print(dog2.species)    # Canis lupus

Methods in Classes

Regular Methods

Regular class methods operate on a specific instance and access its attributes via the self parameter:

class Calculator:
    def __init__(self, value=0):
        self.value = value

    def add(self, number):
        self.value += number
        return self

    def multiply(self, number):
        self.value *= number
        return self

    def get_result(self):
        return self.value

calc = Calculator(10)
result = calc.add(5).multiply(2).get_result()
print(result)  # 30

Static Methods

Static methods are independent of the class instance and do not have access to self or cls. They are used for utility functions logically related to the class:

class MathUtils:
    @staticmethod
    def add(a, b):
        return a + b

    @staticmethod
    def multiply(a, b):
        return a * b

    @staticmethod
    def is_even(number):
        return number % 2 == 0

result = MathUtils.add(10, 5)
print(result)  # 15
print(MathUtils.is_even(4))  # True

Class Methods

Class methods receive the class as the first argument (cls) instead of the instance. They are often used to create alternative constructors:

class Person:
    def __init__(self, name, age):
        self.name = name
        self.age = age

    @classmethod
    def from_string(cls, person_str):
        name, age = person_str.split('-')
        return cls(name, int(age))

    @classmethod
    def create_anonymous(cls):
        return cls("Anonymous", 0)

# Normal creation
person1 = Person("Ivan", 25)

# Creation via class method
person2 = Person.from_string("Maria-30")
person3 = Person.create_anonymous()

Encapsulation in Python

Public and Private Attributes

In Python, all attributes and methods are public by default, but conventions exist to indicate privacy:

class BankAccount:
    def __init__(self, balance):
        self.account_number = "123456789"  # Public attribute
        self._internal_id = "ABC123"       # Protected attribute
        self.__balance = balance           # Private attribute

    def get_balance(self):
        return self.__balance

    def deposit(self, amount):
        if amount > 0:
            self.__balance += amount

    def withdraw(self, amount):
        if 0 < amount <= self.__balance:
            self.__balance -= amount
            return True
        return False

account = BankAccount(1000)
print(account.account_number)  # 123456789
print(account.get_balance())   # 1000
account.deposit(500)
print(account.get_balance())   # 1500

Properties

Python provides a mechanism called properties to control attribute access:

class Temperature:
    def __init__(self, celsius=0):
        self._celsius = celsius

    @property
    def celsius(self):
        return self._celsius

    @celsius.setter
    def celsius(self, value):
        if value < -273.15:
            raise ValueError("Temperature cannot be below absolute zero")
        self._celsius = value

    @property
    def fahrenheit(self):
        return (self._celsius * 9/5) + 32

    @fahrenheit.setter
    def fahrenheit(self, value):
        self.celsius = (value - 32) * 5/9

temp = Temperature(25)
print(temp.celsius)     # 25
print(temp.fahrenheit)  # 77.0
temp.fahrenheit = 86
print(temp.celsius)     # 30.0

Inheritance in Python

Basics of Inheritance

Inheritance allows creating new classes based on existing ones, extending or modifying their functionality:

class Animal:
    def __init__(self, name, species):
        self.name = name
        self.species = species

    def speak(self):
        print(f"{self.name} makes a sound")

    def info(self):
        return f"This is a {self.species} named {self.name}"

class Dog(Animal):
    def __init__(self, name, breed):
        super().__init__(name, "dog")
        self.breed = breed

    def speak(self):
        print(f"{self.name} barks: Woof-woof!")

    def fetch(self):
        print(f"{self.name} fetches the stick")

class Cat(Animal):
    def __init__(self, name, color):
        super().__init__(name, "cat")
        self.color = color

    def speak(self):
        print(f"{self.name} meows: Meow!")

    def purr(self):
        print(f"{self.name} purrs")

dog = Dog("Rex", "Labrador")
cat = Cat("Murka", "gray")

dog.speak()  # Rex barks: Woof-woof!
cat.speak()  # Murka meows: Meow!
dog.fetch()  # Rex fetches the stick
cat.purr()   # Murka purrs

Multiple Inheritance

Python supports multiple inheritance, allowing a class to inherit from multiple parent classes:

class Flyable:
    def fly(self):
        print("Flies through the air")

class Swimmable:
    def swim(self):
        print("Swims in the water")

class Duck(Animal, Flyable, Swimmable):
    def __init__(self, name):
        super().__init__(name, "duck")

    def speak(self):
        print(f"{self.name} quacks: Quack-quack!")

duck = Duck("Donald")
duck.speak()  # Donald quacks: Quack-quack!
duck.fly()    # Flies through the air
duck.swim()   # Swims in the water

Type and Inheritance Checking

Python provides built-in functions for checking object types:

print(isinstance(dog, Dog))     # True
print(isinstance(dog, Animal))  # True
print(isinstance(dog, Cat))     # False

print(issubclass(Dog, Animal))  # True
print(issubclass(Cat, Animal))  # True
print(issubclass(Dog, Cat))     # False

Polymorphism in Python

Polymorphism allows using objects of different classes through a unified interface:

class Shape:
    def area(self):
        raise NotImplementedError("Method must be overridden")

    def perimeter(self):
        raise NotImplementedError("Method must be overridden")

class Rectangle(Shape):
    def __init__(self, width, height):
        self.width = width
        self.height = height

    def area(self):
        return self.width * self.height

    def perimeter(self):
        return 2 * (self.width + self.height)

class Circle(Shape):
    def __init__(self, radius):
        self.radius = radius

    def area(self):
        return 3.14159 * self.radius ** 2

    def perimeter(self):
        return 2 * 3.14159 * self.radius

# Polymorphic usage
shapes = [Rectangle(5, 3), Circle(4), Rectangle(2, 8)]

for shape in shapes:
    print(f"Area: {shape.area():.2f}")
    print(f"Perimeter: {shape.perimeter():.2f}")
    print("-" * 20)

Special Methods (Magic Methods)

Basic Magic Methods

Special methods allow defining object behavior for built-in Python operations:

class Book:
    def __init__(self, title, author, pages):
        self.title = title
        self.author = author
        self.pages = pages

    def __str__(self):
        return f"'{self.title}' by {self.author}"

    def __repr__(self):
        return f"Book('{self.title}', '{self.author}', {self.pages})"

    def __eq__(self, other):
        if isinstance(other, Book):
            return self.title == other.title and self.author == other.author
        return False

    def __lt__(self, other):
        if isinstance(other, Book):
            return self.pages < other.pages
        return NotImplemented

    def __len__(self):
        return self.pages

book1 = Book("1984", "George Orwell", 328)
book2 = Book("Animal Farm", "George Orwell", 112)

print(book1)           # '1984' by George Orwell
print(repr(book1))     # Book('1984', 'George Orwell', 328)
print(book1 == book2)  # False
print(book1 > book2)   # True
print(len(book1))      # 328

Arithmetic Operations

Object behavior for arithmetic operations can be defined:

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

    def __add__(self, other):
        if isinstance(other, Vector):
            return Vector(self.x + other.x, self.y + other.y)
        return NotImplemented

    def __mul__(self, scalar):
        if isinstance(scalar, (int, float)):
            return Vector(self.x * scalar, self.y * scalar)
        return NotImplemented

    def __str__(self):
        return f"Vector({self.x}, {self.y})"

v1 = Vector(2, 3)
v2 = Vector(1, 4)
v3 = v1 + v2
v4 = v1 * 3

print(v3)  # Vector(3, 7)
print(v4)  # Vector(6, 9)

Dataclasses: Simplifying Class Creation

The dataclasses module provides a decorator to automatically generate special methods:

from dataclasses import dataclass, field
from typing import List

@dataclass
class Point:
    x: int
    y: int

    def distance_from_origin(self):
        return (self.x ** 2 + self.y ** 2) ** 0.5

@dataclass
class Student:
    name: str
    age: int
    grades: List[int] = field(default_factory=list)

    def add_grade(self, grade):
        self.grades.append(grade)

    def average_grade(self):
        return sum(self.grades) / len(self.grades) if self.grades else 0

point = Point(3, 4)
print(point)  # Point(x=3, y=4)
print(point.distance_from_origin())  # 5.0

student = Student("Anna", 20)
student.add_grade(85)
student.add_grade(92)
print(student.average_grade())  # 88.5

Organizing Code with Classes

Modules and Class Imports

Classes should be organized into modules for better project structure:

# file: models/user.py
class User:
    def __init__(self, username, email):
        self.username = username
        self.email = email
        self.is_active = True

    def deactivate(self):
        self.is_active = False

    def get_display_name(self):
        return f"@{self.username}"

# file: models/product.py
class Product:
    def __init__(self, name, price, category):
        self.name = name
        self.price = price
        self.category = category

    def apply_discount(self, percentage):
        self.price *= (1 - percentage / 100)

# file: main.py
from models.user import User
from models.product import Product

user = User("ivan_petrov", "ivan@example.com")
product = Product("Laptop", 50000, "Electronics")

Class Composition

Composition is an alternative to inheritance where objects contain other objects:

class Engine:
    def __init__(self, horsepower, fuel_type):
        self.horsepower = horsepower
        self.fuel_type = fuel_type
        self.is_running = False

    def start(self):
        self.is_running = True
        print("Engine started")

    def stop(self):
        self.is_running = False
        print("Engine stopped")

class Car:
    def __init__(self, make, model, engine):
        self.make = make
        self.model = model
        self.engine = engine
        self.speed = 0

    def start_car(self):
        self.engine.start()
        print(f"{self.make} {self.model} ready to go")

    def accelerate(self, speed_increase):
        if self.engine.is_running:
            self.speed += speed_increase
            print(f"Speed increased to {self.speed} km/h")
        else:
            print("Start the engine first")

engine = Engine(200, "gasoline")
car = Car("Toyota", "Camry", engine)
car.start_car()
car.accelerate(50)

Best Practices for Class Design

Principles of Good Design

When creating classes, follow these principles:

  • Single Responsibility: Each class should have only one reason to change.
  • Open/Closed Principle: Classes should be open for extension but closed for modification.
  • Liskov Substitution Principle: Subclass objects should be substitutable for objects of the base class.
  • Interface Segregation: Clients should not depend on interfaces they do not use.
  • Dependency Inversion: Depend on abstractions, not on concrete implementations.

Naming Conventions

Python naming conventions:

  • Class names are in CamelCase (e.g., UserAccount, ProductCatalog).
  • Method and attribute names use snake_case (e.g., get_user_info, total_price).
  • Constants are in uppercase with underscores (e.g., MAX_RETRY_COUNT).
  • Private attributes and methods start with one or two underscores.
class UserManager:
    MAX_USERS = 1000

    def __init__(self):
        self.users = []
        self._cache = {}
        self.__secret_key = "abc123"

    def add_user(self, user):
        if len(self.users) < self.MAX_USERS:
            self.users.append(user)
            return True
        return False

    def _clear_cache(self):
        self._cache.clear()

    def __generate_token(self):
        return f"token_{len(self.users)}"

Common Mistakes and Solutions

Typical Problems with Classes

Common mistakes when working with classes in Python:

  • Forgetting the self parameter in methods:
# Incorrect
class Calculator:
    def add(a, b):  # Missing self
        return a + b

# Correct
class Calculator:
    def add(self, a, b):
        return a + b
  • Incorrect attribute initialization:
# Incorrect - mutable default values
class Team:
    def __init__(self, name, members=[]):
        self.name = name
        self.members = members  # Dangerous!

# Correct
class Team:
    def __init__(self, name, members=None):
        self.name = name
        self.members = members or []
  • Direct access to private attributes:
class Account:
    def __init__(self, balance):
        self.__balance = balance

    def get_balance(self):
        return self.__balance

# Incorrect
account = Account(1000)
print(account.__balance)  # Will raise AttributeError

# Correct
print(account.get_balance())

Debugging and Testing Classes

For effective class debugging, use appropriate tools and methods:

class BankAccount:
    def __init__(self, account_number, initial_balance=0):
        self.account_number = account_number
        self.balance = initial_balance
        self.transaction_history = []

    def deposit(self, amount):
        if amount <= 0:
            raise ValueError("Deposit amount must be positive")

        self.balance += amount
        self.transaction_history.append(f"Deposit: +{amount}")
        return self.balance

    def withdraw(self, amount):
        if amount <= 0:
            raise ValueError("Withdrawal amount must be positive")

        if amount > self.balance:
            raise ValueError("Insufficient funds")

        self.balance -= amount
        self.transaction_history.append(f"Withdrawal: -{amount}")
        return self.balance

    def __repr__(self):
        return f"BankAccount({self.account_number}, {self.balance})"

# Simple tests
def test_bank_account():
    account = BankAccount("123456", 1000)

    # Deposit test
    new_balance = account.deposit(500)
    assert new_balance == 1500, f"Expected balance 1500, got {new_balance}"

    # Withdrawal test
    new_balance = account.withdraw(200)
    assert new_balance == 1300, f"Expected balance 1300, got {new_balance}"

    # Transaction history test
    assert len(account.transaction_history) == 2

    print("All tests passed successfully!")

test_bank_account()

Approach Comparison: Procedural Programming vs. OOP

Procedural Approach

# Procedural approach
def create_user(name, email):
    return {"name": name, "email": email, "is_active": True}

def activate_user(user):
    user["is_active"] = True

def deactivate_user(user):
    user["is_active"] = False

def send_email(user, message):
    if user["is_active"]:
        print(f"Sending email to {user['email']}: {message}")

user = create_user("Ivan", "ivan@example.com")
send_email(user, "Welcome!")

Object-Oriented Approach

# Object-oriented approach
class User:
    def __init__(self, name, email):
        self.name = name
        self.email = email
        self.is_active = True

    def activate(self):
        self.is_active = True

    def deactivate(self):
        self.is_active = False

    def send_email(self, message):
        if self.is_active:
            print(f"Sending email to {self.email}: {message}")

user = User("Ivan", "ivan@example.com")
user.send_email("Welcome!")

Approach Comparison

The object-oriented approach offers:

  • Encapsulation: Data and methods combined in one place.
  • Inheritance: Ability to create specialized classes.
  • Polymorphism: Unified interface for different object types.
  • Reuse: Easier code reuse and extension.
  • Scalability: Better suited for large projects.

The procedural approach may be preferred for:

  • Simple scripts and utilities.
  • Mathematical calculations.
  • Data processing without complex structures.
  • Prototyping and quick solutions.

Advanced OOP Concepts

Abstract Classes

Abstract classes define an interface that subclasses must implement:

from abc import ABC, abstractmethod

class Animal(ABC):
    def __init__(self, name):
        self.name = name

    @abstractmethod
    def make_sound(self):
        pass

    @abstractmethod
    def move(self):
        pass

    def sleep(self):
        print(f"{self.name} sleeps")

class Dog(Animal):
    def make_sound(self):
        return "Woof!"

    def move(self):
        return "Runs on four legs"

class Bird(Animal):
    def make_sound(self):
        return "Chirp!"

    def move(self):
        return "Flies in the sky"

# animal = Animal("Test")  # Will raise TypeError
dog = Dog("Rex")
bird = Bird("Chizhik")

print(f"{dog.name} says: {dog.make_sound()}")
print(f"{bird.name} says: {bird.make_sound()}")

Mixins

Mixins are classes that provide specific functionality to other classes:

class TimestampMixin:
    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        from datetime import datetime
        self.created_at = datetime.now()
        self.updated_at = datetime.now()

    def touch(self):
        from datetime import datetime
        self.updated_at = datetime.now()

class SerializableMixin:
    def to_dict(self):
        return {
            key: value for key, value in self.__dict__.items()
            if not key.startswith('_')
        }

    def from_dict(self, data):
        for key, value in data.items():
            setattr(self, key, value)

class Article(TimestampMixin, SerializableMixin):
    def __init__(self, title, content):
        self.title = title
        self.content = content
        super().__init__()

    def update_content(self, new_content):
        self.content = new_content
        self.touch()

article = Article("Python OOP", "Article content...")
print(article.to_dict())
article.update_content("Updated content")
print(f"Updated: {article.updated_at}")

Performance and Optimization

Slots for Memory Saving

Using slots can significantly reduce memory consumption:

class Point:
    __slots__ = ['x', 'y']

    def __init__(self, x, y):
        self.x = x
        self.y = y

    def distance_to(self, other):
        return ((self.x - other.x) ** 2 + (self.y - other.y) ** 2) ** 0.5

# Memory savings are especially noticeable when creating many objects
points = [Point(i, i*2) for i in range(10000)]

Lazy Properties

Lazy properties are calculated only upon first access:

class DataProcessor:
    def __init__(self, data):
        self._data = data
        self._processed_data = None
        self._statistics = None

    @property
    def processed_data(self):
        if self._processed_data is None:
            print("Processing data...")
            # Simulate heavy calculations
            self._processed_data = [x * 2 for x in self._data]
        return self._processed_data

    @property
    def statistics(self):
        if self._statistics is None:
            print("Calculating statistics...")
            data = self.processed_data
            self._statistics = {
                'min': min(data),
                'max': max(data),
                'avg': sum(data) / len(data)
            }
        return self._statistics

processor = DataProcessor([1, 2, 3, 4, 5])
print("Processor created")
print(f"Average: {processor.statistics['avg']}")  # Calculated now
print(f"Maximum: {processor.statistics['max']}")          # Taken from cache

Conclusion

Classes and objects are foundational tools in Python, allowing code organization according to object-oriented programming principles. They provide effective state and logic management, promote component reuse, and simplify code maintenance.

Understanding OOP basics in Python is essential for developers aiming to create quality, scalable applications. Proper use of classes, inheritance, encapsulation, and polymorphism enables flexible, extensible systems.

After mastering the basic concepts presented in this article, developers can move on to more advanced topics: metaclasses, descriptors, design patterns, object serialization, creating custom iterators, and context managers. This knowledge will unlock new possibilities for creating complex and effective Python applications.

News