Python: Funções
Tags: python, programming
Published: 2023-05-12 20:59:59

Não é de hoje que códigos são escritos e reescritos constantemente. Quando programamos, nos vemos numa situação onde o mesmo trecho de código se aplica a diversas situações e, de fato, gostaríamos que assim fosse, sem ter que escrever tudo de novo o tempo todo. Aí entram as funções.

Funções são blocos nomeados de instruções que podem ser chamados e utilizados quantas vezes forem necessários, permitindo à pessoa programadora repetir o mesmo código sem a necessidade de escrevê-lo do zero.

Uma outra explicação para funções em linguagens de programação seria compará-las ao conceito matemático. Para uma função {% katex inline %} f(x) {% endkatex %}, existe um conjunto {% katex inline %} L {% endkatex %} finito de valores tal que

{% katex %}
f:L\xrightarrow{}Im_f, x\in L
{% endkatex %}

Sendo {% katex inline %} Im_f {% endkatex %} o conjunto de possíveis valores que {% katex inline %} f {% endkatex %} retornará.

As funções em Python se comportam da mesma forma e você pode criá-las através da palavra reservada `def`, seguida do nome da função e seus parênteses `()`. Além disso, funções podem retornar valores através da palavra reservada `return`. Nenhuma instrução da função abaixo do após o retorno será lida, então tome cuidado com seu uso.

```python
# def <nome_da_funcao>(<parametro_1>, <parametro_2>, ...)
def faz_algo():
		#conteúdo
```

No trecho acima, declaramos a função `faz_algo`, seguida de dois pontos (`:`). Como Python usa indentação como marcador para distinguir escopos, utilizar dois pontos diz ao interpretador que aqui estamos criando um novo escopo para a função. Portanto, além de indentar corretamente, não se esqueça dos dois pontos.

Funções são popularmente conhecidas como abstrações de código. De fato, elas nos poupam de muita computação desnecessária, como por exemplo a função `print()`, que escreve uma mensagem no console. E se ela não existisse na linguagem? Eu não gosto nem de pensar nessas coisas.

Dito isso, vamos criar uma função que retorna a soma de 2 mais 2:

```python
# def <nome_da_funcao>()
def soma_dois():
    return 2 + 2

print(soma_dois())
```

Ao rodar o script, receberemos o inteiro 4 como resposta e assim encapsulamos uma operação de soma do número 2 com ele mesmo. Mas e se quiséssemos criar uma função que somasse qualquer número com outro qualquer? 

## Passagem de Parâmetros

Comumente nos deparamos com a necessidade de usar valores externos ao escopo da função dentro dele, mas não seria prático criar uma abstração de código que depende de valores externos. Para tal, temos a passagem de parâmetros a funções.

Parâmetros são variáveis locais especificadas entre os parênteses `()`, no cabeçalho da função, que recebem cópias de valores externos ao escopo da função. Funções podem receber inúmeros parâmetros de diferentes tipos, contanto que sejam separados por vírgula. Como Python é uma linguagem dinamicamente tipada, não há a necessidade de explicitar os tipos na declaração de parâmetros, ou seja, eles serão descobertos em tempo de execução.

Considerando o exemplo de soma acima, façamos com que ele aceite todo tipo de inteiros para somar e não só o número 2;

```python
# parâmetros a e b declarados dentro dos parênteses e
# separados por vírgula
def soma_dois(a, b):
    return a + b

# invocação da função, passando seus dois parâmetros
print(soma_dois(1, 6))
print(soma_dois(10, 2))
print(soma_dois(5, 20))
```

E o output:

```python
$> python3 <nome_do_arquivo>.py
7
12
25
```

Como foi dito anteriormente, quando declaramos uma função, também declaramos um escopo. O escopo pode ser definido como um espaço léxico delimitante aos quais instruções estão associadas. Um exemplo disso são os parâmetros há pouco expliccados. Se os parâmetros são variáveis locais de uma função, então estão vinculados ao escopo e deixarão de existir na memória no fim da mesma. O mesmo serve para variáveis definidas dentro da função.

### Valores Padronizados para argumentos

Podemos passar um valor base para um parâmetro contando que ele substitua o valor passado na invocação da função, assim, omitindo ele. Veja o exemplo a seguir:

  

```python
def falar(mensagem, t=1):
    print(mensagem * t)

falar("Olá, Mundo!")
falar("Olá, Mundo!", 5)
```

A função `falar` recebe dois parâmetros na sua declaração: uma mensagem a ser escrita e a quantidade de vezes que a mensagem será escrita. No segundo parâmetro há uma atribuição de valores, o que nos indica que, logo na declaração, um valor já é atribuído a esse parâmetro, excluindo a necessidade de passar um valor na invocação da função. Graças a esse fato, as duas invocações da função são válidas e rodam. Veja o output:

```python
$> python3 <nome_do_arquivo>.py
Olá, Mundo!
Olá, Mundo!Olá, Mundo!Olá, Mundo!Olá, Mundo!Olá, Mundo!
```

### Passagem por Nomenclatura

Se você tiver uma função com diversos parâmetros e quiser especificar um ou mais deles, então você invocar a função e especificar esses parâmetros pelo nome deles em vez da ordem de passagem.

Normalmente, quando invocamos uma função, usamos a passagem posicional de parâmetros, que leva em consideração a ordem em que foram declarados no cabeçalho da função. Na passagem por nomenclatura, o que é considerado é o nome do parâmetro. Observe o exemplo abaixo:

```python
def mostra_valor(a, b=5, c=10):
    print('a é', a, 'e b é', b, 'e c é', c)

mostra_valor(3, 7)
mostra_valor(25, c=24)
mostra_valor(c=50, a=100)
```

E o seu output:

```python
$> python3 <nome_do_arquivo>.py
a é 3 e b é 7 e c é 10
a é 25 e b é 5 e c é 24
a é 100 e b é 5 e c é 50
```

A função `mostra_valor` tem somente um parâmetro sem valor padrão, seguido de outros dois com valores padrão. Na primeira invocação `mostra_valor(3, 7)`, o parâmetro `a` recebe 3, `b` recebe 7 e `c` mantém seu valor padrão, pois não foi passado nenhum outro parâmetro. Na segunda invocação `mostra_valor(25, c=24)`, o parâmetro `a` recebe o valor 25 devido ao fator posicional da passagem de valores, `b` mantém seu valor padrão e `c` recebe 24 graças à passagem por nomenclatura. Na terceira invocação `mostra_valor(c=50, a=100)`, o parâmetro `c` recebe 50 por nomenclatura, `a` recebe 100 também por nomenclatura e `b` mantém seu valor padrão.

# Recursão

De forma rasa e informal, a recursão na programação pode ser considerada como a repetição de um processo e, portanto, pode ser definida como um processo que chama a si mesmo direta ou indiretamente até que alcance uma condição de término. Mas como que esse processo se repete? É um laço de iteração?

A recursão se aplica diretamente a funções, pois é o único bloco de processamento que pode chamar a si mesmo durante sua execução. Uma função recursiva em sua integridade aplica um algoritmo de Divisão e Conquista ([O que é Divisão e Conquista?](https://pt.wikipedia.org/wiki/Divis%C3%A3o_e_conquista)), que consiste em dividir de forma recursiva um problema grande em problemas menores até que o problema seja resolvido. Ou seja, a função sempre vai retornar ela mesma com uma versão mais simples do problema até chegar na solução.

Consideremos que você, pessoa programadora, tem que determinar a soma dos {% katex inline %} n {% endkatex %} primeiros números naturais ({% katex inline %} \N {% endkatex %}). Existem diversas maneiras de fazer isso, mas a mais simples seria sair somando os números de 1 até {% katex inline %} n {% endkatex %}, parecendo com isso:

{% katex %}
f(n)=1+2+3+4+...+n
{% endkatex %}

Imagina se {% katex inline %} n {% endkatex %} for um número na casa dos milhares. Bastante trabalhoso, né? Nesse caso temos a opção recursiva para resolver esse problema. Veja a seguir:

{% katex %}
f = \begin{cases}
f(n) = 1 & \text{se } n=1 \\\
n + f(n-1) & \text{se } n \gt 1 
\end{cases}
{% endkatex %}

A única diferença entre os dois métodos é que a função {% katex inline %} f {% endkatex %} está sendo chamada dentro de sua própria função, estabelecendo uma condição de recursão.

Ainda ficou confuso? Observe os dois exemplos abaixo, onde um implementa um `for..in` loop e o outro, a recursão:

```python
def sequencia_decolar(contagem):
    for numero in range(contagem, -1, -1):
        if numero == 0:
            print("Decolar!!!")
        else:
            print(numero)

sequencia_decolar(5)
```

No trecho acima, criamos uma contagem regressiva para a decolagem de um foguete com o `for` e um laço condicional para caso a contagem chegue a 0. Agora veja esse mesmo caso com outros olhos.

```python
def sequencia_decolar(contagem):
    if contagem == 0:
        print("Decolar!!!")
        return
    else:
        print(contagem)
        return sequencia_decolar(contagem - 1)

sequencia_decolar(5)
```

Onde a variável local `contagem` representa de onde a contagem deve começar.

No exemplo recursivo vimos que o retorno `sequencia_decolar(contagem - 1)`
 é o mesmo problema, porém simplificado, o que caminha para uma pilha de
 funções onde a anterior, mais complexa, não será resolvida até que a 
próxima, mais simples, seja. Por exemplo, se a variável `contagem` for 5, teremos esse comportamento:

```markdown
## Invocação da função sequencia_decolar(5);

5 é igual a 0? Não.
Então imprima "5" no terminal e retorne sequencia_decolar(5 - 1);

4 é igual a 0? Não.
Então imprima "4" no terminal e retorne sequencia_decolar(4 - 1);

3 é igual a 0? Não.
Então imprima "3" no terminal e retorne sequencia_decolar(3 - 1);

2 é igual a 0? Não.
Então imprima "2" no terminal e retorne sequencia_decolar(2 - 1);

1 é igual a 0? Não.
Então imprima "1" no terminal e retorne sequencia_decolar(1 - 1);

0 é igual a 0? Sim.
Então imprima "Decolar!!!" e retorne vazio;
```

## Tipos de Recursão

Entendemos como a recursão funciona, agora entenderemos onde cada tipo de recursão se encaixa.

Como mencionado anteriormente, a recursividade pode aparecer direta ou indiretamente:

- **Forma direta**: É formada pela mesma estrutura de comandos e uma chamada a si mesma durante seu bloco de execução.
- **Forma indireta**: Nesse caso podem existir **n** funções e todas dependem de todas, gerando uma cadeia de dependências
até que a condição de término seja atingida. Veja o exemplo abaixo:
    
    ```python
    def impar(n):
        if n == 0:
            return False
        else:
            return par(n - 1)
    
    def par(n):
        if n == 0:
            return True
        else:
            return impar(n - 1)
    
    print(odd(5))  # Output: True
    ```
    
    No exemplo acima, `odd()` chama `even()` e `even()` chama `odd()`. Isso cria uma recursão indireta, pois a função `odd()` não chama a si mesma diretamente, mas chama a função `even()`, que por sua vez chama `odd()` novamente. A recursão continua até que a condição de parada seja alcançada. Neste caso, a recursão termina quando o valor de `n` é zero. Tudo que o exemplo faz é dizer se um número é par ou ímpar através de recursão.
    

## Memória na Recursividade

Sabemos que a recursividade aumenta bastante a legibilidade do nosso código, mas nem tudo é um mar de rosas, principalmente quando se trata de memória na recursão. Como será que funciona a recursividade por debaixo dos panos?

Numa função recursiva, enquanto a chamada não atinge a condição de término, empilhamos uma invocação na pilha. Quando a condição de término é atingida, as funções empilhadas são executadas uma por uma, da última empilhada até a primeira, até que não haja mais função a ser executada.

Quando comparadas com laços de iteração, funções recursivas consomem mais memória que laços, visto que cada chamada à função consome mais memória na [stack](https://blog.pantuza.com/artigos/heap-vs-stack) e um loop não requer espaço extra. Dito isso, pense bem antes de aplicar recursividade no seu projeto, principalmente se desempenho for um fator crucial.

Alguns pontos a serem ressaltados ao comparar as duas situações são:

- Iterações terminam quando uma condição se torna falha. As recursões, quando se chega no caso mais básico;
- Iterações não alocam espaços extras na stack;
- Recursões fornecem maior legibilidade ao código. Uma vez que se compreende o processo, fica fácil proceder;