Aulas de C

Aprendizado continuo. Linguagem antiga e moderna

Funções – Parte 2

Olá a todos!
Bem, primeiramente desculpem a demora, pois tive muitas atividades de trabalho que me “frearam” um pouco. Mas não perdi a vontade de passar o que sei de C. E vocês, ainda estão aqui aprendendo?
Bem, então vamos continuar o tópico da aula anterior: Funções.
Na aula anterior, vimos como construir uma função, porque devemos usá-las, e como passar parâmetros e receber seus retornos. Com isso, podemos dizer que sabemos construir funções. Porém, ainda não sabemos como aproveitar ao máximo as funções, uma vez que vimos regras que “amarram” a construção de funções, tornando-as complexas. Em especial a regra de criar-se a função antes do uso (ou seja, colocar o código da função antes de qualquer chamada que seja feita a ela) é muito estranha. Na aula de hoje, iremos ver como escapar dessa “amarra” de programação. Também veremos uma característica das funções em C chamada recursividade, que é a capacidade de uma função chamar a sí própria, o que torna mais simples construir-se determinados algoritmos e programas.
Bem, vamos ao que interessa:

Protótipos de Função:

Bem, vamos começar com o primeiro tópico, que é o de protótipos. No caso, teremos um programa em cada tópico. Para o nosso tópico, usaremos o programa abaixo:
#include <stdio.h>

int soma(int a, int b);
int subtracao(int a, int b);
int multiplicacao(int a, int b);
int divisao(int a, int b);

void main(void)
{
  /**
   * No GCC, esse cabecalho para main retorna o seguinte warning:
   *
   * warning: return type of ‘main’ is not ‘int’
   */

  int val1=0, val2=0, opt=0, res=0;

  do
    {
      printf(“Digite um valor:”);
      scanf(“%d”,&val1);

      printf(“Digite outro valor:”);
      scanf(“%d”,&val2);

      printf(“Escolha a operação a ser realizada…\n\n”);
      printf(“1 – Soma\n”);
      printf(“2 – Subtracao\n”);
      printf(“3 – Multiplicacao\n”);
      printf(“4 – Divisao”);
      printf(“0 – Sair do Programa\n\nDigite os operadores e a operacao separados por espaco:”);

      scanf(“%d”,&opt);

     switch(opt)
      {
      case 1:
        res=soma(val1,val2);
        break;
      case 2:
        res=subtracao(val1,val2);
        break;
      case 3:
        res=multiplicacao(val1,val2);
        break;
      case 4:
        res=divisao(val1,val2);
        break;
      case 0:
        break;
      default:
        printf(“Opcao invalida\n”);
        continue;
      }

      if (opt!=0) printf (“O resultado da sua operacao para %d e %d e %d\n\n”,val1,val2,res);
    } while (opt!=0);

  /**
   * No GCC, a linha abaixo provoca o seguinte warning:
   *
   * warning: ‘return’ with a value, in function returning void
   */

  return(0);
}

int soma(int a, int b)
{
  return a+b;
}

int subtracao(int a, int b)
{
  return a-b;
}

int multiplicacao(int a, int b)
{
  return a*b;
}

int divisao(int a, int b)
{
  return a/b;
}

Primeira coisa: perceba que usamos novamente um outro tipo de protótipo para main(), void main(void). Como dissemos nos comentários do programa, o uso desse protótipo não é padrão e irá retornar alertas (Warnings) pelo compilador. Se preferir fazer um programapedante” (ou seja, sem erros ou alertas), você pode trocar o protótipo do main() de volta para o velho e bom int main(int argc, char** argv). De qualquer forma, apenas fizemos isso para demonstrar o que acontece quando tenta-se utilizar um desses protótipos não-padrão.

Agora, vamos a algo importante antes de entrarmos no nosso tópico. Olhe o código em verde:
    switch(opt)
    {

      case 1:

        res=soma(val1,val2);

        break;

      case 2:

        res=subtracao(val1,val2);

        break;

      case 3:

        res=multiplicacao(val1,val2);

        break;

      case 4:

        res=divisao(val1,val2);

        break;

      case 0:

        break;

      default:

        printf(“Opcao invalida\n”);

        continue;

    }

Esse comando de controle de fluxo, o switch, é muito usado como substituto para cadeias monstruosas de if…elseif…else, em especial quando existem códigos que serão usados em uma ou mais opções. O switch…case compara o valor da variável dada como entrada (no nosso caso, opt) com o valor inserido em cada uma das linhas case. Caso o valor da variável em questão seja igual ao valor de um dos case, o programa irá seguir a execução desse ponto até o final do bloco switch…case ou até encontrar um comando break, o que acontecer primeiro. No caso de nenhum dos case case com o valor da variável a ser comparada, nada será feito, a não ser que exista uma cláusula default estipulada no bloco. Nesse caso, o programa irá continuar a execução a partir desse ponto, valendo as mesmas regras para os demais case. No nosso caso, por exemplo, se opt for igual a 7, o default será executado e exibirá na tela Opcao Invalida, e retornará ao início do laço do…while. Caso, por exemplo, opt estivesse em 2, o resultado da função subtracao(val1,val2) seria associado à variavel res e em seguida o switch…case seria interrompido.
Bem, agora que falamos desse comando de controle de fluxo que “passou batido” até agora, vamos falar sobre o nosso tópico atual.
Como vimos na aula anterior, as funções devem, na teoria, vir antes de serem usadas por um programa. Na realidade, isso não é bem verdade. O que o compilador normalmente precisa é saber como trabalhar com uma função, ou seja, os valores que ele precisa passar para a mesma como parâmetros e o tipo de retorno da mesma. O código em si não precisa sequer ser descrito como parte do seu programa, podendo estar (o código em si) em qualquer outro lugar. (lembram das bibliotecas, como stdio.h, string.h e stdlib.h? Na realidade eles são úteis para o compilador saber como usar as funções que eles “representam”. Os códigos estão armazenados em outras bibliotecas e arquivos dentro do sistema operacional ou do compilador). Como exemplo, podemos pensar em um conector para celular. Cada celular usa um conector específico e, desde que o conector siga o tipo de conexão que o celular exige, pode ser usado conectores de quaisquer marcas (vide a quantidade de carregadores “genéricos” que existem por aí) e com qualquer tipo de entrada de energia (não importa se é de tomada ou de carro, por exemplo).
No C, chamamos esses “padrões” de protótipos de função. Um protótipo de função nada mais é que um informativo que o compilador usa para saber como “chamar” uma função. A idéia é que os protótipos representam a seqüência de parâmetros e o tipo de retorno das funções a serem usadas no nosso código. Lembram-se de quando falamos que existe a idéia de “caixa preta” no código? Os protótipos são o que permitem a existência dessa “caixa preta”: o que o programador e o compilador precisa saber é o que a função precisa de entrada e o que ela devolve como resultado. O programador não precisa como saber como a função foi construída (imaginando que não tenha sido ele que a construiu) e o compilador só precisa saber se as chamadas de função são “encaixáveis” corretamente ao código.
Quando o programa é compilado, as funções que não fazem parte do programa e que estão em biblotecas são “encaixadas” ao programa de várias formas em um passo chamado de linkedição (em alguns livros mais atuais, usa-se o termo ligação). Um binário sem ser linkeditado é chamado ocasionalmente de código-objeto e, embora não seja útil para ser executado, eles são muito úteis (veremos no futuro compilação de programas com múltiplos fontes), inclusive podendo conter funções que possam ser ligadas a posteriori a outros programas (nessa situação, o código-objeto é chamado também de biblioteca). Uma vez que o ou os programas-fontes sejam compilados e linkeditados, o binário executável está pronto.
Voltando ao assunto, é por meio dos protótipos que o compilador sabe como “encaixar” cada função nas partes onde as funções são chamadas (na realidade, são colocados endereços para onde o programa vai e segue a execução). Além disso, por meio dos protótipos que o compilador, até certo ponto, consegue “perceber” se o código está corretamente construído, pois ele tem todas as informações da entrada de dados (parâmetros) e da saída (retorno).
Para o protótipo da função, a construção é igual ao do nome da função no início da mesma (que alguns chamam de cabeçalhos), com a diferença do ; no fim do protótipo. Na realidade, para o protótipo, você não precisa colocar nenhum nome de variável. uma vez que nesse momento, o que ele precisa saber é quais os tipos de parâmetro a serem recebidos, e não seu nome. No caso, embora tenhamos usado:

int soma(int a, int b);

para uma maior legibilidade do código, poderíamos usar simplesmente:

int soma(int, int);

que seria tão útil quanto o protótipo que o colocamos. De qualquer forma, aconselho que mantenha as declarações com “nomes de variáveis” como uma boa prática, para aumentar a legibilidade sobre quais são os parâmetros a serem passadas.
Não existe o que falar mais: uma vez que o protótipo tenha sido colocado de alguma forma à disposição, o compilador pode buscar funções em qualquer lugar, seja dentro do código objeto equivalente ao fonte compilado, em um arquivo de biblioteca ou no próprio sistema operacional e “encaixá-las” ou “ligá-las” ao programa do usuário.
De resto, existe pouco o que falar desse programa, pois ele não tem mistérios quanto ao que cada função faz. Para as “brincadeiras”, sugerimos:
  1. Tente remover os protótipos (comentando-os, por exemplo) e compilar os programas. Alguns compiladores irão dar alertas mas irão compilar o seu fonte, enquanto outros simplesmente se recusarão a compilar o fonte;
  2. Para comprovar que o “nome de variável” no protótipo não faz diferença, modifique o “nome de variável” de alguma das funções no protótipo, mas não na função;
  3. Para entender bem a idéia dos protótipos de função, uma boa forma é ler a documentação da biblioteca-padrão do C. Existem muitas funções interessantes nela, em especial em bibliotecas como stdlib.h, string.h, stdio.h, time.h e math.h. Nesse link você encontra a documentação completa das bibliotecas-padrão (em inglês). Procure ler com calma e tentar entender o que cada função faz. Obviamente você não compreendará tudo no presente momento, pois muitas funções lidam com conceitos avançados que ainda falaremos, mas com calma você verá algumas funções interessantes. Se possível, tente construir seus próprios programas e funções a partir dos códigos que mencionamos no momento;

Bem, com isso terminamos a parte de protótipos de função. Vamos a um tópico mais interessante: recursividade.

Recursividade – Chamando a si próprio:

Existem certos algoritmos (formas de descrever-se algo para um computador) que são mais facilmente representáveis quando eles usam de algum modo a si próprio. Dois exemplos clássicos são os cálculos de Fatorial e do número Fibonacci. Para relembrar, um número N fatorial (representado N!) é representado pela multiplicação de todos os inteiros até N (sendo que os fatoriais de 0 e 1 são definidos como 1). No caso, esse é o algoritmo que iremos ver em C, pois pe um exercício clássico de programação recursiva.

#include <stdio.h>

unsigned int fatorial (unsigned int a)
{
  if ((a==0) || (a==1))
    return 1;
  else
    return a*fatorial(a-1);
}

int main(void)
{
  unsigned int numeroFatorial=0;

  printf(“Digite o numero ao qual deseja-se obter fatorial (apenas positivos): “);
  scanf(“%d”, &numeroFatorial);
 
  printf(“%d! = %d\n”,numeroFatorial,fatorial(numeroFatorial));

  return(0);
}

Esse código é bem básico e tem pouco mistérios. O importante é atentar ao código da função fatorial:

unsigned int fatorial (unsigned int a)
{

  if ((a==0) || (a==1))
    return 1;

  else

    return a*fatorial(a-1);

}

A primeira coisa que ele define é que, caso o valor passado na execução da função seja 0 ou 1, o valor a ser devolvido pela execução é 1. Caso contrário, ele irá devolver o valor passado vezes o valor devolvido pela execução da mesma função com um valor igual ao valor passado-1.
Como funcionaria então, por exemplo, para o fatorial 5? Vejamos em um teste de mesa:

  • main começa executando fatorial(5);
  • Como 5 não é igual a 0 ou 1, ele deveria retornar 5*o resultado de fatorial(5-1), ou seja, fatorial(4). Como não sabe o valor de fatorial(4), ele executa fatorial(4);
  • Como 4 também não é igual a 0 ou 1, ele deveria retornar 4*o resultado de fatorial(4-1), ou seja, fatorial(3). Como não sabe o valor de fatorial(3), ele executa fatorial(3);
  • Como 3 também não é igual a 0 ou 1, ele deveria retornar 3*o resultado de fatorial(3-1), ou seja, fatorial(2). Como não sabe o valor de fatorial(2), ele executa fatorial(2);
  • Como 2 também não é igual a 0 ou 1, ele deveria retornar 2*o resultado de fatorial(2-1), ou seja, fatorial(1). Como não sabe o valor de fatorial(1), ele executa fatorial(1);
  • Como 1 é igual a 1, a função fatorial retorna 1;
  • Agora ele volta para a execução de fatorial(2), pois obteve o valor de fatorial(1), que ele precisava. Ele faz 2*fatorial(1), ou seja, 2*1, retornando 2;
  • Em seguida, retoma a execução de fatorial(3), pois obteve o valor de fatorial(2), que ele precisava. Ele faz 3*fatorial(2), ou seja, 3*2, retornando 6;
  • Em seguida, retoma a execução de fatorial(4), pois obteve o valor de fatorial(3), que ele precisava. Ele faz 4*fatorial(3), ou seja, 4*6, retornando 24;
  • Por fim, retoma a execução de fatorial(5), pois obteve o valor de fatorial(4), que ele precisava. Ele faz 5*fatorial(4), ou seja, 5*24, retornando 120 para main;

Atenção para a questão de:

  if ((a==0) || (a==1))
    return 1;

Todo algoritmo recursivo deve ter uma situação de “escape”, caso contrário provocará um loop infinito. No caso do fatorial, é o fato que os fatoriais de 0 e 1 são definidos por padrão como 1 (no link da Wikipedia mostrado anteriormente há uma explicação dos motivos desses valores serem pré-definidos). Somando-se isso e o fato de que a chamada recursiva é sempre equivalente ao valor da chamada atual-1, o resultado é que cedo ou tarde, o valor vai ser 0 ou 1 (valores negativos são negados já na tipagem unsigned int), ou seja, a “escada” de chamadas irá ser desfeita, com cada chamada devolvendo os valores esperados pela anterior. Caso isso não ocorra, haverá um loop “infinito” que se encerrará com um estouro de memória (uma vez que cada chamada de função armazena localmente valores e portanto precisa de espaço de memória).
Bem, não existe mais o que se falar sobre recursividade. Como “brincadeiras” quanto recursividade, sugiro:

  1. Uma circunstância a ser levada em consideração ao se construir algoritmos com recursividade é sobre o uso dos operadores unários de incremento e decremento (++ e ). Para observar seu impacto, no return a*fatorial(a-1), tente substituir por return a*(a–) e verifique o que acontece. Lembre-se que os operadores unários de incremento e decremento atuam como operadores de incremento/decremento e atribuição;
  2. Edite o código da chamada recursiva e elimine a “condição de escape” da recursão. Coloque algum código que permita você visualizar os valores recebidos a cada chamada recursiva e seus impactos e analise o resultado final;
  3. Tente implementar o algoritmo para determinar-se um número Fibonacci. Lembrando que um número de Fibonacci equivale à soma de todos os números naturais antecessores a ele, predefinido que o 0° Fibonacci é 0 e o 1° Fibonacci é 1. Se você reparar bem, não é muito diferente do cálculo de um número Fatorial;

Com isso, acabamos o básico de Funções. Ainda existem tópicos a serem cobertos. Em especial, um tópico importante que estamos deixando para trás é o de tipos de passagem de parâmetro, um tópico importante que cobriremos quando falarmos de ponteiros, nosso próximo assunto.
Vamos ter algum tempo até começarmos o assunto de ponteiros. Enquanto isso, existem muitos sites e apostilas na internet com exercícios de programação em C que poderão ajudar você a fixar o conteúdo que vimos até agora. Enquanto a mim, vou ficar um tempo sem uma Internet de boa qualidade, mas prometo que, assim que voltar estarei postando o início do tópico de ponteiros, com a parte de matrizes, ponteiros e a correlação entre os dois. Esse será um tópico bastante complexo, mas que se lido com calma irá ser bem fixado.
Então, nos vemos em 2011, pessoal. Até lá, boas festas e muita programação C para todo mundo!😛

8 Respostas para “Funções – Parte 2

  1. André Caldas 13/01/2011 às 9:50

    Primeira coisa: perceba que usamos novamente um outro tipo de protótipo para main(), void main(void). Como dissemos nos comentários do programa, o uso desse protótipo não é padrão e irá retornar alertas (Warnings) pelo compilador. Se preferir fazer um programa “pedante” (ou seja, sem erros ou alertas), você pode trocar o protótipo do main() de volta para o velho e bom int main(int argc, char** argv). De qualquer forma, apenas fizemos isso para demonstrar o que acontece quando tenta-se utilizar um desses protótipos não-padrão.

    O problema aqui não é o protótipo void main (void). O problema é declarar o valor de retorno como sendo void e posteriormente retornar um int. Pelo seu texto, parece que você sabe disso, só quis deixar claro pra quem estivesse lendo…🙂

    Eu fiz um teste aqui, e você pode até mesmo escrever:

    
    struct t
    {
      char x [20];
    } z;
    
    struct t main (void)
    {
      return z;
    }
    

    Eu aconselho sempre usar a opção --pedantic do gcc (se estiver usando o gcc, claro). Com a opção --pedantic a gente aprende um monte de coisas. É interessante sempre tentar entender o porque dos “warnings” pra não contorná-los de modo errado! Por exemplo, alguém poderia querer fazer:

    
    return (void) 0;
    

    O que talvez possa evitar o “warning”, mas seria um erro maior que o original. Os “avisos do compilador” estão lá pra ajudar na manutenção da qualidade do código. Seguindo determinadas “boas práticas”, vai ser muito mais difícil escrever código ilegível e cheio de erros. É muito mais fácil deixar o compilador verificar coisas que podem ser verificadas automaticamente do que instruí-lo a ignorar tudo e tentar verificar manualmente.🙂

    Ah.. o compilador simplesmente emite um aviso e não um erro porque “retornar um int” é basicamente (não sei se depende da arquitetura) colocar um valor em um registrador… uma operação que pode ser ignorada sem problemas. O que seria diferente de retornar o struct t do meu exemplo. Neste caso, a estrutura retornada deve ir para a pilha, ou algo do tipo, e o código que chamar a função será responsável por “limpar a bagunça”. Se o protótipo estiver errado, nenhuma “limpeza de bagunça” seria feita. Neste caso, o compilador emite um erro.

    • André Caldas 13/01/2011 às 12:12

      […] O que seria diferente de retornar o struct t do meu exemplo. Neste caso, a estrutura retornada deve ir para a pilha, ou algo do tipo, e o código que chamar a função será responsável por “limpar a bagunça”. Se o protótipo estiver errado, nenhuma “limpeza de bagunça” seria feita. Neste caso, o compilador emite um erro.

      Parece que eu falei bobagem aqui! Quando o protótipo é void, dá pra retornar o struct t, sim! Parece que essa minha afirmação de que quem chama é que é responsável por fazer a limpeza não está correta no caso da linguagem C. No gcc não dá erro. Só no g++ que dá erro.😉

      • Fábio Emilio Costa 13/01/2011 às 15:06

        Obrigado pelas dicas.

        Sim, eu realmente estava dando return de um int nessa situação como uma forma de demonstrar que o compilador, embora alerte, não provoca um erro real no caso de um retorno “errado” do main(). Para quem quiser ou precisar de um programa “100% correto” em C, fica a dica de usar a opção –pedantic que você deixou. Agradeço a dica.🙂

  2. André Caldas 13/01/2011 às 10:17

    A parte

      if ((a==0) || (a==1))
        return 1;
    

    pode ser considerada “otimização prematura”.🙂

    Poderia ser simplesmente if ( 0 == a ) (eu sempre ponho a constante primeiro). A condição 1 == a dá a impressão que o código será melhor (ou sei lá… por uma insegurança… dá a impressão de que está correto). No entanto, para iteração, a comparação seria MUITO menos eficiente!🙂

    Outra coisa…

    Uma circunstância a ser levada em consideração ao se construir algoritmos com recursividade é sobre o uso dos operadores unários de incremento e decremento […]

    Na verdade, isso não tem nenhuma relação com a recursão. Isso é um problema da falta de entendimento que as pessoas têm do uso desses operadores. Se o programador não entender, fatalmente irá introduzir um bug! No caso do exemplo que você colocou, o bug será a recursividade infinita.

    O ideal é usar os operadores de incremento (e decremento) sempre pré-fixados (++a e não a++). Só se deve usar a++ quando você entende e deseja a diferença de comportamento.

    Outra coisa que faltou dizer, é que muita recursividade não é bom, pois recursividade exige muita memória (na pilha). Antigamente a gente tinha que se preocupar com o “tamanho da pilha”, não sei se hoje ainda é assim. Mesmo com um programa sem bugs, fatorial(0x7FFFFFFF); certamente que seria um problema. Além de que, se não desse problema, não terminaria nunca! :-)

    Existem problemas que são recursivos por natureza. Por exemplo, caminhar em um labirinto. Você dá um passo em uma direção, mas enquanto não sair do labirinto, o problema continua sendo exatamente igual: "decidir qual será o próximo passo." Se quiser uma ajuda pra fazer uma "aula" falando sobre esse problema em específico, eu posso dar uma ajuda. Encontrar um caminho em um labirinto (na força bruta) é essencialmente a mesma coisa que preencher um sudoku (na força bruta) ou então preencher um dominox.

    Abraço,
    André Caldas.
    PS: Alto nível o seu blog!! Parabéns! :-)
    andre.em.caldas@gmail.com

    • Fábio Emilio Costa 13/01/2011 às 15:10

      Obrigado pelas dicas e opiniões.

      Realmente esqueci de mencionar essa questão, mas estava esperando chegarmos na parte de ponteiros e alocação dinâmica de memória para entrarmos à fundo nessa questão.

      Mas o que você diz sobre o uso de recursividade é importante: o uso de recursividade é uma opção que, embora em certos casos torne o código mais legível, é muito consumidor de recursos e tempo de processador. Por isso, fica a dica de que o uso excessivo de recursividade pode comprometer a performance do sistema. Se possível, fica a sugestão de que se procure opções não recursivas para os códigos.

  3. Pingback: Matrizes e Ponteiros – Parte 2 – Alocação dinâmica de memória « Aulas de C

  4. Pingback: Matrizes e Ponteiros – Parte 2 – Alocação dinâmica de memória « Aulas de C

  5. Pingback: Sobre arquivos de código-fonte, arquivos de cabeçalhos e projetos « Aulas de C

Deixe uma resposta

Preencha os seus dados abaixo ou clique em um ícone para log in:

Logotipo do WordPress.com

Você está comentando utilizando sua conta WordPress.com. Sair / Alterar )

Imagem do Twitter

Você está comentando utilizando sua conta Twitter. Sair / Alterar )

Foto do Facebook

Você está comentando utilizando sua conta Facebook. Sair / Alterar )

Foto do Google+

Você está comentando utilizando sua conta Google+. Sair / Alterar )

Conectando a %s

%d blogueiros gostam disto: