OOP Python Overloading the '__init__' method

129 views Asked by At

I am porting a project from Java to Python and there is a class with multiple constructors. I'm trying to port that same idea to python, in a pythonic way. I've recently been informed of the typing.overload decorator, but I can't seem to coerce the code to behave the way I wish. For example, if a Java class had the following constructor signatures:

public Foo(){}

public Foo(int number) {}

publid Foo(String b, float number) {}

public Foo(float number) {}

In Python, I tried to replicate that behavior with the following class definition:

class Foo():

    @typing.overload
    def __init__(self) -> None: 
        ...
    @typing.overload
    def __init__(self, number: int) -> None: 
        ...
    @typing.overload
    def __init__(self, string: str, number: float) -> None: 
        ...
    @typing.overload
    def __init__(self, number: float) -> None: 
        ...
    
    def __init__(self, string: str = None, number: typing.Union[int,float] = None) -> None:
        if isinstance(string, str):
            print(f'String string: {string}')
        elif isinstance(string, int):
            print(f'String int: {string}')
        elif isinstance(string, float):
            print(f'String float: {string}')
        elif isinstance(string, bool):
            print(f'String bool: {string}')
        else:
            print(f'String None')

        if isinstance(number, str):
            print(f'Number string: {number}')
        elif isinstance(number, int):
            print(f'Number int: {number}')
        elif isinstance(number, float):
            print(f'Number float: {number}')
        elif isinstance(number, bool):
            print(f'Number bool: {number}')
        else:
            print(f'Number None')

if __name__ == '__main__':
    test1 = TestClass(1.0)
    print(f'\n')
    test2 = TestClass(6)
    print(f'\n')
    test3 = TestClass('Test 3', 3.0)
    print(f'\n')
    test4 = TestClass('Test 4', True)

With the four instantiations, what I was expecting to see was:

String None
Number float: 1.0 

String None
Number int: 6

String string: Test 3
Number float: 3.0

String string: Test 4
Number bool: True

What I got was:

String float: 1.0
Number None

String int: 6
Number None

String string: Test 3
Number float: 3.0

String string: Test 4
Number int: True

I realize that I won't necessarily get a 1:1 ability with Java's strict typing and Python's overloads only being there for type-checkers and no runtime enforcement. I just seem to be fundamentally missing something with how to actually implement an overloaded init the way I was envisioning.

2

There are 2 answers

1
CraneStyle On

I appreciate the responses, I think the original comment of using Class methods is an overall better approach.

I'm constrained in this project because the code is for beginner level programmers and it has to maintain a slight bit of parity between a Python, Java, and C++ implementation.

In the end, I just used forced kwargs to implement different constructor signatures. Not the pythonic way of doing things, but it ended up working.

class Foo():

    @typing.overload
    def __init__(self) -> None: 
        ...
    @typing.overload
    def __init__(self, *, number: int) -> None: 
        ...
    @typing.overload
    def __init__(self, *, string: str, number: float) -> None: 
        ...
    @typing.overload
    def __init__(self, *, number: float) -> None: 
        ...
    
    def __init__(
        self, 
        *, 
        string: typing.Optional[str] = None, 
        number: typing.Optional[typing.Union[int,float]] = None
    ) -> None:
1
jsbueno On

Firdt thing to have in mind: typing in Python is an optional step,which runs prior to the code being actually compiled, and have no (direct) influence on it (although one's CI'pipeline or commit hooks might force typing to work - I'd advise otherwise).

So, despite typing, Python is essentialy a dynamic language. It can find out about the argument you passed if you expliced pass it as a named argument. Otherwise it will pick the arguments in order.

Otherwise, you put the guard/converting code as code to do a runtime check and assignment of your argument, before running the function body.

There are ways to create decorators for disatching a call to different functions as well - and that might keep your code 1:1 more like the java code (with several redundant implementations of the same function). The standarlibrary contains the functools.singledispatchmethod decorator which can do that for a single parameter. AFAIK you have to use a 3rdy party package, or roll your own decorator if you need to that for more than one argument.

And finally, reiterating, and most important: unnamed arguments are served to a function in the order they are passed, regardless of type. In your example, the first argument will always end up in the parameter named string. However, order can be enforced by simply naming the arguments when calling a method, for example TestClass(number=1.0) will leave string with the default value of None and the 1.0 will be used for the number parameter.

All in all, and typing.overload apart, the "Pythonic" way of getting either a string, a float or an int as the first argument for a method is to check and cast if needed the argument at runtime:

class Foo:
    def __init__(self, argument: str|int|float|None = None) -> None:
        match argument:
            case str:
                string = argument
                number = None
            case int|float:
                number = argument
                string = None
            case None:
                number = string = None
            case _:
                raise TypeError()
                
        # the body with your ifs and prints can then 
        # be kept as you were experimenting:
           
        if isinstance(string, str):
            print(f'String string: {string}')
        ...

Even the use of typing.overload is just to make more complex kinds of parameter acceptable types more visible: it has no effect at runtime, as said, but also, no effect and no advantage at type-checking time, over a simple type-union as in this example. If it can makes things more readable, its nice to use it. If it just adds unneeded boilerplate, as in your example, you are just using Python as if it where Java, and it is not.