#!/usr/bin/env python # coding: utf-8 # # Object Oriented Programming in Python # In[1]: import addutils.toc ; addutils.toc.js(ipy_notebook=True) # __Object Oriented (OOP)__ is a programming paradigm that allows abstraction through the concept of interacting entities. # This is usally opposed to a more conventional model, called procedural, in which programs are organized as a sequence of commands (statements) to perform. # We can think an object as an entity that resides in memory, has a states and it's able to perform some actions. # # More formally objects are entities that represent **instances** of a general abstract concept called **class**. In `Python` the variables defining an object state are called `attributes` and the possible actions are called `methods`. # # In Python everything is an object also classes and functions. # In[2]: from addutils import css_notebook css_notebook() # ## 1 How to define classes # ### 1.1 Creating a class # Suppose we want to create a class, named Person, as a prototype, a sort of template for any number of 'Person' objects (instances). # The python syntax to define a class is the following: # # class ClassName(base_classes): # statements # # # Class names should always be uppercase (it's a naming convention). # Say we need to model a Person as: # # * Name # * Surname # * Age # In[3]: class Person: pass john_doe = Person() john_doe.name = "Alec" john_doe.surname = "Baldwin" john_doe.year_of_birth = 1958 print(john_doe) print("%s %s was born in %d." % (john_doe.name, john_doe.surname, john_doe.year_of_birth)) # The preceding example defines an empty class (i.e. the class doesn't have a state) called _Person_ then creates a _Person_ instance called _john_doe_ and adds three attributes to _john_doe_. We see that we can access objects attributes using the __. dot __ operator. # # This isn't a raccomended style because classes should describe omogeneous entities. A way to do so is the following: # In[4]: class Person: def __init__(self, name, surname, year_of_birth): self.name = name self.surname = surname self.year_of_birth = year_of_birth # __init__(self, ...) # Is a special _Python_ method that is automatically called after an object construction, its purpose is to initialize every object state. The first argument (by convention) __self__ is automatically passed either and refers to the object itself. # # In the preceding example `__init__` adds three attributes to every object that is instantiated. So the class is actually describing each object's state. # # A class isn't something which we can directly manipulate, we need to create an instance of the class: # In[5]: alec = Person("Alec", "Baldwin", 1958) print(alec) print("%s %s was born in %d." % (alec.name, alec.surname, alec.year_of_birth)) # # We have just created an instance of the Person class, bound to the variable `alec`. # ### 1.2 Methods # In[6]: class Person: def __init__(self, name, surname, year_of_birth): self.name = name self.surname = surname self.year_of_birth = year_of_birth def age(self, current_year): return current_year - self.year_of_birth def __str__(self): return "%s %s was born in %d ." % (self.name, self.surname, self.year_of_birth) alec = Person("Alec", "Baldwin", 1958) print(alec) print(alec.age(2014)) # We defined two more methods `age` and `__str__`. The latter is once again a special method that is called by Python when the object has to be represented as a string (e.g. when has to be printed). If the `__str__` method isn't defined the **print** command shows the type of object and its address in memory. We can see that in order to call a method we use the same syntax for attributes (**instance_name.instance _method**). # ### 1.3 Bad practice # It is possible to create a class without the `__init__` method, but this isn't a raccomended style because classes should describe omogeneous entities. # In[7]: class Person: def set_name(self, name): self.name = name def set_surname(self, surname): self.surname = surname def set_year_of_birth(self, year_of_birth): self.year_of_birth = year_of_birth def age(self, current_year): return current_year - self.year_of_birth def __str__(self): return "%s %s was born in %d ." \ % (self.name, self.surname, self.year_of_birth) # In this case an empty instance of the class Person is created, and no attributes have been initialized while instantiating: # In[8]: president = Person() # In[10]: # This code will raise an attribute error: print(president.name) # This raises an Attribute Error... We need to set the attributes: # In[11]: president.set_name('John') president.set_surname('Doe') president.set_year_of_birth(1940) # In[12]: print('Mr', president.name, president.surname, 'is the president, and he is very old. He is', president.age(2014)) # ### 1.4 Protect your abstraction # Since objects are a powerfull mean of abstraction they shouldn't reveal internal implementation detail, hence instance attributes shouldn't be accessible by the end user of an object. # Python doesn't have a strict mechanism to protect objects attributes, but official guidelines suggest that a variable that has an underscore `_` prefix should be treated as private. # Moreover prepending two underscores to a variable name makes the intepreter mangle a little the variable name. # In[13]: class Person: def __init__(self, name, surname, year_of_birth): self._name = name self._surname = surname self._year_of_birth = year_of_birth def age(self, current_year): return current_year - self._year_of_birth def __str__(self): return "%s %s and was born %d." \ % (self._name, self._surname, self._year_of_birth) alec = Person("Alec", "Baldwin", 1958) print(alec) print(alec.age(2014)) # In[14]: class Person: def __init__(self, name, surname, year_of_birth): self.__name = name self.__surname = surname self.__year_of_birth = year_of_birth def age(self, current_year): return current_year - self.__year_of_birth def __str__(self): return "%s %s and was born %d." \ % (self.__name, self.__surname, self.__year_of_birth) alec = Person("Alec", "Baldwin", 1958) print(alec.__dict__.keys()) # `__dict__` is a special attribute that is a dictionary containing each attribute of an object. We can see that prepending two underscores every key has `_ClassName__` prepended. # ## 2 Inheritance # Once a class is defined it models a concept. Often is usefull to extend a class behavior to model a less general concept. Say we need to model a Student, but we know that every student is also a Person so we shouldn't model the Person again but inherit from it instead. # In[15]: class Student(Person): def __init__(self, student_id, *args, **kwargs): super(Student, self).__init__(*args, **kwargs) self._student_id = student_id charlie = Student(1, 'Charlie', 'Brown', 2006) print(charlie) print(type(charlie)) print(isinstance(charlie, Person)) print(isinstance(charlie, object)) # Charlie now has the same behavior of a Person, but his state has also a student ID. Person is one of the base classes of Student and Student is one of the sub classes of Person. Be aware that a subclass knows about its superclasses but the converse isn't true. # # A sub class doesn't only inherits from its base classes, but from its base classes base classes too, forming an inheritance tree that starts from object (every class base class). # # super(Class, instance) # # is a function that returns a proxy-object that delegates method calls to a parent or sibling class of type. # So we used it to access Person's `__init__`. # ### 2.1 Overriding methods # Inheritance allows to add new methods to a subclass, but often is usefull to change the behavior of a method defined in the superclass. To override a method just define it again. # In[16]: class Student(Person): def __init__(self, student_id, *args, **kwargs): super(Student, self).__init__(*args, **kwargs) self._student_id = student_id def __str__(self): return super(Student, self).__str__() + " And has ID: %d" % self._student_id charlie = Student(1, 'Charlie', 'Brown', 2006) print(charlie) # We defined `__str__` again overriding the one wrote in Person, but we wanted to extend it, so we used super to achieve our goal. # ## 3 Encapsulation # Another powerful way to extend a class is called __encapsulation__ and consists on wrapping an object with a second one. # There are two main reasons to use encapsulation: # * Composition # * Dynamic Extension # # ### 3.1 Composition # The abstraction process relies on creating a simplified model that remove useless details from a concept. In order to be simplified a model should be described in terms of other simpler concepts. # For example we can say that a car is composed by: # * Tyres # * Engine # * Body # # And break down each one of this elements in simpler parts until we reach primitive data. # In[17]: class Tyres: def __init__(self, branch, belted_bias, opt_pressure): self.branch = branch self.belted_bias = belted_bias self.opt_pressure = opt_pressure def __str__(self): return ("Tyres: \n \tBranch: " + self.branch + "\n \tBelted-bias: " + str(self.belted_bias) + "\n \tOptimal pressure: " + str(self.opt_pressure)) class Engine: def __init__(self, fuel_type, noise_level): self.fuel_type = fuel_type self.noise_level = noise_level def __str__(self): return ("Engine: \n \tFuel type: " + self.fuel_type + "\n \tNoise level:" + str(self.noise_level)) class Body: def __init__(self, size): self.size = size def __str__(self): return "Body:\n \tSize: " + self.size class Car: def __init__(self, tyres, engine, body): self.tyres = tyres self.engine = engine self.body = body def __str__(self): return str(self.tyres) + "\n" + str(self.engine) + "\n" + str(self.body) t = Tyres('Pirelli', True, 2.0) e = Engine('Diesel', 3) b = Body('Medium') c = Car(t, e, b) print(c) # ### 3.2 Dynamic Extension # Sometimes it's necessary to model a concept that may be a subclass of another one, but it isn't possible to know which class should be its superclass until runtime. # #### 3.2.1 Example # Suppose we want to model a simple dog school that trains instructors too. It will be nice to re-use Person and Student but students can be dogs or peoples. So we can remodel it this way: # In[18]: class Dog: def __init__(self, name, year_of_birth, breed): self._name = name self._year_of_birth = year_of_birth self._breed = breed def __str__(self): return "%s is a %s born in %d." % (self._name, self._breed, self._year_of_birth) kudrjavka = Dog("Kudrjavka", 1954, "Laika") print(kudrjavka) # In[19]: class Student: def __init__(self, anagraphic, student_id): self._anagraphic = anagraphic self._student_id = student_id def __str__(self): return str(self._anagraphic) + " Student ID: %d" % self._student_id alec_student = Student(alec, 1) kudrjavka_student = Student(kudrjavka, 2) print(alec_student) print(kudrjavka_student) # ## 4 Polymorphism and DuckTyping # `Python` uses dynamic typing (also called duck typing). If an object implements a method you can use it, no matter what the type is. This is different from statically typed languages, where the type of a construct need to be explicitly declared. Polymorphism is the ability to use the same syntax for objects of different types: # In[20]: def summer(a, b): return a + b print(summer(1, 1)) print(summer(["a", "b", "c"], ["d", "e"])) print(summer("abra", "cadabra")) # ## 5 How long does a class should be? # # There is an Object Oriented Programming principle called SRP single responsability principle and it states: "A class should have one single responsability" or "A class should have only one reason to change". If you come up with a class that doesn't follow this principle you should consider to split it. You will be grateful to SRP during your software manteinance. # --- # # Visit [www.add-for.com]() for more tutorials and updates. # # This work is licensed under a Creative Commons Attribution-ShareAlike 4.0 International License.