Сообщений 35    Оценка 125 [+1/-0]         Оценить  
Система Orphus

Программирование на языке Delphi

Глава 3. Объектно-ориентированное программирование (ООП)

Авторы: А.Н. Вальвачев
К.А. Сурков
Д.А. Сурков
Ю.М. Четырько
Опубликовано: 19.11.2005
Исправлено: 10.12.2016
Версия текста: 1.0

3.1. Краеугольные камни ООП
3.1.1. Формула объекта
3.1.2. Природа объекта
3.1.3. Объекты и компоненты
3.1.4. Классы объектов
3.1.5. Три кита ООП
3.2. Классы
3.3. Объекты
3.4. Конструкторы и деструкторы
3.5. Методы
3.6. Свойства
3.6.1. Понятие свойства
3.6.2. Методы получения и установки значений свойств
3.6.3. Свойства-массивы
3.6.4. Свойство-массив как основное свойство объекта
3.6.5. Методы, обслуживающие несколько свойств
3.7. Наследование
3.7.1. Понятие наследования
3.7.2. Прародитель всех классов
3.7.3. Перекрытие атрибутов в наследниках
3.7.4. Совместимость объектов различных классов
3.7.5. Контроль и преобразование типов
3.8. Виртуальные методы
3.8.1. Понятие виртуального метода
3.8.2. Механизм вызова виртуальных методов
3.8.3. Абстрактные виртуальные методы
3.8.4. Динамические методы
3.8.5. Методы обработки сообщений
3.9. Классы в программных модулях
3.10. Разграничение доступа к атрибутам объектов
3.11. Указатели на методы объектов
3.12. Метаклассы
3.12.1. Ссылки на классы
3.12.2. Методы классов
3.12.3. Виртуальные конструкторы
3.13. Классы общего назначения
3.13.1. Классы для представления списка строк
3.13.2. Классы для представления потока данных
3.14. Итоги

Объекты — это крупнейшее достижение в современной технологии программирования. Они позволили строить программу не из чудовищных по сложности процедур и функций, а из кирпичиков-объектов, заранее наделенных нужными свойствами. Самое приятное в объктах то, что их внутренняя сложность скрыта от программиста, который просто пользуется готовым строительным материалом.

Сейчас преимущества использования объектов очевидны для всех. Однако так было не всегда. Сначала старая гвардия не поняла и не приняла объекты, поэтому они почти 20 лет потихоньку развивались в различных языках, первым из которых была Simula 67. Постепенно объектно-ориентированный подход нашел себе место и в более мощных языках, таких как C++, Delphi и множестве других языков. Блестящим примером реализации объектов была библиотека Turbo Vision, предназначенная для построения пользовательского интерфейса программ в операционной системе MS-DOS.

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

3.1. Краеугольные камни ООП

3.1.1. Формула объекта

Авторы надеются, что читатель помнит кое-что из главы 2 и такие понятия как тип данных, процедура, функция, запись для него не в новинку. Это прекрасно. Так вот, в конце 60-х годов кому-то пришло в голову объединить эти понятия, и то, что получилось, назвать объектом. Рассмотрение данных в неразрывной связи с методами их обработки позволило вывести формулу объекта:

Объект = Данные + Операции

На основании этой формулы была разработана методология объектно-ориентированного программирования (ООП).

3.1.2. Природа объекта

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

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

Например, объект "кнопка" имеет свойство "цвет". Значение цвета кнопка запоминает в одном из своих полей. При изменении значения свойства "цвет" вызывается метод, который перерисовывает кнопку.

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

3.1.3. Объекты и компоненты

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

Компоненты в среде Delphi — это особые объекты, которые являются строительными кирпичиками визуальной среды разработки и приспособлены к визуальной установке свойств. Чтобы превратить объект в компонент, первый разрабатывается по определенным правилам, а затем помещается в палитру компонентов. Конструируя приложение, вы берете компоненты из Палитры Компонентов, располагаете на форме и устанавливаете их свойства в окне Инспектора Объектов. Внешне все выглядит просто, но чтобы достичь такой простоты, потребовалось создать механизмы, обеспечивающие функционирование объектов-компонентов уже на этапе проектирования приложения! Все это было придумано и блестяще реализовано в среде Delphi. Таким образом, компонентный подход значительно упростил создание приложений с графическим пользовательским интерфейсом и дал толчок развитию новой индустрии компонентов.

В данной главе мы рассмотрим лишь вопросы создания и использования объектов. Чуть позже мы научим вас превращать объекты в компоненты (см. главу 13).

3.1.4. Классы объектов

Каждый объект всегда принадлежит некоторому классу объектов. Классобъектов — это обобщенное (абстрактное) описание множества однотипных объектов. Объекты являются конкретными представителями своего класса, их принято называть экземплярами класса. Например, класс СОБАКИ — понятие абстрактное, а экземпляр этого класса МОЙ ПЕС БОБИК — понятие конкретное.

3.1.5. Три кита ООП

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

Объединение данных и операций в одну сущность — объект — тесно связано с понятием инкапсуляции, которое означает сокрытие внутреннего устройства. Инкапсуляция делает объекты похожими на маленькие программные модули, в которых скрыты внутренние данные и у которых имеется интерфейс использования в виде подпрограмм. Переход от понятий «структура данных» и «алгоритм» к понятию «объект» значительно повысил ясность и надежность программ.

Второй кит ООП — наследование. Этот простой принцип означает, что если вы хотите создать новый класс объектов, который расширяет возможности уже существующего класса, то нет необходимости в переписывании заново всех полей, методов и свойств. Вы объявляете, что новый класс является потомком (или дочерним классом) имеющегося класса объектов, называемого предком (или родительским классом), и добавляете к нему новые поля, методы и свойства. Процесс порождения новых классов на основе других классов называется наследованием. Новые классы объектов имеют как унаследованные признаки, так и, возможно, новые. Например, класс СОБАКИ унаследовал многие свойства своих предков — ВОЛКОВ.

Третий кит — это полиморфизм. Он означает, что в производных классах вы можете изменять работу уже существующих в базовом классе методов. При этом весь программный код, управляющий объектами родительского класса, пригоден для управления объектами дочернего класса без всякой модификации. Например, вы можете породить новый класс кнопок с рельефной надписью, переопределив метод рисования кнопки. Новую кнопку можно "подсунуть" вместо стандартной в какую-нибудь подпрограмму, вызывающую рисование кнопки. При этом подпрограмма "думает", что работает со стандартной кнопкой, но на самом деле кнопка принадлежит производному классу кнопок и отображается в новом стиле. Пока достаточно самого поверхностного понимания всех приведенных выше понятий, ниже мы рассмотрим их подробнее и покажем, как они реализованы в среде Delphi.

3.2. Классы

Для поддержки ООП в язык Delphi введены объектные типы данных, с помощью которых одновременно описываются данные и операции над ними. Объектные типы данных называют классами, а их экземпляры — объектами.

Классы объектов определяются в секции type глобального блока. Описание класса начинается с ключевого слова class и заканчивается ключевым словом end. По форме объявления классы похожи на обычные записи, но помимо полей данных могут содержать объявления пользовательских процедур и функций. Такие процедуры и функции обобщенно называют методами, они предназначены для выполнения над объектами различных операций. Приведем пример объявления класса, который предназначен для чтения текстового файла в формате "delimited text" (файл в таком формате представляет собой последовательность строк; каждая строка состоит из значений, которые отделены друг от друга символом-разделителем):

      type
  TDelimitedReader = class// Поля
    FileVar: TextFile;
    Items: arrayofstring;
    Delimiter: Char;
    // Методыprocedure PutItem(Index: Integer; const Item: string);
    procedure SetActive(const AActive: Boolean);
    function ParseLine(const Line: string): Integer;
    function NextLine: Boolean;
    function GetEndOfFile: Boolean;
  end;

Класс содержит поля (FileVar, Items, Delimiter) и методы (PutItem, SetActive, ParseLine, NextLine, GetEndOfFile). Заголовки методов, (всегда) следующие за списком полей, играют роль упреждающих (forward) описаний. Программный код методов пишется отдельно от определения класса и будет приведен позже.

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

Класс содержит несколько полей:

Класс также содержит ряд методов (процедур и функций):

Обратите внимание, что приведенное выше описание является ничем иным, как декларацией интерфейса для работы с объектами класса TDelimitedReader. Реализация методов PutItem, SetActive, ParseLine, NextLine и GetEndOfFile на данный момент отсутствует, однако для создания и использования экземпляров класса она пока и не нужна.

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

3.3. Объекты

Чтобы от описания класса перейти к объекту, следует выполнить соответствующее объявление в секции var:

      var
  Reader: TDelimitedReader;

При работе с обычными типами данных этого объявления было бы достаточно для получения экземпляра типа. Однако объекты в среде Delphi являются динамическими данными, т.е. распределяются в динамической памяти. Поэтому переменная Reader — это просто ссылка на экземпляр (объект в памяти), которого физически еще не существует. Чтобы сконструировать объект (выделить память для экземпляра) класса TDelimitedReader и связать с ним переменную Reader, нужно в тексте программы поместить следующий оператор:

Reader := TDelimitedReader.Create;

Create — это так называемый конструктор объекта; он всегда присутствует в классе и служит для создания и инициализации экземпляров. При создании объекта в памяти выделяется место только для его полей. Методы, как и обычные процедуры и функции, помещаются в область кода программы; они умеют работать с любыми экземплярами своего класса и не дублируются в памяти.

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

Reader.NextLine;

Кроме того, как и при работе с записями, допустимо использование оператора with, например:

      with Reader do
  NextLine;

Если объект становится ненужным, он должен быть удален вызовом специального метода Destroy, например:

Reader.Destroy; // Освобождение памяти, занимаемой объектом

Destroy — это так называемый деструктор объекта; он присутствует в классе наряду с конструктором и служит для удаления объекта из динамической памяти. После вызова деструктора переменная Reader становится несвязанной и не должна использоваться для доступа к полям и методам уже несуществующего объекта. Чтобы отличать в программе связанные объектные переменные от несвязанных, последние следует инициализировать значением nil. Например, в следующем фрагменте обращение к деструктору Destroy выполняется только в том случае, если объект реально существует:

Reader := nil;
...
if Reader <> nilthen Reader.Destroy;

Вызов деструктора для несуществующих объектов недопустим и при выполнении программы приведет к ошибке. Чтобы избавить программистов от лишних ошибок, в объекты ввели предопределенный метод Free, который следует вызывать вместо деструктора. Метод Free сам вызывает деструктор Destroy, но только в том случае, если значение объектной переменной не равно nil. Поэтому последнюю строчку в приведенном выше примере можно переписать следующим образом.

Reader.Free;

После уничтожения объекта переменная Reader сохраняет свое значение, продолжая ссылаться на место в памяти, где объекта уже нет. Если эту переменную предполагается еще использовать, то желательно присвоить ей значение nil, чтобы программа могла проверить, существует объект или нет. Таким образом, наиболее правильная последовательность действий при уничтожении объекта должна быть следующая:

Reader.Free;
Reader := nil;

С помощью стандартной процедуры FreeAndNil это можно сделать проще и элегантнее:

FreeAndNil(Reader); // Эквивалентно: Reader.Free; Reader := nil;

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

      var
  R1, R2: TDelimitedReader; // Переменные R1 и R2 не связаны с объектомbegin
  R1 := TDelimitedReader.Create; // Связывание переменной R1 с новым объектом// Переменная R2 пока еще не связана ни с каким объектом
  R2 := R1; // Связывание переменной R2 с тем же объектом, что и R1 // Теперь обе переменные связаны с одним объектом
  R2.Free;  // Уничтожение объекта// Теперь R1 и R2 не связаны ни с каким объектом end;

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

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

      type
  TReadersList = class;  // упреждающее объявление класса TReadersList

  TDelimitedReader = class
    Owner: TReadersList;
    ...
  end;

  TReadersList = class
    Readers: arrayof TDelimitedReader;
    ...
  end;

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

Итак, вы уже имеете некоторое представление об объектах, перейдем теперь к вопросу реализации их методов.

3.4. Конструкторы и деструкторы

Особой разновидностью методов являются конструкторы и деструкторы. Напомним, что конструкторы создают, а деструкторы разрушают объекты. Создание объекта включает выделение памяти под экземпляр и инициализацию его полей, а разрушение — очистку полей и освобождение памяти. Действия по инициализации и очистке полей специфичны для каждого конкретного класса объектов. По этой причине язык Delphi позволяет переопределить стандартный конструктор Create и стандартный деструктор Destroy для выполнения любых полезных действий. Можно даже определить несколько конструкторов и деструкторов (имена им назначает сам программист), чтобы обеспечить различные процедуры создания и разрушения объектов.

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

      type
  TDelimitedReader = class
    ...
    // Конструкторы и деструкторыconstructor Create(const FileName: string; const ADelimiter: Char = ';');
    destructor Destroy; override;
    ...
  end;

Приведем их возможную реализацию:

      constructor TDelimitedReader.Create(const FileName: string;
  const ADelimiter: Char = ';');
begin
  AssignFile(FileVar, FileName);
  Delimiter := ADelimiter;
end;

destructor TDelimitedReader.Destroy;
begin// Пока ничего не делаемend;

Если объект содержит встроенные объекты или другие динамические данные, то конструктор — это как раз то место, где их нужно создавать.

Конструктор применяется к классу или к объекту. Если он применяется к классу,

Reader := TDelimitedReader.Create('MyData.del', ';');

то выполняется следующая последовательность действий:

Если конструктор применяется к объекту,

Reader.Create('MyData.del', ';');

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

Деструктор уничтожает объект, к которому применяется:

Reader.Destroy;

В результате:

В теле деструктора обычно должны уничтожаться встроенные объекты и динамические данные, как правило, созданные конструктором.

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

3.5. Методы

Процедуры и функции, предназначенные для выполнения над объектами действий, называются методами. Предварительное объявление методов выполняется при описании класса в секции interface модуля, а их программный код записывается в секции implementation. Однако в отличие от обычных процедур и функций заголовки методов должны иметь уточненные имена, т.е. содержать наименование класса. Приведем возможную реализацию одного из методов в классе TDelimitedReader:

      procedure TDelimitedReader.SetActive(const AActive: Boolean);
beginif AActive then
    Reset(FileVar)        // Открытие файлаelse
    CloseFile(FileVar);   // Закрытие файлаend;

Обратите внимание, что внутри методов обращения к полям и другим методам выполняются как к обычным переменным и подпрограммам без уточнения экземпляра объекта. Такое упрощение достигается путем использования в пределах метода псевдопеременной Self (стандартный идентификатор). Физически Self представляет собой дополнительный неявный параметр, передаваемый в метод при вызове. Этот параметр и указывает экземпляр объекта, к которому данный метод применяется. Чтобы пояснить сказанное, перепишем метод SetActive, представив его в виде обычной процедуры:

      procedure TDelimitedReader_SetActive(Self: TDelimitedReader;
  const AActive: Boolean);
beginif AActive then
    Reset(Self.FileVar)          // Открытие файлаelse
    CloseFile(Self.FileVar);     // Закрытие файлаend;

Согласитесь, что метод SetActive выглядит лаконичнее процедуры TDelimitedReader_SetActive.

Практика показывает, что псевдопеременная Self редко используется в явном виде. Ее необходимо применять только тогда, когда при написании метода может возникнуть какая-либо двусмысленность для компилятора, например при использовании одинаковых имен и для локальных переменных, и для полей объекта.

Если выполнить метод SetActive,

Reader.SetActive(True);

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

TDelimitedReader_SetActive(Reader, True);

3.6. Свойства

3.6.1. Понятие свойства

Помимо полей и методов в объектах существуют свойства. При работе с объектом свойства выглядят как поля: они принимают значения и участвуют в выражениях. Но в отличие от полей свойства не занимают места в памяти, а операции их чтения и записи ассоциируются с обычными полями или методами. Это позволяет создавать необходимые сопутствующие эффекты при обращении к свойствам. Например, в объекте Reader присваивание свойству Active значения True вызовет открытие файла, а присваивание значения False — закрытие файла. Создание сопутствующего эффекта (открытие или закрытие файла) достигается тем, что за присваиванием свойству значения стоит вызов метода.

Объявление свойства выполняется с помощью зарезервированного слова property, например:

        type
  TDelimitedReader = class
    ...
    FActive: Boolean;
    ...
    // Метод записи (установки значения) свойстваprocedure SetActive(const AActive: Boolean);
    property Active: Boolean read FActive write SetActive; // Свойствоend;

Ключевые слова read и write называются спецификаторами доступа. После слова read указывается поле или метод, к которому происходит обращение при чтении (получении) значения свойства, а после слова write — поле или метод, к которому происходит обращение при записи (установке) значения свойства. Например, чтение свойства Active означает чтение поля FActive, а установка свойства — вызов метода SetActive. Чтобы имена свойств не совпадали с именами полей, последние принято писать с буквы F (от англ. field). Мы в дальнейшем также будем пользоваться этим соглашением. Начнем с того, что переименуем поля класса TDelimitedReader: поле FileVar переименуем в FFile, Items — в FItems, а поле Delimiter — в FDelimiter.

        type
  TDelimitedReader = class// Поля
    FFile: TextFile;          // FileVar    -> FFile
    FItems: arrayofstring;  // Items      -> FItems
    FActive: Boolean;
    FDelimiter: Char;         // Delimiter  -> FDelimiter
    ...
  end;

Обращение к свойствам выглядит в программе как обращение к полям:

        var
  Reader: TDelimitedReader;
  IsOpen: Boolean;
...
  Reader.Active := True;   // Эквивалентно Reader.SetActive(True);
  IsOpen := Reader.Active; // Эквивалентно IsOpen := Reader.FActive

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

        type
  TDelimitedReader = class
    ...
    FItems: arrayofstring;
    ...
    function GetItemCount: Integer;
    ...
    property ItemCount: Integer read GetItemCount; // Только для чтения!end;

function TDelimitedReader.GetItemCount: Integer;
begin
  Result := Length(FItems);
end;

Здесь свойство ItemCount показывает количество элементов в массиве FItems. Поскольку оно определяется в результате чтения и разбора очередной строки файла, пользователю объекта разрешено лишь узнавать количество элементов.

В отличие от полей свойства не имеют адреса в памяти, поэтому к ним запрещено применять операцию @. Как следствие, их нельзя передавать в var- и out-параметрах процедур и функций.

Технология объектно-ориентированного программирования в среде Delphi предписывает избегать прямого обращения к полям, создавая вместо этого соответствующие свойства. Это упорядочивает работу с объектами, изолируя их данные от непосредственной модификации. В будущем внутренняя структура класса, которая иногда является достаточно сложной, может быть изменена с целью повышения эффективности работы программы. При этом потребуется переработать только методы чтения и записи значений свойств; внешний интерфейс класса не изменится.

3.6.2. Методы получения и установки значений свойств

Методы получения (чтения) и установки (записи) значений свойств подчиняются определенным правилам. Метод чтения свойства — это всегда функция, возвращающая значение того же типа, что и тип свойства. Метод записи свойства — это обязательно процедура, принимающая параметр того же типа, что и тип свойства. В остальных отношениях это обычные методы объекта. Примерами методов чтения и записи свойств являются методы GetItemCount и SetActive в классе TDelimitedReader:

        type
  TDelimitedReader = class
    FActive: Boolean;
    ...
    procedure SetActive(const AActive: Boolean);
    function GetItemCount: Integer;
    ...
    property Active: Boolean read FActive write SetActive;
    property ItemCount: Integer read GetItemCount;
  end;

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

        procedure TDelimitedReader.SetActive(const AActive: Boolean);
beginif Active <> AActive then// Если состояние изменяетсяbeginif AActive then
      Reset(FFile)          // Открытие файлаelse
      CloseFile(FFile);     // Закрытие файла
    FActive := AActive;     // Сохранение состояния в полеend;
end;

Наличие свойства Active позволяет нам отказаться от использования методов Open и Close, традиционных при работе с файлами. Согласитесь, что открывать и закрывать файл с помощью свойства Active гораздо удобнее и естественнее. Одновременно с этим свойство Active можно использовать и для проверки состояния файла (открыт или нет). Таким образом, для осуществления трех действий требуется всего лишь одно свойство! Это делает использование Ваших классов другими программистами более простым, поскольку им легче запомнить одно понятие Active, чем, например, три метода: Open, Close и IsOpen.

Значение свойства может не храниться, а вычисляться при каждом обращении к свойству. Примером является свойство ItemCount, значение которого вычисляется как Length(FItems).

3.6.3. Свойства-массивы

Кроме обычных свойств в объектах существуют свойства-массивы (array properties). Свойство-массив — это индексированное множество значений. Например, в классе TDelimitedReader множество элементов, выделенных из считанной строки, удобно представить в виде свойства-массива:

        type
  TDelimitedReader = class
    ...
    FItems: arrayofstring;
    ...
    function GetItem(Index: Integer): string;
    ...
    property Items[Index: Integer]: stringread GetItem; 
  end;

function TDelimitedReader.GetItem(Index: Integer): string;
begin
  Result := FItems[Index];
end;

Элементы массива Items можно только читать, поскольку класс TDelimitedReader предназначен только для чтения данных из файла.

В описании свойства-массива разрешено использовать только методы, но не поля. В этом состоит отличие свойства-массива от обычного свойства.

Основная выгода от применения свойства-массива — возможность выполнения итераций с помощью цикла for, например:

        var
  Reader: TDelimitedReader;
  I: Integer;
...
  for I := 0 to Reader.ItemCount - 1 do
    Writeln(Reader.Items[I]);
...

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

Свойства-массивы имеют два важных отличия от обычных массивов:

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

Reader.Items['FirstName'] := 'Alexander';

операции над свойством-массивом в целом запрещены; разрешены операции только с его элементами.

3.6.4. Свойство-массив как основное свойство объекта

Свойство-массив можно сделать основным свойством объектов данного класса. Для этого в описание свойства добавляется слово default:

        type
  TDelimitedReader = class
    ...
    property Items[Index: Integer]: stringread GetItem; default;
    ...
  end;

Такое объявление свойства Items позволяет рассматривать сам объект класса TDelimitedReader как массив и опускать имя свойства-массива при обращении к нему из программы, например:

        var
  R: TDelimitedReader;
  I: Integer;
...
  for I := 0 to R.ItemCount - 1 do
    Writeln(R[I]);
...

Следует помнить, что только свойства-массивы могут быть основными свойствами объектов; для обычных свойств это недопустимо.

3.6.5. Методы, обслуживающие несколько свойств

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

В следующем примере уже известный Вам метод GetItem обслуживает три свойства: FirstName, LastName и Phone:

        type
  TDelimitedReader = class
    ...
    property FirstName: stringindex 0 read GetItem;
    property LastName: stringindex 1 read GetItem;
    property Phone: stringindex 2 read GetItem;
  end;

Обращения к свойствам FirstName, LastName и Phone заменяются компилятором на вызовы одного и того же метода GetItem, но с разными значениями параметра Index:

        var
  Reader: TDelimitedReader;
...
  Writeln(Reader.FirstName); // Эквивалентно: Writeln(Reader.GetItem(0));
  Writeln(Reader.LastName);  // Эквивалентно: Writeln(Reader.GetItem(1));
  Writeln(Reader.Phone);     // Эквивалентно: Writeln(Reader.GetItem(2));
...

Обратите внимание, что метод GetItem обслуживает как свойство-массив Items, так и свойства FirstName, LastName и Phone. Удобно, не правда ли!

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

        type
  TDelimitedReader = class// Поля
    FFile: TextFile;
    FItems: arrayofstring;
    FActive: Boolean;
    FDelimiter: Char;
    // Методы чтения и записи свойствprocedure SetActive(const AActive: Boolean);
    function GetItemCount: Integer;
    function GetEndOfFile: Boolean;
    function GetItem(Index: Integer): string;
    // Методыprocedure PutItem(Index: Integer; const Item: string);
    function ParseLine(const Line: string): Integer;
    function NextLine: Boolean;
    // Конструкторы и деструкторыconstructor Create(const FileName: string; const ADelimiter: Char = ';');
    destructor Destroy; override;
    // Свойстваproperty Active: Boolean read FActive write SetActive;
    property Items[Index: Integer]: stringread GetItem; default;
    property ItemCount: Integer read GetItemCount;
    property EndOfFile: Boolean read GetEndOfFile;
    property Delimiter: Char read FDelimiter;
  end;

{ TDelimitedReader }constructor TDelimitedReader.Create(const FileName: string;
  const ADelimiter: Char = ';');
begin
  AssignFile(FFile, FileName);
  FActive := False;
  FDelimiter := ADelimiter;
end;

destructor TDelimitedReader.Destroy;
begin
  Active := False;
end;

function TDelimitedReader.GetEndOfFile: Boolean;
begin
  Result := Eof(FFile);
end;

function TDelimitedReader.GetItem(Index: Integer): string;
begin
  Result := FItems[Index];
end;

function TDelimitedReader.GetItemCount: Integer;
begin
  Result := Length(FItems);
end;

function TDelimitedReader.NextLine: Boolean;
var
  S: string;
  N: Integer;
begin
  Result := not EndOfFile;
  if Result then// Если не достигнут конец файлаbegin
    Readln(FFile, S);        // Чтение очередной строки из файла
    N := ParseLine(S);       // Разбор считанной строкиif N <> ItemCount then
      SetLength(FItems, N);  // Отсечение массива (если необходимо)end;
end;

function TDelimitedReader.ParseLine(const Line: string): Integer;
var
  S: string;
  P: Integer;
begin
  S := Line;
  Result := 0;
  repeat
    P := Pos(Delimiter, S);  // Поиск разделителяif P = 0 then// Если разделитель не найден, то считается, что
      P := Length(S) + 1;    // разделитель находится за последним символом
    PutItem(Result, Copy(S, 1, P - 1)); // Установка элемента
    Delete(S, 1, P);                    // Удаление элемента из строки
    Result := Result + 1;               // Переход к следующему элементуuntil S = '';                         // Пока в строке есть символыend;

procedure TDelimitedReader.PutItem(Index: Integer; const Item: string);
beginif Index > High(FItems) then// Если индекс выходит за границы массива,
    SetLength(FItems, Index + 1); // то увеличение размера массива
  FItems[Index] := Item;          // Установка соответствующего элементаend;

procedure TDelimitedReader.SetActive(const AActive: Boolean);
beginif Active <> AActive then// Если состояние изменяетсяbeginif AActive then
      Reset(FFile)          // Открытие файлаelse
      CloseFile(FFile);     // Закрытие файла
    FActive := AActive;     // Сохранение состояния в полеend;
end;

3.7. Наследование

3.7.1. Понятие наследования

Классы инкапсулируют (т.е. включают в себя) поля, методы и свойства; это их первая черта. Следующая не менее важная черта классов — способность наследовать поля, методы и свойства других классов. Чтобы пояснить сущность наследования обратимся к примеру с читателем текстовых файлов в формате "delimited text".

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

        type
  TFixedReader = classprivate// Поля
    FFile: TextFile;
    FItems: arrayofstring;
    FActive: Boolean;
    FItemWidths: arrayof Integer;
    // Методы чтения и записи свойствprocedure SetActive(const AActive: Boolean);
    function GetItemCount: Integer;
    function GetEndOfFile: Boolean;
    function GetItem(Index: Integer): string;
    // Методыprocedure PutItem(Index: Integer; const Item: string);
    function ParseLine(const Line: string): Integer;
    function NextLine: Boolean;
    // Конструкторы и деструкторыconstructor Create(const FileName: string;
      const AItemWidths: arrayof Integer);
    destructor Destroy; override;
    // Свойстваproperty Active: Boolean read FActive write SetActive;
    property Items[Index: Integer]: stringread GetItem; default;
    property ItemCount: Integer read GetItemCount;
    property EndOfFile: Boolean read GetEndOfFile;
  end;

{ TFixedReader }constructor TFixedReader.Create(const FileName: string;
  const AItemWidths: arrayof Integer);
var
  I: Integer;
begin
  AssignFile(FFile, FileName);
  FActive := False;
  // Копирование AItemWidths в FItemWidths
  SetLength(FItemWidths, Length(AItemWidths));
  for I := 0 to High(AItemWidths) do
    FItemWidths[I] := AItemWidths[I];
end;

destructor TFixedReader.Destroy;
begin
  Active := False;
end;

function TFixedReader.GetEndOfFile: Boolean;
begin
  Result := Eof(FFile);
end;

function TFixedReader.GetItem(Index: Integer): string;
begin
  Result := FItems[Index];
end;

function TFixedReader.GetItemCount: Integer;
begin
  Result := Length(FItems);
end;

function TFixedReader.NextLine: Boolean;
var
  S: string;
  N: Integer;
begin
  Result := not EndOfFile;
  if Result then// Если не достигнут конец файлаbegin
    Readln(FFile, S);        // Чтение очередной строки из файла
    N := ParseLine(S);       // Разбор считанной строкиif N <> ItemCount then
      SetLength(FItems, N);  // Отсечение массива (если необходимо)end;
end;

function TFixedReader.ParseLine(const Line: string): Integer;
var
  I, P: Integer;
begin
  P := 1;
  for I := 0 to High(FItemWidths) dobegin
    PutItem(I, Copy(Line, P, FItemWidths[I])); // Установка элемента
    P := P + FItemWidths[I];                   // Переход к следующему элементуend;
  Result := Length(FItemWidths); // Количество элементов постоянноend;

procedure TFixedReader.PutItem(Index: Integer; const Item: string);
beginif Index > High(FItems) then// Если индекс выходит за границы массива,
    SetLength(FItems, Index + 1); // то увеличение размера массива
  FItems[Index] := Item;          // Установка соответствующего элементаend;

procedure TFixedReader.SetActive(const AActive: Boolean);
beginif Active <> AActive then// Если состояние изменяетсяbeginif AActive then
      Reset(FFile)          // Открытие файлаelse
      CloseFile(FFile);     // Закрытие файла
    FActive := AActive;     // Сохранение состояния в полеend;
end;

Поля, свойства и методы класса TFixedReader практически полностью аналогичны тем, что определены в классе TDelimitedReader. Отличие состоит в отсутствии свойства Delimiter, наличии поля FItemWidths (для хранения размеров элементов), другой реализации метода ParseLine и немного отличающемся конструкторе. Если в будущем появится класс для чтения элементов из файла еще одного формата (например, зашифрованного текста), то придется снова определять общие для всех классов поля, методы и свойства. Чтобы избавиться от дублирования общих атрибутов (полей, свойств и методов) при определении новых классов, воспользуемся механизмом наследования. Прежде всего, выделим в отдельный класс TTextReader общие атрибуты всех классов, предназначенных для чтения элементов из текстовых файлов. Реализация методов TTextReader, кроме метода ParseLine, полностью идентична реализации TDelimitedReader, приведенной в предыдущем разделе.

        type
  TTextReader = classprivate// Поля
    FFile: TextFile;
    FItems: arrayofstring;
    FActive: Boolean;
    // Методы получения и установки значений свойствprocedure SetActive(const AActive: Boolean);
    function GetItemCount: Integer;
    function GetItem(Index: Integer): string;
    function GetEndOfFile: Boolean;
    // Методыprocedure PutItem(Index: Integer; const Item: string);
    function ParseLine(const Line: string): Integer;
    function NextLine: Boolean;
    // Конструкторы и деструкторыconstructor Create(const FileName: string);
    destructor Destroy; override;
    // Свойстваproperty Active: Boolean read FActive write SetActive;
    property Items[Index: Integer]: string read GetItem; default;
    property ItemCount: Integer read GetItemCount;
    property EndOfFile: Boolean read GetEndOfFile;
  end;
...
constructor TTextReader.Create(const FileName: string);
begin
  AssignFile(FFile, FileName);
  FActive := False;
end;
 
function TTextReader.ParseLine(const Line: string): Integer;
begin// Функция просто возвращает 0, поскольку не известно,// в каком именно формате хранятся элементы
  Result := 0;
end;
...

При реализации класса TTextReader ничего не известно о том, как хранятся элементы в считываемых строках, поэтому метод ParseLine ничего не делает. Очевидно, что создавать объекты класса TTextReader не имеет смысла. Для чего тогда нужен класс TTextReader? Ответ: чтобы на его основе определить (породить) два других класса — TDelimitedReader и TFixedReader, предназначенных для чтения данных в конкретных форматах:

        type
  TDelimitedReader = class(TTextReader)
    FDelimiter: Char;
    function ParseLine(const Line: string): Integer; override;
    constructor Create(const FileName: string; const ADelimiter: Char = ';');
    property Delimiter: Char read FDelimiter;
  end;

  TFixedReader = class(TTextReader)
    FItemWidths: arrayof Integer;
    function ParseLine(const Line: string): Integer; override;
    constructor Create(const FileName: string;
      const AItemWidths: arrayof Integer);
  end;
...

Классы TDelimitedReader и TFixedReader определены как наследники TTextReader (об этом говорит имя в скобках после слова class). Они автоматически включают в себя все описания, сделанные в классе TTextReader и добавляют к ним некоторые новые. В результате формируется дерево классов, показанное на рисунке 3.1 (оно всегда рисуется перевернутым).


Рисунок 3.1. Дерево классов

Класс, который наследует атрибуты другого класса, называется порожденным классом или потомком. Соответственно класс, от которого происходит наследование, выступает в роли базового, или предка. В нашем примере класс TDelimitedReader является прямым потомком класса TTextReader. Если от TDelimitedReader породить новый класс, то он тоже будет потомком класса TTextReader, но уже не прямым.

Очень важно, что в отношениях наследования любой класс может иметь только одного непосредственного предка и сколь угодно много потомков. Поэтому все связанные отношением наследования классы образуют иерархию. Примером иерархии классов является библиотека VCL; с ее помощью в среде Delphi обеспечивается разработка GUI-приложений.

3.7.2. Прародитель всех классов

В языке Delphi существует предопределенный класс TObject, который служит неявным предком тех классов, для которых предок не указан. Это означает, что объявление

        type
  TTextReader = class
    ...
  end;

эквивалентно следующему:

        type
  TTextReader = class(TObject)
    ...
  end;

Класс TObject выступает корнем любой иерархии классов. Он содержит ряд методов, которые по наследству передаются всем остальным классам. Среди них конструктор Create, деструктор Destroy, метод Free и некоторые другие методы.

Таким образом, полное дерево классов для чтения элементов из текстового файла в различных форматах выглядит так, как показано на рисунке 3.2.


Рисунок 3.2. Полное дерево классов

Поскольку класс TObject является предком для всех других классов (в том числе и для ваших собственных), то не лишним будет кратко ознакомиться с его методами:

        type
  TObject = classconstructor Create;
    procedure Free;
    classfunction InitInstance(Instance: Pointer): TObject;
    procedure CleanupInstance;
    function ClassType: TClass;
    classfunction ClassName: ShortString;
    classfunction ClassNameIs(const Name: string): Boolean;
    classfunction ClassParent: TClass;
    classfunction ClassInfo: Pointer;
    classfunction InstanceSize: Longint;
    classfunction InheritsFrom(AClass: TClass): Boolean;
    classfunction MethodAddress(const Name: ShortString): Pointer;
    classfunction MethodName(Address: Pointer): ShortString;
    function FieldAddress(const Name: ShortString): Pointer;
    function GetInterface(const IID: TGUID; out Obj): Boolean;
    classfunction GetInterfaceEntry(const IID: TGUID): PInterfaceEntry;
    classfunction GetInterfaceTable: PInterfaceTable;
    function SafeCallException(ExceptObject: TObject;
      ExceptAddr: Pointer): HResult; virtual;
    procedure AfterConstruction; virtual;
    procedure BeforeDestruction; virtual;
    procedure Dispatch(var Message); virtual;
    procedure DefaultHandler(var Message); virtual;
    classfunction NewInstance: TObject; virtual;
    procedure FreeInstance; virtual;
    destructor Destroy; virtual;
  end;

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

Краткое описание методов в классе TObject:

3.7.3. Перекрытие атрибутов в наследниках

В механизме наследования можно условно выделить три основных момента:

Любой порожденный класс наследует от родительского все поля данных, поэтому классы TDelimitedReader и TFixedReader автоматически содержат поля FFile, FActive и FItems, объявленные в классе TTextReader. Доступ к полям предка осуществляется по имени, как если бы они были определены в потомке. В потомках можно определять новые поля, но их имена должны отличаться от имен полей предка.

Наследование свойств и методов имеет свои особенности.

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

Метод базового класса тоже можно перекрыть в производном классе, например чтобы изменить логику его работы. Обратимся к классам TDelimitedReader и TFixedReader. В них методы PutItem, GetItem, SetActive и GetEndOfFile унаследованы от TTextReader, поскольку логика их работы не зависит от того, в каком формате хранятся данные в файле. А вот метод ParseLine перекрыт, так как способ разбора строк зависит от формата данных:

        function TDelimitedReader.ParseLine(const Line: string): Integer;
var
  S: string;
  P: Integer;
begin
  S := Line;
  Result := 0;
  repeat
    P := Pos(Delimiter, S);  // Поиск разделителяif P = 0 then// Если разделитель не найден, то считается, что
      P := Length(S) + 1;    // разделитель находится за последним символом
    PutItem(Result, Copy(S, 1, P - 1)); // Установка элемента
    Delete(S, 1, P);                    // Удаление элемента из строки
    Result := Result + 1;               // Переход к следующему элементуuntil S = '';                         // Пока в строке есть символыend;
 
function TFixedReader.ParseLine(const Line: string): Integer;
var
  I, P: Integer;
begin
  P := 1;
  for I := 0 to High(FItemWidths) dobegin
    PutItem(I, Copy(Line, P, FItemWidths[I])); // Установка элемента
    P := P + FItemWidths[I];                   // Переход к следующему элементуend;
  Result := Length(FItemWidths); // Количество элементов постоянноend;

В классах TDelimitedReader и TFixedReader перекрыт еще и конструктор Create. Это необходимо для инициализации специфических полей этих классов (поля FDelimiter в классе TDelimitedReader и поля FItemWidths в классе TFixedReader):

        constructor TDelimitedReader.Create(const FileName: string;
  const ADelimiter: Char = ';');
begininherited Create(FileName);
  FDelimiter := ADelimiter;
end;

constructor TFixedReader.Create(const FileName: string;
  const AItemWidths: arrayof Integer);
var
  I: Integer;
begininherited Create(FileName);
  // Копирование AItemWidths в FItemWidths
  SetLength(FItemWidths, Length(AItemWidths));
  for I := 0 to High(AItemWidths) do
    FItemWidths[I] := AItemWidths[I];
end;

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

        destructor TTextReader.Destroy;
begin
  Active := False;
  inherited; // Эквивалентно: inherited Destroy;end;

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

3.7.4. Совместимость объектов различных классов

Для классов, связанных отношением наследования, вводится новое правило совместимости типов. Вместо объекта базового класса можно подставить объект любого производного класса. Обратное неверно. Например, переменной типа TTextReader можно присвоить значение переменной типа TDelimitedReader:

        var
  Reader: TTextReader;
...
  Reader := TDelimitedReader.Create('MyData.del', ';');

Объектная переменная Reader формально имеет тип TTextReader, а фактически связана с экземпляром класса TDelimitedReader.

Правило совместимости классов чаще всего применяется при передаче объектов в параметрах процедур и функций. Например, если процедура работает с объектом класса TTextReader, то вместо него можно передать объект класса TDelimitedReader или TFixedReader.

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

3.7.5. Контроль и преобразование типов

Поскольку реальный экземпляр объекта может оказаться наследником класса, указанного при описании объектной переменной или параметра, бывает необходимо проверить, к какому классу принадлежит объект на самом деле. Чтобы программист мог выполнять такого рода проверки, каждый объект хранит информацию о своем классе. В языке Delphi существуют операторы is и as, с помощью которых выполняется соответственно проверка на тип (type checking) и преобразование к типу (type casting).

Например, чтобы выяснить, принадлежит ли некоторый объект Obj к классу TTextReader или его наследнику, следует использовать оператор is:

        var
  Obj: TObject;
...
  if Obj is TTextReader then ...

Для преобразования объекта к нужному типу используется оператор as, например

          with Obj as TTextReader do
    Active := False;

Стоит отметить, что для объектов применим и обычный способ приведения типа:

          with TTextReader(Obj) do
    Active := False;

Вариант с оператором as лучше, поскольку безопасен. Он генерирует ошибку (точнее исключительную ситуацию; об исключительных ситуациях мы расскажем в главе 4) при выполнении программы (run-time error), если реальный экземпляр объекта Obj не совместим с классом TTextReader. Забегая вперед, скажем, что ошибку приведения типа можно обработать и таким образом избежать досрочного завершения программы.

3.8. Виртуальные методы

3.8.1. Понятие виртуального метода

Все методы, которые до сих пор рассматривались, имеют одну общую черту — все они статические. При обращении к статическому методу компилятор точно знает класс, которому данный метод принадлежит. Поэтому, например, обращение к статическому методу ParseLine в методе NextLine (принадлежащем классу TTextReader) компилируется в вызов TTextReader.ParseLine:

        function TTextReader.NextLine: Boolean;
var
  S: string;
  N: Integer;
begin
  Result := not EndOfFile;
  if Result thenbegin
    Readln(FFile, S); 
    N := ParseLine(S); // Компилируется в вызов TTextReader.ParseLine(S);if N <> ItemCount then
      SetLength(FItems, N); 
  end;
end;

В результате метод NextLine работает неправильно в наследниках класса TTextReader, так как внутри него вызов перекрытого метода ParseLine не происходит. Конечно, в классах TDelimitedReader и TFixedReader можно продублировать все методы и свойства, которые прямо или косвенно вызывают ParseLine, но при этом теряются преимущества наследования, и мы возвращаемся к тому, что необходимо описать два класса, в которых большая часть кода идентична. ООП предлагает изящное решение этой проблемы — метод ParseLine всего-навсего объявляется виртуальным:

        type
  TTextReader = class
    ...
    function ParseLine(const Line: string): Integer; virtual; //Виртуальный метод
    ...
  end;

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

        type
  TDelimitedReader = class(TTextReader)
    ...
    function ParseLine(const Line: string): Integer; override;
    ...
  end;

  TFixedReader = class(TTextReader)
    ...
    function ParseLine(const Line: string): Integer; override;
    ...
  end;

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

        function TTextReader.NextLine: Boolean;
var
  S: string;
  N: Integer;
begin
  Result := not EndOfFile;
  if Result thenbegin
    Readln(FFile, S); 
    N := ParseLine(S); // Работает как <фактический класс>.ParseLine(S)if N <> ItemCount then
      SetLength(FItems, N); 
  end;
end;

Работа виртуальных методов основана на механизме позднего связывания (late binding). В отличие от раннего связывания (early binding), характерного для статических методов, позднее связывание основано на вычислении адреса вызываемого метода при выполнении программы. Адрес метода вычисляется по хранящемуся в каждом объекте описателю класса.

Благодаря механизму наследования и виртуальных методов в среде Delphi реализуется такая концепция ООП как полиморфизм. Полиморфизм существенно облегчает труд программиста, поскольку обеспечивает повторное использование кода уже написанных и отлаженных методов.

3.8.2. Механизм вызова виртуальных методов

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

Все процедурные переменные с адресами виртуальных методов пронумерованы и хранятся в таблице, называемой таблицей виртуальных методов (VMT — от англ. Virtual Method Table). Такая таблица создается одна для каждого класса объектов, и все объекты этого класса хранят на нее ссылку.

Структуру объекта в оперативной памяти поясняет рисунок 3.3:


Рисунок 3.3. Структура объекта TTextReader в оперативной памяти

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

  1. Через объектную переменную выполняется обращение к занятому объектом блоку памяти;
  2. Далее из этого блока извлекается адрес таблицы виртуальных методов (он записан в четырех первых байтах);
  3. На основании порядкового номера виртуального метода извлекается адрес соответствующей подпрограммы;
  4. Вызывается код, находящийся по этому адресу.

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

        type
  TVMT = array[0..9999] of Pointer;
  TParseLineFunc = function (Self: TTextReader; const Line: string): Integer;
var
  Reader: TTextReader;    // объектная переменна
  ObjectDataPtr: Pointer; // указатель на занимаемый объектом блок памяти
  VMTPtr: ^TVMT;          // указатель на таблицу виртуальных методов
  MethodPtr: Pointer;     // указатель на методbegin
  ...
  ObjectDataPtr := Pointer(Reader);      // 1) обращение к данным объекта
  VMTPtr := Pointer(ObjectDataPtr^);     // 2) извлечение адреса VMT
  MethodPtr := VMTPtr^[0];               // 3) извлечение адреса метода из VMT
  TParseLineFunc(MethodPtr)(Reader, S);  // 4) вызов метода
  ...
end.

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

3.8.3. Абстрактные виртуальные методы

При построении иерархии классов часто возникает ситуация, когда работа виртуального метода в базовом классе не известна и наполняется содержанием только в наследниках. Так случилось, например, с методом ParseLine, тело которого в классе TTextReader объявлено пустым. Конечно, тело метода всегда можно сделать пустым или почти пустым (так мы и поступили), но лучше воспользоваться директивой abstract:

        type
  TTextReader = class
    ...
    function ParseLine(const Line: string): Integer; virtual; abstract; 
    ...
  end;

Директива abstract записывается после слова virtual и исключает необходимость написания кода виртуального метода для данного класса. Такой метод называется абстрактным, т.е. подразумевает логическое действие, а не конкретный способ его реализации. Абстрактные виртуальные методы часто используются при создании классов-полуфабрикатов. Свою реализацию такие методы получают в законченных наследниках.

3.8.4. Динамические методы

Разновидностью виртуальных методов являются так называемые динамические методы. При их объявлении вместо ключевого слова virtual записывается ключевое слово dynamic, например:

        type
  TTextReader = class
    ...
    function ParseLine(const Line: string): Integer; dynamic; abstract; 
    ...
  end;

В наследниках динамические методы перекрываются так же, как и виртуальные — с помощью зарезервированного слова override.

По смыслу динамические и виртуальные методы идентичны. Различие состоит только в механизме их вызова. Методы, объявленные с директивой virtual, вызываются максимально быстро, но платой за это является большой размер системных таблиц, с помощью которых определяются их адреса. Размер этих таблиц начинает сказываться с увеличением числа классов в иерархии. Методы, объявленные с директивой dynamic вызываются несколько дольше, но при этом таблицы с адресами методов имеют более компактный вид, что способствует экономии памяти. Таким образом, программисту предоставляются два способа оптимизации объектов: по скорости работы (virtual) или по объему памяти (dynamic).

3.8.5. Методы обработки сообщений

Специализированной формой динамических методов являются методы обработки сообщений. Они объявляются с помощью ключевого слова message, за которым следует целочисленная константа — номер сообщения. Следующий пример взят из исходных текстов библиотеки VCL:

        type
  TWidgetControl = class(TControl)
    ...
    procedure CMKeyDown(var Msg: TCMKeyDown); message CM_KEYDOWN;
    ...
  end;

Метод обработки сообщений имеет формат процедуры и содержит единственный var-параметр. При перекрытии такого метода название метода и имя параметра могут быть любыми, важно лишь, чтобы неизменным остался номер сообщения, используемый для вызова метода. Вызов метода выполняется не по имени, как обычно, а с помощью обращения к специальному методу Dispatch, который имеется в каждом классе (метод Dispatch определен в классе TObject).

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

3.9. Классы в программных модулях

Классы очень удобно собирать в модули. При этом их описание помещается в секцию interface, а код методов — в секцию implementation. Создавая модули классов, нужно придерживаться следующих правил:

Соберем рассмотренные ранее классы TTextReader, TDelimitedReader и TFixedReader в отдельный модуль ReadersUnit:

      unit ReadersUnit;

interfacetype
  TTextReader = classprivate// Поля
    FFile: TextFile;
    FItems: arrayofstring;
    FActive: Boolean;
    // Методыprocedure PutItem(Index: Integer; const Item: string);
    // Методы чтения и записи свойствprocedure SetActive(const AActive: Boolean);
    function GetItemCount: Integer;
    function GetEndOfFile: Boolean;
  protected// Методы чтения и записи свойствfunction GetItem(Index: Integer): string;
    // Абстрактные методыfunction ParseLine(const Line: string): Integer; virtual; abstract;
  public// Конструкторы и деструкторыconstructor Create(const FileName: string);
    destructor Destroy; override;
    // Методыfunction NextLine: Boolean;
    // Свойстваproperty Active: Boolean read FActive write SetActive;
    property Items[Index: Integer]: stringread GetItem; default;
    property ItemCount: Integer read GetItemCount;
    property EndOfFile: Boolean read GetEndOfFile;
  end;

  TDelimitedReader = class(TTextReader)
  private// Поля
    FDelimiter: Char;
  protected// Методыfunction ParseLine(const Line: string): Integer; override;
  public// Конструкторы и деструкторыconstructor Create(const FileName: string; const ADelimiter: Char = ';');
    // Свойстваproperty Delimiter: Char read FDelimiter;
  end;

  TFixedReader = class(TTextReader)
  private// Поля
    FItemWidths: arrayof Integer;
  protected// Методыfunction ParseLine(const Line: string): Integer; override;
  public// Конструкторы и деструкторыconstructor Create(const FileName: string;
      const AItemWidths: arrayof Integer);
  end;

  TMyReader = class(TDelimitedReader)
    property FirstName: stringindex 0 read GetItem;
    property LastName: stringindex 1 read GetItem;
    property Phone: stringindex 2 read GetItem;
  end;

implementation{ TTextReader }constructor TTextReader.Create(const FileName: string);
begininherited Create;
  AssignFile(FFile, FileName);
  FActive := False;
end;

destructor TTextReader.Destroy;
begin
  Active := False;
  inherited;
end;

function TTextReader.GetEndOfFile: Boolean;
begin
  Result := Eof(FFile);
end;

function TTextReader.GetItem(Index: Integer): string;
begin
  Result := FItems[Index];
end;

function TTextReader.GetItemCount: Integer;
begin
  Result := Length(FItems);
end;

function TTextReader.NextLine: Boolean;
var
  S: string;
  N: Integer;
begin
  Result := not EndOfFile;
  if Result then// Если не достигнут конец файлаbegin
    Readln(FFile, S);        // Чтение очередной строки из файла
    N := ParseLine(S);       // Разбор считанной строкиif N <> ItemCount then
      SetLength(FItems, N);  // Отсечение массива (если необходимо)end;
end;

procedure TTextReader.PutItem(Index: Integer; const Item: string);
beginif Index > High(FItems) then// Если индекс выходит за границы массива,
    SetLength(FItems, Index + 1); // то увеличение размера массива
  FItems[Index] := Item;          // Установка соответствующего элементаend;

procedure TTextReader.SetActive(const AActive: Boolean);
beginif Active <> AActive then// Если состояние изменяетсяbeginif AActive then
      Reset(FFile)          // Открытие файлаelse
      CloseFile(FFile);     // Закрытие файла
    FActive := AActive;     // Сохранение состояния в полеend;
end;

{ TDelimitedReader }constructor TDelimitedReader.Create(const FileName: string;
  const ADelimiter: Char = ';');
begininherited Create(FileName);
  FDelimiter := ADelimiter;
end;

function TDelimitedReader.ParseLine(const Line: string): Integer;
var
  S: string;
  P: Integer;
begin
  S := Line;
  Result := 0;
  repeat
    P := Pos(Delimiter, S);  // Поиск разделителяif P = 0 then// Если разделитель не найден, то считается, что
      P := Length(S) + 1;    // разделитель находится за последним символом
    PutItem(Result, Copy(S, 1, P - 1)); // Установка элемента
    Delete(S, 1, P);                    // Удаление элемента из строки
    Result := Result + 1;               // Переход к следующему элементуuntil S = '';                         // Пока в строке есть символыend;

{ TFixedReader }constructor TFixedReader.Create(const FileName: string;
  const AItemWidths: arrayof Integer);
var
  I: Integer;
begininherited Create(FileName);
  // Копирование AItemWidths в FItemWidths
  SetLength(FItemWidths, Length(AItemWidths));
  for I := 0 to High(AItemWidths) do
    FItemWidths[I] := AItemWidths[I];
end;

function TFixedReader.ParseLine(const Line: string): Integer;
var
  I, P: Integer;
begin
  P := 1;
  for I := 0 to High(FItemWidths) dobegin
    PutItem(I, Copy(Line, P, FItemWidths[I])); // Установка элемента
    P := P + FItemWidths[I];                   // Переход к следующему элементуend;
  Result := Length(FItemWidths); // Количество элементов постоянноend;

end.

Как можно заметить, в описании классов присутствуют новые ключевые слова private, protected и public. С их помощью регулируется видимость частей класса для других модулей и основной программы. Назначение каждого ключевого слова поясняется ниже.

3.10. Разграничение доступа к атрибутам объектов

Программист может разграничить доступ к атрибутам своих объектов для других программистов (и себя самого) с помощью специальных ключевых слов: private, protected, public, published (последнее не используется в модуле ReadersUnit).

Перечисленные секции могут чередоваться в объявлении класса в произвольном порядке, однако в пределах секции сначала следует описание полей, а потом методов и свойств. Если в определении класса нет ключевых слов private, protected, public и published, то для обычных классов всем полям, методам и свойствам приписывается атрибут видимости public, а для тех классов, которые порождены от классов библиотеки VCL, — атрибут видимости published.

Внутри модуля никакие ограничения на доступ к атрибутам классов, реализованных в этом же модуле, не действуют. Кстати, это отличается от соглашений, принятых в некоторых других языках программирования, в частности в языке C++.

3.11. Указатели на методы объектов

В языке Delphi существуют процедурные типы данных для методов объектов. Внешне объявление процедурного типа для метода отличается от обычного словосочетанием of object, записанным после прототипа процедуры или функции:

      type
  TReadLineEvent = procedure (Reader: TTextReader; const Line: string) ofobject;

Переменная такого типа называется указателем на метод (method pointer). Она занимает в памяти 8 байт и хранит одновременно ссылку на объект и адрес его метода.

      type
  TTextReader = classprivate
    FOnReadLine: TReadLineEvent;
    ...
  publicproperty OnReadLine: TReadLineEvent read FOnReadLine write FOnReadLine;
  end;

Методы объектов, объявленные по приведенному выше шаблону, становятся совместимы по типу со свойством OnReadLine.

      type
  TForm1 = class(TForm)
    procedure HandleLine(Reader: TTextReader; const Line: string);
  end;

var
  Form1: TForm1;
  Reader: TTextReader;

Если установить значение свойства OnReadLine:

Reader.OnReadLine := Form1.HandleLine;

и переписать метод NextLine,

      function TTextReader.NextLine: Boolean;
var
  S: string;
  N: Integer;
begin
  Result := not EndOfFile;
  if Result then// Если строки для считывания еще есть, тоbegin
    Readln(FFile, S);       // Считывание очередной строки
    N := ParseLine(S);      // Выделение элементов строки (разбор строки)if N <> ItemCount then
      SetLength(FItems, N);
    if Assigned(FOnReadLine) then
      FOnReadLine(Self, S); // уведомление о чтении очередной строкиend;
end;

то объект Form1 через метод HandleLine получит уведомление об очередной считанной строке. Обратите внимание, что вызов метода через указатель происходит лишь в том случае, если указатель не равен nil. Эта проверка выполняется с помощью стандартной функции Assigned, которая возвращает True, если ее аргумент является связанным указателем.

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

3.12. Метаклассы

3.12.1. Ссылки на классы

Язык Delphi позволяет рассматривать классы объектов как своего рода объекты, которыми можно манипулировать в программе. Такая возможность рождает новое понятие — класс класса; его принято обозначать термином метакласс.

Для поддержки метаклассов введен специальный тип данных — ссылка на класс (class reference). Он описывается с помощью словосочетания class of, например:

        type
  TTextReaderClass = classof TTextReader;

Переменная типа TTextReaderClass объявляется в программе обычным образом:

        var
  ClassRef: TTextReaderClass;

Значениями переменной ClassRef могут быть класс TTextReader и все порожденные от него классы. Допустимы следующие операторы:

ClassRef := TTextReader;
ClassRef := TDelimitedReader;
ClassRef := TFixedReader;

По аналогии с тем, как для всех классов существует общий предок TObject, у ссылок на классы существует базовый тип TClass, определенный, как:

        type
  TClass = classof TObject;

Переменная типа TClass может ссылаться на любой класс.

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

Физический смысл и взаимосвязь таких понятий, как переменная-объект, экземпляр объекта в памяти, переменная-класс и экземпляр класса в памяти поясняет рисунок 3.4.


Рисунок 3.4. Переменная-объект, экземпляр объекта в памяти, переменная-класс и экземпляр класса в памяти

3.12.2. Методы классов

Метаклассы привели к возникновению нового типа методов — методов класса. Метод класса оперирует не экземпляром объекта, а непосредственно классом. Он объявляется как обычный метод, но перед словом procedure или function записывается зарезервированное слово class, например:

        type
  TTextReader = class
    ...
    classfunction GetClassName: string;
  end;

Передаваемый в метод класса неявный параметр Self содержит не ссылку на объект, а ссылку на класс, поэтому в теле метода нельзя обращаться к полям, методам и свойствам объекта. Зато можно вызывать другие методы класса, например:

        class
        function TTextReader.GetClassName: string;
begin
  Result := ClassName;
end;

Метод ClassName объявлен в классе TObject и возвращает имя класса, к которому применяется. Очевидно, что надуманный метод GetClassName просто дублирует эту функциональность для класса TTextReader и всех его наследников.

Методы класса применимы и к классам, и к объектам. В обоих случаях в параметре Self передается ссылка на класс объекта. Пример:

        var
  Reader: TTextReader;
  S: string;
begin// Вызов метода с помощью ссылки на класс
  S := TTextReader.GetClassName;  // S получит значение 'TTextReader'// Создание объекта класса TDelimitedReader
  Reader := TDelimitedReader.Create('MyData.del');

  // Вызов метода с помощью ссылки на объект
  S := Reader.GetClassName;       // S получит значение 'TDelimitedReader'end.

Методы классов могут быть виртуальными. Например, в классе TObject определен виртуальный метод класса NewInstance. Он служит для распределения памяти под объект и автоматически вызывается конструктором. Его можно перекрыть в своем классе, чтобы обеспечить нестандартный способ выделения памяти для экземпляров. Метод NewInstance должен перекрываться вместе с другим методом FreeInstance, который автоматически вызывается из деструктора и служит для освобождения памяти. Добавим, что размер памяти, требуемый для экземпляра, можно узнать вызовом предопределенного метода класса InstanceSize.

3.12.3. Виртуальные конструкторы

Особая прелесть ссылок на классы проявляется в сочетании с виртуальными конструкторами. Виртуальный конструктор объявляется с ключевым словом virtual. Вызов виртуального конструктора происходит по фактическому значению ссылки на класс, а не по ее формальному типу. Это позволяет создавать объекты, классы которых неизвестны на этапе компиляции. Механизм виртуальных конструкторов применяется в среде Delphi при восстановлении компонентов формы из файла. Восстановление компонента происходит следующим образом. Из файла считывается имя класса. По этому имени отыскивается ссылка на класс (метакласс). У метакласса вызывается виртуальный конструктор, который создает объект нужного класса.

        var
  P: TComponent;
  T: TComponentClass;  // TComponentClass = class of TComponent;
...
  T := FindClass(ReadStr);
  P := T.Create(nil);
...

На этом закончим изучение теории объектно-ориентированного программирования и в качестве практики рассмотрим несколько широко используемых инструментальных классов среды Delphi. Разберитесь с их назначением и работой. Это поможет глубже понять ООП и пригодится на будущее.

3.13. Классы общего назначения

Как показывает практика, в большинстве задач приходится использовать однотипные структуры данных: списки, массивы, множества и т.д. От задачи к задаче изменяются только их элементы, а методы работы сохраняются. Например, для любого списка нужны процедуры вставки и удаления элементов. В связи с этим возникает естественное желание решить задачу "в общем виде", т.е. создать универсальные средства для управления основными структурами данных. Эта идея не нова. Она давно пришла в голову разработчикам инструментальных пакетов, которые быстро наплодили множество вспомогательных библиотек. Эти библиотеки содержали классы объектов для работы со списками, коллекциями (динамические массивы с переменным количеством элементов), словарями (коллекции, индексированные строками) и другими "абстрактными" структурами. Для среды Delphi тоже разработаны аналогичные классы объектов. Их большая часть сосредоточена в модуле Classes. Наиболее нужными для вас являются списки строк (TStrings, TStringList) и потоки (TSream, THandleSream, TFileStream, TMemoryStream и TBlobStream). Рассмотрим кратко их назначение и применение.

3.13.1. Классы для представления списка строк

Для работы со списками строк служат классы TStrings и TStringList. Они используются в библиотеке VCL повсеместно и имеют гораздо большую универсальность, чем та, что можно почерпнуть из их названия. Классы TStrings и TStringList служат для представления не просто списка строк, а списка элементов, каждый из которых представляет собой пару строка-объект. Если со строками не ассоциированы объекты, получается обычный список строк.

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

Свойства класса TStrings описаны ниже.

Наследники класса TStrings иногда используются для хранения строк вида Имя=Значение, в частности, строк INI-файлов (см. гл. 6). Для удобной работы с такими строками в классе TStrings дополнительно имеются следующие свойства.

Управление элементами списка осуществляется с помощью следующих методов:

Класс TStringList добавляет к TStrings несколько дополнительных свойств и методов, а также два свойства-события для уведомления об изменениях в списке. Они описаны ниже.

Свойства:

Методы:

События:

Ниже приводится фрагмент программы, демонстрирующий создание списка строк и манипулирование его элементами:

          var
  Items: TStrings;
  I: Integer;
begin// Создание списка
  Items := TStringList.Create;
  Items.Add('Туризм');
  Items.Add('Наука');
  Items.Insert(1, 'Бизнес');
  ...
  // Работа со спискомfor I := 0 to Items.Count - 1 do
    Items[I] := UpperCase(Items[I]);
  ...
  // Удаление списка
  Items.Free;
end;

3.13.2. Классы для представления потока данных

В среде Delphi существует иерархия классов для хранения и последовательного ввода-вывода данных. Классы этой иерархии называются потоками. Потоки лучше всего представлять как файлы. Классы потоков обеспечивают различное физическое представление данных: файл на диске, раздел оперативной памяти, поле в таблице базы данных (таблица 3.1).

Класс Описание
TStream Абстрактный поток, от которого наследуются все остальные. Свойства и методы класса TStream образуют базовый интерфейс потоковых объектов.
THandleStream Поток, который хранит свои данные в файле. Для чтения-записи файла используется дескриптор (handle), поэтому поток называется дескрипторным. Дескриптор — это номер открытого файла в операционной системе. Его возвращают низкоуровневые функции создания и открытия файла.
TFileStream Поток, который хранит свои данные в файле. Отличается от ThandleStream тем, что сам открывает (создает) файл по имени, переданному в конструктор.
TMemoryStream Поток, который хранит свои данные в оперативной памяти. Моделирует работу с файлом. Используется для хранения промежуточных результатов, когда файловый поток не подходит из-за низкой скорости передачи данных.
TResourceStream Поток, обеспечивающий доступ к ресурсам в Windows-приложении.
TBlobStream Обеспечивает последовательный доступ к большим полям таблиц в базах данных.
Таблица 3.1. Классы потоков

Потоки широко применяются в библиотеке VCL и наверняка вам понадобятся. Поэтому ниже кратко перечислены их основные общие свойства и методы.

Общие свойства:

Общие методы:

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

          var
  Stream: TStream;
  S: AnsiString;
  StrLen: Integer;

begin// Создание файлового потока
  Stream := TFileStream.Create('Sample.Dat', fmCreate);
  ...
  // Запись в поток некоторой строки
  StrLen := Length(S) * SizeOf(Char);
  Stream.Write(StrLen, SizeOf(Integer)); // запись длины строки
  Stream.Write(S, StrLen);               // запись символов строки
  ...
  // Закрытие потока
  Stream.Free;
end;

3.14. Итоги

Теперь для вас нет секретов в мире ООП. Вы на достаточно серьезном уровне познакомились с объектами и их свойствами; узнали, как объекты создаются, используются и уничтожаются. Если не все удалось запомнить сразу — не беда. Возвращайтесь к материалам главы по мере решения стоящих перед вами задач, и работа с объектами станет простой, естественной и даже приятной. Когда вы достигните понимания того, как работает один объект, то автоматически поймете, как работают все остальные. Теперь мы рассмотрим то, с чем вы встретитесь очень скоро — ошибки программирования.


Любой из материалов, опубликованных на этом сервере, не может быть воспроизведен в какой бы то ни было форме и какими бы то ни было средствами без письменного разрешения владельцев авторских прав.
    Сообщений 35    Оценка 125 [+1/-0]         Оценить