-3

I'm in the process of writing a library in Python, and I've run into a design problem concerning the use of polymorphism.

I have an ABC with abstract method 'foo':

class A(ABC):
    @abstractmethod
    def foo(arg1: int, arg2: int) -> bool:
        pass 

Subclasses of A are part of the public API, but A and its subclasses are not intended to be subclassed by the user.

Most subclasses will implement 'foo' with the same signature as the ABC, but some subclasses will have a few extra default arguments:

class B(A):
    def foo(arg1: int, arg2: int, arg3: str = "bar") -> bool:
        return True


class C(A):
    def foo(arg1: int, arg2: int, arg3: str = "bar", arg4: float = 0.42) -> bool:
        return True

The types of the default arguments are well-defined, ie "arg3", when in use, will always be str, "arg4" will always be float, etc. Also the corresponding default values are stable, ie "arg3" is always "bar" by default, etc.

Now, some other internal classes want to call the foo method on a bunch of subclasses of A at once. Using isinstance checks works, but is of course rather cumbersome:

class D:
    def call_foo_on_child_classes(child_classes: list[A]) -> Iterator[bool]:
        for child_cls in child classes:
            if isinstance(child_cls, B):
                yield child_cls.foo(1, 2, "a")
            elif isinstance(child_cls, C):
                yield child_cls.foo(1, 2, "a", 0.42)
            # etc

Having the child classes of A take *args **kwargs would also work, but seems like an equally bad solution, because method 'foo' is also part of the public API.

I came up with the following idea, and wanted to check if this is a valid way to go about it, or if I should fundamentally rethink the design of my classes instead.

To the ABC, I add a private abstractmethod, that gathers up all the default arguments of the child classes:

class A(ABC):
    @abstractmethod
    def foo(arg1: int, arg2: int) -> bool:
        pass

    @abstractmethod
    def _call_foo(arg1: int, arg2: int, arg3: str = "bar", arg4: float = 0.42) -> bool:
        pass

And the child classes would implement it as such:

class B(A):
    def foo(arg1: int, arg2: int, arg3: str = "bar") -> bool:
        return True

    def _call_foo(arg1: int, arg2: int, arg3: str = "bar", arg4: float: 0.42) -> bool:
        return self.foo(arg1, arg2, arg3)


class C(A):
    def foo(arg1: int, arg2: int, arg3: str = "bar", arg4: float = 0.42) -> bool:
        return True

    def _call_foo(arg1: int, arg2: int, arg3: str = "bar", arg4: float = 0.42) -> bool:
        return self.foo(arg1, arg2, arg3, arg4)

#etc.

And now class D can simply call the child classes of A polymorphically, while the public API of the child classes stays uncluttered:

class D:
    def call_foo_on_child_classes(child_classes: list[A]) -> Iterator[bool]:
        for child_cls in child classes:
            yield child_cls._call_foo(1, 2, "a", 0.41)

Is this advisable?

5
  • 6
    I don't know how often I told this to askers, but: questions asking exclusively with nonsensical terms A, B, C, foo, bar ... make the very wrong assumption that questions about OOP and polymorphism can be sensibly answered by seeing the syntax alone, and not semantics - sorry, but this is wrong. Fullstop. I heavily recommend to rewrite this question, using domain terms and explain the use case for this foo method with different numbers of parameters. Then we can discuss how to solve this issue for this use case. I will be happy to revoke my close vote afterwards.
    – Doc Brown
    Commented Apr 7, 2024 at 20:46
  • 1
    @docbrown i agree, but it does not matter if the FooManipulator grizzlomorphs Bars as long as its explained why (i.e. Domain terms might as well be isomorphically replaced). So yes, semantics required, but the syntax can be given as A,B,c,foobar.
    – sfiss
    Commented Apr 8, 2024 at 4:21
  • 1
    @sfiss: there are currently no domain terms. no "isomorph replacement", and no explanation of "why" in the question. And terms like A,B,C, foobar are not suited as an isomorphic replacement.
    – Doc Brown
    Commented Apr 8, 2024 at 5:42
  • ... ok, I admit, there is one way to answer this question: the way Flater did, essentially saying "we cannot answer this question because {long-winded-explanation} ...". Unfortunately, this makes it impossible for you to rewrite the question in a sensible way without invalidating Flater's answer.
    – Doc Brown
    Commented Apr 8, 2024 at 13:14
  • 1
    If you want an OOP design, promote _call_foo() to the public API that everyone implements. Your subclasses don't have to use every parameter, but they have to be prepared to receive it. Alternatively, if you want different signatures for B and C, then get rid of the A base class and instead use a union type B | C.
    – amon
    Commented Apr 8, 2024 at 16:08

2 Answers 2

6

The very core of what you're trying to achieve goes against the core principle of polymorphism: interchangeability.

The concept of polymorphism is having the ability to write code that handles an object of type A and not having to care whether the actual object is of type A or any of its derived types (B, C, or any of their derived types recursively).
Having different signatures for different derived types breaks the ability to polymorphically treat these as indiscriminate A objects.

Essentially, polymorphism should allow you to write code that does this (I'm switching to C# because I'm more familiar with the syntax, but this point is not language-specific):

public void DoSomething(A myA)
{
    A.Foo(myValue1, myValue2, myValue3);
}

And this code should work for all of these use cases:

DoSomething(new A());
DoSomething(new B());
DoSomething(new C());

Needing to know the specific subtype being used, while the language does allow you to do so, is polymorphism abuse and indicative of bad design and something for which inheritance was not the right tool for the job.

Without repeating very common explanations found online in many guides and blog posts, look up what a Liskov Substitution Principle (LSP) violation is, and what an Open-Closed Principle (OCP) violation is. Your if isinstance(child_cls, B) is an instance of an LSP violation, and creating a chain of if is subtype checks is an OCP violation (the two principles are spiritually linked because they touch on similar topics.
This is precisely the problem you are creating by designing your derived types in a way that a consumer needs to know whether it's B or C.


The problem here is that I can't give you the answer, because I can't tell what it is that you're trying to achieve in practice. You've told me how you want to implement your code, you've not told me why, or what you're hoping to achieve.

The only thing I can say for a fact here is that what you're trying to achieve is not achievable using polymorphism, by the very core definition of what polymorphism is. You need to go back to the drawing board on this one, research OOP concepts (I suggest revisiting what polymorphism is, and looking up SOLID guidelines and what to avoid), and then use that newfound knowledge to come up with a better solution.

3

At first sight, your object hierarchy breaks the Liskov Substitution Principle (the "L" in SOLID), because I cannot substitute an instance of B for an instance of A due to the differing signature of foo. Rather than wrapping this up in _call_foo, just make the public API take the union of all the parameters (i.e. make _call_foo the public API) and you're good - it's perfectly valid for subclasses to not use all the parameters on some functions.

1
  • it's perfectly valid for subclasses to not use all the parameters on some functions If the consumer gives you the values, and the derived class willfully discards them, it's very questionable if that is a desirable implementation from the POV of a consumer who is handling the base type. If my A is really an A, it uses that third param, but if it's a B, it discards it? That sounds like a bad idea. Though I can't exclude that there are fringe cases where this might make sense, I'm hard-pressed to allow that without a very concrete justification as to why this isn't polymorphism abuse.
    – Flater
    Commented Apr 9, 2024 at 0:15

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.