Добавил:
Upload Опубликованный материал нарушает ваши авторские права? Сообщите нам.
Вуз: Предмет: Файл:
Проектування інформаційних систем.doc
Скачиваний:
95
Добавлен:
21.09.2019
Размер:
28.77 Mб
Скачать

14.2.5. Ієрархія

Що таке ієрархія? Абстракція - річ корисна, але завжди, крім найпростіших ситуацій, число абстракцій у системі набагато перевищує наші розумові можливості. Інкапсуляція дозволяє в якійсь ступені усунути цю перешкоду, забравши з поля зору внутрішній зміст абстракцій. Модульність також спрощує задачу, поєднуючи логічно зв'язані абстракції в групи. Але цього виявляється недостатньо.

Значне спрощення в розумінні складних завдань досягається за рахунок утворення з абстракцій ієрархічної структури. Визначимо ієрархію в такий спосіб:

Ієрархія - це впорядкування абстракцій, розташування їх за рівнями.

Основними видами ієрархічних структур стосовно складних систем є структура класів (ієрархія "is-a") і структура об'єктів (ієрархія "part of").

Приклади ієрархії: одиночне спадкування. Важливим елементом об’єктно-орієнтованих систем і основним видом ієрархії "is-a" є згадувана вище концепція спадкування. Спадкування означає таке відношення між класами (відношення батько/нащадок), коли один клас запозичить структурну або функціональну частину одного або декількох інших класів (відповідно, одиничне й множинне спадкування). Іншими словами, спадкування створює таку ієрархію абстракцій, у якій підкласи успадковують будову від одного або декількох суперкласів. Часто підклас добудовує або переписує компоненти вищестоящого класу.

Семантично, спадкування описує відношення типу "is-a". Наприклад, ведмідь є ссавцем, будинок є нерухомість і "швидке сортування" є алгоритм, що сортує. Таким чином, спадкування породжує ієрархію "узагальнення-спеціалізація", у якій підклас являє собою спеціалізований окремий випадок свого суперкласу.

Розглянемо тепер різні види рослин, вирощуваних у нашій городній системі. Ми вже ввели узагальнене подання абстрактного плану вирощування рослин. Однак різні культури вимагають різні плани. При цьому плани для фруктів схожі один на одного, але відрізняються від планів для овочів або квітів. Має сенс ввести на новому рівні абстракції узагальнений "фруктовий" план, що включає вказівки із запилення й складання врожаю. Ось, як буде виглядати на C++ визначення плану для фруктів, як спадкоємця загального плану вирощування.

// Тип урожаю

typedef unsigned int Yield;

class FruitGrowingPlan : public GrowingPlan {

public:

FruitGrowingPlan(char* name);

virtual ~FruitGrowingPlan();

virtual void establish(Day, Hour, Condition&);

void scheduleHarvest(Day, Hour);

Boolean isHarvested() const;

unsigned daysUntilHarvest() const;

Yield estimatedYield() const;

protected:

Boolean repHarvested;

Yield repYield;

};

Це означає, що план вирощування фруктів FruitGrowingPlan є різновидом плану вирощування GrowingPlan. У нього додані параметри repHarvested і repYield, визначені чотири нові функції й перевизначена функція establish. Тепер ми могли б продовжити спеціалізацію - наприклад, визначити на базі "фруктового" плану "яблучний" клас AppleGrowingPlan.

У ієрархії успадкування загальна частина структури й поведінки зосереджена в найзагальнішому суперкласі. Із цієї причини говорять про успадкування, як про ієрархію узагальнення-спеціалізація. Суперкласи при цьому відбивають найзагальніші, а підкласи - спеціалізовані абстракції, в яких члени суперкласу можуть бути доповнені, модифіковані й навіть сховані. Принцип успадкування дозволяє спростити вираження абстракцій, робить проект менше громіздким і виразнішим. Під час відсутності успадкування кожний клас стає самостійним блоком і повинен розроблятися "з нуля". Класи втрачають спільність, оскільки кожний програміст реалізує їх по-своєму. Стрункість системи тоді досягається тільки за рахунок дисциплінованості програмістів. Успадкування дозволяє вводити в обіг нові програми, аналогічно до того, як ми навчаємо новачків новим поняттям - порівнюючи нове із чимось вже відомим.

Принципи абстрагування, інкапсуляції й ієрархії перебувають між собою в якомусь „здоровому” конфлікті. Абстрагування даних створює непрозорий бар'єр, що приховує стан і функції об'єкта; принцип успадкування вимагає відкрити доступ і до стану, і до функцій об'єкта для довільних об'єктів. Для будь-якого класу звичайно існують два види клієнтів: об'єкти, які маніпулюють із екземплярами даного класу, і підкласи-спадкоємці. Існують три способи порушення інкапсуляції через успадкування: підклас може одержати доступ до змінних екземпляра свого суперкласу, викликати закриту функцію й, нарешті, звернутися прямо до суперкласу свого суперкласу. Різні мови програмування по-різному знаходять компроміс між успадкуванням і інкапсуляцією; найгнучкішою щодо цього є C++. У ній інтерфейс класу може бути розділений на три частини: закриту (private), видиму тільки для самого класу; захищену (protected), видиму також і для підкласів; і відкриту (public), видиму для всіх.

Приклади ієрархії: множинне успадкування. У попередньому прикладі розглядалося одиничне спадкування, коли підклас FruitGrowingPlan був створений тільки з одного суперкласу GrowingPlan. Бувають випадки, коли корисно реалізувати успадкування від декількох суперкласів. Припустимо, що потрібно визначити клас, що представляє різновиди рослин.

class Plant {

public:

Plant(char* name, char* species);

virtual ~Plant();

void setDatePlanted(Day);

virtual establishGrowingConditions(const Condition&);

const char* name() const;

const char* species() const;

Day datePlantedt) const;

protected:

char* repName;

char* repSpecies;

Day repPlanted;

private:

...

};

Кожний екземпляр класу plant буде містити ім'я, вид і дату посадки. Крім того, для кожного виду рослин можна задавати особливі оптимальні умови вирощування. Ми хочемо, щоб ця функція перевизначалася підкласами, тому вона оголошена віртуально під час реалізації в C++. Три параметри оголошені як захищені, тобто вони будуть доступні й класу, і підкласам (закрита частина специфікації доступна тільки самому класу).

Вивчаючи предметну область, ми робимо висновок, що різні групи культивуючих рослин - квіти, фрукти й овочі, - мають свої особливі властивості, істотні для технології їхнього вирощування. Наприклад, для квітів важливо знати час цвітіння й дозрівання насінь. Аналогічно, час збору врожаю важливо для абстракцій фруктів і овочів. Створимо два нових класи - квіти (Flower) і фрукти-овочі (FruitVegetable); вони обоє успадковуються від класу Plant. Однак деякі квіткові рослини мають плоди! Для цієї абстракції прийдеться створити третій клас, FlowerFruitVegetable, що буде успадковуватися від класів Flower і FruitVegetablePlant.

Щоб не було надмірності, у цьому випадку використаємо множинне успадкування. Спочатку давайте опишемо окремо квіти й фрукти-овочі.

class FlowerMixin {

public:

FlowerMixin(Day timeToFlower, Day timeToSeed);

virtual ~FlowerMixin();

Day timeToFlower() const;

Day timeToSeed() const;

protected:

...

};

class FruitVegetableMixin {

public:

FruitVegetableMixin(Day timeToHarvest);

virtual ~FruitVegetableMixin();

Day timeToHarvest() const;

protected:

...

};

Ми спеціально описали ці два класи без успадкування. Вони ні від кого не успадковуються і спеціально призначені для того, щоб їх підмішували (звідки й ім'я Mixin) до інших класів. Наприклад, опишемо троянду:

class Rose :

public Plant,

public FlowerMixin...

А ось морква:

class Carrot :

public Plant,

public FruiteVegetableMixin {};

В обох випадках класи успадковуються від двох суперкласів: екземпляри підкласу Rose включають структуру й поведінку із класів Plant та FlowerMixin. Тепер визначимо вишню, у якої є як квіти, так і плоди:

class Cherry :

public Plant,

public FlowerMixin, FruitVegetableMixin...

Множинне успадкування - річ нехитра, але воно ускладнює реалізацію мов програмування. Є дві проблеми - конфлікти імен між різними суперкласами й повторне успадкування. Перший випадок, це коли у двох або більшому числі суперкласів визначені поле або операція з однаковим ім'ям. В C++ цей вид конфлікту повинен бути явно дозволений вручну, а в Smalltalk береться те, що зустрічається першим. Повторне успадкування, це коли клас успадковує два класи, а вони порізно успадковують деякий четвертий клас. Виходить ромбічна структура успадкування й треба вирішити, чи повинен самий нижній клас одержати одну або дві окремі копії самого верхнього класу? У деяких мовах повторне успадкування заборонене, в інших конфлікт вирішується "вольовим порядком", а в C++ це залишається на розсуд програміста. Віртуальні базові класи використовуються для заборони дублювання повторюваних структур, в іншому випадку в підкласі з'являться копії полів і функцій і буде потрібна явна вказівка походження кожної з копій.

Множинним успадкуванням часто зловживають. Наприклад, солодка вата - це окремий випадок солодощів, але ніяк не вати. Часто погано сформовані структури множинного успадкування можуть бути зведені до єдиного суперкласу плюс агрегація інших класів підкласом.

Приклади ієрархії: агрегація. Якщо ієрархія "is_а" визначає відношення "узагальнення/спеціалізація", то відношення "part_of" (частина) вводить ієрархію агрегації. Ось приклад.

class Garden {

public:

Garden();

virtual ~Garden();

protected:

Plant* repPlants[100];

GrowingPlan repPlan;

};

Це - абстракція городу, що складається з масиву рослин і плану вирощування. Маючи справу з такими ієрархіями, ми часто говоримо про рівні абстракції, які вперше запропонував Дейкстра. В ієрархії класів вищестояща абстракція є узагальненням, а нижчестояща - спеціалізацією. Тому ми говоримо, що клас Flower перебуває на вищому рівні абстракції, ніж клас Plant. В ієрархії "part_of" клас перебуває на вищому рівні абстракції, ніж кожний, що використовувався при його реалізації. Так клас Garden стоїть на вищому рівні, ніж клас Plant.

Агрегація є у всіх мовах, що використовує структури або записи, які складаються з різнотипних даних. Але в об’єктно-орієнтованому програмуванні вона має більшу потужність: агрегація дозволяє фізично згрупувати логічно зв'язані структури, а успадкування з легкістю копіює ці загальні групи в різні абстракції.

У зв'язку з агрегацією виникає проблема приналежності об'єктів. У нашому абстрактному городі одночасно росте багато рослин, і від видалення або заміни одного з них город не стає іншим городом. Якщо ми знищуємо город, рослини залишаються (їх звісно можна пересадити). Інакше кажучи, город і рослини мають свої окремі й незалежні строки життя; ми досягли цього завдяки тому, що город містить не самі об'єкти Plant, а показники на них. Об'єкт GrowingPlan внутрішньо пов'язаний з об'єктом Garden і не існує незалежно. План вирощування фізично міститься у кожному екземплярі городу й знищується разом з ним. Докладніше про семантику приналежності ми будемо говорити в наступній главі.

14.2.6. Типізація

Що таке типізація? Поняття типу взяте з теорії абстрактних типів даних. Тип - це точна характеристика властивостей, включаючи структуру й поведінку, яка відноситься до деякої сукупності об'єктів. Для наших цілей досить вважати, що терміни тип і клас взаємозамінні. (Тип і клас не цілком те саме; у деяких мовах їх розрізняють. Наприклад, ранні версії мови Trellis/Owl дозволяли об'єкту мати й клас, і тип. Навіть в Smalltalk об'єкти класів SmallInteger, LargeNegativeInteger, LargePositiveInteger відносяться до одного типу Integer, хоча до різних класів. Досить сказати, що клас реалізує поняття типу). Проте, про поняття типу варто обговорити окремо, оскільки воно виражає зміст абстрагування в зовсім іншому світлі. Зокрема, ми стверджуємо, що:

Типізація - це спосіб захиститися від використання об'єктів одного класу замість іншого, або принаймні керувати таким використанням.

Типізація змушує нас виражати наші абстракції так, щоб мова програмування, використовувана у реалізації, підтримувала дотримання прийнятих проектних рішень. Такий спосіб контролю важливий для програмування "у великому".

Ідея узгодження типів займає в понятті типізації центральне місце. Наприклад, візьмемо фізичні одиниці виміру. Ділячи відстань на час, ми очікуємо отримати швидкість, а не вагу. У множенні температури на силу сенсу немає, а в множенні відстані на силу - є. Все це приклади сильної типізації, коли прикладна область накладає правила й обмеження на використання й сполучення абстракцій.

Приклади сильної й слабкої типізації. Конкретна мова програмування може мати сильний або слабкий механізм типізації, і навіть не мати взагалі ніякого, залишаючись об’єктно-орієнтованою. Наприклад, в Eiffel дотримання правил використання типів контролюється сильно, - операція не може бути застосована до об'єкта, якщо вона не зареєстрована в його класі або суперкласі. У сильно типізованих мовах порушення узгодження типів може бути виявлене під час трансляції програми. З іншого боку, в Smalltalk типів немає: під час виконання будь-яке повідомлення можна послати будь-якому об'єкту, і якщо клас об'єкта (або його надклас) не розуміє повідомлення, то генерується повідомлення про помилку. Порушення узгодження типів може не виявитися під час трансляції й звичайно проявляється як помилка виконання. C++ тяжіє до сильної типізації, але в цій мові правила типізації можна ігнорувати або придушити повністю.

Розглянемо абстракцію різних типів ємкостей, які можуть використовуватися в нашій теплиці. Ймовірно, у ній є ємкості для води й для мінеральних добрив; хоча перші призначені для рідин, а інші для сипучих речовин, вони мають досить багато загального, щоб утворити ієрархію класів. Почнемо з типів.

// Число, що позначає рівень від 0 до 100 відсотків

typedef float Level;

Оператори typedef в C++ не вводять нових типів. Зокрема, і Level і Concentration - насправді інші назви для float, і їх можна вільно використовувати в обчисленнях. У цьому змісті C++ має слабку типізацію: значення примітивних типів, таких, як int або float нерозрізнені в межах даного типу. Ada і Object Pascal надають сильну типізацію для примітивних типів. В Ada можна оголосити самостійним типом інтервал значень або підмножину з обмеженою точністю.

Побудуємо тепер ієрархію класів для ємкостей:

class StorageTank {

public:

StorageTank();

virtual ~StorageTank();

virtual void fill();

virtual void startDraining();

virtual void stopDraining();

Boolean isEmpty() const;

Level level() const;

protected:

...

};

class WaterTank : public StorageTank{

public:

WaterTank();

virtual ~WaterTank();

virtual void fill();

virtual void startDraining();

virtual void stopDraining();

void startHeating();

void stopHeating();

Temperature currentTemperature() const;

protected:

...

};

class NutrientTank : public StorageTank {

public:

NutrientTank();

virtual ~NutrientTank();

virtual void startDrainingt();

virtual void stopDraining();

protected:

...

};

Клас StorageTank - це базовий клас ієрархії. Він забезпечує загальну структуру й поведінку для всіх ємкостей, можливість їх наповнювати або спустошувати. Класи WaterTank (ємкість для води) і NutrientTank (для добрив) успадковують властивості StorageTank, та частково перевизначають їх і додають дещо своє: наприклад, клас WaterTank вводить нову поведінку, пов'язану з температурою.

Припустимо, що ми маємо наступні описи:

StorageTank s1, s2;

WaterTank w;

NutrientTank n;

Зверніть увагу, що такі змінні як s1, s2, w або n - це не екземпляри відповідних класів. Насправді, це просто імена, якими ми позначаємо об'єкти відповідних класів: коли ми говоримо "об'єкт s1" ми насправді маємо на увазі екземпляр StorageTank, який позначається змінною s1. Ми повернемося до цього тонкого питання в наступній главі.

Під час перевірки типів класів, C++ типізована набагато суворіше. Під цим розуміється, що вирази, які містять виклики операцій, перевіряються на узгодження типів під час компіляції. Наприклад наступний програмний код є правильним:

Level l = s1.level();

w.startDrainingt();

n.stopDraining();

Дійсно, такі селектори є в класах, до яких належать відповідні змінні. Навпаки, наступний програмний код є неправильним й викличе помилку компіляції:

s1.startHeating(); // Неправильно

n.stopHeating(); // Неправильно

Таких функцій немає ні в самих класах, ні в їхніх суперкласах. Запис

n.fill();

вірний: функції fill немає у визначенні NutrientTank, але вона є у вищому за ієрархією класі.

Отже, сильна типізація змушує нас дотримуватися правил використання абстракцій, тому вона тим корисніша, чим більший проект. Однак у неї є й інша сторона. А саме, навіть невеликі зміни в інтерфейсі класу вимагають перекомпіляції всіх її підкласів. Крім того, не маючи параметризованих класів, важко уявити собі, як можна було б створити спільноти різнорідних об'єктів. Припустимо, що ми хочемо ввести абстракцію інвентарного списку, у якому збирається все майно, пов'язане з теплицею. Звичайна для С ідіома застосовна й в C++: потрібно використовувати клас-контейнер, що містить показники на void, тобто на об'єкти довільного типу.

class Inventory {

public:

Inventory();

~Inventory();

void add(void*);

void remove(void*);

void* mostRecent() const;

void apply(Boolean (*)(void*));

private:

...

};

Операція apply - це так званий ітератор, що дозволяє застосувати яку-небудь операцію до всіх об'єктів у списку. Докладніше про ітератори дивіться у наступній главі.

Маючи екземпляр класу Inventory, ми можемо додавати й знищувати показники на об'єкти будь-яких класів. Але ці дії небезпечні з погляду типів - у списку можуть виявитися як відчутні об'єкти (ємкості), так і невловимі (температура або план вирощування), що порушує нашу матеріальну абстракцію. Більше того, ми могли б внести до списку об'єкти класів WaterTank і TemperatureSensor, і через необережність, очікуючи від функції mostRecent об'єкта класу WaterTank одержати StorageTank.

Загалом кажучи, для вирішення цієї проблеми є два підходи. По-перше, можна зробити контейнерний клас, безпечний з погляду типів. Щоб не маніпулювати з нетипізованими показниками void, ми могли б визначити інвентаризаційний клас, що маніпулює тільки з об'єктами класу TangibleAsset (відчутного майна), а цей клас буде додаватися до всіх класів, таке майно додається, наприклад, до WaterTank, але не до GrowingPlan. Тим самим можна відсікти проблему першого роду, коли неправомірно змішуються об'єкти різних типів. По-друге, можна ввести перевірку типів під час ходу виконання, для того, щоб знати, з об'єктом якого типу ми маємо справу в цей момент. Наприклад, в Smalltalk можна запитувати в об'єктів їхній клас. В C++ така можливість в стандарт донедавна не входила, хоча на практиці, звичайно, можна ввести в базовий клас операцію, що повертає код класу (рядок або значення перечисленого типу). Однак для цього треба мати дуже серйозні причини, оскільки перевірка типу в ході виконання послаблює інкапсуляцію. Як буде показано в наступному розділі, необхідність перевірки типу можна пом’якшити, використовуючи поліморфні операції.

У мовах із сильною типізацією гарантується, що всі вирази будуть погоджені за типом. Що це значить, краще пояснити на прикладі. Такі присвоювання припустимі:

s1 = s2;

s1 = w;

Перше присвоювання припустиме, оскільки змінні мають той самий клас, а друге - оскільки присвоювання йде знизу нагору за типами. Однак у другому випадку відбувається втрата інформації (відома в C++ як "проблема зрізання"), тому що клас змінної w, WaterTank, семантично багатший, ніж клас змінної s1, тобто StorageTank.

Такі присвоювання неправильні:

w = s1; // Неправильно

w = n; // Неправильно

У першому випадку неправильність в тому, що присвоювання йде зверху донизу за ієрархією, а в другому класи навіть не перебувають у стані підпорядкованості.

Іноді необхідно перетворити типи. Наприклад, подивимося на наступну функцію:

void checkLevel(const StorageTank& s);

Ми можемо звести значення вищестоящого класу до підкласу в тому і тільки в тому випадку, якщо фактичним параметром у виклику виявився об'єкт класу WaterTank.

Розгонимо такий випадок:

if (((WaterTank&)s).currentTemperature() < 32.0) ...

Цей вираз погоджений за типами, але небезпечний. Якщо під час виконання програми раптом виявиться, що змінна s позначала об'єкт класу NutrientTank, приведення типу дасть непередбачений результат під час виконання. Загалом кажучи, перетворень типу треба уникати, оскільки вони часто роблять порушення прийнятої системи абстракцій.

Переваги строго типізованих мов:

  • відсутність контролю типів може приводити до загадкових збоїв у програмах під час їхнього виконання,

  • у більшості систем процес редагування-компіляція-відлагодження складний, і раннє виявлення помилок є неоціненно корисним,

  • оголошення типів поліпшує документування програм,

  • багато компіляторів генерують ефективніший об'єктний код, якщо їм явно відомі типи.

Мови, у яких типізація відсутня, мають більшу гнучкість, але навіть у таких мовах програмісти зазвичай не знають, які об'єкти будуть аргументами і які будуть повертатися. На практиці надійність мов зі строгою типізацією з надлишком компенсує деяку втрату в гнучкості в порівнянні з нетипізованими мовами.

Приклади типізації: статичне й динамічне зв'язування. Сильна й статична типізація - різні речі. Строга типізація стежить за відповідністю типів, а статична типізація (інакше названа статичним або раннім зв'язуванням) визначає час, коли імена зв'язуються з типами. Статичний зв'язок означає, що типи всіх змінних і виразів відомі під час компіляції; динамічне зв'язування (назване також пізнім зв'язуванням) означає, що типи невідомі до моменту виконання програми. Концепції типізації й зв'язування є незалежними, тому в мові програмування може бути: типізація - сильна, зв'язування - статичне (Ada), типізація - сильна, зв'язування - динамічне (C++, Object Pascal), або й типів немає, і зв'язування динамічне (Smalltalk). Мова CLOS займає проміжне місце між C++ і Smalltalk: визначення типів, зроблені програмістом, можуть бути або прийняті до уваги, або відхилені.

Розглянемо приклад на C++. Ось "вільна", тобто така, що не входить у визначення якого-небудь класу, функція (Вільна функція - функція, що не входить ні в який клас. У чисто об’єктно-орієнтованих мовах, типу Smalltalk, вільних процедур не буває, кожна операція пов'язана з яким-небудь класом):

void balanceLevels(StorageTank& s1, StorageTank& s2);

Виклик цієї функції з екземплярами класу StorageTank або будь-яких його підкласів як параметрів буде погоджений за типами, оскільки тип кожного фактичного параметра визначається в ієрархії успадкування від базового класу StorageTank.

Під час реалізації цієї функції ми можемо мати приблизно такий запис:

if (s1.level()> s2.level()) s2.fill();

У чому особливість семантики використання селектора level? Він визначений тільки в класі StorageTank, тому, незалежно від класів об'єктів, які позначаються змінними в момент виконання, буде використана та сама успадкована ними функція. Виклик цієї функції статично зв'язаний під час компіляції - ми точно знаємо, яка операція буде запущена.

Інша справа fill. Цей селектор визначений в StorageTank і перевизначений в WaterTank, тому його прийдеться зв'язувати динамічно. Якщо при виконанні змінна s2 буде класу WaterTank, то функція буде взята із цього класу, а якщо - NutrientTank, то з StorageTank. В C++ є спеціальний синтаксис для явної вказівки джерела; у нашім прикладі виклик fill буде дозволений, відповідно, як WaterTank::fill або StorageTank::fill.

Ця особливість називається поліморфізмом: те саме ім'я може означати об'єкти різних типів, але, маючи загального предка, всі вони мають і загальну підмножину операцій, які можна над ними виконувати. Протилежність поліморфізму називається мономорфізмом; він характерний для мов із сильною типізацією й статичним зв'язуванням (Ada).

Поліморфізм виникає там, де взаємодіють успадкування й динамічне зв'язування. Це одна з найпривабливіших властивостей об’єктно-орієнтованих мов (після підтримки абстракції), що відрізняє їх від традиційних мов з абстрактними типами даних. І, як ми побачимо в наступних главах, поліморфізм відіграє дуже важливу роль в об’єктно-орієнтованому проектуванні.