Добавил:
Опубликованный материал нарушает ваши авторские права? Сообщите нам.
Вуз: Предмет: Файл:

PrIS

.pdf
Скачиваний:
45
Добавлен:
07.12.2018
Размер:
7.24 Mб
Скачать

241

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

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

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

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

Отже, інкапсуляцію можна визначити таким чином:

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

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

242

Як завжди, почнемо з типів.

// Булевий тип

enum Boolean {FALSE, TRUE};

На додаток до трьох запропонованих вище операцій, потрібні звичайні мета-операції створення і знищення об'єкту (конструктор і деструктор). Оскільки в системі може бути кілька нагрівачів, ми будемо при створенні кожного з них повідомляти про місце, де він встановлений, як ми робили це із класом сенсорів температури TemperatureSensor. Отже, маємо клас Heater для абстрактних нагрівачів, написаний на C++:

class Heater { public: Heater(Location); ~Heater();

void turnOn(); void tum0ff(); Boolean is0n() const; private:

};

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

Ось клас, що виражає абстрактний послідовний порт. class SerialPort { public:

SerialPort();

~SerialPort(); void write(char*); void write(int);

static SerialPort ports[10]; private:

};

Екземпляри цього класу будуть послідовними портами, у які можна вводити рядки й числа.

Додамо ще три параметри у клас Heater. class Heater {

public:

243

...

protected:

const Location repLocation; Boolean repIsOn; SerialPort* repPort;

};

Ці параметри repLocation, repIsOn, repPort утворять його інкапсульований стан. Правила C++ такі, що при компіляції програми, якщо клієнт спробує звернутися до цих параметрів прямо, буде видане повідомлення про помилку.

Визначимо тепер реалізації всіх операцій цього класу.

Heater::Heater(Location 1) : repLocation(1), repIsOn(FALSE),

repPort(&SerialPort::ports[l]) {} Heater::Heater() {}

void Heater::turnOn()

{

if (!repls0n) { repPort->write("*"); repPort->write(repLocation); repPort->write(1);

repIsOn = TRUE;

}

}

void Heater::turn0ff()

{

if (repIsOn) { repPort->write("*"); repPort->write(repLocation); repPort->write(0);

repIsOn = FALSE;

}

}

Boolean Heater::is0n() const

{

return repIsOn;

}

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

Припустимо, що з якої-небудь причини змінилася архітектура апаратних засобів системи , замість послідовного порту, керування має

244

здійснюватися через фіксовану область пам'яті. Немає необхідності змінювати інтерфейсну частину класу ─ досить переписати реалізацію. Відповідно до правил C++, після цього доведеться перекомпілювати змінений клас, але не інші об'єкти, якщо тільки вони не залежать від тимчасових і просторових характеристик попереднього коду (що вкрай небажано й зовсім не потрібно).

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

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

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

єможливість внесення змін в об'єкт без зміни інших об'єктів.

Відеальному випадку спроби звертання до даних, закритих для доступу, повинні виявлятися під час компіляції програми. Питання реалізації цих умов для конкретних мов програмування є предметом постійних обговорень. Так, Smalltalk забезпечує захист від прямого доступу до екземплярів іншого класу, виявляючи такі спроби під час компіляції. У той самий час Object Pascal не інкапсулює подання класу, так що ніщо в цій мові не оберігає клієнта від прямих посилань на внутрішні поля іншого об'єкту. Мова CLOS займає в цьому питанні проміжну позицію, покладаючи всі обов'язки з обмеження доступу на програміста. У цій мові всі слоти можуть супроводжуватися атрибутами :reader, :writer і :accessor, що дозволяють відповідно читання, запис або повний доступ до даних (тобто і читання, і запис). При відсутності атрибутів слот повністю інкапсульований. У мові C++ керування доступом і видимістю гнучкіше. Екземпляри класу можуть бути віднесені до відкритих, закритих або захищених частин. Відкрита частина доступна для всіх об'єктів; закрита частина повністю закрита для інших об'єктів; захищена частина видна тільки екземплярам цього класу і його підкласам. Крім того, в C++ існує поняття "друзів" (friends), для яких відкрита закрита частина.

245

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

14.2.4. Модульність

Поняття модульності. Поділ програми на модулі певною мірою дозволяє зменшити її складність. Однак набагато важливішим є той факт, що всередині модульної програми створюється множина добре сформованих і документованих інтерфейсів. Ці інтерфейси необхідні для вичерпного розуміння програми загалом. У деяких мовах програмування, наприклад у Smalltalk, модулів немає, і класи становлять єдину фізичну основу декомпозиції. В інших мовах, включаючи Object Pascal, C++, Ada, CLOS, модуль ─ це самостійна мовна конструкція. У цих мовах класи й об'єкти становлять логічну структуру системи, вони містяться в модулях, що утворюють фізичну структуру системи. Ця властивість стає особливо корисною, коли система складається з багатьох сотень класів.

Модульність ─ це поділ програми на фраґменти, які компілюються окремо, але можуть встановлювати зв'язки з іншими модулями. Зв'язок між модулями ─ це їхнє уявлення один про одного. У більшості мов, що підтримують принцип модульності як самостійну концепцію, інтерфейс модуля відділений від його реалізації. Отже, модульність і інкапсуляція дуже близькі між собою. У різних мовах програмування модульність підтримується по-різному. Наприклад, в C++ модулями є файли, які роздільно компілюються. Для C/C++ традиційним є зберігання інтерфейсної частини модуля в окремому файлі з розширенням .h (так звані файли-заголовки). Реалізація, тобто текст модуля, зберігається у файлах із розширенням .с (у програмах на C++ часто використовуються розширення .сс, .ср і .срр). Зв'язок між файлами оголошується директивою макропроцесора #include. Такий підхід не є строгою вимогою самої мови. У мові Object Pascal принцип модульності формалізований трохи суворіше. У цій мові визначений особливий синтаксис для інтерфейсної частини і реалізації модуля (unit). Мова Ada іде ще на крок далі: модуль (названий package) також має дві частини ─ специфікацію і тіло. Але, на відміну від Object Pascal, допускається роздільне визначення

246

зв'язків з модулями для специфікації і тіла пакету. Таким чином допускається, щоб тіло модуля мало зв'язки з модулями, які невидимі для його специфікації.

Правильний поділ програми на модулі є майже таким самим складним завданням, як вибір правильного набору абстракцій. Оскільки на початку роботи над проектом рішення можуть бути незрозумілими, декомпозиція на модулі може викликати ускладнення. Для добре відомих програм (наприклад, створення компіляторів) цей процес можна стандартизувати, але для нових завдань (військові системи або керування космічними апаратами) завдання може бути дуже важким.

Модулі виконують роль фізичних контейнерів, у яких містяться класи і об'єкти під час логічного проектування системи. Така сама ситуація виникає у проектувальників бортових комп'ютерів. Логіка електронного устаткування може бути побудована на основі елементарних схем типу НЕ, І-НЕ, АБО-НЕ, але можна об'єднати такі схеми у стандартні інтеґральні схеми (модулі), наприклад, серій 7400, 7402 або 7404.

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

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

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

247

чином. Кінцевою метою декомпозиції програми на модулі є зниження витрат на програмування за рахунок незалежного розроблення й тестування. Структура модуля має бути досить простою для сприйняття; реалізація кожного модуля не повинна залежати від реалізації інших модулів; мають бути вжиті заходи для полегшення процесу внесення змін там, де вони найнеобхідніші. На практиці перекомпіляція тіла модуля не є трудомісткою операцією: заново компілюється модуль, і програма перекомпоновується. Перекомпіляція інтерфейсної частини модуля, навпаки, трудомісткіша. У строго типізованих мовах доводиться перекомпілювати інтерфейс і тіло самого зміненого модуля, потім всі модулі, пов'язані з ним, потім модулі, пов'язані з ними, і так далі за ланцюжком. У підсумку для дуже великих програм може витрачатися багато часу на перекомпіляцію (якщо тільки середовище розроблення не підтримує фраґментарну компіляцію), що явно небажано. Тому варто прагнути, щоб інтерфейсна частина модулів була якомога вужчою (у межах забезпечення необхідних зв'язків). Наш стиль програмування вимагає сховати все, що тільки можливо, у реалізації модуля. Поступове перенесення описів з реалізації в інтерфейсну частину набагато небезпечніше, ніж "вичищення" надлишкового інтерфейсного коду.

Отже, програміст повинен знаходити баланс між двома протилежними тенденціями: прагненням сховати інформацію і необхідністю забезпечення видимості тих або інших абстракцій у декількох модулях. Особливості системи, піддані змінам, варто приховувати в окремих модулях; як міжмодульні можна використовувати тільки ті елементи, ймовірність зміни яких мала. Всі структури даних мають бути відображені в модулі; доступ до них буде можливий для всіх процедур цього модуля і закритий для всіх інших. Доступ до даних модуля повинен здійснюватися тільки через процедури цього модуля. Інакше кажучи, варто прагнути побудувати модулі так, щоб об'єднати логічно зв'язані абстракції й мінімізувати взаємні зв'язки між модулями. Виходячи із цього, наведемо визначення модульності:

модульність ─ це властивість системи, що була розкладена на внутрішньо зв'язані, але слабко зв'язані між собою модулі.

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

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

248

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

На вибір розбиття на модулі можуть впливати й деякі зовнішні обставини. Під час колективного розроблення програм розподіл роботи здійснюється, як правило, за модульним принципом, і правильний поділ проекту мінімізує зв'язки між учасниками. При цьому досвідченіші програмісти зазвичай відповідають за інтерфейс модулів, а менш досвідчені – за реалізацію. На вищому рівні такі самі співвідношення справедливі для відношень між субпідрядниками. Абстракції можна розподілити так, щоб швидко встановити інтерфейси модулів за згодою між компаніями, що беруть участь у роботі. Зміни в інтерфейсі викликають багато розмов і сперечань, не говорячи вже про величезну витрату паперу, – всі ці фактори роблять інтерфейс вкрай консервативним. Що стосується документування проекту, то він будується, як правило, також за модульним принципом – модуль служить одиницею опису й адміністрування. Десять модулів замість одного потребують у десять разів більше описів, і тому, на жаль, іноді вимоги з документування впливає на декомпозицію проекту (у більшості випадків негативно). Можуть позначатися й вимоги таємності: частина коду може бути несекретною, а інша – секретною; остання тоді виконується у вигляді окремого модуля (модулів).

Звести разом настільки суперечливі вимоги досить важко, але головне усвідомити: побудова класів і об'єктів у проекті й організація модульної структури – незалежні дії. Процес побудови класів і об'єктів становить частину процесу логічного проектування системи, а розподіл на модулі – етап фізичного проектування. Зрозуміло, іноді неможливо завершити логічне проектування системи, не завершивши фізичного проектування, і навпаки. Ці обидва процеси виконуються ітераційно.

Приклади модульності. Подивимося, як реалізується модульність у гідропонній городній системі. Припустимо, замість закупівлі спеціалізованого апаратного забезпечення, вирішено використовувати стандартну робочу станцію із графічним інтерфейсом користувача GUI (Graphical User Interface). За допомогою робочої станції оператор може формувати нові плани вирощування, модифікувати наявні плани й спостерігати за їхнім виконанням. Тому що абстракція плану вирощування – одна із ключових. Створимо модуль, що містить всі ці операції стосовно плану вирощування. На C++ нам буде потрібний приблизно такий файл-заголовок (нехай він називається gplan.h).

// gplan.h

249

#ifndef _GPLAN_H #define _GPLAN_H 1 #include "gtypes.h" #include "except.h" #include "actions.h" class GrowingPlan ...

class FruitGrowingPlan ...

class GrainGrowingPlan ...

#endif

Тут ми імпортуємо у файл три інших заголовних файли з визначенням інтерфейсів, на які будемо посилатися: gtypes.h, except .h та actions.h. Власне код класів ми помістимо в модуль реалізації, у файл із ім'ям gplan.cpp.

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

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

14.2.5. Ієрархія

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

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

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

250

Основними видами ієрархічних структур стосовно складних систем є структура класів (ієрархія "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. У нього додані параметри