-> Functions:

    functions in python are first class citizen,
    i.e. they can be passed around as variables.

    for ex:

        def hello():
            print('hello')

        greet = hello
        del hello
        print(greet())

    Here in this example, the hello function is defined
    and assigned to newly defined variable called greet.
    Then the function name hello is deleted using del
    keyword.

    Here, the "del hello" is called, only the name of the
    function i.e. hello is deleted instead of deleting
    the function. This is due to the "greet" variable
    which is still pointing the function location.
    Hence, calling the function using greet function
    name works but calling the function with hello 
    name will throw error as the hello variable is
    deleted.

-> High Order function:

    function which might return a function or take a function
    as an argument.

    for ex:

        def greet(func):
            func()

        def greet2():
            def func():
                return 5

            return func

-> decorators:

    These are used to super boost our function:

    for ex:

        #decorator
        def my_decorator(func):

            def wrap_func():
                print('************')
                func()
                print('************')

            return wrap_func

        @my_decorator
        def hello():
            print('hellllooooooooooo')

        def bye():
            print('see ya letter')



    Here, if we our function powerful by using decorators.
    if we use @<decorator-name> and define the function,
    during the function call the decorator function will
    take our function as an argument and inside the
    decorator function it will call the passed function
    inside wrapped function.


    For using decorators we need to write the decorator
    function in way as written above.


    But, how it works under the hood?

        This function defination with decorator is same as

            @my_decorator
            def hello():
                print('helloooo')

            var = my_decorator(hello)
            var()

        
-> decorator with function having arguments:

    
    for ex:
        def my_decorator(func):
            def wrap_func(*args, **kwargs):
                func(*args, **kwargs)

            return wrap_func

        @my_decorator
        def hello(greeting, emoji = ':('):
            print(greeting, emoji)

        hello('hii')


 
    for ex:
        from time import time

        def performance(fn):
            def wrapper(*args, **kwargs):
                t1 = time()
                result = fn(*args, **kwargs)
                t2 = time()
                print(f'took {t2-t1} ms')
                return result
            return wrapper

        @performance
        def long_time():
            for i in range(1000000):
                i*5

        long_time()

    