Unidade 5 - Funções

Para esta unidade, revise as seções que tratam de funções do tutorial Python, começando pela seção 4.6. No texto seguinte, discutiremos alguns exemplos e falaremos um pouco sobre a importância de funções e sobre como utilizá-las efetivamente. No entanto, há diversas formas de utilizar funções em Python que não discutiremos aqui. Para aprender sobre isso, você deve o capítulo 4 do tutorial, particularmente do início da seção 4.7 até 4.7.3. O restante do capítulo também traz alguns detalhes e funcionalidades adicionais, como funções lambda e anotações, mas isso não é conteúdo da disciplina e você pode deixar para aprender depois se quiser.

Vamos retomar o exemplo de nossa calculadora, mas agora vamos adicionar outras operações.

while True:
    operador = input()
    if operador == "+":
        num1 = float(input())
        num2 = float(input())
        soma = num1 + num2
        print(soma)
    elif operador == "-":
        num1 = float(input())
        num2 = float(input())
        diferenca = num1 - num2
        print(diferenca)
    elif operador == "*":
        num1 = float(input())
        num2 = float(input())
        produto = num1 * num2
        print(produto)
    elif operador == "/":
        num1 = float(input())
        num2 = float(input())
        if num2 == 0:
            print("Divisor não pode ser zero")
            continue
        produto = num1 / num2
        print(produto)
    elif operador == "F":
        break
    else:
        print("Operação inválida")

Observe que usamos uma nova palavra-chave continue. Essa instrução termina a iteração atual do laço, mas ao contrário de break, continua na próxima iteração. Assim, imediatamente após executar continue, o interpretador irá voltar a verificar a condição do while.

Perceba que o programa começa a ficar muito grande e já não cabe em muitas telas, mas ainda é razoavelmente simples e a maioria dos programadores não teria dificuldades em ler esse código. No entanto, à medida em que adicionamos operações, a situação fica um pouco mais complicada.

Queremos que nossa calculadora seja mais útil do que as calculadoras de mesa tradicionais, então vamos adicionar duas operações, uma menos trivial do que a outra: a raiz quadrada e as raízes de uma equação do segundo grau.

Para calcular a raiz quadrada, vamos de novo importar um módulo math, então adicionamos no início no arquivo

import math

Com isso, basta basta escolher um nome apropriado para o operador e adicionar mais algumas linhas no corpo do while.

    # ....
    elif operador == "sqrt":
        num = float(input())
        raiz = math.sqrt(num)
        print(raiz)
    # ...

A segunda operação — encontrar as raízes de uma equação do segundo grau — diverge das operações anteriores, pois não há função prontamente disponível em Python, e precisamos de um número de instruções um pouco maior. Primeiro, lembramos que uma equação do segundo grau é escrita como

$$ ax^2 + bx + c = 0. $$

Desde muito sabemos como encontrar o valor de $x$. Primeiro, calculamos o valor do discriminante

$$ \Delta = b^2 - 4ac. $$

Com o valor de $\Delta$, podemos determinar quantas e quais são as soluções da equação. A fórmula para isso é

$$ x = \frac{-b \pm \sqrt{\Delta}}{2a}, $$

que é popularmente conhecida como fórmula de Bhaskara. Adicionamos o seguinte ao corpo da função

while True:
    # ....
    elif operador == "bhaskara":
        a = float(input())
        b = float(input())
        c = float(input())
        delta = b*b - 4 * a * c
        if delta < 0:
            print("Não há raízes reais")
            continue
        elif delta == 0:
            print("Há uma raiz distinta apenas")
        else:
            print("Há duas raízes distintas")
        x1 = (-b + math.sqrt(delta)) / (2 * a)
        x2 = (-b - math.sqrt(delta)) / (2 * a)
        print(f"As raízes são {x1} e {x2}")
    # ...

Se tentarmos olhar para todo o programa, teremos uma surpresa:


while True:
    operador = input()
    if operador == "+":
        num1 = float(input())
        num2 = float(input())
        soma = num1 + num2
        print(soma)
    elif operador == "-":
        num1 = float(input())
        num2 = float(input())
        diferenca = num1 - num2
        print(diferenca)
    elif operador == "*":
        num1 = float(input())
        num2 = float(input())
        produto = num1 * num2
        print(produto)
    elif operador == "/":
        num1 = float(input())
        num2 = float(input())
        if num2 == 0:
            print("Divisor não pode ser zero")
            continue
        produto = num1 / num2
        print(produto)
    elif operador == "sqrt":
        num = float(input())
        raiz = math.sqrt(num)
        print(raiz)
    elif operador == "bhaskara":
        a = float(input())
        b = float(input())
        c = float(input())
        delta = b * b - 4 * a * c
        if delta < 0:
            print("Não há raízes reais")
            continue
        elif delta == 0:
            print("Há uma raiz distinta apenas")
        else:
            print("Há duas raízes distintas")
        x1 = (-b + math.sqrt(delta)) / (2 * a)
        x2 = (-b - math.sqrt(delta)) / (2 * a)
        print(f"As raízes são {x1} e {x2}")
    elif operador == "F":
        break
    else:
        print("Operação inválida")

Dessa vez, tenho certeza de que todo o código não cabe em uma única tela de seu editor. Mais importante do que isso, é extremamente difícil ler esse código e entender o que está acontecendo! O motivo, além do tamanho, é que para entender o código acima precisamos nos preocupar, ao mesmo tempo, com diversos problemas distintos:

Como estamos fazendo tudo isso de maneira intercalada, ao lermos esse código, ora nos preocumos com o while, ora com a entrada e saída, ora com a fórmula de Bhaskara. Pior! Se houver um erro na lógica do nosso programa, vamos gastar bastante tempo tentando descobrir onde ele está.

Funções

Para resolver esse problema, vamos criar uma abstração para cada conjunto de instruções dedicadas a resolver uma determinada tarefa. Vamos reescrever ou refatorar o programa. Utilizaremos uma estratégia de resolver os problemas mais gerais primeiro e, depois, os mais específicos (algumas pessoas chamam a isso de estratégia top-down).

Primeiro precisamos descobrir qual a tarefa principal do programa, isso é, qual é o conjunto de instruções deve ser executado ao iniciar o programa. Nesse exemplo, a primeira instrução é while, que é responsável pelo controle das operações digitadas pelo usuário. Vamos então nos concentrar nesse problema e esconder todos os demais. Escrevemos o seguinte:


while True:
    operador = input()
    if operador == "+":
        operacao_soma()
    elif operador == "-":
        operacao_diferenca()
    elif operador == "*":
        operacao_multiplicacao()
    elif operador == "/":
        operacao_divisao()
    elif operador == "sqrt":
        operacao_raiz()
    elif operador == "bhaskara":
        operacao_bhaskara()
    elif operador == "F":
        break
    else:
        print("Operação inválida")

Agora fica muito mais simples entender o que esse código faz: ele lê uma linha do teclado e realiza uma operação de acordo com o que o usuário digitar. É claro que operacao_soma, operacao_diferenca etc. não são instruções da linguagem Python. Cada um desses nomes é uma abstração: aqui, abstrair significa esconder os detalhes de como realizar um determinada operação.

Se tentarmos executar um código assim iríamos obter uma mensagem de erro do interpretador dizendo que algum nome não está definido, como se ele reclamasse, “não sei do que você está falando”. Vamos então definir cada um desses nomes. Para isso, antes do while, adicione

def operacao_soma():
    pass

def operacao_diferenca():
    pass

def operacao_produto():
    pass

def operacao_divisao():
    pass

def operacao_raiz():
    pass

def operacao_bhaskara():
    pass

O que estamos fazendo é definir diversas novas funções. Uma função é um conjunto de instruções que realiza determinada tarefa identificada por um nome. As funções acima são só stubs, que são funções incompletas. O motivo para criar stub é poder testar o código do programa principal enquanto ainda estamos desenvolvendo nosso programa.

Depois de terminado o trecho de código do programa principal, devemos implementar cada uma das funções definidas acima. Implementar uma função significa escrever o conjunto de instruções que realiza a tarefa correspondente. Uma das vantagens de definir e usar funções é que é muito mais fácil implementar tarefas pequenas, uma de cada vez, do que tentar resolver todo o problema de uma vez. Para a função operacao_soma basta copiar as instruções do código original:

def operacao_soma():
    num1 = float(input())
    num2 = float(input())
    soma = num1 + num2
    print(soma)

Você deve fazer o mesmo para as operações de diferença e produto. Para a operação de divisão, no entanto, precisamos tomar um cuidado a mais. Copiando o código original, teríamos:

def operacao_divisao():
    num1 = float(input())
    num2 = float(input())
    if num2 == 0:
        print("Divisor não pode ser zero")
        continue
    produto = num1 / num2
    print(produto)

O problema do código acima é que a instrução continue só faz sentido dentre do corpo de um comando for ou while. O que queremos, nesse caso, é terminar a função e retornar ao menu. Para isso, existe a instrução return que termina a função que a contém e continua a execução imediatamente depois do ponto de chamada.

def operacao_divisao():
    num1 = float(input())
    num2 = float(input())
    if num2 == 0:
        print("Divisor não pode ser zero")
        return
    produto = num1 / num2
    print(produto)

Faça o mesmo com operação para obter raízes da equação.

Passando argumentos e devolvendo valores

Vamos olhar como ficaria a função para a operação da raiz quadrada:

def operacao_raiz():
    num = float(input())
    raiz = math.sqrt(num)
    print(raiz)

A expressão math.sqrt(num) é uma chamada a uma função de nome sqrt importada com o módulo math. Repare que o radiando é passado como parâmetro da função, dentro dos parênteses. Além disso, a chamada da função é utilizada como se fosse substituído pela raiz da função. Assim, a função sqrt funciona, de fato, como uma função matemática, que tem uma entrada e calcula uma saída.

Embora calcular a raiz quadrada já é uma função própria de uma biblioteca padrão do Python, ela normalmente não é uma instrução elementar dos nossos computadores modernos. Como bons computeiros, devemos ter o espírito curioso de descobrir como uma tal operação complexa pode ser computada a partir de operações elementares (soma, subtração, multiplicação e divisão).

Há vários métodos conhecidos e você deve ter já estudado pelo menos um na escola. Nesse exemplo, vamos usar o método de Newton. Como essa é uma operação complicada, primeiro vamos criar um stub para poder modificar o código de operacao_raiz. Fazemos o seguinte:

def minha_sqrt(radiando):
    raiz = radiando / 2 # TODO: implementar método de Newton
    return raiz

# ...

def operacao_raiz():
    num = float(input())
    raiz = minha_sqrt(num)
    print(raiz)

A função minha_sqrt é só um stub, mas ela ilustra dois conceitos novos:

Já organizamos nosso programa em funções, então podemos nos concentrar em implementar o método de Newton. Você deve ter percebido que fizemos um comentário que começa com # TODO: .... É comum criar comentários como esse ou da forma # FIXME: .... para indicar trechos de código que merecem atenção posterior. Outra maneira, para projetos grandes, é anotar um bug num gerenciador de tarefas (como bugzilla, issues, etc.). Agora, podemos revisar o método de Newton e implementar a função, mas é mais fácil relembrar o exercício em que calculamos a raiz quadrada.

Repetição de código

Vamos resolver mais um exercício:

Escreva um programa que recebe um número inteiro e decide se ele é um produto de dois números primos.

Antes de começar, vamos listar as duas pequenas tarefas que devemos fazer:

  1. controlar a entrada e a saída do programa;
  2. verificar se um número é produto de dois primos.

Novamente, precisamos identificar qual delas é a tarefa principal, isso é, que será executada primeiro quando o programa começar. Na maioria dos nossos programas, essa vai ser a tarefa de ler os dados de entrada, realizar algumas operações e mostrar os dados de saída. Nesse caso, vamos fazer o seguinte:

def main():
    n = int(input("Digite um número inteiro: "))
    if eh_produto_dois_primos(n):
        print(f"O número {n} é produto de dois primos.")
    else:
        print(f"O número {n} não é produto de dois primos.")

main()

Observe que definimos uma função chamada main e que adicionamos uma chamada a essa função na última linha do programa, que é a única instrução do programa que não está dentro de uma função. Sempre que nosso problema não for trivial, vamos preferir organizar nossos programas dessa maneira. O nome main é uma convenção e serve para identificar facilmente qual é a função principal do programa.

Para entender esse programa, precisamos investigar a linha que contém if eh_produto_dois_primos(n):. O que está implícito aqui é que eh_produto_dois_primos é uma função que devolve um valor booleano dependendo se o número n passado como parâmetro é produto de dois primos. Para podermos deduzir isso foi fundamental que o nome da função fosse bem representativo do que ela faz e do que ela devolve.

Para poder testar a função main, faremos um stub.

def eh_produto_dois_primos(n):
    """Devolve True se o argumento n puder
    ser escrito como um produto de dois primos"""
    return True

A novidade nesse programa é que adicionamos uma string na primeira linha da função. Essa string não é associada a nenhuma variável e não tem nenhum efeito. O motivo para adicioná-la é que ela serve para documentar o que a função faz. Por mais que uma função tenha (e deva ter) um bom nome, nem sempre é claro o que cada função faz, particularmente se voltarmos a ler nosso código depois de alguns dias. Essas strings são denominadas strings de documentação ou documentation strings.

Quando testarmos o programa incompleto com qualquer número, digamos, 100, obteremos sempre uma mensagem como

O número 100 é produto de dois primos.

independentemente do número lido. Para testar o conjunto de instruções correspondentes ao else da função main podemos fazer o nosso stub devolver False. Depois, já podemos implementar a função de fato. Antes, vamos escrever um algoritmo, em alto nível.

  1. para cada número $q$ em $1, 2, …., n$:
    • se $n$ for divisível por $q$:
      • faça $r \gets n / q$
      • verifique se $q$ é primo
      • verifique se $r$ é primo
      • se ambos forem primos
        • responda SIM
  2. se não tiver terminado, responda NÃO

Você pode só acreditar que esse algoritmo está correto, ou pode tentar se convencer de que está. Um bom computeiro tenta entender bem o que um algoritmo faz antes de tentar codificá-lo. Para isso, teste com algumas instâncias pequenas utilizando lápis e papel. Quando estivermos confiantes de que o o algoritmo está correto, podemos passar à implementação.

def eh_produto_dois_primos(n):
    """Devolve True se o argumento n puder
    ser escrito como um produto de dois primos"""

    produto_primos = False
    for q in range(1, n + 1):
        if n % q == 0:
            r = n // q

            r_eh_primo = True
            for d in range(2, r):
                if r % d == 0:
                    r_eh_primo = False
                    break

            q_eh_primo = True
            for d in range(2, q):
                if r % d == 0:
                    q_eh_primo = False
                    break

            if r_eh_primo and q_eh_primo:
                produto_primos = True
                break

    return produto_primos

Leia com atenção e copie essa função no seu programa. Podemos testar o programa digitando o número 15 que é um produto de dois primos 3 e 5. Ao verificar a saída iremos ver que o programa imprimiu corretamente

O número 15 é produto de dois primos.

Se testarmos com os números 10 ou 4, o programa também responderá corretamente. Mas devolver a resposta correta para alguns exemplos não significa que o programa está correto. Na verdade, não importa qual número inteiro fornecermos, o programa sempre responderá que ele é produto de dois primos. Isso não é verdade quando a resposta é não, como para o número 8 ou o número 20.

Concluímos duas coisas: primeiro, nosso programa está errado; segundo, é importante testar nossos programas com vários exemplos de entrada, particularmente para entradas que correspondem a saídas diferentes.

Para descobrir onde está o erro do programa, podemos usar diversas estratégias, como simulá-lo com um debugger, ou ler o código lentamente com atenção. Se você ainda não descobriu o erro, pare um pouco e tendo descobri-lo.

Uma vez descoberto o erro, precisamos corrigi-lo. Mais importante, precisamos entender porque esse erro ocorreu para início de conversa. O erro é um erro de digitação na segunda ocorrência de if r % d == 0: que deveria ser if q % d == 0:. Como você deve adivinhar, isso ocorreu porque temos dois trechos de código muito parecidos e o segundo foi obtido copiando e modificando o primeiro, mas esquecemos de modificar uma linha.

Esse exemplo descreve o que chamamos de duplicidade de código, que é uma situação extremamente comum no desenvolvimento de software. Muitas vezes, cada trecho de código tem papeis similares, mas para parâmetros diferentes. No exemplo acima, queremos decidir se um dado número (r ou q) é primo. Em situações como essa, devemos refatorar o código e definir uma função. Reescrevemos assim.

def eh_primo(n):
    """Verifica se n é primo"""

    eh_primo = True
    for d in range(2, n):
        if n % d == 0:
            eh_primo = False
            break
    return eh_primo

def eh_produto_dois_primos(n):
    """Devolve True se o argumento n puder
    ser escrito como um produto de dois primos"""

    produto_primos = False
    for q in range(1, n + 1):
        if n % q == 0:
            r = n // q
            if eh_primo(r) and eh_primo(q):
                produto_primos = True
                break

    return produto_primos

Deve ser evidente que a nova versão é mais simples e muito mais compacta. Dessa vez, não há repetição de código. Testando para o número 8 vemos que, agora sim, ele responde corretamente que não é produto de dois primos.

Infelizmente, como você já pode desconfiar, esse programa ainda não está correto. Estude o programa e tente determinar para que exemplos ele devolve a saída incorreta. Depois, corrija seu programa. Ao terminar esse exercício, você vai descobrir mais uma vantagem de ter refatorado o programa com uma nova função, ao invés de manter as duas cópias praticamente idênticas de um conjunto de instruções.

Criando e organizando seu programa

Vamos resolver mais um problema para nos exercitar.

Crie um programa que leia duas listas do teclado, correspondentes às notas de provas e de exercícios dos estudantes de uma turma, normaliza cada nota dividindo-se a nota pelo máximo da lista correspondente e computa a média final de cada estudante, que é dada pela média geométrica entre a nota de prova e de exercícios.

Você deve tentar fazer todo esse programa por conta própria. Para isso, procure seguir a mesma estratégia que seguimos antes:

  1. Leia o enunciado e procure entender o que é pedido. Para isso, tente formalizar entrada e saída e crie alguns exemplos. Depois, faça uma lista de todas as tarefas curtas que precisam ser realizadas para se resolver esse problema.
  2. Identifique a tarefa principal responsável por controlar entrada e saída e crie uma função main utilizando chamadas para funções auxiliares quando quiser abstrair pequenas tarefas.
  3. Crie funções stubs para cada função auxiliar necessária. Certifique-se de que os nomes das funções sejam adequados para as tarefas abstraídas. Não se esqueça de documentar as funções identificando a entrada e a saída sempre que necessário.
  4. Teste a função principal verificando as instruções que controlam a entrada e a saída da função.
  5. Implemente cada stub definido. Lembre-se de escrever antes um algoritmo em português, testar e, só depois, traduzi-los à linguagem Python. Procure testar seu programa a cada função implementada.

Em seguida eu mostro como eu resolveria esse problema. Não leia esse código enquanto não tiver terminado o seu próprio programa. Seu programa pode divergir completamente do código abaixo, mas isso não significa que uma maneira é mais correta do que a outra. Procure analisar criticamente as diferenças. Uma peculiaridade da Computação é que, embora seja uma ciência dita exata, os algoritmos são tão distintos quanto seus próprios programadores. Tanto que alguns diriam que programação é uma arte!

"""
Calcula as médias finais dos estudantes.

Entrada:
- uma linha com o número n de estudantes
- n linhas correspondentes às notas de provas
- n linhas correspondentes às notas de exercícios

Saída:
- n linhas correspondentes às medias finais
"""

import math

def ler_lista_numeros(n):
    """Lê uma lista de n números do teclado"""
    lista = []
    for _ in range(n):
        numero = float(input())
        lista.append(numero)
    return lista

def imprimir_lista_numeros(lista):
    """Imprime cada número de lista em um linha,
    com duas casas decimais"""
    for valor in lista:
        print(f"{valor:.2f}")

def obter_maximo(lista):
    """Devolve o valor máximo da lista"""
    assert lista, "Lista não pode ser vazia"
    maximo = lista[0]
    for numero in lista:
        if numero > maximo:
            maximo = numero
    return maximo

def criar_lista_normalizada(lista_antiga):
    """Devolve uma nova lista com os elementos
    de lista_antiga normalizados pelo máximo"""
    maximo = obter_maximo(lista_antiga)
    lista_nova = []
    for valor in lista_antiga:
        novo_valor = valor / maximo
        lista_nova.append(novo_valor)
    return lista_nova

def calcular_medias_finais(notas_provas, notas_exercicios):
    """Devolve uma nova lista com as médias geométricas
    dos elementos de notas_provas e notas_exercicios"""
    medias_finais = []
    assert len(notas_provas) == len(notas_exercicios), \
           "As listas de notas devem ter o mesmo tamanho"
    n = len(notas_provas)
    for i in range(n):
        media_final = math.sqrt(notas_provas[i] * notas_exercicios[i])
        medias_finais.append(media_final)
    return medias_finais

def main():
    n = int(input(n))
    notas_provas = ler_lista_numeros(n)
    notas_exercicios = ler_lista_numeros(n)

    notas_provas = criar_lista_normalizada(notas_provas)
    notas_exercicios = criar_lista_normalizada(notas_exercicios)

    medias_finais = calcular_medias_finais(notas_provas, notas_exercicios)
    imprimir_lista_numeros(medias_finais)

main()

Há vários detalhes nesse programa e talvez alguns sejam novos para você. Você deve pesquisar as instruções que não conhecer e descobrir o objetivo de elas estarem ali. O que queremos destacar nesse exemplo, no entanto, é a forma como está organizado. É uma boa prática (embora nem sempre seja seguida no mercado) criar programas bem documentados e com formatação padronizada, como acima. O programa acima está organizado de acordo com algumas convenções:

  1. O programa começa com um comentário descrevendo o que ele faz. No caso de Python, usamos uma string de múltiplas linhas que é mais convenientes. Repare que a documentação descreve precisamente como utilizar o programa.
  2. Em seguida, há uma seção de import. Por enquanto só conhecemos e precisamos de uma função no módulo math, mas podemos ter uma sequência bem grande de instruções desse tipos quando tivermos programas mais complexos que precisam de mais bibliotecas.
  3. Logo em seguida há uma sequência de funções auxiliares para se resolver nosso problema, cada uma bem documentada. As funções estão organizadas nessa ordem não por acaso. Nesse nosso exemplo, primeiro estão as funções mais gerais (de entrada e saída) e depois funções mais específicas para nosso problema. Cada desenvolvedor (ou grupo) normalmente adota uma estratégia particular.
  4. Por último, vem a função principal, denominada main, seguida de uma chamada para ela. O programa começa a executar por ali e a maioria dos programadores também começará a ler programas a partir da função main.

Escopo de variáveis e ciclo de vida de funções

Quando aprendemos a instrução de atribuição, vimos que ela tem a forma

<nome de variável> = <expressão que computa um valor na memória>

Assim, o nome do lado esquerdo deve ser um nome que referencia algum valor armazenado na memória. Essa associação, no jargão de Python é chamada de binding. Uma vez definido o binding, podemos usar o nome da variável em um expressão para representar o valor referenciado. O nome de uma variável, no entanto, só pode ser usado quando satisfeitas determinadas condições:

  1. A atribuição correspondente ao binding tiver sido executada antes do uso da variável. Se houver mais de uma atribuição correspondente ao mesmo nome, então o nome corresponderá ao último valor referenciado.

  2. O nome da variável ser visível dentro do escopo em que foi criada. Um escopo é um conjunto de nomes de variáveis correspondentes a uma região do código bem definida. Uma atribuição sempre corresponde ao nome do escopo atual.

Vamos estudar dois tipos de escopo.

Para deixar esses conceitos um pouco mais concretos, vejamos um exemplo de código:

PI = 3.141592653589793

def calcular_area_disco(raio):
    raio_quadrado = raio ** 2
    area = PI * raio_quadrado

def calcular_volume_esfera(raio):
    raio_cubo = raio ** 3
    volume = 4.0/3.0 * PI * raio_cubo
    return volume

def main():
    raio = float(input("Digite o raio de uma esfera: "))
    peso = float(input("Digite o peso dessa esfera: "))

    volume = calcular_volume_esfera(raio)
    densidade = peso / volume

    print(f"A densidade da esfera á {densidade}")

main()

Existe uma variável global denominada PI. Essa variável pode ser utilizada em qualquer ponto do programa que execute após sua definição. Observe quem ambas as funções calcular_area_disco e calcular_area_disco fazem uso de PI.

As variáveis locais de calcular_area_disco são o parâmetro raio e as demais variáveis raio_quadrado e area. Analogamente, as variáveis locais de calcular_volume_esfera são raio, raio_cubo e volume. Finalmente, as variáveis locais de main são raio, peso, volume e densidade.

Cada função enxerga apenas as variáveis globais e suas variáveis locais! Assim, o nome raio_cubo não está no escopo de main nem de calcular_area_disco. Mas e os nomes de variáveis que são comuns a mais de uma função? Cada função têm suas próprias variáveis locais, assim há três nomes raio distintos. Você pode pensar que há o raio-da-função-calcular_area_disco, o raio-da-função-calcular_volume_esfera e o raio-da-função-main. O mesmo acontece para a variável volume que é comum a calcular_volume_esfera e main.

Para entender melhor, façamos um exercício. O que será impresso pelo programa a seguir?

INCREMENTO = 3

def somar(x):
    x = x + INCREMENTO

def main():
    x = 10
    somar(x)
    print(x)

main()

Você deve ter respondido corretamente: 10. Embora tanto somar quanto main tenham uma variável de nome x, em cada função esse nome se refere a uma variável distinta.

Fazemos uma modificação. O que será impresso?

INCREMENTO = 3

def somar(x):
    x = x + INCREMENTO
    return x

def main():
    x = 10
    soma1 = somar(x)
    INCREMENTO = 4
    soma2 = somar(x)
    print(soma1)
    print(soma2)

main()

Agora o valor calculado pela primeira chamada de somar foi devolvido e armazenado em um variável referenciada por soma1 e o valor calculado pela segunda chamada em soma2. Nesse caso, não é tão simples descobrir o que será impresso. Se você simular esse programa, obterá 13 e 13. Por que não foi impresso 13 e 14? Sabemos que somar depende da variável global INCREMENTO, mas a função main faz uma atribuição a uma variável local INCREMENTO. Lembrem-se de que atribuições feitas em funções são sempre locais!

Por esse motivo (e por alguns outros que você ainda vai descobrir), nunca use ou faça modificações em variáveis globais! Repetindo: nunca! A única razão para usarmos uma variável global é para dar um nome a um valor que nunca deverá ser mudado durante a execução do algoritmo. Chamamos essas variáveis de constantes. Aliás, é por esse motivo que convencionamos escrever todas as variáveis globais em maiúsculas, para indicar que elas são constantes e não devem ser alteradas.

Para descobrir o que está acontecendo internamente no interpretador Python, precisamos entender o ciclo de vida de uma função. Para isso, vamos fazer mais um exercício.

Crie um programa que leia a lista de notas dos estudantes e normalize as notas de forma que a maior seja 10. Em seguida, determine para cada estudante da lista se ele foi aprovado.

Experimente resolver esse exercício. Para os impacientes, segue o código que eu faria:

NOTA_MINIMA = 5.0

def obter_maximo(lista):
    assert lista, "Lista não pode ser vazia"
    maximo = lista[0]
    for valor in lista:
        if maximo < valor:
            maximo = valor
    # breakpoint()
    return maximo

def multiplicar_fator(lista, fator):
    n = len(lista)
    for i in range(n):
        lista[i] = lista[i] * fator

def ler_lista_notas():
    n = int(input("Digite o número de estudantes: "))
    lista_notas = []
    for _ in range(n):
        lista_notas.append(float(input()))
    return lista_notas

def imprimir_lista_aprovacao(lista_notas):
    for nota in lista_notas:
        if nota < NOTA_MINIMA:
            print("reprovado")
        else:
            print("aprovado")

def main():
    lista_notas = ler_lista_notas()

    maximo = obter_maximo(lista_notas)
    fator = 10.0 / maximo
    multiplicar_fator(lista_notas, fator)

    imprimir_lista_aprovacao(lista_notas)

main()

Vamos fazer um desenho representando a memória do computador no momento imediatamente anterior em que obter_maximo devolve o valor máximo da lista.

Há varias coisas a se notar. Nesse momento, a função ler_lista_notas já foi executada e terminada, então todas as variáveis locais dessa função já não estão mais disponíveis na função, isso é, não há escopo para as variáveis dessa função. Do mesmo modo, as funçẽs multiplicar_fator e imprimir_lista_aprovacao também não foram chamadas e, portanto, suas variáveis ainda não foram criadas.

Há exatamente duas funções sendo executadas nesse momento: a função main e a função obter_maximo, então existem exatamente dois escopos de função, além do escopo global.

Se você quiser verificar se a figura acima está correta, faça o seguinte: descomente a linha com breakpoint() na função obter_maximo() e execute o seu programa. Você entrará no mode de debug com uma mensagem mostrando a próxima instrução. Digite bt (que é a abreviatura de backtrace) para mostrar a trajetória do seu programa até essa instrução e explore a memória investigando os valores das variáveis, e.g., digite maximo para ver o valor associado a esse nome no escopo atual. Se você não gosta de usar o terminal, então pode fazer o mesmo configurando sua IDE preferida e adicionado um breakpoint na linha correspondente ao return.

Digite o número de estudantes: 4
4.8
3.5
8.0
7.5
> /home/user/ra123456/funcoes/notas.py(10)obter_maximo()
-> return maximo
(Pdb) bt
  /home/user/ra123456/funcoes/notas.py(40)<module>()
-> main()
  /home/user/ra123456/funcoes/notas.py(34)main()
-> maximo = obter_maximo(lista_notas)
> /home/user/ra123456/funcoes/notas.py(10)obter_maximo()
-> return maximo
(Pdb) valor
7.5
(Pdb) maximo
8.0
(Pdb) continue
aprovado
reprovado
aprovado
aprovado

Imediatamente depois que a função obter_maximo termina, o valor referenciado na frente de return é devolvido para a função main. Nesse momento, o escopo da função obter_máximo é destruído. A próxima instrução de main é uma atribuição ao nome de variável máximo, que recebe o valor devolvido. Podemos olhar para a seguinte figura:

Com isso, podemos resumir o ciclo de vida de uma função:

  1. quando uma função é chamada:

    • criamos um novo escopo para essa chamada
    • para cada argumento, associamos os valores passados entre parênteses
    • continuamos executando a partir da primeira instrução da função
  2. quando uma função termina:

    • devolvemos o valor após return, se houver
    • destruímos o escopo da chamada de função
    • continuamos executando a próxima instrução imediatamente após a chamada

O mecanismo de chamadas de função em Python tem uma consequência especial para listas passadas como argumentos. Quando alteramos uma lista passada por argumento, essas mudanças ficarão visíveis para a função que realizou a chamada. Isso acontece quando chamamos a função multiplicar_fator. Para ver o motivo disso, repare que tanto a variável lista_notas de main quanto a variável lista de multiplicar_fator são associadas à mesma lista. O desenho a seguir representa a memória imediatamente após a primeira iteração da linha lista[i] = lista[i] * fator.

Módulos

À medida em que nossos projetos ficam maiores e mais complexos, copiar e colar um conjunto de funções em nossos arquivos Python torna-se bastante difícil. Mais dos que isso, pode ser que um conjunto de funções possa ser útil a diferentes programas. A maneira de tratar isso no universo Python é criando-se módulos, que agrupam um conjunto de funções e variáveis relacionadas.

Diversas linguagens de programação têm abstrações similares e implementam módulos de maneiras distintas, mas todas elas têm em comum o objetivo de organizar um programa em partes menores, que podem ser criadas e testadas separadamente. Isso é que permite, por exemplo, que grandes sistemas sejam construídos com a colaboração de várias pessoas. Nesta disciplina, iremos estudar apenas como utilizar e criar módulos simples, mas depois de ler o texto abaixo, você pode estudar a seção 6 do tutorial para uma introdução um pouco mais detalhada sobre módulos em Python.

Um módulo é um arquivo Python que é executado quando executamos o comando import. Os módulos podem vir de várias partes, dependendo de como foram instalados:

  1. Já vimos um exemplo de módulo quando digitamos import math. Esse módulo é um módulo da biblioteca padrão Python, o que significa que está disponível em qualquer instalação Python.
  2. Também há módulos adicionais instalados no sistema, que são responsáveis por tarefas comuns, mas de domínios específicos, como manipulação de imagens, comunicação com a Internet, computação científica, etc.
  3. Finalmente, há módulos pessoais, que são aqueles criados pelo próprio desenvolvedor para seus próprios projetos.

Por enquanto, só iremos falar em como criar um módulo pessoal. Para isso, vamos ver um exemplo.

Em uma determinada disciplina, há 2 exercícios. A média parcial de um estudante é dada pela média geométrica das notas dos exercícios. Escreva um programa que leia as listas de notas de exercicios e mostre a lista de notas parciais.

Isso é bem parecido com o que já fizemos, então nada melhor do que copiar e colar algumas funções auxiliares. Copiamos ler_lista_numeros e imprimir_lista_numeros. Para calcular as médias, já temos uma função que faz isso, calcular_medias_finais, mas melhor ajustar os nomes, para não nos confundirmos. Com isso, escrevemos um programa chamado notas_parciais.py.

import math

def ler_lista_numeros(n):
    """Lê uma lista de n números do teclado"""
    lista = []
    for _ in range(n):
        numero = float(input())
        lista.append(numero)
    return lista

def imprimir_lista_numeros(lista):
    """Imprime cada número de lista em um linha,
    com duas casas decimais"""
    for valor in lista:
        print(f"{valor:.2f}")

def calcular_medias_geometricas(lista1, lista2):
    """Devolve uma nova lista com as médias geométricas
    dos elementos de lista1 e lista2"""
    medias_geometricas = []
    assert len(lista1) == len(lista2), "As listas de devem ter o mesmo tamanho"
    n = len(lista1)
    for i in range(n):
        media_geometrica = math.sqrt(lista1[i] * lista2[i])
        medias_geometricas.append(media_geometrica)
    return medias_geometricas

def main():
    n = int(input())
    notas_exercicios1 = ler_lista_numeros(n)
    notas_exercicios2 = ler_lista_numeros(n)
    medias_parciais = calcular_medias_geometricas(notas_exercicios1, notas_exercicios2)
    imprimir_lista_numeros(medias_parciais)

main()

Repare que, enquanto nesse programa resolvemos o problema de repetição de código, afinal, só definimos ler_lista_numeros uma vez, temos que escrever exatamente as mesmas instruções que estavam em outro programa anterior. Nessas situações, é mais conveniente criar um módulo que possa ser compartilhado entre os dois programas. Primeiro, criamos um arquivo, no mesmo diretório, chamado utilidades.py e movemos as funções utilitárias para lá:

import math

def ler_lista_numeros(n):
    """Lê uma lista de n números do teclado"""
    lista = []
    for _ in range(n):
        numero = float(input())
        lista.append(numero)
    return lista

def imprimir_lista_numeros(lista):
    """Imprime cada número de lista em um linha,
    com duas casas decimais"""
    for valor in lista:
        print(f"{valor:.2f}")

def calcular_medias_geometricas(lista1, lista2):
    """Devolve uma nova lista com as médias geométricas
    dos elementos de lista1 e lista2"""
    medias_geometricas = []
    assert len(lista1) == len(lista2), "As listas de devem ter o mesmo tamanho"
    n = len(lista1)
    for i in range(n):
        media_geometrica = math.sqrt(lista1[i] * lista2[i])
        medias_geometricas.append(media_geometrica)
    return medias_geometricas

Com isso podemos modificar nosso arquivo notas_parciais.py para conter apenas:

from utilidades import ler_lista_numeros, imprimir_lista_numeros, calcular_medias_geometricas

def main():
    n = int(input())
    notas_exercicios1 = ler_lista_numeros(n)
    notas_exercicios2 = ler_lista_numeros(n)
    medias_parciais = calcular_medias_geometricas(notas_exercicios1, notas_exercicios2)
    imprimir_lista_numeros(medias_parciais)

main()

A primeira linha desse programa faz o seguinte:

  1. procura o módulo chamado utilidades na sua lista de módulos e encontrar um arquivo utilidades.py;
  2. executa todas as instruções nesse módulo; nesse caso, há apenas instruções de definição de funções;
  3. torna disponível no escopo global do programa (notas_parciais.py) os nomes das funções selecionadas.

Agora, essas funções podem ser utilizadas em vários programas, mas sem a necessidade de copiar e colar. Por exemplo, suponha que, no final do semestre, tenhamos que escrever outro programa.

Além dos exercícios, há uma prova. A média final da disciplina é dada pela média aritmética entre a média parcial e a nota da prova. Escreva um programa que leia as listas de notas marciais e das provas e mostre a lista de notas finais.

Podemos, agora escrever o seguinte programa, notas_finais.py

import utilidades

def calcular_medias_aritmeticas(lista1, lista2):
    """Devolve uma nova lista com as médias aritméticas
    dos elementos de lista1 e lista2"""
    medias_aritmeticas = []
    assert len(lista1) == len(lista2), "As listas de devem ter o mesmo tamanho"
    n = len(lista1)
    for i in range(n):
        media_aritmetica = (lista1[i] + lista2[i]) / 2
        medias_aritmeticas.append(media_aritmetica)
    return medias_aritmeticas

def main():
    n = int(input())
    notas_exercicios1 = utilidades.ler_lista_numeros(n)
    notas_exercicios2 = utilidades.ler_lista_numeros(n)
    medias_parciais = calcular_medias_aritmeticas(notas_exercicios1, notas_exercicios2)
    utilidades.imprimir_lista_numeros(medias_parciais)

main()

Observe que agora utilizamos uma sintaxe alternativa para importar o módulo. O módulo utilidades.py continua sendo executado e importado como antes. A única diferença é que o nome da função ler_lista_numeros e demais não estarão disponíveis no escopo global do nosso programa. Ao invés disso, estará disponível o nome utilidades, por onde acessamos as funções do módulo.

Finalmente, repare que a função auxiliar calcular_medias_aritmeticas que tivemos que criar é suficientemente genérica e pode ser que ela seja útil em outro programa. Assim, pode ser razoável movê-la para o módulo de utilidades. Faça isso como exercício. A decisão de quando uma função deve estar disponível em um módulo para reuso ou mesmo como organizar os módulos não é uma tarefa trivial. Só com a experiência você ganhará mais confiança para tomar essas decisões.