Краеугольные камни ООП
КРАЕУГОЛЬНЫЕ КАМНИ ООП
ФОРМУЛА ОБЪЕКТА
Авторы надеются, что читатель помнит кое-что из второй главы и такие понятия, как тип данных, процедура, функция, запись для него не в новинку. Это прекрасно. Та вот, в конце 60-х годов кому-то пришло в голову объединить эти понятия и то, что получилось, назвать объектом. Рассмотрение данных в неразрывной связи с методами их обработки позволило вывести формулу объекта:
Объект = Данные + Операции
На основании этой формулы была разработана методология объектно-ориентированного программирования (ООП).
ПРИРОДА ОБЪЕКТА
Об объектах можно думать как о полезных существах, которые «живут» в вашей программе и коллективно решают некоторую прикладную задачу. Вы, как Демиург, лепите этих существ, распределяете между ними обязанности и устанавливаете правила их взаимодействия.
В общем случае каждый объект «помнит» необходимую информацию, «умеет» выполнять некоторый набор действий и характеризуется набором свойств. То, что объект «помнит», хранится в его полях. То, что объект «умеет делать», реализуется в виде его внутренних процедур и функций, называемых методами. Свойства объектов аналогичны свойствам, которые мы наблюдаем у обычных предметов. Значения свойств можно устанавливать и читать. Программно свойства реализуются через поля и методы.
Например, объект «кнопка» имеет свойство «цвет». Значение цвета кнопка запоминает в одном из своих полей. При изменении значения свойства «цвет» вызывается метод, который перерисовывает кнопку.
Кстати, этот пример позволяет сделать важный вывод: свойства имеют первостепенное значение для программиста, использующего объект. Чтобы понять суть и назначение объекта, вы обязательно должны знать его свойства, иногда — методы, очень редко — поля (объект и сам знает, что с ними делать).
ОБЪЕКТЫ И КОМПОНЕНТЫ
Когда прикладные программы создавались для операционной системы MS-DOS и были консольно-ориентированными, объекты казались пределом развития программирования, поскольку были идеальным средством разбиения сложных задач на простые подзадачи. Однако с появлением графических систем, в частности Windows, программирование пользовательского интерфейса резко усложнилось. Программист в какой-то мере стал дизайнером, а визуальная компоновка и увязка элементов пользовательского интерфейса (кнопок, меток, строк редактора) начали отнимать основную часть времени. И тогда программистам пришла в голову идея визуализировать объекты, объединив программную часть объекта с его видимым представлением на экране дисплея в одно целое. То, что получилось в результате, было названо компонентом.
Компоненты в Delphi — это особые объекты, которые являются строительными кирпичиками среды визуальной разработки и приспособлены к визуальной установке свойств. Чтобы превратить объект в компонент, первый разрабатывается по определенным правилам, а затем помещается в Палитру Компонентов. Конструируя приложение, вы берете компоненты из Палитры Компонентов, располагаете на форме и устанавливаете их свойства в окне Инспектора Объектов. Внешне все выглядит просто, но чтобы достичь такой простоты, потребовалось создать механизмы, обеспечивающие функционирование объектов-компонентов уже на этапе проектирования приложения! Все это было придумано и блестяще реализовано в среде Delphi. Таким образом, компонентный подход значительно упростил создание приложений с графическим пользовательским интерфейсом и дал толчок развитию новой индустрии компонентов.
В данной главе мы рассмотрим лишь вопросы создания и использования объектов, Чуть позже мы научим вас превращать объекты в компоненты.
КЛАССЫ ОБЪЕКТОВ
Каждый объект всегда принадлежит некоторому классу. Класс — это обобщенное (абстрактное) описание множества однотипных объектов. Объекты являются конкретными представителями своего класса, их принято называть экземплярами класса. Например, класс СОБАКИ — понятие абстрактное, а экземпляр этого класса МОЙ ПЕС БОБИК — понятие конкретное.
ТРИ КИТА ООП
Весь мир ООП держится на трех китах: инкапсуляции, наследовании и полиморфизме. Для начала о них надо иметь только самое общее представление.
Наблюдаемое в объектах объединение данных и операций в одно целое было обозначено термином инкапсуляция (первый кит ООП). Применение инкапсуляции сделало объекты похожими на маленькие программные модули и обеспечило сокрытие их внутреннего устройства. Для объектов появилось понятие интерфейса, что значительно повысило их надежность и целостность.
Второй кит ООП — наследование. Этот простой принцип означает, что если вы хотите создать новый класс, лишь немногим отличающийся от того, что уже существует, то нет необходимости в переписывании заново всех полей, методов и свойств. Вы объявляете, что новый класс является потомком (или дочерним классом) имеющегося класса, называемого предком (или родительским классом), и добавляете к нему новые поля, методы и свойства. Иными словами добавляется то, что нужно для перехода от общего к частному. Процесс порождения новых классов на основе других классов называется наследованием. Новые классы имеют как унаследованные признаки, так и, возможно, новые. Например, класс СОБАКИ унаследовал многие свойства своих предков — ВОЛКОВ.
Третий кит — это полиморфизм. Он означает, что в производных классах вы можете изменять работу уже существующих в базовом классе методов. При этом весь программный код, управляющий объектами родительского класса, пригоден для управления объектами дочернего класса без всякой модификации. Например, вы можете породить новый класс кнопок с рельефной надписью, переопределив метод отрисовки кнопки. Новую кнопку можно «подсунуть» вместо стандартной в какую-нибудь подпрограмму, вызывающую отрисовку кнопки. При этом подпрограмма «думает», что работает со стандартной кнопкой, но на самом деле кнопка принадлежит производному классу и отображается в новом стиле.
Пока достаточно самого поверхностного понимания всех приведенных выше понятий, ниже мы рассмотрим их подробнее и покажем, как они реализованы в Delphi.
КЛАССЫ
Delphi поддерживает две модели представления объектов — старую и новую. Старая модель существует лишь для совместимости с более ранними версиями компилятора, в частности с Borland Pascal 7.0, поэтому мы не будем ее рассматривать. Все, что сказано ниже, относится к новой модели представления объектов, более мощной и богатой по своим возможностям.
Для поддержки ООП в язык Object Pascal введены объектные типы данных, с помощью которых одновременно описываются данные и операции над ними. Объектные типы называют классами, а их экземпляры — объектами.
Классы объектов определяются в секции type глобального блока. Описание класса начинается словом class и заканчивается словом end. По форме объявления классы похожи на обычные записи, но помимо полей данных могут содержать объявления пользовательских процедур и функций. Такие процедуры и функции обобщенно называют методами, они предназначены для выполнения над объектами различных операций.
Приведем пример объявления класса:
type TDiskGauge = class { измеритель дискового пространства} DriveLetter: Char; { буква дискового накопителя} PercentCritical: Integer; { критический процент свободного пространства} function GetPercentFree: Integer; procedure CheckStatus; end;
Заголовки методов, следующие за списком полей, играют роль предварительных (forward) объявлений. Программный код методов помещается ниже определения класса и будет приведен позже.
Класс обычно описывает сущность, моделируемую в программе. Например, класс TDiskGauge описывает измеритель дискового ресурса. Класс содержит два поля: DriveLetter — буква находящегося под наблюдением накопителя, и PercentCritical — процент свободного пространства на диске, с которым работает программа. Когда объем свободных ресурсов снижается до этого порога, пользователю выдается звуковое предупреждение. Функция GetPercentFree определена как метод работы над любым объектом класса TDiskGauge и возвращает процент свободного пространства на диске. Процедура CheckStatus служит для проверки состояния ресурса и выдачи звукового предупреждения.
Обратите внимание, что приведенное выше описание является не чем иным, как декларацией интерфейса для управления объектами класса TDiskGauge. Реализация методов GetPercentFree и CheckStatus отсутствует, но для создания и использования экземпляров класса она пока и не нужна. В этом как раз и состоит сила инкапсуляции, Которая делает объекты аналогичными программным модулям. Для использования модуля необходимо изучить лишь его интерфейсную часть, раздел реализации для этого изучать не требуется. Поэтому дальше от описания класса мы перейдем не к реализации методов, а к созданию на их основе объектов.
ОБЪЕКТЫ
Чтобы от описания класса перейти к объекту, следует выполнить соответствующее объявление в секции var:
var DiskGauge: TDiskGauge;
При работе с обычными типами данных этого объявления было бы достаточно для получения экземпляра типа. Однако объекты в Delphi являются динамическими данными, т.е. распределяются в «куче» (heap). Поэтому переменная DiskGauge — это просто ссылка на экземпляр объекта, которого физически еще не существует. Чтобы сконструировать объект класса TDiskGauge и связать с ним переменную DiskGauge, нужно в текст программы поместить следующий оператор (statement):
DiskGauge: = TDiskGauge.Create;
Create — это так называемый конструктор объекта; он всегда присутствует в классе и служит для создания и инициализации экземпляров. К сведению профессионалов заметим, что в памяти выделяется место только для полей объекта. Методы, так же как и обычные процедуры и функции, помещаются в область кода программы; они умеют работать с любыми экземплярами своего класса и в памяти никогда не дублируются,
После создания объект можно использовать в программе — читать и устанавливать его поля, вызывать методы. Доступ к полям и методам объекта происходит с помощью уточненных имен, например:
DiskGauge.DriveLetter: = 'С'; DiskGauge. PercentCritical: = 10; DiskGauge.CheckStatus;
Кроме того, как и при работе с записями, допустимо использование оператора with, например:
with DiskGauge do begin DriveLetter: = 'С'; PercentCritical: = 10; CheckStatus; end;
Если наступает время, когда объект становится не нужен в программе, он должен быть удален вызовом специального метода Destroy, например:
DiskGauge.Destroy;
Destroy — это так называемый деструктор объекта; он присутствует в классе наряду с конструктором и служит для удаления объекта из динамической памяти. После вызова деструктора переменная DiskGauge становится несвязанной и не должна использоваться для доступа к полям и методам уже несуществующего объекта. Чтобы отличать в программе связанные объектные переменные от несвязанных, последние следует инициализировать значением nil. Например, в следующем фрагменте обращение к деструктору Destroy выполняется только в том случае, если объект реально существует.
DiskGauge: = nil; if DiskGauge <> nil then DiskGauge.Destroy;
Вызов деструктора для несуществующих объектов недопустим и при выполнении программы приведет к ошибке. Чтобы избавить программистов от лишних ошибок, в объекты ввели предопределенный метод Free, который следует вызывать вместо деструктора. Метод Free сам вызывает деструктор Destroy, но только в том случае, если значение объектной переменной не равно nil. Поэтому последнюю строчку в приведенном выше примере можно переписать следующим образом:
DiskGauge.Free;
Значение одной объектной переменной можно присвоить другой. При этом объект не копируется в памяти, а вторая переменная просто связывается с тем же объектом, что и первая:
var DiskGaugel, DiskGauge2: TDiskGauge; begin { Переменные DiskGauge1 и DiskGauge2 не связаны с объектом} DiskGauge1: = TDiskGauge.Create;{Переменная DiskGauge1 связана с объектом, а DiskGauge2 — нет} DiskGauge2: = DiskGauge1;{Обе переменные связаны с одним объектом} DiskGauge2.Free; {Объект удален, переменные DiskGauge1 и DiskGauge2 с ним не связаны} end;
Объекты могут выступать в программе не только в качестве переменных, но также элементов массивов, полей записей, параметров процедур и функций. Кроме того, они могут служить полями других объектов. Во всех этих случаях программист фактически оперирует указателями на экземпляры объектов в динамической памяти. Следовательно, объекты априори приспособлены для создания сложных динамических структур данных, таких как списки и деревья. Указатели на объекты для этого не нужны.
В некоторых случаях требуется, чтобы объекты разных классов содержали ссылки друг на друга. Возникает проблема: объявление первого класса будет содержать ссылку на еще не определенный класс. Она решается с помощью упреждающего объявления.
type TGaugeList = class; { предварительное объявление класса TGaugeList } TDiskGauge = class Owner: TGaugeList; ... TGaugeList = class Gauges: array [0..2] of TDiskGauge; end;
Первое объявление класса TGaugeList называется предварительным (от англ. forward). Оно необходимо для того, чтобы компилятор нормально воспринял объявление поля Owner в классе TDiskGauge.
Итак, вы уже имеете некоторое представление об объектах, перейдем теперь к вопросу реализации их методов.
МЕТОДЫ
Процедуры и функции, предназначенные для выполнения над объектами действий, называются методами. Предварительное объявление методов выполняется при описании класса в секции interface модуля, а их программный код записывается в секции implementation. Однако в отличие от обычных процедур и функций заголовки методов должны иметь уточненные имена, т.е. содержать наименование класса. Приведем, например, возможную реализацию методов в классе TDiskGauge:
function TDiskGauge.GetPercentFree: Integer; { uses SysUtils; } var Drive: Byte; begin Drive := Ord(DriveLetter) - Ord('A') + 1; Result := DiskFree(Drive) * 100 div DiskSize(Drive); end; procedure TDiskGauge.CheckStatus; { uses Windows; } begin if GetPercentFree <= PercentCritical then Beep; end;
Обратите внимание, что внутри методов обращения к полям и другим методам выполняются как к обычным переменным и подпрограммам без уточнения экземпляра объекта. Такое упрощение достигается путем использования в пределах метода псевдопеременной Self (стандартный идентификатор). Физически Self представляет собой дополнительный неявный параметр, передаваемый в метод при вызове. Этот параметр и указывает экземпляр объекта, к которому данный метод применяется. Чтобы пояснить сказанное, перепишем метод CheckStatus, представив его в виде обычной процедуры:
procedure TDiskGauge_CheckStatus (Self: TDiskGauge); begin with Self do if GetPercentFree <= PercentCritical then Beep; end;
Согласитесь, что метод CheckStatus выглядит более предпочтительно, чем процедура TDiskGauge_CheckStatus.
Практика показывает, что псевдопеременная Self редко используется в явном виде. Ее необходимо применять только тогда, когда при написании метода может возникнуть какая-либо двусмысленность для компилятора, например при использовании одинаковых имен и для локальных переменных, и для полей объекта.
Если выполнить метод CheckStatus
DiskGauge.CheckStatus;
то произойдет проверка состояния дискового ресурса. При этом неявный параметр Self будет содержать значение переменной DiskGauge. Такой вызов реализуется обычными средствами процедурного программирования приблизительно так:
TDiskGauge_CheckStatus(DiskGauge);
КОНСТРУКТОРЫ И ДЕСТРУКТОРЫ
Особой разновидностью методов являются конструкторы и деструкторы. Напомним, что конструкторы создают, а деструкторы — разрушают объекты. Создание объекта включает выделение памяти под экземпляр и инициализацию его полей, а разрушение — очистку полей и освобождение памяти.
Очевидно, что выполняемые при инициализации и деинициализации действия специфичны для каждого конкретного класса объектов. По этой причине Object Pascal позволяет переопределить стандартные конструктор Create и деструктор Destroy для выполнения любых полезных действий. Можно даже определить несколько конструкторов и деструкторов (имена им назначает сам программист), чтобы обеспечить различные способы создания и разрушения объектов.
Объявление конструкторов и деструкторов похоже на объявление обычных методов с той лишь разницей, что вместо зарезервированного слова procedure (или function) используются слова constructor и destructor:
type TDiskGauge = class DriveLetter: Char; PercentCritical: Integer; constructor Create; destructor Destroy; ... end;
Приведем их возможную реализацию:
constructor TDiskGauge.Create; begin DriveLetter := 'C'; PercentCritical := 10; end; destructor TDiskGauge.Destroy; begin { Разрушение встроенных объектов и освобождение динамических данных} end;
Конструктор иногда имеет параметры, в которых передаются исходные значения полей объекта. Если объект содержит встроенные объекты или другие динамические данные, то конструктор — это как раз то место, где их нужно создавать.
Конструктор применяется к классу или к объекту. Если он применяется к классуDiskGauge: = TDiskGauge.Create;
то выполняется следующая последовательность действий:
• | в динамической памяти выделяется место для нового объекта; |
• | выделенная память заполняется нулями; в результате все числовые поля и поля порядкового типа приобретают нулевые значения, строковые поля становятся пустыми, а поля, содержащие указатели и объекты, получают значение nil; |
• | затем выполняются заданные программистом действия конструктора; |
• | ссылка на созданный объект возвращается в качестве значения конструктора; тип возвращаемого значения совпадает с типом класса, использованного при вызове (в нашем примере это тип TDiskGauge). |
Если конструктор применяется к объекту (DiskGauge.Create;)то новый объект не создается, а происходит переинициализация полей существующего. В этом случае конструктор не возвращает никакого значения.
Деструктор уничтожает объект, к которому применяется (DiskGauge.Destroy;)
В результате:
• | выполняется заданный программистом код деинициализации; |
• | освобождается занимаемая объектом динамическая память. |
В теле деструктора обычно должны уничтожаться встроенные объекты и динамические данные, созданные конструктором.
СВОЙСТВА
ПОНЯТИЕ СВОЙСТВА
Помимо полей и методов в объектах существуют свойства. При работе с объектом свойства выглядят как поля: они принимают значения и участвуют в выражениях. Но в отличие от полей свойства не занимают места в памяти, а операции их чтения и записи ассоциируются с обычными полями или методами. Это позволяет создавать необходимые побочные эффекты при обращении к свойствам. Например, присваивание свойству Visible значения True вызовет отображение графического объекта на экране, а значения False — его исчезновение.
Объявление свойства выполняется с помощью зарезервированного слова property, например:
type TDiskGauge = class FPercentCritical: Integer; procedure SetPercentCritical (Percent: Integer); property PercentCritical: Integer read FpercentCritical write SetPercentCritical; end;
После слова read указывается поле или метод, к которому происходит обращение при чтении значения свойства, а после слова write — поле или метод, к которому происходит обращение при записи значения свойства. Например, чтение свойства PercentCritical заменяется на чтение поля FPercentCritical, а установка свойства — на вызов метода SetPercentCritical. Чтобы имена свойств не совпадали с именами полей, последние принято писать с буквы F (от англ. field).
Атрибуты read и write называются спецификаторами доступа. Если один из них опущен, то значение свойства можно либо только читать (задан спецификатор read), либо только записывать (задан спецификатор write). В следующем примере объявлено свойство, значение которого можно только читать:
type TDiskGauge = class property PercentFree: Integer read GetPercentFree; end;
Обращение к свойствам выглядит в программе как обращение к полям:
var DiskGauge: TDiskGauge; A : Integer; А := DiskGauge.PercentCritical; { эквивалентно А := DiskGauge.FPercentCritical;} DiskGauge.PercentCritical := A + 10; { эквивалентно DiskGauge.SetPercentCritical(A + 10);}
Однако в отличие от полей свойства не имеют адреса в памяти, поэтому к ним запрещено применять операцию @. Кроме того, их нельзя передавать в var-параметрах процедур и функций.
Технология объектно-ориентированного программирования в Delphi предписывает избегать прямого обращения к полям, создавая вместо этого соответствующие свойства. Это упорядочивает работу с объектами, изолируя их данные от непосредственной модификации. В будущем внутренняя структура класса, которая иногда является достаточно сложной, может быть изменена с целью повышения эффективности работы программы. При этом потребуется переработать только методы чтения и записи значений свойств; внешний интерфейс класса не изменится.
МЕТОДЫ ПОЛУЧЕНИЯ И УСТАНОВКИ СВОЙСТВ
Методы чтения и записи свойств подчиняются определенным правилам. Метод чтения свойства — это всегда функция, возвращающая значение того же типа, что и тип свойства. Метод записи свойства — это обязательно процедура, принимающая параметр того же типа, что и тип свойства. В остальном это обычные методы объекта. Примерами методов чтения и записи свойств являются GetPercentFree и SetPercentCritical в классе TDiskGauge:
type TDiskGauge = class FPercentCritical: Integer; function GetPercentFree: Integer; procedure SetPercentCritical (Value: Integer); property PercentFree: Integer read GetPercentFree; property PercentCritical: Integer read FPercentCritical write SetPercentCritical; end;
Использование методов для получения и установки свойств позволяет проверить корректность значения свойства, сделать дополнительные вычисления, установить значения зависимых полей и т.д. Например, в методе SetPercentCritical целесообразно сделать проверку на то, что устанавливаемое значение находится в диапазоне от 0 до 100:
procedure TDiskGauge.SetPercentCritical (Value: Integer); begin if (Value >= 0) and (Value < 100) then FpercentCritical := Value; end;
МЕТОДЫ, ОБСЛУЖИВАЮЩИЕ НЕСКОЛЬКО СВОЙСТВ
0дин и тот же метод может использоваться для получения (установки) значений нескольких свойств одного типа. В этом случае каждому свойству назначается целочисленный индекс, который передается в метод первым параметром. В следующем примере методы Get и Set обслуживают три свойства: GaugeA, GaugeB и GaugeC:
type TGaugeList = class FGauges: array [0..2] of TDiskGauge; ... function Get (Index: Integer): TDiskGauge; procedure Set (Index: Integer; Value: TDiskGauge); ... property GaugeA: TDiskGauge index 0 read Get write Set; property GaugeB: TDiskGauge index 1 read Get write Set; property GaugeC: TDiskGauge index 2 read Get write Set; ... end; function TGaugeList.Get (Index: Integer): TDiskGauge; begin Result := FGauges [Index]; end; procedure TGaugeList.Set (Index: Integer; Value: TDiskGauge); begin FGauges [Index] := Value; end;
Обращения к свойствам GaugeA, GaugeB и GaugeC заменяются на соответствующие Вызовы методов Get и Set:
var GaugeList: TGaugeList; DiskGauge: TDiskGauge; ... GaugeList.GaugeC := DiskGauge; { эквивалентно GaugeList.Set (2, DiskGauge) } GaugeList.GaugeC.CheckStatus; { эквивалентно GaugeList.Get(2).CheckStatus } ...
СВОЙСТВА-МАССИВЫ
Кроме обычных свойств в объектах существуют свойства-массивы (array properties), Свойство-массив — это индексированное множество свойств. В виде свойства-массива удобно, например, представить множество измерителей ресурсов в классе TGaugeList
type TGaugeList = class ... property Gauges[Index: Integer]: TdiskGauge read Get write Set; ... end;
Обратите внимание, что методы Get и Set обслуживают и свойство-массив Gauges, и индексированные свойства GaugeA, GaugeB и GaugeC. Если в описании обычных свойств могут участвовать поля, то в описании свойств-массивов разрешено использовать только методы.
Основная выгода от применения свойств-массивов — возможность выполнения итераций с помощью цикла for, например:
var GaugeList: TGaugeList; I: Integer; ... for I := 0 to 2 do with GaugeList do Gauges [I] .CheckStatus; ...
Свойства-массивы могут быть многомерными. В этом случае методы чтения и записи их элементов имеют столько же индексных параметров соответствующего типа, что и массив.
Свойства-массивы имеют два важных отличия от обычных массовов:
• | их индексы не ограничиваются диапазоном и могуптаеть-любой тип-данных, а не только Integer; например, можно создать свойство-массив, в котором индексами будут строки; обращение к такому свойству могло бы выглядеть так: |
• операции над свойством-массивом в целом запрещены; разрешены операции только с его элементами.
СВОЙСТВО-МАССИВ КАК ОСНОВНОЕ СВОЙСТВО ОБЪЕКТА
Свойство-массив можно сделать основным свойством объектов данного класса. Для этого в его описание добавляется слово default:
type TGaugeList = class ... property Gauges [Index: Integer]: TdiskGauge read Get write Set; default; ... end;
Такое объявление свойства Gauges позволяет рассматривать сам объект класса TGaugeList как массив и опускать имя свойства-массива при обращении к нему из программы, например:
var GaugeList: TGaugeList; I: Integer; ... for I := 0 to 2 do GaugeList [I] := nil; { эквивалентно GaugeList.Gauges[I] := nil; }
Следует помнить, что только свойства-массивы могут быть основными свойствами объектов; для обычных свойств это недопустимо.