A simple way to have many class constructors in Python
Something missing in Python, which you may be envious of if you are coming from programming languages like C++ or Crystal, is the ability to overload a function or method, especially a class constructor which is the focus of this article. I will show you a simple and elegant way to overcome this issue and avoid a pitfall I see in some code around.
For our example, we will consider a simple Point class that will be a representation of a vector in two dimensions. It can be instantiated in three different ways:
Pass the two coordinates x and y directly
Pass an array of values
Pass a dict (or map in C++ jargon)
Here is what it can look at in C++. Honestly, my C++ is rusty, so I asked ChatGPT to help me with this part. π«£
#include <iostream>
#include <vector>
#include <map>
using namespace std;
class Point {
private:
int x;
int y;
public:
// Constructor with two separate parameters for x and y
Point(int x, int y) {
this->x = x;
this->y = y;
}
// Constructor with a vector having x and y
Point(vector<int> vec) {
if (vec.size() != 2) {
throw invalid_argument("The vector should have only two elements represeting x and y coordinates.");
}
x = vec[0];
y = vec[1];
}
// Constructor with a dict (std::map in C++)
Point(map<string, int> dict) {
x = dict["x"];
y = dict["y"];
}
// Method to display the Point
void displayPoint() {
cout << "Point: (" << x << ", " << y << ")" << endl;
}
};
int main() {
Point p1(1, 2);
p1.displayPoint();
vector<int> vec = {3, 4};
Point p2(vec);
p2.displayPoint();
map<string, int> dict = {{"x", 5}, {"y", 6}};
Point p3(dict);
p3.displayPoint();
return 0;
}
Ok, now how can we reproduce this in Python? I often see junior developers ending with a solution like the following ones.
class Point:
# try to accept all parameters and then parse them in the init code
def __init__(self, *args, **kwargs):
...
class Point:
# a variant of the first one, where the different structures supported
# are explicitly named
def __init__(self, x=None, y=None, array=None, _dict=None):
...
The issue with this is that the initialization code becomes difficult to read because it handles too many cases, and then difficult to debug/maintain. Remember that a developer spends more time reading a code than writing it, so we need to empathize with other developers who will read our code, and be clear and concise.
An elegant solution to this problem is to use class methods. I like to use the pattern from_
to specify a different instantiation method. Here is what our class will look like.
from dataclasses import dataclass
@dataclass
class Point:
x: int
y: int
@classmethod
def from_array(cls, array: list[int] | tuple[int, int]) -> 'Point':
if len(array) != 2:
raise ValueError("your array must have two elements representing x and y coordinates")
return cls(*array)
@classmethod
def from_dict(cls, dictionary: dict[str, int]) -> 'Point':
return cls(**dictionary)
if __name__ == "__main__":
point = Point(1, 2)
print(point)
point = Point.from_array([1, 2])
print(point)
point = Point.from_dict({"x": 3, "y": 4})
print(point)
Notes:
If you donβt know dataclasses, it is a neat feature from Python 3.7 allowing you to write concise class constructors in Python, leveraging typing. I strongly encourage you to use it, especially for classes just holding data and not doing anything special with them.
I use typing introduced in Python 3.5. I highly encourage you to use it to ease the code lecture for you and your fellow programmers. Also, static-type checkers like Mypy (although Iβm not really a fan since it is too intrusive in my opinion) or IDEs can leverage them to spot some errors.
For some with a sharp eye, you can replace the
'Point'
return type withSelf
if you use Python 3.11 or higher.
By the way, if you want to switch between different Python versions to play with different features of the language, I have a tutorial that can help you. π
Pyenv - manage different Python versions
The same issue I see with class constructor also appears with function overriding. Again, unlike languages like C++ or Crystal, there is no way to create different versions of the same function in Python. Something I often see, even from seasoned developers is to leverage overload from the typing module to end up with something like the following:
from typing import overload
@overload
def my_function_accepting_everything(a: int) -> int:
pass
@overload
def my_function_accepting_everything(a: str) -> str:
pass
def my_function_accepting_everything(a):
if isinstance(a, int):
return a + 1
elif isinstance(a, str):
return a.upper()
else:
return None
Again, like before, my point is that the code becomes a monster difficult to read. Just create different functions with clear names. π
This is all for this tutorial, hope you enjoy reading it. Take care of yourself and see you soon! π