2.3. builtin - Class (Object Oriented Programming)

2.3.1. Class object versus Class object instance

  • define a class

class Circle():
    # to set initialization parameters
    #  (these are unique attributes to each instance of the Circle class)
    def __init__(self, radius):
        # self.radius is called a INSTANCE ATTRIBUTE
        self.radius = radius
        # WHAT IS "SELF": think of self as the instance of the class; ex:
        #   at runtime: circle1 = Circle(5), the class instance "circle1" is self
        #   this is why we are able to call circle1.radius which is
        #   analogous to self.radius of that class instance
  • # common mistake #1: AttributeError: type object ‘Circle’ has no attribute ‘radius’

Circle.radius
>>> AttributeError: type object 'Circle' has no attribute 'radius'
# this occurs because we did not initialize an instance of Circle

# fix:
Circle(radius=10).radius
>>> 10
  • we can also save an INSTANCE of Circle as a variable (how its commonly used)

circle1 = Circle(10)
circle2 = Circle(20)
circle1.radius
>>> 10
circle2.radius
>>> 20

2.3.2. Class Method (same as a function)

  • define a class with a METHOD

class Circle():
    def __init__(self, radius):
        self.radius = radius

    # to define a METHOD (unique name to classes, same concept as a function)
    def area(self):
        return 3.14 * self.radius ** 2
  • common mistake #2: TypeError: area() missing 1 required positional argument: ‘self’

Circle.area()
>>> TypeError: area() missing 1 required positional argument: 'self'
# same as above, this occurs because we did not initialize an instance of Circle

# fix:
Circle(radius=10).area()
>>> 314.0
  • assigned to a variable

circle1 = Circle(10)
circle1.area()
>>> 314.0

2.3.3. Class Attribute vs. Instance Attribute

  • define a class with CLASS and INSTANCE ATTRIBUTES

# classes are great with their simple dot completion attributes, however
#  results can be very different than expected when using different attribute types

class Circle():
    # CLASS ATTRIBUTE: same for all instances of the class
    PI = 3.14           # immutable CLASS ATTRIBUTE
    classlist = [1,]    # mutable CLASS ATTRIBUTE

    def __init__(self, radius):
        # INSTANCE ATTRIBUTE: unique to each instance of the class
        self.radius = radius
  • define 2 instances of the parent class Circle

# lets create 2 instances of the class Circle
circle1 = Circle(radius=5)
circle2 = Circle(radius=10)

# note that circle1 and 2 both have a CLASS ATTRIBUTE .PI that is the same
circle1.PI
>>> 3.14
circle2.PI
>>> 3.14
# but their INSTANCE ATTRIBUTE is unique to each instance of the class Circle
circle1.radius
>>> 5
circle2.radius
>>> 10
  • Updating CLASS ATTRIBUTES

# CLASS ATTRIBUTES are connected to all instances of that class,
#   we can change all of them at once by modifying the master CLASS ATTRIBUTE
circle1.PI
>>> 3.14
circle2.PI
>>> 3.14
# now lets update both from the parent class Circle
Circle.PI = 50
circle1.PI
>>> 50
circle2.PI
>>> 50
  • Updating CLASS ATTRIBUTES the wrong way!

# IMPORTANT: python lets you do whatever you like, but with such power comes consequences
#   ex: the ability to overwrite a CLASS ATTRIBUTE of a class instance like circle1
#   note that prior to modifying .PI CLASS ATTRIBUTE has the same ID for all instances
id(circle1.PI)
>>> 72539584
id(circle2.PI)
>>> 72539584
# now when we overwrite .PI we are actually changing the .PI attribute from CLASS to INSTANCE ATTRIBUTE
circle1.PI = 3
id(circle1.PI)
>>> 1865210064
# also note that now instances DO NOT share the same .PI CLASS ATTRIBUTE any more
circle2.PI
>>> 3.14


# now lets see what happens with a mutable CLASS ATTRIBUTE
id(circle1.classlist)
>>> 71716696
id(circle2.classlist)
>>> 71716696
# similar to PI, classlist shares the same ID between classes, but now updating one
#   also updates all because the ID stays the same for mutable objects
circle1.classlist += [2]
circle1.classlist
>>> [1,2]
circle2.classlist
>>> [1,2]       # circle2 instance was also updated!

2.3.4. Class Methods (method, staticmethod, classmethod)

  • define a class with a METHOD, STATICMETHOD, and CLASSMETHOD

# class methods are analogous to function definitions, except they are tied to a class
class Circle():
    def __init__(self, radius):
        self.radius = radius

    # This is a simple METHOD: methods take at least 1 argument "self" and does something with it
    def area(self):
        return 3.14 * self.radius ** 2

    # This is a STATICMETHOD: a static method does not depend on "self"
    #   or more explicitly stating, any unique definition of the class instance
    @staticmethod
    def color(color='black'):
        return 'the color of the circle is: ' + color

    # This is a CLASSMETHOD: a class method takes at least 1 argument "cls" and
    #   it usually returns a new altered instance of the class
    # What is really special about a class method is that the
    #   user is able to call it without instancing the class (see example below)
    @classmethod
    def from_dia(cls, diameter):
        # cls under the hood calls Circle.__new__() that creates a new instance of the class Circle
        # with new __init__ definition that is: diameter/2
        return cls(diameter / 2)
  • Call/use a METHOD

circle1 = Circle(radius=5)
# call a regular METHOD via
circle1.area()
>>> 78.5
  • Call/use a STATICMETHOD

circle1 = Circle(radius=5)
# call a STATICMETHOD
circle1.color()
>>> 'the color of the circle is: black'
  • Call/use a CLASSMETHOD. Define a Circle by diameter (note that the class is never instanced, ie: “Circle()”) circle2 is now instanced via CLASSMETHOD, and all of the regular functionality is available

circle2 = Circle.from_dia(diameter=10)
circle2.radius
>>> 5.0
circle2.area()
>>> 78.5

2.3.5. Double underscore methods (dunder)

  • define a class with __init__, __repr__, __call__

class Circle():
    # INIT: initialize a class instance with parameters
    def __init__(self, radius):
        self.radius = radius

    # REPR: string representation of a class (instead of the default "Circle object at 0x23423423"
    def __repr__(self):
        return "Circle Class"

    # CALL: returns call to the class instance
    def __call__(self, *args, **kwargs):
        print(args)
        args = args if args else ("",)
        print(args)
        return "this is a call on the class, " + len(args)*"{},".format(*args)

    # ADD: defines what to do with a "+" operator
    #  note: operators always work from left, ie: Circle + 10
    #        the "+" operator is actually calling __add__ on Circle
    def __add__(self, arg):
        print("you tried to add to class Circle")
        return arg + self.radius

    def __subtract__(self, arg):
        return "you tried to subtract from class Circle"

    def __mul__(self, arg):
        return "you tried to multiply class Circle"

    def __truediv__(self, arg):
        return "you tried to divide class Circle"

    # you can have the operator read from the right as well, this is useful if you
    #  tried to add: 10 + Circle, by default python will try to read from left but
    #  has no idea how to add a "int" + "class" so then it will look to the right and
    #  see if it has a "radd" definition, the "r" can be defined for all other math operators
    def __radd__(self, arg):
        print("addition with right operator")
        return arg + self.radius

    # to evaluate Circle[arg] sequence
    def __getitem__(self, arg):
        return [self.radius]
  • call/use __init__ (class instance initialization)

# INIT call/use
circle1 = Circle(radius=5)
  • call/use __repr__ (class text representation)

circle1 = Circle(radius=5)
# REPR call/use
circle1
>>> "Circle Class"
# REPR call/use
str(circle1)
>>> "Circle Class"
  • call/use __call__ (call return of the class)

circle1 = Circle(radius=5)
# CALL call/use
circle1()
>>> "this is a call on the class, ,"
circle1(1,2)
>>> "this is a call on the class, 1,2"
  • call/use __add__ and other math dunder’s

circle1 = Circle(radius=5)
# ADD call/use
circle1 + 5 # here __add__ gets called
>>> "you tried to add to class Circle"
>>> 10 # radius + 5

# now a right operation, since int doesnt know how to add Circle, but Circle does
5 + circle1 # int + Circle returns an error, then python tried from right: __radd__ gets called
>>> "addition with right operator"
>>> 10

2.3.6. Subclassing - to extend functionality of a class

Take Circle class for instance, it has a method to calculate area now lets say Circle is locked down as a class by another coder and we cannot change it we dont want to start from scratch and rebuild Circle, but we do want to add functionality we can do this with subclassing

  • define a parent class and a subclass (a subclass inherits functionality of a parent class)

# here is the original Circle Class
class Circle():
    def __init__(self, radius):
        self.radius = radius

    def area(self):
        return 3.14 * self.radius ** 2


# now lets create a custom Class that inherits functionality from Circle
class CustomCircle(Circle):
    def halfarea(self):
        # note that we depend on Circle having a method called area()
        #   but the method itself is not defined here in CustomCircle
        #   it is INHERITED
        return self.area() / 2
  • using a subclass

# lets create an instance of our custom class
circle1 = CustomCircle(radius=10)
# note that we still have access to methods from Circle (it is INHERITED)
circle1.area()
>>> 314.0
# but we also have a new custom functions from CustomCircle
circle1.halfarea()
>>> 157.0

2.3.7. Trick - Print the docstring of a class/method

class Circle():
    """
    Class docs
    """

    def __init__(self):
        """
        Instance docs
        """
        pass

    def func(self):
        """
        Method docs
        """
        pass

Circle.__doc__
>>> "Class docs"
Circle.__init__.__doc__
>>> "Instance docs"
Circle.func.__doc__
>>> "Method docs"

2.3.8. Trick - Testing that a class has a method (compile time)

assert hasattr(Circle, "area"), "The class Circle doesnt have required method area"

2.3.9. Trick - Access a class’s attribute by its string name

class A():
    self.attr1 = []

getattr(A,'attr1')

2.3.10. Trick - How to create multiple levels of attributes

There are a lot of lessons on object oriented programming that talk about the big concepts, then you go to create something in practice and realize that you are still missing some fundamentals. For me, this was the case on nested attributes for a long time. How do I create a class with multiple levels of attributes?

  • Lets say we want have a database class (storage class), and each time is index by an ID but under each ID we would like to call more attributes, ie: db.ID[1].att1. Let’s see how that can be done:

# database class
class Database():
    def __init__(self):
        # initialize the ID container as a dictionary
        self.ID = {}

    # create a method of adding new items to the database
    def additem(self,ID,att1,att2):
        # update database dictionary by calling another class "Attributes"
        #  this says: Database.ID[#] returns the class Attributes,
        #  that can then be called for it's attributes ".att1", ".att2"
        self.ID.update({ID: Attributes(att1,att2)})


# 2nd level nested attributes class
class Attributes():
    def __init__(self,att1,att2):
        self.att1 = att1
        self.att2 = att2


# lets see how it works in pratice

# define a instance of the database
db = Database()
# add a few items
db.additem(1,'att1 from ID1', 'att2 from ID1')
db.additem(2,'att1 from ID2', 'att2 from ID2')
# now to call it nested
db.ID[1].att1
>>> 'att1 from ID1'
db.ID[2].att2
>>> 'att2 from ID2'

2.3.11. Trick - Create multiple instances of a class based on initial input

This is really useful when a class __init__ is setup to take a single value input (like an ID, but instead a range of IDs were given) and we would like to create multiple unique classes out of each ID separately.

# take a class for instance that is a storage of attributes
# its unique identifier is set by an attribute ID, but
# a user would like to define multiple classes at the same time - what do we do

class Signal():

    # note __init__ is called after __new__ via super
    def __init__(self, ID, A, B, C):
        print("initialized Signal")
        self.ID = ID
        self.A = A
        self.B = B
        self.C = C

    # called before __init__
    def __new__(cls, ID, *args, **kwargs):
        # check if ID entered was a range, if so, split them apart
        if type(ID) is list:
            print("muti-ID identified")
            return cls.split_IDs(ID, *args, **kwargs)
        else:
            # this says: from the class Signal create an instance (ie: call __init__)
            print("creating instance ID = ", ID)
            # note that .__new__(cls) only has cls as input, ID, A, B, C are not entered
            # (but they are buffered over to the __init__ automatically
            return super(Signal, cls).__new__(cls)

    @classmethod
    def split_IDs(cls, ID, *args, **kwargs):
        # return a list of Singal instances all with the same attributes A,B,C but unique single IDs
        print("creating a list of unique Signal instances")
        # note that each cls call here for each uniqueID in ID calls __new__ with ID=uniqueID as input
        # therefore this call goes to the "creating instance" logic
        return [cls(uniqueID, *args, **kwargs) for uniqueID in ID]

# now let's test it for a single ID input:
single_signal = Signal(ID=1,A=10,B=20,C=30)
>>> "creating instance ID = 1"
>>> "initialized Signal"

# now for multi-ID input
list_signal = Signal(ID=[1,2],A=10,B=20,C=30)
>>> "muti-ID identified"
>>> "creating a list of unique Signal instances"
>>> "creating instance ID = 1"
>>> "initialized Signal"
>>> "creating instance ID = 2"
>>> "initialized Signal"