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 dogccmostra 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
printfde 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
|
Exercícios
Nível fácil
-
Escreva uma função recursiva
int soma(int n)que calcule a soma dos inteiros de1atén(caso base:n == 0). Chame-a a partir damain(), lendondo usuário. -
Escreva uma função recursiva
void contarRegressiva(int n)que imprima os números denaté1, um por linha, seguidos de"Fim!"quandonchegar a0. -
Escreva um programa que receba dois argumentos de linha de comando (dois números inteiros) e imprima a soma deles, convertendo os argumentos de
argvcomatoi.
Nível médio
-
Escreva uma função recursiva
int potencia(int base, int expoente)que calculebaseexpoente(caso base:expoente == 0, retornando1). -
Divida o programa do Exercício 1 em três arquivos:
soma.h(protótipo),soma.c(implementação) emain.c(chamada). Escreva também umMakefilesimples para compilar o projeto. -
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
-
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çofor. Compare os dois códigos e explique, em texto, por que a versão recursiva refaz muito mais trabalho para valores grandes den. -
O programa abaixo tenta usar
assertpara validar uma entrada digitada pelo usuário. Explique por que esse é um uso incorreto deassert(compare com a distinção entre erro do programador e erro do usuário vista na Seção 5), e reescreva a validação usandoif:#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; }