INF01203 - Estruturas de Dados - Instituto de Informática (UFRGS) - Prof. Dennis Giovani Balreira



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.
  • Princípio da Responsabilidade Única: Cada função deve executar apenas uma tarefa específica. Se uma função está fazendo muitas coisas diferentes (como ler dados, calcular valores e imprimir o resultado), ela deve ser dividida em funções menores.

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 biblioteca math.h), strlen() (da biblioteca string.h), ou printf() (da biblioteca stdio.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:

  1. 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);
    

  2. 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)
    }
    

  3. Chamada: O momento em que a função é invocada e executada em algum ponto do programa (por exemplo, dentro da main ou 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ção return é 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.
  • 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 = &num;
* 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 = &num;    // 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 de num.
  • &num obtém o endereço da variável num.
  • *p acessa o valor armazenado no endereço para o qual p aponta (ou seja, o valor de num).

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 = &num;
    
    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.
  • Sempre inicialize ponteiros: Nunca deixe um ponteiro sem inicialização ou sempre verifique se ele é diferente de NULL antes de desreferenciar.
  • Mantenha a correspondência de tipos: Um ponteiro para int deve sempre apontar para uma variável do tipo int, não para outro tipo.
  • Use ponteiros com propósito: Utilize ponteiros quando realmente necessário (passagem por referência, estruturas dinâmicas). Evite usá-los desnecessariamente, pois aumentam a complexidade do código.

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.
  • Use typedef: Simplifica a sintaxe e torna o código mais legível.
  • Passe por referência (ponteiros) quando possível: Para structs grandes, evita cópias desnecessárias.
  • Use o operador -> com ponteiros: É mais legível que dereferenciar (*p).membro.
  • Compare manualmente: Implemente funções de comparação ao invés de usar ==.

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 (adicionando b)

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.
  • Sempre verifique se fopen retornou NULL: O arquivo pode não existir ou não ter permissão de acesso.
  • Sempre feche arquivos: Use fclose() para liberar recursos. Em caso de erro, feche antes de retornar.
  • Use modo apropriado: "r" para leitura, "w" para escrita, "a" para anexar. Adicione "b" para binário se necessário.
  • Cuidado com fgets vs fscanf: fgets() é mais seguro (você controla o tamanho), fscanf() pode sofrer buffer overflow.
  • Use fwrite/fread para estruturas complexas: Para dados binários, são mais eficientes que fprintf/fscanf.
  • Lembre do cursor: Após ler/escrever, o cursor avança. Use rewind() ou fseek() para repositionar.

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.
  • Toda função recursiva precisa de um caso base: A ausência de um caso base, ou um caso base nunca alcançado, resulta em recursão infinita, esgotando a pilha de execução do programa e causando o erro conhecido como Stack Overflow.

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.
  • Defina claramente o(s) caso(s) base: Sem eles, a recursão nunca termina.
  • Garanta que o problema se aproxime do caso base: Cada chamada recursiva deve trabalhar sobre uma versão menor ou mais simples do problema original.
  • Evite recursões desnecessariamente profundas: Problemas que exigem milhares de chamadas recursivas podem esgotar a pilha de execução.
  • Prefira iteração quando o desempenho for crítico: Especialmente em casos onde a recursão gera chamadas repetidas para os mesmos subproblemas, como ocorre em fib().