HomePage RecentChanges

Lesson15

No desenvolvimento de programas é importante ter em conta como é que um programa é executado e, em particular, como é que o processo computacional inerente à execução do programa evolui. Vamos agora analisar alguns padrões típicos de evolução de programas e funções.

Recursão linear

Exemplo:

def factorial(n):
    if n == 0:
        return 1
    else:
        return n * factorial(n - 1)

Tal como vimos em aulas anteriores, a execução/avaliação desta função implica a geração de vários ambientes locais diferentes, todos referentes a esta função (ou bloco). Em cada ambiente ficamos com uma operação adiada, nomeadamente a multiplicação no segundo return, até atingirmos a o caso base, i.e., n == 0.

Esta sequência de ambientes encadeados começa com expansão dos mesmos até chegarmos ao ambiente final, envolvendo simultaneamente uma expansão na memória necessária para alojar os ambientes criados. Uma vez atingido o caso terminal ou base, ocorre uma contracção em que as operações adiadas são realizadas e os ambientes vão sendo libertados.

O processo gerado pelo encadeamento de chamadas à função factorial:

factorial(4)
| factorial(3)
| | factorial(2)
| | | factorial(1)
| | | | factorial(0)
| | | | return 1
| | | return 1
| | return 2
| return 6
return 24

Exercício 1: Apresente o encadeamento de chamadas à função potencia.

def potencia(x, n):
    if n == 0:
        return 1
    else:
        return x * potencia(x, n - 1)

Exercício 2: Apresente o encadeamento de chamadas à função soma.

def soma(lst):
    if lst == []:
        return 0
    else:
        return lst[0] + soma(lst[1:]

Todas estas funções geram processos computacionais com um comportamento semelhante, são caracterizados por uma fase de crescimento e por uma fase de contracção. Estes processos são denominados de processos recursivos, nos quais é criada uma sequência de ambientes correspondente à sequência de chamadas à função.

Em geral cada chamada recursiva deixa operações em espera ou adiadas, embora possam existir situações em que tal não acontece.

Nos casos vistos até agora, o número de ambientes cresce linearmente em função de um determinado valor, pelo que este tipo de processo recursivo é denominado por processo recursivo linear.

Iteração linear

Vejamos implementações alternativas para as funções anteriores que geram processos com um padrão de evolução denominado iteração linear.

def factorial(n):
    res = 1
    for i in range(1, n+1):
        res = res * i
    return res

Exercício: Providencie implementações iterativas para as funções potencia e soma.

Este tipo de implementação gera um processo denominado de processo iterativo. Este tipo de processo é caracterizado por um conjunto de variáveis, variáveis de estado, e por regras que especificam como actualizá-las. Estas variáveis determinam uma descrição completa do estado do processo computacional em cada momento.

Nos exemplos anteriores o número de operações sobre as variáveis de estado cresce linearmente com um valor associado à função, e.g., no caso da função factorial cresce com o parâmetro n. Um processo iterativo cujo número de operações cresce linearmente em função de um valor designa-se por processo iterativo linear.

Recursão de cauda

Mesmo quando definimos funções recursivamente, podemos utilizar variáveis de estado e gerar processos idênticos aos processos iterativos. Em particular, dependendo dos interpretadores e compiladores de cada linguagem de programação, estas funções geram de facto processo iterativos sem ambientes encadeados ou operações adiadas.

O estado em funções recursivas pode ser mantido através do uso de parâmetros formais extra. Exemplo:

def factorial(n, acc):
    if n == 0:
        return acc
    else:
        return factorial(n - 1, n * acc)

Notar que neste exemplo, assim como na maior parte dos casos em que aplicamos esta técnica, a chamada recursiva é a última operação realizada pela função, não existindo operações adiadas. Este padrão de computação é em geral denominado de recursão de cauda.

Utilizando técnicas que vimos em aulas anteriores, podemos reescrever a função factorial como:

def factorial(n):
    def factorial_aux(n, acc):
        if n == 0:
            return acc
        else:
            return factorial_aux(n - 1, n * acc)
    return factorial_aux(n, 1)

Notar que o acumulador na função auxiliar é inicializado com o valor a retornar no caso base.

Exercício: Providencie implementações recursivas de cauda para as funções potencia e soma.

O processo gerado pelo encadeamento de chamadas à função factorial é neste caso:

factorial(4)
| factorial_aux(4, 1)
| | factorial_aux(3, 4)
| | | factorial_aux(2, 12)
| | | | factorial_aux(1, 24)
| | | | | factorial_aux(0, 24)
| | | | | return 24
| | | | return 24
| | | return 24
| | return 24
| return 24
return 24

Ainda que não existam operações adiadas, como em Python não temos a optimização de recursão de cauda, a sequência de ambientes gerada, assim como a criação e eliminação dos mesmos, é em tudo idêntica ao que acontece nos processos recursivos que vimos acima. Isto poderá levar em particular à exaustão da pilha de execução. Noutras linguagens, como LISP, quer as funções iterativas quer as funções que recorrem a recursão de cauda geram automaticamente processos iterativos.

Na realidade podemos utilizar um truque em Python para que funções com recursão de cauda gerem processos "iterativos".

def tco(arg):
    while True:
        try:
            arg = arg()
        except TypeError:
            return arg

def factorial(n):
    def factorial_aux(n, acc):
        if n == 0:
            return acc
        else:
            return lambda: factorial_aux(n - 1, n * acc)
    return tco(factorial_aux(n, 1))

O truque é retornar uma função anónima sem parâmetros na função auxiliar para que não ocorra a acumulação de ambientes e, portanto, não ocorra a exaustão da pilha de execução.

O processo gerado pelo encadeamento de chamadas é neste caso:

factorial(4)
| tco(factorial_aux(4, 1))
| | factorial_aux(4,1)
| | | return lambda: factorial_aux(3,4)
| | factorial_aux(3,4)
| | | return lambda: factorial_aux(2,12)
| | factorial_aux(2,12)
| | | return lambda: factorial_aux(1,24)
| | factorial_aux(1,24)
| | | return lambda: factorial_aux(0,24)
| | factorial_aux(0,24)()
| | | return 24
| | return 24
| return 24
return 24

Em alternativa, tendo por base um fixed-point combinator (existem várias alternativas) e o truque anterior, podemos reescrever a função factorial utilizando recursão de cauda da seguinte forma:

def Y_combinator(f):
    Yf = (lambda f: (lambda x: x(x))(lambda y: f(lambda *args: lambda: y(y)(*args))))(f)
    return lambda *args: tco(Yf(*args))

factorial = Y_combinator(lambda f: lambda n, acc: acc if n == 0 else f(n-1, n*acc))
factorial(10000,1)

Neste caso temos uma evolução idêntica a:

F = lambda f: lambda n, acc: acc if n == 0 else f(n-1, n*acc)
G = lambda y: F(lambda *args: lambda: y(y)(*args))
(lambda x: x(x))G(10000, 1)
G(G)(10000, 1)
F(lambda *args: lambda: G(G)(*args))(10000, 1)
(lambda n, acc: acc if n == 0 else (lambda *args: lambda: G(G)(*args))(n-1, n*acc))(10000, 1)
(lambda *args: lambda: G(G)(*args))(9999, 10000)
lambda: G(G)(9999, 10000)
G(G)(9999, 10000)
...

Mais detalhes em https://github.com/baruchel/tco, incluindo módulos para Python com esta implementação e outras mais avançadas e optimizadas. Em alternativa, uma vez que já identificámos as variáveis de estado, podemos sempre transformar a nossa implementação numa função iterativa. :-)