Class Objects

10_class_objects

Classes

Classes are the object factories of many programming languages. The objects that classes create are typically called instances. Classes can also be used to organize code and/or data. Python Classes are similar to classes in other languages but in many ways they are quite different.

Python Class | python.org

Class Instantiation & The Instance Object

When a class is called directly you get back an instance object.

In [0]:
class MyClass:
    pass


instance_object = MyClass()

Magic methods

Also known as Dunder Methods – these are invoked by Python and do not need to be called directly. For example, the __call__() method is automatically called when you call the object itself. See Callable Object below.

Python Magic Methods | python.org

Define Fields with __init__()

This is the Init Method. It is used to populate fields on the instance object. The init method allows us to load the instance object with fields, this is the last step of the instantiation process. Fortunately the object already has all the class variables, instance methods, static methods and class methods pre-loaded. Inside any instance method the instance object has the name: self, this is an implict argument. You need to declare it in the method def but it is not expected to be passed in – that’s the implicit part.

Sometime this __init__() method is called the constructor, however it would be better to call it the initiallizer as the object has already been constructed at this point. There is another magic method __new__() – this is the proper constructor. The __new__() magic method will not be covered here as it is almost never used.

Python Init method | python.org

In [2]:
class Name:

    def __init__(self, name):
        self.name = name  # instance variable


name_object = Name("Jim Bob Joe")  # name passed to __init__
print(name_object.name)
Jim Bob Joe

Callable Object with __call__()

In this example we’ll see how we can add to the instance objects the ability to call them as if they where functions.

In [3]:
class Callable:
    fourty_two = 42  # class variable

    def __call__(self):
        return self.fourty_two


callable_obj = Callable()
print(callable_obj)  # not called
print(callable_obj())  # called
<__main__.Callable object at 0x7f994e90d9e8>
42

Printable Object with __str__() and/or __repr__()

__str__(): This magic method should return a string. This is used when the object is to be printed or any time the object is cast to a string.

__repr__(): This magic method should also return a string. Typically this is a string of the class signature.

So long as one of these methods are defined, the objects will be printable directly.

In [4]:
class Printable:
    class_answer = 42

    def __str__(self):
        return f"The answer is {self.class_answer}"

    def __repr__(self):
        return "Printable()"

answer = Printable()
print(answer)
print(repr(answer))
The answer is 42
Printable()

Inheritance

It can be said that Wizard & Fighter both inherit from Character. All fields and methods from any base classes will automatically be present in all derived classes. This is one way to share behavior and data across many classes.

In [5]:
class Character:
    """ Base Class """
    health = 10


class Wizard(Character):
    """ Derived Class """
    mana = 20


class Fighter(Character):
    """ Derived Class """
    power = 15


wizard_object = Wizard()
print("Wizard Health:", wizard_object.health)
print("Wizard Mana:", wizard_object.mana)
print()
fighter_object = Fighter()
print("Fighter Health:", fighter_object.health)
print("Fighter Power:", fighter_object.power)
Wizard Health: 10
Wizard Mana: 20

Fighter Health: 10
Fighter Power: 15

Avoid Multiple Inheritance

The JunkYardShip below, only fires with the power of a StarFighter. This is due to the order that the base classes are inherited… JunkYardShip(StarFighter, IonCanon) should be JunkYardShip(IonCanon, StarFighter), and this is weird. This seems backwards to anyone that knows how CSS works.

Multiple Inheritance is not considered Pythonic and generally it’s best avoided. Composition is a much better pattern, see the StarDestroyer() class.

In [6]:
class StarFighter:

    def fire(self):
        return 10


class IonCanon:

    def fire(self):
        return 100


class JunkYardShip(StarFighter, IonCanon):  # Don't do this
    """ I have a bad feeling about this. """
    pass


class StarDestroyer(StarFighter):  # Do this instead
    """ This class uses composition to gain 
    the full fire power of the IonCanon. """
    primary_weapon = IonCanon()

    def fire(self):
        return self.primary_weapon.fire()


fighter = StarFighter()
print(f"StarFighter: {fighter.fire()}")

junk_ship = JunkYardShip()
print(f"JunkYardShip: {junk_ship.fire()}")

destroyer = StarDestroyer()
print(f"StarDestroyer: {destroyer.fire()}")
StarFighter: 10
JunkYardShip: 10
StarDestroyer: 100

Polymorphism

The example below uses inheritance to achieve full polymorphism between Monsters and Bosses. All fields and methods match in name and logical behavior. They do not need to hold the same data. This allows the two types of objects to be used interchangeably – and yet leverage their logical differences. Inheritance is not the only way to achieve polymorphism.

In [0]:
import random


def dice(rolls, sides):
    return sum(random.randint(1, sides) for _ in range(rolls))


class Monster:
    creature_type = "Monster"
    hit_dice = 8
    damage_dice = 6
    names = ("Goblin", "Troll", "Giant", "Zombie", "Ghoul", "Vampire")

    def __init__(self, level=1):
        self.level = level
        self.name = self.random_name()
        self.total_health = dice(self.level, self.hit_dice)
        self.current_health = self.total_health

    def take_damage(self, amount):
        print(f"{self.name} takes {amount} damage!")
        self.current_health -= amount
    
    def deal_damage(self):
        return dice(self.level, self.damage_dice)

    def __str__(self):
        output = (
            f"{self.creature_type}: {self.name}",
            f"Level: {self.level}",
            f"Health: {self.current_health}/{self.total_health}",
        )
        return "\n".join(output)

    def random_name(self):
        return random.choice(self.names)


class Boss(Monster):
    creature_type = "Boss"
    hit_dice = 12
    damage_dice = 8
    names = (
        "The Loch Ness Monster", "Godzilla", "Nero the Sunblade",
        "The Spider Queen", "Palladia Morris", "The Blood Countess",
    )
In [8]:
some_monster = Monster(10)
print(some_monster, '\n')

dungeon_boss = Boss(20)
print(dungeon_boss, '\n')
Monster: Troll
Level: 10
Health: 48/48 

Boss: Nero the Sunblade
Level: 20
Health: 138/138 

In [9]:
dungeon_boss.take_damage(some_monster.deal_damage())
Nero the Sunblade takes 34 damage!
In [10]:
print(dungeon_boss)
Boss: Nero the Sunblade
Level: 20
Health: 104/138
In [11]:
some_monster.take_damage(dungeon_boss.deal_damage())
Troll takes 84 damage!
In [12]:
print(some_monster)
Monster: Troll
Level: 10
Health: -36/48

Class Scope

This can be tricky. It’s better not to think of what is going on here as scope. But rather a blueprint to make objects. Sometimes the blueprint would like to refer to itself. This complicates things a great deal. What is self? Is it the class or the instance object? We want both abilities, and here we are. The convention is that when we use param ‘self’ we mean the instance object, when we actually mean the class, meaning in class methods, we will instead use the param ‘cls’.

In Java it’s required to declare what are known as ‘get’ and ‘set’ methods to read and write class fields. In Python we may we drink java, but we never write get or set methods. We have direct access to all fields all the time. This is only partially true, see class methods and static methods for exceptions to this rule.

In [13]:
class ClassScope:
    # self does not exit yet.
    class_variable = "class_variable"

    def __init__(self):
        """ 
        Local scope inside a method is just like function scope. However,
        methods also have access to class scope and instance scope
        through self. """

        self.instance_variable = "instance_variable"

    def instance_method(self):
        """ This is a regular Instance Method.
        We have access to everything from here.
        Don't over think it, most of the time this is what you want.
        While it is common to modify instance variables here, it is not wise to
        declare them here. Use the `__init__()` method for that. Use instance
        methods, like this one, to read and update instance variables. """

        return self.instance_variable + ": via instance method"

    @classmethod
    def classy_method(cls):
        """ This is a Class Method.
        It's more restricted than regular methods. Instead of the `self`
        param we use the `cls` param. This is a convention to indicate
        we expect this method to live on a class that might possibly never
        be instantiated. This is the whole point of having class methods.
        This ability comes at a cost: everything we access from this scope
        must live on the class itself, not an instance. Only static methods,
        class methods and class variables are accessible here. """

        return cls.class_variable + ": via class method"

    @staticmethod
    def selfless_method():
        """ This is a Static Method.
        It's way more restricted than regular methods. Static Methods
        have no concept of `self` or `cls` and cannot access anything.

        This is a prime candidate to refactor into a function. """

        local_variable = "local_variable"
        return local_variable + ": via static method"


# Class Scope
print("From the Class:")
print(ClassScope.class_variable)     # There is no spoon, i mean...
print(ClassScope.classy_method())    # There is no instance.
print(ClassScope.selfless_method())  # But we have lots of class!
print()
# Instance Scope
print("From the Instance:")
instance_object = ClassScope()            # instance object instantiated.
print(instance_object.instance_variable)  # now we have everything...
print(instance_object.instance_method())  #    ...except local variables.
print(instance_object.class_variable)
print(instance_object.classy_method())
print(instance_object.selfless_method())
From the Class:
class_variable
class_variable: via class method
local_variable: via static method

From the Instance:
instance_variable
instance_variable: via instance method
class_variable
class_variable: via class method
local_variable: via static method