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.

```
# "_Spam_privatemethod"

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.

## 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**.

```
    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**.

```
        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**.

```
        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:**

```
@<property name>.setter

```

Same applies for a **getter**function.

```
@pineapple_allowed.setter
            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

```