Terminator 4

© 2003 Алексей Морозов

Парадоксальные задачи рождают парадоксальные решения... Именно таким задачам обязана своим появлением на свет утилита Terminator, которой и посвящена данная статья.

О каких же необычных задачах идет речь?

Вы уже, наверное, догадались, что типичное оформление подобной утилиты - иконка в "трее" (Tray Icon). Таким образом, главная форма программы является отличным кандидатом на диалог конфигурирования параметров и большую часть времени остается невидимой. Чтобы не допустить повторного запуска утилиты, воспользуемся именованным "взаимным исключением" (Mutex). Исходный файл проекта (Terminator.dpr) будет выглядеть следующим образом:

program Terminator;

uses
  Windows,
  Forms,
  Main in 'Main.pas' {MainForm};

{$R *.res}

var
  MutexHandle: THandle;

begin
  MutexHandle := CreateMutex(nil, True, 'Terminator'); // Создаем именованный Mutex
  if MutexHandle <> 0 then // Получен корректный дескриптор Mutex?
  begin
    if GetLastError <> ERROR_ALREADY_EXISTS then // Mutex был создан, а не открыт уже существующий?
    begin
      Application.Initialize;
      Application.title := 'Terminator';
      Application.CreateForm(TMainForm, MainForm);
      Application.ShowMainForm := False; // Запрещаем показ главной формы при запуске программы
      Application.Run;
    end;
    CloseHandle(MutexHandle); // Освобождаем дескриптор Mutex
  end
  else
    Application.MessageBox('Unable to determine presence of application instance!',
                           'Terminator', MB_OK or MB_ICONERROR);
end.

Чтобы уменьшить  нагрузку на процессор, будем выполнять всю основную работу в "нити" (Thread) низкого приоритета. С решением задачи о недопущении показа определенного окна трудностей возникнуть не должно: в теле главного цикла нити вызываем Win32 API функцию FindWindow с нужными нам параметрами (можно искать окно указанного класса, с указанным заголовком или все вышеперечисленное вместе). Если функция вернула существующий дескриптор окна, немедленно закрываем это окно, послав ему сообщение WM_CLOSE (предварительно также стоит послать окну сообщение WM_CANCELMODE, которое в случае закрытия диалогового окна вызовет действия, аналогичные нажатию на кнопку "Cancel"). Чтобы главный цикл нити не загружал процессор постоянными проверками, в конце каждой итерации вызовем Win32 API функцию Sleep на короткий промежуток времени (около 250 миллисекунд для данной задачи будет достаточно). Таким образом, отслеживаемое окно может показаться на экране на время не более четверти секунды, а затем исчезнет на глазах изумленной публики (на самом деле, описываемая утилита должна была имитировать сбой в работе системы генерации отчетов некой клиентской программы. Отчеты можно было выдавать либо в MS Excel, либо печатать через форму просмотра отчетов. Разработчики использовали QuickReport и стандартную форму предварительного просмотра, поэтому слежение за окнами с классом "TQRStandardPreview" позволило создать натуральную иллюзию неработоспособности системы печати отчетов, вынуждая пользователя экспортировать данные в MS Excel, а дальше... Но это "дальше" уже выходит за тему, обсуждаемую в данной статье).

Внимательный читатель мог уже догадаться, что именно MS Excel нужно было поддерживать в состоянии постоянной загрузки (только так можно заставить работать подключенные Add-in'ы, так как при запуске MS Excel через OLE-автоматизацию загружается лишь минимальное ядро без каких-либо дополнительно установленных элементов. Господа разработчики, использующие приложения MS Office через OLE-автоматизацию - не забывайте об этой неприятной особенности!)

Далее, задачу по поддержанию в постоянной готовности какой-либо программы можно решить следующим образом: произвести поиск среди исполняющихся в настоящий момент процессов, и если программа в памяти не обнаружена, запустить ее в минимизированном состоянии. В теле главного цикла нити можно использовать Win32 API функцию WaitForSingleObject с дескриптором запущенного процесса в качестве параметра. При успешном завершении функции (код возврата равен константе WAIT_OBJECT_0) перезапускаем нужный нам процесс. Функция WaitForSingleObject ожидает в качестве второго параметра промежуток времени в миллисекундах, после которого она завершиться по тайм-ауту. Самое время избавиться от функции Sleep, а ее параметр использовать в WaitForSingleObject. Запустить же новый процесс совсем несложно, используя Win32 API функцию CreateProcess:

function ExecProcess(const ModuleName: string): THandle;
var
  StartupInfo: TStartupInfo;
  ProcessInfo: TProcessInformation;
begin
  FillChar(StartupInfo, SizeOf(StartupInfo), 0); // Заполняем структуру нулями
  with StartupInfo do
  begin
    cb := SizeOf(StartupInfo); // Поле длины структуры обязательно к заполнению
    dwFlags := STARTF_USESHOWWINDOW; // Используем лишь одно поле wShowWindow
    wShowWindow := SW_SHOWMINNOACTIVE; // Главное окно процесса появится минимизированным и неактивным
  end;
  if CreateProcess(PChar(ModuleName), nil, nil, nil, False, NORMAL_PRIORITY_CLASS,
                                      nil, nil, StartupInfo, ProcessInfo) then
  begin
    CloseHandle(ProcessInfo.hThread); // Освобождаем дескриптор главной нити
    Result := ProcessInfo.hProcess; // Возвращаем дескриптор процесса
                                    //(не забудьте затем освободить этот дескриптор)
  end
  else
    Result := 0; // В случае ошибки возвращаем недействительное значение дескриптора процесса
end;

Подготавливаем структуру типа TStartupInfo таким образом, чтобы новый процесс запускался минимизированным и не получал фокус ввода после старта. Функция CreateProcess заполняет структуру типа TProcessInformation, содержащую дескрипторы как самого процесса, так и его главной нити. Дескриптор главной нити нам не интересен, поэтому освобождаем его посредством вызова Win32 API функции CloseHandle. А вот дескриптор процесса позволит нам организовать наблюдение за завершением этого процесса с помощью упомянутой ранее функции WaitForSingleObject. Замечу также, что дескриптор процесса, возвращенный функцией CreateProcess, имеет разрешение доступа PROCESS_ALL_ACCESS, включающее в себя SYNCHRONIZE, необходимое для отслеживания завершения процесса.

Но как найти процесс, если он уже исполняется на момент запуска нити мониторинга (чтобы не запускать несколько копий приложения)? Давайте используем функции Win32 из раздела SDK Process Status Helper (PSAPI). Сразу замечу, что функции PSAPI реализованы в Windows NT/2000/XP/.Net, так что счастливым обладателям Windows 95/98/ME придется использовать функции SDK Tool Help. Почему именно PSAPI, ведь Tool Help тоже поддерживается на платформах NT/2000/XP/.Net? Уверен, что большинство из читателей не знакомы с этими функциями, так почему бы не начать их применять?!

uses PsApi;

function FindProcess(const ModuleName: string): THandle;
const
  MaxProcesses = 1024;
var
  Processes: array[0 .. MaxProcesses - 1] of DWORD; // Массив идентификаторов запущенных процессов
  ModuleHandle: HMODULE; // Буфер для дескриптора главного модуля процесса
  cbNeeded: DWORD;
  ProcessName: array[0 .. MAX_PATH - 1] of Char; // Буфер для получения имени главного модуля процесса
  I: Integer;
begin
  // Заполняем массив идентификаторов запущенных процессов
  if EnumProcesses(@Processes, SizeOf(Processes), cbNeeded) then
    for I := 0 to cbNeeded div SizeOf(DWORD) - 1 do // Организуем цикл по каждому элементу массива
    begin
      // Получаем дескриптор процесса по его идентификатору
      Result := OpenProcess(PROCESS_QUERY_INFORMATION or PROCESS_VM_READ or SYNCHRONIZE, False, Processes[I]);
      if Result <> 0 then // Попытка увенчалась успехом?
      begin
        // Получаем дескриптор первого (главного) модуля процесса
        if EnumProcessModules(Result, @ModuleHandle, SizeOf(ModuleHandle), cbNeeded) then
        begin
          // Извлекаем имя файла главного модуля процесса
          GetModuleFileNameEx(Result, ModuleHandle, ProcessName, SizeOf(ProcessName));
          if lstrcmpi(ProcessName, PChar(ModuleName)) = 0 then
            Exit; // При положительном результате сравнения возвращаем полученный дескриптор процесса
                  //(не забудьте затем освободить его)
        end;
        CloseHandle(Result); // Освобождаем дескриптор процесса
      end;
    end;
  Result := 0; // Искомый процесс не найден - возвращаем недействительное значение дескриптора процесса
end;

С помощью PSAPI функции EnumProcesses мы получаем массив идентификаторов всех процессов, запущенных в текущий момент времени (не путайте, пожалуйста, идентификатор процесса с дескриптором процесса). Единственное замечание, что массив, переданный в качестве параметра функции, должен быть достаточно большим, так как нет способа узнать заранее необходимое количество элементов (в нашем случае он может вмещать до 1024 идентификаторов). Пройдемся в цикле по возвращенным идентификаторам процессов. С помощью Win32 API функции OpenProcess попытаемся получить дескриптор процесса из его идентификатора. Почему именно попытаемся? Среди списка всех процессов есть системные процессы, запущенные с помощью системных учетных записей (SYSTEM, LOCAL SERVICE, NETWORK SERVICE etc.) Попытка их открытия с разрешением доступа "лучшим" чем PROCESS_QUERY_INFORMATION приведет к ошибке отказа в доступе (Win32 error 5: Access denied). Но, к счастью, эти процессы для нашей задачи интереса не представляют (управлять ими все равно нет никакой нетривиальной возможности). Нам необходимо получить дескриптор процесса с разрешениями доступа, включающими PROCESS_QUERY_INFORMATION и PROCESS_VM_READ для извлечения списка модулей с помощью PSAPI функции EnumProcessModules. Каждый процесс может иметь несколько ассоциированных с ним модулей, но первым из них всегда является исполняемый файл процесса, поэтому мы получаем только первый модуль из списка (другими модулями могут быть различные DLL библиотеки и т.п.) Из дескриптора модуля несложно получить полное имя исполняемого файла модуля. Win32 API функция GetModuleFileNameEx нам в этом поможет. Осталось сравнить полученное имя файла с заранее заданным (вместо Win32 API функции lstrcmpi можно использовать функцию SameText или CompareText из библиотеки SysUtils). Поскольку мы ищем дескриптор процесса для последующего наблюдения за его завершением, то необходимо включить разрешение доступа SYNCHRONIZE в параметры вызова функции OpenProcess. Если результат сравнения положительный, то возвращаем полученный дескриптор процесса, иначе освобождаем связанные с ним ресурсы с помощью вызова функции CloseHandle и переходим к очередному идентификатору процесса. Если же искомый процесс так и не будет найден среди списка запущенных процессов, наша функция поиска вернет значение 0, являющееся недопустимым в качестве дескриптора процесса.

Посмотрим теперь на код нити мониторинга целиком:

type
  TMonitorThread = class(TThread)
  private
    FWindowtitle,
    FWindowClass,
    FAppName: string;
    FTerminateWindow,
    FRespawnApp: Boolean;
    FWaitTimeout: Cardinal;
    procedure SetTerminateWindow(Value: Boolean);
    procedure SetRespawnApp(Value: Boolean);
  protected
    procedure Execute; override;
  public
    constructor Create;
    // Заголовок окна, которое должно быть закрыто
    property Windowtitle: string read FWindowtitle write FWindowtitle;
    // Имя класса окна
    property WindowClass: string read FWindowClass write FWindowClass;
    // Путь к программе, которую необходимо перезапускать
    property AppName: string read FAppName write FAppName;
    // Закрывать ли указанное окно?
    property TerminateWindow: Boolean read FTerminateWindow write SetTerminateWindow default False;
    // Перезапускать ли указанную программу?
    property RespawnApp: Boolean read FRespawnApp write SetRespawnApp default False;
    // Время ожидания (в миллисекундах) в теле цикла нити
    property WaitTimeout: Cardinal read FWaitTimeout write FWaitTimeout default 250;
  end;

constructor TMonitorThread.Create;
begin
  inherited Create(True); // Создаем новую нить приостановленной (Suspended)
  Priority := tpIdle; // Исполнять нить только при простое ОС
  FreeOnTerminate := True; // Автоматически освобождать память после завершения нити
  FWindowtitle := '';
  FWindowClass := '';
  FAppName := '';
  FTerminateWindow := False;
  FRespawnApp := False;
  FWaitTimeout := 250;
end;

procedure TMonitorThread.Execute;
var
  ClsName,
  WndName: PChar;
  Process: THandle;
  Wnd: HWND;
  WaitResult: DWORD;
begin
  if FTerminateWindow then // Подготавливаем параметры для принудительного закрытия окна
  begin
    if FWindowClass = '' then
      ClsName := nil
    else
      ClsName := PChar(FWindowClass);
    if FWindowtitle = '' then
      WndName := nil
    else
      WndName := PChar(FWindowtitle);
  end;
  if FRespawnApp then // Находим в памяти исполняющийся процесс или запускаем новый
  begin
    Process := FindProcess(FAppName);
    if Process = 0 then
      Process := ExecProcess(FAppName);
  end;
  while not Terminated do
  begin
    if FTerminateWindow then
    begin
      Wnd := FindWindow(ClsName, WndName);
      if IsWindow(Wnd) then // Если окно найдено, закрываем его
      begin
        SendMessage(Wnd, WM_CANCELMODE, 0, 0);
        SendMessage(Wnd, WM_CLOSE, 0, 0);
      end;
    end;
    if FRespawnApp then
    begin
      WaitResult := WaitForSingleObject(Process, FWaitTimeout);
      if WaitResult = WAIT_OBJECT_0 then // Если обнаружено завершение процесса, перезапускаем его
      begin
        CloseHandle(Process); // Освобождаем дескриптор уже завершенного процесса
        Process := FindProcess(FAppName);
        if Process = 0 then
          Process := ExecProcess(FAppName);
      end;
    end
    else
      Sleep(FWaitTimeout); // Пауза для случая, когда не требуется перезапускать процесс
  end;
  if FRespawnApp then
    CloseHandle(Process); // Освобождаем дескриптор процесса
end;

procedure TMonitorThread.SetRespawnApp(Value: Boolean);
begin
  FRespawnApp := Value and (FAppName <> '');
end;

procedure TMonitorThread.SetTerminateWindow(Value: Boolean);
begin
  FTerminateWindow := Value and ((FWindowtitle <> '') or (FWindowClass <> ''));
end;

Как вы можете видеть, класс нити содержит ряд управляющих свойств (Windowtitle, WindowClass, AppName, TerminateWindow, RespawnApp, WaitTimeout), которые определяют поведение тела главного цикла нити (метода Execute). Значения по умолчанию для скалярных свойств класса, который не будет сохранен в ресурсах программы (т.е. не является наследником класса TComponent), не требуются (игнорируются компилятором), но помогают автору не забыть, что же он хотел им присвоить при создании класса. Загружая значения управляющих свойств из реестра (Registry), мы получаем возможность гибкого контроля над нитью. Замечу, что для уменьшения количества проверок параметров в теле главного цикла нити, сделано невозможным включение режима перезапуска приложения без указания его имени, а также активация режима уничтожения окна, если не заданы заголовок или имя класса окна (см. реализацию методов SetRespawnApp и SetTerminateWindow). Win32 API функция FindWindow позволяет в качестве одного из параметров (заголовок окна или имя класса окна) передать пустой указатель (nil), поэтому нельзя просто привести строковые переменные к типу PChar (PChar('') <> nil), а приходится копировать значения строк в переменные типа PChar. После завершения главного цикла нити в случае, если мы использовали дескриптор процесса для слежения за его завершением, освобождаем этот дескриптор вызовом функции CloseHandle.

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

MainForm picture

Обратите внимание на размещение компонента CheckBox поверх рамки GroupBox. Обработчик события OnClick CheckBox'а выглядит следующим образом:

procedure TMainForm.TerminateCheckBoxClick(Sender: TObject);
var
  I: Integer;
begin
  with Sender as TCheckBox do
  begin
    for I := 0 to Parent.ControlCount - 1 do
      if (Parent.Controls[I] <> Sender) and (Parent.Controls[I] is TWinControl) then
        Parent.Controls[I].Enabled := Checked;
  end;
  OKButton.Enabled := not ((TerminateCheckBox.Checked and
                          ((titleComboBox.Text = '') and (ClassComboBox.Text = '')))
                          or (RespawnCheckBox.Checked and (AppNameEdit.Text = '')));
end;

Обработчик общий как для CheckBox "Terminate window", так и "Respawn application", поэтому используем в теле обработчика параметр Sender. Перебираем в цикле все дочерние элементы контейнера GroupBox (который доступен через свойство Parent). Если очередной элемент является наследником TWinControl, то активируем или деактивируем его (свойство Enabled) согласно состоянию CheckBox'а (не забудьте про проверку на совпадение с самим CheckBox'ом, а то второй раз вы его включить не сможете). Хорошо бы не позволить пользователю ввести неполный набор параметров, что и делает (запрещая кнопку диалога "OK") последняя строка тела обработчика (на первый взгляд - полный бред, но автор программы, будучи человеком непьющим, смеет утверждать, что при внимательном рассмотрении все становится понятно). Обработчик события OnChange компонентов выбора заголовка окна, имени класса окна и имени приложения для перезапуска также запрещает кнопку "OK" при ошибочном вводе пользователем параметров. Нажатие на кнопку "OK" приводит к сохранению введенных параметров в реестре (Registry) и скрывает форму, а кнопка "Cancel" только скрывает форму.

Вернемся к проблеме экономного расходования ресурсов компьютера. Вы не замечали (с ужасом глядя на данные из Task Manager), что любая даже самая простая программа (и не только написанная лично вами) занимает в бесценной оперативной памяти неприлично много места. Составленное на чистом Win32 API приложение, которое только и делает, что выводит на экран главное окно, занимает около 15 KB на диске и более 1 MB в памяти. Это же форменное БЕЗОБРАЗИЕ! Решить эту проблему не так сложно, как многие из вас могут полагать. Существует Win32 API функция SetProcessWorkingSetSize, которая позволяет регулировать минимальный и максимальный объемы памяти, которую менеджер виртуальной памяти Windows будет выделять для процесса. Если оба эти параметры равны -1 ($FFFFFFFF), вся распределенная виртуальная память процесса освобождается (PSAPI содержит функцию EmptyWorkingSet, выполняющую идентичные действия). Это совершенно не препятствует выполнению процесса, так как вся действительно необходимая память будет тут же выделена вновь, хотя данный метод и оказывает негативное влияние на производительность процесса (не применяйте его для процессов с интенсивными вычислениями). Win32 API функция GetCurrentProcess возвращает псевдо-дескриптор исполняемого процесса, поэтому не надо заботиться об освобождении этого дескриптора - он суть всего лишь специальная константа (т.е. вызов функции CloseHandle не требуется).

procedure TMainForm.AdjustWorkingSetSize;
begin
  SetProcessWorkingSetSize(GetCurrentProcess, DWORD(-1), DWORD(-1));
end;

Вызовем этот метод в обработчике события OnCreate главной формы. Уже лучше, но почему после показа главной формы объем занимаемой памяти подрастает? Добавим вызов этого метода также в обработчик события OnHide главной формы, где заодно настроим контекстное меню программы на случай изменения ее параметров.

procedure TMainForm.FormHide(Sender: TObject);
begin
  StartItem.Enabled := (FTerminateWindow and ((FWindowtitle <> '') or (FWindowClass <> '')))
  		        or (FRespawnApp and (FAppName <> ''));
  AdjustWorkingSetSize;
end;

Вот теперь программа занимает минимум оперативной памяти (для приложения, написанного с использованием VCL), потребляет минимум процессорного времени, и все еще продолжает работать.

Примечание. Майкросовтовский линковщик для C++ имеет параметр (/WS:aggressive), при котором образ исполняемого файла помечается специальным флагом IMAGE_FILE_AGGRESIVE_WS_TRIM. Во время простоя ОС (Idle) ядро должно автоматически вызывать функцию SetProcessWorkingSetSize для данного процесса. Мною были проведены эксперименты, при которых вышеупомянутый флаг заносился в образ обычного исполняемого файла. По крайней мере, на Windows XP SP1 никакой разницы в распределении памяти обнаружено не было, но эта ОС, как бы ее не ругали, имеет большое количество внутренних оптимизаций...

Пришло время разобраться с самой иконкой в трее. Выглядит она так:

TrayIcon picture

Контекстное меню позволяет показать главную форму для настройки параметров, запустить или остановить рабочую нить и завершить исполнение программы. Во время работы нити пункт "Setup" не активен, пункт "Start!" меняется на "Stop", а иконка Арни зажигает глаза недобрым красным светом. Продолжим экономить ресурсы? Если одну иконку (на которой Арни еще добрый) назначить как иконку приложения (Delphi меню Project|Options|Application|Icon), а вторую вы можете лицезреть на главной форме (в качестве Image), то именно их можно использовать для отображения в трее.

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

procedure TMainForm.FormCreate(Sender: TObject);
begin
  ...
  LoadParams; // Загружаем параметры из реестра
  if UpperCase(ParamStr(1)) = '/A' then
  begin
    if StartItem.Enabled then
      Start;
  end;
  AdjustWorkingSetSize;
end;

Метод Start запускает нить мониторинга с необходимыми параметрами, а также меняет иконку в трее (на злобного Арни) и модифицирует контекстное меню. В противоположность ему, метод Stop завершает выполнение нити и возвращает в первоначальный вид иконку и контекстное меню (см. исходный код).

И последнее, на чем хотелось бы заострить ваше внимание - это код метода OnShow главной формы. Если вы заметили, на главной форме поля ввода "Window title to terminate" и "Window class to terminate" реализованы через компоненты ComboBox. Сделано это не случайно, а для большего удобства задания параметров. Первый ComboBox являет собой список заголовков всех видимых в данный момент окон верхнего уровня (т.е. дочерних к рабочему столу (Desktop)), а второй заполнен именами классов этих окон. Таким образом, чтобы настроить программу на подавление определенного окна, надо вывести это окно на экран, а затем выбрать из списка его заголовок и (или) имя класса окна (можно попытаться подобрать класс окна эмпирическим путем, так как его имя не всегда очевидно). Заполнить эти столь полезные в данном случае списки совсем несложно:

function EnumWindowsProc(hWnd: HWND; lParam: LPARAM): BOOL; stdcall;
var
  Str: array[0 .. 255] of Char;
begin
  if IsWindowVisible(hWnd) then // Окно видимое?
  begin
    GetWindowText(hWnd, Str, SizeOf(Str)); // Получаем заголовок окна
    if Str[0] <> #0 then
      // Если заголовок непустой, добавляем его в соответствующий список
      TMainForm(lParam).titleComboBox.Items.Add(Str);
    GetClassName(hWnd, Str, SizeOf(Str)); // Получаем имя класса окна
    if Str[0] <> #0 then
      // Если имя класса непустое, добавляем его в список имен классов
      TMainForm(lParam).ClassComboBox.Items.Add(Str);
  end;
  Result := True; // Продолжаем процесс перебора до конца списка окон
end;

procedure TMainForm.FormShow(Sender: TObject);
begin
  ...
  titleComboBox.Items.Clear; // Очищаем список заголовков окон
  ClassComboBox.Items.Clear; // Очищаем список имен классов окон
  EnumWindows(@EnumWindowsProc, LPARAM(Self)); // Вызываем перебор всех окон верхнего уровня,
                                               // передав ссылку на главную форму в качестве параметра
  ...
end;

Win32 API функция EnumWindows вызывает переданную ей функцию с очередным дескриптором окна верхнего уровня. Так как функция обратного вызова (Callback) будет вызываться не программой, а кодом Windows, она должна быть объявлена как имеющая соглашение вызова stdcall, что влияет на передачу параметров через стек и очистку стека при возврате (Delphi стандартно использует соглашение fastcall). Последний параметр функции EnumWindows передается неизмененным в нашу функцию обратного вызова - воспользуемся этим для ссылки на члены класса главной формы. Сам код функции обратного вызова, надеюсь, не требует комментариев, замечу лишь, что если вернуть значения False в качестве результата, функция EnumWindows прекратит дальнейшие итерации по списку окон (это может быть полезно при поиске конкретного окна, чтобы не перебирать уже ненужные).

Изучение исходных кодов программы Terminator.zip (8.5K) позволит вам самим разобраться в том, что осталось за рамками повествования данной статьи (код написан под Delphi 7, поэтому, возможно, потребуются некоторые изменения под другие версии компилятора). Искренне надеюсь, что кому-то эти материалы окажутся полезными. При возникновении интересных вопросов и дополнений - пишите.

Copyright© 2003 Морозов Алексей Викторович (fox@europaplus.ru)
30 лет, MCSE, MCSD, Brainbench Delphi Professional, Brainbench C Professional

Специально для DelphiPlus

2011123456789101112
2010123456789101112
2009123456789101112
2008123456789101112
2007123456789101112
2006123456789101112
2005123456789101112
2004123456789101112
2003123456789101112
2002123456789101112
2001123456789101112
2000123456789101112
1999123456789101112

Последние статьи
Литература