Что представляют классы?
По сути в системе бывают классы двух видов:
классы, которые прямо отражают понятия области приложения, т.е. понятия, которые использует конечный пользователь для описания своих задач и возможных решений; иклассы, которые являются продуктом самой реализации, т.е. отражают понятия, используемые разработчиками и программистами для описания способов реализации.
Некоторые из классов, являющихся продуктами реализации, могут представлять и понятия реального мира. Например, программные и аппаратные ресурсы системы являются хорошими кандидатами на роль классов, представляющих область приложения. Это отражает тот факт, что систему можно рассматривать с нескольких точек зрения, и то, что с одной является деталью реализации, с другой может быть понятием области приложения. Хорошо спроектированная система должна содержать классы, которые дают возможность рассматривать систему с логически разных точек зрения. Приведем пример:
классы, представляющие пользовательские понятия (например, легковые машины и грузовики),классы, представляющие обобщения пользовательских понятий (движущиеся средства),классы, представляющие аппаратные ресурсы (например, класс управления памятью),классы, представляющие системные ресурсы (например, выходные потоки),классы, используемые для реализации других классов (например, списки, очереди, блокировщики) ивстроенные типы данных и структуры управления.
В больших системах очень трудно сохранять логическое разделение типов различных классов и поддерживать такое разделение между различными уровнями абстракции. В приведенном выше перечислении представлены три уровня абстракции:
[1+2] представляет пользовательское отражение системы,[3+4] представляет машину, на которой будет работать система,[5+6] представляет низкоуровневое (со стороны языка программирования) отражение реализации.
Чем больше система, тем большее число уровней абстракции необходимо для ее описания, и тем труднее определять и поддерживать эти уровни абстракции. Отметим, что таким уровням абстракции есть прямое соответствие в природе и в различных построениях человеческого интеллекта. Например, можно рассматривать дом как объект, состоящий из
атомов,молекул,досок и кирпичей,полов, потолков и стен;комнат.
Пока удается хранить раздельно представления этих уровней абстракции, можно поддерживать целостное представление о доме. Однако, если смешать их, возникнет бессмыслица. Например, предложение "Мой дом состоит из нескольких тысяч фунтов углерода, некоторых сложных полимеров, из 5000 кирпичей, двух ванных комнат и 13 потолков" - явно абсурдно. Из-за абстрактной природы программ подобное утверждение о какой-либо сложной программной системе далеко не всегда воспринимают как бессмыслицу.
В процессе проектирования выделение понятий из области приложения в класс вовсе не является простой механической операцией. Обычно эта задача требует большой проницательности. Заметим, что сами понятия области приложения являются абстракциями. Например, в природе не существуют "налогоплательщики", "монахи" или "сотрудники".
Эти понятия не что иное, как метки, которыми обозначают бедную личность, чтобы классифицировать ее по отношению к некоторой системе. Часто реальный или воображаемый мир (например, литература, особенно фантастика) служат источником понятий, которые кардинально преобразуются при переводе их в классы. Так, экран моего компьютера (Маккинтош) совсем не походит на поверхность моего стола, хотя компьютер создавался с целью реализовать понятие "настольный", а окна на моем дисплее имеют самое отдаленное отношение к приспособлениям для презентации чертежей в моей комнате. Я бы не вынес такого беспорядка у себя на экране.
Суть моделирования реальности не в покорном следовании тому, что мы видим, а в использовании реальности как начала для проектирования, источника вдохновения и как якоря, который удерживает, когда стихия программирования грозит лишить нас способности понимания своей собственной программы.
Здесь полезно предостеречь: новичкам обычно трудно "находить" классы, но вскоре это преодолевается без каких-либо неприятностей. Далее обычно приходит этап, когда классы и отношения наследования между ними бесконтрольно множатся. Здесь уже возникают проблемы, связанные со сложностью, эффективностью и ясностью полученной программы. Далеко не каждую отдельную деталь следует представлять отдельным классом, и далеко не каждое отношение между классами следует представлять как отношение наследования. Старайтесь не забывать, что цель проекта - смоделировать систему с подходящим уровнем детализации и подходящим уровнем абстракции. Для больших систем найти компромисс между простотой и общностью далеко не простая задача.
Гибридный проект
Переход на новые методы работы может быть мучителен для любой организации. Раскол внутри нее и расхождения между сотрудниками могут быть значительными. Но резкий решительный переход, способный в одночасье превратить эффективных и квалифицированных сторонников "старой школы" в неэффективных новичков "новой школы" обычно неприемлем. В то же время, нельзя достичь больших высот без изменений, а значительные изменения обычно связаны с риском.
Язык С++ создавался с целью сократить такой риск за счет постепенного введения новых методов. Хотя очевидно, что наибольшие преимущества при использовании С++ достигаются за счет абстракции данных, объектно-ориентированного программирования и объектно-ориентированного проектирования, совершенно неочевидно, что быстрее всего достичь этого можно решительным разрывом с прошлым. Вряд ли такой явный разрыв будет возможен, обычно стремление к усовершенствованиям сдерживается или должно сдерживаться, чтобы переход к ним был управляемым. Нужно учитывать следующее:
Разработчикам и программистам требуется время для овладения новыми методами.Новые программы должны взаимодействовать со старыми программами.Старые программы нужно сопровождать (часто бесконечно).Работа по текущим проектам и программам должна быть выполнена в срок.Средства, рассчитанные на новые методы, нужно адаптировать к локальному окружению.
Здесь рассматриваются как раз ситуации, связанные с перечисленными требованиями. Легко недооценить два первых требования.
Поскольку в С++ возможны несколько схем программирования, язык допускает постепенный переход на него, используя следующие преимущества такого перехода:
Изучая С++, программисты могут продолжать работать.В окружении, бедном на программные средства, использование С++ может принести значительные выгоды.Программы, написанные на С++, могут хорошо взаимодействовать с программами, написанными на С или других традиционных языках.Язык имеет большое подмножество, совместимое с С.
Идея заключается в постепенном переходе программиста с традиционного языка на С++: вначале он программирует на С++ в традиционном процедурном стиле, затем с помощью методов абстракции данных, и наконец, когда овладеет языком и связанными с ним средствами, полностью переходит на объектно-ориентированное программирование. Заметим, что хорошо спроектированную библиотеку использовать намного проще, чем проектировать и реализовывать, поэтому даже с первых своих шагов новичок может получить преимущества, используя более развитые средства С++.
Идея постепенного, пошагового овладения С++, а также возможность смешивать программы на С++ с программами, написанными на языках, не имеющих средств абстракции данных и объектно-ориентированного программирования, естественно приводит к проекту, имеющему гибридный стиль. Большинство интерфейсов можно пока оставить на процедурном уровне, поскольку что-либо более сложное не принесет немедленного выигрыша. Например, обращение к стандартной библиотеке math из С определяется на С++ так:
extern "C" { #include <math.h> }
и стандартные математические функции из библиотеки можно использовать так же, как и в С. Для всех основных библиотек такое включение должно быть сделано теми, кто поставляет библиотеки, так что программист на С++ даже не будет знать, на каком языке реализована библиотечная функция. Использование библиотек, написанных на таких языках как С, является первым и вначале самым важным способом повторного использования на С++.
На следующем шаге, когда станут необходимы более сложные приемы, средства, реализованные на таких языках как С или Фортран, представляются в виде классов за счет инкапсуляции структур данных и функций в интерфейс классов С++. Простым примером введения более высокого семантического уровня за счет перехода от уровня процедур плюс структур данных к уровню абстракции данных может служить класс строк из §7.6. Здесь за счет инкапсуляции символьных строк и стандартных строковых функций С получается новый строковый тип, который гораздо проще использовать.
Подобным образом можно включить в иерархию классов любой встроенный или отдельно определенный тип. Например, тип int можно включить в иерархию классов так:
class Int : public My_object { int i; public: // definition of operations // see exercises [8]-[11] in section 7.14 for ideas // определения операций получаются в упражнениях [8]-[11] // за идеями обратитесь к разделу 7.14 };
Так следует делать, если действительно есть потребность включить такие типы в иерархию.
Обратно, классы С++ можно представить в программе на С или Фортране как функции и структуры данных. Например:
class myclass { // representation public: void f(); T1 g(T2); // ... };
extern "C" { // map myclass into C callable functions:
void myclass_f(myclass* p) { p->f(); } T1 myclass_g(myclass* p, T2 a) { return p->g(a); } // ... };
В С-программе следует определить эти функции в заголовочном файле следующим образом:
// in C header file
extern void myclass_f(struct myclass*); extern T1 myclass_g(struct myclass*, T2);
Такой подход позволяет разработчику на С++, если у него уже есть запас программ, написанных на языках, в которых отсутствуют понятия абстракции данных и иерархии классов, постепенно приобщаться к этим понятиям, даже при том требовании, что окончательную версии программы можно будет вызывать из традиционных процедурных языков.
Иерархии классов
Рассмотрим моделирование транспортного потока в городе, цель которого достаточно точно определить время, требующееся, чтобы аварийные движущиеся средства достигли пункта назначения. Очевидно, нам надо иметь представления легковых и грузовых машин, машин скорой помощи, всевозможных пожарных и полицейских машин, автобусов и т.п. Поскольку всякое понятие реального мира не существует изолированно, а соединено многочисленными связями с другими понятиями, возникает такое отношение как наследование. Не разобравшись в понятиях и их взаимных связях, мы не в состоянии постичь никакое отдельное понятие. Также и модель, если не отражает отношения между понятиями, не может адекватно представлять сами понятия. Итак, в нашей программе нужны классы для представления понятий, но этого недостаточно. Нам нужны способы представления отношений между классами. Наследование является мощным способом прямого представления иерархических отношений. В нашем примере, мы, по всей видимости, сочли бы аварийные средства специальными движущимися средствами и, помимо этого, выделили бы средства, представленные легковыми и грузовыми машинами. Тогда иерархия классов приобрела бы такой вид:
движущееся средстволегковая машинааварийное средствогрузовая машинаполицейская машинаскорой помощипожарная машинамашина с выдвижной лестницей.
Здесь класс Emergency представляет всю информацию, необходимую для моделирования аварийных движущихся средств, например: аварийная машина может нарушать некоторые правила движения, она имеет приоритет на перекрестках, находится под контролем диспетчера и т.д.
На С++ это можно задать так:
class Vehicle { /*...*/ }; class Emergency { /* */ }; class Car : public Vehicle { /*...*/ }; class Truck : public Vehicle { /*...*/ }; class Police_car : public Car , public Emergency { //... }; class Ambulance : public Car , public Emergency { //... }; class Fire_engine : public Truck , Emergency { //... }; class Hook_and_ladder : public Fire_engine { //... };
Наследование - это отношение самого высокого порядка, которое прямо представляется в С++ и используется преимущественно на ранних этапах проектирования. Часто возникает проблема выбора: использовать наследование для представления отношения или предпочесть ему принадлежность. Рассмотрим другое определение понятия аварийного средства: движущееся средство считается аварийным, если оно несет соответствующий световой сигнал. Это позволит упростить иерархию классов, заменив класс Emergency на член класса Vehicle:
движущееся средство (Vehicle {eptr})легковая машина (Car) грузовая машина (Truck)полицейская машина (Police_car) машина скорой помощи (Ambulance)пожарная машина (Fire_engine)машина с выдвижной лестницей (Hook_and_ladder)
Теперь класс Emergency используется просто как член в тех классах, которые представляют аварийные движущиеся средства:
class Emergency { /*...*/ }; class Vehicle { public: Emergency* eptr; /*...*/ }; class Car : public Vehicle { /*...*/ }; class Truck : public Vehicle { /*...*/ }; class Police_car : public Car { /*...*/ }; class Ambulance : public Car { /*...*/ }; class Fire_engine : public Truck { /*...*/ }; class Hook_and_ladder : public Fire_engine { /*...*/ };
Здесь движущееся средство считается аварийным, если Vehicle::eptr не равно нулю. "Простые" легковые и грузовые машины инициализируются Vehicle::eptr равным нулю, а для других Vehicle::eptr должно быть установлено в ненулевое значение, например:
Car::Car() // конструктор Car { eptr = 0; } Police_car::Police_car() // конструктор Police_car { eptr = new Emergency; }
Такие определения упрощают преобразование аварийного средства в обычное и наоборот:
void f(Vehicle* p) { delete p->eptr; p->eptr = 0; // больше нет аварийного движущегося средства
//...
p->eptr = new Emergency; // оно появилось снова }
Так какой же вариант иерархии классов лучше? В общем случае ответ такой: "Лучшей является программа, которая наиболее непосредственно отражает реальный мир". Иными словами, при выборе модели мы должны стремиться к большей ее"реальности", но с учетом неизбежных ограничений, накладываемых требованиями простоты и эффективности. Поэтому, несмотря на простоту преобразования обычного движущегося средства в аварийное, второе решение представляется непрактичным.
Пожарные машины и машины скорой помощи - это движущиеся средства специального назначения со специально подготовленным персоналом, они действуют под управлением команд диспетчера, требующих специального оборудования для связи. Такое положение означает, что принадлежность к аварийным движущимся средствам - это базовое понятие, которое для улучшения контроля типов и применения различных программных средств должно быть прямо представлено в программе. Если бы мы моделировали ситуацию, в которой назначение движущихся средств не столь определенно, скажем, ситуацию, в которой частный транспорт периодически используется для доставки специального персонала к месту происшествия, а связь обеспечивается с помощью портативных приемников, тогда мог бы оказаться подходящим и другой способ моделирования системы.
Для тех, кто считает пример моделирования движения транспорта экзотичным, имеет смысл сказать, что в процессе проектирования почти постоянно возникает подобный выбор между наследованием и принадлежностью. Аналогичный пример есть в §12.2.5, где описывается свиток (scrollbar) - прокручивание информации в окне.
Игнорирование классов
Рассмотрим первый из указанных моментов - игнорирование классов. В таком случае получившаяся программа на С++ будет приблизительно эквивалентна С-программе, разработанной по тому же проекту, и, можно сказать, что они будут приблизительно эквивалентны программам на Аде или Коболе, разработанным по нему же.
По сути проект составлен как не зависящий от языка реализации, что принуждает программиста ограничиваться общим подмножеством языков С, Ада или Кобол. Здесь есть свои преимущества. Например, получившееся в результате строгое разделение данных и программного кода позволяет легко использовать традиционные базы данных, которые разработаны для таких программ. Поскольку используется ограниченный язык программирования, от программистов требуется меньше опытности (или, по крайней мере другой ее уровень). Для многих приложений, например, для традиционных баз данных, работающих с файлом последовательно, такой подход вполне разумен, а традиционные приемы, отработанные за десятилетия, вполне адекватны задаче.
Однако там, где область приложения существенно отличается от традиционной последовательной обработки записей (или символов), или сложность задачи выше, как, например, в диалоговой системе CASE, недостаток языковой поддержки абстрактных данных из-за отказа от классов (если их не учитывать) повредит проекту. Сложность задачи не уменьшится, но, поскольку система реализована на обедненном языке, структура программы плохо будет отвечать проекту. У нее слишком большой объем, не хватает проверки типов, и, вообще, она плохо приспособлена для использования различных вспомогательных средств. Это путь, приводящий к кошмарам при ее сопровождении.
Обычно для преодоления указанных трудностей создают специальные средства, поддерживающие понятия, используемые в проекте. Благодаря им создаются конструкции более высокого уровня и организуются проверки с целью компенсировать дефекты (или сознательное обеднение) языка реализации. Так метод проектирования становится самоцелью, и для него создается специальный язык программирования. Такие языки программирования в большинстве случаев являются плохой заменой широко распространенных языков программирования общего назначения, которые сопровождаются подходящими средствами проектирования. Использовать С++ с таким ограничением, которое должно компенсироваться при проектировании специальными средствами, бессмысленно. Хотя несоответствие между языком программирования и средствами проектирования может быть просто стадией процесса перехода, а значит временным явлением.
Самой типичной причиной игнорирования классов при проектировании является простая инерция. Традиционные языки программирования не предоставляют понятия класса, и в традиционных методах проектирования отражаются этот недостаток. Обычно в процессе проектирования наибольшее внимание уделяется разбиению задачи на процедуры, производящие требуемые действия. В лекции 1 это понятие называлось процедурным программированием, а в области проектирования оно именуется как функциональная декомпозиция. Возникает типичный вопрос "Можно ли использовать С++ совместно с методом проектирования, базирующимся на функциональной декомпозиции?" Да, можно, но, вероятнее всего, в результате вы придете к использованию С++ как просто улучшенного С со всеми указанными выше проблемами. Это может быть приемлемо на период перехода на новый язык, или для уже завершенного проектирования, или для подзадач, в которых использование классов не дает существенных выгод (если учитывать опыт программирования на С++ к данному моменту), но в общем случае на большом отрезке времени отказ от свободного использования классов, связанный с методом функциональной декомпозиции, никак не совместим с эффективным использованием С++.
Процедурно-ориентированный и объектно-ориентированный подходы к программированию различаются по своей сути и обычно ведут к совершенно разным решениям одной задачи. Этот вывод верен как для стадии реализации, так и для стадии проектирования: вы концентрируете внимание или на предпринимаемых действиях, или на представляемых сущностях, но не на том и другом одновременно.
Тогда почему метод объектно-ориентированного проектирования предпочтительнее метода функциональной декомпозиции? Главная причина в том, что функциональная декомпозиция не дает достаточной абстракции данных. А отсюда уже следует, что проект будет
менее податливым к изменениям,менее приспособленным для использования различных вспомогательных средств,менее пригодным для параллельного развития именее пригодным для параллельного выполнения.
Дело в том, что функциональная декомпозиция вынуждает объявлять "важные" данные глобальными, поскольку, если система структурирована как дерево функций, всякое данное, доступное двум функциям, должно быть глобальным по отношению к ним. Это приводит к тому, что "важные" данные "всплывают" к вершине дерева, по мере того как все большее число функций требует доступа к ним.
В точности так же происходит в случае иерархии классов с одним корнем, когда "важные" данные всплывают по направлению к базовому классу.
Когда мы концентрируем внимание на описаниях классов, заключающих определенные данные в оболочку, то зависимости между различными частями программы выражены явно и можно их проследить. Еще более важно то, что при таком подходе уменьшается число зависимостей в системе за счет лучшей расстановки ссылок на данные.
Однако, некоторые задачи лучше решаются с помощью набора процедур. Смысл "объектно-ориентированного" проектирования не в том, чтобы удалить все глобальные процедуры из программы или не иметь в системе процедурно-ориентированных частей. Основная идея скорее в том, что классы, а не глобальные процедуры становятся главным объектом внимания на стадии проектирования. Использование процедурного стиля должно быть осознанным решением, а не решением, принимаемым по умолчанию. Как классы, так и процедуры следует применять сообразно области приложения, а не просто как неизменные методы проектирования.
Игнорирование наследования
Рассмотрим вариант 2 - проект, который игнорирует наследование. В этом случае в окончательной программе просто не используются возможности основного средства С++, хотя и получаются определенные выгоды при использовании С++ по сравнению с использованием языков С, Паскаль, Фортран, Кобол и т.п. Обычные доводы в пользу этого, помимо инерции, утверждения, что "наследование - это деталь реализации", или "наследование препятствует упрятыванию информации", или "наследование затрудняет взаимодействие с другими системами программирования".
Считать наследование всего лишь деталью реализации - значит игнорировать иерархию классов, которая может непосредственно моделировать отношения между понятиями в области приложения. Такие отношения должны быть явно выражены в проекте, чтобы дать возможность разработчику продумать их.
Сильные доводы можно привести в пользу исключения наследования из тех частей программы на С++, которые непосредственно взаимодействуют с программами, написанными на других языках. Но это не является достаточной причиной, чтобы отказаться от наследования в системе в целом, это просто довод в пользу того, чтобы аккуратно определить и инкапсулировать программный интерфейс с "внешним миром". Аналогично, чтобы избавиться от беспокойства, вызванного путаницей с упрятыванием информации при наличии наследования, надо осторожно использовать виртуальные функции и закрытые члены, но не отказываться от наследования.
Существует достаточно много ситуаций, когда использование наследования не дает явных выгод, но политика "никакого наследования" приведет к менее понятной и менее гибкой системе, в которой наследование "подделывается" с помощью более традиционных конструкций языка и проектирования. Для больших проектов это существенно. Более того, вполне возможно, что несмотря на такую политику, наследование все равно будет использоваться, поскольку программисты, работающие на С++, найдут убедительные доводы в пользу проектирования с учетом наследования в различных частях системы. Таким образом, политика "никакого наследования" приведет лишь к тому, что в системе будет отсутствовать целостная общая структура, а использование иерархии классов будет ограничено определенными подсистемами.
Иными словами, будьте непредубежденными. Иерархия классов не является обязательной частью всякой хорошей программы, но есть масса ситуаций, когда она может помочь как в понимании области приложения, так и в формулировании решений. Утверждение, что наследование может неправильно или чрезмерно использоваться, служит только доводом в пользу осторожности, а вовсе не в пользу отказа от него.
Игнорирование статического контроля типов
Рассмотрим вариант 3, относящийся к проекту, в котором игнорируется статический контроль типов. Распространенные доводы в пользу отказа на стадии проектирования от статического контроля типов сводятся к тому, что "типы - это продукт языков программирования", или что "более естественно рассуждать об объектах, не заботясь о типах", или "статический контроль типов вынуждает нас думать о реализации на слишком раннем этапе". Такой подход вполне допустим до тех пор, пока он работает и не приносит вреда. Вполне разумно на стадии проектирования не заботиться о деталях проверки типов, и часто вполне допустимо на стадии анализа и начальных стадиях проектирования полностью забыть о вопросах, связанных с типами. В то же время, классы и иерархии классов очень полезны на стадии проектирования, в частности, они дают нам большую определенность понятий, позволяют точно задать взаимоотношения между понятиями и помогают рассуждать о понятиях. По мере развития проекта эта определенность и точность преобразуется во все более конкретные утверждения о классах и их интерфейсах.
Важно понимать, что точно определенные и строго типизированные интерфейсы являются фундаментальным средством проектирования. Язык С++ был создан как раз с учетом этого. Строго типизированный интерфейс гарантирует, что только совместимые части программы могут быть скомпилированы и скомпонованы воедино, и тем самым позволяет делать относительно строгие допущения об этих частях. Эти допущения обеспечиваются системой типов языка.
В результате сводятся к минимуму проверки на этапе выполнения, что повышает эффективность и приводит к значительному сокращению фазы интеграции частей проекта, реализованных разными программистами. Реальный положительный опыт интеграции системы со строго типизированными интерфейсами привел к тому, что вопросы интеграции вообще не фигурируют среди основных тем этой лекции.
Рассмотрим следующую аналогию: в физическом мире мы постоянно соединяем различные устройства, и существует кажущееся бесконечным число стандартов на соединения. Главная особенность этих соединений: они специально спроектированы таким образом, чтобы сделать невозможным соединение двух устройств, нерассчитанных на него, то есть соединение должно быть сделано единственным правильным способом. Вы не можете подсоединить электробритву к розетке с высоким напряжением. Если бы вы смогли сделать это, то сожгли бы бритву или сгорели сами. Масса изобретательности была проявлена, чтобы добиться невозможности соединения двух несовместимых устройств. Альтернативой одновременного использования нескольких несовместимых устройств может послужить такое устройство, которое само себя защищает от несовместимых с ним устройств, подключающихся к его входу. Хорошим примером может служить стабилизатор напряжения. Поскольку идеальную совместимость устройств нельзя гарантировать только на "уровне соединения", иногда требуется более дорогая защита в электрической цепи, которая позволяет в динамике приспособиться или (и) защититься от скачков напряжения.
Здесь практически прямая аналогия: статический контроль типов эквивалентен совместимости на уровне соединения, а динамические проверки соответствуют защите или адаптации в цепи. Результатом неудачного контроля как в физическом, так и в программном мире будет серьезный ущерб. В больших системах используются оба вида контроля. На раннем этапе проектирования вполне достаточно простого утверждения: "Эти два устройства необходимо соединить"; но скоро становится существенным, как именно следует их соединить: "Какие гарантии дает соединение относительно поведения устройств?", или "Возникновение каких ошибочных ситуаций возможно?", или "Какова приблизительная цена такого соединения?"
Применение "статической типизации" не ограничивается программным миром. В физике и инженерных науках повсеместно распространены единицы измерения (метры, килограммы, секунды), чтобы избежать смешивания несовместимых сущностей.
В нашем описании шагов проектирования в §11.3.3 типы появляются на сцене уже на шаге 2 (очевидно, после несколько искусственного их рассмотрения на шаге 1) и становятся главной темой шага 4.
Статически контролируемые интерфейсы - это основное средство взаимодействия программных частей системы на С++, созданных разными группами, а описание интерфейсов этих частей (с учетом точных определений типов) становится основным способом сотрудничества между отдельными группами программистов. Эти интерфейсы являются основным результатом процесса проектирования и служат главным средством общения между разработчиками и программистами.
Отказ от этого приводит к проектам, в которых неясна структура программы, контроль ошибок отложен на стадию выполнения, которые трудно хорошо реализовать на С++.
Рассмотрим интерфейс, описанный с помощью "объектов", определяющих себя самостоятельно. Возможно, например, такое описание: "Функция f() имеет аргумент, который должен быть самолетом" (что проверяется самой функцией во время ее выполнения), в отличие от описания "Функция f() имеет аргумент, тип которого есть самолет" (что проверяется транслятором). Первое описание является существенно недостаточным описанием интерфейса, т.к. приводит к динамической проверке вместо статического контроля. Аналогичный вывод из примера с самолетом сделан в §1.5.2. Здесь использованы более точные спецификации, и использован шаблон типа и виртуальные функции взамен неограниченных динамических проверок для того, чтобы перенести выявление ошибок с этапа выполнения на этап трансляции. Различие времен работы программ с динамическим и статическим контролем может быть весьма значительным, обычно оно находится в диапазоне от 3 до 10 раз.
Но не следует впадать в другую крайность. Нельзя обнаружить все ошибки с помощью статического контроля. Например, даже программы с самым обширным статическим контролем уязвимы к сбоям аппаратуры. Но все же, в идеале нужно иметь большое разнообразие интерфейсов со статической типизацией с помощью типов из области приложения, см. §12.4.
Может получиться, что проект, совершенно разумный на абстрактном уровне, столкнется с серьезными проблемами, если не учитывает ограничения базовых средств, в данном случае С++. Например, использование имен, а не типов для структурирования системы приведет к ненужным проблемам для системы типов С++ и, тем самым, может стать причиной ошибок и накладных расходов при выполнении. Рассмотрим три класса:
Инкапсуляция
Отметим, что в С++ класс, а не отдельный объект, является той единицей, которая должна быть инкапсулирована (заключена в оболочку).
Например:
class list { list* next; public: int on(list*); };
int list::on(list* p) { list* q = this; for(;;) { if (p == q) return 1; if (q == 0) return 0; q = q->next; } }
Здесь обращение к частному указателю list::next допустимо, поскольку list::on() имеет доступ ко всякому объекту класса list, на который у него есть ссылка. Если это неудобно, ситуацию можно упростить, отказавшись от возможности доступа через функцию-член к представлениям других объектов, например:
int list::on(list* p) { if (p == this) return 1; if (p == 0) return 0; return next->on(p); }
Но теперь итерация превращается в рекурсию, что может сильно замедлить выполнение программы, если только транслятор не сумеет обратно преобразовать рекурсию в итерацию.
Интерфейсы и реализации
Идеальный интерфейс должен
представлять полное и согласованное множество понятий для пользователя, быть согласованным для всех частей компонента,скрывать специфику реализации от пользователя,допускать несколько реализаций,иметь статическую систему типов,определяться с помощью типов из области приложения,зависеть от других интерфейсов лишь частично и вполне определенным образом.
Отметив необходимость согласованности для всех классов, которые образуют интерфейс компонента с остальным миром, мы можем упростить вопрос интерфейса, рассмотрев только один класс, например:
class X { // пример плохого определения интерфейса Y a; Z b; public: void f(const char* ...); void g(int[],int); void set_a(Y&); Y& get_a(); };
В этом интерфейсе содержится ряд потенциальных проблем:
Типы Y и Z используются так, что определения Y и Z должны быть известны во время трансляции. У функции X::f может быть произвольное число параметров неизвестного типа (возможно, они каким-то образом контролируются "строкой формата", которая передается в качестве первого параметра). Функция X::g имеет параметр типа int[]. Возможно это нормально, но обычно это свидетельствует о том, что определение слишком низкого уровня абстракции. Массив целых не является достаточным определением, так как неизвестно из скольких он может состоять элементов.Функции set_a() и get_a(), по всей видимости, раскрывают представление объектов класса X, разрешая прямой доступ к X::a.
Здесь функции-члены образуют интерфейс на слишком низком уровне абстракции. Как правило классы с интерфейсом такого уровня относятся к специфике реализации большого компонента, если они вообще могут к чему-нибудь относиться. В идеале параметр функции из интерфейса должен сопровождаться такой информацией, которой достаточно для его понимания. Можно сформулировать такое правило: надо уметь передавать запросы на обслуживание удаленному серверу по узкому каналу.
Язык С++ раскрывает представление класса как часть интерфейса. Это представление может быть скрытым (с помощью private или protected), но обязательно доступным транслятору, чтобы он мог разместить автоматические (локальные) переменные, сделать подстановку тела функции и т.д. Отрицательным следствием этого является то, что использование типов классов в представлении класса может привести к возникновению нежелательных зависимостей. Приведет ли использование членов типа Y и Z к проблемам, зависит от того, каковы в действительности типы Y и Z. Если это достаточно простые типы, наподобие complex или String, то их использование будет вполне допустимым в большинстве случаев. Такие типы можно считать устойчивыми, и необходимость включать определения их классов будет вполне допустимой нагрузкой для транслятора. Если же Y и Z сами являются классами интерфейса большого компонента (например, типа графической системы или системы обеспечения банковских счетов), то прямую зависимость от них можно считать неразумной. В таких случаях предпочтительнее использовать член, являющийся указателем или ссылкой:
class X { Y* a; Z& b; // ... };
При этом способе определение X отделяется от определений Y и Z, т.е. теперь определение X зависит только от имен Y и Z. Реализация X, конечно, будет по-прежнему зависеть от определений Y и Z, но это уже не будет оказывать неблагоприятного влияния на пользователей X.
Вышесказанное иллюстрирует важное утверждение: У интерфейса, скрывающего значительный объем информации (что и должен делать полезный интерфейс), должно быть существенно меньше зависимостей, чем у реализации, которая их скрывает. Например, определение класса X можно транслировать без доступа к определениям Y и Z. Однако, в определениях функций-членов класса X, которые работают со ссылками на объекты Y и Z, доступ к определениям Y и Z необходим. При анализе зависимостей следует рассматривать раздельно зависимости в интерфейсе и в реализации. В идеале для обоих видов зависимостей граф зависимостей системы должен быть направленным нецикличным графом, что облегчает понимание и тестирование системы. Однако, эта цель более важна и чаще достижима для реализаций, чем для интерфейсов.
Отметим, что класс определяет три интерфейса:
class X { private: // доступно только для членов и друзей protected: // доступно только для членов и друзей, а также // для членов и друзей производных классов public: // общедоступно };
Члены должны образовывать самый ограниченный из возможных интерфейсов. Иными словами, член должен быть описан как private, если нет причин для более широкого доступа к нему; если же таковые есть, то член должен быть описан как protected, если нет дополнительных причин задать его как public. В большинстве случаев плохо задавать все данные, представляемые членами, как public. Функции и классы, образующие общий интерфейс, должны быть спроектированы таким образом, чтобы представление класса совпадало с его ролью в проекте как средства представления понятий. Напомним, что друзья являются частью общего интерфейса.
Отметим, что абстрактные классы можно использовать для представления понятия упрятывания более высокого уровня (§1.4.6, §6.3, §13.3).
Инварианты
Значение членов или объектов, доступных с помощью членов класса, называется состоянием объекта (или просто значением объекта). Главное при построении класса - это: привести объект в полностью определенное состояние (инициализация), сохранять полностью определенное состояние обЪекта в процессе выполнения над ним различных операций, и в конце работы уничтожить объект без всяких последствий. Свойство, которое делает состояние объекта полностью определенным, называется инвариантом.
Поэтому назначение инициализации - задать конкретные значения, при которых выполняется инвариант объекта. Для каждой операции класса предполагается, что инвариант должен иметь место перед выполнением операции и должен сохраниться после операции. В конце работы деструктор нарушает инвариант, уничтожая объект. Например, конструктор String::String(const char*) гарантирует, что p указывает на массив из, по крайней мере, sz элементов, причем sz имеет осмысленное значение и v[sz-1]==0. Любая строковая операция не должна нарушать это утверждение.
При проектировании класса требуется большое искусство, чтобы сделать реализацию класса достаточно простой и допускающей наличие полезных инвариантов, которые несложно задать. Легко требовать, чтобы класс имел инвариант, труднее предложить полезный инвариант, который понятен и не накладывает жестких ограничений на действия разработчика класса или на эффективность реализации. Здесь "инвариант" понимается как программный фрагмент, выполнив который, можно проверить состояние объекта. Вполне возможно дать более строгое и даже математическое определение инварианта, и в некоторых ситуациях оно может оказаться более подходящим. Здесь же под инвариантом понимается практическая, а значит, обычно экономная, но неполная проверка состояния объекта.
Понятие инварианта появилось в работах Флойда, Наура и Хора, посвященных пред- и пост-условиям, оно встречается во всех важных статьях по абстрактным типам данных и верификации программ за последние 20 лет. Оно же является основным предметом отладки в C++.
Обычно, в течение работы функции-члена инвариант не сохраняется. Поэтому функции, которые могут вызываться в те моменты, когда инвариант не действует, не должны входить в общий интерфейс класса. Такие функции должны быть частными или защищенными.
Как можно выразить инвариант в программе на С++? Простое решение - определить функцию, проверяющую инвариант, и вставить вызовы этой функции в общие операции. Например:
class String { int sz; int* p; public: class Range {}; class Invariant {};
void check();
String(const char* q); ~String(); char& operator[](int i); int size() { return sz; } //... };
void String::check() { if (p==0 || sz<0 || TOO_LARGE<=sz || p[sz-1]) throw Invariant; }
char& String::operator[](int i) { check(); // проверка на входе if (i<0 || i<sz) throw Range; // действует check(); // проверка на выходе return v[i]; }
Этот вариант прекрасно работает и не осложняет жизнь программиста. Но для такого простого класса как String проверка инварианта будет занимать большую часть времени счета. Поэтому программисты обычно выполняют проверку инварианта только при отладке:
inline void String::check() { if (!NDEBUG) if (p==0 || sz<0 || TOO_LARGE<=sz || p[sz]) throw Invariant; }
Мы выбрали имя NDEBUG, поскольку это макроопределение, которое используется для аналогичных целей в стандартном макроопределении С assert(). Традиционно NDEBUG устанавливается с целью указать, что отладки нет. Указав, что check() является подстановкой, мы гарантировали, что никакая программа не будет создана, пока константа NDEBUG не будет установлена в значение, обозначающее отладку. С помощью шаблона типа Assert() можно задать менее регулярные утверждения, например:
template<class T, class X> inline void Assert(T expr,X x) { if (!NDEBUG) if (!expr) throw x; }
вызовет особую ситуацию x, если expr ложно, и мы не отключили проверку с помощью NDEBUG. Использовать Assert() можно так:
class Bad_f_arg { };
void f(String& s, int i) { Assert(0<=i && i<s.size(),Bad_f_arg()); //... }
Шаблон типа Assert() подражает макрокоманде assert() языка С. Если i не находится в требуемом диапазоне, возникает особая ситуация Bad_f_arg.
С помощью отдельной константы или константы из класса проверить подобные утверждения или инварианты - пустяковое дело. Если же необходимо проверить инварианты с помощью объекта, можно определить производный класс, в котором проверяются операциями из класса, где нет проверки, см. упр.8 в §13.11.
Для классов с более сложными операциями расходы на проверки могут быть значительны, поэтому проверки можно оставить только для "поимки" трудно обнаруживаемых ошибок. Обычно полезно оставлять по крайней мере несколько проверок даже в очень хорошо отлаженной программе. При всех условиях сам факт определения инвариантов и использования их при отладке дает неоценимую помощь для получения правильной программы и, что более важно, делает понятия, представленные классами, более регулярными и строго определенными. Дело в том, что когда вы создаете инварианты, то рассматриваете класс с другой точки зрения и вносите определенную избыточность в программу. То и другое увеличивает вероятность обнаружения ошибок, противоречий и недосмотров.
Мы указали в §11.3.3.5, что две самые общие формы преобразования иерархии классов состоят в разбиении класса на два и в выделении общей части двух классов в базовый класс. В обоих случаях хорошо продуманный инвариант может подсказать возможность такого преобразования. Если, сравнивая инвариант с программами операций, можно обнаружить, что большинство проверок инварианта излишни, то значит класс созрел для разбиения. В этом случае подмножество операций имеет доступ только к подмножеству состояний объекта. Обратно, классы созрели для слияния, если у них сходные инварианты, даже при некотором различии в их реализации.
Классы
Основное положение объектно-ориентированного проектирования и программирования заключается в том, что программа служит моделью некоторых понятий реальности. Классы в программе представляют основные понятия области приложения и, в частности, основные понятия самого процесса моделирования реальности. Объекты классов представляют предметы реального мира и продукты процесса реализации.
Мы рассмотрим структуру программы с точки зрения следующих взаимоотношений между классами:
отношения наследования,отношения принадлежности,отношения использования изапрограммированные отношения.
При рассмотрении этих отношений неявно предполагается, что их анализ является узловым моментом в проекте системы. В §12.4 исследуются свойства, которые делают класс и его интерфейс полезными для представления понятий. Вообще говоря, в идеале, зависимость класса от остального мира должна быть минимальна и четко определена, а сам класс должен через интерфейс открывать лишь минимальный объем информации для остального мира.
Подчеркнем, что класс в С++ является типом, поэтому сами классы и взаимоотношения между ними обеспечены значительной поддержкой со стороны транслятора и в общем случае поддаются статическому анализу.
Компоненты
В языке С++ нет конструкций, которые могут выразить прямо в программе понятие компонента, т.е. множества связанных классов. Основная причина этого в том, что множество классов (возможно с соответствующими глобальными функциями и т.п.) может соединяться в компонент по самым разным признакам. Отсутствие явного представления понятия в языке затрудняет проведение границы между информацией (имена), используемой внутри компонента, и информацией (имена), передаваемой из компонента пользователям.
В идеале, компонент определяется множеством интерфейсов, используемых для его реализации, плюс множеством интерфейсов, представляемых пользователем, а все прочее считается "спецификой реализации" и должно быть скрыто от остальных частей системы. Таково может быть в действительности представление о компоненте у разработчика. Программист должен смириться с тем фактом, что С++ не дает общего понятия пространства имен компонента, так что его приходится "моделировать" с помощью понятий классов и единиц трансляции, т.е. тех средств, которые есть в С++ для ограничения области действия нелокальных имен.
Рассмотрим два класса, которые должны совместно использовать функцию f() и переменную v. Проще всего описать f и v как глобальные имена. Однако, всякий опытный программист знает, что такое "засорение" пространства имен может привести в конце концов к неприятностям: кто-то может ненарочно использовать имена f или v не по назначению или нарочно обратиться к f или v, прямо используя "специфику реализации" и обойдя тем самым явный интерфейс компонента. Здесь возможны три решения:
Дать "необычные" имена объектам и функциям, которые не рассчитаны на пользователя.Объекты или функции, не предназначенные для пользователя, описать в одном из файлов программы как статические (static).Поместить объекты и функции, не предназначенные для пользователя, в класс, определение которого закрыто для пользователей.
Первое решение примитивно и достаточно неудобно для создателя программы, но оно действует:
// не используйте специфику реализации compX, // если только вы не разработчик compX: extern void compX_f(T2*, const char*); extern T3 compX_v; // ...
Такие имена как compX_f и compX_v вряд ли могут привести к коллизии, а на тот довод, что пользователь может быть злоумышленником и использовать эти имена прямо, можно ответить, что пользователь в любом случае может оказаться злоумышленником, и что языковые механизмы защиты предохраняют от несчастного случая, а не от злого умысла. Преимущество этого решения в том, что оно применимо всегда и хорошо известно. В то же время оно некрасиво, ненадежно и усложняет ввод текста.
Второе решение более надежно, но менее универсально:
// специфика реализации compX: static void compX_f(T2* a1, const char *a2) { /* ... */ } static T3 compX_v; // ...
Трудно гарантировать, что информация, используемая в классах одного компонента, будет доступна только в одной единице трансляции, поскольку операции, работающие с этой информацией, должны быть доступны везде. Это решение может к тому же привести к громадным единицам трансляции, а в некоторых отладчиках для С++ не организован доступ к именам статических функций и переменных. В то же время это решение надежно и часто оптимально для небольших компонентов.
Третье решение можно рассматривать как формализацию и обобщение первых двух:
class compX_details { // специфика реализации compX public: static void f(T2*, const char*); static T3 v; // ... };
Описание compX_details будет использовать только создатель класса, остальные не должны включать его в свои программы.
В компоненте конечно может быть много классов, не предназначенных для общего пользования. Если их имена тоже рассчитаны только на локальное использование, то их также можно "спрятать" внутри классов, содержащих специфику реализации:
class compX_details { // специфика реализации compX. public: // ... class widget { // ... }; // ... };
Укажем, что вложенность создает барьер для использования widget в других частях программы. Обычно классы, представляющие ясные понятия, считаются первыми кандидатами на повторное использование, и, значит составляют часть интерфейса компонента, а не деталь реализации. Другими словами, хотя для сохранения надлежащего уровня абстракции вложенные объекты, используемые для представления некоторого объекта класса, лучше считать скрытыми деталями реализации, классы, определяющие такие вложенные объекты, лучше не делать скрытыми, если они имеют достаточную общность. Так, в следующем примере упрятывание, пожалуй, излишне:
class Car { class Wheel { // ... }; Wheel flw, frw, rlw, rrw; // ... };
Во многих ситуациях для поддержания уровня абстракции понятия машины (Car) следует упрятывать реальные колеса (класс Wheel), ведь когда вы работаете с машиной, вы не можете независимо от нее использовать колеса. С другой стороны, сам класс Wheel является вполне подходящим для широкого использования, поэтому лучше вынести его определение из класса Car:
class Wheel { // ... }; class Car { Wheel flw, frw, rlw, rrw; // ... };
Использовать ли вложенность? Ответ на этот вопрос зависит от целей проекта и общности используемых понятий. Как вложенность, так и ее отсутствие могут быть вполне допустимыми решениями для данного проекта. Но поскольку вложенность предохраняет от засорения общего пространства имен, в своде правил ниже рекомендуется использовать вложенность, если только нет причин не делать этого.
Отметим, что заголовочные файлы дают мощное средство для различных представлений компонент разным пользователям, и они же позволяют удалять из представления компонента для пользователя те классы, которые связаны со спецификой реализации.
Другим средством построения компонента и представления его пользователю служит иерархия. Тогда базовый класс используется как хранилище общих данных и функций. Таким способом устраняется проблема, связанная с глобальными данными и функциями, предназначенными для реализации общих запросов классов данного компонента.
С другой стороны, при таком решении классы компонента становятся слишком связанными друг с другом, а пользователь попадает в зависимость от всех базовых классов тех компонентов, которые ему действительно нужны. Здесь также проявляется тенденция к тому, что члены, представляющие "полезные" функции и данные "всплывают" к базовому классу, так что при слишком большой иерархии классов проблемы с глобальными данными и функциями проявятся уже в рамках этой иерархии. Вероятнее всего, это произойдет для иерархии с одним корнем, а для борьбы с этим явлением можно применять виртуальные базовые классы (§6.5.4). Иногда лучше выбрать иерархию для представления компонента, а иногда нет. Как всегда сделать выбор предстоит разработчику.
Отношения использования
Для составления и понимания проекта часто необходимо знать, какие классы и каким способом использует данный класс. Такие отношения классов на С++ выражаются неявно. Класс может использовать только те имена, которые где-то определены, но нет такой части в программе на С++, которая содержала бы список всех используемых имен. Для получения такого списка необходимы вспомогательные средства (или, при их отсутствии, внимательное чтение). Можно следующим образом классифицировать те способы, с помощью которых класс X может использовать класс Y:
X использует имя YX использует Y X вызывает функцию-член YX читает член YX пишет в член Y X создает Y X размещает auto или static переменную из YX создает Y с помощью newX использует размер Y
Мы отнесли использование размера объекта к его созданию, поскольку для этого требуется знание полного определения класса. С другой стороны, мы выделили в отдельное отношение использование имени Y, поскольку, указывая его в описании Y* или в описании внешней функции, мы вовсе не нуждаемся в доступе к определению Y:
class Y; // Y - имя класса Y* p; extern Y f(const Y&);
Мы отделили создание Y с помощью new от случая описания переменной, поскольку возможна такая реализация С++, при которой для создания Y с помощью new необязательно знать размер Y. Это может быть существенно для ограничения всех зависимостей в проекте и сведения к минимуму перетрансляции после внесения изменений.
Язык С++ не требует, чтобы создатель классов точно определял, какие классы и как он будет использовать. Одна из причин этого заключена в том, что самые важные классы зависят от столь большого количества других классов, что для придания лучшего вида программе нужна сокращенная форма записи списка используемых классов, например, с помощью команды #include. Другая причина в том, что классификация этих зависимостей и, в частности, обЪединение некоторых зависимостей не является обязанностью языка программирования. Наоборот, цели разработчика, программиста или вспомогательного средства определяют то, как именно следует рассматривать отношения использования. Наконец, то, какие зависимости представляют больший интерес, может зависеть от специфики реализации языка.
Отношения принадлежности
Если используется отношение принадлежности, то существует два основных способа представления объекта класса X:
Описать член типа X.Описать член типа X* или X&.
Если значение указателя не будет меняться и вопросы эффективности не волнуют, эти способы эквивалентны:
class X { //... public: X(int); //... };
class C { X a; X* p; public: C(int i, int j) : a(i), p(new X(j)) { } ~C() { delete p; } };
В таких ситуациях предпочтительнее непосредственное членство объекта, как X::a в примере выше, потому что оно дает экономию времени, памяти и количества вводимых символов. Обратитесь также к §12.4 и §13.9.
Способ, использующий указатель, следует применять в тех случаях, когда приходится перестраивать указатель на "объект-элемент" в течении жизни "объекта-владельца". Например:
class C2 { X* p; public: C(int i) : p(new X(i)) { } ~C() { delete p; }
X* change(X* q) { X* t = p; p = q; return t; } };
Член типа указатель может также использоваться, чтобы дать возможность передавать "объект-элемент" в качестве параметра:
class C3 { X* p; public: C(X* q) : p(q) { } // ... }
Разрешая объектам содержать указатели на другие объекты, мы создаем то, что обычно называется "иерархия объектов". Это альтернативный и вспомогательный способ структурирования по отношению к иерархии классов. Как было показано на примере аварийного движущегося средства в §12.2.2, часто это довольно тонкий вопрос проектирования: представлять ли свойство класса как еще один базовый класс или как член класса. Потребность в переопределении следует считать указанием, что первый вариант лучше. Но если надо иметь возможность представлять некоторое свойство с помощью различных типов, то лучше остановиться на втором варианте. Например:
class XX : public X { /*...*/ };
class XXX : public X { /*...*/ };
void f() { C3* p1 = new C3(new X); // C3 "содержит" X C3* p2 = new C3(new XX); // C3 "содержит" XX C3* p3 = new C3(new XXX); // C3 "содержит" XXX //... }
Приведенные определения нельзя смоделировать ни с помощью производного класса C3 от X, ни с помощью C3, имеющего член типа X, поскольку необходимо указывать точный тип члена. Это важно для классов с виртуальными функциями, таких, например,как класс Shape (§1.1.2.5), и для класса абстрактного множества (§13.3).
Заметим, что ссылки можно применять для упрощения классов, использующих члены-указатели, если в течение жизни объекта-владельца ссылка настроена только на один объект, например:
class C4 { X& r; public: C(X& q) : r(q) { } // ... };
Отношения внутри класса
До сих пор мы обсуждали только классы, и хотя операции упоминались, если не считать обсуждения шагов процесса развития программного обеспечения (§11.3.3.2), то они были на втором плане, объекты же практически вообще не упоминались. Понять это просто: в С++ класс, а не функция или объект, является основным понятием организации системы.
Класс может скрывать в себе всякую специфику реализации, наравне с "грязными" приемами программирования, а иногда он вынужден это делать. В то же время объекты большинства классов сами образуют регулярную структуру и используются такими способами, что их достаточно просто описать. Объект класса может быть совокупностью других вложенных объектов (их часто называют членами), многие из которых, в свою очередь, являются указателями или ссылками на другие объекты. Поэтому отдельный объект можно рассматривать как корень дерева объектов, а все входящие в него объекты как "иерархию объектов", которая дополняет иерархию классов, рассмотренную в §12.2.4. Рассмотрим в качестве примера класс строк из §7.6:
class String { int sz; char* p; public: String(const char* q); ~String(); //... };
Объект типа String можно изобразить так:
Принадлежность и наследование
Учитывая сложность и важность отношений наследования, нет ничего удивительного в том, что часто их неправильно понимают и используют сверх меры. Если класс D описан как общий производный от класса B, то часто говорят, что D есть B:
class B { /* ... */ ; class D : public B /* ... */ }; // D сорта B
Иначе это можно сформулировать так: наследование - это отношение "есть", или, более точно для классов D и B, наследование
- это отношение D сорта B. В отличие от этого, если класс D содержит в качестве члена другой класс B, то говорят, что D "имеет" B:
class D { // D имеет B // ... public: B b; // ... };
Иными словами, принадлежность - это отношение "иметь" или для классов D и B просто: D содержит B.
Имея два класса B и D, как выбирать между наследованием и принадлежностью? Рассмотрим классы самолет и мотор.Новички обычно спрашивают: будет ли хорошим решением сделать класс самолет производным от класса мотор. Это плохое решение, поскольку самолет не "есть" мотор, самолет "имеет" мотор. Следует подойти к этому вопросу, рассмотрев, может ли самолет "иметь" два или больше моторов. Поскольку это представляется вполне возможным (даже если мы имеем дело с программой, в которой все самолеты будут с одним мотором), следует использовать принадлежность, а не наследование. Вопрос "Может ли он иметь два..?" оказывается удивительно полезным во многих сомнительных случаях. Как всегда, наше изложение затрагивает неуловимую сущность программирования. Если бы все классы было так же легко представить, как самолет и мотор, то было бы просто избежать и тривиальных ошибок типа той, когда самолет определяется как производное от класса мотор. Однако, такие ошибки достаточно часты, особенно у тех, кто считает наследование еще одним механизмом для сочетания конструкций языка программирования. Несмотря на удобство и лаконичность записи, которую предоставляет наследование, его надо использовать только для выражения тех отношений, которые четко определены в проекте. Рассмотрим определения:
class B { public: virtual void f(); void g(); };
class D1 { // D1 содержит B public: B b; void f(); // не переопределяет b.f() };
void h1(D1* pd) { B* pb = pd; // ошибка: невозможно преобразование D1* в B* pb = &pd->b; pb->q(); // вызов B::q pd->q(); // ошибка: D1 не имеет член q() pd->b.q(); pb->f(); // вызов B::f (здесь D1::f не переопределяет) pd->f(); // вызов D1::f }
Обратите внимание, что в этом примере нет неявного преобразования класса к одному из его элементов, и что класс, содержащий в качестве члена другой класс, не переопределяет виртуальные функции этого члена. Здесь явное отличие от примера, приведенного ниже:
class D2 : public B { // D2 есть B public: void f(); // переопределение B::f() };
void h2(D2* pd) { B* pb = pd; // нормально: D2* неявно преобразуется в B* pb->q(); // вызов B::q pd->q(); // вызов B::q pb->f(); // вызов виртуальной функции: обращение к D2::f pd->f(); // вызов D2::f }
Удобство записи, продемонстрированное в примере с классом D2, по сравнению с записью в примере с классом D1, является причиной, по которой таким наследованием злоупотребляют. Но следует помнить, что существует определенная плата за удобство записи в виде возросшей зависимости между B и D2 (см. §12.2.3). В частности, легко забыть о неявном преобразовании D2 в B. Если только такие преобразования не относятся к семантике ваших классов, следует избегать описания производного класса в общей части. Если класс представляет определенное понятие, а наследование используется как отношение "есть", то такие преобразования обычно как раз то, что нужно.
Однако, бывают такие ситуации, когда желательно иметь наследование, но нельзя допускать преобразования. Рассмотрим задание класса cfield (controled field - управляемое поле), который, помимо всего прочего, дает возможность контролировать на стадии выполнения доступ к другому классу field. На первый взгляд кажется совершенно правильным определить класс cfield как производный от класса field:
class cfield : public field { // ... };
Это выражает тот факт, что cfield, действительно, есть сорта field, упрощает запись функции, которая использует член части field класса cfield, и, что самое главное, позволяет в классе cfield переопределять виртуальные функции из field. Загвоздка здесь в том, что преобразование cfield* к field*, встречающееся в определении класса cfield, позволяет обойти любой контроль доступа к field:
void q(cfield* p) { *p = "asdf"; // обращение к field контролируется // функцией присваивания cfield: // p->cfield::operator=("asdf")
field* q = p; // неявное преобразование cfield* в field* *q = "asdf"; // приехали! контроль обойден }
Можно было бы определить класс cfield так, чтобы field был его членом, но тогда cfield не может переопределять виртуальные функции field. Лучшим решением здесь будет использование наследования со спецификацией private (частное наследование):
class cfield : private field { /* ... */ }
С позиции проектирования, если не учитывать (иногда важные) вопросы переопределения, частное наследование эквивалентно принадлежности. В этом случае применяется метод, при котором класс определяется в общей части как производный от абстрактного базового класса заданием его интерфейса, а также определяется с помощью частного наследования от конкретного класса, задающего реализацию (§13.3). Поскольку наследование, используемое как частное, является спецификой реализации, и оно не отражается в типе производного класса, то его иногда называют "наследованием по реализации", и оно является контрастом для наследования в общей части, когда наследуется интерфейс базового класса и допустимы неявные преобразования к базовому типу. Последнее наследование иногда называют определением подтипа или "интерфейсным наследованием".
Для дальнейшего обсуждения возможности выбора наследования или принадлежности рассмотрим, как представить в диалоговой графической системе свиток (область для прокручивания в ней информации), и как привязать свиток к окну на экране. Потребуются свитки двух видов: горизонтальные и вертикальные. Это можно представить с помощью двух типов horizontal_scrollbar и vertical_scrollbar или с помощью одного типа scrollbar, который имеет аргумент, определяющий, является расположение вертикальным или горизонтальным. Первое решение предполагает, что есть еще третий тип, задающий просто свиток - scrollbar, и этот тип является базовым классом для двух определенных свитков. Второе решение предполагает дополнительный аргумент у типа scrollbar и наличие значений, задающих вид свитка. Например, так:
enum orientation { horizontal, vertical };
Как только мы остановимся на одном из решений, определится объем изменений, которые придется внести в систему. Допустим, в этом примере нам потребуется ввести свитки третьего вида. Вначале предполагалось, что могут быть свитки только двух видов (ведь всякое окно имеет только два измерения), но в этом примере, как и во многих других, возможны расширения, которые возникают как вопросы перепроектирования. Например, может появиться желание использовать "управляющую кнопку" (типа мыши) вместо свитков двух видов. Такая кнопка задавала бы прокрутку в различных направлениях в зависимости от того, в какой части окна нажал ее пользователь. Нажатие в середине верхней строчки должно вызывать "прокручивание вверх", нажатие в середине левого столбца - "прокручивание влево", нажатие в левом верхнем углу - "прокручивание вверх и влево". Такая кнопка не является чем-то необычным, и ее можно рассматривать как уточнение понятия свитка, которое особенно подходит для тех областей приложения, которые связаны не с обычными текстами, а с более сложной информацией.
Для добавления управляющей кнопки к программе, использующей иерархию из трех свитков, требуется добавить еще один класс, но не нужно менять программу, работающую со старыми свитками:
свитокгоризонтальный_свитоквертикальный_свиток управляющая_кнопка
Это положительная сторона "иерархического решения".
Задание ориентации свитка в качестве параметра приводит к заданию полей типа в объектах свитка и использованию переключателей в теле функций-членов свитка. Иными словами, перед нами обычная дилемма: выразить данный аспект структуры системы с помощью определений или реализовать его в операторной части программы. Первое решение увеличивает объем статических проверок и объем информации, над которой могут работать разные вспомогательные средства. Второе решение откладывает проверки на стадию выполнения и разрешает менять тела отдельных функций, не изменяя общую структуру системы, какой она представляется с точки зрения статического контроля или вспомогательных средств. В большинстве случаев, предпочтительнее первое решение.
Положительной стороной решения с единым типом свитка является то, что легко передавать информацию о виде нужного нам свитка другой функции:
void helper(orientation oo) { //... p = new scrollbar(oo); //... }
void me() { helper(horizontal); }
Такой подход позволяет на стадии выполнения легко перенастроить свиток на другую ориентацию. Вряд ли это очень важно в примере со свитками, но это может оказаться существенным в похожих примерах. Суть в том, что всегда надо делать определенный выбор, а это часто непросто.
Теперь рассмотрим как привязать свиток к окну. Если считать window_with_scrollbar (окно_со_свитком) как нечто, что является window и scrollbar, мы получим подобное:
class window_with_scrollbar : public window, public scrollbar { // ... };
Это позволяет любому объекту типа window_with_scrollbar выступать и как window, и как scrollbar, но от нас требуется решение использовать только единственный тип scrollbar.
Если, с другой стороны, считать window_with_scrollbar объектом типа window, который имеет scrollbar, мы получим такое определение:
class window_with_scrollbar : public window { // ... scrollbar* sb; public: window_with_scrollbar(scrollbar* p, /* ... */) : window(/* ... */), sb(p) { // ... } // ... };
Здесь мы можем использовать решение со свитками трех типов. Передача самого свитка в качестве параметра позволяет окну (window) не запоминать тип его свитка. Если потребуется, чтобы объект типа window_with_scrollbar действовал как scrollbar, можно добавить операцию преобразования:
window_with_scrollbar :: operator scrollbar&() { return *sb; }
Проектирование и язык программирования
Стремись к простоте, максимальной простоте, но не сверх того.
А. Эйнштейн
Если бы мне надо было построить мост, то я серьезно подумал бы, из какого материала его строить, и проект моста сильно зависел бы от выбранного материала, а, следовательно, разумные проекты каменного моста отличаются от разумных проектов металлического моста или от разумных проектов деревянного моста и т.д. Не стоит рассчитывать на выбор подходящего для моста материала без определенных знаний о материалах и их использовании. Конечно, вам не надо быть специалистом плотником для проектирования деревянного моста, но вы должны знать основы конструирования из дерева, чтобы предпочесть его металлу в качестве материала для моста. Более того, хотя для проектирования деревянного моста вы и не должны быть специалистом плотником, вам необходимо достаточно детально знать свойства дерева и еще больше знать о плотниках.
Аналогично, при выборе языка программирования для определенного программного обеспечения надо знать несколько языков, а для успешного проектирования программы надо достаточно детально знать выбранный язык реализации, даже если вам лично не предстоит написать ни одной строчки программы. Хороший проектировщик моста ценит свойства используемых им материалов и применяет их для улучшения проекта. Аналогично, хороший разработчик программ использует сильные стороны языка реализации и, насколько возможно, стремится избежать такого его использования, которое вызовет трудности на стадии реализации.
Можно подумать, что так получается естественным образом, если в проектировании участвует только один разработчик или программист, однако даже в этом случае программист в силу недостатка опыта или из-за неоправданной приверженности к стилю программирования, рассчитанному на совершенно другие языки, может сбиться на неверное использование языка. Если разработчик существенно отличается от программиста, особенно если у них разная программистская культура, возможность появления в окончательной версии системы ошибок, неэффективных и неэлегантных решений почти наверняка превратится в неизбежность.
Итак, чем может помочь разработчику язык программирования? Он может предоставить такие языковые средства, которые позволят выразить прямо на языке программирования основные понятия проекта. Тогда облегчается реализация, проще поддерживать ее соответствие проекту, проще организовать общение между разработчиками и программистами, и появляется возможность создать более совершенные средства как для разработчиков, так и для программистов.
Например, многие методы проектирования уделяют значительное внимание зависимостям между различными частями программы (обычно с целью их уменьшения и гарантии того, что эти части будут понятны и хорошо определены). Язык, допускающий явное задание интерфейсов между частями программы, может помочь в этом вопросе разработчикам. Он может гарантировать, что действительно будут существовать только предполагаемые зависимости. Поскольку большинство зависимостей явно выражено в программе на таком языке, можно разработать средства, читающие программу и выдающие графы зависимостей. В этом случае разработчику и другим исполнителям легче уяснить структуру программы. Такие языки программирования как С++ помогают сократить разрыв между проектом и программой, а значит уменьшают возможность путаницы и недопониманий.
Базовое понятие С++ - это класс. Класс имеет определенный тип. Кроме того, класс является первичным средством упрятывания информации. Можно описывать программы в терминах пользовательских типов и иерархий этих типов. Как встроенные, так и пользовательские типы подчиняются правилам статического контроля типов. Виртуальные функции предоставляют, не нарушая правил статических типов, механизм связывания на этапе выполнения. Шаблоны типа позволяют создавать параметризованные типы. Особые ситуации позволяют сделать регулярной реакцию на ошибки. Все эти средства С++ можно использовать без дополнительных накладных расходов в сравнении с программой на С. Таковы главнейшие средства С++, которые должен представлять и учитывать разработчик. Кроме того, существенно повлиять на принятие решений на стадии проектирования может наличие доступных больших библиотек следующего назначения: для работы с матрицами, для связи с базами данных, для поддержки параллельного программирования, графические библиотеки и т.д.
Страх перед новизной, непригодный здесь опыт работы на других языках, в других системах или областях приложения, бедные средства проектирования - все это приводит к неоптимальному использованию С++. Следует отметить три момента, когда разработчику не удается извлечь выгоду из возможностей С++ и учесть ограничения языка:
Игнорирование классов и составление проекта таким образом, что программистам приходится ограничиваться только С.Игнорирование производных классов и виртуальных функций, использование только подмножества абстрактных данных.Игнорирование статического контроля типов и составление проекта таким образом, что программисты вынуждены применять динамические проверки типов.
Обычно указанные моменты возникают у разработчиков, связанных с:
C, или традиционной системой CASE или методами структурного проектирования;Адой или методами проектирования с помощью абстракции данных;языками, близкими Smalltalk или Lisp.
В каждом случае следует решить: неправильно выбран язык реализации (считая, что метод проектирования выбран верно), или разработчику не удалось приспособиться и оценить язык (считая, что язык реализации выбран верно).
Следует сказать, что нет ничего необычного или позорного в таком расхождении. Просто это расхождение, которое приведет к неоптимальному проекту, возложит дополнительную работу на программистов, а в случае, когда структура понятий проекта значительно беднее структуры языка С++, то и на самих разработчиков.
Отметим, что необязательно все программы должны структурироваться опираясь на понятия классов и (или) иерархий классов, и необязательно всякая программа должна использовать все средства, предоставляемые С++. Как раз наоборот, для успеха проекта необходимо, чтобы людям не навязывали использование языковых средств, с которыми они только познакомились. Цель последующего изложения не в том, чтобы навязать догматичное использование классов, иерархий и строго типизированных интерфейсов, а в том, чтобы показать возможности их использования всюду, где позволяет область приложения, ограничения С++ и опыт исполнителей. В §12.1.4 будут рассмотрены подходы к различному использованию С++ в проекте под заголовком "Проект-гибрид".
Программируемые отношения
Конкретный язык программирования не может прямо поддерживать любое понятие любого метода проектирования. Если язык программирования не способен прямо представить понятие проектирования, следует установить удобное отображение конструкций, используемых в проекте, на языковые конструкции. Например, метод проектирования может использовать понятие делегирования, означающее, что всякая операция, которая не определена для класса A, должна выполняться в нем с помощью указателя p на соответствующий член класса B, в котором она определена. На С++ нельзя выразить это прямо. Однако, реализация этого понятия настолько в духе С++, что легко представить программу реализации:
class A { B* p; //... void f(); void ff(); };
class B { //... void f(); void g(); void h(); };
Тот факт, что В делегирует A с помощью указателя A::p, выражается в следующей записи:
class A { B* p; // делегирование с помощью p //... void f(); void ff(); void g() { p->g(); } // делегирование q() void h() { p->h(); } // делегирование h() };
Для программиста совершенно очевидно, что здесь происходит, однако здесь явно нарушается принцип взаимнооднозначного соответствия. Такие "программируемые" отношения трудно выразить на языках программирования, и поэтому к ним трудно применять различные вспомогательные средства. Например, такое средство может не отличить "делегирование" от B к A с помощью A::p от любого другого использования B*.
Все-таки следует всюду, где это возможно, добиваться взаимнооднозначного соответствия между понятиями проекта и понятиями языка программирования. Оно дает определенную простоту и гарантирует, что проект адекватно отображается в программе, что упрощает работу программиста и вспомогательных средств.
Операции преобразований типа являются механизмом, с помощью которого можно представить в языке класс программируемых отношений, а именно: операция преобразования X::operator Y() гарантирует, что всюду, где допустимо использование Y, можно применять и X. Такое же отношение задает конструктор Y::Y(X). Отметим, что операция преобразования типа (как и конструктор) скорее создает новый объект, чем изменяет тип существующего объекта. Задать операцию преобразования к функции Y - означает просто потребовать неявного применения функции, возвращающей Y. Поскольку неявные применения операций преобразования типа и операций, определяемых конструкторами, могут привести к неприятностям, полезно проанализировать их в отдельности еще в проекте.
Важно убедиться, что граф применений операций преобразования типа не содержит циклов. Если они есть, возникает двусмысленная ситуация, при которой типы, участвующие в циклах, становятся несовместимыми в комбинации. Например:
class Big_int { //... friend Big_int operator+(Big_int,Big_int); //... operator Rational(); //... };
class Rational { //... friend Rational operator+(Rational,Rational); //... operator Big_int(); };
Типы Rational и Big_int не так гладко взаимодействуют, как можно было бы подумать:
void f(Rational r, Big_int i) { //... g(r+i); // ошибка, неоднозначность: // operator+(r,Rational(i)) или // operator+(Big_int(r),i) g(r,Rational(i)); // явное разрешение неопределенности g(Big_int(r),i); // еще одно }
Можно было бы избежать таких "взаимных" преобразований, сделав некоторые из них явными. Например, преобразование Big_int к типу Rational можно было бы задать явно с помощью функции make_Rational() вместо операции преобразования, тогда сложение в приведенном примере разрешалось бы как g(BIg_int(r),i). Если нельзя избежать "взаимных" операций преобразования типов, то нужно преодолевать возникающие столкновения или с помощью явных преобразований (как было показано), или с помощью определения нескольких различных версий бинарной операции (в нашем случае +).
Свод правил
В этой лекции мы коснулись многих тем, но, как правило, избегали давать настоятельные и конкретные рекомендации по рассматриваемым вопросам. Это отвечает моему убеждению, что нет "единственно верного решения". Принципы и приемы следует применять способом, наиболее подходящим для конкретной задачи. Здесь требуются вкус, опыт и разум. Тем не менее, можно предложить свод правил, которые разработчик может использовать в качестве ориентиров, пока не приобретет достаточно опыта, чтобы выработать лучшие. Этот свод правил приводится ниже.
Он может служить отправной точкой в процессе выработки основных направлений проекта конкретной задачи, или же он может использоваться организацией в качестве проверочного списка. Подчеркну еще раз, что эти правила не являются универсальными и не могут заменить собой размышления.
Нацеливайте пользователя на применение абстракции данных и объектно-ориентированного программирования. Постепенно переходите на новые методы, не спешите.Используйте возможности С++ и методы обЪектно-ориентированного программирования только по мере надобности.Добейтесь соответствия стиля проекта и программы.Концентрируйте внимание на проектировании компонента.Используйте классы для представления понятий. Используйте общее наследование для представления отношений "есть".Используйте принадлежность для представления отношений "имеет".Убедитесь, что отношения использования понятны, не образуют циклов, и что число их минимально.Активно ищите общность среди понятий области приложения и реализации, и возникающие в результате более общие понятия представляйте как базовые классы.Определяйте интерфейс так, чтобы открывать минимальное количество требуемой информации: Используйте, всюду где это можно, частные данные и функции-члены.Используйте описания public или protected, чтобы отличить запросы разработчика производных классов от запросов обычных пользователей. Сведите к минимуму зависимости одного интерфейса от других.Поддерживайте строгую типизацию интерфейсов.Задавайте интерфейсы в терминах типов из области приложения.
Дополнительные правила можно найти §11.5.
© 2003-2007 INTUIT.ru. Все права защищены. |
Зависимости в рамках иерархии классов
Естественно, производный класс зависит от своих базовых классов. Гораздо реже учитывают, что обратное также может быть справедливо.
Эту мысль можно выразить таким способом: "Сумасшествие наследуется, вы можете получить его от своих детей."
Если класс содержит виртуальную функцию, производные классы могут по своему усмотрению решать, реализовывать ли часть операций этой функции каждый раз, когда она переопределяется в производном классе. Если член базового класса сам вызывает одну из виртуальных функций производного класса, тогда реализация базового класса зависит от реализаций его производных классов. Точно так же, если класс использует защищенный член, его реализация будет зависеть от производных классов. Рассмотрим определения:
class B { //... protected: int a; public: virtual int f(); int g() { int x = f(); return x-a; } };
Каков результат работы g()? Ответ существенно зависит от определения f() в некотором производном классе. Ниже приводится вариант, при котором g() будет возвращать 1:
class D1 : public B { int f() { return a+1; } };
а при нижеследующем определении g() напечатает "Hello, World" и вернет 0:
class D1 : public { int f() { cout<<"Hello, World\n"; return a; } };
Этот пример демонстрирует один из важнейших моментов, связанных с виртуальными функциями. Хотя вы можете сказать, что это глупость, и программист никогда не напишет ничего подобного. Дело здесь в том, что виртуальная функция является частью интерфейса с базовым классом, и что этот класс будет, по всей видимости, использоваться без информации о его производных классах. Следовательно, можно так описать поведение объекта базового класса, чтобы в дальнейшем писать программы, ничего не зная о его производных классах.
Всякий класс, который переопределяет производную функцию, должен реализовать вариант этой функции. Например, виртуальная функция rotate() из класса Shape вращает геометрическую фигуру, а функции rotate() для производных классов, таких, как Circle и Triangle, должны вращать объекты соответствующих типов, иначе будет нарушено основное положение о классе Shape. Но о поведении класса B или его производных классов D1 и D2 не сформулировано никаких положений, поэтому приведенный пример и кажется неразумным. При построении класса главное внимание следует уделять описанию ожидаемых действий виртуальных функций.
Следует ли считать нормальной зависимость от неизвестных (возможно еще неопределенных) производных классов? Ответ, естественно, зависит от целей программиста. Если цель состоит в том, чтобы изолировать класс от всяких внешних влияний и, тем самым, доказать, что он ведет себя определенным образом, то лучше избегать виртуальных функций и защищенных членов. Если цель состоит в том, чтобы разработать структуру, в которую последующие программисты (или вы сами через неделю) смогут встраивать свои программы, то именно виртуальные функции и предлагают элегантный способ решения, а защищенные члены могут быть полезны при его реализации.
В качестве примера рассмотрим простой шаблон типа, определяющий буфер:
template<class T> class buffer { // ... void put(T); T get(); };
Если реакция на переполнение и обращение к пустому буферу, "запаяна" в сам класс, его применение будет ограничено. Но если функции put() и get() обращаются к виртуальным функциям overflow() и underflow() соответственно, то пользователь может, удовлетворяя своим нуждам, создать буфера различных типов:
template<class T> class buffer { //... virtual int overflow(T); virtual int underflow(); void put(T); // вызвать overflow(T), когда буфер полон T get(); // вызвать underflow(T), когда буфер пуст };
template<class T> class circular_buffer : public buffer<T> { //... int overflow(T); // перейти на начало буфера, если он полон int underflow(); };
template<class T> class expanding_buffer : public buffer<T> { //... int overflow(T); // увеличить размер буфера, если он полон int underflow(); };
Этот метод использовался в библиотеках потокового ввода-вывода (§10.5.3).