OOP in Python

Object Life-Cycle
The lifecycle of an object mis made up of it's creation, manipulation, and destruction.

The stages of the object's lifecycle look like this:
 * 1) Definition - The first stage, the definition of the class to which it belongs.
 * 2) Instantiation - When __init__ is called. Memory is allocated to store the instance.
 * 3) "New" method - The __new__ method is called. This is usually overridden only in special cases.
 * 4) Object ready - Now the object is ready to be used.

When an object is destroyed, the memory allocated to it is freed up, and can be used for other purposes. An object can be deleted using the del keyword.

Data Hiding
A key part of OOP is encapsulation.

Encapsulation is the packaging of related variables and funcitons into a single easy-to-use object - an instance of a class.

A related concept to this is data hiding.

Data hiding states that implementation details of a class should be hidden, and a clean standard interface be presented for users of the class.

In Python, there are no ways of enforcing a method or attribute to be private (much unlike a language like C++). However there are ways to discourage people from accessing parts of a class, such as by denoting that it is an implementation detail, and should be used at own risk.

Weakly private methods and attributes have a single underscore at the beginning. This signals that they are private, and shouldn't br used by external code. However this is just a convention and does not stop external code for accessing them. It's only actual effect is that from module_name import * won't import variables that start with a single underscore.

Strongly private methods and attributes have a double underscore at the beginning of their names. This causes their names to be "mangled", which means that they can't be accessed from outside the class. The purpose of this isn't to ensure that they are kept private, but to avoid bugs if there are subclasses that have methods or attributes with the same names. "Name mangled" methods can still be accessed externally, but by a different name. class Spam: __egg = 7 def print_egg(self): print(self.__egg) s = Spam s.print_egg print(s._Spam__egg) print(s.__egg) >>> 7 7 AttributeError: 'Spam' object has no attribute '__egg' >>> So Python protects those members by internally changing the name to include the class name.
 * 1) The method "__privatemethod" of class "Spam" could be accessed externally with
 * 2) "_Spam_privatemethod"

Class Methods
In most cases so far we have called object methods by an instance of a class, which is then passed to the self parameter of the method.

Class methods are different - they are called by a class, which is passed to the cls parameter of the method.

(Technically the parameters self and cls are just conventions; they could be chanaged to anything else. However, they are universally followed, so it is wise to stick to them)

A common use of these are factory methods, which instantiate an instance of a class, using different parameters than those usually passed to the class constructor.

Class methods are marked with a classmethod decorator. class Rectangle: def __init__(self, width, height): self.width = width self.height = height def calculate_area(self): return self.width * self.height @classmethod def new_square(cls, side_length): return cls(side_length, side_length) square = Rectangle.new_square(5) # <- See how the method is called on the class! print(square.calculate_area) >>> 25 >>> new_squre is a class method and is called on the class, rather than on an instance of the class. It returns a new object of the class cls.

Static Methods
Static methods are similar to class methods, except they don't receive any additional arguments; they are identical to normal functions that belong to a class.

They are marked with the staticmethod decorator. class Pizza: def __init__(self, toppings): self.toppings = toppings    @staticmethod def validate_topping(topping): # <- See! No 'self' or 'cls' parameters! if topping == "pineapple": raise ValueError("No pineapples!") else: return True ingredients = ["cheese", "onions", "spam"] if all(Pizza.validate_topping(i) for i in ingredients): pizza = Pizza(ingredients) Static methods behave like plain functions, except for the fact that you can call them from an instance of the class.

Properties
Properties provide a way of customizing access to instance attributes.

They are created by putting the property decorator above a method, which means when the instance attribute with the same name as the method is accessed, the method will be called instead.

One common use of a property is to make an attribute read-only. class Pizza: def __init__(self, toppings): self.toppings = toppings    @property def pineapple_allowed(self): return False pizza = Pizza(["cheese", "tomato"]) print(pizza.pineapple_allowed) pizza.pineapple_allowed = True >>> False AttributeError: can't set attribute >>> Properties can also be set by defining setter/getter functions.

The setter function sets the corresponding property's value.

The getter gets the value.

To define a setter: @ .setter Same applies for a getter function. @pineapple_allowed.setter def pineapple_allowed(self, value): if value: password = input("Enter the password: ") if password == "Sw0rdf1sh!": self._pineapple_allowed = value else: raise ValueError("Alert! Intruder!") print(pizza.pineapple_allowed) >>> False Enter the password: Sw0rdf1sh! True