# Python Creating a Custom Class and Using Class Special Methods

## Introduction: Operators

When using Python you'll have came across:

The standard operators: +, , *, /, //, % ,^

The logical operators ==, !=, <, <=, >, >=, &, |

And the in-place operators +=, -=, *=, /=, //=, %=, ^=

#### String Operators

You'll have noticed that when using a string str the + operator can be used to concatenate a string to another string.

'a'+'b'

'ab'



And the * operator can be used with a string and an integer n to repeat the string n times.

'ab'*3

'ababab'



#### Integer Operators

The + operator for a number will however behave very differently when using two numbers and will instead perform addition:

2+4

6



And the * operator will perform multiplication when used on two numbers instead of repeating a number multiple times:

6*4

24



## Class and Class Instances

The reason for this is that strings and numbers are different classes and each class type has a different number of methods available to it. Let's create a complex number and a string.

my_num=complex(2,4)
my_str=str('abc')

In this case my_str is an instance of the class str. This can be checked using the function isinstance.

isinstance(my_num,str)
isinstance(my_str,str)

False
True



As expected my_num is not an instance of the str class and my_str is a member of the str class.

Once we have an object which is an instance of a class we can then utilize a number of class attributes and class methods directly on the object. Recall that we can access these by typing in the objects name followed by a . and then tab .

Recall that an attributes can be thought of as a variable that belongs to an object and attributes are looked up without use of parenthesis whereas methods are like functions and must be called using parenthesis. The parenthesis can contain none, one or multiple positional or keyword arguments.

instance_name.attribute
instance_name.method()

For example:

my_num.real
my_str.upper()

2.0
'ABC'



Recall that an attribute is called without parenthesis and a method is called with parenthesis which may enclose an input argument. If ensure whether you are accessing an attribute or a method. Type it in with open parenthesis:

## A Custom Scalar Class

We will first create a very basic custom class of scalar values in order to examine the concepts behind a custom class. We will then look at applying these concepts further to a custom class of fractions.

### Defining a Class

To define a custom class we use class followed by the class name, parenthesis and a colon.

class ScalarClass(object):
pass

For the name of a custom class e.g. CustomClass or ScalarClass CamelCaseCapitilization is used.

Note that the inbuilt Python classes such as str, list, tuple, dict and set are all named using lower case this is done to distinguish core Python classes from custom classes. These show up as highlighted in purple within the Spyder IDE:

issubclass(str,object)
issubclass(int,object)
issubclass(float,object)
issubclass(tuple,object)
issubclass(list,object)
issubclass(dict,object)
issubclass(set,object)

Most third party libraries such as pandas utilize CamelCaseCapitilization for class names for example DataFrame. The main exception to this rule is the numpy library where classes ndarray are also named using lower case. This reflects the fact that numpy is the most commonly used Python library and is commonly though of as an extension to core Python.

import pandas as pd
issubclass(pd.DataFrame,object)
import matplotlib.pyplot as plt
issubclass(plt.Figure,object)
import numpy as np
issubclass(np.ndarray,object)

Returning to our custom class ScalarClass in parenthesis we have the superclass, in our case our superclass will be object which as we seen from the above is the superclass of everything in Python which utilizes object orientated programming.

class ScalarClass(object):
pass

Note leaving the parenthesis blank will automatically set object to the superclass.

class ScalarClass():
pass

The CustomClass will inherit attributes and methods from the superclass which will now be discussed.

### Special Methods

There are a number of special methods for classes these begin and end with a double underscore. Many of these special methods map to a function or operator. Each special method will be class specific. The code behind the special method __add__ is different in the class str and the class int and explains the difference in behaviour. We will continue creating our custom ScalarClass and assign some of the mathematical special methods.

### The __init__ special initialization method

The special initialization method can be used to initialize the data attributes. In the case of our very basic ScalarClass there is only a single data attribute. The first input argument for the special __init__ method is self which refers to the instance of the class (self indicates a generic instance) and the second input argument is the scalar value.

class ScalarClass(object):
def __init__(self,value):
self.value=value

The left hand side of line 3 self.value can be thought of as instance.attribute=scalar_value. This will make more sense when we create a instance of the Class.

### Creating an Instance of a Class

In lines 1-4, the class is defined using the special __init__ method as explained above. In line 5 we can type in the variable name instance0.

class ScalarClass(object):
def __init__(self,value):
self.value=value

instance0=ScalarClass(

If we then assign it to an instance of ScalarClass with open parenthesis. We can see that we are prompted to type in value as a positional input argument. Let's use a simple integer value of 5.

class ScalarClass(object):
def __init__(self,value):
self.value=value

instance0=ScalarClass(5)

The positional input self is not explicitly asked for when calling an instance. Instead the variable name or if you prefer instance name, in this case instance0 is automatically taken as the value of self for this given instance. Therefore if we type in the instance of the class in this case instance0 followed by a dot . and tab .

We will see that we can access the attribute value. If we type in:

instance0.value

We return the value 5. In other words within the __init__ method the line below leads to the creation of an instance attribute.

self.value=value

For instance0:

instance0.value=5

Every instance of this class will utilise the special __init__ method and have the attribute called value. For example we can create two instances:

class ScalarClass(object):
def __init__(self,value):
self.value=value

instance0=ScalarClass(5)
instance1=ScalarClass(2)

And both instances have the attribute value:

instance0.value
instance1.value

It is also possible to create a common attribute within the special __init__ method. For example on line 4 if we assign an attribute and set it to a constant.

class ScalarClass(object):
def __init__(self,value):
self.value=value
self.common='c'

instance0=ScalarClass(5)
instance1=ScalarClass(2)

instance0.common
instance1.common

It is possible to create attributes unique to an instance. For example:

class ScalarClass(object):
def __init__(self,value):
self.value=value
self.common='c'

instance0=ScalarClass(5)
instance1=ScalarClass(2)
instance0.unique='u'

If we attempt to call this attribute:

instance0.unique
instance1.unique

We can access the attribute on the instance the attribute was created but we get an AttributeError: 'ScalarClass' object has no attribute 'unique' on the instance where the attribute wasn't created.

### Positional and Keyword Arguments

Like a function, classes can have both positional input arguments and keyword input arguments. Instances can be called without specifying the keyword input argument and in such a case, the default value of the keyword input argument will be utilized. Alternatively the default value of the keyword input argument can be specified meaning it will override the default value.

We can call the string attribute of each of the instances.

instance0.string
instance1.string

### Class Variables

In the above case we assigned each instance of the Class with a common attribute. This can also be done using a class variable:

class ScalarClass(object):
common='c'
def __init__(self,value):
self.value=value

instance0=ScalarClass(5)
instance1=ScalarClass(2)

The class variable common becomes an attribute for every instance of the class:

instance0.common
instance1.common

A numeric class variable can also be used to count the number of instances in a class. To call the class variable for example within the special method __init__ we need to type dot . index into the class name i.e. the class variable is accessed as a class attribute.

class ScalarClass(object):
n_instances=0
def __init__(self,value):
self.value=value
self.instance=ScalarClass.n_instances
ScalarClass.n_instances+=1

instancea=ScalarClass(5)
instanceb=ScalarClass(2)
instancea.instance
instancea.n_instances
instanceb.instance
instanceb.n_instances

We can see that each instance is assigned an instance number as an attribute instance and the number of instances n_instances increases as instances are created. Note the code in the script is ran before the code in the console. We can also create a new instance directly in the console to see n_instances increase.

instancec.instance
instancec.n_instances

### Accessing Another Class Method

It is possible to add another class method. For example if we create a very simple class method called printing we can access it again using dot . notation from the class name. We can call up this class within the special method __init__. Note the method printing had no inputs and thus was called using no inputs when called within __init__.

class ScalarClass(object):
n_instances=0
def __init__(self,value):
self.value=value
self.instance=ScalarClass.n_instances
ScalarClass.n_instances+=1
ScalarClass.printing()

def printing():
print('Creating Instance')

instancea=ScalarClass(5)
instanceb=ScalarClass(2)

We can create another instance in the console.

instancec=ScalarClass(7)

This method can also be accessed externally using dot . notation following the class name however we will get an error if we try to access it from an instance name we are presented with the error TypeError: printing() takes 0 positional arguments but 1 was given.

ScalarClass.printing()
instancea.printing()

This error may appear to be confusing on first glance however when accessing a method from an instance name, the instance name is always taken as the input argument self.

instance0.print(self=instance0)

This class method however was defined without any input arguments and for this reason we get the TypeError saying we have provided 1 input argument self but the class method printing takes 0 input arguments.

Let's look at this in more detail by making a slightly more complicated method printing2 and during definition supplying self as an input. Doing so will allow us to call up the attributes of the instance in this case self.instance and self.value within the method. Once again we can call this method up within the special method __init__ by using dot . notation following the class name. As the input argument self is defined in the definition of printing2 it must be provided.

class ScalarClass(object):
n_instances=0
def __init__(self,value):
self.value=value
self.instance=ScalarClass.n_instances
ScalarClass.n_instances+=1
ScalarClass.printing()
ScalarClass.printing2(self)

def printing():
print('Creating Instance:')

def printing2(self):
print(f'instance number {self.instance}')
print(f'instance value {self.value}')
print('\n')

instancea=ScalarClass(5)
instanceb=ScalarClass(2)

Failure to provide self here will once again lead to a TypeError: printing2() missing 1 required positional argument: 'self'.

Correcting the above we can attempt to call the class variable using dot . notation from the class name.

ScalarClass.printing2()

Doing so without explicitly providing an instance will yield another TypeError: printing2() missing 1 required positional argument: 'self'.

We need to provide an instance within the parenthesis for example:

ScalarClass.printing2(instancea)

Calling the method directly from the instance will also work this time as the instance name will automatically be assigned to self.

instancea.printing2()

In this case instancea.printing() and ScalarClass2.printing(instancea) can be thought of as equivalent however as we seen above this will only work if the method has self supplied as an input during definition. Compare also when we call the method from the instance and the method from the class using open parenthesis. When calling the method from the Class name we are prompted to provide the instance self but when calling the method from an instance we aren't prompted to specify self as it is already implied.

### Inheritance

Let us create a child class called ChildClass. We will assign its parent as ScalarClass and otherwise leave this class blank by only typing in pass under ChildClass. In this case the ChildClass will inherit the special method __init__ from the parent class ScalarClass. Once again if we call an instance of ChildClass with open parenthesis, we will see that we are prompted for the positional input argument value.

class ScalarClass(object):
def __init__(self,value):
self.value=value
self.common='c'

class ChildClass(ScalarClass):
pass

instance0=ChildClass(

We can then therefore create an instance:

class ScalarClass(object):
def __init__(self,value):
self.value=value
self.common='c'

class ChildClass(ScalarClass):
pass

instance0=ChildClass(5)

And from this instance, access the attributes common and value.

instance0.common
instance0.value

On the other hand if we specify an __init__ method in ChildClass, it will override the __init__ method in the parent class. In this case we will see that we are only prompted to input in value2 when calling the ChildClass.

class ScalarClass(object):
def __init__(self,value):
self.value=value
self.common='c'

class ChildClass(ScalarClass):
def __init__(self,value,value2):
self.value2=value2

instance0=ChildClass(

We can then create an instance:

class ScalarClass(object):
def __init__(self,value):
self.value=value
self.common='c'

class ChildClass(ScalarClass):
def __init__(self,value,value2):
self.value2=value2

instance0=ChildClass(10,20)

And from this we can attempt to access the attributes:

instance0.value2
instance0.value
instance0.common

In this case we can only access the attributes defined within the child class ChildClass __init__ method and cannot access the attributes defined in the parent class ScalarClass.

To rectify this, the ChildClass, __init__ method has to be updated to include all the necessary positional input arguments (this was already the case) and within the ChildClass special __init__ method, the parents special __init__ method has to be called. This is done by typing in the name of the parent class followed by a dot . and then the parent's __init__ method including the self.

class ScalarClass(object):
def __init__(self,value):
self.value=value
self.common='c'

class ChildClass(ScalarClass):
def __init__(self,value,value2):
ScalarClass.__init__(self,value)
self.value2=value2

instance0=ChildClass(10,20)

In this case all the attributes can be accessed that were defined in both the parent and child classes __init__ methods respectively. For example:

instance0.common
instance0.value
instance0.value2

If an attribute has been redefined in the __init__ method of the ChildClass the ChildClass class will use the redefined version. For example if the attribute self.value is redefined to equal value in the ChildClass.

class ScalarClass(object):
def __init__(self,value):
self.value=value
self.common='c'

class ChildClass(ScalarClass):
def __init__(self,value,value2):
ScalarClass.__init__(self,value)
self.value2=value2
self.value=-value

instance0=ChildClass(10,20)

Then we can see this change reflected when we attempt to access the attributes:

instance0.common
instance0.value
instance0.value2

### Custom Methods "Getters" and "Setters"

So far we have only created a class which has a special __init__ method which is used to create attributes. It is common to create custom methods to get and set an attribute respectively which are known as getters and setters. For example:

class ScalarClass(object):
def __init__(self,value):
self.value=value

def get_value(self):
return(self.value)

def set_value(self,new_value):
self.value=new_value

Let's create an instance in the console:

instance0=ScalarClass(10)

Note that we can type in the variable name (instance name) followed by a dot . and then tab to access the methods.

instance0.

Note that get_value is a method, set_value is a method and value is an attribute. We can type in the method get_value with open parenthesis:

instance0.get_value(

In this case we aren't prompted for any input arguments. This is once again because self is implied from the instance name.

Alternatively if we call the method from the class name, we are prompted to provide the instance self as it isn't implied.

ScalarClass.get_value(

Calling this gets the instance attribute value.

instance0.get_value()

We can also look at the method set_value with open parenthesis.

instance0.set_value(

Here we see that we need to provide the input argument new_value.

If we instead call it from the class, we will be prompted for both the instance self and the new_value.

ScalarClass.set_value(

We can use the method set_value to assign value to a new value 11.

ScalarClass.set_value(instance0,11)
instance0.get_value()

### Assertion of Inputs

Looking at the code above, at first glance there may seem to be little to no advantage of using a getter and setter opposed to accessing an attribute directly. However if we make a new class called ScalarIntClass and assert that the input int1 must be an integer and modify the setter using a similar assertion.

class ScalarIntClass(object):
def __init__(self,int1):
assert(isinstance(int1,int)), "int1 must be an integer"
self.int1=int1

def get_int(self):
return(self.int1)

def set_int(self,new_int1):
assert(isinstance(new_int1,int)), "new_int1 must be an integer"
self.int1=new_int1

We can see that when creating a new instance of a class, we can only do so using an integer. Alternatively if we use the set_int method, we are only allowed to do so using an integer.

instance0=ScalarIntClass(10)
instance1=ScalarIntClass(11.5)

instance0.set_int(12)
instance0.set_int(11.5)

The code above does not prevent us from accessing the attribute directly and setting it to a string for example (which in a more complicated class could break additional methods which may for example expect an integer or numerical input). If we type in the instance name followed by a dot . and then tab . We can see the attribute:

We can then set it to a string which the rest of the code does not intend.

instance0.int1='a'

### Private Attributes

In order to prevent the attribute from being accessed directly and for example in the case allowing reassignment to a string. It is possible to use a private attribute. A private attribute name begins with an underscore. Let's modify the code above to set int1 to a private attribute:

class ScalarIntClass(object):
def __init__(self,int1):
assert(isinstance(int1,int)), "int1 must be an integer"
_int1=int1
self._int1=_int1

def get_value(self):
return(self._int1)

def set_value(self,new_int1):
assert(isinstance(new_int1,int)), "new_int1 must be an integer"
self._int1=new_int1

instance0=ScalarIntClass(2)

Now when we type in the instance followed by a dot . and tab only the methods get_int() and set_int() display.

Now we can only get and set the value of this hidden attribute using our specially designed methods.

instance0.get_value()
instance0.set_value(13)
instance0.get_value()

### The __str__ special method (print)

The __str__ special method should return a string. This string will be returned if the print command is used around an instance. For example:

class ScalarClass(object):
def __init__(self,value):
self.value=value

def get_value(self):
return(self.value)

def set_value(self,new_value):
self.value=new_value

def __str__(self):
return(str(ScalarClass.get_value(self)))

instance0=ScalarClass(10)
print(instance0)

We can modify this so our output is enclosed in brackets for example or in this case just to be different enclosed in question marks.

class ScalarClass(object):
def __init__(self,value):
self.value=value

def get_value(self):
return(self.value)

def set_value(self,new_value):
self.value=new_value

def __str__(self):
return("¿"+str(ScalarClass.get_value(self))+"?")

instance0=ScalarClass(10)
print(instance0)    

### The __repr__ special method

The __repr__ special method displays how an instance is shown when input into the console.

class ScalarClass(object):
def __init__(self,value):
self.value=value

def get_value(self):
return(self.value)

def set_value(self,new_value):
self.value=new_value

def __str__(self):
return("¿"+str(ScalarClass.get_value(self))+"?")

def __repr__(self):
return("ScalarClass("+str(ScalarClass.get_value(self))+")")

instance0=ScalarClass(10)  

The console will return this when an instance is called.

instance0

### The __len__ special method

The __len__ special method displays the length of an object when the len() function is used. It should return an int. Because this is a scalar we can set the length to always be 1.

class ScalarClass(object):
def __init__(self,value):
self.value=value

def get_value(self):
return(self.value)

def set_value(self,new_value):
self.value=new_value

def __str__(self):
return("¿"+str(ScalarClass.get_value(self))+"?")

def __repr__(self):
return("ScalarClass("+str(ScalarClass.get_value(self))+")")

def __len__(self):
return(1)
instance0=ScalarClass(23)
len(instance0)

### Self and Other

So far we have only looked at a method that gets or sets an attribute from a single instance. It is common to make a method which compares the self instance to an other instance.

class ScalarClass(object):
def __init__(self,value):
self.value=value

def get_value(self):
return(self.value)

def set_value(self,new_value):
self.value=new_value

def __repr__(self):
return("ScalarClass("+str(ScalarClass.get_value(self))+")")



This method can be called from the class or from an instance. Note that once again the self input argument needs to be provided when calling the method directly from the class because the self instance otherwise is not defined but does not to be supplied when calling the method from an instance as it is implied:

instance0=ScalarClass(1)
instance1=ScalarClass(2)
instance2
instance3

### Using the special methods on a scalar

We seen earlier that the special method __str__ mapped to print(). The special method __add__ maps to the + operator.

For a string the underlying code for the special method __add__ performs a concatenation between the self instance and the other instance. In the case of a number the special method __add__ instead performs an addition. This is the reason why the behaviour is different for the str class and the int class for example:

"a"+"b"
2+4

In place of our earlier method name addition we can use the special method __add__. Now:

class ScalarClass(object):
def __init__(self,value):
self.value=value

def get_value(self):
return(self.value)

def set_value(self,new_value):
self.value=new_value

def __str__(self):
return("¿"+str(ScalarClass.get_value(self))+"?")

def __repr__(self):
return("ScalarClass("+str(ScalarClass.get_value(self))+")")

instance0=ScalarClass(10)
instance1=ScalarClass(20)

We can use the following commands or the more commonly used + operator.

ScalarClass.__add__(instance0,instance1)
instance0+instance1
instance2=instance0+instance1
instance2

We can now go ahead and define a number of additional mathematical methods applicable to a scalar.

class ScalarClass(object):
def __init__(self,value):
# initializes a scalar value as value
self.value=value

def get_value(self):
# custom method to get the value
return(self.value)

def set_value(self,new_value):
# custom method to set the value
self.value=new_value

def __str__(self):
# Defines the way the print command works with an instance
# print(self)
return("¿"+str(ScalarClass.get_value(self))+"?")

def __repr__(self):
# Formal representation of object.
return("ScalarClass("+str(ScalarClass.get_value(self))+")")

def __len__(self):
# special method that
# returns the length when using
# len(self)
return(1)

# special method that
# defines the behaviour of the + operator
# self+value

def __sub__(self,other):
# special method that
# defines the behaviour of the - operator
# self-value
sub_value=(ScalarClass.get_value(self)-ScalarClass.get_value(other))
return(ScalarClass(sub_value))

def __mul__(self,other):
# special method that
# defines the behaviour of the * operator
# self*value
mul_value=(ScalarClass.get_value(self)*ScalarClass.get_value(other))
return(ScalarClass(mul_value))

def __truediv__(self,other):
# special method that
# defines the behaviour of the / operator
# self+value
div_value=(ScalarClass.get_value(self)/ScalarClass.get_value(other))
return(ScalarClass(div_value))

def __floordiv__(self,other):
# special method that
# defines the behaviour of the // operator
# self//value
full_value=(ScalarClass.get_value(self)//ScalarClass.get_value(other))
return(ScalarClass(full_value))

def __mod__(self,other):
# special method that
# defines the behaviour of the % operator
# self%value
rem_value=(ScalarClass.get_value(self)%ScalarClass.get_value(other))
return(ScalarClass(rem_value))

def __divmod__(self,other):
# special method that
# defines the behaviour of the divmod
# divmod(self,value)
divmodtuple=(divmod(ScalarClass.get_value(self),ScalarClass.get_value(other)))
return(ScalarClass(divmodtuple))

def __pow__(self,other):
# special method that
# defines the behaviour of the ** operator
# self**value
return ScalarClass(ScalarClass.get_value(self)**ScalarClass.get_value(other))

def __eq__(self,other):
# special method that
# defines the behaviour of the == operator
# self==other
return ScalarClass(ScalarClass.get_value(self)==ScalarClass.get_value(other))

def __ne__(self,other):
# special method that
# defines the behaviour of the != operator
# self!=other
return ScalarClass(ScalarClass.get_value(self)!=ScalarClass.get_value(other))

def __lt__(self,other):
# special method that
# defines the behaviour of the < operator
# self<other
return ScalarClass(ScalarClass.get_value(self)<ScalarClass.get_value(other))

def __gt__(self,other):
# special method that
# defines the behaviour of the > operator
# self>other
return ScalarClass(ScalarClass.get_value(self)>ScalarClass.get_value(other))

def __le__(self,other):
# special method that
# defines the behaviour of the <= operator
# self<=other
return ScalarClass(ScalarClass.get_value(self)<=ScalarClass.get_value(other))

def __ge__(self,other):
# special method that
# defines the behaviour of the >= operator
# self>=other
return ScalarClass(ScalarClass.get_value(self)>=ScalarClass.get_value(other))

def __and__(self,other):
# special method that
# defines the behaviour of the & operator
# self&other
and_value=ScalarClass.get_value(self)&ScalarClass.get_value(other)
return(ScalarClass(and_value))

def __or__(self,other):
# special method that
# defines the behaviour of the | operator
# self|other
or_value=ScalarClass.get_value(self)|ScalarClass.get_value(other)
return(ScalarClass(or_value))

def __xor__(self,other):
# special method that
# defines the behaviour of the ^ operator
# self^other
xor_value=ScalarClass.get_value(self)^ScalarClass.get_value(other)
return(ScalarClass(xor_value))

def __lshift__(self,other):
# special method that
# defines the behaviour of the << operator
# self<<other
lshift_value=ScalarClass.get_value(self)<<ScalarClass.get_value(other)
return(ScalarClass(lshift_value))

def __rshift__(self,other):
# special method that
# defines the behaviour of the >> operator
# self>>other
rshift_value=ScalarClass.get_value(self)>>ScalarClass.get_value(other)
return(ScalarClass(rshift_value))

instance0=ScalarClass(10)
instance1=ScalarClass(20)
instance0+instance1
instance0-instance1
instance0*instance1
instance0/instance1
instance0//instance1
instance0%instance1

This will allow a number of other operators to be used with the custom ScalarClass. The custom method __iadd__ is very similar to __add__ except the original value of self is updated opposed to defining a new output.

class ScalarClass(object):
def __init__(self,value):
# initializes a scalar value as value
self.value=value

def get_value(self):
# custom method to get the value
return(self.value)

def set_value(self,new_value):
# custom method to set the value
self.value=new_value

def __str__(self):
# Defines the way the print command works with an instance
# print(self)
return("¿"+str(ScalarClass.get_value(self))+"?")

def __repr__(self):
# Formal representation of object.
return("ScalarClass("+str(ScalarClass.get_value(self))+")")

def __len__(self):
# special method that
# returns the length when using
# len(self)
return(1)

# special method that
# defines the behaviour of the + operator
# self+value

# special method that
# defines the behaviour of the += operator
# self+=value
ScalarClass.set_value(self,(ScalarClass.get_value(self)+ScalarClass.get_value(other)))
return(ScalarClass.get_value(self))

instance0=ScalarClass(2)
instance1=ScalarClass(3)
instance0
instance0+instance1
instance0
instance0+=instance1
instance0

## A Custom Fraction Class

We can create a custom class Fraction to work with fractions.

Multiplication of fractions:

$\displaystyle \frac{a}{b}\times \frac{c}{d}=\frac{{a\times c}}{{b\times d}}$

Division of fractions:

$\displaystyle \frac{a}{b}/\frac{c}{d}=\frac{a}{b}\times \frac{d}{c}=\frac{{a\times d}}{{b\times c}}$

$\displaystyle \frac{a}{b}+\frac{c}{d}=\frac{a}{b}\times \frac{d}{d}+\frac{b}{b}\times \frac{c}{d}=\frac{{d\times a+b\times c}}{{b\times d}}$

Subtraction of fractions:

$\displaystyle \frac{a}{b}-\frac{c}{d}=\frac{a}{b}\times \frac{d}{d}-\frac{b}{b}\times \frac{c}{d}=\frac{{d\times a-b\times c}}{{b\times d}}$

To do this we need to use the special method __init__ to initialize two attributes n and d for the numerator and denominator respectively. We can assert that these as n and d respectively and assert that these are integers and the d is non-zero. Next we can simplify the fraction

using the highest common factor between n and d.

class Fraction(object):
def __init__(self,n,d):
"""
Fraction Class
n is numerator and must be int
d is denominator and must be int
"""
# initializes the attributes
assert(isinstance(n,int)), "n must be an integer"
assert(isinstance(d,int)), "d must be an integer"
assert(d!=0), "d must be non-zero"
# Simplifying by the Greatest Common Factor
self.n=n
self.d=d
if n==0:
self.n=0
self.d=1
else:
if n>d:
for i in range(1,n+1):
if (d%i==0 and n%i==0):
self.n=int(n/i)
self.d=int(d/i)
else:
for i in range(1,d+1):
if (d%i==0 and n%i==0):
self.n=int(n/i)
self.d=int(d/i)  

We can test this with:

f0=Fraction(1,2)
f0.n, f0.d
f1=Fraction(4,2)
f1.n, f1.d
f2=Fraction(2,4)
f2.n, f2.d
f3=Fraction(2,1)
f3.n, f3.d
f4=Fraction(0,2)
f4.n, f4.d
f5=Fraction(15,18)
f5.n, f5.d
f6=Fraction(18,15)
f6.n, f6.d
f7=Fraction(2,0)
f8=Fraction(1.5,3)

We can see that the fractions automatically simplify as expected only in the cases where they are expected to.

We can then modify the above so the __init__ special method uses input arguments n and d but stores hidden attributes _n and _d respectively. With this we can create a getters and setters for the hidden attributes _n and _d respectively. Note that the setters have the same restrictions on a valid value of _n and _d being an int and _n being non-zero. They will also use a similar code to the __init__ special method to simplify the fraction by the lowest common factor.

We can then create the special method __repr__ which calls get_n and get_d.

class Fraction(object):
def __init__(self,n,d):
"""
Fraction Class
n is numerator and must be int
d is denominator and must be int
"""
# initializes the attributes
assert(isinstance(n,int)), "n must be an integer"
assert(isinstance(d,int)), "d must be an integer"
assert(d!=0), "d must be non-zero"
# Simplifying by the Greatest Common Factor
self._n=n
self._d=d
if n==d:
self._n=1
self._d=1
elif n==0:
self._n=0
self._d=1
else:
if n>d:
for i in range(1,n+1):
if (d%i==0 and n%i==0):
self._n=int(n/i)
self._d=int(d/i)
else:
for i in range(1,d+1):
if (d%i==0 and n%i==0):
self._n=int(n/i)
self._d=int(d/i)
def get_n(self):
# custom method to get the numerator n
return(self._n)

def get_d(self):
# custom method to get the denominator d
return(self._d)

def set_n(self,new_n):
# custom method to set the numerator n
assert(isinstance(new_n,int)), "n must be an integer"
self._n=new_n
# Simplifying by the Greatest Common Factor
n=Fraction.get_n(self)
d=Fraction.get_d(self)
self._n=n
self._d=d
if n==d:
self._n=1
self._d=1
elif n==0:
self._n=0
self._d=1
else:
if n>d:
for i in range(1,n+1):
if (d%i==0 and n%i==0):
self._n=int(n/i)
self._d=int(d/i)
else:
for i in range(1,d+1):
if (d%i==0 and n%i==0):
self._n=int(n/i)
self._d=int(d/i)

def set_d(self,new_d):
# custom method to set the numerator d
assert(isinstance(new_d,int)), "new_d must be an integer"
assert(new_d!=0), "new_d must be non-zero"
self._d=new_d
# Simplifying by the Greatest Common Factor
n=Fraction.get_n(self)
d=Fraction.get_d(self)
self._n=n
self._d=d
if n==d:
self._n=1
self._d=1
elif n==0:
self._n=0
self._d=1
else:
if n>d:
for i in range(1,n+1):
if (d%i==0 and n%i==0):
self._n=int(n/i)
self._d=int(d/i)
else:
for i in range(1,d+1):
if (d%i==0 and n%i==0):
self._n=int(n/i)
self._d=int(d/i)

def __str__(self):
# Defines the way the fraction prints
# print(self)
return("("+str(Fraction.get_n(self))+"/"+str(Fraction.get_d(self))+")")

def __repr__(self):
# Formal representation of the Fraction.
return("Fraction("+str(Fraction.get_n(self))+","+str(Fraction.get_d(self))+")")


Once again we can test this using the same cases above in the console. This time instance names aren't going to be created however __repr__ will be called because we are in the console showing the simplified fractions

Fraction(1,2)
Fraction(4,2)
Fraction(2,4)
Fraction(2,1)
Fraction(15,18)
Fraction(18,15)

Alternatively we can create an instance and access the methods by typing the instance name followed by a dot . and then a tab :

Finally the mathematical expressions for +, +=, , -=, *, *=, / and /= can be created.

class Fraction(object):
def __init__(self,n,d):
"""
Fraction Class
n is numerator and must be int
d is denominator and must be int
"""
# initializes the attributes
assert(isinstance(n,int)), "n must be an integer"
assert(isinstance(d,int)), "d must be an integer"
assert(d!=0), "d must be non-zero"
# Simplifying by the Greatest Common Factor
self._n=n
self._d=d
if n==0:
self._n=0
self._d=1
else:
if n>d:
for i in range(1,n+1):
if (d%i==0 and n%i==0):
self._n=int(n/i)
self._d=int(d/i)
else:
for i in range(1,d+1):
if (d%i==0 and n%i==0):
self._n=int(n/i)
self._d=int(d/i)
def get_n(self):
# custom method to get the numerator n
return(self._n)

def get_d(self):
# custom method to get the denominator d
return(self._d)

def set_n(self,new_n):
# custom method to set the numerator n
assert(isinstance(new_n,int)), "n must be an integer"
self._n=new_n
# Simplifying by the Greatest Common Factor
n=Fraction.get_n(self)
d=Fraction.get_d(self)
self._n=n
self._d=d
if n==0:
self._n=0
self._d=1
else:
if n>d:
for i in range(1,n+1):
if (d%i==0 and n%i==0):
self._n=int(n/i)
self._d=int(d/i)
else:
for i in range(1,d+1):
if (d%i==0 and n%i==0):
self._n=int(n/i)
self._d=int(d/i)

def set_d(self,new_d):
# custom method to set the numerator d
assert(isinstance(new_d,int)), "new_d must be an integer"
assert(new_d!=0), "new_d must be non-zero"
self._d=new_d
# Simplifying by the Greatest Common Factor
n=Fraction.get_n(self)
d=Fraction.get_d(self)
self._n=n
self._d=d
if n==0:
self._n=0
self._d=1
else:
if n>d:
for i in range(1,n+1):
if (d%i==0 and n%i==0):
self._n=int(n/i)
self._d=int(d/i)
else:
for i in range(1,d+1):
if (d%i==0 and n%i==0):
self._n=int(n/i)
self._d=int(d/i)

def __str__(self):
# Defines the way the fraction prints
# print(self)
return("("+str(Fraction.get_n(self))+"/"+str(Fraction.get_d(self))+")")

def __repr__(self):
# Formal representation of the Fraction.
return("Fraction("+str(Fraction.get_n(self))+","+str(Fraction.get_d(self))+")")

def __len__(self):
# special method that
# returns the length when using
# len(self)
return(1)

def __float__(self):
# special method that
# returns the value as a float
# float()
n=Fraction.get_n(self)
d=Fraction.get_d(self)
return(n/d)

def __bool__(self):
# special method that
# returns the value as a bool
# bool()
n=Fraction.get_n(self)
if n==0:
return(False)
else:
return(True)

def __mul__(self,other):
# special method that
# defines the behaviour of the * operator
# self*value
new_n=(Fraction.get_n(self)*Fraction.get_n(other))
new_d=(Fraction.get_d(self)*Fraction.get_d(other))
return(Fraction(new_n,new_d))

def __imul__(self,other):
# special method that
# defines the behaviour of the += operator
# self*=value
new_n=(Fraction.get_n(self)*Fraction.get_n(other))
new_d=(Fraction.get_d(self)*Fraction.get_d(other))
Fraction.set_n(self,new_n)
Fraction.set_d(self,new_d)
return(Fraction(new_n,new_d))

def __truediv__(self,other):
# special method that
# defines the behaviour of the / operator
# self/value
new_n=(Fraction.get_n(self)*Fraction.get_d(other))
new_d=(Fraction.get_d(self)*Fraction.get_n(other))
return(Fraction(new_n,new_d))

def __itruediv__(self,other):
# special method that
# defines the behaviour of the /= operator
# self/=value
new_n=(Fraction.get_n(self)*Fraction.get_d(other))
new_d=(Fraction.get_d(self)*Fraction.get_n(other))
Fraction.set_n(self,new_n)
Fraction.set_d(self,new_d)
return(Fraction(new_n,new_d))

# special method that
# defines the behaviour of the + operator
# self+value
new_n=((Fraction.get_n(self)*Fraction.get_d(other))+(Fraction.get_n(other)*Fraction.get_d(self)))
new_d=(Fraction.get_d(self)*Fraction.get_d(other))
return(Fraction(new_n,new_d))

# special method that
# defines the behaviour of the += operator
# self+=value
new_n=((Fraction.get_n(self)*Fraction.get_d(other))+(Fraction.get_n(other)*Fraction.get_d(self)))
new_d=(Fraction.get_d(self)*Fraction.get_d(other))
Fraction.set_n(self,new_n)
Fraction.set_d(self,new_d)
return(Fraction(new_n,new_d))

def __sub__(self,other):
# special method that
# defines the behaviour of the - operator
# self-value
new_n=((Fraction.get_n(self)*Fraction.get_d(other))-(Fraction.get_n(other)*Fraction.get_d(self)))
new_d=(Fraction.get_d(self)*Fraction.get_d(other))
return(Fraction(new_n,new_d))

def __isub__(self,other):
# special method that
# defines the behaviour of the -= operator
# self-=value
new_n=((Fraction.get_n(self)*Fraction.get_d(other))-(Fraction.get_n(other)*Fraction.get_d(self)))
new_d=(Fraction.get_d(self)*Fraction.get_d(other))
Fraction.set_n(self,new_n)
Fraction.set_d(self,new_d)
return(Fraction(new_n,new_d))   

Now we can now try:

Fraction(1,2)+Fraction(1,2)
Fraction(2,3)-Fraction(1,6)
Fraction(1,2)*Fraction(4,1)
Fraction(1,2)/Fraction(4,1)
instance0=Fraction(1,2)
print(instance0)
len(instance0)
bool(instance0)
float(instance0)
instance0
instance0+=Fraction(1,2)
instance0
instance0-=Fraction(1,2)
instance0
instance0*=Fraction(2,1)
instance0
instance0/=Fraction(2,1)
instance0