+---------------------------+
|.-------------------------.|
|| kee_reel@blog:~/ru $ cd ||
|| ссылки контакты         ||
|| c c++ linux opengl sql  ||
|| python сети             ||
||                         ||
|.-------------------------.|
+-::---------------------::-+
.---------------------------.
 // /oooooooooooooooooooooo\\ \\ 
 // /oooooooooooooooooooooooo\\ \\ 
//-------------------------------\\
\\-------------------------------//


C. Файлы

Время чтения: 11 минут

Файл (file) – это абстракция, поддерживаемая операционной системой, позволяющая работать с данными, записанными на внешних носителях (магнитная запись на жёстком диске, флеш память на SSD или флешке).

Таким образом, работая с файлами, мы работаем с операционной системой, которая, в свою очередь, работает с драйверами физического устройства.

Дескриптор файла

Я очень упрощаю что там происходит – если хочешь понять как это на самом деле устроено, можешь почитать Э. Танненбаум “Операционные системы”, глава “Файловые системы”.

На картинке, между файлом и драйвером я указал некий дескриптор.

Дескриптор – идентификатор, предоставляемый операционной системой, при указании которого можно производить операции чтения/записи в определённый файл.

А как производить эти операции чтения/записи? Сначала надо открыть файл.

Описание ВСЕХ функций, которые я буду тут использовать, можно найти здесь или в любой другой части интернета.

Открываем файл

fopen – функция, обращающаяся к операционной системе, чтобы получить дескриптор файла с указанными именем. Кроме имени файла, необходимо указать режимом работы с ним.

FILE *f = fopen("some-file.txt", "r");

Имя файла

Файл всегда ищется в той папке, из которой была запущена программа. Если необходимо указать файл, находящийся в другой папке, то нужно указать его с помощью абсолютного или относительного пути.

  • Абсолютный (полный) путь: “D:/some-folder/some-file.txt” – указание диска и всех папок

  • Относительный путь: “../some-folder/some-file.txt” – используя символ “..” можно подняться на уровень выше (выйти из текущей папки). Обычно используется если необходимый файл находится на том же диске в соседней/родительской папке. Символов “..” можно указывать столько, сколько необходимо: “../../../../some-file.txt”

Знание про абсолютный и относительный пути пригодится тебе в любом языке и любой операционной системе – запомни это.

Режим работы с файлом

Всего есть 3 основных режима:

  • “r” – чтение. Открываем существующий файл и вычитываем оттуда данные.
  • “w” – запись. Создаём новый файл (если такой уже есть, то перезаписываем его) и записываем туда данные.
  • “a” – запись в конец. Открываем существующий файл (если файла ещё нет, то создаём его) и записываем данные в конец файла.

К этим режимам можно дописать “+”, чтобы разблокировать возможность чтения для “w” и “a”, и возможность записи для “r”.

Вот табличка, в которой я собрал все комбинации

Режим Чтение Запись Создать новый файл (если нет) Очистить содержимое
“r” + - - -
“w” - + + +
“a” - + + -
“r+” + + - -
“w+” + + + +
“a+” + + + -

Их не нужно запоминать – обычно все пользуются:

  • “r” при чтении
  • “w” при записи
  • “r+” при чтении/записи

Но если в какой-то ситуации тебе понадобится что-то другое – не стесняйся экспериментировать.

Закрываем файл

fclose – функция принимает дескриптор открытого файла, и закрывает его, записывая все данные из буффера.

fclose(f);

Стоп, что за буффер?

FILE – это не дескриптор

Я был не до конца честен, когда говорил, что FILE – это дескриптор файла. На самом деле, FILE это обёртка над настоящим дескриптором.

Чтобы получить настоящий дескриптор, в зависимости от операционной системы, надо использовать функции:

  • Linux: open из “fcntl.h”
  • Windows: _open из “io.h”

Обёртка над дескриптором FILE поддерживает:

  • Буфферизацию – то есть не сразу записывает все данные в файл, а ждёт пока накопится достаточное количество данных во временном буффере (который хранится в оперативной памяти)
  • Отслеживание позиции – благодаря этому мы можем удобно узнать дошли ли мы до конца файла при чтении
  • Обработку ошибок – можно узнать произошла ли ошибка при выполнении чтения/записи с помощью функции ferror

Подробнее про разницу между FILE и дескриптором можно прочитать тут.

Закрываем FILE

Окей, с закрытием FILE разобрались – при закрытии в файл записывается буффер.

Когда это делать? Когда закончили работать с файлом.

Что будет если это не сделать?

Если программа завершается как обычно, то ничего плохого не произойдёт – перед закрытием наша программа запишет буффер в файл и закроет дескриптор файла.

В случае, если программа упадёт до закрытия FILE – в файл ничего не запишется, так как буффер не успел записаться в файл. Однако дескриптор закроется, но не программой, а операционной системой (потому что получившая его программа умерла). Вот пример такой ситуации:

#include <stdio.h>
int main()
{
	char *text = "Some text to fill the file\nAnd some more text";
	FILE *f = fopen("some-file.txt", "w");
	// Записываем текст в FILE
	fprintf(f, "%s", text);
	char *x = NULL;
	// Обращаемся к нулевому указателю (падаем)
	*x = 1;
	// Не доходим до закрытия файла, и файл остаётся пустым т.к. мы его открыли через "w"
	fclose(f);
	return 0;
}

Если бы мы закрыли файл до падения, то всё было бы хорошо в этом случае. В общем – всегда следи за закрытием.

Можно записать буффер в файл до закрытия с помощью функции fflush. Если я вызвал бы её до падения в программе выше, то данные бы записались в файл.

Чтение/запись в файл

Для записи в файл в библиотеке “stdio.h” есть ряд функций:

  • fprintf – записываем текст в файл, указывая строку форматирования (как в обычном printf)
  • fputc – записать один char в файл
  • fputs – записать строку (всё до ‘\0’) в файл
  • fwrite – записать массив значений (с указанным размером элемента и количеством элементов) в файл

Для записи там есть альтер-эго таких же функции:

  • fscanf – считываем текст из файла, указывая строку форматирования (как в обычном scanf)
  • fgetc – считать один char из файла
  • fgets – считать строку из файла. Считывается всё до конца файла или символа переноса строки ‘\n’. Символ переноса строки включается в результирующую строку
  • fread – считать массив значений (с указанным размером элемента и количеством элементов) из файла

Давайте я напишу программу, которая “шифрует” все символы в файле, а потом “дешифрует” их.

“Шифрованием” у меня будет смещение кода символа на 1, а “дешифрацией” – смещение кода символа на -1.

При этом, будет необходимое условие – в начале файла должен стоять символ ‘0’.

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
int main()
{
	FILE *f = fopen("some-file.txt", "r+");
	if(f == NULL)
	{
		printf("Can't open file");
		return 0;
	}
	// Если первый символ 0 - файл дешифрован, а если 1 - зашифрован
	char is_encrypted = fgetc(f) == '1';
	// Возвращаем указатель позиции файла назад на первый символ
	// fgetc и fputc смещают указатель позиции на 1 символ
	fseek(f, -1, SEEK_CUR);
	char c = fgetc(f);
	// Файл закончился?
	while(!feof(f))
	{
		// Смещаемся назад из-за fgetc
		fseek(f, -1, SEEK_CUR);
		// Если зашифрован - расшифровываем, расшифрован - зашифровывааем
		c += is_encrypted ? -1 : 1;
		// Перезаписываем символ, который перед этим прочитали
		// Смещаем указатель позиции на один вперёд
		fputc(c, f);
		// Вызываем fseek, чтобы можно было переключиться с записи на чтение
		fseek(f, 0, SEEK_CUR);
		// Читаем следующий символ, смещаем указатель позиции вперёд
		c = fgetc(f);
	}
	fclose(f);
	return 0;
}

Такой файл:

0Some text to fill the file
And some more textþ

Программа изменяет так:

1Tpnf!ufyu!up!gjmm!uif!gjmfBoe!tpnf!npsf!ufyuÿ

При повторном запуске программы, файл возвращает исходный вид.

Если что, это “шифрование” сможет защитить только от человека, который не является IT-специалистом. В настоящем шифровании каждый символ шифруется с использованием сложных криптографических алгоритмов и длинного уникального секретного ключа (один из них – алгоритм RSA).

Так, в этой программе я использовал какие-то функции feof и fseek – что это?

Навигация по файлу

Благодаря обёртке FILE, у нас есть возможность перемещаться по файлу, смещая указатель позиции файла.

Изначальная позиция

У каждого FILE есть свой указатель позиции файла, изначальная позиция которого зависит от режима работы с файлом, который мы использовали:

  • “r” и “w” – начало файла
  • “a” – конец файла

После этого, при чтении/записи указатель позиции будет смещаться вперёд на:

  • 1 символ для fgetc и fputc
  • Количество символов строки для fgets и fputs
  • Количество символов форматированного ввода/вывода для fscanf и fprintf
  • (Размер_элемента * количество_элементов) символов для fread и fwrite

Сдвигаем позицию

Также, есть функция fseek, которая смещает текущий указатель позиции на указанное количество символов, относительно некоторой позиции.

“Некоторая позиция” также указывается параметром:

  • SEEK_CUR – текущая позиция
  • SEEK_SET – начало файла
  • SEEK_END – конец файла
// На 1 назад
fseek(f, -1, SEEK_CUR);
// На начало
fseek(f, 0, SEEK_SET);
// В конец
fseek(f, 0, SEEK_END);

ВАЖНО: кроме перемещения по файлу, необходимо вызывать fseek, если ты хочешь переключиться с режима чтения на режим записи, в комбинированных режимах, вроде “r+”, “w+” и “a+”. В программе выше я для этого вызываю fseek без смещения.

Значение текущего указателя позиции можно получить через функцию ftell

Проверяем, что дошли до конца файла

Для проверки того, что мы дошли до конца файла, необходимо вызвать функцию feof.

while(!feof(f))

Эта функция возвращает 1, если указатель позиции дошёл до конца файла.

feof вернёт 1 только в случае, если мы перед этим вызвали функцию чтения, которая дошла до конца файла (fgetc, fgets, fscanf, fread).

Это происходит из-за того, что функция чтения, при упирании в конец файла, выставляет флажок “достигли конца файла”, который и проверяет функция feof.

Также, при вызове функций чтения, по возвращаемому значению можно понять что мы дошли до конца файла:

  • fgetc – вернёт -1, если дошли до конца файла; вместо -1 в этом случае используется константа EOF (End Of File)

Обычно fget возвращает код символа от 0 до 255

char c = fgetc(f);
if(c == EOF)
// ...
  • fgets – вернёт NULL
char str[100];
if(fgets(str, 100, f) == NULL)
// ...
  • fscanf – вернёт количество считанных элементов, отличающееся от требуемого (упёрлись в конец файла)
char str[2];
int x;
int count = fscanf(f, "%c %c %d", str, str+1, &x);
if(count != 3)
// ...
  • fread – вернёт количество считанных элементов, отличающееся от требуемого (упёрлись в конец файла)
char str[100];
int count = fread(str, sizeof(char), 100, f);
if(count != 100)
// ...

Бинарные файлы

Это последняя тема, которую я хочу объяснить.

У функции fopen есть ещё один режим работы – бинарный файл.

Для того, чтобы открыть файл в бинарном виде, надо указать “b” в конце строки, определяющей режим работы с файлом:

FILE *f = fopen("some-file.txt", "r+b");

Режим работы при этом может быть любым.

Что это меняет?

Теперь мы работаем с файлом не как с текстом, а как с набором байт.

То есть, я могу создать массив int, записать его в файл, а потом считать оттуда:

int arr[5] = {1, 2, 4, 8, 16};
FILE *f = fopen("some-file.txt", "wb");
fwrite(arr, sizeof(int), 5, f);
fclose(f);
// Представим что я это делаю в другой программе, для эффектности
int new_arr[5];
f = fopen("some-file.txt", "rb");
fread(new_arr, sizeof(int), 5, f);
fclose(f);
for(int i = 0; i < 5; i++)
	printf("%d ", new_arr[i]);
// Вывод программы: 1 2 4 8 16

Возможно ты думаешь, что в файле сейчас записано что-то вроде “1 2 4 8 16”, но нет – там такое:

Текст бинарного файла

Оно выглядит так странно из-за того, что текстовый редактор не может нормально отобразить бинарные данные – он пытается прочитать коды символов.

Чтобы реально узнать что там лежит, необходимо воспользоваться редактором, который может отображать бинарные данные. Вот как содержимое выглядит через такой редактор:

Данные в бинарном файле

Обычно они называются hex-editor, потому что отображают данные в шеснадцатеричной системе счисления. Посмотрев на данные в представлении этого редактора, можно заметить что наши 5 int’ов лежат там как ни в чём не бывало.

Зачем вообще это надо? Почему нельзя хранить всё как текст?

Представь, что я захочу сохранить unsigned int число 4294967295:

  • В бинарном файле это число всё так же займёт 4 байта (FF FF FF FF)
  • В текстовом файле это число займёт 10 байт (в этом числе 10 символов) + дополнительная нагрузка при чтении данных, так как надо перевести строку в unsigned int

Про хранение вещественных чисел я вообще молчу

Если численные данные в базах данных хранились бы текстом, то понадобилось бы в 2+ раз больше дата-центров.

В общем, если пользователю не надо напрямую взаимодействовать с текстом в файле, то бинарные файлы – это отличный вариант.

FAQ

Как работать с русскимим символами?

Надо добавить поддержку русской локализации:

#include <stdio.h>
#include <locale.h>

int main()
{
    SetConsoleCP(1251);
    SetConsoleOutputCP(1251); 
    setlocale(LC_ALL, "Rus");
    // ...
    return 0;
}

И убедись, что ты сохраняешь файл в кодировке “windows 1251” – обычно файлы сохраняются в кодировке UTF-8. Так как кодировка UTF-8 двухбайтная (каждый символ кодируется двумя байтами), ты не сможешь работать с нимми как обычно.

Как перевести строку в число?

Здесь можешь посмотреть документацию к различным функциям перевода строки в число:

  • atoi() – строка в int
  • atof() – строка в double
  • atol() – строка в long int

Функции strtof, strtod, strtol и подобные, тоже переводят строку в число, но обладают расширенной функциональностью – смотри в документацию.

Обратное преобразование возможно через sprintf и fprintf.

Можно ли удалить из файла какие-то символы/слова?

Нет, нельзя – это можно сделать одним из двух способов:

  • Считать файл во временный буффер, удалить из буффера слова/символы и перезаписать файл с флагом “w” новым содержимым
  • Создать временный файл, считывать символы/слова из исходного файла, записывать (если символ/слово подходит) во временный файл, в конце удалить исходный файл и переименовать временный так, чтобы он назывался как исходный.

Вот пример использования первого способа, для решения задачи “удалить слово mother из файла”:

FILE *f = fopen("input.txt", "r+");
if(f == NULL)
	return 0;
char buf[1000];
size_t len = fread(buf, sizeof(char), 1000, f);
fclose(f);
// Открываем с "w", чтобы отчистить файл
f = fopen("input.txt", "w");
char *target_word = "mother";
int target_word_len = strlen(target_word);
int target_word_i = 0;
for(int i = 0; i < len; i++)
{
	// Если символ не из ключевого слова
	if(buf[i] != target_word[target_word_i])
	{
		// Если до этого были символы, входящие в ключевое символы -- записываем в файл
		for(int j = target_word_i; j > 0; j--)
			fputc(buf[i-j], f);
		// Записываем текущий символ
		fputc(buf[i], f);
		target_word_i = 0;
		continue;
	}
	// Увеличиваем индекс символа в ключевом слове
	target_word_i++;
	if(target_word_i == target_word_len)
	{
		// Если дошли до последнего символа в ключевом слове -- сбрасываем индекс
		target_word_i = 0;
	}
}
fclose(f);

Как изменить символы в файле?

Открыть файл в режиме “r+”, считывать, смещаться fseek’ом на символ назад, записывать новое значение.

В главе “Чтение/запись в файл” есть пример.

Заключение

Итого, ты узнал что такое:

  • Файл
  • Дескриптор
  • Открытие файла
  • Закрытие файла
  • FILE – это обёртка надо дескриптором
  • Функции чтения и записи
  • Перемещение указателя позиции по файлу
  • Определение конца файла
  • Бинарные файлы

Поздравляю, это супер-круто! Это сложная тема, и скорее всего всё сразу не уляжется в голове – попробуй написать пару программ, перечитать непонятные места и отдохнуть.

Дальше будут структуры данных.


▲ В начало ▲