Search code examples
pythonoopduck-typing

How to write OOP consistent code with duck typing?


I'm having trouble in deciding on the placement of method in a python program, where it seems like the duck-typing approach I'm used to rely on is at odds with my OOP instincts.

To illustrate, suppose we have three classes: Hero, Sword and Apple. A hero can equip a sword and a hero can eat an apple.

If I were to follow my OOP gut, I think the code would look like this:

duckless.py

class Hero:
    def __init__(self):
        self.weapon = None
        self.inTummy = None

    def equip(self, weapon):
        weapon.define()
        print("I shall equip it.")
        self.weapon = weapon

    def eat(self, food):
        food.define()
        print("I shall consume it.")
        self.inTummy = food

class Sword:
    def define(self):
        print("'tis a shiny sword")

class Apple:
    def define(self):
        print("'tis a plump apple")

hero = Hero()
swd = Sword()
apl = Apple()

hero.equip(swd)
hero.eat(apl)

Which feels very intuitive and readable.

If I were to duck-type, however, I feel like the code would look something like this:

duckfull.py

class Hero:
    def __init__(self):
        self.weapon = None
        self.inTummy = None

    def onEquip(self):
        print("I shall equip it.")

    def onEat(self):
        print("I shall eat it.")

class Sword:
    def define(self):
        print("'tis a shiny sword")

    def equip(self, hero):
        self.define()
        hero.onEquip()
        hero.weapon = self


class Apple:
    def define(self):
        print("'tis a plump apple")

    def eat(self, hero):
        self.define()
        hero.onEat()
        hero.inTummy = self

hero = Hero()
swd = Sword()
apl = Apple()

swd.equip(hero)
apl.eat(hero)

The duck-typed code has the clear advantage that I can perform a try-except at any time to determine whether I'm performing a "legal" action:

try:
    apl.equip()
except AttributeError:
    print("I can't equip that!")

Which feels very pythonic, while the alternative would require me to perform dreaded type checks.

However, from an OOP standpoint, it feels weird to be that a sword is responsible for equipping itself, and that it receives a hero as a parameter. The act of equipping seems like an action performed by the hero, and as such, I feel the method should belong in the Hero class. The whole syntax of

def eat(self, hero):
    self.define()
    hero.onEat()
    hero.inTummy = self

Feels very alien.

Is either approach more pythonic? Is either more OOP consistent? Should I be looking at a different solution altogether?

Thanks in advance.


Solution

  • There is no clear-cut answer; it depends on what your classes do. It is not so horrible to check isinstance(weapon, Weapon) in your Hero.equip to check if the item is a weapon. Also, if you're going to involve both objects as in your second example, you can move more of the handling into the Hero:

    class Hero:
        def __init__(self):
            self.weapon = None
            self.inTummy = None
    
        def equip(self, weapon):
            print("I shall equip it.")
            self.weapon = weapon
    
    class Sword:
        def equip(self, hero):
            hero.equip(self)
    

    This may seem a bit strange, but it is not necessarily a bad thing to have a method on one class that just delegates to a related method on another class (as, here, calling sword.equip just calls hero.equip). You could also do it the other way around, and have Hero.equip call weapon.equip() or weapon.ready() or whatever, which will fail if the item isn't a weapon and so doesn't have such an attribute.

    The other thing is that you can still have duck-typing behavior in your first example, it's just that the error won't be raised until a later stage when you try to do something else with the weapon. Something like:

    hero.equip(apple)  # no error
    hero.weapon.calculateDamage() # AttributeError: Apple object has no attribute `damage`
    

    This may not be considered ideal, because you don't know you equipped an invalid weapon until later. But that's how duck-typing works: you don't know if you did something wrong until you actually attempt an action that triggers that wrongness.

    If all you're going to do with an object is throw it, a bowling ball will work as well as a duck. It's only when you try to make it swim or fly or whatever that you'll notice the bowling ball is not a duck. Likewise, if all you're going to do to equip a "weapon" is strap it to your belt or hold it in your hand, you can do that with an apple as well as with a sword; you won't notice anything amiss until you try to actually wield the apple in battle.