Agora faça o contrário examine o caminho que a tartaruga fez e escreva os comandos dados a ela

Muitas das funções de Julia que usamos, como as funções matemáticas, produzem valores de retorno. Contudo, as funções que escrevemos são todas nulas: têm um efeito, como imprimir um valor ou mover uma tartaruga, mas retornam nothing. Neste capítulo você aprenderá a elaborar funções produtivas.

A chamada de uma função gera um valor de retorno, que geralmente atribuímos a uma variável ou utilizamos como parte de uma expressão.

e = exp(1.0) altura = raio * sin(radianos)

As funções escritas até o momento são nulas, o que significa que elas não têm valor de retorno; mais precisamente, seu valor de retorno é nothing. Neste capítulo, (finalmente) vamos escrever funções produtivas. O primeiro exemplo é a função área, que retorna a área de um círculo dado um raio:

function área(raio) a = π * raio^2 return a end

Já vimos o comando return anteriormente, mas em uma função produtiva o comando return inclui uma expressão. Este comando significa: "Retorne imediatamente a partir desta função e leve a expressão seguinte como um valor de retorno". Como essa expressão pode ser arbitrariamente complicada, então poderíamos ter escrito uma função mais sucinta:

function área(raio) π * raio^2 end

O valor retornado por uma função é o valor do último comando executado, que, por padrão, é a última expressão no corpo da definição da função.

Além disso, variáveis temporárias como a e as instruções explícitas return podem contribuir para a depuração.

Às vezes, é prático ter diversos comandos return, uma em cada ramo de uma condicional:

function valorAbsoluto(x) if x < 0 return -x else return x end end

E já que estas instruções de retorno estão em alternativas exclusivas, somente uma é executada.

Assim que um comando return é executado, a função termina sem executar qualquer comando posterior. O código que aparece após um comando return, ou qualquer outro lugar que o fluxo de execução jamais possa alcançar, é chamado de código morto.

Em uma função produtiva, recomendamos garantir que todos os caminhos possíveis através do programa chegue em um comando de retorno. Por exemplo:

function valorAbsoluto(x) if x < 0 return -x end if x > 0 return x end end

Essa função está incorreta porque se x for 0, nenhuma das condições é verdadeira, e a função termina sem chegar a um comando return. Quando o fluxo de execução chega ao final de uma função, o valor de retorno é nothing, que não é o valor absoluto de 0.

julia> show(valorAbsoluto(0)) nothing

Dica

Julia tem uma função interna chamada abs que calcula valores absolutos.

Escreva uma função comparar que recebe dois valores, x e y, e retorna 1 se x > y, 0 se x == y e -1 se x < y.

À medida que se escreve funções maiores, talvez aconteça de você passar mais tempo depurando.

Ao lidar com programas cada vez mais complexos, tente um processo chamado desenvolvimento incremental, que adiciona e testa apenas uma pequena quantidade de código por vez, evitando assim longas sessões de depuração.

Para exemplificar esse processo, suponha que você queira determinar a distância entre dois pontos, dada pelas coordenadas \(\left(x_1, y_1\right)\) e \(\left(x_2, y_2\right)\). Pelo teorema de Pitágoras, a distância é:

\[\begin{equation} {d = \sqrt{(x_2 - x_1)^2 + (y_2 - y_1)^2}} \end{equation}\]

O primeiro passo é avaliar como deve ser uma função de distância em Julia. Em outras palavras, quais são as entradas (os parâmetros) e qual é a saída (o valor de retorno)?

Neste exemplo, as entradas são dois pontos, que pode ser representado por quatro números. Já o valor de retorno é a distância representada por um valor de ponto flutuante.

Com isso, pode-se esboçar a função:

function distância(x₁, y₁, x₂, y₂) 0.0 end

Obviamente que essa versão não determina as distâncias pois ela sempre retorna zero. Mas é sintaticamente correta, e roda, o que significa que você pode testá-la antes de complicá-la. Os números dos subscritos estão disponíveis na codificação de caracteres Unicode (\_1 TAB, \_2 TAB, etc.).

Para testar a nova função, chame-a com argumentos exemplificados a seguir:

Escolhemos estes valores para que a distância horizontal seja 3 e a vertical seja 4; logo, o resultado é 5 porque é a hipotenusa de um triângulo retângulo 3-4-5. Quando testar uma função, o aconselhável é já saber a resposta correta.

Nesse ponto, como já confirmamos que a função está sintaticamente correta, então podemos começar a adicionar código ao corpo. Um passo subsequente razoável é encontrar as diferenças \(x_2 - x_1\) e \(y_2 - y_1\). A próxima versão da função armazena esses valores em variáveis temporárias que são mostradas com a macro @show.

function distância(x₁, y₁, x₂, y₂) dx = x₂ - x₁ dy = y₂ - y₁ @show dx dy 0.0 end

Se a função estiver funcionando, ela deve exibir dx = 3 e dy = 4. Nesse caso, sabemos que a função está obtendo os argumentos certos e executando os primeiros cálculos corretamente. Caso contrário, há apenas poucas linhas para analisar.

Em seguida, somamos os quadrados de dx e de dy:

function distância(x₁, y₁, x₂, y₂) dx = x₂ - x₁ dy = y₂ - y₁ d² = dx^2 + dy^2 @show d² 0.0 end

Você executaria o programa mais uma vez nesse estágio e verificaria a saída (que deveria ser 25). Números sobrescritos também estão disponíveis (\^2 TAB). Por fim, usa-se sqrt para calcular e retornar o resultado final:

function distância(x₁, y₁, x₂, y₂) dx = x₂ - x₁ dy = y₂ - y₁ d² = dx^2 + dy^2 sqrt(d²) end

Se a função rodar corretamente, pronto. Caso contrário, convém mostrar o valor de sqrt(d²) antes do comando return.

A versão final da função não exibe nada quando é executada, retornando apenas um valor. As instruções de impressão que escrevemos são úteis para a depuração, mas depois que a função estiver funcionando, devemos removê-las. Um código como esse é chamado andaime porque é útil para criar o programa, embora não faça parte do produto final.

Ao iniciar, você deve adicionar apenas uma ou duas linhas de código por vez. À medida que você adquire mais experiência, pode se escrever e depurar pedaços maiores. De qualquer forma, o desenvolvimento incremental pode economizar muito tempo de depuração.

Os principais aspectos do processo são:

  1. Comece com um programa funcional e faça pequenas alterações incrementais. A qualquer momento, se houver um erro, você deverá ter uma boa ideia de onde ele está.

  2. Use variáveis para armazenar valores intermediários de modo que você possa visualizá-los e conferi-los.

  3. Uma vez que o programa esteja funcionando, você pode querer retirar algumas das instruções andaimes ou consolidar múltiplos comandos em expressões compostas, desde que não dificulte a leitura do programa.

Use o desenvolvimento incremental para escrever uma função chamada hipotenusa que retorna o comprimento da hipotenusa de um triângulo retângulo, a partir dos comprimentos dos outros dois catetos como argumentos. Registre cada estágio do processo de desenvolvimento à medida que avança.

Como já esperado, você pode chamar uma função de dentro da outra. Para exemplificar isso, escreveremos uma função que calcula a área do círculo a partir de dois pontos, o centro do círculo e um ponto no perímetro.

Suponha que o ponto central é indicado pelas variáveis xc e yc, e o ponto de perímetro é indicado por xp e yp. O primeiro passo é encontrar o raio do círculo, dado pela distância entre estes dois pontos. Note que acabamos de escrever a função distância:

raio = distância(xc, yc, xp, yp)

O próximo passo é calcular a área de um círculo a partir desse raio, e por isso também escrevemos essa função:

Encapsulando esses passos em uma função, temos:

function área_círculo(xc, yc, xp, yp) raio = distância(xc, yc, xp, yp) resultado = área(raio) return resultado end

As variáveis temporárias raio e resultado são úteis para o desenvolvimento e a depuração, mas depois que o programa estiver funcionando, podemos torná-lo mais conciso fazendo:

function área_círculo(xc, yc, xp, yp) área(distância(xc, yc, xp, yp)) end

As funções podem retornar valores booleanos, o que muitas vezes é conveniente para ocultar testes complicados dentro de funções. Por exemplo:

function é_divisível(x, y) if x % y == 0 return true else return false end end

Frequentemente se atribui nomes de funções booleanas que soam como perguntas de sim/não; neste caso, é_divisível retorna true ou false para saber se x é divisível por y.

julia> é_divisível(6, 4) false julia> é_divisível(6, 3) true

O resultado do operador == é um valor booleano, logo podemos escrever a função de forma mais sucinta por meio de um comando direto:

function é_divisível(x, y) x % y == 0 end

Funções booleanas são constantemente utilizadas em estruturas condicionais:

if é_divisível(x, y) println("x é divisível por y") end

Talvez seja tentador escrever algo como:

if é_divisível(x, y) == true println("x é divisível por y") end

No entanto, a comparação adicional com true é desnecessária.

Escreva uma função está_entre(x, y, z) que retorna true se x ≤ y ≤ z ou false caso contrário.

Mostramos apenas uma pequena fração de Julia, mas você pode estar interessado em saber que essa fração é uma linguagem de programação completa, significando que qualquer coisa que possa ser calculada pode ser expressa nessa linguagem. Qualquer programa já escrito pode ser reescrito usando apenas os recursos da linguagem que você aprendeu até o momento (na verdade, você precisaria de alguns comandos para controlar dispositivos como mouse, discos, etc., mas isso é tudo).

Essa afirmação é um exercício não trivial provado pela primeira vez por Alan Turing, um dos primeiros cientistas da computação (alguns argumentariam que ele era matemático, mas muitos dos primeiros cientistas da computação começaram como matemáticos). Por isso, esta prova é conhecida como a Tese de Turing. Para uma discussão mais completa (e precisa) da Tese de Turing, recomendo o livro Introdução à teoria da computação de Michael Sipser.

Para ter uma noção do que você pode fazer com as ferramentas que sabe até agora, avaliaremos algumas funções matemáticas definidas recursivamente. Uma definição recursiva é semelhante a uma definição circular, no sentido de que a definição contém uma chamada de si própria. Uma definição totalmente circular não é muito vantajosa:

vorpal

Um adjetivo usado para descrever algo que é vorpal.

Ver essa definição no dicionário pode ser irritante. Por outro lado, se consultar a definição da função fatorial, denotada com o símbolo \(!\), poderá obter algo assim:

\[\begin{equation} {n! = \begin{cases} 1& \textrm{se}\ n = 0 \\ n (n-1)!& \textrm{se}\ n > 0 \end{cases}} \end{equation}\]

Essa definição diz que o fatorial de 0 é 1, e o fatorial de qualquer outro valor \(n\) é \(n\) multiplicado pelo fatorial de \(n-1\).

Então \(3!\) é 3 vezes \(2!\), que é 2 vezes \(1!\), que é 1 vezes \(0!\). Colocando tudo junto, \(3!\) é igual a 3 vezes 2 vezes 1 vezes 1, que dá 6.

Se puder escrever uma definição recursiva de algo, pode-se escrever um programa em Julia para testá-la. A primeira etapa é decidir quais devem ser os parâmetros. E nesse caso, é evidente que o fatorial recebe um número inteiro:

Se o argumento for 0, basta retornar 1:

function fatorial(n) if n == 0 return 1 end end

Caso contrário, e esta é a parte interessante, temos que fazer uma chamada recursiva para encontrar o fatorial de n-1 para depois multiplicá-lo por n:

function fatorial(n) if n == 0 return 1 else recursão = fatorial(n-1) resultado = n * recursão return resultado end end

O fluxo de execução deste programa é similar ao fluxo de contagem regressiva em Recursão. Chamando fatorial do valor 3:

Como 3 não é 0, seguimos para o segundo ramo e calculamos o fatorial de n-1 …​

 Como 2 não é 0, seguimos para o segundo ramo e calculamos o fatorial de n-1 …​

  Como 1 não é 0, seguimos para o segundo ramo e calculamos o fatorial de n-1 …​

   Como 0 é igual a 0, seguimos para o primeiro ramo e temos o resultado 1 sem efetuar
    mais chamadas recursivas.

  O valor de retorno (= 1) é multiplicado por n (que é 1), e o resultado é devolvido.

 O valor de retorno (= 1), é multiplicado por n (que é 2), e o resultado é devolvido.

O valor de retorno (= 2) é multiplicado por n (que é 3), e o resultado (= 6), torna-se o valor de retorno da chamada da função que iniciou todo esse processo.

Agora faça o contrário examine o caminho que a tartaruga fez e escreva os comandos dados a ela

Figura 9. Diagrama de Pilha

Diagrama de Pilha mostra como fica o diagrama de pilha para esta sequência de chamadas de função.

Os valores de retorno são exibidos quando devolvidos de volta para cima da pilha. Em cada quadro, o valor de retorno é o valor de resultado, dado pelo produto de n com recursão.

No último quadro, as variáveis locais recursão e resultado não existem porque o ramo que as cria não é executado.

Dica

Em Julia, a função factorial calcula o fatorial de um número inteiro.

Ler programas seguindo o fluxo de execução pode se tornar rapidamente exaustivo. Uma alternativa que eu chamo de "salto de fé" faz a leitura conforme o fluxo de execução e quando se chega a uma chamada de função, assume-se que a função funciona corretamente e devolve o resultado correto.

Na verdade, você já está praticando este salto de fé no uso de funções embutidas. Quando você chama cos ou exp, você não investiga os corpos dessas funções. Você apenas assume que funcionam já que as pessoas que escreveram as funções embutidas eram bons programadores.

A mesma prática ocorre quando você chama uma de suas próprias funções. Por exemplo, em Funções Booleanas, escrevemos a função é_divisível que determina se um número é divisível por outro. Depois de nos convencermos de que essa função está correta ao examinar seu código e testar, podemos usá-la sem olhar para o corpo novamente.

O mesmo se aplica aos programas recursivos. Ao chegar na chamada recursiva, em vez de acompanhar o fluxo de execução, deve-se assumir que a chamada recursiva funciona (retorna o resultado correto) e depois se perguntar: “Supondo que possa encontrar o fatorial do \(n-1\), posso calcular o fatorial do \(n\)?” Sim, multiplicando por \(n\).

É claro que é um pouco estranho assumir que a função funciona corretamente quando ainda não se terminou de escrevê-la, mas é por isso que se chama salto de fé!

\[\begin{equation} {fib(n) = \begin{cases} 0& \textrm{se}\ n = 0 \\ 1& \textrm{se}\ n = 1 \\ fib(n-1) + fib(n-2)& \textrm{se}\ n > 1 \end{cases}} \end{equation}\]

Traduzindo para Julia, tem-se:

function fib(n) if n == 0 return 0 elseif n == 1 return 1 else return fib(n-1) + fib(n-2) end end

Se você tentar acompanhar o fluxo de execução aqui, mesmo para valores razoavelmente pequenos de n, sua cabeça vai enlouquecer. No entanto, de acordo com o salto de fé, se presumir que as duas chamadas recursivas funcionam sem erros, fica nítido que o resultado certo é obtido a partir da soma delas.

O que ocorre se chamarmos fatorial e atribuirmos 1.5 como argumento?

julia> fatorial(1.5) ERROR: StackOverflowError: Stacktrace: [1] fatorial(::Float64) at ./REPL[3]:2

Parece uma recursão infinita. Como pode ser? A função tem um caso base—quando n == 0. Mas se n não for um número inteiro, podemos perder o caso base e ficar recursivo para sempre.

Na primeira chamada recursiva, o valor de n é 0.5. No próximo, é -0.5. A partir daí, vai diminuindo e ficando cada vez mais negativo, mas nunca será 0.

Temos duas escolhas. Podemos tentar generalizar a função fatorial para trabalhar com números de ponto flutuante, ou podemos fazer fatorial verificar o tipo de argumento. Na primeira opção, tem-se a função gama que está um pouco além do escopo deste livro. Logo, vamos adotar a segunda opção.

Podemos usar o operador embutido isa para verificar o tipo do argumento. Ainda falando no assunto, também podemos certificar que o argumento seja positivo:

function fatorial(n) if !(n isa Int64) error("Fatorial é definido somente para números inteiros.") elseif n < 0 error("Fatorial não é definido para números inteiros negativos.") elseif n == 0 return 1 else return n * fatorial(n-1) end end

Enquanto o primeiro caso-base aborda os não-inteiros; o segundo aborda os inteiros negativos. Para esses dois casos, o programa exibe uma mensagem de erro e devolve nothing para indicar que algo deu errado:

julia> fatorial("fred") ERROR: Fatorial é definido somente para números inteiros. julia> fatorial(-2) ERROR: Fatorial não é definido para números inteiros negativos.

Se passarmos pelas duas verificações, concluímos que n é positivo ou zero, logo, conseguimos provar que a recursão termina.

Esse programa demonstra um padrão às vezes de guardião. Os dois primeiros condicionais atuam como guardiões, protegendo o código de valores que podem causar um erro. Além disso, os guardiões tornam possível provar a execução sem erro do código.

Em Capturando Exceções, veremos uma alternativa mais flexível para mostrar uma mensagem de erro: levantando uma exceção.

Dividir um programa grande em funções menores cria pontos de verificação naturais para a depuração. Caso uma função não esteja funcionando, há três possibilidades para analisar:

  • Há algo errado com os argumentos que a função está recebendo; ou seja, uma precondição não foi satisfeita.

  • Há algo errado com a função; isto é, uma pós-condição não foi satisfeita.

  • Há algo errado com o valor de retorno ou com a maneira como ele está sendo utilizado.

Para descartar a primeira possibilidade de erro, você pode imprimir no início da função os valores dos parâmetros (e possivelmente seus tipos). Ou pode escrever um código que verifique claramente as precondições.

Se os parâmetros parecerem bons, imprima o valor de retorno adicionando um comando de impressão antes de cada comando de retorno. Se possível, verifique o resultado à mão. Considere chamar a função com valores que facilitem a conferência do resultado (como em Desenvolvimento Incremental).

Caso a função pareça estar funcionando, observe a chamada de função para garantir que o valor de retorno esteja sendo usado corretamente (ou se está mesmo sendo usado!).

Adicionar comandos de impressão no início e no final de uma função pode facilitar o acompanhamento do fluxo de execução. Por exemplo, aqui está uma versão de fatorial com comandos print:

function fatorial(n) espaço = " " ^ (4 * n) println(espaço, "fatorial ", n) if n == 0 println(espaço, "retornando 1") return 1 else recursão = fatorial(n-1) resultado = n * recursão println(espaço, "retornando ", resultado) return resultado end end

espaço é uma string de espaços que atua na indentação da saída:

julia> fatorial(4) fatorial 4 fatorial 3 fatorial 2 fatorial 1 fatorial 0 retornando 1 retornando 1 retornando 2 retornando 6 retornando 24 24

Caso o fluxo de execução não esteja claro, esse tipo de saída de impressões pode ser útil. Leva algum tempo para usar andaimes eficientemente, mas um pouco de andaime pode economizar muita depuração.

variável temporária

Uma variável que armazena um valor intermediário em um cálculo difícil.

código morto

O pedaço de um programa que nunca será executado, geralmente porque aparece após um comando de retorno.

desenvolvimento incremental

Um plano de desenvolvimento de programa que tem o objetivo de evitar a depuração, adicionando e testando apenas uma pequena quantidade de código de cada vez.

andaime

O código usado no decorrer do desenvolvimento do programa, porém não faz parte da versão final.

guardião

Um padrão de programação que usa a estrutura condicional para conferir e tratar de circunstâncias que possam levar a erros.

Desenhe o diagrama de pilha correspondente ao seguinte programa. O que o programa imprime?

function b(z) produto = a(z, z) println(z, " ", produto) produto end function a(x, y) x = x + 1 x * y end function c(x, y, z) total = x + y + z quadrado = b(total)^2 quadrado end x = 1 y = x + 1 println(c(x, y+3, x+y))

Veja a função de Ackermann, \(A(m, n)\), definida como:

\[\begin{equation} {A(m, n) = \begin{cases} n+1& \textrm{se}\ m = 0 \\ A(m-1, 1)& \textrm{se}\ m > 0\ \textrm{e}\ n = 0 \\ A(m-1, A(m, n-1))& \textrm{se}\ m > 0\ \textrm{e}\ n > 0. \end{cases}} \end{equation}\]

Palíndromo é uma palavra que se soletra igualmente nos dois sentidos, como "arara" e "reviver". Definindo recursivamente, uma palavra é um palíndromo se a primeira e a última letras forem as mesmas e se o meio também for um palíndromo.

As funções seguintes recebem uma string como argumento e retornam respectivamente a primeira, a última letra e as letras do meio:

function primeira(palavra) primeira = firstindex(palavra) palavra[primeira] end function última(palavra) última = lastindex(palavra) palavra[última] end function meio(palavra) primeira = firstindex(palavra) última = lastindex(palavra) palavra[nextind(palavra, primeira) : prevind(palavra, última)] end

Veremos como eles funcionam no Strings.

  1. Teste estas funções. O que acontece se você chamar meio para uma string de duas letras? E de uma letra? E no caso da string vazia, que é escrita "" e não tem nenhuma letra?

  2. Escreva uma função chamada é_palíndromo que recebe um argumento string e retorna true se for um palíndromo e false caso contrário. Lembre-se de que você pode usar a função interna length para verificar o comprimento de uma string.

Um número, \(a\), é dito uma potência de \(b\) se for divisível por \(b\) e \(\frac{a}{b}\) for potência de \(b\). Escreva uma função chamada é_potência que dados os parâmetros a e b devolve true se a for uma potência de b.

Dica

Você terá que considerar o caso base.

O máximo divisor comum (MDC) de \(a\) e \(b\) é o maior número que divide os dois sem sobrar resto.

Uma maneira de encontrar o MDC de dois números é baseada na observação de que se \(r\) é o resto da divisão de \(a\) por \(b\), então mcd(a, b) = mcd(b, r). Para o caso base, considere que mdc(a, 0) = a.

Escreva a função mdc que recebe os parâmetros a e b e retorna o máximo divisor comum.

Crédito: Este exercício é baseado em um exemplo do livro Structure and Interpretation of Computer Programs de Abelson e Sussman.