avatarYang Zhou

Summary

The provided text is an in-depth guide to advanced object-oriented programming (OOP) techniques in Python, offering ten tips to optimize code, enhance maintainability, and ensure consistent behavior of classes.

Abstract

The article on the undefined website delves into the nuances of Python's OOP capabilities, emphasizing the use of data classes, abstract classes, class-level and instance-level attributes, access modifiers, mixins, property decorators, class methods, static methods, the distinction between __new__ and __init__, and the __slots__ feature. It illustrates how these features can be leveraged to write more efficient and maintainable Python code. The author, Yang Zhou, advocates for the use of Python-specific OOP constructs, such as the @dataclass decorator for automatic generation of special methods, and the @property decorator for controlled attribute access. The article also covers the strategic use of abstract classes to define common interfaces, the separation of class and instance attributes, the use of underscores for public, protected, and private attributes, and the implementation of mixins for extending functionality without complex inheritance chains. Additionally, it discusses the proper use of class and static methods, the distinction between object creation and initialization, and the control of attribute creation through __slots__. The conclusion encourages readers to apply these OOP features to create scalable and organized code.

Opinions

  • The author believes that using Python's data classes with the @dataclass decorator significantly reduces boilerplate code and enhances code clarity.
  • The article suggests that abstract classes are crucial for defining common interfaces and ensuring that subclasses implement necessary methods.
  • It is the author's opinion that clearly separating class-level and instance-level attributes is essential for robust Python code.
  • The use of underscores to denote the intended visibility of class attributes (public, protected, private) is recommended as a Pythonic convention.
  • Mixin classes are presented as a flexible and efficient way to add functionality to multiple classes without complicating the inheritance hierarchy.
  • The @property decorator is highly recommended for encapsulating attribute access and ensuring that data validation occurs when attributes are modified.
  • Class methods are seen as valuable for operations related to the class itself rather than specific instances, while static methods are preferred for utility functions unrelated to instance or class data.
  • The distinction between __new__ and __init__ is highlighted as an important concept for developers who need precise control over object creation and initialization.
  • The author advocates for the use of __slots__ to restrict the attributes of a class, which can improve performance and prevent the addition of unexpected attributes.
  • Overall, the author expresses that mastering these advanced OOP techniques in Python can lead to significant improvements in code quality and maintainability.

Python

10 Remarkable Python OOP Tips That Will Optimize Your Code Significantly

Make your Python classes maintainable and extendable

Image from Wallhaven

Object-oriented programming (OOP) is a great programming paradigm to create modular and reusable code that is easy to maintain and extend.

Python, as an excellent programming language, provides full OOP functionalities to us, including some unique features that even pure OOP languages don’t have.

However, mastering OOP is not as easy as understanding some basic syntax and tricks.

This article will summarise 10 advanced techniques for writing better OOP programs in Python.

After reading and practising, you will stand out in the long run of software engineering in Python. 👍

1. Use Data Classes To Automatically Generate Special Methods

To understand the data classes of Python, let’s start with a simple example.

The following code defines a class named Point representing points in Euclidean space:

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

A=Point(2,3)
B=Point(2,3)
print(A==B)
# False

Unfortunately, it printed False even if the two points have the exact same location.

The reason is simple, we didn’t tell Python how to compare different Point instances when defining this class.

Therefore, we have to define the __eq__ method, which will be used to determine if two instances are equal or not:

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

    def __eq__(self, other):
        return self.x == other.x and self.y == other.y

A=Point(2,3)
B=Point(2,3)
print(A==B)
# True

The above code works as expected. However, it’s too much for just an obvious comparison.

Is there any chance that Python can become more intelligent and define the basic internal methods in advance for us? 🤔

Yes. Since Python 3.7, there is a new built-in decorator@dataclass. We can define a data class as follows:

from dataclasses import dataclass

@dataclass
class Point:
    x:int
    y:int

A=Point(2,3)
B=Point(2,3)
print(A==B)
# True

As the above code shows, this class definition creates a Point class with two fields, x and y, and their type hints are both int.

We only defined two attributes of the Point class, nothing else. But why Python knows how to compare the points A and B properly this time?

In fact, the @dataclass decorator automatically generated several methods for the Point class, such as __init__ for initializing objects, __repr__ for generating string representations of objects, and __eq__ for comparing objects for equality.

Since the @dataclass decorator simplifies the process of creating data classes by automatically generating many special methods for us, it saves our time and effort in writing these methods and helps ensure that our data classes have consistent and predictable behavior.

Anytime you need to define classes that are primarily used to store data, don’t forget to leverage the power of the @dataclass decorator.

2. Use Abstract Classes To Define Common Interfaces

An abstract class, which is an important concept of OOP, can define a common interface for a set of subclasses. It provides common attributes and methods for all subclasses to reduce code duplication. It also enforces subclasses to implement abstract methods to avoid inconsistencies.

Python, like other OOP languages, supports the usage of abstract classes.

The following example shows how to define a class as an abstract class by abc.ABC and define a method as an abstract method by abc.abstractmethod:

from abc import ABC, abstractmethod

class Animal(ABC):
    @abstractmethod
    def move(self):
        print('Animal moves')

class Cat(Animal):
    def move(self):
        super().move()
        print('Cat moves')

c = Cat()
c.move()

# Animal moves
# Cat moves

This example defines an abstract class called Animal, and a class Cat which is inherited from Animal.

Given that the Animal is an abstract class and its move() method is an abstract method, we must implement the move() method in the Cat class. This mechanism helps to ensure that all subclasses have a certain set of methods, and helps to prevent errors that might occur if the subclasses do not implement all of the required methods.

The ABC, by the way, is the abbreviation of abstract base class.

3. Separate Class-Level and Instance-Level Attributes

Python classes can be clearly separated into class-level and instance-level attributes:

  • A class attribute belongs to a class rather than a particular instance. All instances of this class can access it and it is defined outside the constructor function of the class.
  • An instance attribute, which is defined inside the constructor function, belongs to a particular instance. It’s only accessible in this certain instance rather than the class. If we call an instance attribute by the class, there will be an AttributeError.
class MyClass(object):
     class_attr = 0
     def __init__(self, instance_attr):
         self.instance_attr = instance_attr
        
MyClass.class_attr
# 0

MyClass.instace_attr
# AttributeError: type object 'MyClass' has no attribute 'instace_attr'

my_instance = MyClass(1)

my_instance.instance_attr
# 1

my_instance.class_attr
# 0

The above example shows the different usages of class attributes and instance attributes. Separating these two types of attributes clearly can make your Python code more robust.

4. Separate Public, Protected and Private Attributes

Unlike C++ or Java, Python doesn’t have strict restrictions for the permissions of attributes.

The Pythonic way to separate different permissions is to use underscores:

class Student:
   def __init__(self, name, age, grade):
   self.name = name # public
   self._age = age # protected
   self.__grade = grade # private

As the above code shows, we can define a protected attribute with a single leading underscore. This is just a convention. We can still use it as a public member. But we should not do this. Following good programming conventions will make our code more elegant and readable.

We define a private attribute with double-leading underscores. This mechanism is beyond convention. Python uses name mangling technique to ensure we won’t use a private member inappropriately.

5. Define Mixin Classes through Multiple Inheritance

In Python, a mixin is a class that is designed to add a specific behavior or set of behaviors to one or more other classes. It can provide a flexible way to add functionality to a class without modifying the class directly or making the inheritance relationship of subclasses complicated.

For example, we define a class ToDictMixin as follows:

class ToDictMixin:
    def to_dict(self):
        return {key: value for key, value in self.__dict__.items()}

Now, any other classes that need the converting to dictionary functionality can inherit this mixin class besides its original parent class:

class MyClass(ToDictMixin, BaseClass):
    pass

Python allows multiple inheritances. This is why we can use mixins. But here is a frequently asked question:

Under multiple inheritances, if two parent classes have the same methods or attributes, what will happen?

In fact, if two parent classes have the same method or attribute, the method or attribute in the class that appears first in the inheritance list will take precedence. This means that if you try to access the method or attribute, the version from the first class in the inheritance list will be used.

6. Use @property Decorator To Control Attributes Precisely

In Python, you can access and modify the attributes of an object directly, using dot notation.

However, it is generally a good object-oriented programming practice to access and modify the attributes of an object through their getters, setters, and deleters, rather than directly using dot notation. This is because using getters, setters, and deleters can give you more control over how the attributes are accessed and modified, and can make your code more readable and easier to understand.

For example, the following example defines a setter method for the attribute _score to limit the range of its value:

class Student:
    def __init__(self):
        self._score = 0

    def set_score(self, s):
        if 0 <= s <= 100:
            self._score = s
        else:
            raise ValueError('The score must be between 0 ~ 100!')
Yang = Student()
Yang.set_score(100)
print(Yang._score)
# 100

It works as expected. However, the above implementation seems not elegant enough.

It would be better if we can modify the attribute like a normal attribute using dot notation but still has the limitations, rather than having to call the setter method like a function.

This is why Python provides a built-in decorator named @propery. Using it, we can modify attributes using dot notation directly. It will improve the readability and elegance of our code.

Now, let’s change the previous program a bit:

class Student:
    def __init__(self):
        self._score = 0

    @property
    def score(self):
        return self._score

    @score.setter
    def score(self, s):
        if 0 <= s <= 100:
            self._score = s
        else:
            raise ValueError('The score must be between 0 ~ 100!')

    @score.deleter
    def score(self):
        del self._score

Yang = Student()

Yang.score=99
print(Yang.score)
# 99

Yang.score = 999
# ValueError: The score must be between 0 ~ 100!

7. Use Class Methods in Classes

Methods in a Python class can be instance-level or class-level, similar to attributes.

An instance method is a method that is bound to an instance of a class. It can access and modify the instance data. An instance method is called on an instance of the class, and it can access the instance data through the self parameter.

A class method is a method that is bound to the class and not the instance of the class. It can’t modify the instance data. A class method is called on the class itself, and it receives the class as the first parameter, which is conventionally named cls.

Defining a class method is very convenient in Python. We can just add a built-in decorator named @classmethod before the declaration of the method.

Let’s see an example:

As the above code shows, the get_from_string() is a class method whose first parameter is the class itself, so it can be invoked by the class name directly.

However, the s2=Student.set_nickname('yang') statement causes a TypeError. Because the set_nickname() is an instance method. So it must be called by an instance of the class rather than the class itself.

8. Use Static Methods in Classes

In addition to instance methods and class methods, there is another special type of method called a static method.

A static method is not bound to the instance or the class and doesn’t receive any special parameters. A static method can be called on the class itself or on an instance of the class.

The following code implements a class named Student including a static method:

We can see that the static method is defined inside the class, but it doesn’t have access to the instance data or the class data. It can be called on the class itself or on an instance of the class.

Some common uses of static methods include utility functions that perform tasks such as formatting data or validating input, and methods that provide a logical grouping of related functions, but do not need to modify the state of the instance or the class.

Therefore, a good OOP practice is:

Define a function as a static method within a class if this function’s logic is closely related to the class.

9. Separate __new__ and __init__: Two Different Python Constructors

When it comes to constructors of Python classes, all of us know the __init__ method, but fewer developers know the __new__ method.

The difference between these two methods is simple:

  • The __new__() method creates a new instance.
  • The __init__() method initialises that instance.

The __new__ method is a special method that is called before the __init__ method. It is responsible for creating the object and returning it. The __new__ method is a static method, which means that it is called on the class, rather than on an instance of the class.

In general, we don’t need to override the __new__ method. Because in most cases, the default implementation of it is sufficient.

If you need some more precise control of your classes, overriding the __new__ method is also a good choice.

For example, if you would like to apply the singleton pattern to a Python class, you may implement it as follows:

The above program overrides the __new__ method to make sure there is only one instance of all time. Therefore the s1==s2 statement is True.

10. Use __slots__ for Better Attributes Control

As a dynamic language, Python has more flexibility than other languages such as Java or C++. When it comes to OOP, a big advantage is that we can add extra attributes and methods into a Python class at runtime.

For example, the following code defines a class named Author. We can add an extra attribute age into an instance of this class:

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

me = Author('Yang')

me.age=29
print(me.age)
# 29

However, in some cases, allowing the users of a class to add additional attributes at runtime is not a safe choice. Especially when the user has no idea about the implementation of the class. Not to mention that it may invoke out-of-memory issues if a user adds too many extra attributes.

Therefore, Python provides a special built-in attribute — __slots__ .

We can add it to a class definition and specify the names of all valid attributes of the class. It works as a whitelist.

Now, let’s change the previous program a bit:

class Author:
    __slots__ = ['name','hobby']
    def __init__(self,name):
        self.name = name

me = Author('Yang')

me.hobby='writing'

me.age=29
# AttributeError: 'Author' object has no attribute 'age'

As the above code shows, an AttributeError was raised when adding the age attribute into an instance at runtime, because the “whitelist” made by __slot__ didn’t allow it.

Conclusion

The OOP features of Python can help us create more organized, efficient, and scalable code. If we can use it properly, it will make our code much easier to maintain and extend.

Thanks for reading. ❤️

Join Medium through my referral link to access millions of great articles that spark bright ideas, answer big questions, and fuel bold ambitions every day:

Python
Programming
Object Oriented
Oop
Technology
Recommended from ReadMedium