8

I know the title is probably a bit confusing, so let me give you an example. Suppose you have a base class Base which is intended to be subclassed to create more complex objects. But you also have optional functionality that you don't need for every subclass, so you put it in a secondary class OptionalStuffA that is always intended to be subclassed together with the base class. Should you also make that secondary class a subclass of Base?

This is of course only relevant if you have more than one OptionalStuff class and you want to combine them in different ways, because otherwise you don't need to subclass both Base and OptionalStuffA (and just have OptionalStuffA be a subclass of Base so you only need to subclass OptionalStuffA). I understand that it shouldn't make a difference for the MRO if Base is inherited from more than once, but I'm not sure if there are any drawbacks to making all the secondary classes inherit from Base.

Below is an example scenario. I've also thrown in the QObject class as a 'third party' token class whose functionality is necessary for one of the secondary classes to work. Where do I subclass it? The example below shows how I've done it so far, but I doubt this is the way to go.

from PyQt5.QtCore import QObject

class Base:
    def __init__(self):
        self._basic_stuff = None

    def reset(self):
        self._basic_stuff = None


class OptionalStuffA:
    def __init__(self):
        super().__init__()
        self._optional_stuff_a = None

    def reset(self):
        if hasattr(super(), 'reset'):
            super().reset()
        self._optional_stuff_a = None

    def do_stuff_that_only_works_if_my_children_also_inherited_from_Base(self):
        self._basic_stuff = not None


class OptionalStuffB:
    def __init__(self):
        super().__init__()
        self._optional_stuff_b = None

    def reset(self):
        if hasattr(super(), 'reset'):
            super().reset()
        self._optional_stuff_b = None

    def do_stuff_that_only_works_if_my_children_also_inherited_from_QObject(self):
        print(self.objectName())


class ClassThatIsActuallyUsed(Base, OptionalStuffA, OptionalStuffB, QObject):
    def __init__(self):
        super().__init__()
        self._unique_stuff = None

    def reset(self):
        if hasattr(super(), 'reset'):
            super().reset()
        self._unique_stuff = None
juanpa.arrivillaga
  • 88,713
  • 10
  • 131
  • 172
mapf
  • 1,906
  • 1
  • 14
  • 40
  • 2
    To me, having things like `if hasattr(super` or `do_stuff_that` indicates that inheritance should be used. – alex_noname Dec 19 '20 at 13:13
  • That is a good point. I did that because if I would only call `super().reset()` and get to the end of the MRO of `ClassThatIsActuallyUsed` it would throw an error because `QObject` doesn't have that method. – mapf Dec 19 '20 at 13:17
  • 1
    The other advantage of subclassing `Base` and `QObject` is of course that the editor won't mark unresolved references in the secondary classes. – mapf Dec 19 '20 at 13:23
  • 4
    Classes like `OptionalStuff`, which provide extra functionality, are known as *mixin* classes, and inherit from `object` so that they may be used in a wide variety of contexts and avoid the *diamond problem* of inheritance that can occur when class `D` inherits from classes `C` and `B`, which are both subclasses of class `A`. In short, the answer is, "No." – Booboo Dec 19 '20 at 21:17
  • I see, thank you @Booboo! I've read about the diamond problem at some point, but forgot about it again.. maybe that was the reason I set up the classes the way I did. I'm revising my code right now and thought there must be a way to improve this. Still I don't really like how I handle the whole `if hasattr(super(), 'reset')` thing. Is there a better way to do this and make sure `reset` is called for all (grand)parent classes? In a specific order even? What if you need one method called in one oder and another in a different one? Because there is only one MRO I guess that is impossible? – mapf Dec 19 '20 at 21:30
  • I am really not sure what you are trying to accomplish. *If* you were following the philosophy of mixin classes, your class `OptionalStuff` would probably only implement one or two methods at most *and* these methods would be distinct from the other methods in other mixin classes you might be "mixing in" or the primary base class you are inheriting from. Therefore the notion of `OptionalStuff`'s `reset` method being concerned with any other class having a `reset` method is contrary to the normal usage. At least that is my understanding. (more...) – Booboo Dec 19 '20 at 23:35
  • You are inheriting a mixin class for code reuse as opposed to expressing an *IS-A* relationship. Perhaps you want to follow some other model than the *mixin* class model but the waters get deep. – Booboo Dec 19 '20 at 23:36
  • You should take a look at https://en.wikipedia.org/wiki/Composition_over_inheritance. Inheritance is too often used in places where simple composition is more appropriate. When you want to know if you need to choose inheritance over composition, ask yourself the question : is class B really a special case of class A ? (like Student is a special case of Person). If yes you can choose inheritance. Otherwise prefer composition (here ClassThatIsActuallyUsed could be composed of OptionalStuff). – Ismael EL ATIFI Dec 24 '20 at 22:01
  • Hi @IsmaelELATIFI, thanks for the advice! In my case however, they really are special cases of the base class. Concretely, the base class is a kind of display class that can plot stuff, and the optional functionalities are things like being able to zoom and pan, or having interactivity where you would be able to open context menus e.g. – mapf Dec 25 '20 at 22:01
  • Thanks for your insight @Booboo! I explained above what I am using the mixin classes for. The reason they also come with a `reset` method is because they also come with a couple of individual attributes that I want to, well, reset, if the method is called. Maybe it makes sense to have some kind of data structure like a `dict` to store all attributes and their default values, which can be appended by the mixins, and then have the base class work with that data structure when `reset` is called, so the minxins don't need that method. I don't know. – mapf Dec 25 '20 at 22:07
  • It looks like you can use MetaClass here "What if you need one method called in one order and another in a different one?", but I think it can also be achieved using normal inheritance but the solution will be ugly, and you don't wanna do that. I will change my answer to explain it better. – wetler Dec 26 '20 at 06:56

2 Answers2

2

What I can get from your problem is that you want to have different functions and properties based on different condition, that sounds like good reason to use MetaClass. It all depends how complex your each class is, and what are you building, if it is for some library or API then MetaClass can do magic if used rightly.

MetaClass is perfect to add functions and property to the class based on some sort of condition, you just have to add all your subclass function into one meta class and add that MetaClass to your main class

From Where to start

you can read about MetaClass here, or you can watch it here. After you have better understanding about MetaClass see the source code of Django ModelForm from here and here, but before that take a brief look on how the Django Form works from outside this will give You an idea on how to implement it.

This is how I would implement it.

#You can also inherit it from other MetaClass but type has to be top of inheritance
class meta_class(type):
    # create class based on condition

    """
    msc: meta_class, behaves much like self (not exactly sure).
    name: name of the new class (ClassThatIsActuallyUsed).
    base: base of the new class (Base).
    attrs: attrs of the new class (Meta,...).
    """

    def __new__(mcs, name, bases, attrs):
        meta = attrs.get('Meta')
        if(meta.optionA){
            attrs['reset'] = resetA
        }if(meta.optionB){
            attrs['reset'] = resetB
        }if(meta.optionC){
            attrs['reset'] = resetC
        }
        if("QObject" in bases){
            attrs['do_stuff_that_only_works_if_my_children_also_inherited_from_QObject'] = functionA
        }
        return type(name, bases, attrs)


class Base(metaclass=meta_class): #you can also pass kwargs to metaclass here

    #define some common functions here
    class Meta:
        # Set default values here for the class
        optionA = False
        optionB = False
        optionC = False


class ClassThatIsActuallyUsed(Base):
    class Meta:
        optionA = True
        # optionB is False by default
        optionC = True

EDIT: Elaborated on how to implement MetaClass.

wetler
  • 374
  • 2
  • 11
  • Thank you for the suggestion! I have never used meta classes before, but I'm happy to get familiar with the concept. I've explained in the comments under my post what I want to use the classes for specifically. Maybe with that information you could elaborate a bit more? – mapf Dec 25 '20 at 22:10
  • Functionality like this can be implemented more simply with `___init_subclass__` https://docs.python.org/3/reference/datamodel.html#object.__init_subclass__ – VPfB Dec 26 '20 at 16:28
  • @VPfB could you maybe explain how that would work in this case? – mapf Dec 26 '20 at 17:03
  • @mapf Slightly modified `meta_class.__new__` will become `Base.__init_subclass__`. No explicit metaclasses code will be necessary. `__init_subclass__` was introduced exactly for this reason: customizing of class creation _without_ metaclasses. See PEP487. I could post an answer with code if you want, but your question was little bit different. – VPfB Dec 26 '20 at 17:14
  • @VPfB thanks! Yes, it would be great if you could do that. – mapf Dec 26 '20 at 17:58
  • It depends on the use case. it can be done with both method, but can get complex if you have to do more work on class creation, using MetaClass you can inherit from different MetaClass and can separate your code with one another, but if you are looking to add 1 or 2 properties or method on class creation then use __init_subclass__ @VPfB – wetler Dec 26 '20 at 18:47
  • @wetler I agree that a metaclass is extremely powerfull. That's also the reason it must be used with care. – VPfB Dec 26 '20 at 20:02
2

Let me start with another alternative. In the example below the Base.foo method is a plain identity function, but options can override that.

class Base:
    def foo(self, x):
        return x

class OptionDouble:
    def foo(self, x): 
        x *= 2  # preprocess example
        return super().foo(x)

class OptionHex:
    def foo(self, x): 
        result = super().foo(x)
        return hex(result)  # postprocess example

class Combined(OptionDouble, OptionHex, Base):
    pass

b = Base()
print(b.foo(10)) # 10

c = Combined()
print(c.foo(10)) # 2x10 = 20, as hex string: "0x14"

The key is that in the definition of the Combined's bases are Options specified before the Base:

class Combined(OptionDouble, OptionHex, Base):

Read the class names left-to right and in this simple case this is the order in which foo() implementations are ordered. It is called the method resolution order (MRO). It also defines what exactly super() means in particular classes and that is important, because Options are written as wrappers around the super() implementation

If you do it the other way around, it won't work:

class Combined(Base, OptionDouble, OptionHex):
    pass

c = Combined()
print(Combined.__mro__)
print(c.foo(10))  # 10, options not effective!

In this case the Base implementation is called first and it directly returns the result.

You could take care of the correct base order manually or you could write a function that checks it. It walks through the MRO list and once it sees the Base it will not allow an Option after it.

class Base:
    def __init_subclass__(cls, *args, **kwargs):
        super().__init_subclass__(*args, **kwargs)
        base_seen = False
        for mr in cls.__mro__:
            if base_seen:
                if issubclass(mr, Option):
                    raise TypeError( f"The order of {cls.__name__} base classes is incorrect")
            elif mr is Base:
                base_seen = True

    def foo(self, x): 
        return x

class Option:
    pass

class OptionDouble(Option):
    ... 

class OptionHex(Option):
    ... 

Now to answer your comment. I wrote that @wettler's approach could be simplified. I meant something like this:

class Base:
    def __init_subclass__(cls, *args, **kwargs):
        super().__init_subclass__(*args, **kwargs)
        print("options for the class", cls.__name__)
        print('A', cls.optionA)
        print('B', cls.optionB)
        print('C', cls.optionC)
        # ... modify the class according to the options ...

        bases = cls.__bases__
        # ... check if QObject is present in bases ...

    # defaults
    optionA = False
    optionB = False
    optionC = False


class ClassThatIsActuallyUsed(Base):
    optionA = True
    optionC = True

This demo will print:

options for the class ClassThatIsActuallyUsed
A True
B False
C True
VPfB
  • 14,927
  • 6
  • 41
  • 75
  • Thanks a lot! All these meta class functionality is not super intuitive to me though, I'll need some time to get comfortable with it. – mapf Dec 26 '20 at 21:49
  • i never looked into __init_subclass__ because I was already used of using MetaClass by the time it came but I like this approach. One small thing can I check what other class is inherited in ClassThatIsActuallyUsed like QObject as in question – wetler Dec 27 '20 at 18:31