UPDATED: All about classes in python

Classes are great when you’re working with concepts in the real world. They are by no means a necessary construct in Python, they’re mostly used for code simplification, readaibility and reusability. We’ll walk through some examples to try and make that a little clearer.

Creating a class and an instance of a class

Let’s start off by creating a class. In the below, we define the employee class. When an employee is created, we pass in three parameters: self, new_name and salary_history. In this example: employee_one = employee(‘Bob Smith’, [70000, 80000, 55000]):

  1. Self is the instance of that object. We have just created an object of type employee, so that object is passed as a parameter, so Python knows which object to update
  2. new_name is the name of the employee, in this case, it’s Bob Smith
  3. The salary history of the employee is also passed in as a list of values

We then assign those values to fields in the employee class by saying self.fieldname (i.e. the fieldname for this instance of the class) is equal to the parameter being passed into the class called new_name.

class employee:
  def __init__(self, new_name, salary_history): 
    self.name = new_name
    self.salary = salary_history

Accessing object attributes

Now that we have assigned values to field names, we can access the value by simpy referencing the field name, like below. That snippet of code would return ‘Bob Smith’.

print(employee_one.name)

Adding methods to the class

Okay, so by this point, we’ve defined a really simple class and assigned values to fields named ‘name’ and ‘salary’. Now let’s create a function to live within the class. When a function lives within a class, we call it a method. No real reason, other than to make it clear that it’s a function that belongs to a class.

We can add all functions related to our employees within the employee class. This keeps all functions related to employees in one place. In the example below, we take the average of all the salaries in the salary field (remember, salaries are passed as a list. So for each employee, we calculate the average of their list of salaries). In the case of Bob Smith, we need to take the average of 70,000. 80,000 and 55,000.

  def average_salary(self):
      return sum(self.salary) / len(self.salary)

Calling the method we’ve defined

We’ve defined a function, how do we use it? We simply call it below, where we reference the instance of the object (in this case employee_one) and then call the average_salary function.

print(employee_one.average_salary())

Using the @property decorator

You can see that we’re currently calling average salary as we would call a method (using our parenthesis), but really, this function returns an attribute. So, wouldn’t it be cool if we could call it like employee_one.average_salary?

Why would that be useful. Well, let’s imagine we have thousands of lines of code in our application. In our code, we’re defining a full name value, based on the concatination of the first and last names. The issue is, if you change an attribute value within a class, it doesn’t automatically force the attributes derived from that value to re calculate. So, if I had Ben Smith and his surname changed to Cooke, the full name would not automatically update to Ben Cooke.

class employee:

    def __init__(self, firstname, lastname):
        self.first = firstname
        self.last = lastname
        self.fullname = self.first + ' '+ self.last

To fix this, we could write a method which would return the concatination of the first and last name correctly. The problem is, if you had a 10,000 line code base, which referenced fullname as a field, it would break as you would have to access the method as object.fullname().

    def fullname(self):
        return self.first + ' '+ self.last

We can get around this by using the @property decorator, which lets us access that method as if it were an attribute. Now, we can simply call object.fullname to get the correct response.

class employee:
    def __init__(self, firstname, lastname):
        self.first = firstname
        self.last = lastname

    @property
    def fullname(self):
        return self.first + ' '+ self.last

emp = employee('bob', 'smith')

print(emp.fullname)

Dunder methods within classes

Now let’s look at some dunder methods, these stand for double underscore. Here are a few examples:

  1. In the below, the init function which we have seen many times initializes the class. If you aren’t going to be setting class attribute values, it’s not necessary.
  2. The len function enables us to implement some shorthand. Instead of len(employee_two.salary), we can define this dunder function & then simply do len(employee_two).
  3. The getitem function means that instead of employee_two.salary[1], we can simply do employee_two[1]. It enables us to index on a specific field.
  4. Repr and Str both provide string representations of the object. You can think of repr as being for developers – lots of information can be packed in there and str for end users. In the examples below, I have used the python f’ structure, which lets us pass variables into a string. One passes in the salaries and the other passes in a string which tells the user how many salaries the employee had. Calling print(objectName) will print the __str__ response. You can also use print(p.__str__()) or print(p.__repr__()), where p is the object name.
  def __init__(self, new_name, salary_history): 
    self.name = new_name
    self.salary = salary_history
  
  def __len__(self):
    return len(self.salary)

  def __getitem__(self, index):
    return self.salary[index]
 
  def __repr__(self):
    return f'<salaries {self.salary}>'

  def __str__(self):
    return f'Employee with {len(self)} historic salaries'

Below is a nice example to help show the difference between repr and str. Ultimately, they’re the same, but str is intended to be the more human readable implementation. You can see below that the str() implementation is an actual datetime stamp whereas the repr() is the datetime object.

import datetime
now = datetime.datetime.now()
str(now)
'2019-03-29 01:29:23.211924'
repr(now)
'datetime.datetime(2019, 3, 29, 1, 29, 23, 211924)'

Inheritence

That’s all about a class I want to cover. Now, let’s talk about inheritence. In the below, I have created an intern_employee class. That class inherits (denoted by employee being in brackets) from the parent employee class. That means, all methods and fields are available to the sub-class, meaning we don’t have to duplicate code from the parent employee class.

In the __init__ method, you’ll see that we still take in new_name and salary_history but we also take in the name of the interns university. We don’t actually have to redefine the name and salary fields though – we just do super().__init__ , which passes the name and salary values to the parent class, re-using that code.

class intern_employee(employee): 
  def __init__(self, new_name, salary_history, university):
    super().__init__(new_name, salary_history) 
    self.university = university

To create that intern_employee, we use the below syntax;

intern_one = intern_employee('Jim Craggle', [10000, 12000, 14000], 'Oxford University')

We can also use the parent class methods using the below syntax.

print(intern_one.average_salary)

Hopefully that has helped you out a bit related to classes in Python.

Going a bit deeper – static methods & class methods

We have a couple of extra decorators that we can add to our methods within our classes. We have @classmethod and @staticmethod.

A static method does not bear any relation to a class but lives within it. This may be useful for something like a math module. You have lots of functions which aren’t related to an object, but grouping them all under a class makes it much more organized and also means, you can simply import the class, rather than each method independently from another python file.

class math:
  @staticmethod
  def square(n):
    return n*n

  @staticmethod
  def add(num1, num2):
    return num1 + num2

A class method is related to a class but does not require an object to exist. A class methiod can access class variables, as you can see below, it can access the students list. A static method cannot access class variables and is totally standalone.

class student:
  students=[]

  def __init__(self, name):
    self.name = name
    self.students.append(self)

  @classmethod
  def num_students(cls):
    return len(cls.students)

  @staticmethod
  def hello(n):
    for _ in range(n):
      print('hello')

x = student('bob')
y = student('sarah')

print(y.num_students())
print(student.num_students())

What about private and public classes

Private and public classes in Python aren’t quite the same as other languages. Just because you mark a class as private, doesn’t actually mean that it cannot be used elsewhere – it’s a flag to the other developers to say that it SHOULD not be used elsewhere. In reality, if they want to, they can. Simply, the underscore in the method or class name shows to other developers that the method/class is private and should not ben used.

  class _math: #_ means private
  @staticmethod
  def _square(n): #private
    return n*n

  @staticmethod
  def add(num1, num2): #public
    return num1 + num2

Alright, but why should we even use classes?

I mentioned at the beginning of this article that you can, for the most part, code things in Python with no classes and it’s true, you can. But as your code base starts to get much bigger, you will find yourself tied in knots without having some way to structure your code nicely – classes do this for us. Don’t underestimate the value of having your related functions in close proximity to one another, rather than spread out across your code base.

#All related functionality within the same structure
class employee:
  #self is the new object with the name and salaries we passed in
  def __init__(self, new_name, salary_history): #special function
    #assigning field values
    self.name = new_name
    self.salary = salary_history
  
  #short hand method. Instead of len(employee_two.salary), we can define this dunder function & then simply do len(employee_two)
  def __len__(self):
    return len(self.salary)

  #short hand method. Instead of employee_two.salary[1], we can simply do employee_two[1]
  def __getitem__(self, index):
    return self.salary[index]
 
  #dunder method to return a string representation of an object
  def __repr__(self):
    return f'<salaries {self.salary}>'

  #dunder method to return a some information about an object
  def __str__(self):
    return f'Employee with {len(self)} historic salaries'

  #functions inside classes are called methods
  @property #using property decorator means we dont need the () in the function call. This works because it outputs a value and requires no input
  def average_salary(self):
      return sum(self.salary) / len(self.salary)

#example of inheritence - don't duplicate methods and fields
class intern_employee(employee): #this is child of employee class
  def __init__(self, new_name, salary_history, university):
    super().__init__(new_name, salary_history) #call the parent class init method to set shared field values
    self.university = university
  
#create object
employee_one = employee('Bob Smith', [70000, 80000, 55000])
employee_two = employee('Carl Carlson', [44000, 22000, 66880])
intern_one = intern_employee('Jim Craggle', [10000, 12000, 14000], 'Oxford University')

#print field from the child class
print(intern_one.university)
#use parent functions
print(intern_one.average_salary) #using the property decorator means we didnt need to have ()

#shows its an employee class
print(employee_one.__class__) 

#access field values within an object
print(employee_two.name)

#call a method within the class
print(employee_two.average_salary) #using the property decorator means we didnt need to have ()only use when youre using the value as a property and not carrying out any action (e.g. DB call or passing value to function)

#appending to a field within an object
employee_two.salary.append(58999)

#with and without using our len dunder method
print(len(employee_two.salary))
print(len(employee_two)) 
#with and without using our getitem dunder method
print(employee_two.salary[1])
print(employee_two[1])

#with the two dunder methods (getitem and len) defined we can now loop over fields. Python uses the len and index from the dunder methods
for salary in employee_two:
  print(salary)

#uses the __str__ method response
print(employee_two)
Kodey