<jupyter_start><jupyter_text>Object Oriented Programming (OOP)Object-oriented programming is a programming paradigm that provides a means of structuring programs so that properties and behaviors are bundled into individual objects.For instance, an object could represent a person with properties like a name, age, and address and behaviors such as walking, talking, breathing, and running. Or it could represent an email with properties like a recipient list, subject, and body and behaviors like adding attachments and sending.The key takeaway is that objects are at the center of object-oriented programming in Python.  ClassesClasses provide a means of bundling data and functionality together. Creating a new class creates a new type of object, allowing new instances of that type to be made. Each class instance can have attributes attached to it for maintaining its state. Class instances can also have methods (defined by its class) for modifying its state.Python classes provide all the standard features of Object Oriented Programming:- the class inheritance mechanism allows multiple base classes- a derived class can override any methods of its base class or classes, and- a method can call the method of a base class with the same name.Objects can contain arbitrary amounts and kinds of data. As is true for modules, classes partake of the dynamic nature of Python: they are created at runtime, and can be modified further after creation. A First Look at ClassesClasses introduce a little bit of new syntax, three new object types, and some new semantics.The simplest form of class definition looks like this:```pythonclass ClassName:        .    .    .    ```     Class ObjectsClass objects support two kinds of operations: attribute references and instantiation.Attribute references use the standard syntax used for all attribute references in Python: `obj.name`. Valid attribute names are all the names that were in the class’s namespace when the class object was created. So, if the class definition looked like this:```pythonclass MyClass:    """A simple example class"""    i = 12345    def f(self):        return 'hello world'        def print_i(self):        print(self.i)```then `MyClass.i` and `MyClass.f` are valid attribute references, returning an integer and a function object, respectively.Class instantiation uses function notation. Just pretend that the class object is a parameterless function that returns a new instance of the class. For example (assuming the above class):```pythonx = MyClass()```creates a new **instance** of the class and assigns this object to the local variable x.  Example: `pd.DataFrame`We have seen pandas DataFrames quite a lot during the last seessions. Notice that `pd.DataFrame` is also a class containing **attributes** such as `columns`, `index`, `values`, `shape`<jupyter_code>import pandas as pd
df = pd.DataFrame({"Name": ["Anthony", "Flea", "Chad", "John"],
                  "Role": ["Singer", "Bassist", "Drummer", "Guitarist"]})
df
print(f"columns attribute:\n{df.columns}\n")
print(f"index attribute:\n{df.index}\n")
print(f"values attribute:\n{df.values}\n")
print(f"shape attribute:\n{df.shape}")<jupyter_output>columns attribute:
Index(['Name', 'Role'], dtype='object')

index attribute:
RangeIndex(start=0, stop=4, step=1)

values attribute:
[['Anthony' 'Singer']
 ['Flea' 'Bassist']
 ['Chad' 'Drummer']
 ['John' 'Guitarist']]

shape attribute:
(4, 2)<jupyter_text>As (almost) every other class it also contains **methods** (i.e. functions bound to a class) such as `sort_values()`, `count()`<jupyter_code>print("sort_values() method:")
display(df.sort_values(by="Name"))

print("\ncount() method:")
display(df.count())<jupyter_output>sort_values() method:<jupyter_text>Challenge: Build your first class- Set up a class called `BankAccount`- Add an **attribute** `bank_name` to the class and assign a Name of your choice to it- Add a **method** `print_bank_name` that prints the `bank_name` variable<jupyter_code>### Your code here...
# %load ../src/_solutions/bank_account_v0.py
bank = BankAccount()
bank.print_bank_name()<jupyter_output>My name is My Bank<jupyter_text>InstantiationThe instantiation operation (“calling” a class object) creates an empty object. Many classes like to create objects with instances customized to a specific initial state. Therefore a class may define a special method named `__init__()`, like this:```pythondef __init__(self):    self.data = []```When a class defines an `__init__()` method, class instantiation automatically invokes `init()` for the newly-created class instance. The `__init__()` method may have arguments for greater flexibility.```pythonclass AddressBook:     def __init__(self, name, email):         self.name = name         self.email = email```Now what can we do with instance objects? The only operations understood by instance objects are attribute references. There are two kinds of valid attribute names, data attributes and methods.```pythonclass AddressBook:     def __init__(self, name, email):         self.name = name         self.email = email                 def print_name(self):         print(f'Hi, {self.name}. Your email is {self.email}')```  Challenge: Add `__init__()` function- Implement a `__init__()` function which takes `bank_name` and `customer_name` as arguments and instantiates the class with them<jupyter_code>class BankAccount:
    ### your code here ...
    pass
# %load ../src/_solutions/bank_account_v1.py
bank_ermi = BankAccount('Commerzbank', 'Ermi')
bank_ermi.print_bank_name()
bank_ermi.print_customer_name()
bank_aleks = BankAccount('Sparkasse', 'Aleks')
bank_aleks.print_bank_name()
bank_aleks.print_customer_name()<jupyter_output>My name is Sparkasse
This account belongs to Aleks<jupyter_text>Printing a class instanceUpon trying to print a class instance you will see a weird looking output:- Something like ``.To actually make a object printable you need to implement a `__str__()` method.Example:```pythondef __str__(self):    return "Hello World"```<jupyter_code>print(bank_ermi)<jupyter_output><__main__.BankAccount object at 0x000001FC46CE2D90><jupyter_text>Challenge: Add a `__str__()` method<jupyter_code>class BankAccount:
    def __init__(self, bank_name, customer_name):
        self.bank_name = bank_name
        self.customer_name = customer_name
    
    def print_bank_name(self):
        print(f"My name is {self.bank_name}")
        
    def print_customer_name(self):
        print(f"This account belongs to {self.customer_name}")
        
    ### Your code here...
    
# %load ../src/_solutions/bank_account_v2.py
bank_ermi = BankAccount('Commerzbank', 'Ermi')
print(bank_ermi)
bank_aleks = BankAccount('Sparkasse', 'Ermi')
print(bank_aleks)<jupyter_output>This Sparkasse account belongs to Ermi<jupyter_text>Private attributes and methodsYou can "hide" attributes and methods by prepending `__` to a attribute/method name.It then won't get automatically suggested (and is hard to access, _though not impossible!_)Keep in mind that Python doesn't offer Encapsulation such as Java for example does. The private attribute or method is not in fact private, just hidden!<jupyter_code>class BankAccount:    
    def __init__(self, bank_name, customer_name):
        self.bank_name = bank_name
        self.customer_name =  customer_name
        self.__balance = 0
    
    def __str__(self):
        return f'This {self.bank_name} account belongs to {self.customer_name}'

    def print_bank_name(self):
        print('My name is', self.bank_name)
         
    def print_customer_name(self):
        print('This account belongs to', self.customer_name)
        
    # public method to access private attribute
    def get_balance(self):
        return self.__balance
bank_ermi = BankAccount('Commerzbank', 'Ermi')
bank_ermi.get_balance()
# See: You can acceess hidden variables by using instance._ClassName__hiddenVariableName
# and even overwrite it! So there are no secure variables in Python
bank_ermi._BankAccount__balance<jupyter_output><empty_output><jupyter_text>Challenge: Extend the class for to "fully" featured bank accountAdd the following:- deposit() method- withdraw() method- pin attribute (ask for it before deposit and withdraw)- (opt) lock account after 3 entering a wrong pin three times<jupyter_code>class BankAccount:    
    def __init__(self, bank_name, customer_name):
        self.bank_name = bank_name
        self.customer_name =  customer_name
        self.__balance = 0
    
    def __str__(self):
        return f'This {self.bank_name} account belongs to {self.customer_name}'

    def print_bank_name(self):
        print('My name is', self.bank_name)
         
    def print_customer_name(self):
        print('This account belongs to', self.customer_name)
        
    # public method to access private attribute
    def get_balance(self):
        return self.__balance
    
    ### Your code here...
# %load ../src/_solutions/bank_account_v3.py
bank = BankAccount('Sparkasse', 'Ermi', '7777')
bank.deposit(400)
bank.get_balance()
bank.withdraw(100)
bank.get_balance()<jupyter_output><empty_output><jupyter_text>InheritanceA child class can inherit from a parent class, inheriting all attributes and methods from the specified parent function.Syntax: ```pythonclass ChildClass(ParentClass):    pass```You can then extend the child class or even overwrite some of it's functionality. You can call a method or attribute of the parent class using the `super()` keyword.<jupyter_code>class SparkassenSpecialAccount(BankAccount):

    def __init__(self, customer_name, pin):
        super().__init__(bank_name = "Sparkasse", customer_name = customer_name, pin = pin)
        self._BankAccount__balance = 500
sparkasse = SparkassenSpecialAccount("Aleks", "1234")
print(sparkasse)
sparkasse.get_balance()
sparkasse.deposit(1000)
sparkasse.get_balance()<jupyter_output><empty_output>
{'fraction_non_alphanumeric': 0.09096422073984232, 'fraction_numerical': 0.00505356781888013, 'mean_word_length': 4.699884792626728, 'pattern_counts': {'":': 2, '<': 32, '<?xml version=': 0, '>': 32, 'https://': 0, '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': '9', 'dir': 'jupyter-structured-clean-dedup', 'id': '217834', 'license': 'MIT', 'n_tokens_mistral': 2793, 'n_tokens_neox': 2680, 'n_words': 1049, 'path': '09-2021-06-18/notebooks/09_Object_oriented_programming.ipynb', 'repo_name': 'eotp/python-FU-class', 'size': '9894'}