Книга: Объектно-ориентированный анализ и проектирование с примерами приложений на С++

3.1. Природа объекта

3.1. Природа объекта

Что является и что не является объектом?

Способностью к распознанию объектов физического мира человек обладает с самого раннего возраста. Ярко окрашенный мяч привлекает внимание младенца, но, если спрятать мяч, младенец, как правило, не пытается его искать: как только предмет покидает поле зрения, он перестает существовать для младенца. Только в возрасте около года у ребенка появляется представление о предмете: навык, который незаменим для распознавания. Покажите мяч годовалому ребенку и спрячьте его: скорее всего, ребенок начнет искать спрятанный предмет. Ребенок связывает понятие предмета с постоянством и индивидуальностью формы независимо от действий, выполняемых над этим предметом [1].

В предыдущей главе объект был неформально определен как осязаемая реальность, проявляющая четко выделяемое поведение. С точки зрения восприятия человеком объектом может быть:

• осязаемый и (или) видимый предмет;

• нечто, воспринимаемое мышлением;

• нечто, на что направлена мысль или действие.

Таким образом, мы расширили неформальное определение объекта новой идеей: объект моделирует часть окружающей действительности и таким образом существует во времени и пространстве. Термин объект в программном обеспечении впервые был введен в языке Simula и применялся для моделирования реальности [2].

Объектами реального мира не исчерпываются типы объектов, интересные при проектировании программных систем. Другие важные типы объектов вводятся на этапе проектирования, и их взаимодействие друг с другом служит механизмом отображения поведения более высокого уровня [3]. Это приводит нас к более четкому определению, данному Смитом и Токи: "Объект представляет собой конкретный опознаваемый предмет, единицу или сущность (реальную или абстрактную), имеющую четко определенное функциональное назначение в данной предметной области" [4]. В еще более общем плане объект может быть определен как нечто, имеющее четко очерченные границы [5].

Представим себе завод, на котором создаются композитные материалы для таких различных изделий как, скажем, велосипедные рамы и крылья самолетов. Заводы часто разделяются на цеха: механический, химический, электрический и т.д. Цеха подразделяются на участки, на каждом из которых установлено несколько единиц оборудования: штампы, прессы, станки. На производственных линиях можно увидеть множество емкостей с исходными материалами, из которых с помощью химических процессов создаются блоки композитных материалов. Затем из них делается конечный продукт - рамы или крылья. Каждый осязаемый предмет может рассматриваться как объект. Токарный станок имеет четко очерченные границы, которые отделяют его от обрабатываемого на этом станке композитного блока; рама велосипеда в свою очередь имеет четкие границы по отношению к участку с оборудованием.

Существуют такие объекты, для которых определены явные концептуальные границы, но сами объекты представляют собой неосязаемые события или процессы. Например, химический процесс на заводе можно трактовать как объект, так как он имеет четкую концептуальную границу, взаимодействует с другими объектами посредством упорядоченного и распределенного во времени набора операций и проявляет хорошо определенное поведение. Рассмотрим систему пространственного проектирования CAD/CAM. Два тела, например, сфера и куб, имеют как правило нерегулярное пересечение. Хотя эта линия пересечения не существует отдельно от сферы и куба, она все же является самостоятельным объектом с четко определенными концептуальными границами.

Объекты могут быть осязаемыми, но иметь размытые физические границы: реки, туман или толпы людей [Это верно только на достаточно высоком уровне абстракции. Для человека, идущего через полосу тумана, бессмысленно отличать "мой туман" от "твоего тумана". Однако, рассмотрим карту погоды: полосы тумана в Сан-Франциско и в Лондоне представляют собой совершенно разные объекты]. Подобно тому, как взявший в руки молоток начинает видеть во всем окружающем только гвозди, проектировщик с объектно-ориентированным мышлением начинает воспринимать весь мир в виде объектов. Разумеется, такой взгляд несколько упрощен, так как существуют понятия, явно не являющиеся объектами. К их числу относятся атрибуты, такие, как время, красота, цвет, эмоции (например, любовь или гнев). Однако, потенциально все перечисленное - это свойства, присущие объектам. Можно, например, утверждать, что некоторый человек (объект) любит свою жену (другой объект), или что конкретный кот (еще один объект) - серый.  


Объект имеет состояние, обладает некоторым хорошо определенным поведением и уникальной идентичностью.

Полезно понимать, что объект - это нечто, имеющее четко определенные границы, но этого недостаточно, чтобы отделить один объект от другого или дать оценку качества абстракции. На основе имеющегося опыта можно дать следующее определение:

Объект обладает состоянием, поведением и идентичностью; структура и поведение схожих объектов определяет общий для них класс; термины "экземпляр класса" и "объект" взаимозаменяемы.

Состояние

Семантика. Рассмотрим торговый автомат, продающий напитки. Поведение такого объекта состоит в том, что после опускания в него монеты и нажатия кнопки автомат выдает выбранный напиток. Что произойдет, если сначала будет нажата кнопка выбора напитка, а потом уже опущена монета? Большинство автоматов при этом просто ничего не сделают, так как пользователь нарушил их основные правила.

Другими словами, автомат играл роль (ожидание монеты), которую пользователь игнорировал, нажав сначала кнопку. Или предположим, что пользователь автомата не обратил внимание на предупреждающий сигнал "Бросьте столько мелочи, сколько стоит напиток" и опустил в автомат лишнюю монету. В большинстве случаев автоматы не дружественны к пользователю и радостно заглатывают все деньги.

В каждой из таких ситуаций мы видим, что поведение объекта определяется его историей: важна последовательность совершаемых над объектом действий. Такая зависимость поведения от событий и от времени объясняется тем, что у объекта есть внутреннее состояние. Для торгового автомата, например, состояние определяется суммой денег, опущенных до нажатия кнопки выбора. Другая важная информация - это набор воспринимаемых монет и запас напитков.

На основе этого примера дадим следующее низкоуровневое определение:

Состояние объекта характеризуется перечнем (обычно статическим) всех свойств данного объекта и текущими (обычно динамическими) значениями каждого из этих свойств.

Одним из свойств торгового автомата является способность принимать монеты. Это статическое (фиксированное) свойство, в том смысле, что оно - существенная характеристика торгового автомата. С другой стороны, этому свойству соответствует динамическое значение, характеризующее количество принятых монет. Сумма увеличивается по мере опускания монет в автомат и уменьшается, когда продавец забирает деньги из автомата. В некоторых случаях значения свойств объекта могут быть статическими (например, заводской номер автомата), поэтому в данном определении использован термин "обычно динамическими".

К числу свойств объекта относятся присущие ему или приобретаемые им характеристики, черты, качества или способности, делающие данный объект самим собой. Например, для лифта характерным является то, что он сконструирован для поездок вверх и вниз, а не горизонтально. Перечень свойств объекта является, как правило, статическим, поскольку эти свойства составляют неизменяемую основу объекта. Мы говорим "как правило", потому что в ряде случаев состав свойств объекта может изменяться. Примером может служить робот с возможностью самообучения. Робот первоначально может рассматривать некоторое препятствие как статическое, а затем обнаруживает, что это дверь, которую можно открыть. В такой ситуации по мере получения новых знаний изменяется создаваемая роботом концептуальная модель мира.

Все свойства имеют некоторые значения. Эти значения могут быть простыми количественными характеристиками, а могут ссылаться на другой объект. Состояние лифта может описываться числом 3, означающим номер этажа, на котором лифт в данный момент находится. Состояние торгового автомата описывается в терминах других объектов, например, имеющихся в наличии напитков. Конкретные напитки - это самостоятельные объекты, отличные от торгового автомата (их можно пить, а автомат нет, и совершать с ними иные действия).

Таким образом, мы установили различие между объектами и простыми величинами: простые количественные характеристики (например, число 3) являются "постоянными, неизменными и непреходящими", тогда как объекты "существуют во времени, изменяются, имеют внутреннее состояние, преходящи и могут создаваться, уничтожаться и разделяться" [6].

Тот факт, что всякий объект имеет состояние, означает, что всякий объект занимает определенное пространство (физически или в памяти компьютера).

Примеры. Предположим, что на языке C++ нам нужно создать регистрационные записи о сотрудниках. Можно сделать это следующим образом:

struct PersonnelRecord {

char name[100]; int socialSecurityNumber; char department[10]; float salary;

};

Каждый компонент в приведенной структуре обозначает конкретное свойство нашей абстракции регистрационной записи. Описание определяет не объект, а класс, поскольку оно не вводит какой-либо конкретный экземпляр [Точнее, это описание определяет структуру в C++, семантика которой соответствует классу, у которого все поля открыты. Таким образом, структуры - это неинкапсулированные абстракции]. Для того чтобы создать объекты данного класса, необходимо написать следующее:

PersonnelRecord deb, dave, karen, jim, torn, denise, kaitlyn, krista, elyse;

В данном случае объявлено девять различных объектов, каждый из которых занимает определенный участок в памяти. Хотя свойства этих объектов являются общими (их состояние представляется единообразно), в памяти объекты не пересекаются и занимают каждый свое место. На практике принято ограничивать доступ к состоянию объекта, а не делать его общедоступным, как в предыдущем определении класса. С учетом сказанного, изменим данное определение следующим образом:

class PersonnelRecord { public:

char* employeeName() const; int employeeSocialSecurityNumber() const; char* employeeDepartment() const;

protected:

char name[100]; int socialSecurityNumber; char department[10]; float salary;

};

Новое определение несколько сложнее предыдущего, но по ряду соображений предпочтительнее [К вопросу о стилях: по критериям, которые вводятся в этой главе далее, предложенное определение класса PersonnelRecord - это далеко не шедевр. Мы хотим здесь только показать семантику состояния класса. Иметь в классе функцию, которая возвращает значение char*, часто опасно, так как это нарушает парадигму защиты памяти: если метод отводит себе память, за которую получивший к ней доступ клиент не отвечает, результатом будет замусоривание памяти. В наших системах мы предпочитаем использовать параметризованный класс строк переменной длины, который можно найти в базовой библиотеке классов, вроде той, что описана в главе 9. И еще: классы - это больше чем структуры из С с синтаксисом классов C++; как объясняется в главе 4, классификация требует определенного согласования структуры и поведения]. В частности, в новом определении реализация класса скрыта от других объектов. Если реализация класса будет в дальнейшем изменена, код придется перекомпилировать, но семантически клиенты не будут зависеть от этих изменении (то есть их код сохранится). Кроме того, решается также проблема занимаемой объектом памяти за счет явного определения операций, которые разрешены клиентам над объектами данного класса. В частности, мы даем всем клиентам право узнать имя, код социальной защиты и место работы сотрудника, но только особым клиентам (а именно, подклассам данного класса) разрешено устанавливать значения указанных параметров. Только этим специальным клиентам разрешен доступ к сведениям о заработной плате. Другое достоинство последнего определения связано с возможностью его повторного использования. В следующем разделе мы увидим, что механизм наследования позволяет повторно использовать абстракцию, а затем уточнить и многими способами специализировать ее.

В заключение скажем, что все объекты в системе инкапсулируют некоторое состояние, и все состояние системы инкапсулировано в объекты. Однако, инкапсуляция состояния объекта - это только начало, которого недостаточно, чтобы мы могли охватить полный смысл абстракций, которые мы вводим при разработке. По этой причине нам нужно разобраться, как объекты функционируют.

Поведение

Что такое поведение. Объекты не существуют изолированно, а подвергаются воздействию или сами воздействуют на другие объекты.

Поведение - это то, как объект действует и реагирует; поведение выражается в терминах состояния объекта и передачи сообщений.

Иными словами, поведение объекта - это его наблюдаемая и проверяемая извне деятельность.

Операцией называется определенное воздействие одного объекта на другой с целью вызвать соответствующую реакцию. Например, клиент может активизировать операции append и pop для того, чтобы управлять объектом-очередью (добавить или изъять элемент). Существует также операция length, которая позволяет определить размер очереди, но не может изменить это значение. В чисто объектно-ориентированном языке, таком как Smalltalk, принято говорить о передаче сообщений между объектами. В языках типа C++, в которых четче ощущается процедурное прошлое, мы говорим, что один объект вызывает функцию-член другого. В основном понятие сообщение совпадает с понятием операции над объектами, хотя механизм передачи различен. Для наших целей эти два термина могут использоваться как синонимы.

В объектно-ориентированных языках операции, выполняемые над данным объектом, называются методами и входят в определение класса объекта. В C++ они называются функциями-членами. Мы будем использовать эти термины как синонимы.

Передача сообщений - это одна часть уравнения, задающего поведение. Из нашего определения следует, что состояние объекта также влияет на его поведение. Рассмотрим торговый автомат. Мы можем сделать выбор, но поведение автомата будет зависеть от его состояния. Если мы не опустили в него достаточную сумму, скорее всего ничего не произойдет. Если же денег достаточно, автомат выдаст нам желаемое (и тем самым изменит свое состояние). Итак, поведение объекта определяется выполняемыми над ним операциями и его состоянием, причем некоторые операции имеют побочное действие: они изменяют состояние. Концепция побочного действия позволяет уточнить наше определение состояния:

Состояние объекта представляет суммарный результат его поведения.

Наиболее интересны те объекты, состояние которых не статично: их состояние изменяется и запрашивается операциями.

Примеры. Опишем на языке C++ класс Queue (очередь):

class Queue { public:

Queue(); Queue(const Queue&); virtual ~Queue(); virtual Queue& operator=(const Queue&); virtual int operator==(const Queue&) const; int operator!=(const Queue&) const; virtual void clear(); virtual void append(const void*); virtual void pop(); virtual void remove(int at); virtual int length() const; virtual int isEmpty() const; virtual const void* front() const; virtual int location(const void*);

protected: ... };

В определении класса используется обычная для С идиома ссылки на данные неопределенного типа с помощью void*, благодаря чему в очередь можно вставлять объекты разных классов. Эта техника не безопасна - клиент должен ясно понимать, с каким (какого класса) объектом он имеет дело. Кроме того, при использовании void* очередь не "владеет" объектами, которые в нее помещены. Деструктор ~Queue() уничтожает очередь, но не ее участников. В следующем разделе мы рассмотрим параметризованные типы, которые помогают справляться с такими проблемами.

Так как определение Queue задает класс, а не объект, мы должны объявить экземпляры класса, с которыми могут работать клиенты:

Queue a, b, c, d;

Мы можем выполнять операции над объектами:

a.append(&deb); a.append(&karen); a.append (&denise); b = a; a.pop();

Теперь очередь а содержит двух сотрудников (первой стоит karen), а очередь b - троих (первой стоит deb). Таким образом, очереди имеют определенное состояние, которое влияет на их будущее поведение - например, одну очередь можно безопасно продвинуть (pop) еще два раза, а вторую - три.

Операции. Операция - это услуга, которую класс может предоставить своим клиентам. На практике типичный клиент совершает над объектами операции пяти видов [Липпман предложил несколько иную классификацию: функции управления, функции реализации, вспомогательные функции (все виды модификаторов) и функции доступа (эквивалентные селекторам) [7]]. Ниже приведены три наиболее распространенные операции:  

? Модификатор   Операция, которая изменяет состояние объекта 

? Селектор   Операция, считывающая состояние объекта, но не меняющая состояния 

? Итератор   Операция, позволяющая организовать доступ ко всем частям объекта в строго определенной последовательности 

  Поскольку логика этих операций весьма различна, полезно выбрать такой стиль программирования, который учитывает эти различия в коде программы. В нашей спецификации класса Queue мы вначале перечислили все модификаторы (функции-члены без спецификаторов const - clear, append, pop, remove), а потом все селекторы (функции со спецификаторами const - length, isEmpty, front и location). Позднее в главе 9, следуя нашему стилю, мы определим отдельный класс, который действует как агент, отвечающий за итеративный просмотр очередей.

Две операции являются универсальными; они обеспечивают инфраструктуру, необходимую для создания и уничтожения экземпляров класса:  

? Конструктор   Операция создания объекта и/или его инициализации 

 ? Деструктор   Операция, освобождающая состояние объекта и/или разрушающая сам объект 

  В языке C++ конструктор и деструктор составляют часть описания класса, тогда как в Smalltalk и CLOS эти операторы определены в протоколе метакласса (то есть класса класса).

В чисто объектно-ориентированных языках, таких как Smalltalk, операции могут быть только методами, так как процедуры и функции вне классов в этом языке определять не допускается. Напротив, в языках Object Pascal, C++, CLOS и Ada допускается описывать операции как независимые от объектов подпрограммы. В C++ они называются функциями-нечленами; мы же будем здесь называть их свободными подпрограммами. Свободные подпрограммы - это процедуры и функции, которые выполняют роль операций высокого уровня над объектом или объектами одного или разных классов. Свободные процедуры группируются в соответствии с классами, для которых они создаются. Это дает основание называть такие пакеты процедур утилитами класса. Например, для определенного выше класса Queue можно написать следующую свободную процедуру:

void copyUntilFound(Queue& from, Queue& to, void* item) {

while ((!from.isEmpty()) && (from.front() != item)) {

to.append(from.front()); from.pop();

}

}

Смысл в том, что содержимое одной очереди переходит в другую до тех пор, пока в голове первой очереди не окажется заданный объект. Это операция высокого уровня; она строится на операциях-примитивах класса Queue.

В C++ (и Smalltalk) принято собирать все логически связанные свободные подпрограммы и объявлять их частью некоторого класса, не имеющего состояния. Все такие функции будут статическими.

Таким образом, можно утверждать, что все методы - операции, но не все операции - методы: некоторые из них представляют собой свободные подпрограммы. Мы склонны использовать только методы, хотя, как будет показано в следующем разделе, иногда трудно удержаться от искушения, особенно если операция по своей природе выполняется над несколькими объектами разных классов и нет никаких причин объявить ее операцией именно одного класса, а не другого.

Роли и ответственности. Совокупность всех методов и свободных процедур, относящихся к конкретному объекту, образует протокол этого объекта. Протокол, таким образом, определяет поведение объекта, охватывающее все его статические и динамические аспекты. В самых нетривиальных абстракциях полезно подразделять протокол на частные аспекты поведения, которые мы будет называть ролями. Адамс говорит, что роль - это маска, которую носит объект [8]; она определяет контракт абстракции с ее клиентами.

Объединяя наши определения состояния и поведения объекта, Вирфс-Брок вводит понятие ответственности. "Ответственности объекта имеют две стороны - знания, которые объект поддерживает, и действия, которые объект может исполнить. Они выражают смысл его предназначения и место в системе. Ответственность понимается как совокупность всех услуг и всех контрактных обязательств объекта" [9]. Таким образом можно сказать, что состояние и поведение объекта определяют исполняемые им роли, а те, в свою очередь, необходимы для выполнения ответственности данной абстракции.

Действительно большинство интересных объектов исполняют в своей жизни разные роли, например [10]:

• Банковский счет может быть в хорошем или плохом состоянии (две роли), и от этой роли зависит, что произойдет при попытке снятия с него денег.

• Для фондового брокера пакет акций - это товар, который можно покупать или продавать, а для юриста это знак обладания определенными правами.

• В течении дня одна и та же персона может играть роль матери, врача, садовника и кинокритика.

Роли банковского счета являются динамическими и взаимоисключающими. Роли пакета акций слегка перекрываются, но каждая из них зависит от того, что клиент с ними делает. В случае персоны роли динамически изменяются каждую минуту.

Как мы увидим в главах 4 и 6, мы часто начинаем наш анализ с перечисления разных ролей, которые может играть объект. Во время проектирования мы выделяем эти роли, вводя конкретные операции, выполняющие ответственности каждой роли.

Объекты как автоматы. Наличие внутреннего состояния объектов означает, что порядок выполнения операций имеет существенное значение. Это наводит на мысль представить объект в качестве маленькой независимой машины [11]. Действительно, для ряда объектов такой временной порядок настолько существен, что наилучшим способом их формального описания будет конечный автомат. В главе 5 мы введем обозначения для описания иерархических конечных автоматов, которые можно использовать для выражения соответствующей семантики.

Продолжая аналогию с машинами, можно сказать, что объекты могут быть активными и пассивными. Активный объект имеет свой поток управления, а пассивный - нет. Активный объект в общем случае автономен, то есть он может проявлять свое поведение без воздействия со стороны других объектов. Пассивный объект, напротив, может изменять свое состояние только под воздействием других объектов. Таким образом, активные объекты системы - источники управляющих воздействий. Если система имеет несколько потоков управления, то и активных объектов может быть несколько. В последовательных системах обычно в каждый момент времени существует только один активный объект, например, главное окно, диспетчер которого ловит и обрабатывает все сообщения. В таком случае остальные объекты пассивны: их поведение проявляется, когда к ним обращается активный объект. В других видах последовательных архитектур (системы обработки транзакций) нет явного центра активности, и управление распределено среди пассивных объектов системы.

Идентичность

Семантика. Хошафян и Коуплэнд предложили следующее определение:

"Идентичность - это такое свойство объекта, которое отличает его от всех других объектов" [12].

Они отмечают, что "в большинстве языков программирования и управления базами данных для различения временных объектов их именуют, тем самым путая адресуемость и идентичность. Большинство баз данных различают постоянные объекты по ключевому атрибуту, тем самым смешивая идентичность и значение данных". Источником множества ошибок в объектно-ориентированном программировании является неумение отличать имя объекта от самого объекта.

Примеры. Начнем с определения точки на плоскости.

struct Point {

int x; int y; Point() : x(0), y(0) {} Point(int xValue, int yValue) : x(xValue), y(yValue) {)

};

Мы определили point как структуру, а не как полноценный класс. Правило, на основании которого мы так поступили, очень просто. Если абстракция представляет собой собрание других объектов без какого-либо собственного поведения, мы делаем ее структурой. Однако, когда наша абстракция подразумевает более сложное поведение, чем простой доступ к полям структуры, то нужно определять класс. В данном случае абстракция point - это просто пара координат (x, y). Для удобства предусмотрено два конструктора: один инициализирует точку нулевыми значениями координат, а другой - некоторыми заданными значениями.

Теперь определим экранный объект (DisplayItem). Это абстракция довольно обычна для систем с графическим интерфейсом (GUI) - она является базовым классом для всех объектов, которые можно отображать в окне. Мы хотим сделать его чем-то большим, чем просто совокупностью точек. Надо, чтобы клиенты могли рисовать, выбирать объекты и перемещать их по экрану, а также запрашивать их положение и состояние. Мы записываем нашу абстракцию в виде следующего объявления на C++:

class DisplayItem { public:

DisplayItem(); DisplayItem(const Point& location); virtual ~DisplayItem(); virtual void draw(); virtual void erase(); virtual void select(); virtual void unselect(); virtual void move(const Point& location); int isSelected() const; Point location() const; int isUnder(const Point& location) const;

protected: ... };

В этом объявлении мы намеренно опустили конструкторы, а также операторы для копирования, присваивания и проверки на равенство. Их мы оставим до следующего раздела.

Мы ожидаем, что у этого класса будет много наследников, поэтому деструктор и все модификаторы объявлены виртуальными. В особенности это относится к draw. Напротив, селекторы скорее всего не будут переопределяться в подклассах. Заметьте, что один из них, isUnder, должен вычислять, накрывает ли объект данную точку, а не просто возвращать значение какого-то свойства.

Объявим экземпляры указанных классов:

DisplayItem item1; DisplayItem* item2 = new DisplayItem(Point(75, 75)); DisplayItem* item3 = new DisplayItem(Point(100, 100)); DisplayItem* item4 = 0;

Рис. 3-1а показывает, что при выполнении этих операторов возникают четыре имени и три разных объекта. Конкретно, в памяти будут отведены четыре места под имена item1, item2, item3, item4. При этом item1 будет именем объекта класса DisplayItem, а три других будут указателями. Кроме того, лишь item2 и item3 будут на самом деле указывать на объекты класса DisplayItem. У объектов, на которые указывают item2 и item3, к тому же нет имен, хотя на них можно ссылаться "разыменовывая" соответствующие указатели: например, *item2. Поэтому мы можем сказать, что item2 указывает на отдельный объект класса DisplayItem, на имя которого мы можем косвенно ссылаться через *item2. Уникальная идентичность (но не обязательно имя) каждого объекта сохраняется на все время его существования, даже если его внутреннее состояние изменилось. Эта ситуация напоминает парадокс Зенона о реке: может ли река быть той же самый, если в ней каждый день течет разная вода?  


Рис. 3-1. Идентичность объектов.

Рассмотрим результат выполнения следующих операторов (рис. 3-1б):

item1.move(item2->location()); item4 = item3; item4->move(Point(38, 100));

Объект item1 и объект, на который указывает item2, теперь относятся к одной и той же точке экрана. Указатель item4 стал указывать на тот же объект, что и item3. Кстати, заметьте разницу между выражениями "объект item2" и "объект, на который указывает item2". Второе выражение более точно, хотя для краткости мы часто будем использовать их как синонимы.

Хотя объект item1 и объект, на который указывает item2, имеют одинаковое состояние, они остаются разными объектами. Кроме того, мы изменили состояние объекта *item3, использовав его новое косвенное имя item4. Эта ситуация, которую мы называем структурной зависимостью, подразумевая под этим ситуацию, когда объект именуется более чем одним способом несколькими синонимичными именами. Структурная зависимость порождает в объектно-ориентированном программировании много проблем. Трудность распознания побочных эффектов при действиях с синонимичными объектами часто приводит к "утечкам памяти", неправильному доступу к памяти, и, хуже того, непрогнозируемому изменению состояния. Например, если мы уничтожим объект через указатель item3, то значение указателя item4 окажется бессмысленным; эта ситуация называется повисшей ссылкой. На рис. 3-1в иллюстрируется результат выполнения следующих действий:

item2 = &item1; item4->move(item2->location());

В первой строке создается синоним: item2 указывает на тот же объект, что и item1. Во второй доступ к состоянию item1 получен через этот новый синоним. К сожалению, при этом произошла утечка памяти, - объект, на который первоначально указывала ссылка item2, никак не именуется ни прямо, ни косвенно, и его идентичность потеряна. В Smalltalk и CLOS память, отведенная под объекты, будет вновь возвращена системе сборщиком мусора. В языках типа C++ такая память не освобождается, пока не завершится программа, создавшая объект. Такие утечки памяти могут вызвать и просто неудобство, и крупные сбои, особенно, если программа должна непрерывно работать длительное время [Представьте себе утечку памяти в программе управления спутником или сердечным стимулятором. Перезапуск компьютера на спутнике в нескольких миллионах километров от Земли очень неудобен. Аналогично, непредсказуемая сборка мусора в программе, управляющей стимулятором, может оказаться смертельным для пациента. В таких случаях разработчики систем реального времени предпочитают воздерживаться от динамического распределения памяти].

Копирование, присваивание и равенство. Структурная зависимость имеет место, когда объект имеет несколько имен. В наиболее интересных приложениях объектно-ориентированного подхода использование синонимов просто неизбежно. Например, рассмотрим следующие две функции:

void highLight(DisplayItem& i); void drag(DisplayItem i); // Опасно

Если вызвать первую функцию с параметром item1, будет создан псевдоним: формальный параметр i означает указатель на фактический параметр, и следовательно item1 и i именуют один и тот же объект во время выполнения функции. При вызове второй функции с аргументом item1 ей передается новый объект, являющийся копией item1: i обозначает совершенно другой объект, хотя и с тем же состоянием, что и item1. В C++ различается передача параметров по ссылке и по значению. Надо следить за этим, иначе можно нечаянно изменить копию объекта, желая изменить сам объект [В Smalltalk семантика передачи объектов методам в качестве аргументов является по своему духу эквивалентом передачи параметра по ссылке в C++]. Как мы увидим в следующем разделе, передача объектов по ссылке в C++ необходима для программирования полиморфного поведения. В общем случае, передача объектов по ссылке крайне желательна для достаточно сложных объектов, поскольку при этом копируется ссылка, а не состояние, и следовательно, достигается большая эффективность (за исключением тех случаев, когда передаваемое значение очень простое).

В некоторых обстоятельствах, однако, подразумевается именно копирование. В языках типа C++ семантику копирования можно контролировать. В частности, мы можем ввести копирующий конструктор в определение класса, как в следующем фрагменте кода, который можно было бы включить в описание класса DisplayItem:

DisplayItem(const DisplayItem&);

В C++ копирующий конструктор может быть вызван явно (как часть описания объекта) или неявно (с передачей объекта по значению). Отсутствие этого специального конструктора вызывает копирующий конструктор, действующий по умолчанию, который копирует объект поэлементно. Однако, когда объект содержит ссылки или указатели на другие объекты, такая операция приводит к созданию синонимов указателей на объекты, что делает поэлементное копирование опасным. Мы предлагаем эмпирическое правило: разрешать неявное размножение путем копирования только для объектов, содержащих исключительно примитивные значения, и делать его явным для более сложных объектов.

Это правило поясняет то, что некоторые языки называют "поверхностным" и "глубоким" копированием. Чтобы копировать объект, в языке Smalltalk введены методы shallowCopy (метод копирует только объект, а состояние является разделяемым) и deepCopy (метод копирует объект и состояние, если нужно - рекурсивно). Переопределяя эти методы для классов с агрегацией, можно добиваться эффекта "глубокого" копирования для одних частей объекта, и "поверхностного" копирования остальных частей.

Присваивание - это, вообще говоря, копирование. В C++ его смысл тоже можно изменять. Например, мы могли бы добавить в определение класса DisplayItem следующую строку:

virtual DisplayItem& operator=(const DisplayItem&);

Этот оператор намеренно сделан виртуальным, так как ожидается, что подклассы будут его переопределять. Как и в случае копирующего конструктора, копирование можно сделать "глубоким" и "поверхностным". Если оператор присваивания не переопределен явно, то по умолчанию объект копируется поэлементно.

С вопросом присваивания тесно связан вопрос равенства. Хотя вопрос кажется простым, равенство можно понимать двумя способами. Во-первых, два имени могут обозначать один и тот же объект. Во-вторых, это может быть равенство состояний у двух разных объектов. В примере на рис. 3-1в оба варианта тождественности будут справедливы для item1 и item2. Однако для item2 и item3 истинным будет только второй вариант.

В C++ нет предопределенного оператора равенства, поэтому мы должны определить равенство и неравенство, объявив эти операторы при описании:

virtual int operator=(const DisplayItem&) const; int operator!=(const DisplayItem&) const;

Мы предлагаем описывать оператор равенства как виртуальный (так как ожидаем, что подклассы могут переопределять его поведение), и описывать оператор неравенства как невиртуальный (так как хотим, чтобы он всегда был логическим отрицанием равенства: подклассам не следует переопределять это).

Аналогичным образом мы можем создавать операторы сравнения объектов типа >= и <=.

Время жизни объектов. Началом времени существования любого объекта является момент его создания (отведение участка памяти), а окончанием - возвращение отведенного участка памяти системе.

Объекты создаются явно или неявно. Есть два способа создать их явно. Во-первых, это можно сделать при объявлении (как это было с item1): тогда объект размещается в стеке. Во-вторых, как в случае item3, можно разместить объект, то есть выделить ему память из "кучи". В C++ в любом случае при этом вызывается конструктор, который выделяет известное ему количество правильно инициализированной памяти под объект. В Smalltalk этим занимаются метаклассы, о семантике которых мы поговорим позже.

Часто объекты создаются неявно. Так, передача параметра по значению в C++ создает в стеке временную копию объекта. Более того, создание объектов транзитивно: создание объекта тянет за собой создание других объектов, входящих в него. Переопределение семантики копирующего конструктора и оператора присваивания в C++ разрешает явное управление тем, когда части объекта создаются и уничтожаются. К тому же в C++ можно переопределять и оператор new, тем самым изменяя политику управления памятью в "куче" для отдельных классов.

В Smalltalk и некоторых других языках при потере последней ссылки на объект его забирает сборщик мусора. В языках без сборки мусора, типа C++, объекты, созданные в стеке, уничтожаются при выходе из блока, в котором они были определены, но объекты, созданные в "куче" оператором new, продолжают существовать и занимать место в памяти: их необходимо явно уничтожать оператором delete. Если объект "забыть", не уничтожить, это вызовет, как уже было сказано выше, утечку памяти. Если же объект попробуют уничтожить повторно (например, через другой указатель), последствием будет сообщение о нарушении памяти или полный крах системы.

При явном или неявном уничтожении объекта в C++ вызывается соответствующий деструктор. Его задача не только освободить память, но и решить, что делать с другими ресурсами, например, с открытыми файлами [Деструкторы не освобождают автоматически память, размещенную оператором new, программисты должны явно освободить ее].

Уничтожение долгоживущих объектов имеет несколько другую семантику. Как говорилось в предыдущей главе, некоторые объекты могут быть долгоживущими; под этим понимается, что их время жизни может выходить за время жизни породивших их программ. Обычно такие объекты являются частью некой долговременной объектной структуры, поэтому вопросы их жизни и смерти относятся скорее к политике соответствующей объектно-ориентированной базы данных. В таких системах для обеспечения долгой жизни наиболее принят подход на основе постоянных "подмешиваемых классов". Все объекты, которым мы хотим обеспечить долгую жизнь, должны наследовать от этих классов.

Оглавление книги


Генерация: 1.134. Запросов К БД/Cache: 3 / 0
поделиться
Вверх Вниз