“Get Next Line”: Dominando Leitura de Arquivos, Gerenciamento de Memória e Variáveis Estáticas em C
Introdução
Ler um arquivo parece ser uma tarefa fácil e até comum quando lidamos com linguagens de alto nível, mas quando descemos para linguagens mais próximas da máquina (como a linguagem C) pode ser algo desafiador. Nesse projeto da escola 42 somos desafiados a construir uma função que seja capaz de ler um arquivo de texto linha por linha, utilizando apenas System Calls e File Descriptor (FD), uma variável estática para guardar informações de sobra e a nossa capacidade de gerenciar memória dinamicamente.
Antes de navegarmos em águas profundas, precisamos entender alguns conceitos fundamentais que, muitas vezes, são negligenciados em linguagens de alto nível:
System Calls:
Quando você abre um documento no Word ou um arquivo .txt no bloco de notas do seu computador, para o Sistema Operacional (S.O.) você está abrindo um fluxo de bytes acessado por um índice. Para que um programa em C consiga ler um arquivo de texto, ele precisa “conversar” com o S.O. para acessar esse fluxo de bytes. Nós fazemos isso através de System Calls (Chamadas de Sistema ou Syscalls).
Mas o que de fato são Syscalls? Pense nelas como uma interface de segurança. O seu programa não tem permissão para acessar o hardware diretamente, você precisa solicitar esse acesso para o Sistema Operacional e, caso tenha permissão pra isso, o Kernel fará essa intermediação entre o software (seu programa/seu código) e o hardware (memória, arquivos, etc) de forma controlada. Essas chamadas seguem o seguinte fluxo:
- A CPU suspende a execução do programa.
- O controle é passado para o Kernel, que executa a solicitação da chamada.
- Após o serviço ser concluído, o controle é retornado para o programa, que continua sua execução.
Alguns exemplos de Syscalls são: open(), read(), write() ,exit() , close() , mkdir() , rmdir() , etc.
Open e Read:
Como você já sabe, o open() e o read() são chamadas de sistema (Syscall), mas o que eles fazem realmente? Vamos entender isso agora.
open(): É a porta de entrada. Ele não lê o conteúdo do arquivo, mas solicita o acesso ao Kernel. Você deve passar o caminho do arquivo e qual a sua intenção com ele (através de flags). A flag que usaremos nesse projeto é oO_RDONLY(que significa read only, ou traduzindo, somente leitura). Se a resposta for positiva, o Kernel retornará um número inteiro que será o seu File Descriptor (já iremos falar sobre ele). Se o arquivo não existir ou se você não tiver permissão para lê-lo, então o Kernel retornará -1, indicando um erro. Existem outras flags além doO_RDONLY, mas não irei explicar aqui para não desviar muito do nosso objetivo, porém vale a pena pesquisar quais são as essas flags e como elas funcionam caso tenha despertado sua curiosidade.
Exemplo de como usar o open: int fd = open(“arquivo.txt", O_RDONLY);
read(): É quem de fato transportará os dados do disco para o seu programa. Ele se conecta com o arquivo através do File Descriptor e sabe exatamente a partir de onde ele deve ler. Além do File Descriptor, você também deve enviar a variável que receberá esses dados (no nosso caso, um espaço de memória alocado) e a quantidade de bytes que deseja transferir.
Exemplo de como usar o read: ssize_t readed = read(fd, buffer, 10);
File Descriptor (FD):
O FD é a resposta de uma Syscall, como a open() por exemplo, que indica o index exato de onde aquele arquivo foi aberto. O que você precisa entender sobre o FD é que ele não é o seu arquivo literalmente, mas sim uma referência para ele, um número inteiro que será gerado pela Syscall e será a nossa porta de conexão com o arquivo. Por padrão, o S.O. mantém uma tabela de descritores para cada processo em execução, então quando nosso FD recebe o inteiro 3, por exemplo, significa que o arquivo aberto ocupa a quarta posição dessa tabela interna no Kernel. É importante que você saiba também que, geralmente, essa tabela inicia no índice 3, pois todo programa em C já começa com 3 descritores ocupados por padrão, sendo eles: entrada de dados stdin no índice 0, saída de dados stdout no índice 1 e stderr, que representa erro, no índice 2. A quarta posição, ou o índice 3, é o menor índice disponível dessa tabela e, quanto mais arquivos você solicitar, outras posições dessa tabela vão sendo ocupadas.
Além de ser um índice, o FD também consegue rastrear o progresso da sua leitura (chamamos isso de offset), funciona como um marcador de páginas: Imagine que o arquivo que queremos ler seja um livro, você solicita ao Kernel que gostaria de ler 10 páginas desse livro, o Kernel pega todas as informações dessas 10 páginas, te entrega uma ficha com a posição uma posição que representa esse conteúdo em uma tabela, e coloca um marcador na página 11. Dessa forma, enquanto você estiver lendo o livro (seu programa estiver aberto), você saberá exatamente em qual página parou.
Espero que essa explicação tenha ficado clara pois estamos lidando com baixo nível de programação, então realmente é bastante complexo.
Gerenciamento de Memória Dinâmica (malloc e free):
Antes de falarmos sobre variáveis estáticas, precisamos garantir que o conceito de gerenciamento de memória esteja sólido em sua “memória” (hehe), pois esse é o grande desafio desse projeto. Caso você nunca tenha ouvido falar sobre Alocação Dinâmica de Memória, faremos um breve resumo agora, para que todos estejam na mesma página. A Alocação Dinâmica permite que um programa consiga reservar um espaço na memória durante a sua execução, em vez de reservá-la fixamente no código fonte durante a compilação, o que chamamos Alocação Estática.
Get matguima’s stories in your inbox
Join Medium for free to get updates from this writer.
A Memória Dinâmica permite que o programador tenha controle total sobre o tempo de vida e o tamanho daquela memória reservada. Mas isso também exige um grau de responsabilidade, pois toda memória alocada manualmente também deve ser devidamente liberada manualmente, para garantir que não haja nenhum vazamento de memória (Memory Leak), ou, se ficar mais fácil de entender, um bloco de memória preso que poderia ser utilizado para outros fins, mas como você não liberou manualmente, o Sistema Operacional acha que ainda está sendo utilizado. Em pequenos programas, pode parecer inofensivo, mas em larga escala seu programa pode consumir toda memória disponível do sistema e causar um travamento.
- malloc(): Solicita ao sistema operacional um bloco específico de memória. Você deve informar a quantidade de bytes que deseja alocar e a função retorna um ponteiro para o início desse bloco. Para descobrir o tamanho exato de bytes que você irá precisar, basta multiplicar o tamanho que você quer alocar * a quantidade de bytes que cada tipo carrega. Por exemplo, um
charocupa 1 byte, neste caso se quiser alocar uma string com 100 caracteres nós multiplicamos 100 * sizeof(char)), reservando então um bloco de 100 bytes na memória. E se fosse umint? Bom, um inteiro possui 4 bytes em C, então multiplicamos 100 * sizeof(int)) que resultaria em um bloco de memória de 400 bytes reservado. - Exemplo de como usar o malloc:
int i = 0;
int tamanho_string = 42;
char *string = "Gerenciar-memoria-eh-arte-de-sobrevivencia";
char *buffer = (char *)malloc((tamanho_string + 1) * sizeof(char));
//buffer agora é um espaço de memória com 42 bytes + 1 para nulo.
while (string[i])
{
buffer[i] = string[i];
i++;
}
buffer[i] = '\0';
//agora buffer é oficialmente uma string- free(): O free é o comando que devolve a memória que você reservou de volta para o sistema. Essa memória que foi alocada permanece ocupada até que o programa seja encerrado, mesmo que você pare de usá-la. Se você não deseja mais usar a memória alocada e esquecer de dar free nela, ela ficará presa até que o programa seja finalizado, isso é o que chamamos de Memory Leak (vazamento de memória), é o consumo de memória desnecessário. Como expliquei anteriormente, em larga escala isso pode causar sérios problemas, então lembre-se: usou o malloc()? Use o
free(variável).
//continuação do código acima
printf("%s\n", buffer);
free(buffer);
return (0);Sem liberar memória (free):
Com liberação de memória (free):
Variáveis Estáticas (static):
Quando falamos de de variáveis estáticas, estamos falando de memória de longo prazo. Em uma função comum, quando você declara uma variável local, ela nasce no momento que a função começa e morre no momento em que a função termina. Caso a função seja chamada novamente, suas variáveis serão reiniciadas do zero, como se tivessem tido amnésia. Mas a palavra static muda tudo. Quando você cria uma variável static, essa memória é preservada mesmo após o término da função, ela ficará salva até o fim do programa. Guarde essa informação, ela será importante.
static char *rest; //isso é uma variável estática
char *line;
char *buffer;Mas como funciona a get_next_line?
Agora que você já tem uma base sólida, posso explicar de fato como funciona a get_next_line. Pense nela como uma impressora de linhas que utiliza 3 ferramentas principais: o FD que contém a referência do arquivo, o read() para transferir esses dados em pequenos pedaços que são definidos no seu BUFFER_SIZE e uma variável estática static para guardar a sobra após cada quebra de linha da string transferida pelo read().
O Ciclo de funcionamento
O objetivo dessa função não é ler um arquivo inteiro, mas processá-lo em pequenos pedaços, encontrar uma quebra de linha \n ou um nulo \0 e retornar uma string tratada, salvando a sobra em uma variável estática. O fluxo segue a seguinte lógica:
- Abrir o arquivo através do
open()e guardar o File Descriptor. Lembre-se que esse passo é responsabilidade da main e não se trata de uma funcionalidade da função “get_next_line”, mas para que a função funcione sua main precisa fazer exatamente isso. Uma variável receberá uma string e, enquanto existir string, nós continuamos chamando oget_next_line(fd).Dentro dowhilenós printamos a resposta na tela e depois damos free na string alocada, para que abra espaço para a próxima string que será retornada pela função até que não haja mais string no arquivo:
int main(void)
{
int fd;
char *line;
fd = open("text.txt", O_RDONLY);
line = 0;
while ((line = get_next_line(fd)))
{
printf("%s", line);
free(line);
}
close(fd);
return (0);
}- A
get_next_linereceberá o FD como parâmetro. Criamos uma variável estática para guardar sobrar depois de um\n, criamos uma segunda variável para guardar o buffer (valor bruto ) e uma última variável para guardar a linha depois de todos os tratamentos (valor lapidado). Como nosso BUFFER_SIZE é definido em uma macro, nós já sabemos quanto espaço de memória precisamos alocar:malloc((BUFFER_SIZE + 1) * sizeof(char)), somamos BUFFER_SIZE + 1 pois como se trata de uma string, ao final deve-se colocar o\0. Esse espaço de memória receberá os dados transferidos peloread()e, assim que variávelbufferperder sua utilidade, nós liberamos ela com ofree():
char *get_next_line(int fd)
{
static char *rest;
char *line;
char *buffer;
if (fd < 0 || BUFFER_SIZE < 1)
return (NULL);
buffer = (char *)malloc((BUFFER_SIZE + 1) * sizeof(char));
if (!buffer)
return (NULL);
line = read_file(fd, rest, buffer);
free(buffer);
buffer = NULL;
if (!line)
return (NULL);
rest = get_rest(line);
line = first_line(line);
return (line);
}- O
read()retornará um produto bruto, devemos tratá-lo para que o resultado retornado seja uma string válida e concatenada (caso tenha sobrado algum caracter após o\nanteriormente) e, caso seja a primeira volta, nós usamos a strdup para criar uma string vazia, pois a variável não carrega nenhum valor no seu início, e não podemos concatenar uma string com NULL. - Nós utilizamos um loop infinito estrategicamente, buscando algumas validações para encerrá-lo. O encerramento do loop ocorre em três situações: primeiro, se a função
read()retornar -1, indicando um erro grave de sistema, o loop libera a memória alocada e aborta retornandoNULL. Segundo, o loop utiliza umbreakpara quebrar o laço de repetição quando a leitura chega ao fim do arquivo, que é identificado quando a variável readed é igual a\0, sinalizando que não há mais dados para processar. Por fim, existe uma condição de saída lógica através da funçãoft_strchr(rest, '\n'), que interrompe o ciclo assim que um caractere\né encontrado, isso permite que o programa processe linha por linha.
static char *read_file(int fd, char *rest, char *buffer)
{
char *temp;
ssize_t readed;
while (1)
{
readed = read(fd, buffer, BUFFER_SIZE);
if (readed == -1)
{
free(rest);
return (NULL);
}
else if (readed == '\0')
break ;
buffer[readed] = '\0';
if (rest == NULL)
rest = ft_strdup("");
temp = rest;
rest = ft_strjoin(temp, buffer);
free(temp);
temp = NULL;
if (ft_strchr(rest, '\n'))
break ;
}
return (rest);
}- Como você não sabe o tamanho de cada linha ou quantas vezes o
readserá chamado, utilizamos a memória dinâmica através domalloc()para criar o espaço necessário que guardará nossa string de retorno. O processo é simples: criamos uma variável temporária para guardar um valor X e concatenamos a string temporária com a string Y. Atribuímos o resultado da concatenação para a string X. Após isso, nós damosfree()na variável temporária, pois ela só foi criada para auxiliar na concatenação de X com Y.
temp = rest;
rest = ft_strjoin(temp, buffer);
free(temp);
temp = NULL;- As funções de separar a primeira linha e separar o resto depois do
\nseguem a mesma lógica, mas com propósitos diferentes. A ideia aqui é utilizar a funçãosubstr()para recortar a string até a quebra de linha, porém enquanto a funçãofirst_lineretorna a primeira linha (incluindo o\n), a funçãoget_restretorna o resto após a quebra de linha. Inclusive, essas duas funções poderiam ser uma só, porém eu preferi separá-las para que ficasse mais fácil de visualizar o processo de tratamento passo a passo.
static char *first_line(char *line)
{
char *line_cuted;
int position;
position = (ft_strchr(line, '\n') - line) + 1;
line_cuted = ft_substr(line, 0, position);
free(line);
return (line_cuted);
}static char *get_rest(char *line)
{
char *rest;
int position;
position = (ft_strchr(line, '\n') - line) + 1;
rest = ft_substr(line, position, ft_strlen(line));
if (*rest == '\0')
{
free(rest);
rest = NULL;
return (rest);
}
return (rest);
}- Após todo esse tratamento, atribuímos o valor de retornado de cada função acima para suas respectivas variáveis, sendo elas:
static char *rest, que guardará a sobra para a próxima rodada echar *line, que será a linha que retornaremos para a main. Lembra que eu comentei que a funçãostrdupserá usada para atribuir uma string vazia pararestcaso seja a primeira volta? A partir do momento que nós utilizarmos a funçãoget_rest, essa condição não será mais verdadeira, isso significa que, a partir da segunda rodada,restnão irá mais entrar na condição dostrduppois já possuí um valor para ser concatenado.
rest = get_rest(line);
line = first_line(line);- Repetimos esse processo até o fim do while na nossa main.
CONCLUSÃO
Espero que esse artigo possa ter te ajudado a entender os conceitos de gerenciamento de memória, chamadas de sistema e variáveis estáticas. Dominar esses conceitos de baixo nível nos permite entender como linguagens mais modernas funciona por debaixo dos panos. Isso abre um mar de possibilidades, tornando sua capacidade lógica mais performática independente da linguagem.
Pense na get_next_line como um prisma de cristal, que separa a luz de forma organizada, retornando cada “cor” em uma determinada linha.
Repositório: https://github.com/matheusguimaraesrs/Get_Next_Line
LinkedIn: https://www.linkedin.com/in/matheus-guimaraes-dev/






