<jupyter_start><jupyter_text>The Anatomy of a Python Class (Part I)---> Author: <NAME>  > Email: <EMAIL>  > Year: 2019  > Version: 1.0---Classes are one of the fundamental building blocks of Python and are essential for object-oriented programming. In this tutorial we will explore how classes work and look at tips and tricks for getting the most out of them. By the end you should have, not only a much better understanding of what Python classes are, but also some new ideas for writing better code.This first notebook presents some introductory and intermidate topics. The focus being on a intimate understanding of how classes work in Python. You are expected to already have a good grasp on functions, dictionaries and object types. More advanced topics are discussed in the [second notebook](./Classes_II.ipynb). This tutorial is in no way exhaustive and you are encouraged to suppliment your understanding with further reading. To this end, some links to some additional resources are provided.If you are new to Jupyter notebooks note that cells are executed by pressing SHIFT+ENTER (&x21E7;+ &x23ce;). See the Jupyter documentation for more details. Contents---1. [Set-Up](1-Set-Up)1. [Object-Oriented Programming](2-Object-Oriented-Programming)  1. [A Simple Example](A-Simple-Example)1. [Disecting a Class](3-Disecting-a-Class)  1. [The Class Dictionary](The-Class-Dictionary)  1. [Instantiation](Instantiation)1. [Methods](4-Methods)  1. [Functions as Attributes](Functions-as-Attributes)  1. [Static Methdos](Static-Methods)  1. [Class Methods](Class-Methods)  1. [Instance Methods](Instance-Methods)  1. [Mixing Methods](Mixing-Methods)1. [Properties](5-Properties)  1. [Getters and Setters](Getters-and-Setters)  1. [Class Properties](Class-Properties)1. [Operator Overloading](6-Operator-Overloading)  1. [Class String](Class-String)  1. [Class Representation](Class-Representation)  1. [Mathematical Operators](Mathematical-Operators)  1. [Further Options](Further-Options)1. [Exercises](7-Exercises)  1 Set-UpThe following cell contains some set-up commands. Be sure to execute this cell before continuing.<jupyter_code># Notebook Set-Up Commands

from math import pi, sqrt

def print_error(error):
    """ Print Error
    
    Function to print exceptions in red.
    
    Parameters
    ----------
    error : string
        Error message
    
    """
    print('\033[1;31m{}\033[1;m'.format(error))<jupyter_output><empty_output><jupyter_text>2 Object-Oriented Programming---Object-Oriented Programming (OOP) is based around the concept of *[objects](https://en.wikipedia.org/wiki/Object_(computer_science))*, which in turn are usually generated by *[classes](https://en.wikipedia.org/wiki/Class_(computer_programming))*. The philosophy of OOP is generally summarised by four core concepts:* [abstraction](https://en.wikipedia.org/wiki/Abstraction_(computer_science)) - *only showing certain features to the user** [encapsulation](https://en.wikipedia.org/wiki/Encapsulation_(computer_programming)) - *grouping together certain features** [inheritance](https://en.wikipedia.org/wiki/Inheritance_(object-oriented_programming)) - *reusing and extending features** [polymorphism](https://en.wikipedia.org/wiki/Polymorphism_(computer_science)) - *allow objects to take various forms*Some of the main benfits that OOP provides, which are directly linked to these core concepts are:* **reusability** - *minimising the amount of copying and pasting of code** **maintainability** - *minimising the amount of code that needs to be changed to fix a bug** **security** - *minimising the amount of misuse by a user*In this tutorial we will take an in-depth look at Python classes, which should provide you with all of the tools you need to take advantage of OOP.  A Simple ExampleBefore diving into what a class is and how it works let's take a moment to understand why you would ever need a class in the first place by looking at a very simple example.Imagine you are working on a project where you need to keep track of various properties of the planets in the Solar System. Specifically, you want to know the radius (in $\textrm{km}$) at the equator, the mass (in $\textrm{kg}$) and the gravitational acceleration at the surface (in $\textrm{ms}^{-2}$). There are obviously many different ways of doing this. The simplest (in terms of coding) would be to define a variable for each parameter.<jupyter_code># Set individual parameter values for the Earth
earth_radius = 6371
earth_mass = 5.97e24
earth_gravity = 9.8

# Print the Earth properties
print('planet Earth')
print(' - Radius: {}km'.format(earth_radius))
print(' - Mass: {}kg'.format(earth_mass))
print(' - Gravity: {}m/s'.format(earth_gravity))<jupyter_output><empty_output><jupyter_text>This approach would require us to define 21 more variables if we extended it for all of the other planets in the Solar System. It's pretty clear that this becomes quite messy and difficult to maintain as the number of parameters increases.Another approach would be to define a single variable for each planet, and a simple way to store all of the paremeters would be using a list (or a tuple).<jupyter_code># Set list values for the Earth
earth = [6371, 5.97e24, 9.8]

# Print the Earth properties
print('planet Earth')
print(' - Radius: {}km'.format(earth[0]))
print(' - Mass: {}kg'.format(earth[1]))
print(' - Gravity: {}m/s'.format(earth[2]))<jupyter_output><empty_output><jupyter_text>This certainly makes things easier, as we only need to define 7 more variables. The problem here is that we now need to keep track of the order of the paremers in the list, which, in addition to be being less readable, also becomes complicated as the number of parameters increases.A third option would be to use a dictionary. This allows us to keep the simplicty of a list-like structure, with a single variable for each planet, but also allows us to add labels to the parameter values.<jupyter_code># Set dictionary keys and values for the Earth
earth = {'radius': 6371, 'mass': 5.97e24, 'gravity': 9.8}

# Print the Earth properties
print('planet Earth')
print(' - Radius: {}km'.format(earth['radius']))
print(' - Mass: {}kg'.format(earth['mass']))
print(' - Gravity: {}m/s'.format(earth['gravity']))<jupyter_output><empty_output><jupyter_text>This solution is pretty good but we still need to manually define a dictionary for each planet.Let's try tackling this problem using a an object-oriented approach. For this solution we can define a generic class for handling planets. Each planet then constitutes an *object* or instance of the class.> For now don't worry too much about the syntax and content of this class as we will explore this in depth in the following sections.<jupyter_code># Define a planet class
class Planet:
    
    # class variable
    object_type = 'planet'
    
    # class initialiser
    def __init__(self, name, radius, mass, gravity):
        
        # instance variables
        self.name = name
        self.radius = radius
        self.mass = mass
        self.gravity = gravity
       
    # class instance method
    def show(self):
        
        # Print the object properties
        print(self.object_type, self.name)
        print(' - Radius: {}km'.format(self.radius))
        print(' - Mass: {}kg'.format(self.mass))
        print(' - Gravity: {}m/s'.format(self.gravity))
        print()
        
# create class instances
earth = Planet('Earth', 6371, 5.97e24, 9.8)
earth.show()

jupiter = Planet('Jupiter', 69911, 1.89e27, 24.79)
jupiter.show()

saturn = Planet('Saturn', 58232, 5.68e26, 10.45)
saturn.show()<jupyter_output><empty_output><jupyter_text>This approach is a bit more complicated in terms of the amount of code required initially but once the class is defined it is very easy to generate instances for each planet. We can even define properties for all instances (*e.g.* the `object_type`).Hopefully this simple example has given you a sneak preview of the power of classes. In the following sections we will take a deeper look at how classes work and the various ways in which they can be used. Once you have mastered these concepts you may find that you will want to rethink the way in which you structure your codes moving towards a more object-oriented implementation.  3 Disecting a Class---To get a better grip on what a class is and how it works lets have a look at what is going on "inside". We can start by defining a dummy class and looking at its properties.Classes are defined with the keyword `class`, much like the keyword `def` is used for defining functions. In the following cell we define a class without any attributes (note the use of the null `pass` statement).<jupyter_code># Define a dummy class
class myClass:
    pass<jupyter_output><empty_output><jupyter_text>This object clearly has little use, but it is a good starting place to look at the structure of classes in general. Let's look at the `help` for this class.<jupyter_code># Print help for dummy class
help(myClass)<jupyter_output><empty_output><jupyter_text>We can see that despite not setting attributes we have two default attributes predefined, namely `__dict__` and `__weakref__`. `__weakref__` is a special attribute that lists *weak references* to the class object (a topic outside the scope of this tutorial). Instead, we will focus on the `__dict__` attribute. The Class DictionaryLet's begin by printing the contents and type of `__dict__` for our dummy class.<jupyter_code># Print the class dictionary
print('myClass.__dict__ =', myClass.__dict__)
print()
print('__dict__ is of type', type(myClass.__dict__))<jupyter_output><empty_output><jupyter_text>This attribute is a *mappingproxy*, which is a special type of dictionary that does not have a `__setattr__` method (we will come back to this). In addition to the default attributes discussed above we can see:* `'__doc__'` : The class docstring (defaults to `None`).* `'__module__'` : The name of module in which the class was defined.Now, let's see what happens when we assign a new attribute to the class. Class attributes are accessed and set using a dot (`.`) following the class name.> Note that any Python object can be assigned as a class attribute. We will come back to this concept later!<jupyter_code># Assign the attribute myattr with value True to the class
myClass.myattr = True

# Print the class dictionary
print('myClass.__dict__ =', myClass.__dict__)<jupyter_output><empty_output><jupyter_text>We can see that the class dictionary has a new entry with the key `myattr` and corresponding value `True`. We can even demonstrate that this dictionary behaves as any other...<jupyter_code># Print the class dictionary with key 'myattr'
print('myattr =', myClass.__dict__['myattr'])<jupyter_output><empty_output><jupyter_text>...except that it does not allow assignment, given the absence of the `__setattr__` method.<jupyter_code># Assign a new value to 'myattr' in the class dictionary
try:
    myClass.__dict__['myattr'] = False
except Exception as error:
    print_error(error)<jupyter_output><empty_output><jupyter_text>Now let's define a new class with some predefined attributes and see what changes.<jupyter_code># Define a new dummy class with some attributes
class myClass:
    """ This is my class 
    """

    mybool = True
    myint = 1
    myfloat = 1.0
    mystring = 'string'
    
# Print the class dictionary    
print(myClass.__dict__)<jupyter_output><empty_output><jupyter_text>All of the attributes can be found in the class dictionary. Notice that `__doc__` is now a string. All of these attributes can be accessed...<jupyter_code># Print the class attributes
print('mybool =', myClass.mybool)
print('myint =', myClass.myint)
print('myfloat =', myClass.myfloat)
print('mystring =', myClass.mystring)
print('mydoc =', myClass.__doc__)<jupyter_output><empty_output><jupyter_text>...and modified.<jupyter_code># Modify the value of the string attribute
myClass.mystring = 'my new string'

# Print the string attribute
print('mystring =', myClass.mystring)<jupyter_output><empty_output><jupyter_text>In summary the class `__dict__` attribute is useful for understanding how class attributes are stored, but should not really be used directly. InstantiationWhile accessing class attributes directly can be useful, in most applications it will be necessary to create a *class instance* (also reffered to simply as *objects*). Every time a class is isntantiated a unique Python class object is created. These objects retain the global class attributes but can also be assigned unique instance attributes.We can create a class instance by calling the class name followed by `()`.<jupyter_code># Create an instance of the class
myinst = myClass()

# Print the instance dictionary
print('myinst.__dict__ =', myinst.__dict__)
print()
print('__dict__ is of type', type(myinst.__dict__))<jupyter_output><empty_output><jupyter_text>Here we see that the instance dictionary is a true dictionary and is empty, which simply means that no *instance attributes* have been set. However, instances still have access the class attributes.<jupyter_code># Print the instance class dictionary
print(myinst.__class__.__dict__)<jupyter_output><empty_output><jupyter_text>We can clearly demonstrate that istances are unique.<jupyter_code># Create two instances of the class
myinst1 = myClass()
myinst2 = myClass()

# Check if instances are equal
print('Instances are equal?', myinst1 == myinst2)<jupyter_output><empty_output><jupyter_text>As with the class itself, attributes can be assigned to the instance object.<jupyter_code># Assign an attribute to the instance
myinst.newbool = False

# Print the instance and dictionaries
print('myinst.__dict__ =', myinst.__dict__)
print()
print('myClass.__dict__ =', myClass.__dict__)<jupyter_output><empty_output><jupyter_text>We can see that the instance attribute has no impact on the class attributes.In order to preassign instance attributes we will need to define a special initialisation (or constructor) method (`__init__`), which takes the special variable `self` as an argument.<jupyter_code># Define a new dummy class with an init method
class myClass:
    
    # Initialisation method called when instance is created
    def __init__(self):
        
        # Set the instance attribute
        self.myfloat = 3.5
        
# Create an instance of the class and print the attribute properties
myinst = myClass()
print('myinst.__dict__ =', myinst.__dict__)
print('myfloat =', myinst.myfloat)<jupyter_output><empty_output><jupyter_text>`self` represents any given class instance and attributes are assigned to it as usual with a dot. Printing the instance dictionary (which is equivalent to `self.__dict__`) reveals the attributes that have been assigned to it.As with class attributes, instance attribute values can be modified.<jupyter_code>myinst.myfloat = 7.2
print('myinst.__dict__ =', myinst.__dict__)
print('myfloat =', myinst.myfloat)<jupyter_output><empty_output><jupyter_text>Additional agruments can be passed to `__init__` allowing for a more instance specific initialisation of the class.<jupyter_code># Define a new dummy class with a more dynamic init method
class myClass:
    
    # Pass the argument value when creating an instance
    def __init__(self, value):
        
        # Set the instance attribute
        self.myfloat = value

# Create instances of the class and print the attribute properties
myinst1 = myClass(3.14)
myinst2 = myClass(6.28)
print('myfloat =', myinst1.myfloat)
print('myfloat =', myinst2.myfloat)<jupyter_output><empty_output><jupyter_text>An important detail to take into account is that instance attributes will override class attributes with the same name, but only for that instance object.<jupyter_code># Define a new dummy class with a class attribute
class myClass:
    
    myfloat = 6.15
    
    def __init__(self):
        pass

# Create 2 instances of the class
myinst1 = myClass()
myinst2 = myClass()
print('myClass.myfloat =', myClass.myfloat)
print('myinst1.myfloat =', myinst1.myfloat)
print('myinst1.__dict__', myinst1.__dict__)
print('myinst2.myfloat =', myinst2.myfloat)
print('myinst2.__dict__', myinst2.__dict__)
print()

# Modify the myfloat attribute of the first instance
myinst1.myfloat = 8.18
print('myClass.myfloat =', myClass.myfloat)
print('myinst1.myfloat =', myinst1.myfloat)
print('myinst1.__dict__', myinst1.__dict__)
print('myinst2.myfloat =', myinst2.myfloat)
print('myinst2.__dict__', myinst2.__dict__)
print()

# Show the first instance class dictionary
print('myinst1.__class__.__dict__', myinst1.__class__.__dict__)<jupyter_output><empty_output><jupyter_text>By now you should have a pretty clear idea of the differences between a class and a clsss instance. When designing a class it is important to think about which attributes need to be assigned to the class and which to the class instances.In the following section we will focus on the various types of method attributes.> Further Reading  > https://codesachin.wordpress.com/2016/06/09/the-magic-behind-attribute-access-in-python/  > https://rushter.com/blog/python-class-internals/  4 Methods--- Functions as AttributesAs briefly mentioned in the previous section, any Python object can be allocated as class attribute. This of course includes functions. To do so we could define a class and a function and manually assign the function object to the a class attribute as follows.<jupyter_code># Define a new dummy class
class myClass:
    pass

# Define a simple function
def say_hello():
    print('Hello!')
    
# Assign the function to the class
myClass.myfunc = say_hello
# Execute the function
myClass.myfunc()

# Print the class dictionary
print()
print(myClass.__dict__)<jupyter_output><empty_output><jupyter_text>We can see that the class is able to call the function and we can also see the object in the class dictionary.If we only want the function to exist as a class attribute we can simply define the function inside the class. These functions are called *class methods*.In the following example we define a calculator class with four methods.<jupyter_code># Define a calculator class
class Calculator:
    
    def add(x, y):
        return x + y
    
    def substract(x, y):
        return x - y

    def multiply(x, y):
        return x * y
    
    def divide(x, y):
        return x / y
    
# Print the class dictionary
print(Calculator.__dict__)<jupyter_output><empty_output><jupyter_text>These methods work the same as any function...<jupyter_code>print('1 + 2 =', Calculator.add(1, 2))
print('5 - 2 =', Calculator.substract(5, 2))
print('7 * 4 =', Calculator.multiply(7, 4))
print('6 / 2 =', Calculator.divide(6, 2))<jupyter_output><empty_output><jupyter_text>... but only exist inside the class.<jupyter_code>try:
    print('1 + 2 =', add(1, 2))
except Exception as error:
    print_error(error)<jupyter_output><empty_output><jupyter_text>While this may be useful for grouping certain methods together we don't gain any new functionality. We could have simply defined the functions in separate module. Static MethodsIf we create an instance of `Calculator` we cannot use its methods, as the instance object is also passed as an argument (we will come back to this).<jupyter_code># Create an instance of the class
calc = Calculator()

try:
    print(calc.add(1, 2))
except Exception as error:
    print_error(error)<jupyter_output><empty_output><jupyter_text>To avoid this problem we can declare that the methods are *static*. This can be done using the `@staticmethod` decorator, which essentially tells the method not to expect the instance object as a first argument.<jupyter_code>class staticCalculator:
    
    @staticmethod
    def add(x, y):
        return x + y
    
    @staticmethod
    def substract(x, y):
        return x - y
    
    @staticmethod
    def multiply(x, y):
        return x * y
    
    @staticmethod
    def divide(x, y):
        return x / y
    
# Print the class dictionary
print(staticCalculator.__dict__)<jupyter_output><empty_output><jupyter_text>We can see that the methods are accessable direcly from the class or from an instance.<jupyter_code># Create an instance of the class
calc = staticCalculator()
print('1 + 2 =', staticCalculator.add(1, 2))
print('1 + 2 =', calc.add(1, 2))<jupyter_output><empty_output><jupyter_text>This is an improvement, but we are still not getting much from the class that we could not already get from individual functions.  Class MethodsSimilarly to static methods, we can use the `@classmethod` decorator to define a class method. This tells the method to expect the class object (`cls`) as the first argument to a given method rather than an instance object. This means class methods have access to all class attributes.In the following example we define a class for implementing the Stefan-Boltzmann law.<jupyter_code>class StefBoltz:
    """ The Stefan–Boltzmann law
    """
    
    # The Stefan-Boltzmann constant
    sigma = 5.670367e-8 # Wm^−2K^−4
        
    @classmethod
    def luminosity(cls, radius, effective_temp):
     
        return 4 * pi * radius ** 2 * cls.sigma * effective_temp ** 4
    
    @classmethod
    def radius(cls, luminosity, effective_temp):
    
        return sqrt(luminosity / (4 * pi * cls.sigma * effective_temp ** 4))
    
    @classmethod
    def temperature(cls, radius, luminosity):
    
        return (luminosity / (4 * pi * radius ** 2 * cls.sigma)) ** 0.25
    
# Print the class dictionary
print(StefBoltz.__dict__)<jupyter_output><empty_output><jupyter_text>We can see that all three methods have access to the class attribute `sigma`, which means that this variable does not have be redefined or passed to each method.<jupyter_code>print('The luminosity of the Sun is {:.2e}W'.format(StefBoltz.luminosity(7e8, 5800)))
print('The radius of Alpha Centauri is {:.2e}m'.format(StefBoltz.radius(4e28, 9700)))<jupyter_output><empty_output><jupyter_text>Class methods can also be called by class instances.<jupyter_code>sb = StefBoltz()
print('The effective temperature of the Earth is {:.2f}K'
      ''.format(sb.temperature(6.37e6, 1.75e+17)))<jupyter_output><empty_output><jupyter_text>Finally, class methods can also access static methods and/or other class methods, meaning that a more complicated class structure can be designed that does not require instantitaion.<jupyter_code>class Newton:
    
    @staticmethod
    def velocity(displacement, time):
        return displacement / time
    
    @classmethod
    def acceleration(cls, displacement, time):
        return cls.velocity(displacement, time) / time
    
    @classmethod
    def force(cls, mass, displacement, time):
        return mass * cls.acceleration(displacement, time)
    
print('Force = {}N'.format(Newton.force(5, 2, 1)))<jupyter_output><empty_output><jupyter_text>While this certainly adds some new functionality, we are still not using class methods to their full potential. Instance MethodsIn the previous section you learned how to instantiate a class using the `self` variable. You also saw that (if we don't add a special decorator) when an instance calls a method it expects an extra agrument. You might have already guessed that this argument is `self` and by defining methods with this argument we can pass instance attributes.This is the default and arguably most powerful way of using methods. In the following example we demonstrate a very simply class for doubling and tripling an input value.<jupyter_code>class simpleClass:
    
    def __init__(self, value):
        self.value = value
        
    def double(self):
        return self.value * 2
    
    def triple(self):
        return self.value * 3

# Create a class instance
sc1 = simpleClass(3)
sc2 = simpleClass(7)
print('Instance 1:', sc1.value, sc1.double(), sc1.triple())
print('Instance 2:', sc2.value, sc2.double(), sc2.triple())<jupyter_output><empty_output><jupyter_text>This may seem trivial, but this allows us to use methods that are automatically tailored to the class instance. The input value only needs to passed once, meaning the code is easier to debug and a single Python object ends up with a lot of functionality (*i.e.* encapsulation). Mixing MethodsIn the next example we show that various class methods can be combined to provide more complex behaviour.<jupyter_code>class listHandler:
    """ List Handler
    
    Class for handling list properties.
    
    """
    
    def __init__(self, mylist):
        
        self._list = mylist
        self.get_length()
        self.get_first()
        self.get_last()
    
    @staticmethod
    def _get_length(_list):
        
        return len(_list)
    
    @staticmethod
    def _get_element(_list, index):
        
        return _list[index]
    
    @classmethod
    def _get_first(cls, _list):
        
        return cls._get_element(_list, 0)
    
    @classmethod
    def _get_last(cls, _list):
        
        return cls._get_element(_list, -1)
    
    def get_length(self):
        
        self.len = self._get_length(self._list)
        
    def get_first(self):
        
        self.first = self._get_first(self._list)
        
    def get_last(self):
        
        self.last = self._get_last(self._list)

print('listHandler.__dict__', listHandler.__dict__)<jupyter_output><empty_output><jupyter_text>> Note that an underscore is the convention used to denote a "private" attribute. These attributes can be accessed in the usual way, but it is assumed that the user should not need to (*i.e.* abstraction).<jupyter_code>lh = listHandler(list(range(10)))
print('Length:', lh.len)
print('First:', lh.first)
print('Last:', lh.last)<jupyter_output><empty_output><jupyter_text>This is a very complicated way of doing something simple, but it demonstrates how static, class and instance methods can be used together.  5 Properties---Now that you are familiar with basic class attributes and methods we can look at class properties. Getters and SettersAs you have already seen, in Python attributes can direcly be assigned to classes or instances. In some cases, however, it may be useful to formally define methods for getting and setting attributes.<jupyter_code>class myClass:
    
    def __init__(self):
        self.myattr = None
        
    def get_attr(self):
        return self.myattr
    
    def set_attr(self, value):
        self.myattr = value
        
myinst = myClass()
print('myinst.myattr =', myinst.get_attr())
myinst.set_attr(5)
print('myinst.myattr =', myinst.get_attr())<jupyter_output><empty_output><jupyter_text>This may seem silly, as we could have just as easily done the following to get the same result without defining two additional methods.<jupyter_code>myinst = myClass()
print('myinst.myattr =', myinst.myattr)
myinst.myattr = 5
print('myinst.myattr =', myinst.myattr)<jupyter_output><empty_output><jupyter_text>But now let's assume that we want `myattr` to be a positive integer, but returned as a float and if you attemp to set something else the code will raise an exception.<jupyter_code>class myClass:
    
    def __init__(self):
        self.myattr = 1
        
    def get_attr(self):
        return float(self.myattr)
    
    def set_attr(self, value):
        if not isinstance(value, int) or value < 1:
            raise ValueError('myattr must be a positive integer! '
                             'Input value {} is of type {}.'.format(value, type(value)))
        self.myattr = value
        
myinst = myClass()
print('myinst.myattr =', myinst.get_attr())
myinst.set_attr(5)
print('myinst.myattr =', myinst.get_attr())<jupyter_output><empty_output><jupyter_text>We can see that everything works as before, but when we try setting a negative integer<jupyter_code>try:
    myinst.set_attr(-4)
except Exception as error:
    print_error(error)<jupyter_output><empty_output><jupyter_text>or a float the code raises an exception.<jupyter_code>try:
    myinst.set_attr(5.6)
except Exception as error:
    print_error(error)<jupyter_output><empty_output><jupyter_text>We can see that getters and setters provide a means of adding conditions to the assignment or retrieval of an attribute. Class PropertiesIn the previous example we saw that we could add conditions to the assignment of an attribute using getters and setters, however there is nothing to prevent a user from directly accessing the attribute and assigning an inccorect value. One way to help avoid this is by using the special `property` decorator.<jupyter_code>class myClass:
    
    def __init__(self):
        self._myattr = 1
        
    def get_attr(self):
        return float(self._myattr)
    
    def set_attr(self, value):
        if not isinstance(value, int) or value < 1:
            raise ValueError('myattr must be a positive integer! '
                             'Input value is of type {}.'.format(type(value)))
        self._myattr = value
        
    myattr = property(get_attr, set_attr)
        
myinst = myClass()
print('myinst.myattr =', myinst.myattr)
myinst.myattr = 5
print('myinst.myattr =', myinst.myattr)<jupyter_output><empty_output><jupyter_text>This allows us to return to the more natural syntax of simply assigning a value to the attribute via the intermediate private attribute `_myattr`.We can rewrite the same class in the following way for a simpler implementation.<jupyter_code>class myClass:
    
    def __init__(self):
        self._myattr = 1
    
    @property
    def myattr(self):
        return float(self._myattr)
    
    @myattr.setter
    def myattr(self, value):
        if not isinstance(value, int) or value < 1:
            raise ValueError('myattr must be a positive integer! '
                             'Input value {} is of type {}.'.format(value, type(value)))
        self._myattr = value
        
myinst = myClass()
print('myinst.myattr =', myinst.myattr)
myinst.myattr = 5
print('myinst.myattr =', myinst.myattr)<jupyter_output><empty_output><jupyter_text>Which retains all of the same functionality.<jupyter_code>try:
    myinst.myattr = 5.6
except Exception as error:
    print_error(error)<jupyter_output><empty_output><jupyter_text>6 Operator Overloading---Now that you have mastered the basic properties of classes you may want to add more functionality. One way of doing so is to customise your class by defining some special methods that overload standard Python operators. Class StringAs you are no doubt aware you can print any Python object, however the output is not always user friendly. For example, if we were print a class instance...<jupyter_code>class myClass:
    pass

print(myClass())<jupyter_output><empty_output><jupyter_text>... the output tells us the name of the class but little more.The special `__str__` method can be used to customise this output.<jupyter_code>class Wolf:
    
    def __init__(self, age, name):
        self.age = age
        self.name = name
    
    def __str__(self):
        return 'A {} year old wolf named {}'.format(self.age, self.name)

inst1 = Wolf(3, 'Grey Wind')
inst2 = Wolf(2, 'Nymeria')

print('inst1 =', inst1)
print('inst2 =', inst2)<jupyter_output><empty_output><jupyter_text>This can be used to provide the user with details of how the class was instantiated. Class RepresentationSimilar to  `__str__`, `__repr__` can be provided to customise the class representation. This is intended to be more explicit and more for the benefit of developers.<jupyter_code>class Wolf:
    
    def __init__(self, age, name):
        self.age = age
        self.name = name
    
    def __repr__(self):
        return 'Wolf({}, "{}")'.format(self.age, self.name)

inst1 = Wolf(3, 'Lady')
inst2 = Wolf(2, 'Ghost')

print('inst1 =', repr(inst1))
print('inst2 =', repr(inst2))<jupyter_output><empty_output><jupyter_text>> Note: The representation is what is called simply by typing the object.<jupyter_code>inst1<jupyter_output><empty_output><jupyter_text>Mathematical OperatorsIn some cases we may need to define ways in which class instances can interact with each other. For example, what happens if we add two instances or multiply them? How can we determine if a given intance is bigger or smaller than another?This can be occomplished using special methods such as `__add__`, `__mul__`, `__lt__` and `__gt__` (see the [Python data model](https://docs.python.org/3/reference/datamodel.htmlemulating-numeric-types) for a more comprehensive list).<jupyter_code>class Building:
    
    def __init__(self, name, year, height):
        self.name = name
        self.year = year
        self.height = height
        
    def __add__(self, inst):
        
        return self.height + inst.height
    
    def __mul__(self, inst):
    
        return self.height * inst.height
    
    def __lt__(self, inst):
        
        return self.height < inst.height
    
    def __gt__(self, inst):
        
        return self.height > inst.height

inst1 = Building('<NAME>', 2010, 828)
inst2 = Building('Empire State Building', 1931, 381)

print('inst1.height =', inst1.height)
print('inst2.height =', inst2.height)
print('inst1 + inst2 =', inst1 + inst2)
print('inst1 * inst2 =', inst1 * inst2)
print('inst1 < inst2 =', inst1 < inst2)
print('inst1 > inst2 =', inst1 > inst2)<jupyter_output><empty_output><jupyter_text>CallingIt is even possible to define a `__call__` method for a class instance. This effectively allows the instance to behave like a function.<jupyter_code>class Greeting:
    
    def __init__(self, greet):
        
        self._greet = greet
        
    def __call__(self, name):
        
        return '{} {}!'.format(self._greet, name)
    
inst = Greeting('Howdy')
print(inst('Arnold'))
print(inst('Sylvester'))<jupyter_output><empty_output><jupyter_text>Further Options This mechanism is quite extensive. You will find there may different ways in which you can customise your class and we can't possibly cover all of them. If, however, you understand the principle you will find it very easy to extrapolate this to other special methods.Here is an example of some things that can be done.<jupyter_code>class BirthDates:
    
    def __init__(self):
        self._names = []
        self._years = []
        
    def add(self, name, year):
        self._names.append(name)
        self._years.append(year)
    
    # define the length of an instance
    def __len__(self):
        return len(self._names)
    
    # define what the instance contains
    def __contains__(self, name):
        return name in self._names
    
    # define what the indexing an instance returns
    def __getitem__(self, index):
        return (self._names[index], self._years[index])
    
inst = BirthDates()
inst.add('<NAME>', 1643)
inst.add('<NAME>', 1867)
inst.add('<NAME>', 1879)

print('len(inst) =', len(inst))
print('<NAME>' in inst)
print('inst[1] =', inst[1])<jupyter_output><empty_output><jupyter_text>7 Exercises---1. Write a class to calculate the distance between two massive objects using using Newton's law of universal gravitation.     1. Use it to determine the distance between the Earth and the Moon in kilometres.     1. Then use it to determine the distance between the Earth and the Sun in kilometres.$$F=\frac{Gm_1m_2}{r^2}$$> You may find the following values useful:* Gravitational Constant: $G = 6.674\times 10^{-11}~\textrm{m}^3 \textrm{kg}^{-1} \textrm{s}^{-2}$* Mass of the Earth: $M_\oplus = 5.972\times 10^{24}~\textrm{kg}$ * Mass of the Moon: $m = 7.348\times 10^{22}~\textrm{kg}$ * Mass of the Sun: $M_\odot = 1.989\times 10^{30}~\textrm{kg}$* Force of attraction between the Earth and the Moon: $F = 1.986\times 10^{20}~\textrm{N}$* Force of attraction between the Earth and the Sun: $F = 3.6\times 10^{22}~\textrm{N}$<jupyter_code># Add your solution here<jupyter_output><empty_output><jupyter_text>2. Write a class that generates a numpy array of whole numbers from 0 to a given limit and has a method that returns a given metric on this array.    1. Use it to return the median of an array of 20 values.    1. Then use it to return the standard deviation of an array of 50 values.<jupyter_code># Add your solution here<jupyter_output><empty_output><jupyter_text>3. Write a class that can be used to store galaxy properties, in particular right ascension, declination and redshift.     1. Make sure you only permit appropriate values for these parameters.     1. Make it such that your class instances can be added and subtracted to create a new instance for which the all attributes are updated.     1. Include an appropriate representation for your class instances.     1. Finally, create instances to demonstrate that your class works.<jupyter_code># Add your solution here<jupyter_output><empty_output>
{'fraction_non_alphanumeric': 0.08879625088551032, 'fraction_numerical': 0.011988447496049262, 'mean_word_length': 4.142636962309093, 'pattern_counts': {'":': 0, '<': 230, '<?xml version=': 0, '>': 239, 'https://': 9, 'lorem ipsum': 0, 'www.': 0, 'xml': 0}, 'pii_count': 0, 'substrings_counts': 0, 'word_list_counts': {'cursed_substrings.json': 0, 'profanity_word_list.json': 0, 'sexual_word_list.json': 0, 'zh_pornsignals.json': 0}}
{'chain_length': '54', 'dir': 'jupyter-structured-clean-dedup', 'id': '203174', 'license': 'MIT', 'n_tokens_mistral': 10750, 'n_tokens_neox': 10337, 'n_words': 4151, 'path': 'Classes_I.ipynb', 'repo_name': 'Euclid-Python/python_and_oop', 'size': '36756'}