PrIS
.pdf281
побічних ефектів при діях із синонімічними об'єктами часто приводить до неправильного доступу до пам'яті, і, гірше того, до непрогнозованих змін станів. Наприклад, якщо ми знищимо об'єкт через показник item3, то значення показника item4 виявиться безглуздим; ця ситуація називається повислим посиланням.
На рис. 15.1в ілюструється результат виконання таких дій: item2 = &item1; item4->move(item2->location());
У першому рядку створюється синонім: item2 вказує на той самий об'єкт, що й item1. У другому доступ до стану item1 отриманий через цей новий синонім. На жаль, при цьому відбувся „витік пам'яті”, – об'єкт, на який спочатку вказувало посилання item2, ніяк не йменується ні прямо, ні побічно, і його ідентичність загублена. У Smalltalk і CLOS пам'ять, відведена під об'єкти, буде знову повернута системі збирачем сміття. У мовах типу C++ така пам'ять не звільняється, поки не завершиться програма, що створила об'єкт. Такі „витоки пам'яті” можуть викликати незручність, великі збої, особливо, якщо програма повинна безупинно працювати тривалий час.
Копіювання, присвоєння і рівність. Структурна залежність має місце, коли об'єкт має кілька імен. У складних системах, реалізованих за допомогою об’єктно-орієнтованого підходу використання синонімів просто неминуче. Наприклад, розглянемо такі дві функції:
void highLight(DisplayItem& i);
void drag(DisplayItem i); // Небезпечно
Якщо викликати першу функцію з параметром item1, буде створений псевдонім: формальний параметр i означає вказівник на фактичний параметр, і, отже, item1 та i йменує той самий об'єкт під час виконання функції. При виклику другої функції з арґументом item1 їй передається новий об'єкт, який є копією item1: i позначає зовсім інший об'єкт, хоча із тим самим станом, що й item1. У C++ відрізняється передавання параметрів за посиланням й за значенням. Треба стежити за цим, інакше можна ненавмисно змінити копію об'єкту, бажаючи змінити сам об'єкт. Як ми побачимо далі, передавання об'єктів за посиланням у C++ необхідне для програмування поліморфної поведінки. У загальному випадку, передавання об'єктів за посиланням вкрай бажане для досить складних об'єктів, оскільки при цьому копіюється посилання, а не стан, а отже, досягається більша ефективність (за винятком тих випадків, коли передане значення дуже просте).
У деяких випадках, однак, мається на увазі саме копіювання. У мовах типу C++ семантику копіювання можна контролювати. Зокрема, ми можемо ввести копіювальний конструктор у визначення класу, як у
282
поданому далі фраґменті коду, який можна було б включити в опис класу
DisplayItem:
DisplayItem(const DisplayItem&);
У C++ копіювальний конструктор може бути викликаний явно (як частина опису об'єкту) або неявно (з передаванням об'єкту за значенням). Відсутність цього спеціального конструктора викликає копіювальний конструктор, що діє за замовчуванням, і який копіює об'єкт поелементно. Однак, коли об'єкт містить посилання або вказівники на інші об'єкти, така операція приводить до створення синонімів вказівників на об'єкти, що робить поелементне копіювання небезпечним. Ми пропонуємо емпіричне правило: дозволяти неявне розмноження шляхом копіювання тільки для об'єктів, що містять винятково примітивні значення, і робити його явним для складніших об'єктів.
Це правило пояснює те, що деякі мови називають "поверхневим" і "глибоким" копіюванням. Щоб копіювати об'єкт, у мові Smalltalk уведені методи shallowCopy (метод копіює тільки об'єкт, а стан є роздільним) і deepCopy (метод копіює об'єкт і стан, якщо потрібно – рекурсивно). Перевизначаючи ці методи для класів з аґреґацією, можна домагатися ефекту "глибокого" копіювання для одних частин об'єкту, і "поверхневого" копіювання для інших частин.
Присвоювання – це, загалом кажучи, копіювання. У C++ його зміст теж можна змінювати. Наприклад, ми могли б додати у визначення класу
DisplayItem такий рядок:
virtual DisplayItem& operator=(const DisplayItem&);
Цей оператор навмисно зроблений віртуальним, оскільки очікується, що підкласи будуть його перевизначати. Як і у випадку копіювального конструктора, копіювання можна зробити "глибоким" і "поверхневим". Якщо оператор присвоювання не перевизначений явно, то за замовчуванням об'єкт копіюється поелементно.
Із питанням присвоєння тісно пов'язане питання рівності. Хоча питання здається простим, рівність можна розуміти двома способами. Поперше, два імені можуть позначати той самий об'єкт. По-друге, це може бути рівність станів двох різних об'єктів. У прикладі на рис. 15.1в обидва варіанти тотожності будуть справедливі для item1 та item2. Однак для item2 та item3 вірним буде тільки другий варіант.
У C++ немає визначеного оператора рівності, тому ми повинні визначити рівність і нерівність, оголосивши ці оператори під час опису:
virtual int operator=(const DisplayItem&) const; int operator!=(const DisplayItem&) const;
Ми пропонуємо описувати оператор рівності як віртуальний (оскільки очікуємо, що підкласи можуть перевизначати його поведінку), і
283
описувати оператор нерівності як невіртуальний (бо хочемо, щоб він завжди був логічним запереченням рівності: підкласам це не треба перевизначати).
Аналогічним чином ми можемо створювати оператори порівняння об'єктів типу >= і <=.
Час життя об'єктів. Початком часу існування будь-якого об'єкту є момент його створення (відведення пам'яті), а закінченням – повернення відведеної пам'яті назад системі.
Об'єкти створюються явно або неявно. Є два способи створити їх явно. По-перше, це можна зробити при оголошенні (як це було з item1): тоді об'єкт розміщується у стеку. По-друге, як у випадку item3, можна розмістити об'єкт, тобто виділити йому пам'ять. У C++ у кожному випадку викликається конструктор, що виділяє відому йому кількість правильно ініціалізованої пам'яті під об'єкт. У Smalltalk цим займаються метакласи, про семантику яких ми поговоримо пізніше.
Часто об'єкти створюються неявно. Так, передавання параметра за значенням у C++ створює у стеку тимчасову копію об'єкту. Понад це, створення об'єктів транзитивне: створення об'єкту тягне за собою створення інших об'єктів, що входять у нього. Перевизначення семантики копіювального конструктора і оператора присвоєння у C++ дозволяє явне керування створенням і знищенням об'єкту. До того ж, у C++ можна перевизначати й оператор new, тим самим змінюючи політику керування пам'яттю для окремих класів.
У Smalltalk і деяких інших мовах, якщо втрачено останнє посилання на об'єкт, його забирає збирач сміття. У мовах без збирання сміття, типу C++, об'єкти, створені у стеку, знищуються при виході із блоку, в якому вони були визначені, але об'єкти, створені оператором new, продовжують існувати й займати місце в пам'яті: їх необхідно явно знищувати оператором delete. Якщо про об'єкт "забути", не знищити, це викличе, як вже було сказано вище, витік пам'яті. Якщо ж об'єкт спробують знищити повторно (наприклад, через інший вказівник), наслідком буде повідомлення про порушення пам'яті або повний крах системи.
При явному або неявному знищенні об'єкту у C++ викликається відповідний деструктор. Його завдання не тільки звільнити пам'ять, але й вирішити, що робити з іншими ресурсами, наприклад, з відкритими файлами. Деструктори не звільняють автоматично пам'ять, яку вони виділили оператором new, програмісти повинні самі звільнити її.
Знищення довготривалих об'єктів має трохи іншу семантику. Як наголошувалося вище, деякі об'єкти можуть бути довготривалими; під цим розуміється, що їхній час життя може перевищувати час життя програм, що їх породили. Зазвичай такі об'єкти є частиною якоїсь довгострокової об'єктної структури, тому питання їхнього життя й смерті
284
ставляться швидше до політики відповідної об’єктно-орієнтованої бази даних. У таких системах для забезпечення довготривалості найприйнятнішим є підхід, що базується на основі постійних класів. Всі об'єкти, яким ми хочемо забезпечити довготривалість, повинні успадковуватися від цих класів.
15.2. Відношення між об'єктами
15.2.1. Типи відношень
Самі по собі об'єкти не викликають жодної зацікавленості: лише у процесі взаємодії об'єктів реалізується система. Замість процесора, що перемелює структури даних, ми одержуємо співтовариство добре вихованих об'єктів, які чемно просять один одного про послуги. Літак, за визначенням – сукупність елементів, кожний з яких за своєю природою прагне впасти на землю, але за рахунок спільних безперервних зусиль він перемагає цю тенденцію. Він летить тільки завдяки погодженим зусиллям своїх компонентів.
Відношення між будь-якими двома об'єктами ґрунтуються на припущеннях, які один має щодо іншого: про операції, які можна виконувати, і про очікувану поведінку. Особливу зацікавленість об’єктоорієнтованого аналізу й проектування викликають два типи ієрархічних співвідношень об'єктів:
зв'язок;
аґреґація.
Ці два типи відношень називаються відношеннями старшинства й "батько/нащадок" відповідно.
15.2.2. Зв'язки
Семантика. Зв'язок – фізичне або концептуальне з'єднання між об'єктами. Об'єкт співпрацює з іншими об'єктами через зв'язки, що з'єднують його з ними. Інакше кажучи, зв'язок – це специфічне співставлення, через яке клієнт запитує послугу в об'єкту-сервера або через яке один об'єкт знаходить шлях до іншого.
На рис. 15.2 показано кілька різних зв'язків. Вони позначені лініями і зображають шляхи проходження повідомлень. Самі повідомлення показані стрілками (відповідно до їхнього напрямку) і позначені іменем операції. На рисунку об'єкт aController зв'язаний із двома об'єктами класу DisplayItem (об'єкти a і b). Своєю чергою, обидва, ймовірно, пов'язані з
285
aView, але нас цікавитиме лише один із цих зв'язків. Тільки вздовж зв'язку один об'єкт може послати повідомлення іншому.
Рис. 15.2. Зв'язки.
Зв'язок між об'єктами і передавання повідомлень зазвичай односторонні (як на рисунку; хоча технічно можуть бути і взаємними). Подібний поділ прав і обов'язків типовий для добре структурованих об'єктних систем. Звертаємо увагу на те, що хоча передане повідомлення ініціалізоване клієнтом (у цьому випадку aController), дані передаються в обох напрямках. Наприклад, коли aController викликає операцію move для пересилання даних об'єктові а, дані передаються від клієнта серверу, однак під час виконання операції isUnder над об'єктом b результат передається від сервера клієнтові.
Беручи участь у зв'язку, об'єкт може виконувати одну із трьох ролей.
Актор |
(Actor – це діяч, виконавець. А виконавець ролей, це і є |
|
актор). Об'єкт може впливати на інші об'єкти, але сам |
|
ніколи не піддається впливу інших об'єктів; у певному |
|
сенсі це відповідає поняттю активний об'єкт. |
Сервер |
Об'єкт може тільки піддаватися впливу зі сторони інших |
|
об'єктів, але він ніколи не відіграє роль об'єкту, який |
|
впливає. |
Аґент |
Такий об'єкт може відігравати як активну, так і пасивну |
|
роль; як правило, об'єкт-аґент створюється для |
|
виконання операцій в інтересах якого-небудь об'єкту- |
|
актора або аґента. |
На рис. 15.2 об'єкт aController відіграє роль актора, об'єкт a – аґента і об'єкт aView – сервера.
Приклад. У багатьох промислових процесах потрібна безперервна зміна температури. Необхідно підняти температуру до заданого значення, втримати її деякий заданий час і понизити до норми. Профіль зміни
286
температури в різних процесах різний; дзеркало телескопа треба охолоджувати дуже повільно, а сталь, що загартовується – дуже швидко.
Абстракція нагрівання має досить чітку поведінку, що дає нам право на опис такого класу. Спочатку визначимо тип, значення якого задає минулий час у хвилинах.
// Кількість хвилин, що минули typedef unsigned int Minute;
Тепер опишемо сам клас TemperatureRamp, що за змістом задає функцію часу від температури:
class TemperatureRamp { public:
TemperatureRamp(); virtual ~TemperatureRamp(); virtual void clear();
virtual void bind (Temperature, Minute); Temperature TemperatureAt (Minute); protected:
...
};
Витримуючи наш стиль, ми описали деякі з операцій віртуальними, бо очікуємо, що цей клас буде мати підкласи.
Насправді у сенсі поведінки нам треба щось більше, ніж просто залежність температури від часу. Нехай, наприклад, відомо, що на 60-й хвилині має досягнутися температура 80ºС, а на 180-й – 50ºС. Запитується, якою вона має бути на 120-й хвилині? Це вимагає лінійної інтерполяції, отже, необхідна від абстракції поведінка ускладнюється.
Разом з тим, керування нагрівачем, що підтримує необхідний профіль, ми від цієї абстракції не вимагаємо. Ми розділяємо поняття, при яких необхідна поведінка досягається взаємодією трьох об'єктів: екземпляра TemperatureRamp, нагрівача й контролера. Клас
TemperatureController можна визначити так: class TemperatureController { public:
TemperatureController(Location);
~TemperatureController();
void process(const TemperatureRamp&);
Minute schedule(const TemperatureRamp&) const; private:
...
};
287
Тип Location був визначений у розділі 14. Зауважте, що ми не очікуємо успадкування від цього класу й тому не повідомляємо в ньому ніяких віртуальних функцій.
Операція process забезпечує основну поведінку цієї абстракції; її призначення – передати графік зміни температури нагрівачу, встановленому в конкретному місці. Наприклад, оголосимо:
TemperatureRamp growingRamp; TemperatureController rampController(7);
Тепер задамо пару точок і завантажимо план у контролер: growingRamp.bind (250, 60); growingRamp.bind(150, 180); rampController.process(growingRamp);
Уцьому прикладі rampController – аґент, який відповідає за виконання температурного плану, він використовує об'єкт growingRamp як сервер. Цей зв'язок проявляється хоча б у тому, що rampController явно одержує growingRamp як параметр однієї зі своїх операцій.
Одне зауваження із приводу нашого стилю. На перший погляд може здатися, що наші абстракції – лише об'єктні оболонки для елементів, отриманих у результаті звичайної функціональної декомпозиції. Приклад операції schedule показує, що це не так. Об'єкти класу TemperatureController мають достатньо інтелекту, щоб визначати розклад для конкретних профілів, і ми включаємо цю операцію як додаткову поведінку нашої абстракції. У деяких енергоємних технологіях (наприклад, плавлення металів) можна істотно виграти, якщо враховувати охолодження установки й тепло, що залишається після попереднього плавлення. Оскільки існує операція schedule, клієнт може запитати об'єкт TemperatureController, щоб той рекомендував оптимальний момент запуску наступного нагрівання.
Видимість. Нехай є два об'єкти А і В і зв'язок між ними. Щоб А міг послати повідомлення В, треба, щоб А у якомусь сенсі бачив В. Ми можемо не піклуватися про це на стадії аналізу, але коли справа доходить до реалізації системи, ми повинні забезпечити видимість зв'язаних об'єктів.
Упопередньому прикладі об'єкт rampController бачить об'єкт growingRamp, оскільки обоє вони оголошені в одній області видимості й тому, що growingRamp передається об'єкту rampController як параметр. Загалом є наступні чотири способи забезпечення видимості.
Сервер ґлобальний щодо клієнта.
Сервер (або вказівник на нього) переданий клієнтові як параметр операції.
Сервер є частиною клієнта.
288
Сервер локально породжується клієнтом під час виконання якоїсь операції.
Який саме із цих способів вибрати, залежить від тактики проектування.
Синхронізація. Коли один об'єкт посилає через зв'язок повідомлення іншому, пов'язаному з ним об’єкту, то кажуть, що вони синхронізуються. У багатопотоковій системі об'єкти вимагають витонченішої схеми передавання повідомлень, щоб розв'язати проблеми взаємного виключення, які типові для паралельних систем. Активні об'єкти самі собою виконуються як потоки, тому присутність інших активних об'єктів на них зазвичай не впливає. Якщо ж активний об'єкт має зв'язок із пасивним, можливі наступні три підходи до синхронізації:
послідовний – семантика пасивного об'єкту забезпечується при наявності тільки одного активного процесу;
захищений – семантика пасивного об'єкту забезпечується при наявності багатьох потоків керування, але активні клієнти повинні домовитися й забезпечити взаємне виключення;
синхронний – семантика пасивного об'єкту забезпечується при наявності багатьох потоків керування; взаємне виключення забезпечує сервер.
Всі об'єкти, описані в цьому розділі, були послідовними.
15.2.3. Аґреґація
Семантика. У той час, як зв'язки позначають рівноправні або "клієнт-серверні" відношення між об'єктами, аґреґація описує відношення цілого й частини, що приводить до відповідної ієрархії об'єктів, причому, ідучи від цілого (аґреґата), ми можемо прийти до його частин (атрибутів). У цьому сенсі аґреґація – спеціалізований окремий випадок асоціації. На рис. 15.3 об'єкт rampController має зв'язок з об'єктом growingRamp, як і атрибут h класу Heater (нагрівач). У цьому випадку rampController – ціле, a h – його частина. Інакше кажучи, h – частина стану rampController. Виходячи з rampController, можна знайти відповідний нагрівач. Однак через h не можна знайти об'єкт, який його містить, (його також називають контейнером), якщо тільки відомості про нього не є випадково частиною стану h.
289
growingRamp
temperatureAt()
h:Heater
Рис. 15.3. Аґреґація.
Аґреґація може означати фізичне входження одного об'єкту в інший, але не обов'язково. Літак складається із крил, двигунів, шасі й інших частин. З іншого боку, відношення акціонера до його акцій – це аґреґація, що не передбачає фізичного включення. Акціонер монопольно володіє своїми акціями, але вони в нього не входять фізично. Це, безсумнівно, відношення аґреґації, але скоріше концептуальне, ніж фізичне за своєю природою.
Вибираючи одне із двох – зв'язок або аґреґацію – треба мати на увазі таке. Аґреґація іноді переконливіша, оскільки дозволяє сховати частини в цілому. Іноді – навпаки краще використовувати зв'язок, оскільки він слабший і менш обмежений. Приймаючи рішення, треба зважити все.
Об'єкт, що є атрибутом іншого об'єкту (аґреґату), має зв'язок зі своїм аґреґатом. Через цей зв'язок аґреґат може посилати йому повідомлення.
Приклад 15.1. Додамо у специфікацію класу TemperatureController опис нагрівача:
Heater h;
Після цього кожний об'єкт TemperatureController буде мати свій нагрівач. Відповідно до нашого визначення класу Heater у попередньому розділі ми повинні включити нагрівач при створенні нового контролера, тому що сам цей клас не передбачає конструктора за замовчуванням. Ми могли б визначити конструктор класу TemperatureController у такий спосіб:
TemperatureController::TemperatureController(Loc ation 1)
: h(1) {}
290
15.3. Природа класів
15.3.1. Що таке клас?
Поняття класу й об'єкту настільки тісно позв'язані, що неможливо говорити про об'єкт безвідносно до його класу. Однак існує важлива відмінність цих двох понять. У той час, як об'єкт позначає конкретну сутність, яка існуює в часі й у просторі, клас визначає лише абстракцію істотного в об'єкті. Отже, можна говорити про клас "Ссавці", що містить характеристики, загальні для всіх ссавців. Для вказівки на конкретного представника ссавців необхідно сказати "це – ссавець".
У загально-зрозумілих термінах можна дати таке визначення класу: група, множина або вид із загальними властивостями або загальною властивістю, різновидами, які відрізняються від усього іншого якістю, можливостями або умовами. У контексті об’єктно-орієнтованого аналізу дамо таке визначення класу:
клас – це якась множина об'єктів, що мають спільну структуру й спільну поведінку.
Будь-який конкретний об'єкт є просто екземпляром класу. Що ж не є класом? Об'єкт не є класом, хоча надалі ми побачимо, що клас може бути об'єктом. Об'єкти, які не зв'язані спільною структурою й поведінкою, не можна об'єднати у клас, тому що за означенням вони не зв'язані між собою нічим, крім того, що всі вони об'єкти.
Зазначимо, що класи, як їх розуміють у більшості наявних мов програмування, необхідні, але не достатні для декомпозиції складних систем. Деякі абстракції настільки складні, що не можуть бути виражені в термінах простого опису класу. Наприклад, на досить високому рівні абстракції графічний інтерфейс користувача, база даних або система обліку як ціле, це явні об'єкти, але не класи. Краще вважати їх якимись сукупностями (кластерами) класів, що співпрацюють. Такі кластери називають компонентами.
15.3.2. Інтерфейс і реалізація
Складні задачі треба розділити на багато маленьких й передоручити їх дрібним субпідрядникам. Ніде ця ідея не проявляє себе так яскраво, як у проектуванні класів.
За своєю природою, клас – це ґенеральний контракт між абстракцією і всіма її клієнтами. Виразником зобов'язань класу служить його інтерфейс, причому в мовах із сильною типізацією потенційні порушення контракту можна виявити вже на стадії компіляції.
