1. Классы и объекты
2. Инкапсуляция
3. Наследование
4. Виртуальные методы и полиморфизм
5. Абстрактные классы
На программу можно смотреть как на модель некоторой части реального или воображаемого мира. Этому миру присущи объекты, и естественно ожидать, что в программе им могут соответствовать программные объекты. Объекты реального мира чрезвычайно сложны и ещё более сложны и запутаны их взаимосвязи. Было бы наивно считать, что реальный мир полнокровно можно представить в образах взаимодействующих программных объектов, но даже некоторое приближение в этом направлении может оказаться полезным на практике. Именно такой подход и реализуется в объектно-ориентированном программировании (ООП). Рассмотрим каким образом представляются объекты в современных языках программирования, к числу которых относится и Turbo Pascal. В этом нам поможет пример программы, в которой вводится понятие "Банковский счёт".
program Account1; type CAccount = object fNumber: Integer; fBalance: Integer; procedure Deposit(amount: Integer); procedure Withdraw(amount: Integer); end; var a: CAccount; procedure CAccount.Deposit(amount: Integer); begin fBalance := fBalance + amount end;
procedure CAccount.Withdraw(amount: Integer); begin fBalance := fBalance - amount end; begin a.fNumber := 5; a.fBalance := 0; {Инициализация объекта} a.Deposit(100); {Положить на счёт 100$} a.Withdraw(75); {Снять со счёта 75$} a.Withdraw(50); {И ещё снять 50$} WriteLn('Account # ',a.fNumber,': balance is ',a.fBalance,'$.'); end.
Выход программы Account1.pas:
Account # 5: balance is -25$.
В программе Account1.pas понятие "Банковский счёт" вводится с помощью описания типа CAccount, которое начинается ключевым словом object и заканчивается ключевым словом end. Это и есть описание класса, которое по виду напоминает описание записи, но кроме полей ещё и содержит заголовки процедур. Эти процедуры - их принято называть методами - определяют операции, которые можно выполнять с объектами данного типа. В этой связи принято считать, что поля выражают внутреннее состояние объекта, а методы - его поведение, суть которого состоит в изменении внутреннего состояния объекта.
Описание класса содержит только заголовки методов. Сами методы описываются в разделе процедур программы. Их именам должен предшествовать префикс имени класса. Чтобы создать объект заданного класса необходимо объявить переменную, имя типа которой есть имя класса. Понятия "класс" и "тип" в ООП считаются эквивалентными. Доступ к полям объекта выполняется точно также как к полям записи. Вызов метода объекта похож на обращение к его полю, но вместо имени поля указывается имя вызываемого метода вместе с необходимыми фактическими параметрами.
В примере определён класс с двумя полями - fNumber ("номер счёта") и fBalance ("сумма на счёте"), и с двумя методами - Deposit ("положить на счёт") и Withdraw ("снять со счёта"). Переменная a - это объект класса CAccount.
В контекст метода кроме его описания входит и описание класса. В пределах контекста метода имена полей и методов класса можно указывать без префикса имени класса. Так обычно и поступают.
Предыдущий пример демонстрирует подход к программированию с использованием объектов, который вряд ли обрадует любого банкира. Отрицательная сумма на счёте - результат обескураживающий. Выход из положения состоит в использовании принципа инкапсуляции, суть которого состоит в том, что при составлении программы сознательно отказываются от прямого обращения к полям объектов - обращение возможно только через посредничество методов, причём последние должны быть составлены таким образом, чтобы при их выполнении объект не смог оказаться в неправильном состоянии.
Ниже приведен более реалистичный пример описания и реализации класса CAccount. Кроме того, это описание помещено в отдельный модуль, что делает его использование более удобным.
unit Account; interface type TpCAccount = ^CAccount; CAccount = object fNumber: Integer; fBalance: Integer; constructor Init(number: Integer); function getBalance: Integer; function Deposit(amount: Integer): Integer; function Withdraw(amount: Integer): Integer; procedure printAccount; end; implementation constructor CAccount.Init(number: Integer); begin fNumber := number; fBalance := 0; end; function CAccount.getBalance: Integer; begin getBalance := fBalance; end; function CAccount.Deposit(amount: Integer): Integer; begin if amount <= 0 then begin Deposit := -1; Exit end; fBalance := fBalance + amount; Deposit := fBalance; end; function CAccount.Withdraw(amount: Integer): Integer; begin if (amount <= 0) or (amount > fBalance) then begin Withdraw := -1; Exit end; fBalance := fBalance - amount; Withdraw := fBalance; end; procedure CAccount.printAccount; begin WriteLn('Account # ', fNumber, ': balance is ', fBalance, '$.'); end; end.
Нововведения достаточно очевидны. Так как вызов методов Deposit и Withdraw может означать попытку перевода объекта в неправильное состояние, то в этих случаях самим кодом этих методов предусмотрена блокировка изменения его состояния с возвратом кода завершения -1.
Новое ключевое слово constructor используется для описания особых методов, которые используются для инициализации объекта после его создания. Конструктор не возвращает никакого значения. В некоторых случаях возникает необходимость в выполнении особых методов и непосредственно перед удалением объекта - для описания таких методов введено ключевое слово destructor. Деструктор не имеет параметров и тоже не возвращает никакого значения. Особое значение конструкторы и деструкторы приобретают при динамическом выделении памяти для объектов и её освобождении с помощью стандартных прцедур new и dispose.
Тип TpCAccount определён для удобства работы с объектами класса CAccount в случае их размещения в динамической памяти.
Программа Account2.pas иллюстрирует использование модуля Account и предоставляет возможности для тестирования описанного в нём класса CAccount.
program Account2; uses Account; var a: CAccount; Amount: Integer; Rest: Integer; OpCode: Char; begin a.Init(7); WriteLn; WriteLn('OpCodes: d - Deposit, w - Withdraw, g - getBalance, e - Exit.'); repeat Write('Enter OpCode & Amount: '); Read(OpCode); if OpCode = 'e' then Exit; if OpCode in ['d','w'] then Read(Amount); ReadLn; case OpCode of 'd': Rest := a.Deposit(Amount); 'w': Rest := a.Withdraw(Amount); 'g': Rest := a.getBalance; end; Write(' Rest = ',Rest); if Rest < 0 then Write(': Wrong operation!'); WriteLn; Write(' '); a.printAccount; until False; end.
Пример диалога программы Account2.pas с клиентом:
OpCodes: d - Deposit, w - Withdraw, g - getBalance, e - Exit. Enter OpCode & Amount: g Rest = 0 Account # 7: balance is 0$. Enter OpCode & Amount: w 100 Rest = -1: Wrong operation! Account # 7: balance is 0$. Enter OpCode & Amount: d 50 Rest = 50 Account # 7: balance is 50$. Enter OpCode & Amount: w 25 Rest = 25 Account # 7: balance is 25$. Enter OpCode & Amount: d 50 Rest = 75 Account # 7: balance is 75$. Enter OpCode & Amount: w 100 Rest = -1: Wrong operation! Account # 7: balance is 75$. Enter OpCode & Amount: e
При попытке выполнить операцию с недопустимыми параметрами программа возвращает сообщение "Wrong operation!"
Классы можно использовать для создания новых классов. Так, например, в описание нового класса может входить поле уже существующего класса . Такой метод создания новых классов называется композицией. Но особую мощь ООП придаёт метод создания новых классов, который получил название наследование. Его суть состоит в том, что новый класс - его называют производным - кроме собственных полей и методов может использовать поля и методы базового класса. Производный класс в свою очередь тоже может выступить в роли базового класса для другого производного класса и так далее - таким образом могут возникать целые иерархии классов.
Предположим, что банк решил ввести новый вид счетов, которые отличаются от старых тем, что на них можно положить сумму только однократно. Совершенно ясно, что нет никакой необходимости создавать для этого новый класс с нуля - достаточно слегка "подправить" уже существующий. Пусть имя нового класса будет CmAccount. Его описание поместим в модуль mAccount.
unit mAccount; interface uses Account; type TpCmAccount = ^CmAccount; CmAccount = object(CAccount) fPut: Boolean; constructor Init(number: Integer); function Deposit(amount: Integer): Integer; function Withdraw(amount: Integer): Integer; procedure printAccount; end; implementation constructor CmAccount.Init(number: Integer); begin fPut := False; CAccount(number); end; function CmAccount.Deposit(amount: Integer): Integer; var Res: Integer; begin if fPut then begin Deposit := -1; Exit end; Res := CAccount.Deposit(amount); if Res > 0 then fPut := True; Deposit := Res; end; function CmAccount.Withdraw(amount: Integer): Integer; begin if not fPut or (fBalance <> amount) then begin Withdraw := -1; Exit end; fBalance := 0; Withdraw := fBalance; end; procedure CmAccount.printAccount; begin Write('m'); CAccount.printAccount end;
end.
Обратите внимание на то, что имя базового класса указывается в скобках сразу за ключевым словом object. В новом классе определено дополнительное поле fPut, в котором отмечается факт того, что на счёт положена какая-то сумма. При определении новых методов можно свободно обращаться как к новым, так и к наследуемым полям. Также можно вызывать методы нового класса и методы классов-предшественников по линии наследования.
Имена новых методов могут совпадать со старыми - в этом случае они перекрывают их, а это означает, что эти методы для нового класса приобретают другой смысл. Если старый метод не перекрыт, то в новом классе он сохраняет тот же смысл. В примере перекрыты методы Init, Deposit, Withdraw и printAccount, а метод getBalance не перекрыт. В случае перекрытия для вызова метода класса-предшественника его имя необходимо снабдить префиксом из имени класса. Соответствующие примеры можно видеть в методах Init, Deposit и printAccount.
Программа для тестирования класса CmAccount почти не отличается от программы Accoun2.pas.
Процесс обратный наследованию в ООП называют обобщением. Интересно отметить, что при обобщении общее количество полей и методов уменьшается. Очевидно, что оставшиеся в процессе обобщения поля и методы должны представлять наиболее важные родовые особенности базового класса, присущие абсолютно всем его классам-потомкам.
Чтобы метод стал виртуальным достаточно после его заголовка в описании класса записать ключевое слово virtual. Особенность виртуальных методов проявляется при их вызове по указателю. Пусть объект относится к одному из классов-потомков базового класса заданного указателя. Если обычные методы вызываются строго в соответствии с типом указателя, то виртуальные методы вызываются в соответствии с классом объекта, а не типом указателя! Для примера воспользуемся уже известными классами CAccount и CmAccount, но внесём в них некоторые изменения в соответствии с обсуждаемой темой.
unit Account; ... CAccount = object fNumber: Integer; fBalance: Integer; constructor Init(number: Integer); destructor Done; virtual; function getBalance: Integer; function Deposit(amount: Integer): Integer; virtual; function Withdraw(amount: Integer): Integer; virtual; procedure printAccount; virtual; end; implementation ... destructor CAccount.Done; begin WriteLn('Done Account # ',fNumber,'.') end; ...
unit mAccount; ... CmAccount = object(CAccount) fPut: Boolean; constructor Init(number: Integer); destructor Done; virtual; function Deposit(amount: Integer): Integer; virtual; function Withdraw(amount: Integer): Integer; virtual; procedure printAccount; virtual; end; implementation ... destructor CAccount.Done; begin WriteLn('Done mAccount # ',fNumber,'.') end; ...
Мы сделали некоторые методы наших классов виртуальными. Попутно приведён пример записи деструкторов. Остальные части модулей Account и mAccount остались без изменения.
Далее рассмотрим программу Account4.pas.
program Account4; uses Account, mAccount; const m = 3; var pa: array[1..m] of TpCAccount; i: Integer; begin pa[1] := new(TpCAccount,Init(5)); pa[2] := new(TpCmAccount,Init(7)); pa[3] := new(TpCAccount,Init(11)); for i:=1 to m do WriteLn(i,pa[i]^.Deposit(200):5); for i:=1 to m do WriteLn(i,pa[i]^.Withdraw(150):5); for i:=1 to m do pa[i]^.printAccount; for i:=1 to m do dispose(pa[i],Done); end.
Вот программы Account4.pas:
1 200 2 200 3 200 1 50 2 -1 3 50 Account # 5: balance is 50$. mAccount # 7: balance is 200$. Account # 11: balance is 50$. Done Account # 5. Done mAccount # 7. Done Account # 11.
Сначала обсудим новые особенности стандартных процедур new и dispose, которые введены специально для работы с объектами. Новый формат процедуры new предусматривает указание вторым параметром имени конструктора, который автоматически вызывается сразу же после резервирования памяти для объекта. Первый параметр может быть указателем, тип которого определяет тип создаваемого объекта, или именем типа объекта. Во втором случае значение указателя на объект new возвращает как функция. Аналогичным образом второй параметр процедуры Dispose указывает имя деструктора, который автоматически вызывается непосредственно перед освобождением памяти занимаемой объектом.
Массив pa в программе - это массив указателей на объекты базового класса CAccount. Раздел операторов программы начинается с создания трёх объектов разных классов - двух класса CAccount и одного класса CmAccount. Указатели на эти объекты записываются в соответствующие элементы массива pa. Далее записаны четыре оператора цикла, при выполнении которых с каждым объектом-счётом выполняется последовательно четыре операции: 1) добавляется 200$, 2) снимается 150$, 3) печатается, 4) удаляется. Анализ выхода программы показывает, что счёт номер 7 ведёт себя не так как другие. Это объясняется тем, что фактически для объектов разных классов вызываются присущие им методы, а они, естественно, разные! Это в равной мере относится и к вызову виртуальных деструкторов.
Если бы рассматриваемые методы были бы обычными, то для всех объектов вызывались методы класса CAccount, так как указатель, по которому выполняется вызов, относится к классу TpCAccount. В этом случае объект класса CmAccount просто обобщался бы до объекта базового класса CAccount.
Сочетание имени метода, последовательности типов его формальных параметров и типа возвращаемого значения называется его сигнатурой. Обязательным следует считать то обстоятельство, что при перекрытии виртуального метода в производном классе он должен иметь такую же сигнатуру, как и в базовом классе и тоже должен быть снабжён описателем virtual! В противном случае это будет уже какой-то другой новый метод производного класса. Отметим также, что перед вызовом любого виртуального метода какого-нибудь объекта, предварительно надо выполнить его конструктор.
Говорят, что совокупность методов класса образует его интерфейс. В нашем примере классы CAccount и CmAccount имеют одинаковые интерфейсы. Это, конечно, в общем случае не обязательно, но в данном случае сделано совершенно сознательно для того, чтобы продемонстрировать ещё одно понятие ООП - полиморфизм. Полиморфизм даёт возможность одинаковым образом "управлять" объектами разных классов. Именно полиморфизм обеспечивает в конечном итоге значительную долю преимуществ ООП! Полиморфизм позволяет программисту абстрагироваться от многочисленных нюансов поведения объектов разных классов и это существенно упрощает создание программы.
В практике программирования с использованием объектов большую роль играет приведение типов указателей на объекты и их совместимость по присваиванию. Указателю базового класса разрешается присваивать указатель одного из производных классов. Присваивание в обратном порядке требует явного приведения типа присваиваемого указателя. Такая операция не безопасна и выполнять её можно лишь при наличии уверенности, что с данным указателем действительно связан объект нужного класса.
Класс называется абстрактным, если хотя бы один его виртуальный метод не имеет фактической реализации. Объекты абстрактного класса создавать бессмысленно, а часто и просто нельзя. Но он может служить базовым классом для построения классов вполне реальных объектов. В следующем примере вводится абстрактный класс CFig ("фигура") с виртуальным методом Area ("площадь"). Абстракное понятие "фигура" со свойством "площадь" наполняется реальным содержанием в производных классах CRect ("прямоугольник") и CCirc ("круг"). Теперь со всеми этими "прямоугольниками" и "кругами" мы можем работать как с объектами единого абстрактного класса "фигура".
unit Fig; interface type TpCFig = ^CFig; CFig = object {Абстрактный класс} constructor Init; function Area: Real; virtual; {Фиктивный метод} end; TpCRect = ^CRect; CRect = object(CFig) fW,fH: Real; constructor Init(w,h: Real); function Area: Real; virtual; end; TpCCirc = ^CCirc; CCirc = object(CFig) fR: Real; constructor Init(r: Real); function Area: Real; virtual; end; implementation constructor CFig.Init; begin end; function CFig.Area: Real; {Реализация фиктивного метода} begin Area := -1 end; { абстрактного класса } constructor CRect.Init(w,h: Real); begin fW := w; fH := h end; function CRect.Area: Real; begin Area := fW*fH end; constructor CCirc.Init(r: Real); begin fR := r end; function CCirc.Area: Real; begin Area := Pi*Sqr(fR) end; end.
Программа TestFig.pas даёт возможность проверить модуль Fig.
program TestFig; uses Fig; var pf: array[1..4] of TpCFig; i: Integer; begin pf[1] := new(TpCRect, Init(1,2)); pf[2] := new(TpCCirc, Init(1)); pf[3] := new(TpCRect, Init(2,2)); pf[4] := new(TpCFig, Init); for i := 1 to 4 do begin Write(pf[i]^.Area:6:2); Dispose(pf[i]) end; WriteLn; end.
Выход программы TestFig.pas:
2.00 3.14 4.00 -1.00
Мы полагаем, что значение площади фигуры равное -1.00, это просто способ сообщить об ошибке в программе - нам не следовало бы создавать объект класса CFig, так как это не существующая в природе абстракция!
Copyright г Барков Валерий Андреевич, 2000