Reforço de Algoritmos e Programação - Instituto de Informática (UFRGS) - Prof. Dennis Giovani Balreira



Aula 12 - Recursividade, Programação Modular e Boas Práticas

Nesta aula, fechamos a Área II com quatro assuntos que costuram tudo o que vimos até aqui: recursividade (uma forma alternativa de repetição, baseada em funções que chamam a si mesmas), programação modular (como organizar um programa grande em múltiplos arquivos), argumentos de linha de comando (como configurar um programa no momento em que ele é iniciado) e um apanhado de boas práticas de programação e depuração.


1. Recursividade

Até agora, toda repetição em nossos programas foi feita com for, while ou do-while. Existe uma segunda forma de repetir uma tarefa: fazer uma função chamar a si mesma. Isso é chamado de recursividade (ou recursão).

Toda função recursiva precisa de dois elementos, sem os quais ela nunca para de chamar a si mesma:

Elemento Papel
Caso base A condição mais simples do problema, resolvida diretamente, sem nova chamada recursiva. É o que garante que a recursão vai parar.
Caso recursivo Resolve o problema atual chamando a própria função com uma versão menor (ou mais simples) do problema, aproximando-se do caso base a cada chamada.

Vimos, na Aula 8, o fatorial calculado de forma iterativa. A definição matemática do fatorial já é, na verdade, recursiva: 0! = 1 (caso base) e n! = n × (n-1)! para n > 0 (caso recursivo). Isso se traduz quase diretamente para código C:

#include <stdio.h>

// Protótipo
int fatorial(int n);

int main() {
    printf("5! = %d\n", fatorial(5)); // 120
    return 0;
}

// Implementação
int fatorial(int n) {
    if (n == 0) {          // caso base
        return 1;
    }
    return n * fatorial(n - 1); // caso recursivo
}

Para entender o que acontece "por dentro", é útil acompanhar a pilha de chamadas: cada chamada de fatorial fica "empilhada", esperando o resultado da chamada seguinte, até que o caso base seja alcançado — só então as chamadas começam a "desempilhar", uma a uma, multiplicando os resultados:

fatorial(5)
  = 5 * fatorial(4)
        = 4 * fatorial(3)
              = 3 * fatorial(2)
                    = 2 * fatorial(1)
                          = 1 * fatorial(0)
                                = 1                 <- caso base, comeca a "desempilhar"
                          = 1 * 1  = 1
                    = 2 * 1  = 2
              = 3 * 2  = 6
        = 4 * 6  = 24
  = 5 * 24 = 120
Cuidado: recursão sem caso base (ou que nunca o alcança) nunca termina. Cada chamada recursiva consome espaço na pilha de chamadas; sem um caso base correto, o programa entra em recursão infinita e, na prática, trava com um erro de stack overflow (estouro de pilha). Sempre confira: (1) existe um caso base? (2) o caso recursivo caminha de fato em direção a ele?

Outro exemplo clássico é a sequência de Fibonacci, em que cada termo é a soma dos dois anteriores (0, 1, 1, 2, 3, 5, 8, 13, ...), com dois casos base:

#include <stdio.h>

int fibonacci(int n);

int main() {
    int i;
    for (i = 0; i < 8; i++) {
        printf("%d ", fibonacci(i)); // 0 1 1 2 3 5 8 13
    }
    printf("\n");
    return 0;
}

int fibonacci(int n) {
    if (n == 0) {                          // caso base 1
        return 0;
    }
    if (n == 1) {                          // caso base 2
        return 1;
    }
    return fibonacci(n - 1) + fibonacci(n - 2); // caso recursivo
}
Recursão × iteração: quando usar qual? Tudo que se resolve com recursão também pode ser resolvido com for/while (e vice-versa) — a escolha é, na maioria das vezes, de clareza. Recursão tende a deixar o código mais próximo da definição matemática do problema (como no fatorial e no Fibonacci), especialmente para problemas naturalmente recursivos (percorrer árvores, dividir para conquistar). Porém, ela usa mais memória (cada chamada ocupa espaço na pilha) e pode ser mais lenta — o Fibonacci recursivo acima, por exemplo, recalcula os mesmos valores repetidamente, algo que uma versão iterativa evita facilmente. Na dúvida, para problemas simples, prefira a versão iterativa; use recursão quando ela tornar o código genuinamente mais claro.

2. Programação modular: arquivos .c e .h

Até agora, todo programa desta disciplina foi escrito em um único arquivo .c. Isso funciona bem para programas pequenos, mas apresenta o mesmo problema de escala que discutimos na Aula 8 sobre a main(): à medida que um programa cresce (dezenas de funções, centenas ou milhares de linhas), manter tudo em um único arquivo torna a navegação, a compilação e o trabalho em equipe cada vez mais difíceis.

A programação modular resolve isso dividindo um programa em múltiplos arquivos, cada um agrupando um conjunto coerente de funções (por exemplo, todas as funções relacionadas a "cadastro de alunos" em um módulo, e todas as relacionadas a "cálculos estatísticos" em outro). Em C, cada módulo é normalmente composto por dois arquivos:

Arquivo Conteúdo
.h (header, cabeçalho) Apenas os protótipos das funções do módulo (a "interface pública" — o que o módulo oferece para quem for usá-lo).
.c (código-fonte) A implementação de fato das funções — o corpo de cada uma delas.

Por exemplo, um módulo matematica seria dividido em matematica.h e matematica.c:

/* matematica.h */
#ifndef MATEMATICA_H
#define MATEMATICA_H

int somar(int a, int b);
int fatorial(int n);

#endif
/* matematica.c */
#include "matematica.h"

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

int fatorial(int n) {
    if (n == 0) {
        return 1;
    }
    return n * fatorial(n - 1);
}

E, no arquivo principal do programa, basta incluir o cabeçalho para ter acesso às funções do módulo — repare que agora usamos aspas ("matematica.h"), e não os sinais de menor/maior (<stdio.h>), porque estamos incluindo um arquivo nosso, e não da biblioteca padrão:

/* main.c */
#include <stdio.h>
#include "matematica.h"

int main() {
    printf("%d\n", somar(2, 3));
    printf("%d\n", fatorial(5));
    return 0;
}
Por que #ifndef / #define / #endif? Esse padrão é chamado de include guard ("guarda de inclusão"). Ele evita que o conteúdo de um .h seja colado duas vezes no mesmo arquivo (o que aconteceria, por exemplo, se dois módulos diferentes incluíssem o mesmo cabeçalho) — o que causaria um erro de "redefinição". Na primeira inclusão, MATEMATICA_H ainda não foi definido, então o conteúdo entra normalmente e MATEMATICA_H passa a existir; em qualquer inclusão seguinte, o compilador já vê que MATEMATICA_H existe e pula direto para o #endif, sem duplicar nada.

Para compilar um programa dividido em múltiplos arquivos .c, basta listar todos eles para o compilador:

gcc -Wall main.c matematica.c -o programa
./programa

3. Argumentos de linha de comando

Até agora, sempre lemos dados através de scanf()/fgets(), com o programa já em execução. Também é possível passar informações para um programa no momento em que ele é iniciado, através de argumentos de linha de comando. Para isso, a main() pode receber dois parâmetros:

int main(int argc, char *argv[]) {
    // ...
}
Parâmetro Significado
argc argument count: quantidade de argumentos recebidos, incluindo o nome do próprio programa.
argv argument vector: vetor de strings, uma para cada argumento. argv[0] é sempre o nome do programa.
#include <stdio.h>

int main(int argc, char *argv[]) {
    int i;

    printf("Numero de argumentos: %d\n", argc);

    for (i = 0; i < argc; i++) {
        printf("argv[%d] = %s\n", i, argv[i]);
    }

    return 0;
}

Compilando e executando esse programa com dois argumentos:

gcc -Wall main.c -o programa
./programa turma.txt 10

produz a saída:

Numero de argumentos: 3
argv[0] = ./programa
argv[1] = turma.txt
argv[2] = 10
Atenção ao tipo. Todo elemento de argv é uma string (char *), mesmo que pareça um número. Para usar argv[2] como um valor numérico, é preciso convertê-lo explicitamente, por exemplo com atoi(argv[2]) (visto na Aula 5).

4. Compilação e Makefile

Um programa em C passa por duas etapas até virar algo executável: a compilação (o código-fonte é traduzido para um arquivo executável) e a execução (o sistema operacional roda esse executável):

gcc -Wall main.c -o programa   # compila main.c e gera o executável "programa"
./programa                    # executa o programa (Linux/macOS)
programa.exe                  # executa o programa (Windows)

Quando o programa está dividido em vários arquivos-fonte (Seção 2), todos precisam ser informados ao compilador de uma só vez:

gcc -Wall main.c matematica.c -o programa

Digitar esse comando à mão, listando manualmente todo arquivo .c do projeto, é cansativo e cresce junto com o projeto — e é exatamente esse problema que o Makefile resolve. Um Makefile é um arquivo de configuração lido pelo programa make, que descreve como construir um projeto: quais arquivos dependem de quais, e qual comando executar para gerar cada um. Em vez de lembrar (e digitar) o comando completo do gcc, basta rodar make:

programa: main.c matematica.c
	gcc -Wall main.c matematica.c -o programa

clean:
	rm -f programa
Estrutura de uma regra. Cada regra do Makefile segue o formato alvo: dependências, seguido de uma linha de comando (que deve começar com um caractere de tabulação, não espaços). A regra programa: main.c matematica.c diz que o alvo programa depende desses dois arquivos; se qualquer um deles for modificado, rodar make novamente recompila o projeto. A regra clean é uma convenção comum para remover os arquivos gerados, executada com make clean.

5. Boas práticas de programação e depuração

O qualificador const

Quando passamos um ponteiro como parâmetro apenas para ler o dado apontado (sem nunca alterá-lo), é uma boa prática marcar o parâmetro como const. Isso documenta a intenção da função e faz o próprio compilador acusar um erro caso o corpo da função tente, por engano, alterar o valor:

// Promete não alterar o vetor apontado por v
int somaVetor(const int *v, int tamanho) {
    int soma = 0, i;
    for (i = 0; i < tamanho; i++) {
        soma += v[i];
        // v[i] = 0;  // ERRO de compilação: v é const!
    }
    return soma;
}

O tipo bool

C não tem um tipo booleano nativo (tradicionalmente, usamos int, com 0 para falso e qualquer valor diferente de zero para verdadeiro). Incluindo <stdbool.h>, ganhamos o tipo bool e as constantes true/false, deixando o código mais legível:

#include <stdbool.h>

bool ehPar(int n) {
    return n % 2 == 0; // já retorna true ou false
}

assert: verificando erros do programador

A macro assert (de <assert.h>) encerra o programa imediatamente, com uma mensagem de erro indicando o arquivo e a linha, se a condição passada for falsa. É importante entender a diferença entre assert e um if comum:

Papel
assert Verifica erros do programador — situações que nunca deveriam acontecer se o código estiver correto (uma pré-condição interna violada, um parâmetro que a própria lógica do programa garante ser válido). Serve para pegar bugs durante o desenvolvimento.
if Trata erros do usuário — situações esperadas, que podem legitimamente acontecer em uso normal (entrada inválida, arquivo que não existe), e que o programa deve tratar com uma mensagem adequada, não travar.
#include <assert.h>

int dividir(int a, int b) {
    assert(b != 0); // erro do programador: b nunca deveria chegar 0 aqui
    return a / b;
}

Dicas de depuração

  • Compile sempre com -Wall: essa opção do gcc mostra avisos (warnings) sobre código suspeito (variáveis não usadas, comparações estranhas, funções sem protótipo) que muitas vezes indicam bugs reais, antes mesmo de rodar o programa.
  • Use printf de depuração: imprimir o valor de variáveis-chave em pontos estratégicos do código é a forma mais simples de descobrir onde o comportamento do programa diverge do esperado.
  • Isole o problema: se um programa grande está com bug, tente reproduzir o erro no menor trecho de código possível — isso costuma revelar a causa muito mais rápido do que ficar lendo o programa inteiro.
  • Leia a mensagem de erro com calma: mensagens de erro de compilação geralmente indicam o arquivo e a linha exatos — mas o erro real, às vezes, está em uma linha anterior (por exemplo, uma chave { ou ponto e vírgula ; faltando).

Resumo

  • Recursividade: uma função que chama a si mesma, sempre com um caso base (que encerra a recursão) e um caso recursivo (que se aproxima do caso base). Prefira a versão iterativa quando ela for igualmente clara.
  • Programação modular: dividir um programa em múltiplos arquivos .c (implementação) e .h (protótipos), usando include guards (#ifndef/#define/#endif) para evitar redefinições.
  • argc/argv: permitem passar argumentos para um programa no momento em que ele é iniciado, via linha de comando. Todo elemento de argv é uma string.
  • Makefile: automatiza o comando de compilação de um projeto com múltiplos arquivos.
  • Boas práticas: const em parâmetros que não devem ser alterados, bool/true/false (<stdbool.h>) para clareza, assert para erros do programador (diferente de if, que trata erros do usuário), compilar sempre com -Wall, e depurar com printf e isolamento do problema.

Exercícios

Nível fácil

  1. Escreva uma função recursiva int soma(int n) que calcule a soma dos inteiros de 1 até n (caso base: n == 0). Chame-a a partir da main(), lendo n do usuário.
  2. Escreva uma função recursiva void contarRegressiva(int n) que imprima os números de n até 1, um por linha, seguidos de "Fim!" quando n chegar a 0.
  3. Escreva um programa que receba dois argumentos de linha de comando (dois números inteiros) e imprima a soma deles, convertendo os argumentos de argv com atoi.

Nível médio

  1. Escreva uma função recursiva int potencia(int base, int expoente) que calcule baseexpoente (caso base: expoente == 0, retornando 1).
  2. Divida o programa do Exercício 1 em três arquivos: soma.h (protótipo), soma.c (implementação) e main.c (chamada). Escreva também um Makefile simples para compilar o projeto.
  3. Explique, sem executar o código, qual será a saída do programa abaixo, e por quê:
    #include <stdio.h>
    
    void misterio(int n) {
        if (n > 3) {
            return;
        }
        misterio(n + 1);
        printf("%d ", n);
    }
    
    int main() {
        misterio(0);
        printf("\n");
        return 0;
    }
    

Nível difícil

  1. Escreva uma função recursiva int fibonacci(int n) (como a da Seção 1), e uma versão iterativa equivalente, int fibonacciIterativo(int n), usando um laço for. Compare os dois códigos e explique, em texto, por que a versão recursiva refaz muito mais trabalho para valores grandes de n.
  2. O programa abaixo tenta usar assert para validar uma entrada digitada pelo usuário. Explique por que esse é um uso incorreto de assert (compare com a distinção entre erro do programador e erro do usuário vista na Seção 5), e reescreva a validação usando if:
    #include <stdio.h>
    #include <assert.h>
    
    int main() {
        int idade;
    
        printf("Digite sua idade: ");
        scanf("%d", &idade);
    
        assert(idade >= 0); // o que acontece se o usuário digitar um valor negativo?
    
        printf("Idade: %d\n", idade);
    
        return 0;
    }
    

Sugestões de Respostas dos Exercícios

Exercício 1
#include <stdio.h>

int soma(int n);

int main() {
    int n;

    printf("Digite n: ");
    scanf("%d", &n);

    printf("Soma: %d\n", soma(n));

    return 0;
}

int soma(int n) {
    if (n == 0) {
        return 0;
    }
    return n + soma(n - 1);
}

Exercício 2
#include <stdio.h>

void contarRegressiva(int n);

int main() {
    contarRegressiva(5);
    return 0;
}

void contarRegressiva(int n) {
    if (n == 0) {
        printf("Fim!\n");
        return;
    }
    printf("%d\n", n);
    contarRegressiva(n - 1);
}

Exercício 3
#include <stdio.h>
#include <stdlib.h>

int main(int argc, char *argv[]) {
    int a, b;

    if (argc != 3) {
        printf("Uso: %s numero1 numero2\n", argv[0]);
        return 1;
    }

    a = atoi(argv[1]);
    b = atoi(argv[2]);

    printf("Soma: %d\n", a + b);

    return 0;
}

Exercício 4
#include <stdio.h>

int potencia(int base, int expoente);

int main() {
    printf("%d\n", potencia(2, 10)); // 1024
    return 0;
}

int potencia(int base, int expoente) {
    if (expoente == 0) {
        return 1;
    }
    return base * potencia(base, expoente - 1);
}

Exercício 5
/* soma.h */
#ifndef SOMA_H
#define SOMA_H

int soma(int n);

#endif
/* soma.c */
#include "soma.h"

int soma(int n) {
    if (n == 0) {
        return 0;
    }
    return n + soma(n - 1);
}
/* main.c */
#include <stdio.h>
#include "soma.h"

int main() {
    int n;

    printf("Digite n: ");
    scanf("%d", &n);

    printf("Soma: %d\n", soma(n));

    return 0;
}
# Makefile
programa: main.c soma.c
	gcc -Wall main.c soma.c -o programa

clean:
	rm -f programa

Exercício 6

A saída será 3 2 1 0. A função misterio chama a si mesma antes de imprimir n — ou seja, o printf só é executado quando a chamada recursiva correspondente retornar. As chamadas empilham na ordem misterio(0), misterio(1), misterio(2), misterio(3), misterio(4) (caso base, que apenas retorna sem imprimir nada); ao desempilhar, cada chamada imprime seu próprio n depois de a chamada seguinte terminar — por isso a ordem de impressão é invertida (do maior n que efetivamente imprime, 3, até o menor, 0), e não a ordem em que as chamadas foram feitas.


Exercício 7
#include <stdio.h>

int fibonacci(int n);
int fibonacciIterativo(int n);

int main() {
    printf("%d\n", fibonacci(10));
    printf("%d\n", fibonacciIterativo(10));
    return 0;
}

int fibonacci(int n) {
    if (n == 0) {
        return 0;
    }
    if (n == 1) {
        return 1;
    }
    return fibonacci(n - 1) + fibonacci(n - 2);
}

int fibonacciIterativo(int n) {
    int anterior = 0, atual = 1, i, proximo;

    if (n == 0) {
        return 0;
    }

    for (i = 2; i <= n; i++) {
        proximo = anterior + atual;
        anterior = atual;
        atual = proximo;
    }

    return atual;
}

A versão recursiva refaz muito trabalho porque, a cada chamada, ela dispara duas novas chamadas recursivas (fibonacci(n-1) e fibonacci(n-2)), que por sua vez também se ramificam em duas — os mesmos valores menores (como fibonacci(2)) acabam sendo recalculados repetidas vezes em ramos diferentes dessa "árvore" de chamadas. A versão iterativa, por outro lado, calcula cada valor uma única vez, guardando apenas os dois últimos termos, o que a torna muito mais eficiente para valores grandes de n.


Exercício 8

O uso é incorreto porque uma idade negativa digitada pelo usuário é um erro do usuário — uma entrada inválida que pode legitimamente acontecer durante o uso normal do programa, e que deveria ser tratada com uma mensagem apropriada. assert, porém, é destinado a erros do programador — situações que a própria lógica interna do código deveria impedir de acontecer. Usar assert aqui faz o programa simplesmente encerrar abruptamente (sem nenhuma mensagem amigável) sempre que um usuário digitar um número negativo — um comportamento ruim para lidar com uma entrada que, aliás, é bastante previsível.

#include <stdio.h>

int main() {
    int idade;

    printf("Digite sua idade: ");
    scanf("%d", &idade);

    if (idade < 0) {
        printf("Idade invalida!\n");
        return 1;
    }

    printf("Idade: %d\n", idade);

    return 0;
}