<jupyter_start><jupyter_text>02 - Python basics - advanced functionsIn this lecture we will have a look to some advanced stuff regarding functions and after that we will move to classes and object oriented programming. Functions - number of argumentsFrom last lesson you know that you can define the function with some parameters which you can later pass to it when you call the function. But what happens when you pass different number of arguments then you have defined in the header of function?<jupyter_code>def foo(arg1, arg2):
    print(arg1 + arg2)
    
foo(5)
def foo(arg1, arg2):
    print(arg1 + arg2)
    
foo(5, 4, 3)<jupyter_output><empty_output><jupyter_text>Arbitrary argumentsAs expected, too few or too much arguments will break the function. If you don't know how many arguments will be passed to your function you can use **Arbitrary arguments**, ```*args```. By adding ```*``` before the name of argument you will get a tuple of passed arguments once the function is called. We can demonstrate this with a function which multiplies all incoming numbers.<jupyter_code>def foo(*nums):
    num_sum = 1
    for num in nums:
        num_sum *= num
    print(num_sum)
    
foo(1,2,3)
foo(1,2,3,4)
foo(1,2,3,4,5)<jupyter_output>6
24
120<jupyter_text>You can combine this with other arguments but there are some rules. First you must pass the positional arguments to the function. Othervise ```*args``` wouldn't know where it should end.<jupyter_code>def foo_correct(base_val, *nums): # this way we can specificaly define first value
    num_sum = base_val
    for num in nums:
        num_sum *= num
    print(num_sum)

foo_correct(0, 2, 3, 4)
foo_correct(1, 2, 3, 4)
foo_correct(2, 2, 3, 4)
def foo_wrong(*nums, base_val): # this is wrong because *nums "eats" all passed arguments and for the base_val there remains none
    num_sum = base_val
    for num in nums:
        num_sum *= num
    print(num_sum)
    
foo_wrong(0, 2, 3, 4)
foo_wrong(1, 2, 3, 4)
foo_wrong(2, 2, 3, 4)<jupyter_output><empty_output><jupyter_text>Keyword argumentsSolution for the error above is usage of keyword argument. The definition of function will stay the same but in the function call we will specify the value for the argument with operator ```=```.<jupyter_code>def foo_wrong(*nums, base_val): # this way the *nums "eats" arguments only until it finds the keyword argument
    num_sum = base_val
    for num in nums:
        num_sum *= num
    print(num_sum)
    
foo_wrong(0, 2, 3, base_val=4)
foo_wrong(1, 2, 3, base_val=4)
foo_wrong(2, 2, 3, base_val=4)<jupyter_output>0
24
48<jupyter_text>On top of that when we have more than one keyword argument we can swap their respective order as we wish.<jupyter_code>def university_location(university_name, university_location):
    print(f'{university_name} is located in {university_location}')
    
university_location(university_name='CTU', university_location='Prague') # these 3 options are equivalent
university_location(university_location='Prague', university_name='CTU')
university_location('CTU', 'Prague')

# while this option is incorrect 
university_location('Prague', 'CTU') # bacause we swaped the order of positional arguments passed to the function<jupyter_output>CTU is located in Prague
CTU is located in Prague
CTU is located in Prague
Prague is located in CTU<jupyter_text>Arbitrary Keyword ArgumentsAnother adition to our pool of arguments are **Arbitrary Keyword Arguments**, ```**kwargs```. Similarly to Arbitrary positional arguments ```**kwargs``` are used for variable number of keyword arguments. ```**kwargs``` works as dictionary of incoming variable-value pairs. We need to use the name of variable as a **key** for ```**kwargs``` dictionary<jupyter_code>def capital_city(**kwargs):
    print(f'Capital city of Czech republic is {kwargs["city"]}') # using string of the varible's name 
                                                    # ^^^^^^ WARNING: you have to use other type of quotes ('' or "") if you are putting string value into formatted string
    
capital_city(greeting='Hello world!', city='Prague', fav_num=7, random_stuff='whatever')<jupyter_output>Capital city of Czech republic is Prague<jupyter_text>Remeber that you need to pass first the positional arguments (the basic ones), followed by ```*args```, then basic keyword arguments and after that ```**kwargs```. You can also pass in your own dictionary or other containers with variables you want to use inside of the function.<jupyter_code>def capital_city(my_args):
    print(f'Capital city of Czech republic is {my_args["city"]}')

my_args = {
    'greeting' : 'Hello world!',
    'city' : 'Prague',
    'fav_num' : 7,
    'random_stuff' : 'whatever'
}
capital_city(my_args)<jupyter_output>Capital city of Czech republic is Prague<jupyter_text>Default valueLast important thing about arguments which we will mention here **default value**. You can set the default value for a variable which is used if no value for that variable is passed. The assignment ```=``` operator is used for that but this time it is used in the header of function.<jupyter_code>def default_val_demonstration(val=0):
    print(f'The value is: {val}')
    
default_val_demonstration(1) # it works as a normal argument when we pass a value
default_val_demonstration() # but when we dont the default value is used instead<jupyter_output><empty_output><jupyter_text>This behaviour may be used together with other arguments but there is again some restriction on the order of arguments. For demonstration we will use the simple adding function.<jupyter_code>def foo(arg1, arg2=2):
    print(arg1 + arg2)
    
foo(5)
def foo(arg1=2, arg2):
    print(arg1 + arg2)
    
foo(5)<jupyter_output><empty_output><jupyter_text>So as the error says, you have to use non-default arguments first. You can also combine the default arguments with other type of arguments mentioned above, but we will skip that here. If you are intrested in it fell free to explore it more deeply on your own.  Lambda functionLambda function is bringing concept of anonymous function. Instead of defining whole header and body of some function we can write it inline.<jupyter_code>x = lambda a, b : a * b

print(x(2, 3))<jupyter_output>6<jupyter_text>... instead of ...<jupyter_code>def x(a, b):
    return a * b

print(x(2, 3))<jupyter_output>6<jupyter_text>GeneratorsPython provides a generator to create your own iterator function. It does not return a single value, instead, it returns an iterator object with a sequence of values. In a generator function, a ```yield``` statement is used rather than a ```return``` statement. ```yield``` returns the value the same way as ```return``` but the difference is that if the function is called once again after that it continues from the ```yield``` statement. In other words ```yield``` is something like: "give me the value and wait here until i come again"<jupyter_code>def generator_foo(max_val):
    val = 1
    while val < max_val:
        yield val # value is yielded ("returned") but with next call function continues on the next line
        val *= 2 # here <---------------------------------------------------------------------------/
        
gen = generator_foo(2**8)
print(next(gen)) # with next() function we can get next value with each call
print(next(gen))
print(next(gen))
print(next(gen))
def generator_foo(max_val):
    val = 1
    while val <= max_val:
        yield val
        val *= 2
        
gen = generator_foo(2**8)
for val in gen: # iterating the loop over iterator generated by generator :-)
    print(val)<jupyter_output>1
2
4
8
16
32
64
128
256<jupyter_text>Fucntion inside functionIt is possible to define function inside another function. These functions are only defined inside the scope of the parent function so they cant be called from outside.<jupyter_code>def foo():  
    def weekday():
        print('It is a weeekday so I go to work')
    
    def weekend():
        print('It is a weeekend so I can go to a trip')
    
    print('Parent function')
    
foo() # function only executes it direct commands, not  defined inner function
print() # print empty line to separate outputs

weekday() # inner function can't be called just like this<jupyter_output>Parent function<jupyter_text>These inner function are also not called by default. You need to call them inside the body of parent function.<jupyter_code>def foo(day_of_the_week):  
    def weekday():
        print('It is a weeekday so I go to work')
    
    def friday():
        print('It is a friday so I go to party after work')
    
    def weekend():
        print('It is a weeekend so I can go to a trip')
    
    print(f'Parent function input: {day_of_the_week}')
    
    if day_of_the_week < 5: # according to given parametr we can decide which inner function will be executed
        weekday()
    elif day_of_the_week == 5:
        friday()
    else:
        weekend()
    
foo(1)
print() 
foo(5)
print() 
foo(7)<jupyter_output>Parent function input: 1
It is a weeekday so I go to work

Parent function input: 5
It is a friday so I go to party after work

Parent function input: 7
It is a weeekend so I can go to a trip<jupyter_text>Or you can return the inner function and then call it from outside.<jupyter_code>def foo(day_of_the_week):  
    def weekday():
        print('It is a weeekday so I go to work')
    
    def friday():
        print('It is a friday so I go to party after work')
    
    def weekend():
        print('It is a weeekend so I can go to a trip')
    
    print(f'Parent function input: {day_of_the_week}')
    
    if day_of_the_week < 5:  # according to given parametr we can decide which inner function will be returned
        return weekday  # we return function without the (), otherwise we would call it and then we would returned the return value of that function
    elif day_of_the_week == 5:
        return friday
    else:
        return weekend
    
monday = foo(1) # now the inner function is assigned to monday variable
friday = foo(5)
sunday = foo(7)

print() 

# return of the function does not call it, we need to call that variable we assigned function to
# so to get result of foo(1) we need to call monday()
monday() # with calling of tha monday() we finally get the execution of inner function
# and so on
friday()
sunday()<jupyter_output>Parent function input: 1
Parent function input: 5
Parent function input: 7

It is a weeekday so I go to work
It is a friday so I go to party after work
It is a weeekend so I can go to a trip<jupyter_text>DecoratorsWith the knowledge of inner functions we can create  decorators (wrapper functions). Decorators are supposed to somehow enhance the bahaviour of the function which will be wrapped inside them. We can create simple decorator and pass some function into that<jupyter_code>def decorator_foo(func):  # decorator function with argument for a function
    def wrapper_foo(*args, **kwargs): # inner function which will wrap the fucntion passed inside, *args and **kwargs are there to allow us to pass arguments with the function
        """A wrapper function"""
        # Extend some capabilities of func
        print(f'Here starts the decorated function: {func.__name__}') # we define some extended functionality for the wrapped function
        func()  # and then we call it
        print(f'Here ends the decorated function: {func.__name__}')
    return wrapper_foo  # the inner function is returned so we need to put it in some variable


def basic_foo():
    print('I am function which will be decorated')

basic_foo() # calling a function without decorator
print()
    
wrapped_foo = decorator_foo(basic_foo)  # passing our base function inside decorator where it is wrapped and enchanced with new functionality
wrapped_foo() # calling the decorated function<jupyter_output>I am function which will be decorated

Here starts the decorated function: basic_foo
I am function which will be decorated
Here ends the decorated function: basic_foo<jupyter_text>With some syntactic sugar we can do the same by puting the name of decorator on top of function header with  ```@``` symbol. See the example<jupyter_code>def decorator_foo(func):  # decorator function with argument for a function
    def wrapper_foo(*args, **kwargs): # inner function which will wrap the fucntion passed inside, *args and **kwargs are there to allow us to pass arguments with the function
        """A wrapper function"""
        # Extend some capabilities of func
        print(f'Here starts the decorated function: {func.__name__}') # we define some extended functionality for the wrapped function
        func(*args, **kwargs)  # and then we call it, also with arguments
        print(f'Here ends the decorated function: {func.__name__}')
    return wrapper_foo  # the inner function is returned so we need to put it in some variable

@decorator_foo  # this statement makes same step as we have done with wrapped_foo = decorator_foo(basic_foo) and allows us to call already decorated function by its name
def basic_foo(passed_arg):
    print(f'I am function which will be decorated. Passed argument is: {passed_arg}')

basic_foo(5)<jupyter_output>Here starts the decorated function: basic_foo
I am function which will be decorated. Passed argument is: 5
Here ends the decorated function: basic_foo
{'fraction_non_alphanumeric': 0.08130020268748593, 'fraction_numerical': 0.010509721492380452, 'mean_word_length': 4.015813253012048, 'pattern_counts': {'":': 0, '<': 61, '<?xml version=': 0, '>': 56, 'https://': 0, 'lorem ipsum': 0, 'www.': 0, 'xml': 0}, 'pii_count': 0, 'substrings_counts': 0, 'word_list_counts': {'cursed_substrings.json': 2, 'profanity_word_list.json': 0, 'sexual_word_list.json': 0, 'zh_pornsignals.json': 0}}
{'chain_length': '17', 'dir': 'jupyter-structured-clean-dedup', 'id': '109453', 'license': 'MIT', 'n_tokens_mistral': 3900, 'n_tokens_neox': 3775, 'n_words': 1730, 'path': 'courses/E375004/python_basics_2/basics_02a.ipynb', 'repo_name': 'CVUT-FS-12110/Python-for-scientific-computation-and-control', 'size': '13321'}