С того момента, как была написана первая строчка кода на языке C, прошло больше 30 лет. За это время были созданы миллионы программ - от миниатюрных утилит, исходный код которых полностью умещается на экране, до огромных программных комплексов, состоящих из миллионов строк кода. Наверняка ни для кого не секрет, что многие алгоритмы и типовые задачи реализованы разными программистами по сотне и тысяче раз каждый, что дублирование кода и низкий процент повторного использования реализации того или иного алгоритма - одна из наболевших проблем в индустрии ПО. И причиной тому, как правило, вовсе не вопросы лицензионного характера или нежелание программистов поделиться с коллегами результатами своего труда, а несовершенство языковых средств, затрудняющее создание повторно используемого кода: практически полное отсутствие поддержки обобщенных алгоритмов, слабая типизация, отсутствие механизма представления абстракций предметной области... Список можно продолжать, но результат известен заранее: несмотря на наличие большого числа готовых реализаций той или иной задачи на C, мы снова и снова решаем ее, стоит условиям хоть немного измениться. И даже при наличии готовой библиотеки, написанной кем-то другим, зачастую не используем ее, потому что проще написать свой код с нуля, чем разбираться в чужом.

Языки программирования совершенствовались, после C появился C with classes Бьярна Страуструпа. Через некоторое время стало ясно, что название языка уже не отражает его сущности, а именно - язык этот идеологически значительно отличается от C. Поэтому языку C with classes дали новое название, C++, чтобы подчеркнуть как его связь (совместимость) с предшественником, так и «более высокий уровень» по сравнению с ним.

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

Один из наиболее ярких примеров удачной реализации повторно используемых компонентов - стандартная библиотека шаблонов (Standard Template Library, STL), разработанная фирмой SGI и получившая широкое признание благодаря изящной архитектуре, единому стилю, эффективной реализации, компактности как исходного, так и скомпилированного кода. Версии этой библиотеки существуют практически для всех платформ, где есть компилятор C++ (одной из наиболее популярных многоплатформенных реализаций - и одной из самых эффективных - по праву считается STLport Борис Фомичев, http://www.stlport.org).

Ровно три года назад, в августе 1998 г., был принят стандарт языка C++, включающий в себя библиотеку STL. За время существования STL появилось довольно много компонентов на ее основе. Число программистов, активно использующих данную библиотеку, быстро растет, и те, кто оценил ее удобство и эффективность, вряд ли станут писать реализацию списка, стека или функции линейного поиска - разве что судьба забросит их на какую-нибудь экзотическую платформу, для которой нет STL.

Что такое поток данных?

Потоки данных - повсюду. Они проникают в наше сознание через глаза и уши, они незаметно пронизывают нас электромагнитными волнами радиопередач и пакетов данных сотовой связи. В компьютерном мире поток данных - не менее важное понятие, ведь любая операция ввода/вывода данных или пересылки информации от одного компьютера другому либо обмен информацией между приложениями в конечном итоге могут быть сведены к абстракции потока: поток данных - это последовательный прием или передача однотипных элементов данных. Например, вывод символьного представления числа в текстовый файл - это поток байтов. Вообще, «то, что разработчик программ считает выводом, на самом деле является преобразованием объектов некоторого типа, такого, как int, char, или Employee_record, в последовательность символов» (Бьерн Страуструп, «Язык программирования C++»). Таким образом, потоки - это и обмен информацией клиента с сервером через Internet, и диалог с модемом через последовательный порт, и сообщения о событиях и ошибках, появляющиеся в файле журнала программы. Поскольку поток - очень важная и часто встречающаяся абстракция, было бы странно, если бы она не была реализована в виде набора обобщенных алгоритмов. Наиболее известной ее реализацией в языке C++ являются потоки в STL.

В этой статье мы рассмотрим использование механизма потоков STL при работе с последовательным портом и обмене информации по сети с удаленным компьютером.

Почему STL?

  • В нашем несовершенном мире не так уж много явлений, которые можно обозначить понятием The Right Thing (см. http://www.tuxedo.org/~esr/jargon/html/entry/Right-Thing.html). Это в полной мере относится и к программированию: количество добротного, эффективного и одновременно красивого кода удручающе невелико. Однако именно разработчикам STL удалось создать нечто приближенное к понятию «код убойной силы» (так мой приятель, которого я считаю программистом с большой буквы, обозначает краткий, изящный и в то же время эффективный код). Возможно, далеко не все согласятся с таким утверждением: STL поначалу кажется ужасно запутанной и сложной в использовании. К счастью - только поначалу. Итак, первая, эстетическая, часть ответа на вопрос подзаголовка: «потому что STL - это красиво».
  • Канонизация эффективных, «правильных» решений - одно из условий снижения энтропии, к чему человечество, вроде бы, должно стремиться. В индустрии ПО принятие стандартов и следование им - один из важнейших аспектов универсализации, создания повторно используемого кода. STL - часть стандарта языка C++, поэтому вторая, каноническая, часть ответа - «потому что STL стандартизирована».
  • Полиморфизм и отделение интерфейса от реализации, использование обобщенных алгоритмов - основополагающие принципы современного объектно-ориентированного программирования. STL предоставляет некий абстрактный интерфейс для работы с потоковыми данными, который в общем случае не привязан ни к типам данных, помещаемых в поток/извлекаемых из него, ни к физическому представлению потока, ни даже к типу его элементов. В то же время библиотека предоставляет готовые реализации «наиболее популярных» потоков: файловый и строковый потоки символов. Третья, объектно-ориентированная, часть ответа - «потому что STL обладает потрясающей гибкостью».
  • Инструмент должен облегчать работу, а не усложнять ее. Если мы хотим вывести на экран значение целой знаковой переменной как восьмеричное число в поле шириной в 12 символов (с выравниванием вправо), за ним - шестнадцатеричный код символа в поле шириной в 3 символа с выравниванием влево, а после - строку «вот так!» с выравниванием по умолчанию, то хорошо бы иметь возможность выразить это какой-нибудь простой конструкцией. Так, чтобы почти ничего не менять, даже если захочется вместо вывода на экран послать то же самое через полмира по сети. Например,
    int foo = 42;
    char bar = '@';
    cout << setw(12) << ios::oct << foo <<
     setw(2) << ios::hex << bar << «вот так!»;
    ...или подставить вместо cout объект класса iosockinet - тогда вывод пойдет на удаленный компьютер.

Итак, четвертая, очевидная часть ответа: «Потому что на STL такие вещи делаются просто!»

  • Компьютеры становятся все быстрее, а программы «пожирают» все больше ресурсов наших скоростных компьютеров, так что прирост производительности зачастую почти незаметен... Не странно ли? Неужели весь накопленный опыт программирования бесполезен? Да, бесполезен - если пренебрегать им. Эффективная реализация, как по размеру получаемого кода, так и по скорости выполнения, - один из важнейших принципов STL. Благодаря хорошо продуманной архитектуре, использованию шаблонов C++ (которые при компиляции разворачиваются в легко оптимизирующийся код) и тщательнейшей отладке, STL стала одной из наиболее эффективных библиотек. Для большинства алгоритмов STL существует оценка временной характеристики. Практика показывает, что продуманный код с использованием шаблонов и, в частности, STL сопоставим по скорости и размеру с кодом без шаблонов или кодом на чистом C. На некотором этапе освоения C++ код, в котором активно (и, главное, - правильно) используются концепции ООП, нередко оказывается эффективнее кода на чистом C! Зачастую это удивительно. И всегда приятно! Пятая, умиротворяющая и заключительная, часть ответа на вопрос «почему STL?» звучит так: «Потому что эффективность STL проверена временем и доказана сотнями тысяч программ, использующих ее».

Концепция STL stream

STL использует обобщенный механизм потокового ввода/вывода, основным понятием которого является stream (поток) - последовательно считываемые или записываемые элементы данных. Если присмотреться к абстракции потока, можно заметить, что она состоит из двух частей: входного потока и выходного. Действительно, файловый поток часто открывается только для чтения или только для записи; в клавиатуру (по крайней мере, стандартную) нельзя записать символ - можно лишь дождаться прихода символа от нее. Есть и двунаправленные потоки - например, сессия по протоколу telnet или обмен данными между компьюте-ром и модемом, но двунаправлен-ный поток с успехом можно сконструировать из двух готовых однонаправленных.

В STL базовый класс входного потока называется basic_istream<> и объявлен так:

template  >
class basic_istream ...

базовый класс выходного потока -

template  >
class basic_ostream ...

а базовый класс двунаправленного потока -

template  >
class basic_iostream : public basic_istream,
public basic_ostream

Все эти шаблоны параметризуются формальными аргументами Ch и Tr. Ch - тип единичного элемента данных, Tr - класс, предоставляющий информацию об этом типе. Чаще всего мы сталкиваемся с потоками байтов или символов - т. е. с потоками, состоящими из элементов типа char; либо, если в системе используются символы Unicode, - wchar_t. Для таких потоков STL объявляет короткие имена (см. Листинг 1).

Собственно классы потоков редко реализуют «настоящие» операции ввода/вывода самостоятельно, поскольку, как правило, требуется буферизованный ввод/вывод. Поэтому важной частью механизма потокового ввода/вывода является шаблон класса basic_streambuf<>, базовый для конкретных реализаций буферов в классах-потомках. Примером буферизованного потока может служить консоль (std::cout): символы, помещаемые в поток, буферизуются и выводятся на физическое устройство только в том случае, если в поток помещен символ конца строки либо явно вызван метод flush().

Поскольку потоки используются, в частности, для организации взаимодействия пользователя с компьютером, в библиотеке предусмотрены специальные средства, позволяющие форматировать выводимую информацию и задавать формат вводимой. Например, можно указать формат представления чисел (десятичное, шестнадцатеричное или восьмеричное), задать ширину поля и выравнивание вправо или влево, заполнить неиспользованную часть поля заданным символом и т. п. Кроме того, потоки учитывают национальные особенности посредством применения классов locale; потоки предоставляют операции позиционирования указателя потока по абсолютной позиции от начала или конца потока либо по смещению относительно текущей позиции - если, конечно, конкретный тип потока такую функцию поддерживает. Потоки STL предусматривают использование как двоичных, так и текстовых данных, поэтому одинаково легко выполнять ввод/вывод и обработку как машинно-ориентированной, так и ориентированной на человека информации.

Путем наследования от базового класса потока легко реализуется работа с конкретными типами потоков, такими, как файловые, строковые (потоки, хранящие данные в оперативной памяти), потоки, реализующие TCP-соединение, и т. д.

Вывод в поток совершается при вызове метода write() выходного потока либо с помощью operator<<(), который реализован для всех встроенных в C++ типов данных, а также для типа std::string из STL; конечно же возможность вывода в поток данных, тип которых определен пользователем, реализуется простой перегрузкой оператора вывода. То же относится и к операциям извлечения информации из потока: ввод из потока возможен как через функции read(), get(), getline(), так и через operator>>().

Для изменения формата выводимой информации и других параметров потока используются соответствующие методы, или манипуляторы, которые делают код более наглядным. Так, если нужно, чтобы буферизованный поток выполнил физическую операцию вывода, можно воспользоваться методом flush() или манипулятором потока flush:

cout << «Copying file...» << flush;
if (copyfile(...))
cout << endl;

Примеры использования потоков

Сериализация объектов. При слове «сериализация» любой программист, который достаточно долго работал с MSVC++, вспоминает библиотеку MFC: часто именно с нее начинается знакомство с понятием сериализации данных. В MFC объект сохраняет свое состояние в некоем хранилище, представленном базовым классом CAr-chive. Для этого объект должен реализовать виртуальные методы Save() и Load(). Если не вдаваться в подробности, такое описание дает примерное представление не только о сериализации в MFC, но и о механизме сериализации как таковом. Кроме MFC существует большое число библиотек, которые обеспечивают сохранность состояния объекта во внешнем хранилище и восстанавление из него. С понятием сериализации тесно связано понятие персистентности объекта, подразумевающее динамическое создание объекта на основе имеющейся в хранилище информации о состоянии объекта и его типе. MFC предоставляет полный набор инструментов, необходимый для реализации персистентности, однако нужно признать, что MFC на сегодняшний день уже далеко не лучшая инструментальная библиотека. Дизайн и концепции, заложенные в MFC, безнадежно устарели в смысле ООП, и вряд ли это утверждение необходимо доказывать: достаточно приглядеться к архитектуре MFC, с ее неприятием множественного наследования, трудностью (а иногда и невозможностью) использования параметризованных классов и т. д. Фактически, для применения MFC в наше время нужны довольно веские основания. Конечно, не следует переписывать проекты, в которых уже используется эта библиотека, но при разработке «с нуля» стоит задуматься: а нужна ли MFC? Если некая подсистема не имеет графического интерфейса - ответ однозначный: нет. Вся невизуальная функциональность MFC достаточно легко (и, как правило, более эффективно) реализуется другими средствами: библиотеками независимых производителей, STL, наконец, средствами собственной разработки, которые достаточно написать один раз. В качестве хранилища объектов удобно использовать потоки STL, что позволит сохранять объекты и восстанавливать их из любого потока-наследника basic_[io]stream<>.

Реализация текстового протокола. Разработчики большинства протоколов логического уровня, используемых для обмена информацией в Internet, стремились к тому, чтобы эти протоколы были понятны и программе (почтовый клиент, ftp-клиент, web browser), и человеку (на любой из серверов ftp, smtp, pop3, nntp можно зайти telnet-клиентом). Например, протокол посылки письма через smtp-сервер выглядит примерно так, как представлено в Листинге 2.

Как видите, сообщения smtp-сервера всегда начинаются с трехзначного числа, а вернее было бы сказать - с трех последовательных десятичных цифр. Значения их, как правило, стандартизированы. Так, если в первой позиции цифра 2 - значит, последняя операция завершилась успешно. Если 3 - значит, промежуточный этап операции прошел нормально, но от клиента требуются дополнительные действия для ее завершения. В данном случае сервер ответил кодом 354 на команду data (запрос на ввод текста письма), после кода следует объяснение - какие действия ожидаются от клиента для завершения операции («Введите сообщение, окончив его строкой с единственным символом «.» на ней). После выполнения этого требования сервер ответил «250 OK...», т. е. операция завершилась успешно.

Коды неудачного завершения - 4 и 5. До боли знакомый ответ http-сервера «404 Not Found» означает фатальную ошибку, которая не может быть устранена пользователем. Он начинается с цифры 4. Код ответа, начинающийся с цифры 5, например, в ответ на ввод пароля «550 Access Denied» означает, что ошибка совершена клиентом (неверно введен пароль) и может быть им исправлена, если, допустим, пользователь вспомнит верный пароль и повторит команду ввода.

Как уже отмечалось, потоки STL довольно удобны для работы с информацией, ориентированной на пользователя (human-readable). Предположим, у нас есть класс iosockinet, реализующий двунаправленное TCP-соединение и являющийся наследником basic_iostream. Тогда приведенный выше диалог с smtp-сервером мог бы выглядеть в коде на C++ так, как показано в Листинге 3.

Сетевые потоки и потоки для работы с последовательным портом. Так случилось, что в рамках довольно крупного проекта потребовалось передавать информацию по TCPIP и через последовательный порт. Характер информации - состояние неких объектов, сериализованное в поток. В качестве хранилища сериализованных данных использовались потоки STL, поэтому весьма логичным решением для передачи данных представлялась реализация соответствующих потоков. За основу классов, реализующих поток TCP/IP, была взята библиотека Socket++ by Gnanasekaran Swami-nathan, 1996. Библиотека была перенесена на Win32 (изначально написана под UNIX) и в значительной мере переработана (исходная версия рассчитана на работу с ANSI iost-ream).

Сетевые потоки представлены следующими классами:

class sockbuf : public streambuf;
class isockstream : public istream;
class osockstream : public ostream;
class iosockstream : public iostream;

class sockinetbuf : public sockbuf;
class isockinet : public isockstream;
class osockinet : public osockstream;
class iosockinet : public iostream;

Как отмечалось в предыдущем разделе, основная функциональность потоков-наследников basic_[io]stream<> реализуется не самими классами-наследниками, а классами буферов: sockbuf и sockinetbuf. Классы потоков лишь добавляют родительским классам функциональность, необходимую для создания соответствующих буферов и доступа к ним (доступ к буферу потока реализуется перегрузкой оператора ->), в чем нетрудно убедиться, изучив исходные тексты. Поэтому наше внимание будет сосредоточено именно на классах sockbuf и sockinetbuf.

Класс sockbuf предоставляет интерфейс для работы с сокетами (в Win32 - библиотека Winsock). Сокет может реализовать работу по различным протоколам, поэтому реализация TCP/IP-сокета вынесена в отдельный класс sockinetbuf, наследник sockbuf. Поэтому, если бы мы захотели реализовать работу по протоколу IrDA, достаточно было бы унаследовать класс (например, sockinfraredbuf) от sockbuf и реализовать в нем необходимые методы. Как правило, в классе-наследнике sockbuf достаточно определить методы, специфичные для данного протокола (IrDA, TCP/IP), поскольку общие для всех разновидностей сокетов операции над буфером уже реализованы в sockbuf.

В Листинге 4 приведены объявления виртуальных методов класса streambuf, переопределенные в sockbuf. Назначение этих методов можно посмотреть в документации по STL. Другие методы - такие, как connect(), bind(), select(), - являются обертками или аналогами соответствующих вызовов библиотеки Winsock.

Поток для работы с последовательным портом реализован следующими классами:

commbuf : public streambuf;
icommstream : public istream;
ocommstream : public ostream;
iocommstream : public iostream;

Как и в случае с сетевыми потоками, основная работа выполняется классом commbuf. Реализация сводится к переопределению тех же методов класса streambuf и добавлению методов для открытия последовательного порта - open(), установки его параметров - dcb() и т. п. Здесь нет смысла подробно рассматривать детали реализации, так как они ясны из исходных текстов.

Заключение

Перечитав статью, я понял, что затронул весьма обширную тему. В силу ограниченного объема статья получилась несколько эклектичной и не столь стройной, подробной и последовательной, как мне бы хотелось. Однако, если вы лишь начинаете осваивать STL и по прочтении этой статьи хотя бы немного заинтересовались механизмом потоков и его потенциальными возможностями, я считаю, что моя цель достигнута.

Левон Геворкян - программист компании «Геолинк Электроникс». С ним можно связаться по адресу: levong@chat.ru.