![]() |
Файловый ввод/вывод в C# |
![]() |
Потоковый ввод/вывод
Рассмотренные в предыдущей работе функции File.ReadAllText()
и
File.WriteAllText()
, несмотря на удобство в использовании, обладают серьезным
недостатком - низкой масштабируемостью. Это означает, что производительность
программы, использующей эти функции, падает с увеличением объема
читаемого/записываемого файла. Например, вызов File.ReadAllText()
над файлом,
имеющим размер в 8 Гбайт, приведёт к тому, что программа попытается выделить
такой же объём оперативной памяти, чтобы разместить содержимое файла в
переменной.
Для решения этой проблемы во многих языках программирования присутствует концепция потокового I/O. Потоком (stream, не путать с другим термином thread) называется абстракция над каким-либо источником или приёмником данных (файлом, участком памяти, сетевым подключением и т.д.), позволяющая читать и записывать данные небольшими порциями. Такой подход позволяет значительно сократить объем потребляемой памяти, однако требует больше манипуляций со стороны разработчика программы.
Каждая операция чтения из потока изменяет скрытое целочисленное значение, называемое курсором. Курсор играет роль указателя, отделяющего уже прочитанные данные от ещё непрочитанных. В зависимости от источника данных, который представляет поток, курсор может быть передвинут пользователем назад, чтобы получить возможность повторить чтение.
Потоковый текстовый ввод/вывод в C# реализуется с помощью типов StreamReader
и StreamWriter
. Рассмотрим сперва процедуру чтения из потока:
= File.OpenText("somefile.txt");
StreamReader sr
.WriteLine($"Объём данных в потоке: {sr.BaseStream.Length}");
Console
string перваяСтрока = sr.ReadLine();
int кодСимвола = sr.Read();
if (кодСимвола != -1)
char отдельныйСимвол = (char)кодСимвола;
char[] массивСимволов = new char[5];
.Read(массивСимволов, 0, 5);
sr
string остальныеДанные = sr.ReadToEnd();
Функция File.OpenText()
создает StreamReader
, который будет черпать данные
из указанного файла. Свойство .BaseStream.Length
возвращает количество символов,
которые могут быть прочитаны из данного потока. Это позволяет оценить объём
файла перед началом работы с ним.
Функция .ReadLine()
, подобно Console.ReadLine()
прочитывает одну строку из
потока, передвигая курсор на следующую строку. Часто используется в циклических
конструкциях для того, чтобы построчно обрабатывать файлы большого объёма.
Вариант функции .Read()
без аргументов прочитывает очередной символ из потока.
Возвращаемым типом этой функции является int
, а не char
, чтобы дать
возможность функции сигнализировать об окончании доступных данных. В этом случае
вызов возвращает значение -1
. В остальных случаях, значение содержит в себе
код прочитанного символа, который может быть сконвертирован в char
с помощью
явного приведения типов.
Вариант функции .Read()
с тремя аргументами прочитывает сразу несколько
символов в массив, указанный в качестве первого аргумента. Второй аргумент
задает индекс, начиная с которого прочитанные символы будут заноситься в массив,
а третий - максимальное количество символов, которое требуется прочитать. Важно
иметь ввиду, что вызов .Read()
может прочитать меньше символов, чем было
запрошено в третьем аргументе. Чтобы позволить обнаружить такую ситуацию,
функция возвращает количество прочитанных символов в действительности в качестве
возвращаемого значения.
Наконец, функция .ReadToEnd()
прочитывает всё содержимое потока от текущей
позиции курсора до конца.
Рассмотрим теперь процедуру потоковой записи в файл:
= File.CreateText("somefile.txt");
StreamWriter sw
.WriteLine("Строка1");
sw.Write(1);
sw.Write(true);
sw
= new StringBuilder();
StringBuilder sb .Append("текст");
sb.Append("текст");
sb.Append("текст");
sb.Write(sb);
sw
.Close(); sw
Тип StreamWriter
создается вызовом функции File.CreateText()
и позволяет
записывать данные в файл в текстовом виде. Если указанный файл отсутствует, он
будет создан, а если уже существует - перезаписан.
Функции .WriteLine()
и .Write()
позволяют записывать различные типы данных в
поток. Они аналогичны друг другу, с тем лишь отличием, что .WriteLine()
дополнительно дописывает символ перевода строки.
Функция .Close()
сохраняет файл и закрывает поток.
Байты и кодировки
В предыдущем параграфе мы выяснили, что целочисленное значение может быть сконвертировано в строковый символ с помощью явного приведения типов. На самом деле, это не единственный способ конверсии, а сам процесс скрывает в себе отдельную проблему.
Кодировкой (encoding) называется таблица, устанавливающая соответствие между числовыми кодами и символами, которые эти коды представляют. На сегодняшний день существует большое количество разнообразных кодировок, однако наиболее популярными из них являются ASCII и Unicode в различных способах представления (UTF-8, UTF-16 и др.)
Стандарт ASCII является одной из первых предложенных кодировок. Он определяет коды для 127 символов, которые включают арабские цифры, латинские буквы в строчном и прописном варианте, знаки пунктуации и ряд служебных символов. Каждый символ в кодировке ASCII умещается в 1 байт, что делает её компактной, однако эта кодировка не подходит для записи текстов на отличных от английского языках.
Так как ASCII занимает лишь половину возможных значений байта (0-127), с повсеместным развитием компьютерных технологий появилось множество кодировок для нелатинских алфавитов, в которых значения кодов для добавляемых символов распределялись из второй половины значений байта (128-255). Это позволило, с одной стороны, записывать тексты в кириллических, западноевропейских и др. наборах символов, а с другой стороны - оставаться совместимыми с ASCII.
Стандарт Unicode призван решить проблему многообразия кодировок путем включения в таблицу кодировки всех существующих символов. Разумеется, объёма одного байта для этого не достаточно, поэтому символ, закодированный в Unicode может иметь объём от 1 до 4 байтов. Несмотря на то, что текст в Unicode имеет, в среднем, больший объём, чем тот же текст, закодированный в однобайтовой кодировке, этот недостаток компенсируется универсальностью стандарта. В настоящее время, кодировка Unicode является преобладающей в сети Интернет и различных форматах файлов.
Язык C#, в отличие от более низкоуровневых языков С и С++, различает байты и
массивы байт (типы byte
и byte[]
) от символов и строк (char
и string
),
соответственно. Несмотря на то, что структурно string
представляется в памяти
компьютера так же как byte[]
, содержимое этого типа обрабатывается как текст
в кодировке Unicode. Как следствие, над значениями типа char
и string
возможны преобразования, имеющие смысл только для текстовых данных (например,
.ToUpper()
), в то время как над byte
подобные преобразования производить
нельзя. Тип string
в С# имеет кодировку Unicode в представлении UTF-16.
Рассмотренные выше типы StreamReader
и StreamWriter
по умолчанию используют
кодировку Unicode в представлении UTF-8. Если читаемые или записываемые данные
используют другую кодировку, её можно указать при создании потоков:
= new StreamReader("somefile.txt", Encoding.ASCII); StreamReader sr
При использовании File.OpenText()
, выбрать кодировку нельзя.
Для того чтобы сконвертировать string
в byte[]
, получив доступ, таким
образом, к кодам символов, составляющих строку, используется следующий код:
byte[] кодыСимволов = Encoding.Unicode.GetBytes("some text");
Аналогичным образом можно сконвертировать массив в байт в строку, декодируя символы согласно заданной кодировке:
byte[] кодыВASCII = ...;
string s1 = Encoding.ASCII.GetString(кодыВASCII);
byte[] кодыВUTF32 = ...;
string s2 = Encoding.UTF32.GetString(кодыВUTF32);
Двоичные потоки
Все данные, обрабатываемые компьютерными программами, принято делить на
текстовые и двоичные. Текстовыми называются данные, состоящие из
символов в какой-либо кодировке, а двоичными - любые дргуие данные, не
обладающие этим свойством. Как правило, двоичные форматы данных более компактны
и имеют большую производительность при обработке, однако для их редактирования
требуются специальные программы. Напротив, текстовые данные чаще всего избыточны,
однако легко воспринимаются человеком, и могут быть изменены с помощью
простейшего текстового редактора (например, notepad.exe
в ОС Windows).
Двоичный ввод/вывод используется при работе с двоичными файлами, а так же с
текстовыми файлами в неизвестных или необычных кодировках. С точки зрения
программиста работа с двоичными потоками не сильно отличается от работы с
текстовыми: функции чтения и записи оперируют типами byte
и byte[]
, вместо
char
и string
.
= File.OpenRead("somefile");
FileStream fs1 = File.OpenWrite("otherfile");
FileStream fs2
int результатЧтения = fs1.ReadByte();
if (результатЧтения != -1)
{
byte прочитанныйБайт = (byte)результатЧтения;
.WriteByte(прочитанныйБайт);
fs2}
byte[] массивБайт = new byte[5];
.Read(массивБайт, 0, 5);
fs1.Write(массивБайт, 0, массивБайт.Length);
fs2
.Seek(-5, SeekOrigin.Current);
fs1.Seek(0, SeekOrigin.Begin);
fs1
.Close(); fs2
Функция .Seek()
позволяет перенести курсор потока в произвольное место. Второй
аргумент задает точку отсчета: начало файла, текущая позиция, или же конец файла.
Текстовые потоки не предоставляют возможности изменять положение курсора вручную. Это обусловлено тем, что курсор может быть выставлен не в начало кодовой последовательности, из-за чего весь дальнейший процесс декодирования будет нарушен.
Буферизация потоков
Как уже было сказано ранее, потоки абстрагируют пользователя от реального
источника/приёмника данных. Однако, такая абстракция может снижать
скорость обмена данными в некоторых случаях. Например, если поток представляет
собой файл на жестком диске, побайтовое чтение с помощью .Read()
будет очень
медленным - на каждый запрос операционная система будет производить отдельное
обращение к диску. Вместо этого, код .Read()
при первом вызове может прочитать
больше байт, чем было запрошено, сохранив “лишние” данные в некоторой переменной.
При последующих вызовах, .Read()
будет возвращать байты из этой переменной,
минуя долгое обращение к жесткому диску. Такой подход называется буферизацией
(buffering).
Большинство типов потоков в языке C#, включая рассмотренные выше, производят
буферизацию при операциях чтения и записи. Потоки, не осуществляющие
буферизацию, можно превратить в буферизирующие с помощью типа BufferedStream
:
= ...;
Stream небуферизирующийПоток = new BufferedStream(небуферизирующийПоток); BufferedStream буферизирующийПоток
Контрольные вопросы
- Зачем нужен вариант
.Write()
, принимающийStringBuilder
, если есть вариант, принимающийstring
? - Почему в
StreamReader
нет метода.Seek()
? - Что получится, если создать
BufferedStream
из другого буферизирующего потока, напримерFileStream
? - Почему не существует единственной функции, конвертирующей
byte[]
вstring
? - В какой кодировке задача поиска n-го символа в строке решается быстрее - ASCII или Unicode?
Задание на лабораторную работу
Модифицируйте код своего варианта из лабораторной работы №3 следующим образом:
- В качестве третьего аргумента командной строки пользователь передает отличное от нуля целое число;
- Операции чтения и записи должны производиться с помощью текстовых потоков. Чтение должно производиться порциями по числу символов, указанному в третьем аргументе командной строки.
