Сообщений 17    Оценка 321        Оценить  
Система Orphus

GUI-приложение на .NET за 0x4EC секунд

Автор: Владислав Чистяков
The RSDN Group

Источник: RSDN Magazine #1
Опубликовано: 26.10.2002
Исправлено: 15.04.2009
Версия текста: 1.0.2
Введение
Проектирование
Определяемся с целями!
Соглашения об именовании элементов
Подготовка к работе
Дизайн формы
Закладка «Settings»
Размещение элементов управления
Сохранение настроек в файле
Загрузка настроек из файла
О наворотах, без которых никуда
Настройка ToolBar
Функциональные классы
AscSearchEngine
AscDir
AscRegExpParser
Закладка «Preview»
Пакетная обработка
Обработка параметров командной строки
Заключение

Проект приложения (C#)
Инсталлятор исполняемых файлов

Введение

Идея этой статьи родилась при анализе голосования о том, какие статьи нужно публиковать в RSDN Magazine. Самыми частыми ответами были «чё-нить про C# и .Net» и «создание приложений». Цель этой статьи - продемонстрировать основные принципы создания GUI-приложений с помощью .Net Framework. Я попытаюсь описать процесс создания приложения RegExRep. При его создании использовалась VS.Net (язык C#). Это приложение позволяет осуществлять контекстную замену текста в файлах некоторого типа, находящихся в одном каталоге (и подкаталогах).

Проектирование

По большому счету есть два стиля разработки ПО.

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

Второй – RAD-стиль, или стиль VB. Это стиль программирования, в котором проектирование заканчивается на довольно ранней стадии, а многие решения принимаются программистом непосредственно в процессе разработки. Использование этого стиля не отрицает проектирования, просто проектирование частично сливается с кодированием. Так как .Net Framework является, несомненно, RAD-остным продуктом, то и я буду придерживаться именно этого стиля разработки. Тем более, что такой стиль мне по душе. Однако, RAD-ости RAD-остями, а определить цели и хотя бы примерные пути их реализации нужно.

Определяемся с целями!

Для начала нужно сформулировать, что мы хотим получить от нового приложения?

Итак, RegExRep – это GUI-приложение, которое должно позволять производить поиск файлов и контекстную замену в них. Для того, чтобы сделать поиск и замену более гибкой, неплохо было бы обеспечить поддержку регулярных выражений.

Прототипом для RegExRep может являться контекстная замена в VS.Net. Приятными возможностями в ней являются возможность поиска без замены (своего рода предварительный просмотр) и интерактивной замены, выдача диагностических сообщений, поддержка регулярных выражений. Слабыми: очень медленная работа, функционально слабые регулярные выражения, неприемлемо большие трудности, возникающие при попытке производить контекстную замену, очень большой размер программы, долгая инсталляция, и, наконец, дороговизна.

Исходя из этого создадим список возможностей (фич :) ) программы:

1. Поиск и контекстная замена по файлам.

    1.1. В момент поиска и замены пользовательский интерфейс должен блокироваться, а на экран выводиться диалоговое окно с индикацией прогресса и кнопкой отмены.

2. Графический интерфейс пользователя (GUI).

    2.1. Так как все части программы не поместятся на одной форме, их придется разделять. Можно было бы использовать отдельные диалоги, но это довольно неудобно. Лучше предпочесть форму с закладками (как в страницах свойств файлов в Windows Explorer).

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

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

      2.3.1. Все настройки должны задаваться интерактивно через стандартные элементы управления Windows.

      2.3.2. Все элементы интерфейса настройки поиска и замены должны быть одновременно видны пользователю, так что его следует вынести на отдельную закладку (Settings).

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

      2.4.1. Так как предварительный просмотр занимает довольно большую площадь экрана, необходимо вынести его на отдельную закладку (Preview).

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

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

      2.4.4. Выбранный файл должен показываться в текстовом окне.

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

      2.4.6. Предварительный просмотр по возможности должен сохранять просматриваемый файл и позицию в нем для обеспечения интерактивности.

      2.4.7. Предварительный просмотр должен работать максимально быстро, чтобы обеспечить интерактивность.

    2.5. Необходимо обеспечить вывод диагностической и статистической информации, т.е. Log-выполнения. Чтобы не захламлять интерфейс, вынести связанный с этим GUI на отдельную закладку.

3. Обеспечить пакетную замену с возможностью выполнения нескольких замен последовательно.

    3.1. Приложение должно поддерживать интерфейс командной строки.

    3.2. При пакетной замене должен показываться процент выполнения работы и должна быть доступна кнопка отмены.

    3.3. Сохранение и загрузка настроек введенных пользователем в отдельных файлах. Имена конфигурационных файлов задаются через командную строку или через GUI.

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

Но, прежде чем начать, договоримся о соглашения именования элементов управления.

Соглашения об именовании элементов

Для текстовых окон (TextBox) будем давать префикс tb, для переключателей (CheckBox) – cb, для кнопок (Push Buttons) – pb, для TabControl – tab, для страниц TabControl-а – tp, для элементов меню (Menu Items) – mi, для кнопок тулбара – tbb, для разделителей тулбара – tbs, для сплитеров – spl. Эти префиксы дадут возможность легко определять элемент управления по его названию и облегчать поиск нужного элемента при использовании Complete Word в VS.Net.

Подготовка к работе

Теперь настало время создать проект. С этим, думаю, вы справитесь сами. Отмечу только, что для создания Win32 GUI-приложения нужно выбрать шаблон «Windows Application».

Дизайн формы

Начнем с пунктов 2.1, 2.2, 2.3. Наша форма должна содержать меню, тулбар и окна с закладками, на которых и будет располагаться основной GUI.

В VS.Net дизайн формы претерпел некоторые изменения по сравнению с VB6 и VC. Однако назвать революционно новым его нельзя. Во-первых, концептуально все осталось по старому, а во-вторых, большинство нововведений позаимствованы у Delphi. Так, меню формы стало компонентом. Появились такие удобные возможности, как Docking и Anchors. Меню при всем новаторстве не заслуживает отдельного разговора, так как все что можно сказать его использование – это то, что нужно найти Toolbox-е контрол MainMenu и бросить его на форму, остальные настройки можно сделать в визуальном режиме. К сожалению, реализация меню в .Net Framework не отличается качеством и возможностями. В том же Delphi меню на порядок лучше. Скажу больше, при реализации меню были допущены серьезные просчеты.

Так, класс контекстного меню жестоко зашит в реализацию класса System.Windows.Forms.Control (от которого наследуются все визуальные элементы управления), а метод Show, отвечающий за показ меню, не является виртуальным. В результате изменить поведение контекстного меню подключенного к какому-нибудь контролу невозможно. И что самое смешное, Show содержит ошибку (не задан флаг TPM_RIGHTBUTTON в функции TrackPopupMenuEx). Скорее всего, это досадное упущение – не единственное. Будем надеяться, что в следующей версии .Net Framework Microsoft их исправит или хотя бы предоставит исходные коды, чтобы это могли сделать мы сами.

Ну, да ладно. Мы немножко отвлеклись... Что же такое Docking и Anchors? Docking - это возможность указать элементу управления, что он должен автоматически выравниваться (прилипать) по одной из сторон родительского контрола (parent), в котором он размещен, или, что контрол должен занимать все свободное место в своем родителе. Настойка Docking производится с помощью свойства Dock, которое может принимать значения None, Bottom, Left, Right, Top и Fill. Можно одновременно задать одно и тоже значение свойства Dock для нескольких элементов управления, и они будут как бы прилипать друг к другу. При этом, если размер родителя изменяется, контролы стараются подогнать свой размер и положение, чтобы сохранить выравнивание. Можно использовать вложенный Docking, при котором элемент управления, содержащий другие, выровненные относительно его, элементы, сам выравнивается относительно своего родителя. При Docking-е элементы управления прилегают друг к другу без зазоров (если, конечно, они имеют прямоугольную форму). Внутри одного родителя можно иметь несколько элементов управления с выравниванием Fill, но виден будет только один (остальные будут перекрываться им). В общем, понять правила Docking довольно просто, за одним небольшим исключением... Дело в том, что порядок Docking зависит не от координат элементов управления, а от их z-ордера. Это настолько не интуитивно, что можно часами сидеть, глядя на работающий пример, и не понимать, как он работает. Я сам, разбираясь с VS.Net бета 1, долго не мог понять принципов его работы, и тщетно пытался поставить элемент управления перед предыдущим, чтобы поменять их местами, но они попросту не перетаскивались! Тогда я открыл пример работы со сплитером, поставляемый вместе с VS.Net, и стал сравнивать настройки в моем приложение и в этом примере... Настройки были идентичны, но пример из VS.Net прекрасно работал, а мой сплитер гордо стоял на первом месте, ничего не делая. Никакие настройки свойств всех до единого элементов управления (включая форму) не помогали. Еще бы! Ведь z-ордер в VS.Net настраивается в контекстном меню и никак не отражается на свойствах элементов. Заглянуть в документацию я не догадался, и провел несколько десятков минут, с упоением вспоминая родственников программиста из Microsoft, который сотворил это чудо. Но сумасшедшая на первый взгляд идея с использованием z-ордера оказалась очень гибкой. И за пару десятков секунд можно расположить окна так, как хочется.

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

Итак, поместите меню и тулбар на форму и установите тулбару свойство ButtonSize в «39; 20».

Теперь добавьте на форму TabControl и установите его свойство Docking в Fill. Если при этом исчезнет тулбар (бывает и такое), вызовите контекстное меню TabControl-а и выберите пункт «Send to Back». Теперь в свойствах TabControl выделите свойство TabPages и нажмите кнопку «...», которая появится рядом с этим свойством. Добавьте три закладки и дайте им следующие имена/заголовки: tpSetings/Settings, tpPreview/Preview и tpLog/Log. Нажмите «ОК» и наслаждайтесь плодами своей работы. В лучших традициях Sheridan и Delphi, TabControl позволяет переключать закладки и добавлять на них элементы управления.

Сейчас можно запустить приложение и полюбоваться на него.

Настройте меню File так, чтобы оно соответствовало рисунку 1.


Рисунок 1. Меню «File»

Названия пунктов меню сделайте такими же, как заголовки, но без пробелов. Каждое слово должно начинаться с большой буквы. Каждое имя должно начинаться с префикса mi. Например, пункт должен иметь заголовок «&Browse for folder» (& означает, что следующая за ним буква будет горячей клавишей) и имя «miBrowseForFolder».

Теперь добавьте на форму компонент ImageList и подключите его к тулбару (выбрав в свойстве ImageList). Впоследствии мы будем добавлять в ImageList картинки, которые будут выводиться на форме, как это делалось в VB6 или Delphi.

Закладка «Settings»

Подготовительный этап закончен. Настала пора перейти к реализации пункта 2.3. – закладке «Settings».

Задача этой закладки – предоставить интерфейс для ввода и коррекции параметров поиска и замены. Ниже приведено описание элементов управления на этой закладке:

  1. tbFind – Искомый текст. Образец для поиска в файлах. Может содержать регулярные выражения (подробнее о регулярных выражениях можно прочесть в статье ЧЧЧ лежащей на прилагающемся к журналу компакт диске, или в журнале «Технология Клиент-Сервер» ЧЧЧ), а также в документации к VS.Net (ms-help://MS.VSCC/MS.MSDNVS/cpguide/html/cpconcomregularexpressions.htm – общая информация, ms-help://MS.VSCC/MS.MSDNVS/cpref/html/frlrfSystemTextRegularExpressions.htm –программная модель).
  2. tbReplaceTo – Текст для замены. Может содержать так называемые вхождения регулярных выражений ($X. Где X – это порядковый номер вхождения (Mach)). Более подробно все это разобрано в указанной мною статье.
  3. lblFilesPatern – элемент управления Label с надписью «File's pattern».
  4. tbExtensions – Расширения файлов, используемые для фильтрации файлов.
  5. lblPath – элемент управления Label с надписью «Path».
  6. tbPath – Путь. Директория в которой будет производиться поиск файлов.
  7. pbBrowseFolder – Кнопка (с троеточием, находящаяся рядом с tbPath), позволяющая открыть диалог выбора пути.
  8. cbReplAll и tbReplCount. Количество замен. При замене файла может оказаться, что нужно делать только определенное количество замен. Например, одну. Это может ускорить работу приложения, так как после замены первого вхождения повторный поиск производиться не будет. Если cbReplAll включен, должны заменяться все найденные вхождения. Иначе количество замен берется из tbReplCount.
  9. gbRegExpOptions – GroupBox («RegExp options»), содержащий флаги (см. ниже) регулярных выражений.
  10. Флаги. Это флаги, используемые регулярными выражениями .Net для задания своего поведения. Мы должны дать пользователю возможность настраивать хотя бы самые важные из них. Заодно поясню, что каждый из них означает:

Регулярные выражения .Net поддерживают еще два флага: RightToLeft и ECMAScript. Но они не имеют особого смысла на российских просторах

Внешний вид программы с активной закладкой «Settings» изображен на рисунке 2.


Рисунок 2. Настройки поиска и замены.

Размещение элементов управления

Выше было описано размещение на форме элементов TabControl и ToolBar. Причем Docking позволил нам без программирования добиться автоматического масштабирования этих элементов управления. Теперь пришла пора разместить описанные выше элементы управления на закладке Settings.

Для начала подвергнем анализу эти элементы управления. Некоторые из них не нуждаются в изменении размеров. Это главным образом кнопки и переключатели. Другие же – наоборот.

Так, было бы желательно, чтобы tbFind и tbReplaceTo занимали максимально большую область закладки (не занятую переключателями). При этом неизвестно, где окажется больше текста – в tbFind или в tbReplaceTo. Так как регулярные выражения обычно бывают более компактными, чем обычный текст, скорее всего, tbReplaceTo будет чаще испытывать нужду в большем размере, но реально все будет определять ситуация. Лучше всего сделать горизонтальный сплитер между этими элементами управления. Но как, вы можете увидеть на рисунке Рисунок , tbFind и tbReplaceTo должны занимать не все пространство закладки. Как же быть? Выход из этой ситуации довольно прост. На закладку нужно добавить панель (элемент управления Panel) в который и поместить эти два текстовых окна вместе со сплитером. Так как программно эту панель мы использовать не будем, можно её даже не переименовывать (по умолчанию ее имя – panel1). Итак, добавьте на форму панель. Теперь поместите в нее tbFind и установите значение его свойства Dock в Top. Поместите в эту же панель элемент сплитер (Splitter), и также задайте его свойству Dock значение Top. Добавьте поле tbReplaceTo и задайте его свойству Dock значение Fill. Если после этого начать изменять размеры панели, элементы управления будут автоматически подстраивать свои размеры. В рантайме будет доступен сплитер. С его помощью можно будет изменять высоту tbFind и tbReplaceTo.

Остается только одна проблема. tbFind, tbReplaceTo и сплитер полностью перекрыли панель «panel1». Как теперь ее выделять, чтобы произвести другие настройки? Можно воспользоваться выбором группы, зачерпнув эту панель выделением. В нашем случае это работает, но иногда бывает так, что вся свободная площадь родительского элемента управления занята, и групповое выделение попросту недоступно. В этом случае придется выбирать нужный элемент управления через выпадающий список элементов управления в окне «Properties».

Было бы замечательно сделать так, чтобы при изменении ширины формы (а с ней и TabControl-а) опции регулярных выражений и шаблон поиска файлов прижимались вправо, а панель с текстовыми окнами расширялась (или сужалась), занимая всё оставшееся место. Так же было бы удобно, если бы элементы управления, относящиеся к вводу пути (lblPath, tbPath и pbBroseFolder), при изменении высоты формы прижимались (с небольшим отступом) к нижней части формы, а панель с текстовыми окнами прижималась к ним (тоже с небольшим отступом). Было бы приятно, если бы кнопка pbBroseFolder прижималась вправо, а текстовое поле tbPath занимало максимально допустимую область (по ширине). Все это можно сделать, наплодив множество вложенных панелей и задав им соответствующее выравнивание, но это достаточно муторно. Для решения подобных задач в VS.Net имеются другая возможность – Anchors.

Как и Docking Anchors проявляется в виде дополнительного свойства у контролов. Anchors – это другая система автоматического выравнивания элементов управления, в которой вместо «прилипания» используется идеология якорей. По всей видимости, впервые эта система была применена в Delphi, хотя зарекаться не буду, возможно, Anchors тоже был придуман Xerox-ом. (

В этой идеологии считается, что каждый элемент управления прикрепляется к родительскому с помощь якорей. Имеется четыре якоря: Top, Bottom, Left, Right. По умолчанию у большинства элементов управления установлены якоря Top и Left. При этом изменение размеров формы никак не воздействует на элементы управления, они просто остаются на месте, не изменяя своих размеров.

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

Якоря как бы приклеивают части элемента управления к некоторой позиции. Например, если задать все якоря, элемент управления будет изменять свои размеры пропорционально родительскому (см. рисунок 3), соблюдая отступы от краев.


Рисунок 3. Выравнивание при Anchor = Top, Bottom, Left, Right.

А если задать Bottom и Right (см. рисунок 4), элемент управления начнет прижиматься вниз и вправо. При этом его размеры и расстояние до правой и нижней границы родительского контрола останутся неизменными.


Рисунок 4. Выравнивание при Anchor = Bottom и Right.

Если задать элементам управления на закладке «Settings» якори в соответствии с таблицей 1, то при изменении размеров формы они будут автоматически выравниваться как нужно. При этом автоматически будут соблюдаться все отступы.

Имя контролаЯкоря
panel1 (панель к которой лежать поля tbFind и tbReplaceTo)Top, Bottom, Left, Right
lblFilesPaternTop, Right
tbExtensionsTop, Right
gbRegExpOptionsTop, Bottom, Right
lblPathBottom, Left
tbPathBottom, Left, Right
pbBroseFolderBottom, Right
Таблица 1

Если вы до сих пор не поняли принципов работы якорей, то лучше отложить чтение и поэкспериментировать самостоятельно.

После размещения элементов управления и задания выравнивания и якорей необходимо задать некоторые дополнительные свойства некоторых из них.

Так, для полей tbFind и tbReplaceTo нужно установить в TRUE свойство Multiline (это позволит вводить многострочный текст), выбрать в свойстве ScrollBars значение Both (это приведет к отображению строк прокрутки) и установить WordWrap в FALSE (это предотвратит автоматическое перенесение строк, если они не будут помещаться в окно по ширине).

Сохранение настроек в файле

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

Учитывая модные течения, а также в демонстрационных целях, я выбрал в качестве базового формата XML. В принципе, нам бы прекрасно подошли и обычные ini-файлы, но это сейчас не модно...

Итак, хотя базовым форматом и является XML, сама структура файла целиком и полностью зависит от нас. Наше приложение не поймет XML-файл, созданный не им, и наоборот, другое приложение не поймет наш формат (если, конечно это приложение не способно работать с абстрактным XML. Исходя из этого, лучше всего давать этим файлам специальное расширение. Я выбрал расширение «shr». Почему? А вдруг спросят, а у нас ни одного расширения «shr». :)

Данные в этом файле хранятся в UNICODE-кодировке, точнее в формате UTF8. Это позволит не зависеть от языка, которым предпочитает пользоваться пользователь. Единственное неудобство заключается в том, что если кому-нибудь захочется отредактировать этот файл вручную, то ему придется воспользоваться приложением, поддерживающим UNICODE. Под W2k с этим не будет проблем, так как даже обычный Notepad поддерживает UNICODE, под Windows 9х придется воспользоваться программой типа Word. Но ввиду малой целесообразности правки этого файла вручную (да и работы под Windows 9х :) ), и явных преимуществах UNICODE-а, на это можно смело закрыть глаза. Еще одним голосом за это решение может стать то, что по умолчанию .Net работает с XML именно в этой кодировке.

Итак, нам остается создать файл с расширением «shr» и сохранить в него настройки. Правильно? Конечно, но не все так просто. Во-первых, если файл новый, нам необходимо дать возможность пользователю выбрать имя файла.

Но записывать значение каждого элемента управления в файл, а потом извлекать его оттуда слишком утомительно. Для столь ленивого существа, как программист, такое решение является крайностью. К тому же в любой момент может появиться новая настойка и, чтобы ее учесть, придется менять код сразу в нескольких местах. Чтобы избежать этого, воспользуемся объектной моделью WinForms. Дело в том, что каждый элемент управления в WinForms, а также формы, имеют свойство Controls. Оно перечисляет все элементы управления, для которых данный элемент является родительским. Таким образом, несложно создать код, который будет перебирать все элементы управления для заданного.

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

Итак, вот наша первая функция:

/// <summary>Возвращает массив с элементами управления типа CheckBox 
/// или TextBox.</summary>
/// <param name="ctrls">Коллекция контролов. Например, this.Controls</param>
/// <returns>Динамический массив элементов управления</returns>
static Control[] GetStatefullCtrls(Control.ControlCollection ctrls)
{
  // Создаем динамический массив. К сожалению, в .Net из-за отсутствия
  // шаблонов пока нельзя создавать динамические типизированные массивы.
  // Вернее, конечно, можно, но на каждый случай массивов не наделаешь.
  // В качестве параметра конструктора ArrayList-у передается предполагаемый
  // размер массива (т.н. Capacity). Это позволяет избежать ненужных
  // перезаемов памяти.
  ArrayList aryCtrls = new ArrayList(ctrls.Count);

  // Перебираем все элементы управления этого уровня.
  foreach(Control c in ctrls)
  {
    // Если это CheckBox TextBox (оператор is позволяет проверить тип в
    // режиме выполнения), добавляем в массив ссылку на элемент управления.
    if(c is CheckBox || c is TextBox)
      aryCtrls.Add(c);
    else
      // Иначе рекурсивный вызов и добавляем результат в массив.
      // В качестве параметра, при этом, передается коллекция 
      // элементов управления текущего объекта.
      aryCtrls.AddRange(GetStatefullCtrls(c.Controls));
  }
  // Создаем массив типа Control необходимого размера.
  Control[] cs = new Control[aryCtrls.Count];
  // Копируем в него элементы из динамического массива.
  aryCtrls.CopyTo(cs, 0);
  return cs;
}

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

Кроме рекурсивного обхода вложенных элементов управления, в этой функции есть еще три интересных момента. Определение типа во время исполнения (оператор is), использование динамического массива и использование оператора цикла foreach. Первое, надеюсь, понятно без дополнительных объяснений, а вот второе и третье стоит пояснить.

Дело в том, что по иронии судьбы C# не имеет встроенных динамических массивов. По иронии – потому, что в C, а C# позиционируется как развитие C, как раз хотят их ввести. C# также не поддерживает шаблонов (как C++), и реализовать универсальные, но в тоже время типизированные классы в C# невозможно. Но в .Net все же был добавлен класс, эмулирующий работу динамического массива. Он называется ArrayList. Такое необычное название говорит о том, что этот класс является реализацией обобщенной абстракции – списка (List) на базе массива. Выражаясь языком .Net, этот класс реализует интерфейс IList на базе обычного массива. Так, к слову, как и многое в библиотеках .Net, интерфейс и название этого класса были смело позаимствованы из Явы. В качестве типа отдельного элемента в ArrayList используется тип object, но так как все типы в .Net приводятся к object, в ArrayList можно хранить любые данные. Хочу сразу предостеречь, что хранить в нем value-типы (простые не ссылочные типы и структуры) крайне неэффективно, так как при каждом обращении к массиву происходит боксинг (для данных выделяется место в хипе, они копируются туда, а в массив помещается ссылка на эту область) и анбоксинг (обратная боксингу процедура). Однако при хранении объектов потери в производительности относительно малы, так что единственным неудобством является нетипизированность. В нашем случае использование динамического массива позволяет не задумываться над расширением массива, а копирование элементов в типизированный массив перед выходом из функции позволяет сделать программу более типобезопасной.

Стоит обратить внимание и на конструкцию foreach. Она была позаимствована из VB. С ее помощью очень удобно перебирать элементы коллекций (таких, как массивы, списки и т.п.). Единственный недостаток этой конструкции (в отличие от традиционного for) заключается в том, что при ее использовании не доступен индекс текущего элемента, зато, если он не нужен, код становится более легко читаемым. От синтаксиса VB эту конструкцию отличает то, что переменная, в которую помещается текущий элемент, должна быть объявлена внутри foreach. Это избавляет от некоторых ошибок, но реально несущественно.

Кроме всего прочего, стоит обратить внимание на формат комментариев. Дело в том, что в VS.Net применяется свой формат автодокументирующих комментариев на базе вездесущего XML. Если непосредственно перед именем функции набрать «///», то VS. автоматически преобразует эти три косые черты в:

/// <summary>
/// *
/// </summary>
/// <param name="ctrls"></param>
/// <returns></returns>
static Control[] GetStatefullCtrls(Control.ControlCollection ctrls) { ... }

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

<summary> – предназначен для ввода комментария к методу, теги <param> описывают соответствующие параметры, а тег <returns> - возвращаемое значение (если метод является функцией). Текст, введенный в этих комментариях, будет отображаться в редакторе VS.Net при вводе кода (Complete Word) и при нажатии Ctrl+Shift+Пробел, находясь в списке параметров вызываемого метода.

Мы немножечко отвлеклись. Пора вернуться к записи настроек в XML. Сначала нужно рассмотреть два метода. Один из них сохраняет значение свойства Text любого элемента управления, имеющего таковое свойство, второй – свойство Value CheckBox-а.

/// <summary>Записывает текст элемента управления в ветку соответствующую 
/// имени элемента управления!</summary>
void SaveToXmlControlText(XmlDocument doc, Control ctrl)
{
  // Получаем корневую ветку XML-документа.
  XmlNode root = doc.DocumentElement;
  // Получаем имя элемента управления и удаляем у него префикс.
  string sName = ctrl.Name.Remove(0, 2);
  // Создаем XML-элемент.
  XmlNode Elem = doc.CreateNode(XmlNodeType.Element, sName, "");
  // Создаем XML-текст.
  XmlNode ElemTxt = doc.CreateNode(XmlNodeType.Text, "", "");
  // Присваем текст элемента управления в XML-текст.
  ElemTxt.Value = ctrl.Text;
  // Добавляем XML-текст к XML-элементу.
  Elem.AppendChild(ElemTxt);
  // Добавляем XML-элемент к корневому элементу документа.
  root.AppendChild(Elem);
}

/// <summary>Записывает состояние CheckBox-а в ветку, соответствующую
/// имени элемента управления</summary>
void SaveToXmlCheckBox(XmlDocument doc, CheckBox ctrl)
{
  XmlNode root = doc.DocumentElement;
  string sName = ctrl.Name.Remove(0, 2);
  XmlNode Elem = doc.CreateNode(XmlNodeType.Element, sName, "");
  XmlNode ElemTxt = doc.CreateNode(XmlNodeType.Text, "", "");
  ElemTxt.Value = ctrl.Checked.ToString();
  Elem.AppendChild(ElemTxt);
  root.AppendChild(Elem);
}

Эти две функции практически идентичны, за исключением того, что одна из них сохраняет значение свойства Text, а вторая – свойства Value CheckBox-а.

Теперь настала пора привести код, сохраняющий конфигурацию:

/// <summary>
/// Записывает настройки приложения в XML-файл.
/// sName: Имя файла.
/// </summary>
/// <param name="sName">Имя XML-файла.</param>
void SaveConfig(string sName)
{
  // Создаем объект "XML-документ" 
  XmlDocument doc = new XmlDocument();
  // Создаем корневую ветку документа
  XmlNode root = doc.CreateNode(XmlNodeType.Element, "shr", "");
  // Добавляем корневую ветку к документу.
  doc.AppendChild(root);
  
  // Перебираем элементы управления и записываем значение каждого 
  // в конфигурационный файл.
  foreach(Control ctrl in GetStatefullCtrls(tpSetings.Controls))
  {
    if(ctrl is CheckBox)
      SaveToXmlCheckBox(doc, ctrl as CheckBox);
    else if(ctrl is TextBox)
      SaveToXmlControlText(doc, ctrl);
    else
      throw new Exception(string.Format(
        "Unknown control type.  Name={0}, Info={1}", 
        ctrl.Name, 
        ctrl.ToString()));
  }

  // Сохраняем выделенную ветку в Preview-дереве 
  // (TreeView с закладки Preview).
  if(null != tvFiles.SelectedNode)
  {
    XmlNode Elem = doc.CreateNode(XmlNodeType.Element,"m_sCurrentTvPath","");
    XmlNode ElemTxt = doc.CreateNode(XmlNodeType.Text, "", "");
    // Считываем путь к выделенной ветке в TreeView, содержащем иерархию
    // директорий. Каталоги отделяются знаком «\».
    ElemTxt.Value = tvFiles.SelectedNode.FullPath;
    Elem.AppendChild(ElemTxt);
    root.AppendChild(Elem);
  }
  
  // Сохраняем XML-документ в файле.
  doc.Save(sName);
  // Устанавливаем признак измененности документа в FALSE.
  SetDirty(false);
}

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

Инструкция throw возбуждает так называемое исключение. Тем, кто работал с ОО-языками (типа C++ или Object Pascal), идеология исключений должна быть уже знакома. Возбуждение исключения в случае ошибки дает возможность строить приложение, как будто ошибок вовсе не бывает, т.е. ошибка – это исключительная ситуация, которая идет вразрез с основной логикой программы, и ее удобнее обрабатывать отдельно. По сути оператор throw аналогичен оператору return, за тем исключением, что он возвращает объект, унаследованный от Exception, и выходит не только из текущего метода, но и из всех других методов и/или областей видимости до тех пор, пока не достигнет конструкции try/catch. Если блок try/catch так и не находится, программа завершается аварийно.

Метод SetDirty выглядит следующим образом:

void SetDirty(bool bIsDirty)
{
  // Устанавливаем переменную-член класса m_bIsDirty.
  if(!m_bLoading)
    m_bIsDirty = bIsDirty;

  // Формируем и устанавливаем через свойство Text формы новый заголовок 
  // окна. В начале идет имя приложения csAppName, затем разделитель « - »
  Text = csAppName + " - " + 
    // Если в данный момент имеется открытый конфигурационный файл
    // (при этом переменная m_sCurrentConfig не равна NULL или "",
    ((m_sCurrentConfig != null && m_sCurrentConfig.Length > 0)
      // то добавляем надпись имя файла. Иначе "<New search>".
      ? m_sCurrentConfig : "<New search>") 
        // Если требуется сохранение, добавляем в строку звездочку.
        + (m_bIsDirty && !m_bLoading ? " *" : "");
}

Этот метод, во-первых, устанавливает переменную формы m_bIsDirty, говорящую, что конфигурационные данные изменены и их требуется сохранить (при m_bIsDirty = TRUE), или не требуется сохранять (при m_bIsDirty = FALSE). Во-вторых, он формирует заголовок окна, используя непереводимый C-шный фольклор. :) Подробности можно узнать из комментариев.

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

Загрузка настроек из файла

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

/// <summary>
/// Открывает XML-файл настроек.
/// </summary>
/// <param name="sName">Имя XML-файла. Null или пустая строка
/// приведет к созданию нового документа.</param>
void OpenConfig(string sName)
{
  // Если имя конфигурационного файла не задано, 
  // необходимо создать новый поиск.
  if(sName == null || sName.Length == 0)
  {
    SetDirty(false); // Это позволит избежать выдачи предложения записаться.
    FileNew(); // Создаем новый поиск.
    return;
  }

  // Поднимаем флаг загрузки. Он анализируется в местах где при загрузке
  // изменяется поведение программы.
  m_bLoading = true;
  try
  {
    XmlDocument doc = new XmlDocument();
    doc.Load(sName); // Загружаем содержимое XML-файла.
    XmlElement d = doc.DocumentElement; // Получаем корневой элемент.

    // Перебираем элементы управления и считываем для каждого из них
    // значение из конфигурационного файла.
    foreach(Control ctrl in GetStatefullCtrls(tpSetings.Controls))
    {
      // Каждый элемент управления имеет двухбуквенный префикс (cb или tb)
      // Поэтому имя тега можно получить, отбросив первые два
      // символа имени элемента управления.
      string Name = ctrl.Name.Remove(0, 2);
      if(ctrl is CheckBox) // Если элемент управления CheckBox...
        // операции чтения взяты в try{} catch{}, так как если значений нет
        // в xml не нужно выдавать сообщений об ошибке, ведь это скорее 
        // всего новый элемент управления (добавленный программистом).
        try
        {
          // Выражение (xxx as yyy) приводит ссылку xxx к типу yyy.
          // yyy должен быть ссылочного типа.
          (ctrl as CheckBox).Checked = 
              // bool.Parse() преобразует строки в bool
              bool.Parse(d.SelectSingleNode(Name).InnerText); 
        } catch{} // Не страшно, если такого элемента в файле не окажется.
      else if(ctrl is TextBox) // Если элемент управления TextBox...
        try{ (ctrl as TextBox).Text = d.SelectSingleNode(Name).InnerText; }
        catch{} // Нестрашно если такого элемента в файле не окажется.
      else // Появление элемента управления других типов исключено...
        throw new Exception(
           string.Format("Unknown control type.  Name={0}, Info={1}",
                         ctrl.Name, ctrl.ToString()));
    }
    // Считываем текущий путь в Preview-дереве.
    try
    { 
      m_sCurrentTvPath = d.SelectSingleNode("m_sCurrentTvPath").InnerText;
    } catch{}
    m_sCurrentConfig = sName; // Запоминаем имя конфигурационного файла
    RecentAdd(sName); // Обновляем список открывавшихся файлов
    SetDirty(false); // Помечаем состояние как "записанное".
  }
  catch(Exception ex)
  {
     // Отлавливаем исключение и повторно возбуждаем новое исключение,
     // добавляя свое сообщение и сообщение из пойманного исключения.
     // Это позволяет пользователю одновременно понять контекст и суть 
     // ошибки. Такой прием особенно хорош в распределенных приложениях,
     // где сообщение об ошибке может проходить через несколько машин.
     throw new Exception("Can not open the configuration file\"" 
        + sName + "\"\n\nError: " + ex.Message); 
  }
  // Опускаем флаг m_bLoading. Это обязательно нужно делать в секции 
  // finally, так как иначе в случае сбоя эта переменная так и не 
  // будет сброшена.
  finally{ m_bLoading = false; }
}

Процесс считывания настроек из XML-файла аналогичен обратному, за исключением того, что необходимо обновить «список ранее открываемых файлов». Этот список показывается в меню «File-> Recent». Функцией RecentAdd вносит в этот список путь к файлу. Вот реализация этой функции:

/// <summary>
/// Добавляет имя файла в начало Recent-списка.
/// Если он там уже был, элемент перемещается в начало списка.
/// </summary>
void RecentAdd(string sConfigName)
{
  sConfigName = sConfigName.ToLower(); // Опускаем регистр.
  string[] asNew = null;
  // Если список ранее открываемых файлов пуст, ...
  if(m_asRecent == null || m_asRecent.Length == 0)
    asNew = new string[1]; // создаем массив с одним элементом.
  else // Иначе добавляем или перемещаем вверх новый путь.
  {
    // Макс. размер Recent-списка (ранее открываемых файлов).
    const int ciRecentMax = 30;

    // Ищем, не было ли такого элемента...
    int i = Array.IndexOf(m_asRecent, sConfigName);
    // Если путь sConfigName не найден в массиве, i будет равна -1.
    // Иначе i будет содержать индекс найденного элемента.
    // В случае, если путь уже имеется в массиве или размер массива равен
    // максимально допустимому (ciRecentMax), нам не нужно расширять массив.
    // В противном случае нужно добавить в массив дополнительный элемент.
    // (Для этого придется его перезанять.) 
    asNew = i >= 0 || m_asRecent.Length >= ciRecentMax
      ? m_asRecent // Используем старый массив.
      // Иначе создаем новый, шириной на единицу больше прежнего.
      : new string[m_asRecent.Length + 1];
    // Если вставляется новый путь, делаем вид, что он найден 
    // в последнем (новом) элементе массива.
    if(i < 0)
      i = asNew.GetUpperBound(0);
    // Копируем элементы, тем самым сдвигая их на элемент вниз.
    Array.Copy(m_asRecent, 0, asNew, 1, i);
    // Задняя часть массива остается без изменений.
  }
  // Новый элемент всегда вставляется в начало списка.
  asNew[0] = sConfigName;
  m_asRecent = asNew;
  RecentUpdate();
}

Первая реализация этой функции значительно отличалась от приведенной выше. Дело в том, что я довольно много программировал на C и C++ (без STL или MFС) и привык писать простые алгоритмы вручную. Вот выдержка из него:

int i = 0;
for(; i < iLen; i++)
  if(sConfigName == m_asRecent[i])
    break;
asNew = new string[i < iLen ? iLen : iLen + 1];
for(int j = 0, k = 1; j < iLen; j++)
  if(i != j)
  {  
    // Копируем элементы, оставляя первый не заполненным
    // и пропуская уже имеющийся...
    asNew[k] = m_asRecent[j];
    k++;
  }
// Новый элемент всегда вставляется в начало списка.
asNew[0] = sConfigName;

Этот код был не намного длиннее и вряд ли значительно медленнее. Но он явно хуже. Как минимум, его значительно сложнее понять. Но самое главное, он потенциально опасен. Ошибиться в таком коде несложно.

Основная разница заключается в использовании готовых функций. Класс Array (а это именно класс, а не объект) является базовым классом для всех массивов в C# и предоставляет ряд полезных статических функций. В их число входит: сортировка, бинарный поиск, переворот массива, очистка, динамическое создание нового экземпляра, сравнение массивов и использованные мной простой поиск (IndexOf), а так же копирование/перемещение (Copy, типизированный аналог memmove из CRT).

В C# массив является объектом и имеет свои методы и свойства. Так, свойство Length возвращает текущий размер массива (количество элементов), а GetUpperBound – верхнюю границу (в данном случае единственной нулевой мерности, так как массив одномерный). Почему некоторые функции сделаны как функции члены, а другие – как статические функции класса Array, остается только догадываться. Возможно, влияют соображения потокобезопасности, а возможно, это просто каприз программистов.

Перед окончанием работы RecentAdd в переменную m_asRecent присваивается ссылка на измененный массив. Заметьте – это может быть и ссылка на старый массив, но ввиду ссылочной природы массивов в .Net в этом нет никакой разницы, так как любая переменная является только ссылкой на реальный массив, и ссылок может быть любое количество.

В самом конце вызывается метод RecentUpdate который обновляет меню Recent в соответствии с массивом m_asRecent. Вот код этого метода:

void RecentUpdate()
{
  miRecent.MenuItems.Clear(); // Удаляем имеющиеся элементы меню...
  foreach(string s in m_asRecent) // ...и добавляем новые.
    miRecent.MenuItems.Add(s, new System.EventHandler(miRec1_Click));
}

Он несколько лаконичен, но за лаконичностью кроются кое-какие хитрости. Все, что делает этот метод – он перебирает элементы массива m_asRecent и добавляет новые элементы меню. В качестве текста для элементов меню используются строки из m_asRecent. Вроде бы все просто, но здесь же происходит подключение к обработчикам событий новых элементов меню. Для всех элементов используется один метод-обработчик miRec1_Click. Использовать один обработчик для нескольких элементов меню стало возможным потому, что идеология обработки событий в .Net претерпела изменения по сравнению с VB6, и стала более похожа на принятую в Delphi. Рассмотрим метод-обработчик:

/// <summary>Это событие вызывается при выборе одного из Recent-файлов.</summary>
private void miRec1_Click(object sender, System.EventArgs e)
{
  // Предлагаем пользователю сохранить изменения конфигурационного файла.
  if(TrySave())
    OpenConfigSafe((sender as MenuItem).Text);
}

Как видите, теперь в одном из параметров передается ссылка на объект-возбудитель события. Так как мы точно знаем, что это объект типа MenuItem, то можем сделать приведение типов и получить доступ к объекту-зачинщику события. При выборе одного из пунктов меню Recent всего лишь необходимо взять путь, являющийся текстом элемента меню (свойство Text) и открыть нужный конфигурационный файл (с помощью уже описанной нами функции).

Метод TrySave проверяет, не был ли изменен текущий (открытый в данный момент) файл, и если был, выдает предложение записать его. При этом можно согласиться, отказаться от записи или отказаться от всей операции. В последнем случае TrySave возвращает FALSE и никаких действий не производится. Более подробно этот метод будет описан ниже.

О наворотах, без которых никуда

Итак, наш код позволяет пользователю ввести настройки, и у нас уже есть код, позволяющий сохранять или загружать файлы настроек, но в интерфейсе пользователя можно только выбирать варианты из Recent-меню, а записать файл вообще невозможно. Нужно создать и оформить обработчики событий от меню и тулбара.

Выберите пункт меню «File->Save» (в дизайнере форм) и двойным нажатием правой кнопки мыши добавьте обработчик события (для меню по умолчанию создается обработчик события Click, которое вызывается при выборе пункта меню).

Теперь нам нужно каким-то образом вызвать метод SaveConfig, но этому методу требуется передать имя открываемого файла. В коде уже фигурировала переменная m_sCurrentConfig. Это строковая переменная, областью видимости которой является экземпляр формы. В ней хранится путь к файлу настроек, который открыт в данный момент. Поэтому для записи достаточно вызвать SaveConfig(m_sCurrentConfig);. Однако не все так просто. В первый раз путь к файлу будет пустым. К тому же может случиться, что по каким-то причинам файл не может быть записан (например, файл или каталог защищен от записи). При этом неминуемо будет возбуждено исключение и, если его не перехватить, программа, повозмущавшись, закроется. Можно конечно взять вызов функции в try/catch-блок и выдать сообщение пользователю, объяснив попутно, мол, извини, дорогой, так получилось... не обессудь. Но вряд ли от такой программы будет прок, разве что поиздеваться над кем-нибудь. Правильным выходом будет в случае неудачи предложить пользователю записать файл под другим именем, выдав диалог «Save As». К тому же диалог «Save As» сам по себе должен присутствовать в приличной программе.

Еще одно соображение заключается в том, что реализовывать и простую запись, и «Save As» лучше не в обработчиках событий, а в отдельных методах. Во-первых, интерфейсный код может вызываться из нескольких мест. В нашем случае из: меню, тулбара и командной строки. А, во-вторых, к этому призывают принципы грамотного программирования. В конце концов, это отдельный функционально законченный кусок кода.

Ниже приведен метод записи настроечного файла:

void SaveCurrentConfig()
{
  try{ SaveConfig(m_sCurrentConfig); }
  catch{ SaveConfigAs(); }
}

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

Созданный же нами чуть ранее обработчик меню «Save» должен выглядеть так:

private void miSave_Click(object sender, System.EventArgs e) 
{
  SaveCurrentConfig(); 
}

Но, как же реализовать запись файла под другим именем? Оптимально было бы предложить пользователю выбрать или ввести имя файла в стандартном диалоге выбора файлов Windows. В WinForms есть компонент, который выполняет эту работу. Компонент – это не визуальный объект, который можно класть на форму (вернее в специальную панель под ней) и настраивать его свойства в режиме разработки. Этот компонент называется SaveFileDialog. Бросьте его на форму, откройте его свойства и дайте ему имя SaveFile. Теперь модифицируйте его свойства, описанные в следующей таблице:

Название свойстваЗначениеОписание
DefaultExtshrРасширение, подставляемое по умолчанию
FileNameconfig1Имя файла, показываемое при открытии диалога
FilterAsc Search|*.shrСписок поддерживаемых фильтров. В нашем случае только один *.shr.
Таблица 2.

Теперь, используя этот компонент, можно без труда реализовать «запись файла под другим именем». Вот код метода:

void SaveConfigAs()
{
  // Если пользователь подтвердил переименование файла (нажал Save)...
  if(DialogResult.OK == SaveFile.ShowDialog(this))
  {
    try
    {
       
       // Пытаемся записаться...
       SaveConfig(SaveFile.FileName);
       // Если удается, запоминаем имя открытого файла...
       m_sCurrentConfig = SaveFile.FileName;
       // и добавляем путь к Recent-списку.
       RecentAdd(m_sCurrentConfig);
    }
    catch(Exception e)
    { // Показывем сообщение об ошибке одновременно объясняя, где она
      // возникла. MsgBox – это простенькая функция-обертка.
      MsgBox("Can not save settings to the configuration file.\r\nError: "
             + e.Message); 
    }
  }
}

Добавьте обработчик для меню «Save As» и впишите в него вызов SaveConfigAs:

private void miSaveAs_Click(object sender, System.EventArgs e) 
{
  SaveConfigAs(); 
}

Вот, вроде бы, и все о записи и загрузке файлов настроек. Я упустил только описание метода TrySave. Он применяется перед открытием файла настроек, а так же перед завершением работы программы. Вот его код:

/// <summary>
/// Выводит диалоговое окно с запросом на запись (Yes, No и Cancel).
/// </summary>
/// <returns>Если пользователь выбирает No, возвращается true.
/// Если пользователь выбирает Yes, производится попытка записать изменения.
/// При успехе возвращается true, при неудаче возбуждается исключение.
/// Если пользователь выбирает Cancel, возвращается false.</returns>
bool TrySave()
{
  if(m_bIsDirty)
    switch(MessageBox.Show(this, "Save current settings?", csAppName, 
             MessageBoxButtons.YesNoCancel, MessageBoxIcon.Question))
    {
      case DialogResult.Cancel:                      return false;
      case DialogResult.Yes:    SaveCurrentConfig(); return true;
    }
  return true;
}

MessageBox – это загадочное решение Microsoft. Дело в том, что MessageBox – это класс, метод Show которого выводит то самое окно сообщения, к которому привыкли все, кто программирует для Windows на VC++. Метод Show имеет 12 перегрузок. И это вместо одного с параметрами по умолчанию, как это было в MFC и ATL! Ладно, если бы MessageBox был переменной, в которой можно было бы настроить значения по умолчанию заголовка сообщения и иконки, но MessageBox – это класс, а Show – статический метод. Текст вызова MessageBox.Show слишком длинный. В больших приложениях есть смысл делать методы-обертки, которые сокращают и упрощают синтаксис. Например, для выдачи сообщений об ошибке можно сделать следующую обертку:

void MsgBox(string sMsg)
{
  MessageBox.Show(this, sMsg, csAppName, 
    MessageBoxButtons.OK, MessageBoxIcon.Error);
}

а для выбора, как и в случае метода TrySave, можно написать такую обертку:

DialogResult MsgBox(string sMsg, MessageBoxButtons Buttons)
{
  return MessageBox.Show(this, sMsg, csAppName,
    Buttons, MessageBoxIcon.Question);
}

Правда, вам придется копировать эти методы во все формы. Но зато код станет кратче и понятнее.

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

private void Form1_Closing(object sender, 
        System.ComponentModel.CancelEventArgs e)
{
  // Предлагаем пользователю сохранить изменения конфигурационного файла
  // перед закрытием приложения.
  e.Cancel = !TrySave(); // Если он выберет Cancel, форма не закроется.
}

Добавить обработчик можно следующим образом. Нужно выбрать форму (ткнув мышью в свободную ее часть или заголовок), открыть ее свойства, включить режим отображения событий (кнопка ), найти событие с именем Closing и дважды кликнуть по нему правой кнопкой мыши (для тех, кто ранее пользовался Delphi, это занятие покажется знакомым).

Настройка ToolBar

ToolBar является хотя и удобным, но вспомогательным элементом управления. Думаю, что с его настройкой вы справитесь самостоятельно, я продемонстрирую только, как настроить одну кнопку и обработать событие «нажатие кнопки».

Для начала к элементу управления ToolBar (который к этому моменту уже должен находится на форме) необходимо подключить настроенный ImageList. Добавьте ImageList на форму, откройте его свойства и установите его свойство ImageSize в «16; 15» (это стандартный размер картинки в ToolBar-ах), а TransparentColor (цвет, принимаемый в картинках как прозрачный) в Silver. Потом выделите свойство Images и нажмите на появившуюся кнопку. При этом откроется диалог, в котором можно добавлять/удалять картинки. Их можно взять в каталоге c:\MvsNet\Common7\Graphics\bitmaps\OffCtlBr\Large\Color\, где c:\MvsNet – это каталог, в который вы установили VS. В нем и близлежащих каталогах лежит много полезных иконок, однако парочку мне пришлось нарисовать вручную. Итак, добавьте необходимые иконки в список и нажмите OK. Индексы картинок можно менять в этом же диалоге.

Теперь выделите ToolBar и выберите в его свойстве ImageList созданный на предыдущем шаге ImageList (по умолчанию он должен иметь имя imageList1). Теперь выделите свойство Buttons и нажмите появившуюся кнопку. Перед вами откроется диалог, в котором можно настраивать список кнопок и их свойства. Из свойств вам нужно заполнить следующие:

Name – имя кнопки. Практическая надобность в разумных именах кнопок возникает, только если нужно динамически изменять их атрибуты или узнавать их характеристики. Так, если кнопка имеет стиль ToggleButton, может возникнуть потребность переключать ее состояния, например, при выборе аналогичного пункта меню. В этом приложении обращение по имени идет к только к одной кнопке – tbbOpen. Дело в том, что я в демонстрационных целях дал ей стиль DropDownButton (см. ниже) и при нажатии стрелочки вывожу контекстное меню. Чуть ниже я опишу все более подробно. Однако для остальных кнопок наличие осмысленных имен не обязательно (я их внес просто ради приличия).

Style – стиль кнопки. Возможные варианты: PushButton (обычная кнопка, устанавливается по умолчанию), ToggleButton (кнопка-переключатель, в миру липучка), Separator (разделительная черта, разделяет смысловые группы кнопок), DropDownButton (кнопка со стрелочкой сбоку, обычно подразумевается, что при на ее нажатии появится контекстное меню).

ToolTipText – текст подсказки, появляющийся при наведении на кнопку курсора мыши.

Tag – свойство типа object (доступное и в списке свойств) в которое можно положить любую дополнительную информацию. Это поле удобно использовать для идентификации кнопки в обработчиках событий ToolBar-а. Во время настройки свойств кнопок в это свойство можно занести текст, соответствующий значению кнопки (В нашем случае: New, Open, Save, ReplaceInFile и Esc). Это позволит в обработчике события «ButtonClick» ToolBar-а легко определять, какая кнопка нажата. Пытливый читатель может спросить, почему нельзя для этого использовать свойство Name кнопки? Вообще-то в аргументах обработчика события ButtonClick передается ссылка на нажатую кнопку, но имени кнопки по этой ссылке установить нельзя. Дело в том, что хотя в списке свойств кнопки и присутствует такое свойство, реально его не существует. Такое свойство существует только для элементов управления (наследников класса Control). Кнопка ToolBar-а же является всего лишь компонентом (реализует интерфейс IComponent). Можно, конечно, написать гору if-ов:

if(e.Button == tbbNew)
  ...
else if(e.Button == tbbOpen)
  ...
// И так далее...

Но это как-то не спортивно. Намного красивее применить возможность C# использовать оператор switch со строковыми метками. Вот код обработчика ButtonClick, использующий эту особенность:

private void toolBar1_ButtonClick(object sender, 
                   System.Windows.Forms.ToolBarButtonClickEventArgs e)
{
  switch((string)e.Button.Tag)
  {
    case "New":           FileNew();                            break;
    case "Open":          FileOpenConfig();                     break;
    case "Save":          SaveCurrentConfig();                  break;
    case "ReplaceInFile": ReplaceInFileWithPromt();             break;
    case "Esc":           EscepeSelection();                    break;
    default:              MessageBox.Show("Нет обработчика!");  break;
  } 
}

Честно говоря, серьезных аргументов за такое решение у меня нет, но то, что так можно делать, уже очень приятно. До C# не было языков, позволяющих использовать в качестве меток в операторах, подобных switch, не простые char или int, а строки. Конечно, того же эффекта можно добиться, создав хэш-таблицу со строковым ключом, но switch позволяет добиться большей выразительности.

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

Чтобы добавить выпадающее меню к DropDownButton-кнопке ToolBar-а, нужно всего лишь создать это меню и выбрать его в свойстве DropDownMenu кнопки ToolBar-а (или свойстве ContextMenu обычных элементов управления). Однако есть одна закавыка. Дело в том, что это меню должно обязательно быть типа ContextMenu. Если уже имеется MenuItem (элемент меню) в MainMenu (главном меню формы), то использовать его как DropDownMenu или контекстное меню не удастся. Честно говоря, это явно очередной просчет программистов Microsoft (с проектированием меню у них просчет на просчете), но от осознания этого жизнь лучше не становится. Так что есть смысл попытаться обойти это ограничение. Например, у нас есть меню (MenuItem) miRecent, который содержит (постоянно обновляемый) список ранее открывавшихся файлов. Это меню имеет довольно глубокую вложенность, из-за чего доступ к нему с помощью мыши становится неудобным. Можно присвоить кнопке «Open» (открывающей диалог выбора конфигурационного файла) стиль DropDownButton, и при нажатии на стрелку рядом с кнопкой выводить меню со списком ранее открывавшихся файлов. Меню у нас уже есть, но, к сожалению, напрямую мы его использовать не можем. Зато мы можем динамически создать пустое контекстное меню, скопировать туда все элементы меню miRecent и вывести контекстное меню непосредственно под кнопкой tbbOpen. Сделать это можно в обработчике события ButtonDropDown:

private void toolBar1_ButtonDropDown(object sender,
               System.Windows.Forms.ToolBarButtonClickEventArgs e)
{
  if(e.Button != tbbOpen)
    throw new Exception("Поддерживается только одна DropDownButton (tbbOpen)!");

  Rectangle r = tbbOpen.Rectangle; // Получаем координаты кнопки.
  ContextMenu cm = new ContextMenu();
  cm.MergeMenu(miRecent); // Копируем элементы меню из miRecent.
  cm.Show(toolBar1, new Point(r.Left, r.Bottom)); // Показываем меню.
}

Метод MergeMenu объединяет меню, для которого она вызывается, с получаемым в параметре, но так как контекстное меню на этот момент еще пусто, он просто копирует элементы меню.

Обратите внимание и на то, что ссылки на объекты можно проверять на равенство и неравенство. Здесь эта проверка поставлена на всякий пожарный случай, так как DropDownButton в нашем ToolBar-е всего одна.

Конечно, намного удобнее было бы просто выбрать miRecent в качестве контекстного меню еще во время разработки, но увы.

Функциональные классы

Функциональная часть RegExRep вынесена в два класса:

AscRegExpParser – инкапсулирует работу с регулярными выражениями.

AscSearchEngine - инкапсулирует операции по поиску файлов.

AscSearchEngine

Класс содержит:

private AscDir m_Root;

Это объект типа AscDir (скрытого вложенного класса), осуществляющий поиск, хранение и обработку файлов и каталогов. Он будет описан позже.

Метод ScanDir сканирует каталог (и все подкаталоги), путь к которому передается в параметре sPath, и ищет все файлы, удовлетворяющие маске sPattern:

public void ScanDir(string sPath, string sPattern)
{
  m_Root = new AscDir(null, sPath, sPattern);
}

Сведущий метод – FillTreeView, он заполняет TreeView:

public void FillTreeView(TreeView tv)
{
  tv.Nodes.Clear(); // Очистить TreeView.
  FillNode(tv.Nodes, m_Root); // Заполнить каталогами и файлами.
}
/// <summary>
/// Константы, определяющие индексы картинок в дереве.
/// </summary>
public const int ciDir = 2, ciDirSel = 3, ciFile = 4, ciFileSel = 4;

/// <summary>
/// Внутренняя рекурсивная функция, заполняющая TreeView.
/// </summary>
private void FillNode(TreeNodeCollection tnc, AscDir dir)
{
  TreeNode tn;
  // Перебираем все подкаталоги.
  foreach(AscDir ad in dir.m_SubDir)
  {
    // Добавляем ветку, только если в каталоге найдены нужные файлы.
    if(ad.m_iFiles > 0)
    {
      // Создаем новую ветку дерева.
      tn = new TreeNode(ad.m_sName);
      // Задаем индексы картинок.
      tn.SelectedImageIndex = ciDirSel;
      tn.ImageIndex = ciDir;
      // Добавляем ветку к дереву.
      tnc.Add(tn);
      // Вызываем эту же функцию (рекурсивно) для заполнения подветок.
      FillNode(tn.Nodes, ad);
    }
  }

  // Если в ветке есть файлы...
  if(dir.m_Files != null)
    foreach(string s in dir.m_Files) // Перебираем их...
    {
      // и создаем для каждого из них ветку...
      tn = new TreeNode(s);
      // задаем индексы картинок...
      tn.SelectedImageIndex = ciFileSel;
      tn.ImageIndex = ciFile;
      // и добавляем в дерево.
      tnc.Add(tn);
    }
}

Константы используются для улучшения читаемости текста. Это особенно важно, поскольку при выделении ветки в TreeView именно по типу картинки (свойству ImageIndex) определяется, файл это или каталог.

У класса AscSearchEngine есть два свойства:

public int DirsCount{ get{ return m_Root.m_iDirs; } }
public int FilesCount{ get{ return m_Root.m_iFiles; } }

Они возвращают количество найденных каталогов и файлов, соответственно. В данном случае свойства позволяют только читать информацию, защищая тем самым данные от случайной порчи. Свойства также позволяют получить доступ к скрытым данным. Можно было бы оформить этот код и в виде методов, но свойства более легко читаются. Чтобы отличать свойства от переменных-членов класса, желательно давать переменным определенный префикс. Думаю, вы уже заметили, что я даю переменным прификс m_ (сокращение от английского member).

В классе AscSearchEngine есть также метод FileCallback, позволяющий перебрать все найденные файлы (он используется для организации пакетной замены):

// Объявление делегата
public delegate void FileCallback(string sFileName);

public void IterateNodes(FileCallback Callback)
{
  // Передаем делегат и пустой относительный путь классу AscDir
  m_Root.InternalIterateNode(Callback, "");
}

Он переадресует это почетное занятие объекту m_Root (классу AscDir), который хранит найденные каталоги и файлы. InternalIterateNode перебирает последовательно каждый найденный файл, и вызывать callback-метод, ссылка на который передается в так называемом делегате.

Делегаты

Делегат – это объектно-ориентированный аналог указателя на функцию в C. Именно в C. В C++ было введено понятие указателя на член конкретного класса. По-моему, указатель на член класса – совершенно никчемное изобретение (в отличии от делегатов в .Net). Обычно есть необходимость вызвать по указателю метод с определенным описанием, но не определенным заранее классом. Так, методу IterateNodes нужно вызвать метод объекта, но ему совершенно все равно, какой тип объекта, к которому принадлежит метод. Главное, чтобы совпадало описание метода. К тому же, указатель на метод из C++ не хранил ссылку на экземпляр объекта, и для вызова метода нужно было иметь ссылку на нужный объект. В сочетании с ужасным синтаксисом это делало практическое применение указателей на методы в C++ бессмысленным:

struct A
{
  void Func1(){ printf("A::Func1();\n"); };
  void Func2(){ printf("A::Func2();\n"); };
};
struct B
{
  void Func1(){ printf("B::Func1();\n"); };
};
void (A::*AFunc1)();

В переменную AFunc1 можно присвоить указатель на A::Func1 или A::Func2, но не B::Func1.

Для решения задачи вызова метода с определенным описанием, но заранее не известным классом, использовалась идея абстрактных классов, или, как их еще называют, интерфейсов:

#define interface struct
interface ITest
{
  virtual void Func1() = 0;
};

struct A : ITest
{
  void Func1(){ printf("A::Func1();\n"); };
  void Func2(){ printf("A::Func2();\n"); };
};
struct B : ITest
{
  void Func1(){ printf("B::Func1();\n"); };
};
void Test(ITest * pITest){ pITest->Func1(); }
...
A a;
B b;

Test(&a);
Test(&b);

Основными проблемами здесь являются неразрывность интерфейса (если хочешь реализовать один метод интерфейса – реализуй все), муторность (наследование, ...) и уменьшение производительности (метод автоматически становится виртуальным, и даже если его вызывать не через интерфейс, а через указатель на класс, он все равно будет вызываться косвенно).

Делегаты в .Net как раз и призваны решить подобные проблемы, упростив жизнь программистов. Делегаты просто описываются, наподобие декларации функций в C/C++, но с дополнительным ключевым словом (для C# – delegate). При этом получается тип, на базе которого можно объявить переменную. В такую переменную можно поместить ссылку на любой метод любого класса, декларация которого совпадает с декларацией делегата. Причем это может быть как обычный метод класса, так и статический метод. В случае с обычным методом делегат вместе со ссылкой на метод сохраняет и ссылку на экземпляр объекта, и при вызове делегата вызывает метод именно у этого экземпляра.

public class CDelegateTest 
{
  // Объявление делегата (типа).
  public delegate void SomeDelegate(string sFileName);
  // Этот метод получает делегат в качестве параметра.
  public void DelegateTest(SomeDelegate sd)
  {
    // Вызов делегата синтаксически эквивалентен вызову глобальной функции,
    // или локальной функции (находящейся в этом же классе). Однако при
    // этом вызывается метод, ссылка на который помещена в делегат.
    // Причем если метод не статический, то вызов производится у конкретного
    // экземпляра объекта, ссылка на который также хранится в делегате.
    sd(); // Вызов метода, указанного в делегате.
  }
}

class SomeClass2 // Другой класс.
{
  // Метод некоторого класса, никак не связанного с делегатом.
  // Единственное, что его роднит с делегатом – у них
  // совпадают декларации.
  public void SomeMethod(){...}

  void Method2()
  {
    CDelegateTest cdt = new CDelegateTest();
    // Создаем новый делегат типа SomeDelegate. При этом в его конструктор
    // передается указатель на метод экземпляра текущего объекта.
    // Обратите внимание, что хотя указатель на текущий объект (т.е. this)
    // явно не передается, компилятор все же передает его делегату!
    cdt.DelegateTest(new CDelegateTest.SomeDelegate(SomeMethod));
  }
}

AscDir

Этот класс реализует разбор каталогов и поиск файлов. Вот список переменных этого класса:

// Ссылка на объект (AscDir) описывающий родительский каталог.
// Для первого объекта в иерархии эта ссылка устанавливается в NULL.
protected AscDir m_ParentDir;
// Имя каталога, описываемого объектом AscDir (без пути).
internal string m_sName;
// Список описаний подкаталогов (таких же объектов AscDir).
internal AscDir[] m_SubDir;
// Список файлов, лежащих непосредственно в этом каталоге 
// и удовлетворяющих маске. Не включает файлов, найденных в подкаталогах.
internal string[] m_Files;
// Количество подкаталогов. Всех, а не только текущего уровня.
// Вычисляется рекурсивно.
internal int m_iDirs = 0;
// Количество файлов во всех подкаталогах всех уровней.
// Вычисляется рекурсивно.
internal int m_iFiles = 0;

Модификаторы доступа

В C# есть четыре уровня доступа к переменным класса. И, соответственно, четыре модификатора доступа:

Я считаю, что использовать private нужно очень осторожно, и только в тех случаях, когда это действительно нужно. Этот модификатор сильно ослабляет возможности расширения класса наследованием. Лучше по умолчанию использовать модификатор protected, а private использовать только там, где нужно явно запретить доступ к методу, полю или вложенному классу.

Интересно, что в C# нет идеологии друзей и соответствующего модификатора friend (как это сделано в C++). Вместо этого в C# добавлен модификатор internal, выполняющий очень сходную (но тем не менее другую) роль. Для большинства членов класса AscDir (объявленного как скрытый вложенный класс) используется модификатор internal. Это позволяет методам внешнего класса (AscSearchEngine) иметь прямой доступ к членам класса AscDir. В то же время сам класс не виден за пределами внешнего класса.

Конструктор и методы AscDir

Основная работа осуществляется в его конструкторе:

/// <summary>
/// Разбирает переданный каталог. Если находит подкаталоги,
/// вызывает себя рекурсивно. 
/// </summary>
internal AscDir(AscDir ParentDir, string sPath, string sPattern)
{
  m_ParentDir = ParentDir; // Запоминаем родительский каталог.
  // Класс DirectoryInfo позволяет получить исчерпывающую информацию
  // об одном каталоге. Путь к каталогу задается в параметре конструктора.
  DirectoryInfo di = new DirectoryInfo(sPath);
  // Запоминаем имя каталога. В принципе, можно было бы просто хранить
  // сам объект DirectoryInfo, но это привело бы к неоправданному 
  // расходу ресурсов.
  m_sName = di.Name;
  // Получаем список (массив) подкаталогов (следующего уровня вложенности).
  DirectoryInfo[] adi = di.GetDirectories();
  // Создаем аналогичный по длине массив объектов AscDir.
  m_SubDir = new AscDir[adi.Length];
  // Перебираем подкаталоги и для каждого из них создаем свой объект AscDir.
  // Это приводит к рекурсивному вызову, так что, в конце концов строится
  // иерархия каталогов.
  for(int i = 0; i < m_SubDir.Length; i++)
    // В новый объект AscDir передается путь, получаемый как текущий плюс
    // имя обрабатываемого каталога, и маска поиска файлов.
    m_SubDir[i] = new AscDir(this, sPath + @"\" + adi[i].Name, sPattern);

  // Теперь остается найти файлы, находящиеся в текущем каталоге
  // и удовлетворяющие маске поиска.

  // Для этого разбираем маску поиска. Маска задается как строка, где
  // отдельные маски разделены знаком «;». Например, маска может быть 
  // такой «*.txt;*.htm*;*.asp».
  // Разбор делается методом Split класса string.
  string[] asPatterns = sPattern.Split(';');
  // Теперь нужно попытаться найти файлы, соответствующие каждой маске.
  // Для этого перебираем маски...
  foreach(string s in asPatterns)
    // и считываем список файлов, удовлетворяющих этой маске.
    // Список получается методам GetFiles класса DirectoryInfo.
    // Для того, чтобы не загромождать конструктор, считывание вынесено
    // в отдельный внутренний метод.
    AppendFileNames(di.GetFiles(s));

  // И, наконец, подсчитываем общее количество файлов (в этом каталоге
  // и всех подкаталогах).

  // Для начала определяем количество файлов, найденных непосредственно
  // в текущем каталоге. Это несложно, так как оно равно количеству 
  // элементов массива m_Files, в который они помещаются функцией 
  // AppendFileNames. Но есть одна проблема. Если файлов в этом
  // каталоге не найдено, массив вообще не будет создан, и переменная 
  // m_iFiles будет содержать NULL. Следующий код является стандартным
  // обходом подобных проблем.
  m_iFiles = m_Files == null ? 0 : m_Files.Length;
  // Делаем тоже самое для каталогов.
  m_iDirs = m_SubDir.Length;

  // Теперь нужно подсчитать количество файлов и каталогов в подкаталогах.
  // Для этого просто перебираем объекты AscDir, которые к этому времени
  // уже полностью проинициализированы. Так как конструктор вызывает себя
  // рекурсивно, после выхода из него поле m_iFiles будет содержать 
  // количество файлов во всех его подкаталогах, а значит нам достаточно 
  // считать значение m_iFiles только у следующего уровня подкаталогов.
  foreach(AscDir SubDir in m_SubDir)
  {
    m_iFiles += SubDir.m_iFiles;
    m_iDirs  += SubDir.m_iDirs;
  }
}

/// <summary>
/// Внутренняя функция, копирующая имена файлов из FileInfo в 
/// список файлов (m_Files). Чтобы кто-нибудь не попытался вызвать ее 
/// извне, объявляем ее как private.
/// </summary>
private void AppendFileNames(FileInfo[] afi)
{
  if(afi != null && afi.Length > 0) // Если массив не пуст.
  {
    // Узнаем, сколько имен файлов уже помещено в массив m_Files.
    int iCurLen = (m_Files == null ? 0 : m_Files.Length);
    // Создаем новый массив, размер которого достаточен, чтобы вместить
    // дополнительные имена файлов. В принципе, здесь можно было бы 
    // воспользоваться ArrayList для упрощения алгоритма, но и так
    // тоже неплохо.
    string[] sTmp = new string[iCurLen + afi.Length];
    // Если старый массив не пуст...
    if(m_Files != null)
      // копируем элементы из старого массива в новый.
      Array.Copy(m_Files, sTmp, m_Files.Length);
    // Теперь iCurLen указывает на первый новый элемент.
    // Но использовать переменную с таким именем будет нехорошо по
    // отношению к тем, кто будет читать этот код в будущем. Так что 
    // заводим новую переменную для перебора оставшихся элементов массива.
    // Оптимизация должна ее выкинуть.
    int i = iCurLen;
    // Перебираем список найденных файлов и копируем их имена 
    // в хвост массива.
    foreach(FileInfo fi in afi)
    {
      sTmp[i] = fi.Name;
      i++;
    }
    // Присваиваем в m_Files ссылку на новый массив.
    m_Files = sTmp;
  }
}

Рекурсия позволила упростить алгоритм разбора каталогов и поиска файлов. Правда, не все могут нормально воспринимать рекурсивные алгоритмы, так как для этого нужно иметь рекурсивное воображение. :) Но рекурсивные алгоритмы очень красивы и лаконичны, чем мне и нравятся.

Последний метод класса AscDir – InternalIterateNode. Он позволяет упростить перебор файлов, вызывая для каждого файла callback-метод, ссылка на который передается через параметр (имеющий тип – делегат):

internal void InternalIterateNode(FileCallback Callback, string sPath)
{
  // Если это корневой объект...
  if(m_ParentDir != null)
    sPath += m_sName + '\\'; // Добавляем имя корневого каталога.
  // Перебираем все подкаталоги, вызывая этот же метод (рекурсивно).
  foreach(AscDir Dir in m_SubDir)
    Dir.InternalIterateNode(Callback, (string)sPath.Clone());
  // Если список имен файлов не пуст...
  if(m_Files != null)
    foreach(string File in m_Files) // перебираем их...
      Callback(sPath + File); // и вызываем для каждого callback-метод.
}

Такой стиль перебора элементов (основанный на обратных вызовах [callback]), не лежащих в одном плоском списке, был очень популярен во времена царствования языка С. С приходом C++ это метод потерял популярность, так как C++ не имел возможностей наподобие делегатов. С появлением .Net этот стиль получает вторую путевку в жизнь.

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

Создание стандартного итератора

В .Net уже имеется интерфейс итератора:

interface IEnumerator
{
  bool MoveNext ();
  void Reset ();
  object Current {  get; }
}

Обычно этот интерфейс реализуется в отдельном классе. Сам класс, который должен поддерживать перечисление, должен реализовать интерфейс IEnumerable, состоящий из одного метода:

interface IEnumerable
{
  IEnumerator GetEnumerator();
}

Так, класс AscSearchEngine реализует этот интерфейс следующим образом:

// Реализация интерфейса IEnumerable
// Нужно реализовать только один метод GetEnumerator
IEnumerator IEnumerable.GetEnumerator()
{ // Создаем итератор и запрашиваем у него IEnumerator
  return (IEnumerator)new AscDir.Iterator(m_Root);
}

Ниже поведен код вложенного в AscDir класса Iterator. Он реализует перебор файлов. Используя этот итератор, можно перебирать файлы с помощью оператора foreach:

AscSearchEngine asd = new AscSearchEngine();
asd.ScanDir(@"C:\temp\test", "*.h");
foreach(string sFile in asd)
  SomeFileProcessingFunction(sFile);

Такой код превращается компилятором в нечто вроде следующего:

AscSearchEngine asd = new AscSearchEngine();
asd.ScanDir(@"C:\temp\test", "*.h");
IEnumerator en = (IEnumerable)asd.GetEnumerator();
while(en. MoveNext())
  SomeFileProcessingFunction(en.Current);

Итак, вот обещанная реализация итератора:

/// <summary>
/// Итератор последовательно просматривает все ветки дерева
/// объектов AscDir и позволяет перебрать находящиеся в нем файлы.
/// </summary>
public class Iterator : IEnumerator 
{
  AscDir m_ad; // Текущая ветка.
  AscDir m_adRoot; // Корневая ветка.
  // Позиция текущего каталога в списке подкаталогов (m_SubDir[]) 
  // родительского AscDir. При возврате из рекурсивного вызова нужно
  // знать эту позицию, чтобы продолжить просмотр со следующей.
  int m_iCurrDir;

  // Текущий (в m_Files[]) просматриваемый файл.
  int m_iCurrFile;

  // Стек для хранения позиции в списке каталогов (m_SubDir[]) при 
  // рекурсивном вызове.
  // 20 – это размер занимаемой по умолчанию памяти. Задание разумного
  // размера позволяет увеличить скорость работы программы. В случае
  // превышения этого значения стек будет автоматически расширен.
  Stack aryDirStak = new Stack(20);

  public Iterator(AscDir ad)
  {
    m_adRoot = ad; // Запоминаем корневой элемент.
    Reset(); // Сбрасываем состояние итератора.
  }

  // IEnumerator (методы – реализация интерфейса)

  // Сброс состояния. Если вызвать этот метод, то перебор начнется заново.
  public void Reset()
  { // Сброс означает установку начальных значений.
    m_iCurrDir = 0;
    m_iCurrFile = -1;
    m_ad = m_adRoot;
  }

  // Этот метод вызывается перед обработкой каждого элемента (даже в 
  // первый раз). Если возвратить false, то перебор прекратится, если
  // TRUE, перебор продолжится. При этом MoveNext должен обеспечить
  // переход на следующий элемент.
  public bool MoveNext()
  {
    if(m_ad.m_Files == null || m_iCurrFile + 1 >= m_ad.m_Files.Length)
    { // Если мы перебрали все файлы или их нет вовсе...
      if(m_iCurrDir < m_ad.m_SubDir.Length)
      { // m_iCurrDir указывает на поддкаталог...
        // Запоминаем ее позицию в стек...
        aryDirStak.Push(m_iCurrDir);
        // Делаем родительским каталог из позиции m_iCurrDir. Т.е. 
        // продвигаемся на ветку ниже...
        m_ad = m_ad.m_SubDir[m_iCurrDir];
        // Сбрасываем позицию каталога и файла.
        m_iCurrDir = 0;
        m_iCurrFile = -1;
        // Рекурсивно вызываем этот же метод. Тем самым перебирая подветки
        // данной ветки.
        return MoveNext();
      }
      else
      { // Подкаталогов больше нет...
        if(aryDirStak.Count > 0)
        { // Если мы внутри подкаталога, то нужно из него выйти.
          // Восстанавливаем значения всех переменных...
          // При восстановлении индекса текущей подкаталога (m_iCurrDir)
          // увеличиваем его на единицу, делая тем самым активным следующий
          // подкаталог.
          m_iCurrDir = (int)aryDirStak.Pop() + 1;
          m_ad = m_ad.m_ParentDir;
          m_iCurrFile = m_ad.m_Files == null 
            ? 0 : m_ad.m_Files.Length;
          //...вызываем рекурсивно ту же функцию. Пускай она сама 
          // разбирается, что нужно делать дальше.
          return MoveNext();
        }
        // Если стек вложенности каталогов пуст, и нет больше
        // файлов, перебор закончен.
        return false;
      }
    }
    else
    { // Перебираем файлы текущего подкаталога...
      m_iCurrFile++;
      // Если мы вышли за пределы массива файлов, нужно рекурсивно
      // вызвать эту же функцию, чтобы она разобралась с этой ситуацией
      // на следующем шаге своей работы.
      if(m_iCurrFile >= m_ad.m_Files.Length)
        return MoveNext();
      // Иначе возвращаем TRUE, говря тем самым, что элемент можно 
      // читать с помощью свойства Current.
      return true;
    }
  }

  // Свойство возвращающее текущий элемент. 
  public object Current 
  {
    // m_iCurrFile задается в MoveNext
    get{return m_ad.m_Files[m_iCurrFile]; }
  }
}

Принцип действия этого итератора прост, он перебирает все подкаталоги, заходя в каждый из них, и перебирает файлы в этих каталогах. Но, как вы могли заметить, реализация этого итератора намного сложнее, чем функции IterateNodes (точнее, InternalIterateNode), которая была описана выше. Дело в том, что в итераторе приходится изворачиваться и уходить от рекурсии. Разворот рекурсии всегда приводит к увеличению и усложнению кода. Зачастую такой разворот может сопровождаться ускорением работы алгоритма (но в нашем случае это не так). В принципе, процедура остается рекурсивной, но введение дополнительных переменных и стека позволяет перейти к итеративному поиску следующего элемента. Пока ищется следующий элемент, используется рекурсия, но как только элемент найден, текущее состояние запоминается в переменных итератора, а управление возвращается основной программе.

AscRegExpParser

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

Класс имеет две внутренние переменные:

// Объект, предоставляющий функциональность работы с 
// регулярными выражениями.
protected Regex m_rx;
// Строка которой должен заменяться найденный текст.
protected string m_sReplaceWith;

Эти переменные инициализируются в конструкторе:

/// <summary>
/// Инициализирует регулярные выражения.
/// </summary>
/// <param name="sPatern">Строка для поиска.</param>
/// <param name="sReplaceWith">Строка для заметы.</param>
/// <param name="Options">Опции.</param>
public AscRegExpParser(string sPatern, string sReplaceWith, 
                       RegexOptions Options)
{
  m_rx = new Regex(sPatern, Options);
  m_sReplaceWith = sReplaceWith;
}

Конструктор Regex принимает два параметра: строку с регулярным выражением и опции (в виде битовых флагов). Интересно, что RegexOptions является перечислением (имеет тип enum). Но C# запрещает логические операции над константами перечислений, а параметр Options как раз должен содержать результат таких логических операций. Как же быть? В Паскале для решения подобных задач был введен специальный тип данных (Set). В C/C++ приходилось объявлять такие переменные как целочисленные (например, int). Решение C/C++ является нетипобезопасным, но, видимо, пойти на поводу у Паскаля разработчики C# тоже не могли, и у них родилось альтернативное решение. Они ввели атрибут [Flags]. Если при описании перечисления (enum-а) указать атрибут [Flags], то над переменными, имеющими тип этого перечисления, можно будет производить булевы операции, как будто это не перечисление, а обычный int. Получился красивый, хотя и спорный, паллиативный вариант.

Этот класс содержит также несколько простых методов. ReadFile – читает содержимое файла в строку.

/// <param name="sFilePath">Имя файла.</param>
/// <returns>Содержимое файла.</returns>
public string ReadFile(string sFilePath)
{
  using(StreamReader sr = new StreamReader(sFilePath, Encoding.Default, 
                                           false, 3000000))
  {
    return sr.ReadToEnd();
  }
}

StreamReader позволяет прочесть Stream (объект, обеспечивающий потоковые чтение и запись) или файл в виде текстовой строки. По-моему, более подходящим было бы название AnythingReader или просто Reader, но программисты из Microsoft предпочли назвать его так.

После окончания чтения данных нужно закрыть файл. Для этого нужно вызывать метод Close или метод Dispose интерфейса IDisposable. Задача усложняется тем, что в любой момент после открытия файла может произойти исключение, и файл не будет закрыт до следующей сборки мусора (а она может быть отложена на очень большой срок, вплоть до закрытия приложения). Поэтому работу с файлом нужно помещать в блок try, а вызов метода Close помещать в блок finally. Это довольно неудобно (много кода). Упростить код можно с помощью конструкции using() {...}. Нужно просто объявить переменную, класс которой реализует интерфейс IDisposable, а все действия с файлом поместить в фигурные скобки (если это отдельный оператор, как в нашем случае, то фигурные скобки можно опустить). Отсутствие в стопроцентно CLR-совместимых (в MC++ это возможно, хотя и с значительными ограничениями) языках автоматически вызываемых при выходе из области видимости деструкторов делает using единственным надежным способом контроля за системными ресурсами (файлами, подключениями к БД и т.п.).

Метод Parse разбирает содержимое файла с помощью регулярных выражений, ищущий в нем соответствующие вхождения (не производя замены):

/// <param name="sText">Текст, в котором нужно производить поиск.</param>
/// <returns>Коллекция вхождений, найденных в тексте.</returns>
/// <remarks>Все недостающие значения задаются в параметрах
/// конструктора.</remarks>
public MatchCollection Parse(string sText)
{
  return m_rx.Matches(sText);
}

Элемент коллекции MatchCollection содержит информацию о вхождении в тексте регулярного выражения, например: смещение вхождения относительно начала текста – Index, длину текста – Length и сам текст. Этой информации достаточно, чтобы найти вхождение внутри основного текста (что, собственно, нам и нужно сделать на стадии предварительного просмотра – «Preview»).

ReadAndParsFile – объединяет функциональность двух предыдущих методов:

public MatchCollection ReadAndParsFile(string sFilePath)
{
  return Parse(ReadFile(sFilePath));
}

И, наконец, Replace - Считывает файл и производит замену его содержимого:

/// <param name="sFilePath">Полный путь к файлу.</param>
/// <remarks>Все недостающие значения задаются в параметрах
/// конструктора.</remarks>
public void Replace(string sFilePath, int iReplaceCount)
{
  string sText = null;
  if(iReplaceCount > 0)
    sText = m_rx.Replace(ReadFile(sPath), m_sReplaceWith, iReplaceCount);
  else
    sText = m_rx.Replace(ReadFile(sPath), m_sReplaceWith);
  using(StreamWriter sw = new StreamWriter(sPath, false, Encoding.Default))
    sw.Write(sText);
}

Параметр iReplaceCount этого метода говорит о том, сколько замен нужно сделать. Если он меньше или равен нулю, значит, нужно заменять все найденные вхождения. В зависимости от значения iReplaceCount вызывается тот или иной конструктор Regex. Далее происходит запись измененного содержимого обратно в файл. Это делается с помощью экземпляра класса StreamWriter (антипода класса StreamReader). Как и в других местах (например, функции ReadFile этого же класса), закрытие файла производится автоматически (с помощью директивы using).

Закладка «Preview»

Вторая закладка RegExRep предназначена для предварительного просмотра результатов поиска и замены. Она будет содержать следующие элементы управления:

Как и на предыдущей закладке, все элементы управления выровнены с помощью Docking, но я больше не буду обращать на это внимание, так как думаю, вы уже разобрались в принципах его работы. Законченный вариант можно увидеть, загрузив пример с прилагаемого к журналу CD, или скачав с сайта rsdn.ru.

На рисунке 5 приведен внешний вид закладки «Preview».


Рисунок 5. Предварительный просмотр результатов поиска и замены.

В дереве справа выводятся файлы, удовлетворяющие шаблону поиска, заданному в поле «File's pattern». При этом сама замена не производится. Вместо этого найденные вхождения выводятся в списке справа, а ниже, под этим списком, выводится содержимое файла и текст, который получится в результате замены выделенного вхождения. Для удобства происходит прокрутка к найденному фрагменту, а сам он выделяется.

Таким образом, пользователь может задавать параметры поиска/замены и интерактивно проверять их на закладке «Preview», не боясь испортить файлы.

Для того, чтобы все это великолепие появилось, нужно обработать событие переключения закладки, и, если открывается закладка «Preview», заполнять дерево и список вхождений. Определить, что это произошло, можно, обработав событие SelectedIndexChanged элемента управления tabControl1. Вот этот обработчик:

private void tabControl1_SelectedIndexChanged(object sender, System.EventArgs e)
{
  // Следующий вызов являются чистым шаманством. Он нужен из-за
  // того, что сплитеры не проверяют выхода за пределы окна при его 
  // уменьшении. И не реагируют даже на эту шаманскую операцию, если они
  // не видны на экране. Так что приходится шаманить. Единственный полезный
  // урок, который можно извлечь из этого кода, это то, что шаманство лучше
  // локализовать в отдельной процедуре и хорошенько его прокомментировать.
  // Это позволит более опытным товарищам устранить шаманство, заменив его 
  // разумным кодом, или хотя бы отделить шаманство от разумного кода. Кто
  // знает? Возможно в следующей версии библиотеки недоработка (или ошибка)
  // будет исправлена, и такой код станет бесполезным, а то и вредным...
  SplitterShamanism();

  // Если открывается закладка «Preview», перечитать список каталогов
  // и файлов.
  if(tpPreview == tabControl1.SelectedTab)
    PreviewReadDirs();
}

Шаманская заплатка выглядит так:

private void SplitterShamanism()
{
  if(this.WindowState == FormWindowState.Normal)
  {
    splHorPrivew.SplitPosition = splHorPrivew.SplitPosition;
    splVerPrivew.SplitPosition = splVerPrivew.SplitPosition;
    splHorSetings.SplitPosition = splHorSetings.SplitPosition;
  }  
}

Она всего-навсего эмулирует модификацию свойства SplitPosition, что вызывает проверку минимальных размеров элементов управления (задаваемых свойствами MinExtra, MinSize). При этом происходит коррекция размеров элементов управления и положения сплитера. Если бы такой коррекции не происходило, сплитер вместе со вторым элементом управления периодически оказывались бы за пределами видимой области окна, (что, скажу вам по опыту, сильно раздражает пользователей, которые пытаются выместить свое раздражение на вас).

Метод PreviewReadDirs загружает список каталогов и файлов в TreeView:

void PreviewReadDirs()
{
  try
  {
    // Инициализируем парсер регулярных выражений.
    // В качестве параметров передаем ему искомый текст, текст для замены
    // и настройки регулярных выражений.
    m_Rep = new AscRegExpParser(tbFind.Text, tbReplaceTo.Text, 
                                GetRegExOptions());

    // Запоминаем во временные переменные индекс в списке вхождений и путь
    // до выделенного файла. Это делается исключительно для удобства
    // пользователя. Мы попытаемся восстановить файл и индекс после 
    // окончания загрузки списка файлов и каталогов. Не факт, что получится
    // это сделать, так как все в этом мире переменчиво, и порой внезапно.
    // Но если это удастся сделать, вы сможете услышать благодарное 
    // молчание пользователя, взамен воспоминаний о ваших предках по
    // материнской линии.
    string sPath = m_sCurrentTvPath;
    int iSelFileMatch = lbFileMatch.SelectedIndex;

    // Начинаем обновление дерева (объекта TreeView). Для того, чтобы 
    // не было морганий нужно вызвать метод BeginUpdate.
    tvFiles.BeginUpdate();
    // Чем бы не закончилась операция заполнения дерева после ее 
    // окончания, необходимо включить отрисовку. Для этого 
    // код, заполняющий дерево, нужно взять в блок try, а включение
    // отрисовки делать в блоке finally.
    try
    {
      // Вызываем процедуру сканирования каталогов.
      m_SearchEngine.ScanDir(tbPath.Text, tbExtensions.Text);
      // Заполняем TreeView найденными значениями.
      m_SearchEngine.FillTreeView(tvFiles);

      // Пытаемся найти ветку с запомненным ранее путем. Если находим,
      // делаем ее активной...
      tvFiles.SelectedNode = FindNode(tvFiles.Nodes, sPath);
      if(iSelFileMatch >= 0 && lbFileMatch.Items.Count > iSelFileMatch)
        lbFileMatch.SelectedIndex = iSelFileMatch;

      // Выводим в последнюю панель StatusBar-а количество найденных файлов.
      // Чтобы можно было работать с панелями, нужно установить
      // свойство ShowPanels StatusBar-а в TRUE и настроить панели. Для
      // этого нужно вызвать дизайнер, нажав кнопку возле свойства Panels.
      statusBar1.Panels[ciPanelFileCount].Text = 
             m_SearchEngine.FilesCount.ToString();
    } // Что бы ни произошло, включаем отрисовку у TreeView.
    finally { tvFiles.EndUpdate(); }
  }
  catch(System.Exception ex)
  { // Если произошла ошибка, выводим сообщение о ней. 
    // Не стоит аварийно завершать приложение, если не удалось показать
    // предварительный просмотр. Ведь пользователь может захотеть сохранить
    // настройки!
    MessageBox.Show(ex.Message, csAppName, MessageBoxButtons.OK,
                    MessageBoxIcon.Error);
  }
  // Операция была охочая до памяти, поэтому можно вызвать GC, хотя
  // можно этого и не делать.
  GC.Collect();
}

Функция GetRegExOptions проверяет все CheckBox-ы и возвращает набор флагов RegexOptions (о котором уже говорилось выше), управляющий поведением парсера регулярных выражений:

RegexOptions GetRegExOptions()
{
  RegexOptions o = RegexOptions.None;
  if(cbIgnoreCase.Checked)
    o |= RegexOptions.IgnoreCase;
  if(cbMultiline.Checked) 
    o |= RegexOptions.Multiline;
  if(cbExplicitCapture.Checked) 
    o |= RegexOptions.ExplicitCapture;
  if(cbSingleline.Checked) 
    o |= RegexOptions.Singleline;
  if(cbIgnorePatternWhitespace.Checked) 
    o |= RegexOptions.IgnorePatternWhitespace;
  if(cbCompiled.Checked) 
    o |= RegexOptions.Compiled;
  return o;
}

FindNode ищет ветку по заданному (в виде строки) пути.

/// <param name="tnc">Коллекция подветок ветки дерева.</param>
/// <param name="sPath">Путь в виде строки, разделенной символами '\\'
/// или '/'.</param>
/// <returns>В случае успеха возвращается найденная ветка (TreeNode).
/// Иначе возвращается null.</returns>
TreeNode FindNode(TreeNodeCollection tnc, string sPath)
{
  // Разбираем путь. Элементы массива aryPathNode будут содержать имена
  // веток в иерархии.
  string[] aryPathNode = sPath.Split(new char[] {'\\', '/'});
  TreeNode tnCurr = null;
  // Перебираем все поддветки
  foreach(string sPathNode in aryPathNode)
  {
    foreach(TreeNode tn in tnc)
    { // Ищем ветку с совпадающим заголовком.
      if(tn.Text == sPathNode)
      {
        tnCurr = tn;
        break;
      }
    }
    // Если ветка не найдена, tnCurr равна null
    if(null == tnCurr)
      return null;
    tnc = tnCurr.Nodes;
  }
  return tnCurr;
}

Вернемся к PreviewReadDirs... После заполнения TreeView каталогами и файлами производится попытка найти и сделать активной ветку с путем, аналогичным ранее активной ветке. Такой ветки может и не найтись. В этом случае в свойство SelectedNode (свойство TreeView позволяющее узнать или установить активную ветку) попадает NULL. В результате TreeView не будет иметь ни одной активной ветки. При активации некоторой ветки TreeView генерирует событие AfterSelect. Это же событие генерируется, если ветка активизируется пользователем. Необходимо обработать это событие, загрузив на него список вхождений регулярных выражения в lbFileMatch (ListBox) и содержимое файла в tbFileBody (TextBox):

private void tvFiles_AfterSelect(object sender, 
              System.Windows.Forms.TreeViewEventArgs e)
{
  // Считываем активную ветку в локальную переменную.
  TreeNode tnSel = tvFiles.SelectedNode;
  if(tnSel == null) // Если активной ветки нет...
    return;  // ...завершаем обработку.
  // Получаем путь (в виде строки с именами каталогов, разделенными
  // знаком «\» (Например «TEMP\001\Magic»), и запоминаем ее в 
  // переменной m_sCurrentTvPath. 
  m_sCurrentTvPath = tnSel.FullPath;
  // Формируем полный путь к каталогу (из выбранного в дереве 
  // и указанного в поле tbPath).
  string sPath = MakePath(tbPath.Text, m_sCurrentTvPath);
  // Выводим этот получившийся путь в статусную строку.
  SetStatusTetx(sPath);

  // Если выделяется не файл (файлы мы отличаем по иконкам)...
  if(tnSel.ImageIndex != AscSearchEngine.ciFileSel)
  {
    lbFileMatch.Items.Clear(); // очищаем список вхождений рег. выр...
    tbFileBody.Clear(); // очищаем текст файла...
    return; // и выходим, так как ветка не является файлом.
  }

  GC.Collect(); // Предстоит массовая работа с памятью, так что почистим GC.
  // Читаем файл и помещаем его содержимое в текстовое окно tbFileBody.
  tbFileBody.Text = m_Rep.ReadFile(sPath);
  // Разбираем текст (анализируем на вхождение искомого текста).
  // В итоге получаем список вхождений (коллекцию MatchCollection). 
  MatchCollection mc = m_Rep.Pars(tbFileBody.Text);
  lbFileMatch.BeginUpdate(); // Блокируем отрисовку ListBox-а.
  try
  {
    lbFileMatch.Items.Clear(); // Очищаем ListBox.
    // Перебираем вхождения, найденные парсером регулярных выражений...
    foreach(Match m in mc)
       // и добавляем их описание в ListBox.
      lbFileMatch.Items.Add(new AscMatch(m));
    // Если найдено хотя бы одно вхождение, ...
    if(lbFileMatch.Items.Count > 0)
      lbFileMatch.SelectedIndex = 0; // активизируем первый элемент списка.
  }
  finally{ lbFileMatch.EndUpdate(); } // Снимаем блокировку перерисовки.
  GC.Collect(); // Мы активно работали с памятью... можно ее и уплотнить...
}

MakePath – это простая функция позволяющая «склеить» два пути:

/// <param name="sPath1">Первый путь. Может оканчиваться на '/' или '\\',
/// а может не оканчиваться.</param>
/// <param name="sPath2">Не может начинаться с '/' или '\\'.</param>
/// <returns></returns>
private static string MakePath(string sPath1, string sPath2)
{
  StringBuilder sb = new StringBuilder(sPath1, 1000);
  if(sb.Length > 0)
    if(sb[sb.Length - 1] != '/' || sb[sb.Length - 1] != '\\')
      sb.Append('\\');
  sb.Append(sPath2);
  return sb.ToString();
}

Если строка не заканчивается на символ «\» или «/», то функция дописывает в конец символ «\». В конце работы функция добавляет к первому пути второй. При этом второй путь не должен начинаться с перечисленных символов. Это чисто вспомогательная функция, не имеющая прямого отношения к классу, поэтому она объявлена как статическая и скрытая (private static). Данный код вынесен в отдельную функцию для упрощения основной функции, к тому же он встречается в коде программы дважды.

Обратите внимание, что к символам StringBuilder-а можно обращаться с помощью квадратных скобок (так, как будто StringBuilder является массивом символов). Это возможно потому, что C# поддерживает переопределение операторов. Эту возможность он позаимствовал у C++. Оператор «[]» так же переопределен и для системного класса String (строки C#). Это позволяет в C# работать со строками как с массивом символов, что должно порадовать С-шников и Паскалистов.

Самым интересным моментом обработчика события tvFiles_AfterSelect является заполнение ListBox-а lbFileMatch. Интересен он тем, что в качестве элемента списка методу Add передается экземпляр класса, а не строка, как это обычно бывает. Дело в том, что обычно ListBox хранит список в виде строк. Если нужно хранить дополнительную информацию, то с элементом списка можно ассоциировать некоторые пользовательские данные. В C++ это обычно указатель на void, в высокоуровневых языках типа VB – это Variant (или другой универсальный тип). В WinForms в качестве элемента списка можно добавить любой объект. Более того, один ListBox может хранить объекты разных типов. При этом текст задавать вообще не нужно! Это довольно неожиданно и странно, но на самом деле очень удобно (к тому же оригинально)! Дело в том, что любой объект (даже простые типы, такие как int или double) в CLR (и в том числе в C#) унаследован (хоть и мнимо) от класса object (object является всего лишь псевдонимом в C# для CLR-типа System.Object). Класс object реализует виртуальный метод ToString. Этот метод возвращает строку, приемлемую для чтения человеком. Так, число с плавающей точкой или экземпляр структуры DateTime вернут свое строковое представление. При этом и DateTime (дата), и число с плавающей точкой будут выведены с учетом региональных установок текущего пользователя. Новый класс наследует реализацию базового. Реализация System.Object (по умолчанию) выведет название класса и другую малоинтересную информацию.

Но мы немного отвлеклись. Вернемся к ListBox-у. Итак, как вы уже, наверное, догадались, ListBox использует в качестве строки, идентифицирующей элемент списка, значение, возвращаемое методом ToString. Такое оригинальное решение принято не спроста. Дело в том, что это позволяет чертовски упростить и укоротить код. Так, в ListBox без каких бы то ни было преобразований можно добавлять экземпляры любых типов. От базовых до структур и классов. Главное, чтобы их методы ToString предоставлял строку разумного содержания, способную идентифицировать значение хранимого экземпляра. Естественно, что в качестве такого экземпляра можно использовать и обычную строку (она тоже предоставляет реализацию метода ToString, которая, впрочем, ничего не делает, просто возвращает ссылку на эту же строку). Но намного интереснее помещать в ListBox объекты, так сказать, собственного производства. Естественно, для них нужно создать собственную реализацию метода ToString. Таким способом убиваются сразу два зайца. С одной стороны, мы храним необходимую нам информацию (экземпляр нужного нам объекта или простого типа). С другой, тем же самым объектом мы задаем текст для элемента списка. В принципе, объект может быть добавлен к списку более одного раза, поэтому идентификация элементов списка в основном осуществляется по индексу. Так, у списка есть коллекция объектов (Items), доступ к элементам которой производится по индексу. Но первый элемент коллекции можно узнать с помощью метода коллекции IndexOf. Активный элемент списка можно узнать из свойств SelectedItem или SelectedIndex ListBox-а. Первое свойство возвращает индекс активного элемента (или отрицательное значение, если такового нет). Второе – сам объект (или null в отсутствии активной записи).

Для Value-типов (структуры и простые типы (int, char, double...) перед добавлением производится так называемый боксинг (реально он происходит автоматически при преобразовании Value-типов к типу object). Для обычных объектов в список помещается ссылка. Причем в отличие от многих языков программирования (таких, как C++ и Object Pascal) список удерживает объекты от разрушения. Можно создать объект и, не оставляя дополнительных ссылок, поместить ссылку на него в ListBox. При этом объект будет жить ровно столько, сколько будет жить сам ListBox (т.е. пока в программе будут иметься ссылки на ListBox). После уничтожения ListBox-а сборщик мусора автоматически уничтожит все объекты из списка, на которые нет ссылок из других мест программы.

Учитывая все это, для хранения информации о найденных вхождениях я использовал созданный мной специально для этого класс AscMatch. Вот его описание:

class AscMatch
{
  Match m_m;
  internal Match Match
  {
    get { return m_m; }
  }
  internal AscMatch(Match m)
  {
    m_m = m;
  }
  public override string ToString ()
  {
    return "(Index=" + m_m.Index + ", Len=" + m_m.Length + ")  " 
           + m_m.ToString();
  }
}

Нетрудно заметить, что этот класс является всего лишь контейнером для экземпляра класса Match, описывающего найденное вхождение. Метод ToString этого класса выводит строку, содержащую в своем начале индекс и длину вхождения (в скобках), а в конце саму найденную строку (внешний вид получаемой строки можно попытаться разглядеть на рисунке 3).

Код обработчика события tvFiles_AfterSelect создает экземпляр AscMatch, передавая в конструктор ссылку на Match, и передает ссылку на новый объект методу Add коллекции Items ListBox-а. Add добавляет ссылку на AscMatch в коллекции, тем самым удерживая сборщик мусора от уничтожения этого объекта. Когда ListBox-у нужно отобразить строку, он получает ее путем вызова метода ToString объекта AscMatch.

При установке свойства SelectedIndex (как в обработчике события tvFiles_AfterSelect) или при активизации элемента списка пользователем ListBox-е lbFileMatch генерирует событие SelectedIndexChanged. Обработчик этого события выделяет соответствующий фрагмент текста в текстовом окне tbFileBody (производя прокрутку этого окна, так, чтобы выделенный текст стал виден на экране) и заполняет текстовое окно tbReplaceResult значением подстановки (которое получалось бы, если бы замена была произведена):

private void lbFileMatch_SelectedIndexChanged(object sender, 
                                              System.EventArgs e)
{
  try
  {
    // Получаем активный объект (элемент списка). Так как в список 
    // добавлялись только экземпляры класса AscMatch, смело приводим
    // выделенный элемент к типу AscMatch.
    AscMatch m = (AscMatch)lbFileMatch.SelectedItem;
    // Выделяем соответствующий участок текста...
    tbFileBody.Select(m.Match.Index, m.Match.Length);
    // и заставляем текстовое окно показать выделенный текст, прокрутив 
    // содержимое окна.
    // Эта функциональность не реализована в WinForms,
    // поэтому приходится прибегать к старому доброму WinAPI (см. ниже).
    SendMessage(tbFileBody.Handle, EM_SCROLLCARET, 0, 0);
    // Помешаем в tbReplaceResult результат замены найденного фрагмента.
    tbReplaceResult.Text = m.Match.Result(tbReplaceTo.Text);
  }
  catch(Exception ex)
  { // В случае чего пугаем пользователя. :)
    MessageBox.Show(ex.Message, csAppName, MessageBoxButtons.OK, 
                    MessageBoxIcon.Error);
  }
}

Как сказано в комментариях, TextBox в .Net не умеет осуществлять программного скролинга. Но так как он создан на базе стандартного класса окна Windows (Edit), скроллинг можно осуществить с помощь посылки окну сообщения EM_SCROLLCARET. Handle окна можно получить из одноименного свойства любого оконного объекта WinForms. В бета версиях VS.Net у оконных объектов типа TextBox-а существовал даже метод, позволяющий послать Windows-сообщение этому окну. Но в окончательной версии WinForms он был скрыт от посторонних глаз. Чтобы послать текстовому окну сообщение, мне пришлось самостоятельно описать функцию SendMessage и сообщение EM_SCROLLCARET:

const int EM_SCROLLCARET = 0x00B7;
[DllImport("User32.dll")]
extern static int SendMessage(IntPtr hWnd, UInt32 msg, 
                              Int32 wParam, Int32 lParam);

Сообщение EM_SCROLLCARET прокручивает текст в текстовом окне таким образом, чтобы курсор или начало выделения были видны пользователю.

Прямое использование Win32 API может создать непереносимый код и привнести в программу трудноуловимые ошибки. Если первое при создании десктоп-приложений на .Net не более, чем снобизм, то второе опасение нужно воспринимать очень серьезно. Прямые вызовы Windows API Windows лучше вообще не применять в приложениях напрямую . Если без них не обойтись, лучше запаковать их в отдельную библиотеку, сделав безопасную обертку над ними. Кстати, вы будете не единственным из пытающихся обойти неполноту в .Net. Перед созданием собственных библиотек стоит сделать поиск по Internet. Возможно, кто-то уже предпринял подобную попытку. Например, для благоустройства WinForms-приложений можно использовать свободно распространяемую библиотеку «Magic» (http://www.dotnetmagic.com/). В ней реализованы расширенная Docking-инфраструктура, аналогичная применяемой в VS.Net, меню в стиле Office XP и окна-закладки (на любой вкус). Причем все это уже оформлено в безопасные управляемые обертки. Помните, что только в крайнем случае есть смысл прибегать к прямому использованию API.

Пакетная обработка

Итак, на данные момент наше приложение позволяет ввести/сохранить/загрузить настройки, а также обладает режимом предварительного просмотра. Но главной целью этого приложения является пакетная замена. Мы намеревались создать два режима пакетной замены. Один должен вызываться из GUI (при нажатии кнопки в ToolBar-е или при выборе пункта меню "Run\Go"). Второй должен позволять производить замену из командной строки. При этом пользователь вообще не обязан интерактивно взаимодействовать с приложением (все необходимые настройки должны передаваться через командную строку).

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


Рисунок 6. Окно с индикатором прогресса.

Это диалог оформлен как отдельная форма. Добавить ее можно, выбрав во время разработки пункт меню «Project\Add Windows Form...». Теперь необходимо задать форме имя «frmBatchReplace», установить ее размеры и поместить на нее три элемента управления:

Имя элемента управленияТипОписание
ProgBarProgressBarПоказывает прогресс работы.
lbInfoLabelВ этот элемент управления выводится информация о количестве обработанных файлов.
pbCancelButtonКнопка прерывания пакетной замены.
Таблица 3.

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

Есть два способа заставить работать пользовательский интерфейс. Первый – проталкивать сообщения, например, вызвать функции Windows API PeekMessage, TranslateMessage, DispatchMessage. Но при этом придется влезать в подробности Windows, да и код окажется довольно большим. Второй способ – создать дополнительный рабочий поток и выполнить всю работу в нем.

Раньше я бы выбрал первый подход, но теперь, когда в .Net работа с потоками так упростилась, а обработка сообщений Windows столь сильно завуалирована, я остановился на втором варианте.

Итак, чтобы пользователь мог нажать кнопку, остановив тем самым пакетную замену, нужно производить поиск и замену в отдельном потоке. Этот поток должен также обновлять состояние полосы прогресса и содержимое элемента управления Label. Потоку потребуются данные, которые находятся в форме. Можно просто скопировать их в отдельную структуру и передать потоку (в принципе, так было бы правильнее). Но создавать промежуточные структуры – это немалый труд. Лень, двигатель прогресса, подтолкнула меня испробовать устойчивость и надежность WinForms и вместо создания промежуточной структуры вызывать из рабочего потока функцию главной формы.

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

frmBatchReplace m_frmBatchRep = new frmBatchReplace();

Метод ReplaceInFile активизирует закладку с Log-ом и открывает окно индикации прогресса в модальном режиме.

private void ReplaceInFile()
{
  tabControl1.SelectedTab = tpLog;
  m_frmBatchRep.ShowDialog(this);
}

Заметьте, что при открытии окна ему передается ссылка на родительское окно. С одной стороны, это позволяет сделать новое окно плавающим (popup) над родительским, а с другой - с помощью этой ссылки можно обращаться к данным родительского окна.

При загрузке форма генерирует событие Load. В нем мы создадим и запустим рабочий поток:

private void frmBatchReplace_Load(object sender, System.EventArgs e)
{
  Form1 frmMain = (Form1)Owner;
  m_bWorckDone = false;
  m_Thread = new Thread(new ThreadStart(frmMain.BatchReplase));

  m_Thread.Start();
}

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

Создаваемому потоку нужно передать делегат, содержащий ссылку на метод, являющийся главным методом потока (его стартовой точкой). Как уже говорилось выше, делегат может содержать ссылку на метод экземпляра объекта (т.е. фактически содержать адрес метода и ссылку на экземпляр объекта). Это делает работу с потоком похожей на вызов метода, только вызов происходит в контексте другого потока, и сразу после вызова управление возвращается в основной поток.

Обработчик frmBatchReplace_Load приводит ссылку на родительское окно к ссылке на основную форму приложения и передает в делегат ссылку на его метод BatchReplase. Теперь можно запускать поток.

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

public void BatchReplase()
{
  try // Нужно для перехвата ошибок и завершения обработки методом Abort
  {
    // Выводим в Log информацию о начале пакетной замены...
    BatchReplaseReport(string.Format(
         "Job started at {0} (Log is written in reverse mode).\r\n",
         DateTime.Now));
    // ... и обрабатываемом файле.
    BatchReplaseReport("Processing configuration file: " + m_sCurrentConfig);
    // Определяем, сколько замен в одном файле нужно производить (если
    // CheckBox cbReplAll включен, то заменять все вхождения (-1)).
    // Большинство простых системных типов имеют статический метод Parse,
    // позволяющий получить значение этого типа из строкового представления.
    // Таким образом «int.Parse(tbReplCount.Text)» преобразует строковое
    // значение, находящееся в текстовом окне tbReplCount в целое число.
    m_iReplaceCount = cbReplAll.Checked ? -1 : int.Parse(tbReplCount.Text);
    // Инициализируем парсер регулярных выражений ( как и при «preview»).
    m_Rep = new AscRegExpParser(tbFind.Text, tbReplaceTo.Text, 
                                GetRegExOptions());
    // Инициализируем счетчик файлов.
    m_iCurrFileIndex = 0;
    // Производим поиск файлов, подлежащих обработке.
    m_SearchEngine.ScanDir(tbPath.Text, tbExtensions.Text);
    // Выводим количество файлов в ячейку статусной строки.
    statusBar1.Panels[ciPanelFileCount].Text = 
          m_SearchEngine.FilesCount.ToString();
    // Задаем начальную (количество обрабатываемых файлов) информацию 
    // диалогу, отражающему прогресс поиска и замены.
    m_frmBatchRep.SetInfo(m_SearchEngine.FilesCount);
    // Перебираем все файлы.
    m_SearchEngine.IterateNodes(
         new AscSearchEngine.FileCallback(ProcessFile));
    // Выводим в Log информацию о количестве обработаных файлов.
    BatchReplaseReport(string.Format("\r\n{0} of {1} file(s) processed.",
        m_iCurrFileIndex, m_SearchEngine.FilesCount));
  }
  catch(ThreadAbortException) 
  { // Это исключение вызывается, если где-то в коде был вызван метод
    // Abort объекта Thread, так что выводим в Log сообщение о том,
    // что пакетная замена была прервана пользователем.
    BatchReplaseReport(string.Format(
        "Job was interruped by user. {0} of {1} file(s) processed.",
        m_iCurrFileIndex, m_SearchEngine.FilesCount)); }
  catch(Exception ex) 
  {
    // Обо всех остальных ошибках сообщаем пользователю, добавляя сообщение
    // в Log и выводя окно сообщения.
    BatchReplaseReport(string.Format(
       "\r\nJob was interruped due to error. {0} of {1} file(s) processed.",
       m_iCurrFileIndex, m_SearchEngine.FilesCount));
    MessageBox.Show("\r\nError while batch processing.\n\nDescription: " 
      + ex.Message, csAppName, MessageBoxButtons.OK, MessageBoxIcon.Error);
    throw ex; // Повторно возбуждаем исключение.
  }
}

Создавая логику работы потока, я сначала невольно (по привычке) создал переменную, говорящую о том, что пакетная обработка прервана. При создании многопоточных приложений другими средствами разработки использование исключений для завершения работы потока считалось очень нежелательным. В то же время логика «аварийного выхода» во многих случаях оказывается чересчур громоздкой и сложной для реализации. В .Net прерывание потока по исключению не является опасной ситуацией. GC освобождает разработчиков от контроля за правильностью освобождения памяти, тем самым упрощая работу с потоками. Для аварийной остановки работы потока в .Net предусмотрен даже специальный тип исключения – System.Threading.ThreadAbortException. Его не нужно возбуждать вручную. Это делает метод Abort объекта Thread. Этот метод может быть вызван из любого потока. Мы воспользуемся им для остановки рабочего потоке в случае если пользователь захочет закрыть диалог отображения прогресса (нажав крестик в правом верхнем углу окна). Вот код события Closing, вызываемого у формы, когда пользователь (или программа) пытается закрыть окно:

private void frmBatchReplace_Closing(object sender, System.ComponentModel.CancelEventArgs e)
{
  // В качестве метода потока используется метод основной
  // формы, которая является родительской по отношению к этой.
  Form1 frmMain = (Form1)Owner;
  // Если окно закрывается до окончания работы рабочего потока...
  if(!m_bWorckDone)
  {
    // Устанавливаем в FALSE переменную, говорящую, что нужно прекратить
    // выполнение всех пакетных замен (используется в режиме командной 
    // строки. Об этом речь пойдет дальше.
    frmMain.m_bCancelBatch = true;
    // ...и просто выдаем команду Abort рабочему потоку. Корректное 
    // закрытие файлов и т.п. обеспечивается использованием операторов
    // using() и try/finally.
    m_Thread.Abort();
  }
}

Интересно, что обрабатывать нажатие кнопки pbCancel нет необходимости. Достаточно указать в ее свойстве DialogResult значение Cancel и она будет пытаться закрыть окно при нажатии на нее или нажатии кнопки ESC.

IterateNodes получает в делегате ссылку на метод ProcessFile этой же формы:

/// <param name="sFileName">Имя файла.</param>
public void ProcessFile(string sFileName)
{
  // Увеличиваем счетчик файлов. Мы имеем к нему доступ, так как делегат
  // вызывается в контексте главной формы (хотя и из другого потока).
  m_iCurrFileIndex++;
  // Формируем путь к файлу.
  string sFilePath = MakePath(tbPath.Text, sFileName);
  SetStatusTetx(sFilePath); // Выводим путь в Log.
  try { m_Rep.Replace(sFilePath, m_iReplaceCount); } // Производим замену.
  // Обрабатываем исключение UnauthorizedAccessException, выводя сообщение
  // о нем в Log. Это исключение появляется в случае, если у пользователя 
  // нет прав на доступ к файлу или каталогу. Это не критичная ошибка.
  // Остальные исключения данным обработчиком не обрабатываются.
  catch(UnauthorizedAccessException ex) 
  { BatchReplaseLog(ex.Message); } // Выводим сообщение в Log...

  // Оповещаем окно прогресса о текущем состоянии работы.
  m_frmBatchRep.UpdateInfo(m_iCurrFileIndex);
}

Как уже говорилось ранее, передаваемый в этот метод делегат вызывается для каждого найденного файла. ProcessFile формирует полный путь к файлу и производит в нем замену. После этого вызывается метод UpdateInfo формы индикации прогресса (m_frmBatchRep). Этот метод выглядит следующим образом:

public void UpdateInfo(int iFile)
{
  ProgBar.Value = iFile; // Устанавливаем состояние ProgressBar-а.
  // Формируем сообщение о количестве обработанных файлов.
  lbInfo.Text = string.Format("Files: {0} of {1}", iFile, m_iFiles);
  if(m_iFiles == iFile) // Если обработан последний файл...
  {
    // ...выставляем флаг, говорящий том, что работа потока завершена.
    m_bWorckDone = true;
    Close(); // и закрываем окно.
  }
}

Информация о количестве файлов задается перед началом обработки функцией SetInfo:

public void SetInfo(int iFiles)
{
  m_iFiles = iFiles; // Запоминаем количество файлов.
  ProgBar.Maximum = iFiles; // Задаем максимальное значение ProgressBar-а.
  UpdateInfo(0); // Обновляем информацию о прогрессе.
}

Функции вывода текста в Log очень просты:

void BatchReplaseReport(string sText)
{
  SetStatusTetx(sText);
  BatchReplaseLog(sText);
}
void BatchReplaseLog(string sText)
{
  tbLog.Text = sText + "\r\n" + tbLog.Text;
}

Они выводят сообщение в Log (и в StatusBar) на закладке Result. Чтобы последние сообщения всегда были видны, она выводит сообщения в обратном порядке, т.е. новые сообщения оказываются сверху Log-а. Внешний вид Log-а можно увидеть на рисунке 7.


Рисунок 7. Log выполненной работы.

Обработка параметров командной строки

По замыслу, программа должна также вызываться в пакетном режиме и получать отдельные настройки из командной строки. Опция:

-H – выводит описание доступных опций командной строки.

-L – задает Log-файл. Если этот параметр указан, то после выполнения пакетной замены в него помещается информация с закладки «Log».

-D – каталог поиска. Этот параметр имеет более высокий приоритет, чем аналогичная настройка, сохраняемая в shr-файл. Это позволяет выполнять пакетную замену в нужном каталоге, не изменяя файла настроек.

-R – запускает программу в пакетном режиме. При этом пользовательский интерфейс не выводится, и сразу начинается пакетная замена. Этот параметр требует, чтобы был задан один или более shr-файлов. (см. параметр –S). Если задано более одного shr-файла, производится соответствующее (количеству заданных shr-файлов) количество замен.

Например, строка:

RegExRep.exe -r -SCppStr-2.shr -SCppStr.shr -LTestLog.txt -DC:\TEMP\Test

выполняет две пакетные замены в каталоге C:\TEMP\Test. Описание замен берется из файлов CppStr-2.shr и CppStr.shr. Эти файлы должны находиться в каталоге, из которого производится вызов RegExRep.exe. Информация о ходе работы помещается в фойл TestLog.txt.

Файлы и путь должны следовать сразу за именем параметра (без пробелов). Если в их именах есть пробелы, то необходимо взять эти имена в кавычки. Например, «-D"C:\TEMP\My Test"».

Разбор командной строки производится в главной функции приложения.

C# является наследником C. Не мудрено, что он тоже использует концепцию главной функции в качестве входной точки приложения. Но в C# было решено не включать глобальные переменные и глобальные функции, поэтому главная функция в C# должна быть объявлена как статическая в рамках какого-нибудь класса (который автоматически становится главным классом приложения). Чтобы загрузчик мог распознать и вызвать эту функцию, она должна быть объявлена следующим образом:

[STAThread]
static void Main(string[] Args);

где STAThread – это необязательный атрибут, задающий потоковую модель приложения. Зачем в Microsoft решили писать Main с большой буквы, современной науке неизвестно. Видимо, это сделано, чтобы неопытные программисты не путали программы, написанные на C# с их Ява-близнецами. :)

Ну да вернемся к разбору командной строки... С тех пор, как появилась командная строка и параметры приложений, их принято было разделять пробелами. Это было удобно до тех пор, пока не появились файловые системы, допускающие использование пробелов внутри имен файлов и каталогов. Возможно, из-за этого, а возможно и по другим причинам, но при программировании в Windows командную строку принято передавать в не разобранном виде, а параметры, содержащие пробел, заключать в двойные кавычки. Разбор командной строки в таких условиях стал далеко не тривиальным занятием. И что самое печальное, каждый программист изобретал собственные правила разбора командной строки и писал собственный код для этих целей.

В .Net программисты из Microsoft решили вернуться к корням (языку С) и вернули обратно передачу параметров в виде массива, взяв на себя обязанности по выделению отдельных параметров из командной строки. Причем они автоматически обрабатывают параметры, которые целиком или частично заключены в кавычки и выдают их в виде отдельных строк, выкидывая ненужные кавычки. Правда, кое-какие изменения они внесли. Так, исчез параметр argc, а параметр argv был переименован в args. Переименование лежит целиком и полностью на совести Microsoft, а вот argc исчез не случайно. Дело в том, что массивы в C# стали объектами, и у них появилось такое свойство, как размер. Надобность в argc просто отпала.

Рассмотрим поподробнее содержимое функции Main, которая занимается разбором параметров и запуском главной формы (или пакетной обработке, если задан соответствующий параметр командной строки):

[STAThread]
static void Main(string[] Args) 
{
  // Если не перехватить в этом месте исключения, то в случае непредвиденной
  // ошибки пользователь получит сообщение об ошибке от CLR. Оно слишком 
  // сложно для обычного пользователя, так что лучше обработать исключение
  // и вывести собственное сообщение об ошибке.  
  try
  {
    bool bRun = false; // TRUE если приложение запускается в пакетном режиме
    // Список имен shr-файлов, переданных программе в командной строке.
    ArrayList arysConfigs = null;
    string sLogFileName = null; // Имя Log-файла
    // Каталог, в котором будет производиться замена. Если он не задан, 
    // берется каталог из обрабатываемого shr-файла.
    string sPath = null;

    // Цикл разбора аргументов. 
    foreach(string arg in Args)
    {
      // В эту строку будет выделен ключ параметра.
      string sKey = "";
      // Проверяем параметр на наличие ключа. Допускается задавать ключ
      // с помощью символов «/» и «-».
      if(arg.Length >= 2 && (arg[0] == '/' || arg[0] == '-'))
        // Считываем ключ параметра (символ идущий за '/' или '-'.
        // Ключ определяет, как будет интерпретироваться данный параметр.
        sKey = arg[1].ToString(); // Конвертация в строку (char тоже объект)
      else
        // Если параметр не является ключом, возбуждаем исключение, которое
        // перехватывается оператором catch (см. ниже). 
        throw new Exception("Invalid argument \"" + arg 
              + "\". For more information run this program with -h key.");

      // Приложение должно понимать имена ключей, введенных в любом 
      // регистре. Поэтому перед проверками приводим строку с ключем к
      // верхнему регистру.
      // Строки тоже являются объектами, так что функции манипуляции со
      // строками являются методами. Допустима даже конструкция 
      // "некоторая строка".ToUpper().
      sKey = sKey.ToUpper();

      // Если параметр содержит кроме ключа еще и дополнительную строку 
      // с именем файла или каталога, выделяем эту строку и помещаем
      // в переменную sFile. Для этого достаточно отбросить два первых
      // символа.
      string sFile = arg.Length > 2 ? arg.Substring(2, arg.Length - 2) : "";

      // Теперь настала очередь определить тип параметра.
      // Этот код можно было бы свободно реализовать и используя для 
      // хранения ключа тип char, но со строками интереснее, все равно 
      // время разбора командной строки ничтожно мало.
      switch(sKey)
      {
        case "?": // Help
        case "H":
          // Обратите внимание, на очень интересный и нетривиальный момент!
          // По ключу "H" необходимо вывести описание других ключей.
          // Можно было бы просто встроить текст сообщения в тело программы.
          // Но, во-первых, это очень неудобно (текст довольно большой, а
          // во-вторых, это вообще не правильно, так как его будет трудно
          // править и, если возникнет задача перевода приложения на другой
          // язык, придется вносить изменения в исходный код программы.
          // Выходом может служить хранение текста в ресурсах приложения.
          // Следующий код считывает текст сообщения из ресурсов (точнее 
          // манифеста приложения).
          // Текстовый файл, содержащий сообщение, был добавлено в проект
          // и в его свойстве "Build Action" было установлено значение
          // "Embedded Resource". Открыть свойства файла можно выбрав 
          // соответствующий пункт из контекстного меню в окне 
          // «Solution Explorer».
          // Как и в случае с файлом StreamReader, позволяем читать данные 
          // в виде строки (значительно упрощая нашу жизнь).
          // Считать данные из ресурсов можно с помощью следующего кода: 
          using(StreamReader sr = new StreamReader(
            // Считывание ресурсов производится функцией
            // GetManifestResourceStream. Она вызывается у сборки (Assembly),
            // в которой расположен ресурс (в данном случае у exe-модуля).
            // Имя ресурса состоит из имени пространства имен "RegExRep"
            // и имени файла. Имя пространства имен добавляется VS.Net. Если
            // добавлять ресурсы в модуль вручную (с помощью утилит командной
            // строки, имена ресурсов можно будет задавать самостоятельно.
            Assembly.GetExecutingAssembly().GetManifestResourceStream(
                  "RegExRep.Promt.txt"), 
            // Чтобы получить корректные данные (на русском языке), нужно
            // указать кодировку. Если задать значение Encoding.Default,
            // будет браться текущие системные настройки. Но файл содержит
            // данные в кодировке 1251, а она может не совпадать с текущей
            // кодировкой. Поэтому лучше задать кодировку жестко.
            System.Text.Encoding.GetEncoding(1251))
          )
          {
            // Читаем все данные в строку (sr.ReadToEnd()) 
            // и выводим ее в окне-сообщения.
            MessageBox.Show(sr.ReadToEnd(), 
               csAppName, // Заголовок (имя приложения)
               MessageBoxButtons.OK, // Выводить кнопку «OK»...
               MessageBoxIcon.Information); // И иконку «Information»
          }
          // Как уже говорилось, в C# выражения case и default должны 
          // обязательно заканчиваться оператором выхода (break, return
          // или throw). Если нужно перейти к сведущему оператору case,
          // можно использовать конструкцию goto, goto case и goto default.
          return;
        case "L":  // Log (задает Log-файл)
          // Проверяем, не был ли задан Log-файл ранее.
          if(sLogFileName != null) // Если был, возбуждаем исключение.
            throw new Exception(
                 "You can specify only one Log-file as a parameter!");
          sLogFileName = sFile; // Запоминаем Log-файл.
          break;
        case "D": // Dir (каталог для пакетного режима)
          // Возбуждаем исключение, если каталог уже задан.
          if(sPath != null)
            throw new Exception(
                  "You can specify only one directory as a parameter!");
          sPath = sFile; // Запоминаем путь.
          break;
        case "R": // Run (включает режим пакетного обновления)
          bRun = true;
          break;
        case "S": // Задает shr-файл настройки, может встречаться более
                  // одного раза.
          // Если массив еще не создан (ключ S встретился впервые)... 
          if(arysConfigs == null)
            arysConfigs = new ArrayList(); // ...создаем его.

          arysConfigs.Add(sFile); // Добавляем shr-файл в массив.
          break;
        default: // Встретился неизвестный науке ключ...
          throw new Exception("Invalid argument '" + sKey + "'");
          // Обратите внимание, если бы не было throw, пришлось бы вставить
          // сюда оператор break или return. Иначе компилятор будет
          // сильно ругаться... И это верно! Вдруг мы решим перенести код 
          // обработчика «default» выше?! Cut&Paste – лучший способ 
          // размножить ошибку. :)
      }
    }

    // Выдаем сообщение об ошибке, если задана опция R, но не задано 
    // ни одного файла с настройками.
    if(bRun && arysConfigs == null)
      throw new Exception("If you start the program with -r option "
            + "(batch processing), you must specify at least one *.shr-file!");

    // Создаем главную форму приложения. При этом форма не показывается 
    // на экране но с ней можно взаимодействовать (созданы все элементы
    // управления, внутренние переменные и методы).
    Form1 frmMain = new Form1();
    if(bRun) // Если приложение работает в пакетном режиме...
    {
      // Перебираем файлы настроек, заданные через командную строку, 
      // и вызываем для каждого метод Run (передавая ему имя 
      // конфигурационного файла и путь.
      foreach(string sConfig in arysConfigs)
        frmMain.Run(sConfig, sPath);
      // Если задано имя Log-файла, записываем Log в него.
      if(sLogFileName != null)
        frmMain.LogSave(sLogFileName);
    }
    else
    {
      // Инициализируем форму... Если задан хоть один конфигурационный 
      // файл, загружаем первый из них.
      frmMain.Init(arysConfigs == null ? "" : (string)arysConfigs[0], null);
      // Показываем форму пользователю.
      Application.Run(frmMain); // Стандартный код, генерируемый мастером.
    }
  }
  catch(Exception ex) // обрабатываем все необработанные исключения.
  {
    MessageBox.Show(ex.Message, csAppName, MessageBoxButtons.OK, 
       MessageBoxIcon.Error);
  }
}

Заметьте, что C# в операторе switch допускает перечисление нескольких меток подряд. Создатели языка долго думали, оставлять ли такую возможность (в смысле заимствовать ли ее из C/C++). Дело в том, что «сквозной» проход управления между метками часто приводил к трудно понимаемым (а, значит и трудноуловимым) ошибкам:

case 1:
   DoFirstOperation();
case 2:
   DoSecondOperation();
   break;

Не каждый с первого раза заметит, что после DoFirstOperation пропущен break (особенно когда вокруг море другого кода), а компилятор C/C++ воспринимал такую ситуацию как вполне обыденную. С другой стороны, в C/C++ зачастую применялись конструкции типа нашей:

        case "?":
        case "H":

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

В бета-версиях языка сквозной проход меток был начисто запрещен. Вместо этого был добавлен новый оператор «goto case» (вернее радикально пересмотрен старый goto). Нашу конструкцию пришлось бы записать так:

        case "?": goto case "H";
        case "H":

Первое опасно, второе уродливо. Что же делать? Красивый выход был найден в Паскале. Там было разрешено перечислять метки через запятую. Наш вариант при этом выглядел бы примерно так (с поправкой на псевдосинтаксис C):

        case "?", "H";

Но как же быть с обратной совместимостью с C? Да и Паскаль является «заклятым врагом», которого нужно клеймить, а не цитировать. :)

Осознавая всю маразматичность ситуации, и принимая во внимание, что такое решение с «goto case» разрывает обратную совместимость между C# и C, создатели языка решили пойти на компромисс. Они разрешили перечисление оператора case, но запретили переход кода. То есть писать так:

        case "?":
        case "H":

и так:

        case "?":
          SomeCall();
          break;
        case "H":

можно, а так:

        case "?":
          SomeCall();
        case "H":

нельзя!

На первый взгляд явная несовместимость с C! Но хохма заключается в том, что квалифицированные C/C++-программисты под страхом удара железной линейкой по пальцам избегают последнего варианта, предпочитая более безопасные обходные варианты. Получается, что C# является обратно совместимым с «правильным» :с) C/C++-кодом. И, так как цель любой совместимости в переносимости кода, на мой взгляд, такой компромисс является оправданным, хотя ввести возможность перечислять метки через запятую-то можно было бы (ох уж эта ревность…).

Итак, функция main анализирует командную строку и, в зависимости от переданных параметров открывает приложение в GUI-режиме, запускает batch-обработку, выводит описание ключей командной строки или выводит сообщение об ошибке.

Заключение

Непросто в рамки статьи заключить описание создания пусть маленького, но полноценного приложения. Возможно, многое ускользнуло от моего взора, но вы должны помнить, что я старался, и, если что-то я все же пропустил, в этом виноват подлый кот-визард (из MS Word) то и дело отвлекающий меня от работы своими штучками. Ну, и конечно, сам Microsoft, создавший такую гору кода. :)


Эта статья опубликована в журнале RSDN Magazine #1. Информацию о журнале можно найти здесь
    Сообщений 17    Оценка 321        Оценить