1
Python Object-Oriented Programming Basics

2024-10-12

Classes and Objects

Class Definition and Creation

Python is an object-oriented programming language that supports both procedural and object-oriented programming paradigms. Object-Oriented Programming (OOP) is a programming concept that abstracts real-world entities into objects, with each object having its own attributes and behaviors.

In Python, the class keyword is used to define a class. Here's a simple definition of a Person class:

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

    def say_hello(self):
        print(f"Hello, my name is {self.name} and I'm {self.age} years old.")

In this example:

  • __init__ is a special method called the constructor, which is automatically called when creating an object. It's used to initialize the object's attributes.
  • self is a reference to the current object, through which we can access the object's attributes and methods.
  • name and age are two attributes of this class.
  • say_hello is a method of this class, used to print personal information.

The process of creating an object is called instantiation. We can create an object using the class name followed by parentheses:

person1 = Person("Alice", 25)
person2 = Person("Bob", 30)

Here, we've created two Person objects, person1 and person2. We can access their attributes and methods through these objects:

print(person1.name)  # Output: Alice
person2.say_hello()  # Output: Hello, my name is Bob and I'm 30 years old.

By defining classes, we can create multiple objects with the same attributes and behaviors. This improves code reusability and maintainability.

Object Instantiation and Usage

In the previous section, we've seen how to define a Person class and how to create objects (instantiate). Now let's explore further on how to use objects.

First, let's review the Person class definition:

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

    def say_hello(self):
        print(f"Hello, my name is {self.name} and I'm {self.age} years old.")

We've already created two Person objects:

person1 = Person("Alice", 25)
person2 = Person("Bob", 30)

Now, we can access their attributes and methods through these objects. For example:

print(person1.name)  # Output: Alice
print(person2.age)   # Output: 30


person1.say_hello()  # Output: Hello, my name is Alice and I'm 25 years old.
person2.say_hello()  # Output: Hello, my name is Bob and I'm 30 years old.

We can also modify the attribute values of objects:

person1.age = 26
print(person1.age)  # Output: 26

Objects are the core concept of object-oriented programming. By creating objects, we can encapsulate data (attributes) and behaviors (methods) together, forming an independent entity. This improves code modularity and reusability.

Additionally, objects in Python are mutable, allowing us to dynamically modify their attribute values. This provides us with greater flexibility.

Attributes and Methods

Instance Attributes and Class Attributes

In Python, attributes can be categorized into instance attributes and class attributes.

Instance attributes are attributes bound to a specific object instance. Each object instance has its own independent copy of instance attributes. In the previous example, name and age are instance attributes of the Person class.

Class attributes are attributes defined at the class level and shared by all instances of that class. To define class attributes, you can assign values directly inside the class or define them outside the __init__ method.

For example, we can add a class attribute species to the Person class:

class Person:
    species = "Human"  # Class attribute

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

    def say_hello(self):
        print(f"Hello, my name is {self.name} and I'm {self.age} years old.")

Now, all Person instances share the same species attribute:

person1 = Person("Alice", 25)
person2 = Person("Bob", 30)

print(person1.species)  # Output: Human
print(person2.species)  # Output: Human
print(Person.species)   # Output: Human

We can access class attributes through the class name or instances. However, if we try to modify the class attribute through an instance, it actually creates a new instance attribute for that instance without affecting the class attribute itself:

person1.species = "Alien"
print(person1.species)  # Output: Alien (instance attribute)
print(person2.species)  # Output: Human (class attribute)
print(Person.species)   # Output: Human (class attribute)

By using instance attributes and class attributes, we can better organize and manage object data. Instance attributes are used to store unique data for each object, while class attributes are used to store data shared by all objects.

Instance Methods, Class Methods, and Static Methods

In Python, methods are functions associated with classes or objects. Based on how methods are defined and called, they can be categorized into instance methods, class methods, and static methods.

Instance methods are the most common type of methods. They are defined inside the class, and their first parameter must be self, used to access the instance's attributes and other instance methods. Instance methods can only be called through instances.

In the previous Person class, say_hello is an instance method:

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

    def say_hello(self):
        print(f"Hello, my name is {self.name} and I'm {self.age} years old.")

person1 = Person("Alice", 25)
person1.say_hello()  # Output: Hello, my name is Alice and I'm 25 years old.

Class methods are methods defined inside the class using the @classmethod decorator. Their first parameter is cls, used to access and modify class attributes. Class methods can be called through the class or instances.

Here's an example using a class method:

class Person:
    species = "Human"

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

    @classmethod
    def get_species(cls):
        return cls.species

    def say_hello(self):
        print(f"Hello, my name is {self.name} and I'm {self.age} years old.")

print(Person.get_species())  # Output: Human
person1 = Person("Alice", 25)
print(person1.get_species())  # Output: Human

Static methods are methods defined using the @staticmethod decorator. They don't require self or cls parameters because they don't access any instance or class attributes. Static methods can be seen as regular functions defined within a class.

Here's an example using a static method:

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

print(Utils.is_even(4))  # Output: True
print(Utils.is_even(7))  # Output: False

In summary:

  • Instance methods are used to manipulate instance attributes and behaviors, called through instances.
  • Class methods are used to manipulate class attributes and behaviors, can be called through classes or instances.
  • Static methods are functions independent of instances and classes, can be called through classes or instances.

The choice of which method type to use depends on your specific needs. Instance methods are used for instance-level logic, class methods for class-level logic, and static methods for implementing functionality independent of instances and classes.

Inheritance

Single Inheritance and Multiple Inheritance

Inheritance is an important concept in object-oriented programming. It allows a class (subclass) to inherit attributes and methods from another class (parent class), enabling code reuse and extension. Python supports both single inheritance and multiple inheritance.

Single inheritance refers to a subclass inheriting from only one parent class. This is the most common and simplest form of inheritance. For example:

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

    def speak(self):
        print("The animal speaks")

class Dog(Animal):
    def bark(self):
        print("The dog barks")

dog = Dog("Buddy")
dog.speak()  # Output: The animal speaks
dog.bark()   # Output: The dog barks

In this example, the Dog class inherits from the Animal class. A Dog instance can not only call the bark method but also the speak method inherited from the Animal class.

Multiple inheritance refers to a subclass inheriting from multiple parent classes. In this case, the subclass inherits all attributes and methods from all parent classes. For example:

class Animal:
    def speak(self):
        print("The animal speaks")

class Walkable:
    def walk(self):
        print("The creature walks")

class Dog(Animal, Walkable):
    def bark(self):
        print("The dog barks")

dog = Dog()
dog.speak()  # Output: The animal speaks
dog.walk()   # Output: The creature walks
dog.bark()   # Output: The dog barks

In this example, the Dog class inherits from both Animal and Walkable classes, so it can call methods from both parent classes.

It's worth noting that in the case of multiple inheritance, if multiple parent classes have attributes or methods with the same name, they will be searched according to the inheritance order. Python uses a depth-first, left-to-right approach to resolve attributes and methods. This order is also known as the Method Resolution Order (MRO).

In general, single inheritance is usually simpler and easier to understand, while multiple inheritance provides greater flexibility but may also introduce more complexity and potential naming conflicts. In actual development, the appropriate inheritance method should be chosen based on specific requirements.

Using the super() Function

In Python, the super() function is used to call methods from the parent class. It is mainly used in two situations:

  1. Calling a parent class method with the same name in a subclass method

Suppose we have an Animal class and a Dog class inheriting from it:

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

    def speak(self):
        print("The animal speaks")

class Dog(Animal):
    def __init__(self, name, breed):
        super().__init__(name)  # Call the __init__ method of the parent class
        self.breed = breed

    def speak(self):
        print("The dog barks")
        super().speak()  # Call the speak method of the parent class

In the __init__ method of the Dog class, we use super().__init__(name) to call the constructor of the parent class Animal. This ensures that the attributes of the parent class are also properly initialized.

In the speak method of the Dog class, we first print "The dog barks", then use super().speak() to call the speak method of the parent class Animal. This allows extending the functionality of the parent class in the subclass.

  1. Calling an overridden parent class method in a subclass

Sometimes, we may need to call a method from the parent class that has been overridden in the subclass. For example:

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

    def eat(self):
        print("The animal eats")

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

    def eat(self):
        super().eat()  # Call the eat method of the parent class
        print("The dog also barks while eating")

dog = Dog("Buddy", "Labrador")
dog.eat()  # Output: The animal eats
           #         The dog also barks while eating

In this example, the Dog class overrides the eat method inherited from the Animal class. However, in the eat method of the Dog class, we use super().eat() to call the eat method of the parent class, then add our own behavior.

Using the super() function ensures that the subclass can extend the functionality of the parent class while also utilizing the code already implemented in the parent class. This improves code reusability and maintainability.

It's important to note that the behavior of the super() function depends on the Method Resolution Order (MRO). In the case of single inheritance, it will directly call the method of the parent class. But in the case of multiple inheritance, it will search and call the next parent class's method with the same name according to the MRO.

Encapsulation

Private Attributes and Methods

In Python, encapsulation is an important principle of object-oriented programming. It controls access to an object's attributes and methods to protect the internal state of the object from external interference.

Python doesn't use keywords like Java or C++ to distinguish between public and private members. Instead, Python uses a naming convention to implement encapsulation:

  • Attributes or methods starting with a single underscore (_) are considered protected, typically used only within the class and its subclasses.
  • Attributes or methods starting with double underscores (__) are considered private, can only be used within the class.

For example:

class Person:
    def __init__(self, name, age):
        self._name = name  # Protected attribute
        self.__age = age   # Private attribute

    def get_age(self):
        return self.__age  # Can access private attribute within the class

    def _helper_method(self):  # Protected method
        print("This is a helper method")

person = Person("Alice", 25)
print(person._name)  # Can access protected attribute

print(person.get_age())  # Output: 25

In this example:

  • _name is a protected attribute, it can be accessed outside the class, but this practice is not encouraged.
  • __age is a private attribute, it can only be accessed within the class through the get_age method.
  • _helper_method is a protected method, it should not be called outside the class.

It's worth noting that private members in Python are not truly private in the strictest sense. They are only name-mangled (renamed to _ClassName__attribute) to restrict external access. Therefore, if really necessary, private members can still be accessed, but this practice should be avoided.

Also, Python's naming convention is just a coding habit, it cannot truly prevent programmers from directly accessing protected or private members. Therefore, encapsulation in Python is more of a self-constraint rather than a mandatory language mechanism.

Using encapsulation can improve code maintainability and extensibility, while also increasing code readability and security. However, in Python, the implementation of encapsulation relies on programmers' conscious adherence, requiring consensus within the team.

Property Decorators

In Python, the @property decorator can be used to create a "property", making a method appear to be accessed like an attribute. This technique is usually referred to as a "property accessor" or "property descriptor".

Using property decorators has several main advantages:

  1. Simplify code: Using property decorators can avoid writing explicit getter and setter methods, making the code more concise.
  2. Encapsulate data: Through property decorators, additional logic can be executed when accessing attributes, thus achieving data encapsulation and protection.
  3. Improve maintainability: If the access logic of attributes needs to be modified, only the decorator function needs to be changed, without changing all access points.

Here's an example using property decorators:

class Person:
    def __init__(self, name):
        self._name = name  # Private attribute

    @property
    def name(self):
        "This is a name property."
        print("Getting name")
        return self._name

    @name.setter
    def name(self, value):
        print("Setting name to", value)
        self._name = value

    @name.deleter
    def name(self):
        print("Deleting name")
        del self._name

person = Person("Alice")
print(person.name)  # Output: Getting name
                    #         Alice

person.name = "Bob"  # Output: Setting name to Bob

del person.name  # Output: Deleting name

In this example:

  • The @property decorator marks the name method as the getter method of the property.
  • The @name.setter decorator marks another name method as the setter method of the property.
  • The @name.deleter decorator marks a third name method as the deleter method of the property.

After using property decorators, we can access, set, and delete name like a normal attribute, without explicitly calling getter, setter, and deleter methods.

In addition to using the @property decorator, Python also provides the property class for creating properties. The @property decorator is actually a shorthand form of the property class.

In summary, property decorators provide an elegant way to encapsulate and control access to attributes while maintaining code conciseness and readability. It is a very useful feature in Python's object-oriented programming.