Глава 15. Текстови файлове

В тази тема...

В настоящата тема ще се запознаем с основните принципи за работа с текстови файлове в C#. Ще разясним какво е това поток, за какво служи и как се ползва. Ще обясним какво е текстов файл и как се чете и пише в текстови файлове. Ще демонстрираме и обясним добрите практики за прихващане и обработка на изключения, възникващи при работата с файлове. Разбира се, всичко това ще онагледим и демонстрираме на практика с много примери.

Съдържание

Видео

Презентация

Мисловни карти


Потоци

Потоците (streams) са важна част от всяка входно-изходна библиотека. Те намират своето приложение, когато програмата трябва да "прочете" или "запише" данни от или във външен източник на данни – файл, други компютри, сървъри и т.н. Важно е да уточним, че терминът вход (input) се асоциира с четенето на информация, а терминът изход (output) – със записването на информация.

Какво представляват потоците?

Потокът е наредена последователност от байтове, които се изпращат от едно приложение или входно устройство и се получават в друго прило­жение или изходно устройство. Тези байтове се изпращат и получават един след друг и винаги пристигат в същия ред, в който са били изпра­тени. Потоците са абстракция на комуникационен канал за данни, който свързва две устройства или програми.

Потоците са основното средство за обмяна на информация в компютърния свят. Чрез тях различни програми достъпват файловете на компютъра, чрез тях се осъществява и мрежова комуникация между отдалечени ком­пютри. Много операции от компютърния свят могат да се разглеждат като четене или писане в поток. Например печатането на принтер е всъщност пращане на поредица байтове към поток, свързан със съответния порт, към който е свързан принтера. Възпроизвеждането на звук от звуковата карта може да стане като се изпратят някакви команди, следвани от семплирания звук (който представлява поредица от байтове). Сканира­нето на документи от скенер може да стане като на скенера се изпратят някакви команди (чрез изходен поток) и след това се прочете сканира­ното изображение (като входен поток). Така работата с почти всяко периферно устройство (видеокамера, фотоапарат, мишка, клавиатура, USB стик, звукова карта, принтер, скенер и други) може да се осъществи през абстракцията на потоците.

За да прочетем или запишем нещо от или във файл, трябва да отворим поток към дадения файл, да извършим четенето или писането и да затворим потока. Потоците могат да бъдат текстови или бинарни, но това разделение е свързано с интерпретацията на изпращаните и получаваните байтове. Понякога, за удобство серия байтове се разглеждат като текст (в предварително зададено кодиране) и това се нарича текстов поток.

Модерните сайтове в Интернет не могат без потоци и така наречения streaming (произлиза от stream, т.е. поток), който представлява поточно достъпване на обемни мултимедийни файлове, идващи от Интернет. Поточното аудио и видео позволява файловете да се възпроизвеждат преди цялостното им локално изтегляне, което  прави съответния сайт по-интерактивен.

Основни неща, които трябва да знаем за потоците

Потоците се използват, за четене и запис на данни от и на различни устройства. Те улесняват комуникацията между програма и файл, прог­рама и отдалечен компютър и т.н.

Потоците са подредени серии от байтове. Неслучайно наблягаме на думата подредени. От огромна важност е да се запомни, че потоците са строго подредени и организирани. По никакъв начин не можем да си позволим да влияем на подредбата на информацията в потока, защото по този начин ще я направим неизползваема. Ако един байт е изпратен към даден поток по-рано от друг, то той ще пристигне по-рано от него и това се гарантира от абстракцията "поток".

Потоците позволяват последователен достъп до данните си. Отново е важно да се вникне в значението на думата последователен. Може да манипулираме данните само в реда, в който те пристигат от потока. Това е тясно свързано с предходното свойство. Имайте това предвид, когато създа­вате собствени програми. Не можете да вземете първия байт, след това осмия, третия, тринадесетия и така нататък. Потоците не предо­ставят произволен достъп до данните си, а само последователен. Ако ви се струва по-лесно, може да мислим за потоците като за свързан списък от байтове, в който те имат строга последователност.

В различните ситуации се използват различни видове потоци. Едни потоци служат за работа с текстови файлове, други – за работа с бинарни (двоични) файлове, трети пък – за работа със символни низове. Различни са и потоците, които се използват при мрежова комуникация. Голямото изобилие от потоци ни улеснява в различните ситуации, но също така и ни затруднява, защото трябва да сме запознати със спецификата на всеки отделен тип, преди да го използваме в приложението си.

Потоците се отварят преди началото на работата с тях и се затварят след като е приключило използването им. Затварянето на потоците е нещо абсолютно задължи­телно и не може да се пропусне, поради риск от загуба на данни, повреждане на файла, към който е отворен потока и т.н. – все неприятни неща, които не трябва да допускаме да се случват в нашите програми.

Потоците можем да оприличим на тръби, свързващи две точки:

clip_image002

От едната страна "наливаме" данни, а от другата данните "изтичат". Този, който налива данните, не се интересува как те се пренасят, но е сигурен, че каквото е налял, такова ще излезе от другата страна на тръбата. Тези, които ползват потоците, не се интересуват как данните стигат до тях. Те знаят, че ако някой налее нещо от другата страна, то ще пристигне при тях. Следователно можем да разглеждаме потоците са транспортен канал за данни, както и тръбите.

Основни операции с потоци

Когато работим с потоци в компютърните технологии, върху тях можем да извършваме следните операции:

Създаване

Свързваме потока с източник на данни, механизъм за пренос на данни или друг поток. Например, когато имаме файлов поток, тогава подаваме името на файла и режима, в който го отваряме (за четене, за писане или за четене и писане едновременно).

Четене

Извличаме данни от потока. Четенето винаги се извършва последователно от текущата позиция на потока. Четенето е блокираща операция и ако отсрещната страна не е изпратила данни докато се опитваме да четем или изпрате­ните данни още не са пристигнали, може да се получи забавяне – от няколко милисекунди до часове, дни или по-голямо. Например, ако четем от мрежов поток, данните могат да се забавят по мрежата или отсрещната страна може изобщо да не изпрати никакви данни.

Запис

Изпращаме данни в потока по специфичен начин. Записът се извършва от текущата позиция на потока. Записът потенциално може да е блокираща операция и да се забави докато данните поемат по своя път. Например ако изпращаме обемни данните по мрежов поток, операцията може да се забави докато данните отпътуват по мрежата.

Позициониране

Преместване на текущата позиция на потока. Преместването се извършва спрямо текуща позиция, като можем да позиционираме спрямо текуща позиция, спрямо началото на потока, или спрямо края на потока. Премест­ване можем да извършваме единствено в потоци, които поддържат пози­циониране. Например файловите потоци обикновено поддържат позицио­ниране, докато мрежовите не поддържат.

Затваряне

Приключваме работата с потока и освобождаваме ресурсите, заети от него. Затварянето трябва да се извършва възможно най-рано след при­ключване на работата с потока, защото ресурс, отворен от един потребител, обикновено не може да се ползва от останалите потребители (в това число от други програми на същия компютър, които се изпълняват паралелно на нашата програма).

Потоци в .NET – основни класове

В .NET Framework класовете за работа с потоци се намират в прост­ранството от имена System.IO. Нека се концентрираме върху тяхната йерархия, организация и функционалност.

Можем да отличим два основни типа потоци – такива, които работят с двоични данни и такива, които работят с текстови данни. Ще се спрем на основните характеристики на тези два вида след малко.

На върха на йерархията на потоците стои абстрактен клас за входнo-изходен поток. Той не може да бъде инстанциран, но дефинира основ­ната функционалност, която притежават всички останали потоци.

Същест­вуват и буферирани потоци, които не добавят никаква допълни­телна функ­ционалност, но използват буфер при четене и записване на информа­цията, което значително повишава бързодействието. Буфери­раните потоци няма да се разглеждат в тази глава, тъй като ние се концентри­раме върху обработката на текстови файлове. Ако имате желание, може да се допитате до богатата документация, достъпна в Интернет, или към някой учебник за по-напреднали в програмирането.

Някои потоци добавят допълнителна функционалност при четене и запис­ване на данните. Например съществуват потоци, които компресират / декомпресират изпратените към тях данни и потоци, които шифрират и дешифрират данните. Тези потоци се свързват към друг поток (например файлов или мрежов поток) и добавят към неговата функционалност допълнителна обработка.

Основните класове в пространството от имена System.IO са Stream – базов абстрактен клас за всички потоци, BufferedStream, FileStream, MemoryStream, GZipStream, NetworkStream. Сега ще се спрем по-обстойно на някои от тях, разделяйки ги по основния им признак – типа данни, с който работят.

Всички потоци в C# си приличат и по едно основно нещо – задължи­телно е да ги затворим, след като сме приключили работа с тях. В противен случай рискуваме да навредим на данните в потока или файла, към който сме го отворили. Това ни води и до първото основно правило, което винаги трябва да помним при работа с потоци:

clip_image003

Винаги затваряйте потоците и файловете, с които рабо­тите! Оставянето на отворен поток или файл води до загуба на ресурси и може да блокира работата на други потребители или процеси във вашата система.

Двоични и текстови потоци

Както споменахме по-рано, можем да разделим потоците на две големи групи в съответствие с типа данни, с който боравят, а именно – двоични потоци и текстови потоци.

Двоични потоци

Двоичните потоци, както личи от името им, работят с двоични (бинарни) данни. Сами се досещате, че това ги прави универсални и тях може да ползваме за четене на информация от всякакви файлове (картинки, музикални и мултимедийни файлове, текстови файлове и т.н.). Ще ги разгледаме съвсем накратко, защото за момента се ограничаваме до работа с текстови файлове.

Основните класове, които използваме, за да четем и пишем от и към двоични потоци са: FileStream, BinaryReader и BinaryWriter.

Класът FileStream ни предлага различни методи за четене и запис от бинарен файл (четене / запис на един байт и на поредица от байтове), пропускане на определен брой байтове, проверяване на броя достъпни байтове и, разбира се, метод за затваряне на потока. Обект от този клас може да получим, извиквайки конструктора му с параметър име на файл.

Класът BinaryWriter позволява записването в поток на данни от прими­тивни типове във вид на двоични стойности в специфично кодиране. Той има един основен метод – Write(…), който позволява записване на всякакви примитивни типове данни – числа, символи, булеви стойности, масиви, стрин­гове и др. Класът BinaryReader позволява четенето на данни от примитивни типове, записани с помощта на BinaryWriter. Основните му методи ни позволяват да четем символ, масив от символи, цели числа, числа с плаваща запетая и др. Подобно на предходните два класа, обект от този клас може да получим, извиквайки конструктора му.

Текстови потоци

Текстовите потоци са много подобни на двоичните, но работят само с текстови данни или по-точно с поредици от символи (char) и стрингове (string). Идеални са за работа с текстови файлове. От друга страна това ги прави неизползваеми при работа с каквито и да е бинарни файлове.

Основните класове за работа с текстови потоци са TextReader и TextWriter. Те са абстрактни класове и от тях не могат да бъдат създа­вани обекти. Тези класове дефинират базова функционалност за четене и запис на класовете, които ги наследяват. По важните им методи са:

-     ReadLine() – чете един текстов ред и връща символен низ.

-     ReadToEnd() – чете всичко от потока до неговия край и връща символен низ.

-     Write() – записва символен низ в потока.

-     WriteLine() – записва един текстов ред в потока.

Както знаете, символите в .NET са Unicode символи, но потоците могат да работят освен с Unicode и с други кодирания (кодировки), например стандартното за кирилицата кодиране Windows-1251.

Класовете, на които ще обърнем най-голямо внимание в тази глава са StreamReader и StreamWriter. Те наследяват директно класовете TextReader и TextWriter и реализират функционалност за четене и запис на текстова информация от и във файл. За да създадем обект от StreamReader или StreamWriter, ни е нужен файл или символен низ с име и път до файла. Боравейки с тези класове, можем да използваме всички методи, с които вече сме добре запознати от работата ни с конзолата. Четенето и писането на конзолата приличат много на четенето и писането съответно от StreamReader и StreamWriter.

Връзка между текстовите и бинарните потоци

При писане на текст класът StreamWriter скрито от нас превръща текста в байтове преди да го запише на текущата позиция във файла. За целта той използва кодирането, което му е зададено по време на създаването му. По подобен начин работи и StreamReader класът. Той вътрешно използва StringBuilder и когато чете бинарни данни от файла, ги конвертира към текст преди да ги върне като резултат от четенето.

Запомнете, че в операционната система няма понятие "текстов файл". Файлът винаги е поредица от байтове, а дали е текстов или бинарен зависи от интерпретацията на тези байтове. Ако искаме да разглеждаме даден файл или поток като текстов, трябва да го четем и пишем с текстови потоци (StreamReader или StreamWriter), а ако искаме да го разглеждаме като бинарен (двоичен), трябва да го четем и пишем с бинарен поток (FileStream).

Трябва да обърнем внимание, че текстовите потоци работят с текстови редове, т.е. интерпретират бинарните данни като поредица от редове, разделени един от друг със символ за нов ред. Символът за нов ред не е един и същ за различните платформи и операционни системи. За UNIX и Linux той е LF (0x0A), за Windows и DOS той е CR + LF (0x0D + 0x0A), а за Mac OS (до версия 9) той е CR (0x0A). Така четенето на един текстов ред от даден файл или поток означава на практика четене на поредица от байтове до прочитане на един от символите CR или LF и преобразуване на тези байтове до текст спрямо използваното в потока кодиране (encoding). Съответно писането на един текстов ред в текстов файл или поток означава на практика записване на бинарната репрезентация на текста (спрямо използваното кодиране), следвано от символа (или символите) за нов ред за текущата операционна система (например CR + LF).

Четене от текстов файл

Текстовите файлове предоставят идеалното решение за четене и запис­ване на данни, които трябва да ползваме често, а са твърде обемисти, за да ги въвеждаме ръчно всеки път, когато стартираме програмата. Затова сега ще разгледаме как да четем и пишем текстови файлове с класовете от .NET Framework и езика C#.

Класът StreamReader за четене на текстов файл

C# предоставя множество начини за четене от файлове, но не всички са лесни и интуитивни за използване. Ето защо се спираме на StreamReader класа. Класът System.IO.StreamReader предоставя най-лесния начин за четене на текстов файл, тъй като наподобява четенето от конзолата, което до сега сигурно сте усвоили до съвършенство.

Четейки всичко до момента, е възможно да сте малко объркани. Вече обяснихме, че четенето и записването от и в текстови файлове става само и изключително с потоци, а същевременно StreamReader не се появи никъде в изброените по-горе потоци и не сте сигурни дали въобще е поток. Наистина, StreamReader не е поток, но може да работи с потоци. Той предоставя най-лесния и разбираем клас за четене от текстов файл.

Отваряне на текстов файл за четене

Може да създадем StreamReader просто по име на файл (или пълен път до файла), което значително ни улеснява и намалява възможностите за грешка. При създаването можем да уточним и кодирането (encoding). Ето пример как може да бъде създаден обект от класа StreamReader:

// Create a StreamReader connected to a file

StreamReader reader = new StreamReader("test.txt");

 

// Read file here...

 

// Close the reader resource after you've finished using it

reader.Close();

Първото, което трябва да направим, за да четем от текстов файл, е да създадем променлива от тип StreamReader, която да свържем с конкретен файл от файловата система на нашия компютър. За целта е нужно само да подадем като параметър в конструктора му името на желания файл. Имайте предвид, че ако файлът се намира в папката, където е компилиран проектът (поддиректория bin\Debug), то можем да подадем само конкрет­ното му име. В противен случай може да подадем пълния път до файла или да използваме релативен път.

Редът код в горния пример, който създава обект от тип StreamReader, може да предизвика появата на грешка. Засега просто подавайте път до съществуващ файл, а след малко ще обърнем внимание и на обработката на грешки при работа с файлове.

Пълни и релативни пътища

При работата с файлове можем да използваме пълни пътища (например C:\Temp\example.txt) или релативни пътища, спрямо директорията, от която е стартирано приложението (примерно ..\..\example.txt).

Ако използвате пълни пътища, при подаване на пълния път до даден файл не забравяйте да направите escaping на наклонените черти, които се използват за разделяне на папките. В C# това можете да направите по два начина – с двойна наклонена черта или с цитирани низове, започващи с @ преди стринговия литерал. Например за да запишем в стринг пътя до файл "C:\Temp\work\test.txt" имаме два варианта:

string fileName = "C:\\Temp\\work\\test.txt";

string theSamefileName = @"C:\Temp\work\test.txt";

Въпреки, че използването на релативни пътища е по-трудно, тъй като трябва да съобразявате структурата на директориите на вашия проект, е силно препоръчително да избягвате пълните пътища.

clip_image003[1]

Избягвайте пълни пътища и работете с относителни! Това прави приложението ви преносимо и по-лесно за инста­лация и поддръжка.

Използването на пълен път до даден файл (примерно C:\Temp\test.txt) е лоша практика, защото прави програмата ви зависима от средата и непре­носима. Ако я прехвърлите на друг компютър, ще трябва да коригирате пътищата до файловете, които тя търси, за да работи коректно. Ако използвате относителен (релативен) път спрямо текущата директория (например ..\..\example.txt), вашата програма ще е лесно преносима.

clip_image003[2]

Запомнете, че при стартиране на C# програма текущата директория е тази, в която се намира изпълнимият (.exe) файл. Най-често това е поддиректорията bin\Debug спрямо коренната директория на проекта. Следователно, за да отворите файла example.txt от коренната директория на вашия Visual Studio проект, трябва да използвате релатив­ния път ..\..\example.txt.

Отваряне на файл със задаване на кодиране

Както вече обяснихме, четенето и писането от и към текстови потоци изисква да се използва определено, предварително зададено кодиране на символите (character encoding). Кодирането може да се подаде при създа­ването на StreamReader обект като допълнителен втори параметър:

// Create a StreamReader connected to a file

StreamReader reader = new StreamReader("test.txt",

      Encoding.GetEncoding("Windows-1251"));

 

// Read file here...

 

// Close the reader resource after you've finished using it

reader.Close();

Като параметри в примера подаваме име на файла, който ще четем и обект от тип Encoding. Ако не бъде зададено специфично кодиране при отварянето на файла, се използва стандартното кодиране UTF-8. В показаният по–горе случай използваме кодиране Windows-1251. Windows-1251 е 8-битов (еднобайтов) набор символи, проектиран от Майкрософт за езиците, използващи кирилица като български, руски и други. Кодира­нията ще разгледаме малко по-късно в настоящата глава.

Четене на текстов файл ред по ред – пример

След като се научихме как да създаваме StreamReader вече можем да се опитаме да направим нещо по-сложно: да прочетем цял текстов файл ред по ред и да отпечатаме прочетеното на конзолата. Нашият съвет е да създавате текстовия файл в Debug папката на проекта (.\bin\Debug), така той ще е в същата директория, в която е вашето компилирано приложение и няма да се налага да подаваме пълния път до него при отварянето на файла. Нека нашият файл изглежда така:

sample.txt

This is our first line.

This is our second line.

This is our third line.

This is our fourth line.

This is our fifth line.

Имаме текстов файл, от който да четем. Сега трябва да създадем обект от тип StreamReader и да прочетем и отпечатаме всички редове. Това можем да направим по следния начин:

FileReader.cs

class FileReader

{

      static void Main()

      {

            // Create an instance of StreamReader to read from a file

            StreamReader reader = new StreamReader("Sample.txt");

 

            int lineNumber = 0;

 

            // Read first line from the text file

            string line = reader.ReadLine();

 

            // Read the other lines from the text file

            while (line != null)

            {

                  lineNumber++;

                  Console.WriteLine("Line {0}: {1}", lineNumber, line);

                  line = reader.ReadLine();

            }

 

            // Close the resource after you've finished using it

            reader.Close();

      }

}

Сами се убеждавате, че няма нищо трудно в четенето на текстови файлове. Първата част на програмата вече ни е добре позната – създаваме променлива от тип StreamReader като в конструктора подаваме името на файла, от който ще четем. Параметърът на конструктора е пътят до файла, но тъй като нашият файл се намира в Debug директорията на проекта, ние задаваме като път само името му. Ако нашият файл се намираше в директорията на проекта, то тогава като път щяхме да подадем стринга - "..\..\Sample.txt".

След това създаваме и една променлива – брояч, чиято цел е да брои и показва на кой ред от файла се намираме в текущия момент.

Създаваме и една променлива, която ще съхранява всеки прочетен ред. При създаването й направо четем първия ред от текстовия файл. Ако текстовият файл е празен, методът ReadLine() на обекта StreamReader ще върне null.

За същинската част – прочитането на файла ред по ред, използваме while цикъл. Условието за изпълнение на цикъла е докато в променливата line има записано нещо, т.е. докато има какво да четем от файла. В тялото на цикъла задачата ни се свежда до увеличаване на стойността на промен­ливата-брояч с единица и след това да отпечатаме текущия ред от файла в желания от нас формат. Накрая отново с ReadLine() четем следващия ред от файла и го записваме в променливата line. За отпечатване използ­ваме един метод, който ни е отлично познат от задачите, в които се е изисквало да се отпечата нещо на конзолата – WriteLine().

След като сме прочели нужното ни от файла, отново не бива да забравяме да затворим обекта StreamReader, за да избегнем загубата на ресурси. За това ползваме метода Close().

clip_image003[3]

Винаги затваряйте инстанциите на StreamReader след като приключите работа с тях. В противен случай рискувате да загубите системни ресурси. За затваряне използвайте метода Close() или конструкцията using.

Резултатът от изпълнението на програмата би трябвало да изглежда така:

Line 1: This is our first line.

Line 2: This is our second line.

Line 3: This is our third line.

Line 4: This is our fourth line.

Line 5: This is our fifth line.

Автоматично затваряне на потока след приключване на работа с него

Както се забелязва в предния пример, след като приключихме работа с обекта от тип StreamReader, извикахме Close() и затворихме скрития поток, с който обектът StreamReader работи. Много често обаче начинаещите програмисти забравят да извикат Close() метода и с това излагат на опасност файла, от който четат, или в който записват. C# предлага конструкция за автоматично затваряне на потока или файла след приключване на работата с него. Тази конструкция е using. Синтаксисът й е следният:

 

using(<stream object>) { … }

Използването на using гарантира, че след излизане от тялото й автоматично ще се извика метода Close(). Това ще се случи дори ако при четенето на файла възникне някакво изключение.

След като вече знаем за using конструкцията, нека преработим предходния пример, така че да я използва:

FileReader.cs

class FileReader

{

      static void Main()

      {

            // Create an instance of StreamReader to read from a file

            StreamReader reader = new StreamReader("Sample.txt");

 

            using (reader)

            {

                  int lineNumber = 0;

 

                  // Read first line from the text file

                  string line = reader.ReadLine();

 

                  // Read the other lines from the text file

                  while (line != null)

                  {

                        lineNumber++;

                        Console.WriteLine("Line {0}: {1}", lineNumber, line);

                        line = reader.ReadLine();

                  }

            }

      }

}

Ако се чудите по какъв начин е най-добре да се грижите за затварянето на използва­ните във вашите програми потоци и файлове, следвайте следното правило:

clip_image003[4]

Винаги използвайте using конструкцията в C# за да затва­ряте коректно отворените потоци и файлове!

Кодиране на файловете. Четене на кирилица

Нека сега разгледаме проблемите, които се появяват при четене с неко­ректно кодиране, например при четене на файл на кирилица.

Кодиране (encoding)

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

Кодирането се състои в заместването на текстовите символи (цифри, букви, препинателни знаци и т.н.) с точно определени поредици от числови стой­ности. Може да си го представите като голяма таблица, в която срещу всеки символ стои определена стойност (пореден номер).

Кодиращите схеми (character encodings) задават правила за преобра­зуване на текст в поредица от байтове и на обратно. Кодиращата схема е една таблица със символи и техните номера, но може да съдържа и специ­ални правила. Например символът "ударение" (U+0300) е специален и се залепя за последния символ, който го предхожда. Той се кодира като един или няколко байта (в зависимост от кодиращата схема), но на него не му съответства никакъв символ, а част от символ. Ще разгледаме две кодирания, които се използват най-често при работа с кирилица: UTF-8 и Windows-1251.

UTF-8 е кодираща схема, при която най-често използваните символи (латинската азбука, цифри и някои специални знаци) се кодират в един байт, по-рядко използваните Unicode символи (като кирилица, гръцки и арабски) се кодират в два байта, а всички останали символи (китайски, японски и много други) се кодират в 3 или 4 байта. Кодирането UTF-8 може да преобразува произволен Unicode текст в бинарен вид и на обратно и поддържа всичките над 100 000 символа от Unicode стандарта. Кодирането UTF-8 е универсално и е подходящо за всякакви езици, азбуки и писмености.

Друго често използвано кодиране е Windows-1251, с което обикновено се кодират текстове на кирилица (например съобщения изпратени по e-mail). То съдържа 256 символа, включващи латинската азбука, кирили­цата и някои често използвани знаци. То използва по един байт за всеки символ, но за сметка на това някои символи не могат да бъдат записани в него (например символите от китайската азбука) и се губят при опит да се направи това. Това кодиране се използва по подразбиране в Windows, който е настроен за работа с български език.

Други примери за кодиращи схеми (encodings или charsets) са ISO 8859-1, Windows-1252, UTF-16, KOI8-R и т.н. Те се ползват в специфични региони по света и дефинират свои набори от символи и правила за преминаване от текст към бинарни данни и на обратно.

За представяне на кодиращите схеми в .NET Framework се използва класът System.Text.Encoding, който се създава по следния начин:

Encoding win1251 = Encoding.GetEncoding("Windows-1251"));

Четене на кирилица

Вероятно вече се досещате, че ако искаме да четем от файл, който съдържа символи от кирилицата, трябва да използваме правилното коди­ране, което "разбира" тези специални символи. Обикновено в Windows среда текстовите файлове, съдържащи кирилица, са записани в кодиране Windows-1251. За да го използваме, трябва да го зададем като encoding на потока, който ще обработваме с нашия StreamReader.

Ако не укажем изрично кодиращата схема (encoding) за четене от файла, .NET Framework ще бъде използва по подразбиране encoding UTF-8.

Може би се чудите какво става, ако объркаме кодирането при четене или писане във файл. Възможни са няколко сценария:

-     Ако ползваме само латиница, всичко ще работи нормално.

-     Ако ползваме кирилица и четем с грешен encoding, ще прочетем т. нар. каракацили (познати още като джуджуфлечки или маймуняци). Това са безсмислени символи, които не могат да се прочетат.

-     Ако записваме кирилица в кодиране, което не поддържа кирилската азбука (например ASCII), буквите от кирилицата ще бъдат заменени безвъзвратно със символа "?" (въпросителна).

При всички случаи това са неприятни проблеми, които може да не забележим веднага, а чак след време.

clip_image003[5]

За да избегнете проблемите с неправилно кодирането на файловете, винаги задавайте кодирането изрично. Иначе програмата може да работи некоректно или да се счупи на по-късен етап.

Стандартът Unicode. Четене на Unicode

Unicode представлява индустриален стандарт, който позволява на ком­пютри и други електронни устройства винаги да представят и манипу­лират по един и същи начин текст, написан на повечето от световните писмености. Той се състои от дефиниции на над 100 000 символа, както и разнообразни стандартни кодиращи схеми (encodings). Обединението на различните символи, което ни предлага Unicode, води до голямото му разпространение. Както знаете, символите в C# (типовете char и string) също се представят в Unicode.

За да прочетем символи, записани в Unicode, трябва да използваме някоя от поддържаните в този стандарт кодиращи схеми. Най-известен и широко използван е UTF-8. Можем да го зададем като кодираща схема по вече познатия ни начин:

StreamReader reader = new StreamReader("test.txt",

      Encoding.GetEncoding("UTF-8"));

Ако се чудите дали за четене на текстов файл на кирилица да ползвате кодиране Windows-1251 или UTF-8, на този въпрос няма ясен отговор. И двата стандарта масово се ползват за записване на текстове на български език. И двете кодиращи схеми са позволени и може да ги срещнете.

Писане в текстов файл

Писането в текстови файлове е много удобен способ за съхранение на различни видове информация. Например, можем да записваме резулта­тите от изпълнението на дадена програма. Можем да полз­ваме текстови файлове, примерно направата на нещо като дневник (log) на програмата – удобен начин за следене кога се е стартирала, отбелязване на различни грешки при изпълнението и т.н.

Отново, както и при четенето на текстов файл, и при писането, ще изпол­зваме един подобен на конзолата клас, който се нарича StreamWriter.

Класът StreamWriter

Класът StreamWriter е част от пространството от имена System.IO и се използва изключително и само за работа с текстови данни. Той много наподобява класа StreamReader, но вместо методи за четене, предлага такива за записване на текст във файл. За разлика от другите потоци, преди да запише данните на желаното място, той ги превръща в байтове. StreamWriter ни дава възможност при създаването си да определим желания от нас encoding. Можем да създадем инстанция на класа по следния начин:

StreamWriter writer = new StreamWriter("test.txt");

В конструктора на класа можем да подадем като параметър както път до файл, така и вече създаден поток, в който ще записваме, а също и кодираща схема. Класът StreamWriter има няколко предефинирани кон­структора, в зависимост от това дали ще пишем във файл или в поток. В примерите ще използваме конструктор с параметър път до файл. Пример за използването на конструктора на класа StreamWriter с повече от един параметър е следният:

StreamWriter writer = new StreamWriter("test.txt",

      false, Encoding.GetEncoding("Windows-1251"));

В този пример подаваме път до файл като първи параметър. Като втори подаваме булева променлива, която указва дали ако файлът вече съще­ствува, данните да бъдат залепени на края на файла или файлът да бъде презаписан. Като трети параметър подаваме кодираща схема (encoding).

Примерните редове код отново може да предизвикат появата на грешка, но на обработката на грешки при работа с текстови файлове ще обърнем внимание малко по–късно в настоящата глава.

Отпечатване на числата от 1 до 20 в текстов файл – пример

След като вече можем да създаваме StreamWriter, ще го използваме по предназначение. Целта ни ще е да запишем в един текстов файл числата от 1 до 20, като всяко число е на отделен ред. Можем да го направим по следния начин:

class FileWriter

{

      static void Main()

      {

            // Create a StreamWriter instance

            StreamWriter writer = new StreamWriter("numbers.txt");

 

            // Ensure the writer will be closed when no longer used

            using(writer)

            {

                  // Loop through the numbers from 1 to 20 and write them

                  for (int i = 1; i <= 20; i++)

                  {

                        writer.WriteLine(i);

                  }

            }

      }

}

Започваме като създаваме инстанция на StreamWriter по вече познатия ни от примера начин.

За да изведем числата от 1 до 20 използваме един for-цикъл. В тялото на цикъла използваме метода WriteLine(…), който отново познаваме от работата ни с конзолата, за да запишем текущото число на нов ред във файла. Не бива да се притеснявате, ако файл с избраното от вас име не съществува. Ако случаят е такъв, той ще бъде автоматично създаден в папката на проекта, а ако вече съществува, ще бъде презаписан (ще бъде изтрито старото му съдържание). Резултатът има следния вид:

numbers.txt

1

2

3

20

За да сме сигурни, че след приключване на работата с файла той ще бъде затворен, използваме using конструкцията.

clip_image003[6]

Не пропускайте да затворите потока след като приключите използването му! За затварянето му използвайте C# кон­струкцията using.

Когато искате да отпечатате текст на кирилица и се колебаете кое кодиране да ползвате, предпочитайте кодирането UTF-8. То е универсално и поддържа не само кирилица, но и всички широкоразпространени све­товни азбуки: гръцки, арабски, китайски, японски и т.н.

Обработка на грешки

Ако сте следили примерите до момента, сигурно сте забелязали, че при доста от операциите, свързани с файлове, могат да възникнат изключи­телни ситуации. Основните принципи и подходи за тяхното прихващане и обработка вече са ви познати от главата "Обработка на изключения". Сега ще се спрем малко на специфичните грешки при работа с файлове и най-добрите практики за тяхната обра­ботка.

Прихващане на изключения при работа с файлове

Може би най-често срещаната грешка при работа с файлове е FileNotFoundException (от името и личи, че това изключение съобщава, че желаният файл не е намерен). Тя може да възникне при създаването на StreamReader.

Когато задаваме определен encoding при създаване на StreamReader или StreamWriter, може да възникне изключение ArgumentException. Това означава, че избраният от нас encoding не се поддържа.

Друга често срещана грешка е IOException. Това е базов клас за всички входно-изходни грешки при работа с потоци.

Стандартният подход при обработване на изключения при работа с файлове е следният: декларираме променливите от клас StreamReader или StreamWriter в try-catch блок. В блока ги инициализираме с нуж­ните ни стойности и прихващаме и обработваме потенциалните грешки по подходящ начин. За затваряне на потоците използваме конструкция using. За да онагледим казаното до тук, ще дадем пример.

Прихващане на грешка при отваряне на файл – пример

Ето как можем да прихванем изключенията, настъпващи при работа с файлове:

class HandlingExceptions

{

      static void Main()

      {

            string fileName = @"somedir\somefile.txt";

            try

            {

                  StreamReader reader = new StreamReader(fileName);

                  Console.WriteLine(

                        "File {0} successfully opened.", fileName);

                  Console.WriteLine("File contents:");

                  using (reader)

                  {

                        Console.WriteLine(reader.ReadToEnd());

                  }

            }

            catch (FileNotFoundException)

            {

                  Console.Error.WriteLine(

                        "Can not find file {0}.", fileName);

            }

            catch (DirectoryNotFoundException)

            {

                  Console.Error.WriteLine(

                        "Invalid directory in the file path.");

            }

            catch (IOException)

            {

                  Console.Error.WriteLine(

                        "Can not open the file {0}", fileName);

            }

      }

}

Примерът демонстрира четене от файл и печатане на съдържанието му на конзолата. Ако случайно сме объркали името на файла или сме изтрили файла, ще бъде хвърлено изключение от тип FileNotFoundException. В catch блок прихващаме този тип изключение и ако евентуално такова възникне, ще го обработим по подходящ начин и ще отпечатаме на конзолата съобщение, че не може да бъде намерен такъв файл. Същото ще се случи и ако не съществува директория с името "somedir". Накрая за подсигу­ряване сме добавили и catch блок за IOException. Там ще попадат всички останали входно-изходни изключения, които биха могли да настъпят при работата с файла.

Текстови файлове – още примери

Надяваме се теоретичните обяснения и примерите досега да са успели да ви помогнат да навлезете в тънкостите при работа с текстови файлове. Сега ще разгледаме още няколко по-комплексни примери с цел да затвърдим получените до момента знания и да онагледим как да ги ползваме при решаването на практически задачи.

Брой срещания на подниз във файл – пример

Ето как може да реализираме проста програма, която брои колко пъти се среща даден подниз в даден текстов файл. В примера нека търсим подниз "C#", а текстовият файл има следното съдържание:

sample.txt

This is our "Intro to Programming in C#" book.

In it you will learn the basics of C# programming.

You will find out how nice C# is.

Броенето можем да направим така: ще прочитаме файла ред по ред и всеки път, когато срещнем търсената от нас дума, ще увеличаваме стой­ността на една променлива (брояч). Ще обработим възможните изключи­телни ситуации, за да може потребителят да получава адекватна инфор­мация при появата на грешки. Ето и примерна реализация:

CountingWordOccurrences.cs

static void Main()

{

      string fileName = @"..\..\sample.txt";

      string word = "C#";

      try

      {

            StreamReader reader = new StreamReader(fileName);

            using (reader)

            {

                  int occurrences = 0;

                  string line = reader.ReadLine();

                  while (line != null)

                  {

                        int index = line.IndexOf(word);

                        while (index != -1)

                        {

                              occurrences++;

                              index = line.IndexOf(word, (index + 1));

                        }

                        line = reader.ReadLine();

                  }

                  Console.WriteLine(

                        "The word {0} occurs {1} times.", word, occurrences);

            }

      }

      catch (FileNotFoundException)

      {

            Console.Error.WriteLine(

                  "Can not find file {0}.", fileName);

      }

      catch (IOException)

      {

            Console.Error.WriteLine(

                  "Can not read the file {0}.", fileName);

      }

}

За краткост в примерния код думата, която търсим, е твърдо зададена (hardcoded). Вие може да реализирате програмата така, че да търси дума, въведена от потребителя.

Виждате, че примерът не се различава много от предишните. В него инициализираме променливите извън try-catch блока. Пак използваме while-цикъл, за да прочитаме редовете на текстовия файл един по един. Вътре в тялото му има още един while-цикъл, с който преброяваме колко пъти се среща думата в дадения ред и увеличаваме брояча на среща­нията. Това става като използваме метода IndexOf(…) от класа String (припомнете си какво прави той в случай, че сте забравили). Не пропускаме да си гарантираме затварянето на StreamReader обекта из­ползвайки using кон­струкцията. Единственото, което после ни остава да направим, е да изведем резултата в конзолата.

За нашия пример резултатът е следният:

The word C# occurs 3 times.

Коригиране на файл със субтитри – пример

Сега ще разгледаме един по-сложен пример, в който едновременно четем от един файл и записваме в друг. Става дума за програма, която коригира файл със субтитри за някакъв филм.

Нашата цел ще бъде да изчетем един файл със субтитри, които са некоректни и не се появяват в точния момент и да отместим времената по подходящ начин, за да се появяват правилно. Един такъв файл в общия случай съдържа времето за появяване на екрана, времето за скриване от екрана и текста, който трябва да се появи в дефинирания интервал от време. Ето как изглежда един типичен файл със субтитри:

GORA.sub

{1029}{1122}{Y:i}Капитане, системите са|в готовност.

{1123}{1270}{Y:i}Налягането е стабилно.|- Пригответе се за кацане.

{1343}{1468}{Y:i}Моля, затегнете коланите|и се настанете по местата си.

{1509}{1610}{Y:i}Координати 5.6|- Пет, пет, шест, точка ком.

{1632}{1718}{Y:i}Къде се дянаха|координатите?

{1756}{1820}Командир Логар,|всички говорят на английски.

{1821}{1938}Не може ли да преминем|на сръбски още от началото?

{1942}{1992}Може!

{3104}{3228}{Y:b}Г.О.Р.А.|филм за космоса

...

За да го коригираме, просто трябва да нанесем корекция във времето за показване на субтитрите. Такава корекция може да бъде отместване (до­бавяне или изваждане на някаква константа) или промяна на ско­ростта (умножаване по някакъв коефициент, примерно 1.05).

Ето и приме­рен код, с който може да реализираме такава програма:

FixingSubtitles.cs

using System;

using System.IO;

 

class FixingSubtitles

{

      const double COEFFICIENT = 1.05;

      const int ADDITION = 5000;

      const string INPUT_FILE = @"..\..\source.sub";

      const string OUTPUT_FILE = @"..\..\fixed.sub";

 

      static void Main()

      {

            try

            {

                  // Getting the Cyrillic encoding

                  System.Text.Encoding encoding =

                        System.Text.Encoding.GetEncoding(1251);

 

                  // Create reader with the Cyrillic encoding

                  StreamReader streamReader =

                        new StreamReader(INPUT_FILE, encoding);

 

                  // Create writer with the Cyrillic encoding

                  StreamWriter streamWriter =

                        new StreamWriter(OUTPUT_FILE, false, encoding);

 

                  using (streamReader)

                  {

                        using (streamWriter)

                        {

                              string line;

                              while ((line = streamReader.ReadLine()) != null)

                              {

                                    streamWriter.WriteLine(FixLine(line));

                              }

                        }

                  }

            }

            catch (IOException exc)

            {

                  Console.WriteLine("Error: {0}.", exc.Message);

            }

      }

 

      static string FixLine(string line)

      {

            // Find closing brace

            int bracketFromIndex = line.IndexOf('}');

 

            // Extract 'from' time

            string fromTime = line.Substring(1, bracketFromIndex - 1);

 

            // Calculate new 'from' time

            int newFromTime = (int) (Convert.ToInt32(fromTime) *

                  COEFFICIENT + ADDITION);

 

            // Find the following closing brace

            int bracketToIndex = line.IndexOf('}',

                  bracketFromIndex + 1);

 

            // Extract 'to' time

            string toTime = line.Substring(bracketFromIndex + 2,

                  bracketToIndex - bracketFromIndex - 2);

 

            // Calculate new 'to' time

            int newToTime = (int) (Convert.ToInt32(toTime) *

                  COEFFICIENT + ADDITION);

 

            // Create a new line using the new 'from' and 'to' times

            string fixedLine = "{" + newFromTime + "}" + "{" +

                  newToTime + "}" + line.Substring(bracketToIndex + 1);

 

            return fixedLine;

      }

}

В примера създаваме StreamReader и StreamWriter и задаваме да използват encoding "Windows-1251", защото ще работим с файлове, съдър­жащи кирилица. Отново използваме вече поз­натия ни начин за четене на файл ред по ред. Различното този път е, че в тялото на цикъла записваме всеки ред във файла с вече коригирани субтитри, след като го поправим в метода FixLine(string) (този метод не е обект на нашата дискусия, тъй като може да бъде имплементиран по много и различни начини в зависи­мост какво точно искаме да кориги­раме). Тъй като използваме using блокове за двата файла, си гарантираме, че те задължително се затварят, дори ако при обработката възникне изключение (това може да случи например, ако някой от редовете във файла не е в очаквания формат).

Упражнения

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

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

3.    Напишете програма, която прочита съдържанието на текстов файл и вмъква номерата на редовете в началото на всеки ред и след това записва обратно съдържанието на файла.

4.    Напишете програма, която сравнява ред по ред два текстови файла с еднакъв брой редове и отпечатва броя съвпадащи и броя различни редове.

5.    Напишете програма, която чете от файл квадратна матрица от цели числа и намира подматрицата с размери 2 х 2 с най-голяма сума и записва тази сума в отделен текстов файл. Първият ред на входния файл съдържа големината на записаната матрица (N). Следващите N реда съдържат по N числа, разделени с интервал.

Примерен входен файл:

4

2 3 3 4

0 2 3 4

3 7 1 2

4 3 3 2

Примерен изход: 17.

6.    Напишете програма, която чете списък от имена от текстов файл, подрежда ги по азбучен ред и ги запазва в друг файл. Имената са записани по едно на ред.

7.    Напишете програма, която заменя всяко срещане на подниза "start" с "finish" в текстов файл. Можете ли да пренапишете програмата така, че да заменя само цели думи? Работи ли програмата за големи фай­лове (примерно 800 MB)?

8.    Напишете предната програма така, че да заменя само целите думи (не части от думи).

9.    Напишете програма, която изтрива от текстов файл всички нечетни редове.

10. Напишете програма, която извлича от XML файл всичкия текст без таговете. Примерен входен файл:

<?xml version="1.0"><student><name>Pesho</name>
<age>21</age><interests count="3"><interest> Games</instrest><interest>C#</instrest><interest> Java</instrest></interests></student>

Примерен резултат:

Pesho

21

Games

C#

Java

11. Напишете програма, която изтрива от текстов файл всички думи, които започват с "test". Думите съдържат само символите 0...9, a…z, A…Z,_.

12. Даден е текстов файл words.txt, съдържащ списък от думи, по една на ред. Напишете програма, която изтрива от файла text.txt всички думи, които се срещат в другия файл. Прихванете всички възможни изключения (Exceptions).

13. Напишете програма, която прочита списък от думи от файл, наречен words.txt, преброява колко пъти всяка от тези думи се среща в друг файл text.txt и записва резултата в трети файл – result.txt, като преди това ги сортира по броя срещания в намаляващ ред. Прихванете всички възможни изключения (Exceptions).

Решения и упътвания

1.    Използвайте примерите, които разгледахме в настоящата глава. Използвайте using конструкцията за да гарантиране коректното затва­ряне на входния и резултатния поток.

2.    Ще трябва първо да прочетете първия входен файл ред по ред и да го запишете в резул­татния файл в режим презаписване (overwrite). След това трябва да отворите втория входен файл и да го запишете ред по ред в резултатния файл в режим добавяне (append). За да създадете StreamWriter в режим презаписване / добавяне използвайте подходящ конструктор (намерете го в MSDN).

Алтернативен начин е да прочетете двата файла в string с ReadToEnd(), да ги съедините в паметта и да ги запишете в резултатния файл. Този подход, обаче няма да работи за големи файлове (от порядъка на няколко гигабайта).

3.    Следвайте примерите от настоящата глава. Помислете как бихте се справили със задачата, ако размера на файла е огромен (например няколко GB).

4.    Следвайте примерите от настоящата глава. Ще трябва да отворите двата файла за четене едновременно и в цикъл да ги четете ред по ред заедно. Ако срещнете край на файл (т.е. прочетете null), който не съвпада с край на другия файл, значи двата файла съдържат различен брой редове и трябва да изведете съобщение за грешка.

5.    Прочетете първия ред от файла и създайте матрица с прочетения размер. След това четете останалите редове един по един и отделяйте числата. След това ги записвайте на съответния ред в матрицата. Накрая намерете с два вложени цикъла търсената подматрица.

6.    Записвайте всяко прочетено име в списък (List<string>), след това го сортирайте по подходящ начин (потърсете информация за метода Sort()) и накрая го отпечатайте в резултатния файл.

7.    Четете файла ред по ред и използвайте методите на класа String. Ако зареждате целия файл в паметта вместо да го четете ред по ред, ще има проблеми при зареждане на големи файлове.

8.    За всяко срещане на ‘start’ ще проверявате дали това е цялата дума или само част от дума.

9.    Работете по аналогия на примерите от настоящата глава.

10. Четете входния файл символ по символ. Когато срещнете "<", значи започва таг, а когато срещнете ">" значи тагът завършва. Всички символи, които срещате, които са извън таговете, изграждат текста, който трябва да се извлече. Можете да го натрупвате в StringBuilder и да го печатате, когато срещнете "<" или достигнете края на файла.

11. Четете файла ред по ред и заменяйте думите, които започват с "test" с празен низ. За целта използвайте Regex.Replace(…) с подходящ регулярен израз. Алтернативно можете да търсите в прочетения ред от файла подниз "test" и всеки път, когато го намерите да вземете всички съседни на него букви вляво и вдясно. Така намирате думата, в която низът "test" участва и можете да я изтриете, ако започва с "test".

12. Задачата е подобна на предходната. Можете да четете текста ред по ред и да заменяте в него всяка от дадените думи с празен низ. Тествайте дали вашата задача обработва правилно изключенията като симулирате възможни сценарии (например липса на файл, липса на права за четене и писане и т.н.)002E

13. Създайте хеш-таблица с ключ думите от words.txt и стойност броя срещания на всяка дума (Dictionary<string, int>). Първоначално запишете в хеш-таблицата, че всички думи се срещат по 0 пъти. След това четете ред по ред файла text.txt и разделяйте всеки ред на думи. Проверявайте дали всяка от получените при разде­лянето думи се среща в хеш-таблицата и ако е така прибавяйте 1 към броя на срещанията й. Накрая запишете всички думи и броя им срещания в масив от тип KeyValuePair<string, int>. Сортирайте масива пода­вайки подходяща функция за сравнение, например по средния начин:

Array.Sort<KeyValuePair<string, int>>(

          arr, (a, b) => a.Value.CompareTo(b.Value));

Демонстрации (сорс код)

Изтеглете демонстрационните примери към настоящата глава от книгата: Текстови-файлове-Демонстрации.zip.

Дискусионен форум

Коментирайте книгата и задачите в нея във: форума на софтуерната академия.

2 отговора до “Глава 15. Текстови файлове”

  1. stefan says:

    Moje li oshte zadachki ?

Отговори на stefan

Трябва да сте влезнали, за да коментирате.