1. Instance-level data#

  • Let’s see how we can distinguish Instance-level data and Class-level data.

  • The following code has name and salary as attributes, with specific values for each new instance of the class. They are called instance attributes

Instance-level Data

Notes

class Employee:
  def __init__(self, name, salary):
    self.name = name
    self.salary = salary

emp1 = Employee("Poor Guy", 30000)
emp2 = Employee("You my friend", 100000) 
print(emp1.name + ': ' + str(emp1.salary))
print(emp2.name + ': ' + str(emp2.salary))
  • name, salary are instance attributes

  • self binds to an instance

2. Class-level data#

  • But now, we want to store some data that is shared among all the instances.

  • For example, we can introduce a minimal salary across the entire organization. In other words, the data should not differ among object instances. So we can define class attributes in the body of class. That class attribute will serve as a global variable within a class.

Class-level Data

Notes

class Employee:
    # Define a `class attribute`
    MIN_SALARY = 30000
    def __init__(self, name, salary):
        self.name = name
        #-------------------------#
        # Use class name to access 
        # class attribute
        #------------------------#
        if salary >= Employee.MIN_SALARY:
            self.salary = salary
        else:
            self.salary = Employee.MIN_SALARY

emp1 = Employee("Poor Guy", 40000)
emp2 = Employee("You my friend", 120000) 
print(emp1.name + ': ' + str(emp1.MIN_SALARY))
print(emp2.name + ': ' + str(emp2.MIN_SALARY))
  • MIN_SALARY is shared among all instance

  • self is not used to define class attributes.

  • use ClassName.ATTR_NAME to access the class attribute value.

  • The benefit of using class attributes is to have global constants that are related to class, for example min and max values for attributes, or commonly used values and constants, e.g. pi.

3. Class Methods#

  • Regular methods are already shared between instances. The same code gets executed for every instance. The only difference is the data that is fed into it.

  • It is possible to define methods bound to class rather than an instance. But these methods will not be able to use any instance-level data.

  • To define a class-method , we have to start with a classmethod decorator, followed by a method definition. The only difference is that now the first argument is not self, but cls, referring to the class, just like the self argument was a reference to a particular instance.

class MyClass:
    
    @classmethod                               # --> use decorator to declare a class method
    def my_method(cls,argument_1,argument_2):  # --> `cls` argument refers to the class

MyClass.my_method(argument_1,argument_2)       # --> class-dot-method syntax
  • A class can only have one __init__ method, but there might be multiple ways to initialize an object.

  • For example, if you want to create an Employee object from data stored in a file, we can’t use a method. This would require an instance, and there isn’t one yet. So, we can create a class-method that accepts a file name, reads the first line from the file that contains the name of the employee, and returns an object instance.

3.1. Example - Class Method 1#

class Employee:
    MIN_SALARY = 30000
    def __init__(self, name, salary=30000):
        self.name = name
        if salary >= Employee.MIN_SALARY:
            self.salary = salary
        else:
            self.salary = Employee.MIN_SALARY
    @classmethod
    def from_file(cls,filename):
        with open(filename,"r") as f:
            name = f.readline()
        return cls(name)


emp1 = Employee("fernando", 500000)
print(type(emp1))
print(emp1.name, str(emp1.salary))

# TODO - Find a way to load a file from here
#emp2 = Employee.from_file("")
#print(type(emp2))
#print(emp2.name, str(emp2.salary))
<class '__main__.Employee'>
fernando 500000

3.2. Example - Class Method 2#

  • Let’s create a constructor = __init__ that creates BetterDate objects given the values for year month, and day. But we can also create a BetterDate objects from strings and datetime.

from datetime import datetime
 
class BetterDate:
    # Constructor
    def __init__(self, year, month, day):
        self.year, self.month, self.day = year, month, day

    # Define a class method from_str
    @classmethod
    def from_str(cls, datestr):
        # Split the string at "-" and convert each part to integer
        parts = datestr.split("-")
        year, month, day = int(parts[0]), int(parts[1]), int(parts[2])
        # Return the class instance
        return cls(year, month, day)

    @classmethod
    def from_datetime(cls, datetime):
        return cls(datetime.year, datetime.month, datetime.day)


# Constructor able to create `BetterDate` objects froms strings
bd = BetterDate.from_str('2050-01-01')
print('!' + '-'*70)
print("BetterDate objects from strings:")
print(bd.year, bd.month, bd.day)
print(bd.__dict__)
print('!'+ '-'*70)

# BetterDate objets given values of year, month, day
bd2 = BetterDate(year=2022, month = 2, day =22)
print("`BetterDate` objects from year, month, day:")
print(bd2.year, bd2.month, bd2.day)
print(bd2.__dict__)
print('!' + '-'*70)

# BetterDate objects from datetime objects
today = datetime.today()
bd3 = BetterDate.from_datetime(today) 
print("`BetterDate` objects from datetime:")
print(bd3.year, bd3.month, bd3.day)
print(bd3.__dict__)
print('!' + '-'*70)

!———————————————————————-
BetterDate objects from strings:
2050 1 1
{‘year’: 2050, ‘month’: 1, ‘day’: 1}
!———————————————————————-
BetterDate objects from year, month, day:
2022 2 22
{‘year’: 2022, ‘month’: 2, ‘day’: 22}
!———————————————————————-
BetterDate objects from datetime:
2022 4 19
{‘year’: 2022, ‘month’: 4, ‘day’: 19}
!———————————————————————-

4. Inheritance#

See Reference here

  • Inheritance allows us to define a class that inherits all the methods and attributes from another class.

  • The code below has a class Sensor with attributes name, location, and record_date that pass from the creation of an object and an attribute data as an empty dictionary to store data. The method add_data contains t and data as input parameters to take in timestamp and data arrays. Within this method, we also assign t and data to the self.data attribute with time and data as the [`keys`]. The method clear_data is created to delete the data.

4.1. Example - Inheritance 1#

import numpy as np 
class Sensor():
    def __init__(self, name, location, record_date):
        self.name = name
        self.location = location
        self.record_date = record_date
        self.data = {}
        
    def add_data(self, t, data):
        self.data['time'] = t
        self.data['data'] = data
        print(f'We have {len(data)} points saved')        
        
    def clear_data(self):
        self.data = {}
        print('Data cleared!')

if __name__ == "__main__":
    sensor1 = Sensor('sensor1', 'Berkeley', '2019-01-01')
    data = np.random.randint(-10, 10, 10)
    sensor1.add_data(np.arange(10), data)
    print(sensor1.data)
  • Say we have a new type of sensor: an accelerometer. It shares the same attributes and the methods as Sensor class, but it also has different attributes or methods need to be appended or modified from the original class.

  • This new class will inherit from the Sensor class with all the attributes and methods. Let us create this new class Accelerometer, and add a new methods, show_type, to report what kind of sensor it is.

# New Child Class
class Accelerometer(Sensor):    # --> Sensor is a `superclass`
    
    def show_type(self):        # --> New method 
        print('I am an accelerometer!')

acc = Accelerometer('acc1', 'Oakland', '2019-02-01')
acc.show_type()
print(acc.name, acc.location, acc.record_date)
print('!' + '-'*70)
data = np.random.randint(-10, 10, 10)
acc.add_data(np.arange(10), data)
print('!' + '-'*70)
print(acc.data)
  • The Sensor class is the parent class (or superclass) and is more general. It passes all the characteristics to the child class Accelerometer.

4.2. Example - Inheritance 2#

class Employee:
  MIN_SALARY = 30000    

  def __init__(self, name, salary=MIN_SALARY):
      self.name = name
      if salary >= Employee.MIN_SALARY:
        self.salary = salary
      else:
        self.salary = Employee.MIN_SALARY
        
  def give_raise(self, amount):
    self.salary += amount      
        
# MODIFY Manager class and add a display method
class Manager(Employee):     

  def display(self):
    print("Manager " + self.name )

mng = Manager("Debbie Lashko", 86500)
print(mng.name)

# Call mng.display()
mng.display()

4.3. The super().__init__#

  • Let’s now create a new class called NewSensor that inherits from Sensor class, but with updated the attributes by adding a new attribute brand.

  • To do the above, we can use super().__init__(self,):

class NewSensor(Sensor):
    def __init__(self, name, location, record_date, brand):
        super().__init__(name, location, record_date)       #--> super().__init__ == Sensor().__init__
        self.brand = brand
        
new_sensor = NewSensor('OK', 'SF', '2019-03-01', 'XYZ')
print(new_sensor.brand)
  • The above is a good way to keep our code reusable.

5. Encapsulation#

  • It describes the idea of restricting access to methods and attributes in a class.

  • Prevents data being modified by accident.

  • We can create private methods or private attributes using underscore as prefix, i.e single_ or double __.

5.1. Example - Encapsulation 1#

class Sensor():
    def __init__(self, name, location):
        self.name = name
        self._location = location
        self.__version = '1.0'
    
    # a getter function
    def get_version(self):
        print(f'The sensor version is {self.__version}')
    
    # a setter function
    def set_version(self, version):
        self.__version = version
        

if __name__ == "__main__":

    sensor1 = Sensor('Acc', 'Berkeley')
    print(sensor1.name)
    print(sensor1._location)
    print(sensor1.__version)

!———————————————————————-
Acc
Berkeley
!———————————————————————-

Error

AttributeError Traceback (most recent call last)
/tmp/ipykernel_165/1597133680.py in
19 print(sensor1.name)
20 print(sensor1._location)
—> 21 print(sensor1.__version)

AttributeError: ‘Sensor’ object has no attribute ‘__version’
!———————————————————————-

  • See the notes below:

Attribute

Notes

self._location

It should not be accessed directly.
But you still can if you want to.

self.__version

This can not be accessed or modified directly.

  • To get access to the double underscore attributes, we need to use getter and setter functions to access it internally.

sensor1.get_version()
sensor1.set_version('2.0')
sensor1.get_version()

The sensor version is 1.0
The sensor version is 2.0

6. Polymorphism#

  • Polymorphism allow us to use a single interface with different underlying forms such as data type or classes.

Reference

class Cat:
    def __init__(self, name, age):
        self.name = name
        self.age = age

    def info(self):
        print(f"I am a cat. My name is {self.name}. I am {self.age} years old.")

    def make_sound(self):
        print("Meow")


class Dog:
    def __init__(self, name, age):
        self.name = name
        self.age = age

    def info(self):
        print(f"I am a dog. My name is {self.name}. I am {self.age} years old.")

    def make_sound(self):
        print("Bark")


cat1 = Cat("Kitty", 2.5)
dog1 = Dog("Fluffy", 4)

for animal in (cat1, dog1):
    animal.make_sound()
    animal.info()
    animal.make_sound()

Note

See that both classes Cat and Dog share a similar structure and have same method names info() and make_sound().