Você está na página 1de 12

Armazenamento em memória secundária https://ead5.ifmg.edu.br/bambui/mod/book/tool/print/inde...

Armazenamento em memória secundária

Site: AVA - IFMG Campus Bambuí Impresso por: Letícia Moreira Leonel
Curso: Técnicas de Programação - BIBENGC.2021.1-A Data: quinta, 20 out 2022, 10:44
Livro: Armazenamento em memória secundária

1 of 12 20/10/2022 10:45
Armazenamento em memória secundária https://ead5.ifmg.edu.br/bambui/mod/book/tool/print/inde...

Índice

1. Introdução

2. Serialização de Objetos

3. Arquivo de Registros

4. Bibliografia Complementar

2 of 12 20/10/2022 10:45
Armazenamento em memória secundária https://ead5.ifmg.edu.br/bambui/mod/book/tool/print/inde...

1. Introdução

No tópico anterior, vimos a leitura e gravação de dados em disco usando arquivos ASCII puros, ou seja, arquivos com dados representados por
caracteres da tabela ASCII, não estruturados ou semiestruturados. A vantagem desses arquivos é que podem ser editados em qualquer editor
de texto que não adicione formatação, como o Bloco de Notas ou o próprio Code::Blocks.

No entanto, armazenar em formato ASCII não é uma boa escolha em diversas situações. Geralmente, não são muito práticos para gerenciar o
armazenamento. Vamos imaginar a gravação dos dados de um aluno. O registro acadêmico (RA) é um número inteiro, representado por 7
dígitos. Se escrevermos em ASCII, vamos precisar de 7 caracteres para armazená-lo. No entanto, na memória, esse mesmo número pode ser
representado com um número inteiro de 4 bytes, sem sinal. Portanto, ao armazenar no formato ASCII, desperdiçamos 3 bytes sem carregar
informação útil.

Para isso, vamos usar uma técnica chamada serialização. Serializar é criar uma representação dos dados/objetos, colocando seus valores em
sequência, numa ordem pré-definida, que nos permita recuperar (desserializar) e recuperar a informação original. A serialização é uma etapa
importante para funcionamento de sistemas, pois é por meio dela que podemos armazenar dados em arquivos, transmiti-los pela rede de um
host para outro, etc.

Embora a serialização envolva várias nuances, como por exemplo, a presença de objetos multivalorados (vetores, matrizes, listas), ponteiros,
etc., dentro de um objeto, vamos utilizar a conceituação mais simples e gerar registros de tamanho fixo, que serão úteis logo à frente para
armazenarmos em disco. É importante ter cuidado, pois embora alguns exemplos na internet façam apenas o typecast para uma string, nem
todos os objetos são corretamente serializados dessa forma. Considere um ponteiro dentro de um objeto, que aponta para uma coleção de
outros objetos (um vetor, por exemplo). Se serializarmos o objeto pelo typecast dele, o resultado será incorreto: o ponteiro será serializado como
um inteiro de 4 bytes, contendo o endereço dos objetos, e perderemos todos os objetos ali referenciados. A serialização se ocupa de fazer o
derreferenciamento e a serialização de todos os componentes de um objeto, de dentro para fora.

E por que serializamos para strings? Porque são o tipo utilizado, por exemplo, para ser escrito em arquivo ou enviado por um socket de rede. O
que faremos é tratá-las com a classe string de C++ (que não vai interpretar o caracter nulo ('\0'), ou seja, o caracter com código ASCII igual a
zero como terminador da string, como acontece com o tipo char* de C padrão. Quando precisarmos usar o ponteiro para char, faremos uma
conversão segura, sem perda de dados.

Vamos fazer um pequeno exemplo de leitura de dados binários em um arquivo. Vamos usar a classe fstream, para abrirmos o arquivo ao
mesmo tempo para leitura e para escrita. Neste caso, se o arquivo não existir, um erro ocorre e o arquivo não será criado, sendo necessário
reabri-lo sem a flag de leitura. Da mesma forma, o arquivo não tem seu conteúdo excluído após a abertura, mantendo o conteúdo original.
Vamos indicar também a abertura em formato binário, para evitar que caracteres especiais sejam traduzidos e, consequentemente, os dados
corrompidos. Isso acontece, por exemplo, com a quebra de linha: em Windows, a quebra de linha é representada por dois caracteres ('\r\n',
retorno de carro e nova linha). Em Linux, é exigido apenas um caracter ('\n', quebra de linha), enquanto no MacOS, até a versão 9, também era
usado um único caracter ('\r', retorno de carro). A partir do MacOS X, por ser uma variante do Unix, usa-se o mesmo fim de linha que o Linux
('\n'). Quando abrimos um arquivo com qualquer classe da hierarquia fstream, sem especificar o modo binário, a classe vai traduzir as
mudanças de linha para o formato correto do sistema operacional, ou seja, em Windows, será expandido para 2 caracteres, no Linux, para
apenas um. Isso é útil para que um editor de texto puro não mostre caracteres estranhos quando abrimos um arquivo vindo do Windows em
Linux, nem as quebras de linha desapareçam quando abrimos um arquivo criado em Linux no Windows. No entanto, quando nossa intenção é
usar cada byte para representar um valor, essa expansão/retração pode corromper os dados. Lembre-se que cada caracter tem um conjunto de
8 bits que correspondem a um valor na tabela ASCII e podemos usar como um inteiro. Os caracteres abaixo da posição 32 são chamados de
caracteres não imprimíveis (não podem ser exibidos na tela corretamente) e são usados para controle, como tabulação ('\t'), quebra de linha
('\n'), backspace ('\b'), etc. Por isso, a abertura de arquivos de dados não ASCII é feita em binário, para evitar essas conversões. Um exemplo
simples: um programa compilado é uma sequência de bytes que representam as instruções, os parâmetros para essas instruções (operandos),
etc. Se abrirmos um programa executável e permitirmos as expansões de quebra de linha, esse arquivo estará corrompido e, no melhor cenário,
abortará quando alguma instrução não puder mais ser reconhecida, ou até mesmo nem será carregado.

Vamos ao nosso código para ler 10 números inteiros (de 65 a 74) em um arquivo de dados, armazenando os 4 bytes que representam cada
valor, em vez de usarmos os dígitos da tabela ASCII. Esta escolha foi intencional, como poderemos ver mais à frente.

3 of 12 20/10/2022 10:45
Armazenamento em memória secundária https://ead5.ifmg.edu.br/bambui/mod/book/tool/print/inde...

1 #include <iostream>
2 #include <fstream>
3 using namespace std;
4
5 int main() {
6 int i, a = 65, pos = 0;
7
8 fstream file("data.dat", ios::in | ios::out | ios::ate | ios::binary);
9
10 if (!file.is_open()) { // first run will create the file
11 file.open("data.dat", ios::out);
12 file.close();
13 file.open("data.dat", ios::in | ios::out | ios::ate | ios::binary);
14 }
15
16 if (file.is_open()) {
17 // fstream::seekp moves the writing head to the designated byte
18 // fstream::write writes the specified number of bytes (chars) to file
19 for (i = 0; i < 10 && file.good(); i++) {
20 pos = i * 4;
21 file.seekp(pos);
22 file.write(reinterpret_cast<char*>(&a), sizeof(int));
23 a++;
24 }
25
26 // setting an arbitrary value to a to show the reading
27 a = 1000;
28 cout << "a: " << a << endl;
29 pos = 0;
30
31 // fstream::seekg moves the reading head to the designated byte
32 // fstream::read read the specified number of bytes (char) from file
33 for (i = 0; i < 10 && file.good(); i++) {
34 pos = i * 4;
35 file.seekg(pos);
36 file.read(reinterpret_cast<char*>(&a), sizeof(int));
37 cout << "Read data from pos " << i << ": " << a << endl;
38 }
39 file.close();
40 } else {
41 cerr << "Couldn't open file!" << endl;
42 }
43 return 0;
44 }

No código, podemos observar que o arquivo é aberto para leitura (ios::in), escrita (ios::out), modo binário (ios::binary) e para posicionar a cabeça
de leitura/escrita no final do arquivo (ios::ate). Se o arquivo não existir, tentamos reabrí-lo somente com a flag de escrita, para que seja criado, o
fechamos e o reabrimos com as flags originais para que possamos trabalhar em seu conteúdo (linhas 8-14).

Em seguida, vamos realizar a operação de escrita dos dados no arquivo. Cabe relembrar que gravaremos blocos de tamanho fixo, de 4 bytes de
comprimento. Desta forma, faremos com que a cabeça de escrita se movimente sempre em múltiplos de quatro. Fazemos o posicionamento da
cabeça de escrita com o método fstream::seekp (de put), enquanto a cabeça de leitura, mais à frente, é movimentada com o método
fstream::seekg (de get). É muito útil a abstração com posições de leitura e escrita independentes, cabendo ao programador manuseá-las
corretamente. Podemos consultar onde estão as cabeças com os métodos fstream::tellp e fstream::tellg, como contrapartes dos métodos acima.

Escrevemos no arquivo um bloco de caracteres, que correspondem à representação dos dados na memória. O método fstream::write recebe um
ponteiro para char com os dados e o tamanho desse bloco. Como nossos dados são números inteiros, precisamos fazer com que o compilador
enxergue-os como caracteres e a forma mais segura é usando a função reinterpret_cast<char*>(), como mostrado na linha 22. Esse método
de typecast é mais seguro e a função fstream::write vai enxergar aquela posição de memória, temporariamente, como 4 caracteres a serem
escritos no disco e não um número inteiro.

Antes de fazer a leitura, alteramos o valor da variável a para um número qualquer, para mostrar que não terá nenhuma relação com os valores
lidos. Faremos o mesmo procedimento para a leitura dos dados no arquivo, vamos posicionar no primeiro byte e ler blocos de 4 em 4 bytes. A
função fstream::read lê do arquivo um número de caracteres (especificado no segundo parâmetro) e o copia para o buffer indicado no primeiro
parâmetro. Como estamos lendo um inteiro, vamos fazer com que o compilador ignore o tipo int da posição de memória, enxergando aquela
região como um ponteiro para char, usando novamente a função reinterpret_cast. Após a escrita, aquela área da memória será vista e
manipulada, novamente, como um inteiro, como se nada anormal houvesse ocorrido ao definir seus valores.

Como dissemos acima, usamos os inteiros de 65 a 74 intencionalmente. Vamos analisar o conteúdo do arquivo, capturando seu valor com um
programa que converte os bytes para hexadecimal:

4 of 12 20/10/2022 10:45
Armazenamento em memória secundária https://ead5.ifmg.edu.br/bambui/mod/book/tool/print/inde...

00000000 41 00 00 00 42 00 00 00 43 00 00 00 44 00 00 00 A...B...C...D...
00000010 45 00 00 00 46 00 00 00 47 00 00 00 48 00 00 00 E...F...G...H...
00000020 49 00 00 00 4A 00 00 00 I...J...

Podemos ver que cada número inteiro é representado por 4 números em hexadecimal. O byte menos significativo está à esquerda, seguido
pelos três mais significativos, em ordem. Podemos ver, à direita, a representação ASCII do conteúdo, relembrando que todos os caracteres
abaixo do valor decimal 32 (0x20 em hexadecimal) são substituídos por um ".", porque não podem ser representados na tela. O valor 0x41
corresponde a 65 em decimal e, naquela posição, podemos observar a letra 'A' no conteúdo do arquivo, pois aquele byte foi interpretado como
um caracter. A saída do programa deve mostrar os valores sem qualquer tipo de alteração, nem resquícios dos caracteres no disco. Se
substituirmos o valor inicial por qualquer valor que preencha os demais bytes, poderemos visualizar diferentes representações.

5 of 12 20/10/2022 10:45
Armazenamento em memória secundária https://ead5.ifmg.edu.br/bambui/mod/book/tool/print/inde...

2. Serialização de Objetos

Serializar um objeto é, portanto, gerar uma string que contenha todos os dados dos atributos daquele objeto. Existem vários tipos de
serialização, dependendo da forma como desejamos transmitir a informação.

Para transmissão por rede e armazenamento em disco, uma forma bastante comum é a serialização binária, como vimos na seção anterior, que
gera representações mais compactas das informações, portanto, mais eficientes para transmissão e armazenamento.

Outras formas de representação também são utilizadas, em formato ASCII, para leitura simultânea de forma automatizada e humana. Um
formato bastante conhecido é o arquivo CSV (comma separated values), em que cada atributo é colocado em uma coluna, separando os valores
por vírgula, e podem ser facilmente importados em uma planilha eletrônica. O formato CSV é útil quando não temos, no objeto, atributos
multivalorados, como vetores e matrizes, que poderiam dificultar o estabelecimento de um conjunto de colunas fixo. Para estruturas mais
complexas, é bastante comum usarmos a representação em XML (eXtended Markup Language) e, mais recentemente, JSON (JavaScript Object
Notation), especialmente na web. Muitas aplicações web utilizam um dos dois formatos para obter dados do servidor e atualizar a página,
havendo um aumento do uso de JSON por gerar uma forma mais compacta que o XML.

Vamos considerar uma classe student, bastante simples, que possui dois atributos, um inteiro com o registro acadêmico (ID) e o nome do aluno.
Como buscamos representações de tamanho fixo, vamos estabelecer que o nome terá, no máximo, 128 caracteres de comprimento, pois isso
será utilizado na hora de gerar versão serializada em formato binário.

1 /*
2 * Filename: student.h
3 */
4 #ifndef STUDENT_H
5 #define STUDENT_H
6 #include <string>
7
8 using namespace std;
9
10 const int MAX_NAME_LENGTH = 128;
11
12 class student {
13 public:
14 student();
15 student(int id, string name);
16 virtual ~student() {}
17 int getID() const { return id; }
18 string getName() const { return name; }
19 void setID(int id);
20 void setName(string n);
21 string toString();
22 void fromString(string repr);
23 unsigned int size();
24 protected:
25 int id;
26 string name;
27 };
28
29 #endif /* STUDENT_H */

Podemos observar que a classe provê dois métodos: student::fromString e student::toString, que serão utilizados para a reconstruir o objeto
(desserialização) e para a serialização, respectivamente. Vejamos a implementação da classe.

6 of 12 20/10/2022 10:45
Armazenamento em memória secundária https://ead5.ifmg.edu.br/bambui/mod/book/tool/print/inde...

1 /*
2 * Filename: student.cpp
3 */
4 #include "student.h"
5
6 student::student() {
7 id = 0;
8 name.resize(MAX_NAME_LENGTH);
9 name = "";
10 }
11
12 student::student(int id, string name) {
13 this->id = id;
14 this->name.resize(MAX_NAME_LENGTH);
15 this->name = name.substr(0, MAX_NAME_LENGTH);
16 }
17
18 void student::setID(int i) {
19 id = i;
20 }
21
22 void student::setName(string n) {
23 this->name.resize(MAX_NAME_LENGTH);
24 this->name = name.substr(0, MAX_NAME_LENGTH);
25 }
26
27 string student::toString() {
28 string repr;
29 repr.resize(this->size());
30 repr.insert(0, this->size(), '\0'); // fill the string with '\0'
31 repr.replace(0, sizeof(int), reinterpret_cast<char*>(&id), sizeof(int));
32 repr.replace(sizeof(int), MAX_NAME_LENGTH, name);
33 return repr;
34 }
35
36 void student::fromString(string repr) {
37 repr.copy(reinterpret_cast<char*>(&id), sizeof(int), 0);
38 name = repr.substr(sizeof(int), MAX_NAME_LENGTH);
39 }
40
41 unsigned int student::size() {
42 return sizeof(int) + MAX_NAME_LENGTH;
43 }
44

Não vamos nos deter em conceitos que já foram trabalhados antes, portanto, vamos nos concentrar na serialização e na desserialização do
objeto. Para este propósito, declaramos uma constante, que será usada para dimencionar o atributo name em todas as operações que o
manipulam, para garantir que o registro sempre terá um tamanho fixo. Podemos ver nos construtores esse dimensionamento, inclusive usando o
método string::substr para extrair, do parâmetro recebido, apenas o tamanho máximo permitido, evitando que alguém passe para o objeto uma
string maior que o necessário.

O método student::toString é de especial interesse. Nele, podemos perceber que criamos uma string com o comprimento total de todos os
atributos do objeto, que é calculado pelo método student::size. É importante ter o tamanho do objeto calculado dessa forma, para evitar que o
compilador tente estimar o tamanho, especialmente quando há ponteiros nos atributos. Em seguida, preenchemos essa string com o caracter
nulo ('\0'), para garantir que não haja nenhum resíduo armazenado na memória que polua os dados (os espaços não preenchidos), que
poderiam ser interpretados incorretamente ao recuperar o objeto. Em seguida, chamamos o método string::replace para a representação,
passado os atributos do objeto como parâmetro e seu tamanho, fazendo o typecast quando necessário. Para cada tipo de dados, há uma
chamada específica desse método para garantir que o resultado seja correto. Finalizados esses atributos, podemos retornar a representação
completa para quem chamou esse método do objeto.

A desserialização é feita pelo método student::fromString. Para o atributo do tipo inteiro, usamos o método string::copy para copiar do objeto da
representação para o atributo. Notem o uso do reinterpret_cast para que o compilador, temporariamente, veja o espaço do número inteiro como
um buffer de caracteres, para realização da cópia e o uso do tamanho, para garantir que todos os bytes sejam copiados, sem parar no primeiro
caracter '\0'. Em seguida, usamos o método string::substr para copiar da representação a porção que armazena a string com o nome do
estudante.

Vamos fazer um pequeno programa que instancie um objeto dessa classe, salve o conteúdo no disco em formato binário e recupere-o para
instanciar um novo objeto. Temos que garantir que ambos os objetos terão o mesmo conteúdo, senão o propósito de serializar e armazenar em
disco será frustrado.

7 of 12 20/10/2022 10:45
Armazenamento em memória secundária https://ead5.ifmg.edu.br/bambui/mod/book/tool/print/inde...

1 /*
2 * Filename: main.cpp
3 */
4 #include <iostream>
5 #include <string>
6 #include <fstream>
7 #include "student.h"
8
9 using namespace std;
10
11 int main() {
12 student s(123456, "John Doe"), s2;
13 string serializedStudent = s.toString();
14 char *aux = new char[s.size()];
15
16 fstream file("student.dat", ios::in | ios::out | ios::ate | ios::binary);
17
18 if (!file.is_open()) {
19 file.open("student.dat", ios::out);
20 file.close();
21 file.open("student.dat", ios::in | ios::out | ios::ate | ios::binary);
22 }
23
24 file.seekp(0, ios::beg);
25 file.write(serializedStudent.c_str(), s.size());
26
27 file.seekg(0, ios::beg);
28 file.read(aux, s.size());
29
30 serializedStudent = string(aux, s2.size());
31 s2.fromString(serializedStudent);
32
33 cout << "======================================================" << endl;
34 cout << "Original Student: " << endl;
35 cout << "ID: " << s.getID() << endl;
36 cout << "Name: " << s.getName() << endl;
37 cout << "======================================================" << endl;
38 cout << "Student read from file:" << endl;
39 cout << "ID: " << s2.getID() << endl;
40 cout << "Name: " << s2.getName() << endl;
41 cout << "======================================================" << endl;
42
43 if (aux) {
44 delete[] aux;
45 aux = nullptr;
46 }
47
48 return 0;
49 }

Começamos instanciando dois objetos, s e s2, que vão armazenar os dados do estudante criado em memória e do recuperado do disco,
respectivamente. Abrimos o arquivo para leitura e escrita em modo binário. Caso o arquivo não exista, o trecho das linhas 18-22 o abrirá
somente para escrita, para criá-lo no disco, fechando-o e reabrindo-o com as flags originais para garantir o funcionamento desejado do
programa.

As linhas 24-25 se encarregam da gravação do objeto serializado. Primeiro, posicionamos a cabeça de escrita no primeiro byte do arquivo, a
partir do início (ios::beg). Em seguida, chamamos o método fstream::write, passando o retorno do método student::toString e o tamanho do
objeto que será escrito em disco. Notem que, como o método fstream::write espera como parâmetro um pointeiro para um buffer de char, nós
utilizamos o método string::c_str para entregar essa representação.

As linhas 27-28 se ocupam da leitura do registro. Posicionamos a cabeça de leitura no primeiro byte a partir do início do arquivo e procedemos
com a leitura para um buffer de char, uma vez que não é possível usar a classe string nesse momento. Devemos manter o uso de char*  o
mínimo possível, dentro do escopo local e tomando as precauções para evitar memory leaking (linhas 43-46).

Nas linhas 30-31, estamos reconstruindo o objeto. Notem o uso de um objeto "anônimo"  na linha 30, sendo atribuído à string com a
representação, para que possamos passar o tamanho da string a ser criada. É importante para que o compilador não pare no primeiro '\0'
encontrado, corrompendo a interpretação dos dados. Em seguida, passamos essa string para o método student::fromString, para que o objeto
seja preenchido com os dados obtidos em disco. Em seguida, imprimimos ambos os objetos, para comprovar que a operação foi bem sucedida.

Podemos verificar o conteúdo do arquivo criado, lançando mão, mais uma vez, da representação em hexadecimal de seu conteúdo:

8 of 12 20/10/2022 10:45
Armazenamento em memória secundária https://ead5.ifmg.edu.br/bambui/mod/book/tool/print/inde...

00000000 40 E2 01 00 4A 6F 68 6E 20 44 6F 65 00 00 00 00 @...John Doe....


00000010 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ................
00000020 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ................
00000030 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ................
00000040 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ................
00000050 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ................
00000060 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ................
00000070 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ................
00000080 00 00 00 00 ....

Notem que na representação, temos os 4 bytes iniciais com o número de RA do estudante (no caso, o primeiro byte tem um valor hexadecimal
0x40, que está acima do número decimal 32 e corresponde ao caracter '@', como mostrado na tela. Os demais estão abaixo do valor decimal 32
e foram representados como um '.' à direita. Após os 4 bytes iniciais, temos os 128 bytes que correspondem ao nome do estudante, com todo o
espaço não utilizado preenchido com '\0', como previsto.

Para facilitar a serialização de objetos, vamos adotar a seguinte classe abstrata serializable, que será herdada por todos os objetos que
precisarmos armazenar em disco.

1 /*
2 * Filename: serializable.h
3 */
4 #ifndef SERIALIZABLE_H
5 #define SERIALIZABLE_H
6 #include <string>
7
8 using namespace std;
9
10 class serializable {
11 public:
12 serializable() {}
13 virtual ~serializable() {}
14 virtual string toString() = 0;
15 virtual void fromString(string repr) = 0;
16 virtual string toXML() = 0;
17 virtual void fromXML(string repr) = 0;
18 virtual string toCSV() = 0;
19 virtual void fromCSV(string repr) = 0;
20 virtual string toJSON() = 0;
21 virtual void fromJSON(string repr) = 0;
22 virtual unsigned long long int size() const = 0;
23 };
24
25 #endif // SERIALIZABLE_H

A classe define funções virtuais puras que devem ser sobrepostas nas classes filhas, para garantir a geração das representações de acordo
com a estrutura do objeto. Foram previstas as 4 formas principais de serialização, mesmo que o objeto não faça uso delas, podendo sobrepô-las
com corpo vazio (sem implementação). A diferença em relação ao exemplo da classe student é que, prevendo objetos maiores, utilizaremos o
tipo unsigned long long int, de 64 bits de comprimento.

Na próxima seção, veremos como estruturar um arquivo de registros de tamanho fixo, que nos permitirá armazenar os objetos serializados.

9 of 12 20/10/2022 10:45
Armazenamento em memória secundária https://ead5.ifmg.edu.br/bambui/mod/book/tool/print/inde...

3. Arquivo de Registros

Vimos até o momento como ler e escrever dados em arquivos, inclusive no formato binário, que permite uma representação mais compacta de
alguns tipos de dados, embora em outros, como strings, pela restrição de tamanho fixo, poderá haver um desperdício de espaço não alocado.
Entretanto, as técnicas para uso de registros de tamanho variável são muito complexas e vamos concentrar nossos esforços no uso de registros
de tamanho fixo.

Já vimos também como serializar um objeto e propomos uma classe abstrata que nos guie nesse procedimento. Estamos prontos para o
próximo passo, que é a criação de arquivos tipados, também conhecidos como arquivos de registros, em formato binário. Arquivos tipados
possuem esse nome porque eles possuem uma estrutura, um formato de objetos que poderá armazenar. Nossa implementação focará na
capacidade de escrever objetos de uma única classe nesses arquivos.

Basicamente, um arquivo tipado é composto por um cabeçalho e os registros. O cabeçalho é parte fundamental da estrutura e é o único
elemento que ficará o tempo todo na memória: é lido quando abrimos o arquivo e reescrito todas as vezes que sofrer uma alteração. No
cabeçalho é comum colocarmos campos que nos auxiliem no gerenciamento e identificação do arquivo, como o tipo (alguns caracteres que
podemos escolher, arbitrariamente, para definir qual o conteúdo está armazenado, por exemplo, qual classe) e versão. Não podemos confundir
o campo tipo com a extensão do arquivo. A extensão tem uso (para alguns sistemas operacionais) para associar ao tipo de programa que fará a
abertura do arquivo, mas ela é de escolha arbitrária do usuário. A informação dentro do cabeçalho nos permite saber se estamos aptos a ler
aquele formato de arquivo. A versão também é útil, pois permite selecionar qual classe foi utilizada. Por exemplo, se criarmos um arquivo da
classe student, que vimos na seção anterior, podemos atribuí-lo à versão 1. Se mudarmos a classe, por exemplo, acrescentando novos
atributos, essa versão atualizada da classe não conseguirá desserializar corretamente o conteúdo do arquivo, nem podemos escrever objetos
criados por ela nesse arquivo. Podemos fazer um programa que leia os objetos da versão 1, altere para o formato da nova classe e salve em um
arquivo temporário, copiando depois esses registros atualizados de volta para o arquivo e mudando a versão para 2.

Outros elementos fundamentais nos cabeçalhos são os apontadores para os registros válidos e para os excluídos. Primeiro, vamos definir:
apontador aqui não é usado no sentido de ponteiro de memória, pois não vamos guardar endereços de memória. Eles são similares, mas
guardam o número do registro no arquivo. Como todos os registros possuem tamanho fixo, podemos converter o número de registro para a
posição onde os dados se iniciam com facilidade.

Mas por que usar duas listas, com registros válidos e excluídos? Vamos começar pelos excluídos: não fazemos a exclusão do conteúdo em
disco, porque isso consumiria muitos acessos. Remover fisicamente um registro significaria copiar todos os registros para um arquivo
temporário, truncar o arquivo original e copiá-los novamente, para que nenhum espaço fique desperdiçado. Em vez disso, vamos marcar o
registro como deletado e colocar aquela posição em uma lista para que possa ser reciclado, ou seja, reutilizado com novos dados. Assim,
evitamos acessos excessivos e que o arquivo cresça também, descontroladamente. A lista de registros válidos nos permite acessar somente os
registros que possuem realmente algum significado útil, eliminando acessos desnecessários aos que foram excluídos. Todas as inserções e
remoções são feitas na cabeça das listas, para reduzir o número de acessos ao disco.

Figura 1 - Visão física do arquivo tipado

Fonte: Autor (2021)

Na Figura 1, podemos observar uma visão da estrutura física do arquivo. Temos o cabeçalho (H) e 9 registros, todos com o mesmo tamanho.
Em vermelho, os registros 2, 3, 6 e 9 representam registros apagados, que poderão ser utilizados para guardar novos dados sem aumentar o
tamanho do arquivo. Os registros 1, 4, 5, 7 e 9 são registros atualmente válidos, com informações gravadas pelo usuário. Podemos imaginar o
arquivo sendo acessado como um vetor, cada registro representado por seu número, iniciando em 1. Não mostramos os apontadores, pois
ficariam muito confusos nessa representação. Podemos percebemos na visão lógica do arquivo, representada na Figura 2.

Figura 2 - Visão lógica do arquivo tipado

Fonte: Autor (2021)

Na Figura 2, podemos observar que o arquivo tipado pode ser visto como duas listas que iniciam no cabeçalho. A ordem dos registros é
apresentada fora de ordem, porque depende da sequência em que foram criados/excluídos/reciclados. Não há qualquer presunção de ordem na
lista, nem do registro físico, nem do conteúdo que armazena.

Para inserir um novo registro, verificamos se o cabeçalho tem algum excluído disponível (usamos o valor 0 para representar o fim da lista, ou
seja, ausência de um elemento). O cabeçalho da Figura 2 indica que o registro 8 está disponível para ser reaproveitado, então alteramos o seu
conteúdo, fazemos o cabeçalho apontar para o registro 3 (sucessor) como nova cabeça dos registros excluídos e inserimos o registro 8 na
cabeça dos registros válidos, alterando os ponteiros do cabeçalho e do registro, e a flag que indica que ele foi excluído para válido. As listas
sempre crescem na cabeça. Quando a lista de excluídos ficar vazia, não teremos novo espaço para reciclar, então faremos o arquivo crescer em

10 of 12 20/10/2022 10:45
Armazenamento em memória secundária https://ead5.ifmg.edu.br/bambui/mod/book/tool/print/inde...

tamanho: posicionamos a cabeça de escrita no final do arquivo, obtemos o número do byte daquela posição e usamos para converter para um
número de registro (que no exemplo das Figuras 1 e 2, seria o número 10. Fazemos o apontador do próximo apontar para a cabeça dos válidos,
o cabeçalho apontará o registro 10 como nova cabeça da lista e gravamos esse registro no final do arquivo, fazendo-o crescer, efetivamente, o
tamanho de mais um registro.

Para excluir um registro, o processo também é simples: o usuário vai informar o número de registro que quer excluir, por exemplo, o registro 5
da Figura 2. A partir do cabeçalho, verificamos se quem queremos remover é a cabeça da lista. Como não é, buscamos a cabeça no arquivo e
verificamos o próximo, para definir se é o que desejamos excluir. No caso, também não é o registro desejado, então buscamos o próximo (1) e
verificamos para quem ele aponta. O registro 1 indica como o sucessor o registro 5, que é quem desejamos excluir, então o mantemos em
memória, trazemos o registro 5 do disco, marcamos como excluído na flag interna, fazemos a atualização dos apontadores, para que o registro
1 aponte o registro 4 como seu sucessor (era o que seguia 5 na lista), e gravamos o registro 1 em disco. Agora, fazemos o registro 5 apontar
para a antiga cabeça dos excluídos (8), atualizamos o cabeçalho para que a cabeça seja o registro 5 e gravamos o cabeçalho e o registro 5 em
disco.

Os acessos aos registros não precisam ser feitos somente por meio das listas, especialmente para a recuperação das informações. Quando
temos em mãos o número do registro, podemos buscá-lo diretamente no disco, sem percorrer a lista. Esse é o motivo para mantermos uma flag
em cada registro para indicar que ele foi excluído, para evitar manipular dados que não sejam mais válidos para a aplicação. Portanto, o acesso
aleatório é permitido e facilitado pelo tamanho fixo dos registros.

O processo de busca de uma informação é feito seguindo a lista de registros válidos. Cada registro tem um atributo com a informação
armazenada (lembre-se dos nós de listas em AED II), a partir do cabeçalho percorremos a lista até encontrar o dado que desejamos ou
chegarmos ao final da lista, indicando que ele não existe naquele arquivo.

As classes da hierarquia fstream não fazem acesso direto a registro, elas leem um bloco de dados a partir de um byte especificado como ponto
de partida. Para isso, vamos implementar uma classe que representa nosso arquivo tipado, usando templates e fazendo os cálculos
necessários. Dado um número de registro (n), é fácil descobrir a partir de qual byte ele se encontra (considere |H| e |R| o tamanho em bytes do
cabeçalho e do registro, respectivamente):

pos = |H| + (n - 1) * |R|

O cálculo do número do registro, sabendo a posição (em bytes) que ele está no disco (pos), como no caso do crescimento do arquivo no final,
também é facilmente calculada:

n = (pos - |H|)/|R| + 1

As classes que representam o cabeçalho, o registro e o conteúdo armazenado nos registros devem todas serem classes filhas da classe
abstrata serializable que apresentamos na seção anterior, para que os objetos sejam corretamente serializados, pela chamada dos métodos
correspondentes. Em outras palavras, nã poderemos fazer arquivos de registros de tipos básicos (int, float, double, char, long, etc.) ou de
classes originais da linguagem (string, vector, queue, etc.). Para isso, temos duas opções: encapsulá-los em objetos serializáveis ou alterar a
classe para detectá-los e fazer a serialização, o que seria muito trabalhoso. Por isso, a assinatura da classe typedFile inclui uma asserção (um
recurso de programação que garante que uma condição será satisfeita para que o programa seja compilado) que exige que o template seja
sempre descendente de serializable.

No material de apoio, vamos disponibilizar os arquivos de cabeçalho do arquivo tipado. Como são classes usando templates, é importante
lembrar que a implementação deve estar no mesmo arquivo que a assinatura da classe. No entanto, a versão disponibilizada tem toda a
implementação removida, para que você faça a implementação como exercício e preparação para o trabalho da disciplina.

11 of 12 20/10/2022 10:45
Armazenamento em memória secundária https://ead5.ifmg.edu.br/bambui/mod/book/tool/print/inde...

4. Bibliografia Complementar

C++ Reference. [s.l.]: CPPReference.com, 2017. Disponível em: https://en.cppreference.com/w/cpp. Acesso em 23 mar. 2021.

SERIALIZATION. In: Standard C++ Foundation. [s.l.]: Standard C++ Foundation, 2021. Disponível em: https://isocpp.org/wiki/faq/serialization.
Acesso em: 04 abr. 2021.

THE C++ Resources Network. [s.l.]: cplusplus.com, 2020. Disponível em: http://www.cplusplus.com/. Acesso em 23 mar. 2021.

12 of 12 20/10/2022 10:45

Você também pode gostar