Material Extra - Revisão de Linguagem C (Parte 2)
Nesta aula daremos continuidade à revisão dos principais conceitos da linguagem C necessários para o restante da disciplina, iniciada no Material Extra — Revisão de Linguagem C (Parte 1). Assume-se que todos os alunos já cursaram uma disciplina introdutória de programação e possuem familiaridade com a linguagem C. Assim, o objetivo desta aula não é ensinar programação desde o início, mas revisar rapidamente os conceitos que servirão de base para as próximas aulas.
Nesta segunda parte revisaremos os recursos mais importantes para Estruturas de Dados, como funções, modularização, ponteiros, structs e arquivos.
Subprogramação (Funções)
A subprogramação é uma técnica que consiste em dividir um programa complexo em partes menores, mais simples e independentes, chamadas de funções. A principal motivação é permitir o reaproveitamento de código, facilitar a manutenção, melhorar a legibilidade e evitar a repetição de trechos de lógica idênticos.
Diretriz de Projeto.
|
Em C, as funções podem ser classificadas em dois grupos:
- Pré-definidas (ou de biblioteca): Funções já implementadas pelo próprio compilador ou por bibliotecas padrão do sistema, como
sqrt()(da bibliotecamath.h),strlen()(da bibliotecastring.h), ouprintf()(da bibliotecastdio.h). - Desenvolvidas pelo usuário: Funções criadas pelo programador para resolver necessidades específicas do programa.
Estrutura e Utilização: Declaração, Protótipo e Chamada
O uso de uma função desenvolvida pelo usuário envolve três etapas essenciais:
- Protótipo (ou Assinatura): Declaração da existência da função para o compilador, informando seu tipo de retorno, nome e tipos dos parâmetros. Geralmente inserido no início do código (antes da função
main), serve para que a função possa ser chamada mesmo se sua implementação física estiver escrita mais abaixo no arquivo.
Sintaxe genérica:tipo nome(tipo1 param1, tipo2 param2 … tipoN paramN);
- Declaração (ou Definição): A implementação física da função, contendo o bloco de instruções (corpo da função) que realiza a tarefa.
Sintaxe genérica:tipo nome(tipo1 param1, tipo2 param2 … tipoN paramN) { ... return expressão; // Opcional (obrigatório se o tipo de retorno não for void) } - Chamada: O momento em que a função é invocada e executada em algum ponto do programa (por exemplo, dentro da
mainou de outra função).
Sintaxe genérica:var = nome(argum1, argum2, … argumN);
#include <stdio.h> // 1. PROTÓTIPO da função int somar(int a, int b); int main() { int x = 5, y = 3; // 2. CHAMADA da função int resultado = somar(x, y); printf("Soma: %d\n", resultado); return 0; } // 3. DECLARAÇÃO (Definição) da função int somar(int a, int b) { return a + b; }
Argumentos, Parâmetros e Retorno
Para que as funções possam se comunicar e compartilhar dados entre si, utilizamos os conceitos de parâmetros/argumentos e retorno:
- Parâmetros: São as variáveis declaradas na assinatura ou definição da função. Eles atuam como receptores locais para os valores que serão passados para a função.
- Argumentos: São os valores reais ou variáveis fornecidos à função no momento da sua chamada (os valores reais passados).
- Retorno: É o valor que a função devolve para a instrução que a chamou, através do comando
return. Quando a instruçãoreturné executada, a função é imediatamente encerrada.
Uma função em C pode ou não ter parâmetros e pode ou não ter retorno.
#include <stdio.h> // 1. Sem retorno (void) e sem parâmetros void cabecalho() { printf("====================\n"); printf(" SISTEMA DE TESTE \n"); printf("====================\n"); } // 2. Com retorno (float) e sem parâmetros float obterPi() { return 3.14159; } // 3. Sem retorno (void) e com parâmetros void imprimirMensagemRepetida(char texto[], int vezes) { for (int i = 0; i < vezes; i++) { printf("%s\n", texto); } } // 4. Com retorno (int) e com parâmetros int quadrado(int num) { return num * num; } int main() { cabecalho(); printf("Valor de PI: %f\n", obterPi()); imprimirMensagemRepetida("Ola!", 3); int val = 5; printf("O quadrado de %d eh %d\n", val, quadrado(val)); return 0; }
Escopo de Variáveis
O escopo define a visibilidade e o tempo de vida de uma variável dentro de um programa. As variáveis pertencem a um determinado escopo (trecho de código delimitado por chaves { }).
- Variáveis locais:
- São declaradas dentro do corpo de uma função (inclusive dentro da função
main) ou de um bloco específico. - Apenas a função onde a variável foi declarada tem acesso a ela.
- Seu tempo de vida se encerra quando a execução da função termina.
- Regra de Ouro: Devem ser amplamente utilizadas para garantir o encapsulamento e evitar efeitos colaterais em outras partes do código.
- São declaradas dentro do corpo de uma função (inclusive dentro da função
- Variáveis globais:
- São declaradas fora de qualquer função, geralmente no topo do arquivo.
- Qualquer parte do programa (qualquer função) pode ler ou alterar seus valores livremente.
- Seu tempo de vida dura toda a execução do programa.
- Regra de Ouro: NÃO devem ser usadas. Variáveis globais dificultam o rastreamento de erros, impedem a modularização e comprometem a segurança e legibilidade do código.
Passagem de Parâmetros: Passagem por Valor
Em C, a passagem de parâmetros padrão para tipos primitivos é sempre realizada por valor.
Isso significa que, durante a chamada de uma função, o valor das variáveis enviadas como argumentos é copiado para as variáveis receptoras (parâmetros da função). Qualquer modificação feita nos parâmetros dentro da função afeta apenas as cópias locais, não alterando os valores das variáveis originais na função chamadora.
#include <stdio.h> void incrementar(int n) { n = n + 1; // Modifica apenas a cópia local do parâmetro printf("Valor dentro da função: %d\n", n); } int main() { int num = 10; incrementar(num); // Passagem por valor printf("Valor na main: %d\n", num); // Imprime 10 (permanece inalterado) return 0; }
Ponteiros
Um ponteiro é uma variável que armazena o endereço de memória de outra variável. Ponteiros são um dos conceitos mais fundamentais em C e são essenciais para implementar estruturas de dados dinâmicas como listas encadeadas, árvores e grafos.
Cada variável armazenada na memória possui um endereço, e ponteiros fornecem uma forma de acessar e manipular esses endereços diretamente.
Declaração de Ponteiros
A declaração de um ponteiro utiliza o operador * (asterisco). A sintaxe geral é:
tipo *nome_do_ponteiro;
Por exemplo:
int *p; // Ponteiro para int float *pf; // Ponteiro para float char *pc; // Ponteiro para char
O tipo do ponteiro deve corresponder ao tipo da variável para a qual ele apontará. Um ponteiro para int é declarado como int *, um ponteiro para float como float *, e assim por diante.
Operadores de Ponteiros
Dois operadores são fundamentais para trabalhar com ponteiros em C:
| Operador | Nome | Significado | Exemplo |
|---|---|---|---|
& |
Endereço-de | Obtém o endereço de memória de uma variável. | p = # |
* |
Desreferenciação | Acessa o valor armazenado no endereço para o qual o ponteiro aponta. | printf("%d", *p); |
Considere o seguinte exemplo:
#include <stdio.h> int main() { int num = 42; // Variável comum int *p = # // Ponteiro recebe o endereço de num printf("Valor de num: %d\n", num); // Imprime: 42 printf("Endereço de num: %p\n", &num); // Imprime: 0x7fff5fbff8ac (exemplo) printf("Valor do ponteiro p: %p\n", p); // Imprime: 0x7fff5fbff8ac (mesmo endereço) printf("Valor apontado por p: %d\n", *p); // Imprime: 42 return 0; }
Neste exemplo:
numé uma variável inteira que armazena o valor 42.pé um ponteiro que armazena o endereço denum.&numobtém o endereço da variávelnum.*pacessa o valor armazenado no endereço para o qualpaponta (ou seja, o valor denum).
Modificação de Valores através de Ponteiros
Uma das aplicações práticas de ponteiros é modificar o valor de uma variável através do seu ponteiro.
#include <stdio.h> int main() { int num = 10; int *p = # printf("Antes: %d\n", num); // Imprime: 10 *p = 20; // Modificamos o valor de num através do ponteiro printf("Depois: %d\n", num); // Imprime: 20 return 0; }
Ponteiros e Funções: Passagem por Referência
Uma das aplicações mais importantes de ponteiros em C é permitir que funções modifiquem valores de variáveis da função chamadora. Como vimos anteriormente, a passagem por valor não permite isso. Utilizando ponteiros, podemos implementar uma passagem por referência.
#include <stdio.h> // Função que incrementa o valor apontado pelo ponteiro void incrementar(int *n) { *n = *n + 1; // Modifica o valor original } int main() { int num = 10; printf("Antes: %d\n", num); // Imprime: 10 incrementar(&num); // Passamos o endereço de num printf("Depois: %d\n", num); // Imprime: 11 return 0; }
Note a diferença fundamental:
- Sem ponteiros (passagem por valor): Modificações dentro da função não afetam a variável original.
- Com ponteiros (passagem por referência): Modificações dentro da função afetam a variável original.
Aritmética de Ponteiros
Ponteiros suportam operações aritméticas que permitem navegar pela memória. As operações mais comuns são adição e subtração.
#include <stdio.h> int main() { int vetor[5] = {10, 20, 30, 40, 50}; int *p = vetor; // p aponta para o primeiro elemento printf("Elemento 0: %d (endereço: %p)\n", *p, p); p++; // Avança o ponteiro para o próximo elemento printf("Elemento 1: %d (endereço: %p)\n", *p, p); p = p + 2; // Avança 2 elementos printf("Elemento 3: %d (endereço: %p)\n", *p, p); return 0; }
Quando incrementamos um ponteiro (por exemplo, p++), ele não avança simplesmente 1 byte na memória. Em vez disso, ele avança um número de bytes equivalente ao tamanho do tipo apontado. Se p aponta para um int (4 bytes), então p++ avançará 4 bytes. Essa automatização é uma das razões pelas quais C oferece um controle tão fino sobre a memória.
Ponteiros NULL
Um ponteiro sem inicialização contém um valor impreciso (lixo de memória), e dereferenciá-lo (*p) resultará em comportamento indefinido. Para indicar que um ponteiro não aponta para nenhum local válido, utilizamos NULL.
#include <stdio.h> int main() { int *p = NULL; // Inicializa com NULL (seguro) if (p != NULL) { printf("%d\n", *p); // Só será executado se p não for NULL } else { printf("Ponteiro é NULL\n"); } return 0; }
Ponteiros e Vetores/Strings
Em C, há uma relação muito próxima entre ponteiros e vetores. Na verdade, o nome de um vetor é um ponteiro para seu primeiro elemento. Isso significa que vetor, &vetor[0] e &vetor apontam para o mesmo endereço em memória.
#include <stdio.h> int main() { int vetor[5] = {10, 20, 30, 40, 50}; char string[] = "Olá"; // Três formas equivalentes de acessar o primeiro elemento printf("vetor = %p\n", (void*)vetor); printf("&vetor[0] = %p\n", (void*)&vetor[0]); printf("&vetor = %p\n", (void*)&vetor); // vetor == &vetor[0] == &vetor (mesmo endereço) // Acessar elementos via ponteiro int *p = vetor; printf("Primeiro: %d\n", *p); // Acessa vetor[0] printf("Segundo: %d\n", *(p + 1)); // Acessa vetor[1] printf("Segundo: %d\n", p[1]); // Equivalente: p[1] == *(p+1) // Iterando com ponteiro for (int i = 0; i < 5; i++) { printf("%d ", *(p + i)); } printf("\n"); return 0; }
Isso explica por que você pode passar um vetor diretamente para uma função: na realidade, você está passando um ponteiro. Strings em C são apenas arrays de char, então a mesma lógica se aplica. Se você escrever "Olá", você tem um ponteiro para o primeiro caractere.
Boas práticas com Ponteiros.
|
Structs (Estruturas)
Uma struct (estrutura) é um tipo de dado composto que permite agrupar variáveis de diferentes tipos sob um único nome. Isso é fundamental para organizar dados relacionados e criar abstrações que facilitam o desenvolvimento de programas complexos.
Por que structs são importantes: Sem structs, você precisaria manter múltiplas variáveis relacionadas separadas, dificultando a leitura do código e aumentando a chance de erros. Por exemplo, se você precisa armazenar informações sobre uma pessoa (nome, idade, altura), sem structs você teria três variáveis soltas. Com structs, você agrupa tudo em um único tipo, facilitando passagem para funções, armazenamento em arrays, e organização lógica dos dados.
Em C moderno, é preferível sempre usar typedef ao definir structs, para simplificar a sintaxe e melhorar a legibilidade do código:
#include <stdio.h> #include <string.h> // Definição de uma struct com typedef typedef struct { char nome[100]; int idade; float altura; } Pessoa; int main() { Pessoa pessoa1; // Declare normalmente, sem "struct" strcpy(pessoa1.nome, "João"); pessoa1.idade = 30; pessoa1.altura = 1.80; printf("Nome: %s, Idade: %d\n", pessoa1.nome, pessoa1.idade); return 0; }
Structs e Funções
Structs podem ser passadas como argumentos para funções e retornadas. Isso permite código modular. Por valor: função recebe uma cópia. Por referência (ponteiro): função acessa o original.
#include <stdio.h> typedef struct { int x; int y; } Ponto; void imprimir_ponto(Ponto p) { printf("Ponto: (%d, %d)\n", p.x, p.y); } void incrementar_ponto(Ponto *p) { p->x++; // Operador -> acessa membros via ponteiro p->y++; } int main() { Ponto p1 = {5, 10}; imprimir_ponto(p1); // Imprime: Ponto: (5, 10) incrementar_ponto(&p1); imprimir_ponto(p1); // Imprime: Ponto: (6, 11) return 0; }
Arrays de Structs
Um array de structs permite armazenar múltiplas instâncias de forma organizada.
#include <stdio.h> #include <string.h> typedef struct { char nome[50]; int idade; } Aluno; int main() { Aluno turma[3]; strcpy(turma[0].nome, "Alice"); turma[0].idade = 20; strcpy(turma[1].nome, "Bob"); turma[1].idade = 21; for (int i = 0; i < 2; i++) { printf("%s tem %d anos\n", turma[i].nome, turma[i].idade); } return 0; }
Ponteiros para Structs e o Operador ->
Quando você tem um ponteiro para uma struct, acessa seus membros com o operador seta (p->membro). Isso é equivalente a (*p).membro, mas muito mais legível.
Por que o operador -> é necessário: O operador ponto (.) tem prioridade sobre o operador de desreferência (*). Se você escrever *p.membro, o compilador interpretará como *(p.membro), tentando acessar um membro de p (que não é uma struct, mas um ponteiro) e depois desreferenciar o resultado. Isso não faz sentido e causaria um erro. Por isso, você deve usar parênteses: (*p).membro. O operador -> foi criado exatamente para evitar essa confusão.
#include <stdio.h> #include <stdlib.h> typedef struct { int dia; int mes; int ano; } Data; int main() { Data *data = (Data*) malloc(sizeof(Data)); // Usando -> (recomendado) data->dia = 25; data->mes = 12; data->ano = 2023; // Equivalente, mas menos legível: (*data).dia = 25; printf("%d/%d/%d\n", data->dia, data->mes, data->ano); free(data); return 0; }
Structs Aninhadas (Structs dentro de Structs)
Uma struct pode conter outras structs como membros, permitindo criar estruturas complexas e bem organizadas.
#include <stdio.h> #include <string.h> typedef struct { int dia; int mes; int ano; } Data; typedef struct { char nome[100]; Data data_nascimento; // Struct dentro de struct } Contato; int main() { Contato contato; strcpy(contato.nome, "Maria"); contato.data_nascimento.dia = 15; contato.data_nascimento.mes = 6; contato.data_nascimento.ano = 1995; printf("%s nasceu em %d/%d/%d\n", contato.nome, contato.data_nascimento.dia, contato.data_nascimento.mes, contato.data_nascimento.ano); return 0; }
Cópia de Structs
Em C, você pode copiar uma struct simplesmente atribuindo uma variável a outra. Isso copia todos os membros.
#include <stdio.h> typedef struct { int x; int y; } Ponto; int main() { Ponto p1 = {10, 20}; Ponto p2 = p1; // Cópia: p2 recebe os valores de p1 p2.x = 30; printf("p1: (%d, %d)\n", p1.x, p1.y); // Ainda é (10, 20) printf("p2: (%d, %d)\n", p2.x, p2.y); // Agora é (30, 20) return 0; }
Comparação de Structs
Você não pode comparar structs diretamente com ==. Implemente uma função de comparação que compara seus membros individualmente.
#include <stdio.h> typedef struct { int x; int y; } Ponto; int pontos_iguais(Ponto p1, Ponto p2) { return (p1.x == p2.x) && (p1.y == p2.y); } int main() { Ponto p1 = {5, 10}; Ponto p2 = {5, 10}; Ponto p3 = {3, 7}; if (pontos_iguais(p1, p2)) { printf("p1 e p2 são iguais\n"); } if (!pontos_iguais(p1, p3)) { printf("p1 e p3 são diferentes\n"); } return 0; }
Resumo: Boas Práticas com Structs.
|
Arquivos
Um arquivo é um conjunto de dados armazenado em memória secundária (disco rígido, SSD, etc). Diferentemente da memória principal (RAM), que é volátil e rápida, a memória secundária é persistente: os dados sobrevivem mesmo após desligar o computador.
Comparação entre Memória Principal e Secundária:
- Memória Principal (RAM): Rápida, volátil (dados são perdidos ao desligar), limited (gigabytes). Usada para executar programas e armazenar dados temporários.
- Memória Secundária (Disco): Lenta, persistente (dados sobrevivem), grande capacidade (terabytes). Usada para armazenar dados permanentemente.
Arquivos são streams de bytes. Não importa se o arquivo é "texto" ou "binário": internamente, tudo são bytes (números de 0-255). A diferença está em como interpretamos esses bytes:
- Arquivos de Texto: Bytes representam caracteres ASCII (ou UTF-8). Legíveis por humanos. Exemplos: .txt, .csv, .html.
- Arquivos Binários: Bytes representam dados em formato compactado (imagens, áudio, executáveis). Não legíveis diretamente. Exemplos: .jpg, .mp3, .exe.
Abrindo e Fechando Arquivos
Para trabalhar com arquivos em C, usamos um ponteiro do tipo FILE. A função fopen() abre um arquivo e retorna um ponteiro para ele. Sempre verifique se a abertura foi bem-sucedida (não é NULL).
#include <stdio.h> int main() { // Abrindo arquivo em modo leitura FILE *arquivo = fopen("dados.txt", "r"); // Verificando se abriu corretamente if (arquivo == NULL) { printf("Erro: não foi possível abrir o arquivo\n"); return 1; } // ... operações com o arquivo ... // Fechando sempre ao final fclose(arquivo); return 0; }
Modos de Abertura:
"r"- Leitura (arquivo deve existir)"w"- Escrita (cria novo ou sobrescreve)"a"- Anexação (escreve ao final)"r+"- Leitura e escrita (arquivo deve existir)"w+"- Leitura e escrita (cria novo ou sobrescreve)"rb","wb","ab"- Modos binários (adicionandob)
Operações Gerais com Arquivos
Estas operações funcionam com qualquer tipo de arquivo (texto ou binário). Abaixo, explicamos cada uma com sua sintaxe e propósito.
rewind(FILE *arquivo) - Volta o cursor do arquivo para o início (posição 0). Equivalente a fseek(arquivo, 0, SEEK_SET).
long ftell(FILE *arquivo) - Retorna a posição atual do cursor em bytes. Útil para saber onde você está no arquivo. Retorna -1 se houver erro.
int fseek(FILE *arquivo, long offset, int origem) - Move o cursor para uma posição específica. O parâmetro origem pode ser:
SEEK_SET- Início do arquivo.offseté a posição absoluta.SEEK_CUR- Posição atual.offseté relativo à posição atual.SEEK_END- Fim do arquivo.offseté relativo ao fim (geralmente negativo).
Retorna 0 se bem-sucedido, -1 se houver erro.
int fflush(FILE *arquivo) - Envia imediatamente todos os dados do buffer para o arquivo. Útil quando você quer garantir que os dados foram escritos antes de continuar. Retorna 0 se bem-sucedido, EOF se houver erro.
int feof(FILE *arquivo) - Retorna verdadeiro (não-zero) se o cursor atingiu o fim do arquivo, falso (zero) caso contrário. Use com while (!feof(arquivo)) para ler até o final.
#include <stdio.h> int main() { FILE *arquivo = fopen("dados.txt", "r"); if (arquivo == NULL) return 1; // rewind: volta para o início rewind(arquivo); printf("Posição após rewind: %ld\n", ftell(arquivo)); // 0 // fseek: posiciona em lugar específico fseek(arquivo, 10, SEEK_SET); // Move para byte 10 do início printf("Posição agora: %ld\n", ftell(arquivo)); // 10 fseek(arquivo, 5, SEEK_CUR); // Avança 5 bytes da posição atual printf("Posição após avançar 5: %ld\n", ftell(arquivo)); // 15 fseek(arquivo, -10, SEEK_END); // Move 10 bytes antes do fim printf("Posição (10 bytes antes do fim): %ld\n", ftell(arquivo)); // fflush: garante que dados foram escritos fflush(arquivo); // feof com loop de leitura rewind(arquivo); char buffer[256]; while (!feof(arquivo)) { if (fgets(buffer, 256, arquivo) != NULL) { printf("%s", buffer); } } fclose(arquivo); return 0; }
Nota sobre o cursor do arquivo: Toda estrutura FILE contém um campo pos_cursor (interno) que controla onde o próximo byte será lido ou escrito. Quando você abre um arquivo em modo "r", o cursor começa no início (posição 0). Cada operação de leitura/escrita avança o cursor. Use ftell() para saber a posição atual e fseek() (ou rewind()) para mudar.
Leitura e Escrita em Arquivos Binários
Para arquivos binários, use fread() (leitura) e fwrite() (escrita). Elas trabalham com blocos de dados.
#include <stdio.h> typedef struct { int id; char nome[100]; float salario; } Funcionario; int main() { // Escrevendo struct binária FILE *arquivo = fopen("dados.bin", "wb"); if (arquivo == NULL) return 1; Funcionario func = {1, "João", 5000.0}; // fwrite: escreve 1 bloco de sizeof(Funcionario) bytes fwrite(&func, sizeof(Funcionario), 1, arquivo); fclose(arquivo); // Lendo struct binária arquivo = fopen("dados.bin", "rb"); Funcionario func_lido; // fread: lê 1 bloco de sizeof(Funcionario) bytes size_t bytes_lidos = fread(&func_lido, sizeof(Funcionario), 1, arquivo); if (bytes_lidos == 1) { printf("ID: %d, Nome: %s, Salário: %.2f\n", func_lido.id, func_lido.nome, func_lido.salario); } fclose(arquivo); return 0; }
Leitura e Escrita em Arquivos de Texto
Para arquivos de texto, use fprintf() e fscanf() (como printf() e scanf(), mas para arquivo), ou fgets() e fgetc() para leitura linha por linha.
#include <stdio.h> int main() { // Escrita formatada em arquivo FILE *arquivo = fopen("output.txt", "w"); if (arquivo == NULL) return 1; fprintf(arquivo, "Nome: %s\n", "Maria"); fprintf(arquivo, "Idade: %d\n", 30); fprintf(arquivo, "Salário: %.2f\n", 3500.50); fclose(arquivo); // Leitura de arquivo de texto arquivo = fopen("output.txt", "r"); char linha[256]; // fgets: lê até 255 caracteres ou \n (inclui \n na string) while (fgets(linha, 256, arquivo) != NULL) { printf("%s", linha); // Já contém \n } fclose(arquivo); return 0; }
Outras funções úteis para texto:
#include <stdio.h> #include <stdlib.h> int main() { // fgetc: lê um caractere FILE *arquivo = fopen("dados.txt", "r"); int caractere = fgetc(arquivo); // Retorna int (ou EOF) // fscanf: lê dados formatados (cuidado com buffer overflow) int idade; char nome[50]; fscanf(arquivo, "%s %d", nome, &idade); // atoi: converte string para inteiro int numero = atoi("42"); // 42 // atof: converte string para float float valor = atof("3.14"); // 3.14 fclose(arquivo); return 0; }
Resumo: Boas Práticas com Arquivos.
|
Recursão
A recursão é uma técnica de programação em que uma função chama a si mesma para resolver um problema. Um problema é resolvido recursivamente ao dividi-lo em subproblemas menores e mais simples, do mesmo tipo do problema original, até que se atinja um caso trivial, resolvido diretamente sem novas chamadas.
Toda função recursiva bem definida deve possuir dois elementos fundamentais:
- Caso base: condição de parada que interrompe as chamadas recursivas, retornando um valor diretamente, sem realizar nova chamada à função.
- Caso recursivo: parte da função em que ocorre a chamada da própria função, aplicada a uma versão menor ou mais simples do problema original, aproximando-a do caso base.
Diretriz de Projeto.
|
Um Primeiro Exemplo: Fatorial
O cálculo do fatorial de um número é um exemplo clássico de recursão. O fatorial de n (representado por n!) é definido matematicamente como:
0! = 1 (caso base) n! = n × (n-1)! (caso recursivo, para n > 0)
Essa definição matemática pode ser traduzida quase diretamente para uma função em C:
#include <stdio.h> int fatorial(int n) { // Caso base if (n == 0) { return 1; } // Caso recursivo return n * fatorial(n - 1); } int main() { printf("5! = %d\n", fatorial(5)); // Imprime: 120 return 0; }
Como Funciona: A Pilha de Chamadas
Cada chamada recursiva gera uma nova ativação da função, armazenada na pilha de execução (call stack) do programa. Cada uma dessas ativações mantém suas próprias variáveis locais e parâmetros, independentes das demais chamadas.
As chamadas se acumulam na pilha até que o caso base seja alcançado. A partir desse momento, os valores de retorno começam a ser propagados de volta, em ordem inversa, para cada chamada pendente, até que a chamada original seja finalmente resolvida.
Para fatorial(3), essa sequência pode ser representada da seguinte forma:
Chamadas (empilhando) Retornos (desempilhando)
fatorial(3) fatorial(3) = 3 * 2 = 6
fatorial(2) fatorial(2) = 2 * 1 = 2
fatorial(1) fatorial(1) = 1 * 1 = 1
fatorial(0) fatorial(0) = 1 (caso base)
Observe que a função só começa a "devolver" valores após alcançar o caso base (fatorial(0)). Antes disso, todas as chamadas permanecem pendentes, aguardando o resultado da chamada seguinte.
Segundo Exemplo: Sequência de Fibonacci
A sequência de Fibonacci é outro exemplo clássico, no qual cada termo é definido como a soma dos dois termos anteriores. Diferentemente do fatorial, esse problema possui dois casos base.
fib(0) = 0 (caso base) fib(1) = 1 (caso base) fib(n) = fib(n-1) + fib(n-2) (caso recursivo, para n > 1)
#include <stdio.h> int fib(int n) { // Casos base if (n == 0) { return 0; } if (n == 1) { return 1; } // Caso recursivo: soma de duas chamadas return fib(n - 1) + fib(n - 2); } int main() { for (int i = 0; i < 8; i++) { printf("%d ", fib(i)); // 0 1 1 2 3 5 8 13 } printf("\n"); return 0; }
Note que, ao contrário do fatorial, cada chamada de fib(n) gera duas novas chamadas recursivas, o que faz o número de chamadas crescer rapidamente conforme n aumenta.
Recursão vs. Iteração
Praticamente todo problema resolvido de forma recursiva também pode ser resolvido de forma iterativa (utilizando while ou for), e vice-versa. A escolha entre uma abordagem e outra costuma depender da natureza do problema e da clareza da solução resultante.
| Aspecto | Recursão | Iteração |
|---|---|---|
| Legibilidade | Costuma ser mais próxima da definição matemática do problema, especialmente em estruturas de dados recursivas (árvores, listas). | Costuma ser mais direta em problemas simples de contagem ou repetição. |
| Uso de memória | Cada chamada consome espaço na pilha de execução, podendo causar Stack Overflow em recursões muito profundas. | Não gera novas ativações de função, utilizando uma quantidade constante de memória adicional. |
| Desempenho | Pode ser mais lenta devido ao custo de empilhar e desempilhar chamadas de função. | Geralmente mais eficiente, por evitar esse custo adicional. |
Considere, por exemplo, a versão iterativa do fatorial, equivalente à versão recursiva apresentada anteriormente:
int fatorialIterativo(int n) { int resultado = 1; for (int i = 1; i <= n; i++) { resultado = resultado * i; } return resultado; }
Em Estruturas de Dados, entretanto, a recursão frequentemente se mostra especialmente útil, pois diversas estruturas (como listas encadeadas e árvores) são definidas de forma naturalmente recursiva, tornando os algoritmos recursivos sobre elas mais simples e legíveis do que suas versões iterativas equivalentes.
Resumo: Boas Práticas com Recursão.
|