3

I've been running into a weird little smell in my python code lately and I think it has something to do with parallel inheritance. Here is a small example I concocted:

class DogHabits:
    def __init__(self):
        self.habits = ['lick butt']

class GermanShepherdHabits(DogHabits):
    def __init__(self):
        super().__init__()
        self.habits.extend(['herd sheep'])

class LabradorHabits(DogHabits):
    def __init__(self):
        super().__init__()
        self.habits.extend(['hunt', 'pee on owner'])

class Dog:
    def __init__(self):
        self.type = 'generic_dog'
        self.my_habits = DogHabits()

    def do_stuff(self):
        for habit in self.my_habits.habits:
            print(habit)

class GermanShepherd(Dog):
    def __init__(self):
        self.type = 'german shepherd'
        self.my_habits = GermanShepherdHabits()

class Labrador(Dog):
    def __init__(self):
        self.type = 'labrador'
        self.my_habits = LabradorHabits()

if __name__ == "__main__":
    german_shepherd = GermanShepherd()
    print('\n{}'.format(german_shepherd.type))
    german_shepherd.do_stuff()


    labrador = Labrador()
    print('\n{}'.format(labrador.type))
    labrador.do_stuff()

I have a generic dog class from which concrete dog implementations inherit. Every dog class (including the generic/abstract one) has a set of habits, itself represented by another class hierarchy for the habits.

I am annoyed by the fact that I have to have both hierarchies exactly the same at all times. Furthermore the inheritance between the DogHabits is useful within the habits hierarchy, but it is not useful within the dogs hierarchy, as I need to instantiate a separate habits object for each class in the dog hierarchy.

What is the antidote to this? I may want to add many implementations of the dog class, and updating the corresponding habits hierarchy sounds tedious and smells bad...

user32882
  • 5,094
  • 5
  • 43
  • 82
  • 1
    This is not a python problem. This is a design smell not a code smell. The fundamental flaw is that you're combining design and coding. Back up to the design phase, write some UML and solve the problem before writing any code. – nicomp Jun 02 '20 at 14:13
  • Good point.... but now the code is already written unfortunately – user32882 Jun 02 '20 at 14:16
  • You can set the base habits in Dog.__init__, then extend them in the subclass's init method, and just get rid of the classes for habits, assuming that the hierarchy of habits represents the hierarchy of dogs exactly. – b9s Jun 02 '20 at 14:26
  • It is not really clear why there is any class other than a basic ``Dog``. Does the actual use-case necessitate them? What are the constraint that make you think this complexity is needed? None of the classes produce instances that hold state which would differentiate instances. ``DogHabits`` appears to be just a complicated ``list``. Since ``Dog`` instances already have a ``type`` field, there is no need to have subclasses for each type. Just having ``Dog(type: str, habits: list)`` seems more appropriate. – MisterMiyagi Jun 02 '20 at 14:48
  • In the actual use case the `DogHabits` are dict mappings which map the arguments of each concrete dog-like `__init__` method to an appropriate cast type. The arguments to instantiate each object are read from an ini file as strings. Each dog-like implementation must have such a mapping, and the concrete dog implementations must inherit the mapping of the parent dog class as they also are instantiated with some of the same elements... – user32882 Jun 02 '20 at 14:56
  • 1
    "but now the code is already written unfortunately " - makes no difference whatsoever. You can still go back and properly design it. – nicomp Jun 02 '20 at 15:54
  • 1
    @nicomp That's what I'm trying to do by asking this question and getting feedback from people dude. Let's not go in circles here please. – user32882 Jun 02 '20 at 15:55
  • @nicomp *write some UML* for design? Now that's a smell all on its own, innit? At least for Python. Not that I begrudge a nice *Communication Diagram* as **documentation, once you have a stable design**. – JL Peyret Jun 03 '20 at 03:49
  • 1
    @JLPeyret No, a UML class diagram for design is not a smell. – nicomp Jun 03 '20 at 10:33
  • @nicomp what does a UML class diagram for meta classes even look like? Chepner’s answer is also based on it. How much time are you going to spend wondering about that? And like most Java /UML folk class diagrams immediately come to your mind but they show static behavior only, which can more easily be read from code. Agreed, in Java, at 1 file per class each of which needs adding to SCM, + all the Java boilerplate, might be worthwhile drawing lil diagrams first. Python-wise, UML’s a design smell alright. Get code working first, thru experiment, possibly w TDD, then looking good, then doc. – JL Peyret Jun 03 '20 at 14:36
  • @JLPeyret ". Get code working first, thru experiment, possibly w TDD, then looking good, then doc" -- You're completely absolutely wrong and you couldn't be more wrong and you're entitled to your opinion.Have a nice day. – nicomp Jun 03 '20 at 15:43

3 Answers3

5

This might be going too far afield, but I don't see the need for a separate DogHabits class. habits should be a class attribute, not an instance attribute, and could be set by __init_subclass__.

class Dog:
    habits = ['lick butts']

    def __init_subclass__(cls, habits=None, **kwargs):
        super().__init_subclass__(**kwargs)
        if habits is not None:
            cls.habits = cls.habits + habits


class GermanShepherd(Dog, habits=['herd sheep']):
    def __init__(self):
        self.type = 'german shepherd'


class Labrador(Dog, habits=['pee on owner']):
    def __init__(self):
        self.type = 'labrador'

type itself is also more of a class attribute than an instance attribute, as it's simply an (alternate) string representation of information already encoded by the class itself. Since you wouldn't append to an existing value, it's easier to just set the class attribute where necessary rather than going through __init_subclass:

class Dog:
    habits = ['lick butts']
    type = 'generic_dog'

    def __init_subclass__(cls, habits=None, **kwargs):
        super().__init_subclass__(**kwargs)
        if habits is not None:
            cls.habits = cls.habits + habits


class GermanShepherd(Dog, habits=['herd sheep']):
    type = 'german shepard'


class Labrador(Dog, habits=['pee on owner']):
    type = 'labrador'


class BlackLabrador(Labrador):
    pass  # E.g. if you are happy with inheriting Labrador.type
chepner
  • 497,756
  • 71
  • 530
  • 681
  • Your answer is interesting. I'll give it a shot and let you know if it works on my actual use case... – user32882 Jun 02 '20 at 14:22
  • 2
    Make sure you're using the updated answer; (with `cls.habits = cls.habits + habits`), as the previous version was always updating `Dog.habits`, rather than creating a new list for each subclass. – chepner Jun 02 '20 at 14:24
1

This answer assumes that the DogHabits is much more complex than a mere list and is really worth a dedicated class with its own inheritance.

On a design point of view, I can see a first question on whether habits and type should be class or instance members. Here again, this answer assumes that there are reasons to make them instance members.

I would make Habits an inner class of Dogs and state in the class documentation that is can be customized by building a subclass of it in a subclass of Dogs:

 class Dog:
    class Habits:
        """Represents the habits of a Dog.

        It can be customized in a child class by creating in the subclass an
        inner class named Habits that would be a subclass of Dog.Habits
        """

        def __init__(self):
            self.habits = ['lick butt']

    def __init__(self, typ='generic_dog'):
        self.type = typ
        self.my_habits = self.__class__.Habits()

    def do_stuff(self):
        for habit in self.my_habits.habits:
            print(habit)


class GermanShepherd(Dog):

    class Habits(Dog.Habits):

        def __init__(self):
            super().__init__()
            self.habits.extend(['herd sheep'])

    def __init__(self):
        super().__init__('german shepherd')


class Labrador(Dog):

    class Habits(Dog.Habits):
        def __init__(self):
            super().__init__()
            self.habits.extend(['hunt', 'pee on owner'])

    def __init__(self):
        super().__init__('labrador')
Serge Ballesta
  • 143,923
  • 11
  • 122
  • 252
  • I appreciate your effort, but a nested class makes me very uncomfortable... I'd argue that having a parallel hierarchy is better ... – user32882 Jun 04 '20 at 07:05
1

IF habits need to a class attribute, rather than instance attributes, this may actually be a good use for metaclasses.

Habits need not be a simple list, it could be something else, as long as there is the notion of addition to previous and return new. (__add__ or __radd__ on a Habits class would do the trick I think)

class DogType(type):

    def __init__(cls, name, bases, attrs):
        """ this is called at the Dog-class creation time.  """

        if not bases:
            return

        #pick the habits of direct ancestor and extend it with 
        #this class then assign to cls.
        if "habits" in attrs:
            base_habits = getattr(bases[0], "habits", [])
            cls.habits = base_habits + cls.habits


class Dog(metaclass=DogType):
    habits = ["licks butt"]

    def __repr__(self):
        return f"My name is {self.name}.  I am a {self.__class__.__name__} %s and I like to {self.habits}"

    def __init__(self, name):
        """ dog instance can have all sorts of instance variables"""
        self.name = name

class Sheperd(Dog):
    habits = ["herds sheep"]

class GermanSheperd(Sheperd):
    habits = ["bites people"]

class Poodle(Dog):
    habits = ["barks stupidly"]

class StBernard(Dog):
    pass


for ix, cls in enumerate([GermanSheperd, Poodle, StBernard]):
    name = f"dog{ix}"
    dog = cls(name)
    print(dog)

output:

My name is dog0.  I am a GermanSheperd %s and I like to ['licks butt', 'herds sheep', 'bites people']
My name is dog1.  I am a Poodle %s and I like to ['licks butt', 'barks stupidly']
My name is dog2.  I am a StBernard %s and I like to ['licks butt']
JL Peyret
  • 10,917
  • 2
  • 54
  • 73
  • It's a creative/elegant solution, but in my use case my `Dog` happens also to be an ABC. If I try also setting it as a metaclass I get an error: `TypeError: metaclass conflict: the metaclass of a derived class must be a (non-strict) subclass of the metaclasses of all its bases` How would you solve that? – user32882 Jun 04 '20 at 07:16
  • You're solution appears to work on a simplified version of my use case and I am quite confident it will work on the final use case. I will therefore accept it. – user32882 Jun 04 '20 at 08:43